Monorepo for Aesthetic.Computer aesthetic.computer

fix: DJ speed control, captive portal ClearPass, TTS, @@ handle

- audio: speed control now in audio callback with linear interpolation
instead of broken resampler approach — scratching actually works
- audio-decode: allow negative speed (-4 to +4), init ring_frac,
decoder always decodes at 1x (speed applied at playback)
- wifi: add ClearPass cmd=authenticate strategy for Getty portals,
add POST with accept params, add -k flag for HTTPS portals
- dj.mjs: TTS was calling tts.speak() but API exposes sound.speak()
— replaced all tts refs with sound.speak via say() helper
- ac-os: strip @ prefix from handle to prevent @@jeffrey double prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+102 -44
+1 -1
fedac/native/ac-os
··· 736 736 exit 1 737 737 fi 738 738 # Read handle and email too 739 - local USER_HANDLE=$(node -e "const t=JSON.parse(require('fs').readFileSync('${TOKEN_FILE}','utf8')); console.log(t.user?.handle || t.user?.name || '')" 2>/dev/null) 739 + local USER_HANDLE=$(node -e "const t=JSON.parse(require('fs').readFileSync('${TOKEN_FILE}','utf8')); let h=t.user?.handle || t.user?.name || ''; if(h.startsWith('@'))h=h.slice(1); console.log(h)" 2>/dev/null) 740 740 local USER_EMAIL=$(node -e "const t=JSON.parse(require('fs').readFileSync('${TOKEN_FILE}','utf8')); console.log(t.user?.email || '')" 2>/dev/null) 741 741 export AC_USER_SUB="${USER_SUB}" 742 742 export AC_USER_HANDLE="${USER_HANDLE}"
+23 -21
fedac/native/pieces/dj.mjs
··· 42 42 } 43 43 } 44 44 45 - function scan(system, tts) { 45 + function say(sound, text) { sound?.speak?.(text); } 46 + 47 + function scan(system, sound) { 46 48 files = []; 47 49 const dirs = ["/media", "/mnt/samples", "/mnt"]; 48 50 for (const d of dirs) scanDir(system, d, files, 0); 49 51 files.sort((a, b) => a.name.localeCompare(b.name)); 50 - if (tts) tts.speak(files.length > 0 ? `${files.length} tracks` : "no tracks"); 52 + say(sound, files.length > 0 ? `${files.length} tracks` : "no tracks"); 51 53 msg(files.length > 0 ? `${files.length} tracks` : "no tracks found"); 52 54 } 53 55 ··· 58 60 return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; 59 61 } 60 62 61 - function loadTrack(sound, tts) { 63 + function loadTrack(sound) { 62 64 if (files.length === 0) return; 63 65 if (trackIdx >= files.length) trackIdx = 0; 64 66 const f = files[trackIdx]; 65 67 const ok = sound?.deck?.load(0, f.path); 66 68 if (ok) { 67 69 msg(f.name.replace(/\.[^.]+$/, "")); 68 - if (tts) tts.speak(f.name.replace(/\.[^.]+$/, "")); 70 + say(sound, f.name.replace(/\.[^.]+$/, "")); 69 71 sound.deck.play(0); 70 72 spinSpeed = 1; 71 73 } else { ··· 73 75 } 74 76 } 75 77 76 - function boot({ system, sound, tts }) { 78 + function boot({ system, sound }) { 77 79 mounted = system?.mountMusic?.() || false; 78 80 usbConnected = mounted; 79 - scan(system, null); 81 + scan(system, sound); 80 82 const d = sound?.deck?.decks?.[0]; 81 83 if (d?.loaded) { 82 84 msg("resumed"); 83 85 if (d.playing) spinSpeed = 1; 84 86 } else if (files.length > 0) { 85 - loadTrack(sound, tts); 87 + loadTrack(sound); 86 88 } 87 89 } 88 90 89 - function act({ event: e, sound, system, tts, screen }) { 91 + function act({ event: e, sound, system, screen }) { 90 92 const dk = sound?.deck; 91 93 const d = dk?.decks?.[0]; 92 94 const w = screen?.width || 320; ··· 101 103 if (d.playing) { dk.pause(0); spinSpeed = 0; msg("paused"); } 102 104 else { dk.play(0); spinSpeed = 1; msg("playing"); } 103 105 } 104 - } else if (b.id === "next") { trackIdx++; loadTrack(sound, tts); } 105 - else if (b.id === "prev") { trackIdx = Math.max(0, trackIdx - 1); loadTrack(sound, tts); } 106 + } else if (b.id === "next") { trackIdx++; loadTrack(sound); } 107 + else if (b.id === "prev") { trackIdx = Math.max(0, trackIdx - 1); loadTrack(sound); } 106 108 else if (b.id === "reset") { if (d?.loaded) { dk.setSpeed(0, 1); msg("1.00x"); } } 107 109 else if (b.id === "scan") { 108 110 mounted = system?.mountMusic?.() || false; 109 - scan(system, tts); 110 - if (files.length > 0) { trackIdx = 0; loadTrack(sound, tts); } 111 + scan(system, sound); 112 + if (files.length > 0) { trackIdx = 0; loadTrack(sound); } 111 113 } 112 114 return; 113 115 } ··· 179 181 // N: next track 180 182 if (e.is("keyboard:down:n")) { 181 183 trackIdx++; 182 - loadTrack(sound, tts); 184 + loadTrack(sound); 183 185 return; 184 186 } 185 187 // P: prev track 186 188 if (e.is("keyboard:down:p")) { 187 189 trackIdx = Math.max(0, trackIdx - 1); 188 - loadTrack(sound, tts); 190 + loadTrack(sound); 189 191 return; 190 192 } 191 193 192 194 // R: rescan 193 195 if (e.is("keyboard:down:r")) { 194 196 mounted = system?.mountMusic?.() || false; 195 - scan(system, tts); 196 - if (files.length > 0) { trackIdx = 0; loadTrack(sound, tts); } 197 + scan(system, sound); 198 + if (files.length > 0) { trackIdx = 0; loadTrack(sound); } 197 199 return; 198 200 } 199 201 ··· 375 377 } 376 378 } 377 379 378 - function sim({ system, tts, sound }) { 380 + function sim({ system, sound }) { 379 381 // USB hot-plug check every 3 seconds 380 382 if (frame - lastUsbCheck > 180) { 381 383 lastUsbCheck = frame; 382 384 const nowMounted = system?.mountMusic?.() || false; 383 385 if (nowMounted && !usbConnected) { 384 386 usbConnected = true; mounted = true; 385 - if (tts) tts.speak("USB DJ on"); 387 + say(sound, "USB DJ on"); 386 388 scan(system, null); 387 - if (files.length > 0) { trackIdx = 0; loadTrack(sound, tts); } 389 + if (files.length > 0) { trackIdx = 0; loadTrack(sound); } 388 390 } else if (!nowMounted && usbConnected) { 389 391 usbConnected = false; 390 392 const dk = sound?.deck; 391 393 const d = dk?.decks?.[0]; 392 394 if (d?.playing) { dk.pause(0); spinSpeed = 0; } 393 395 files = []; 394 - if (tts) tts.speak("USB DJ off"); 396 + say(sound, "USB DJ off"); 395 397 msg("USB removed"); 396 398 } 397 399 } ··· 399 401 const d = sound?.deck?.decks?.[0]; 400 402 if (d?.loaded && !d.playing && d.position >= d.duration - 0.1 && d.duration > 0 && !dragging) { 401 403 trackIdx++; 402 - loadTrack(sound, tts); 404 + loadTrack(sound); 403 405 } 404 406 } 405 407
+5 -10
fedac/native/src/audio-decode.c
··· 72 72 // Reset ring buffer 73 73 d->ring_write = 0; 74 74 d->ring_read = 0; 75 + d->ring_frac = 0.0; 75 76 d->position = d->seek_target; 76 77 d->finished = 0; 77 78 d->seek_requested = 0; ··· 124 125 if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; 125 126 if (ret < 0) { d->error = 1; break; } 126 127 127 - // Calculate output samples accounting for speed 128 - double effective_speed = d->speed; 129 - if (effective_speed < 0.25) effective_speed = 0.25; 130 - if (effective_speed > 4.0) effective_speed = 4.0; 131 - 132 - // Resample: source rate -> output rate / speed 133 - // Playing at 2x speed means we produce half as many output samples 134 - // per source frame, which the audio thread plays at normal rate = 2x speed 128 + // Resample: source rate -> output rate (always 1x, speed applied at playback) 135 129 int out_samples = av_rescale_rnd( 136 130 swr_get_delay(swr, d->src_sample_rate) + frame->nb_samples, 137 - (int64_t)(d->out_rate / effective_speed), 131 + d->out_rate, 138 132 d->src_sample_rate, 139 133 AV_ROUND_UP 140 134 ); ··· 189 183 190 184 d->out_rate = output_rate; 191 185 d->speed = 1.0; 186 + d->ring_frac = 0.0; 192 187 d->ring_size = output_rate * DECK_RING_SECONDS; 193 188 d->ring = calloc(d->ring_size * 2, sizeof(float)); // stereo interleaved 194 189 if (!d->ring) { ··· 366 361 367 362 void deck_decoder_set_speed(ACDeckDecoder *d, double speed) { 368 363 if (!d) return; 369 - if (speed < 0.25) speed = 0.25; 364 + if (speed < -4.0) speed = -4.0; 370 365 if (speed > 4.0) speed = 4.0; 371 366 d->speed = speed; 372 367 }
+2 -1
fedac/native/src/audio-decode.h
··· 32 32 volatile double seek_target; // seconds 33 33 34 34 // Speed control (pitch-coupled, like vinyl) 35 - volatile double speed; // 1.0 = normal, 0.5-2.0 range 35 + volatile double speed; // 1.0 = normal, negative = reverse 36 + double ring_frac; // fractional read position for interpolated speed 36 37 37 38 // Track metadata 38 39 double duration; // total duration in seconds
+32 -5
fedac/native/src/audio.c
··· 371 371 // (lock released at end of buffer loop) 372 372 373 373 // Mix DJ deck audio (lock-free: single consumer = audio thread) 374 + // Speed control: advance ring read by `speed` samples per output sample 375 + // with linear interpolation for smooth pitch shifting / scratching. 374 376 for (int d = 0; d < AUDIO_MAX_DECKS; d++) { 375 377 ACDeck *dk = &audio->decks[d]; 376 378 if (!dk->active || !dk->playing || !dk->decoder) continue; 377 379 ACDeckDecoder *dec = dk->decoder; 378 - if (dec->ring_read >= dec->ring_write) continue; 379 - int ri = (dec->ring_read % dec->ring_size) * 2; 380 - float sl = dec->ring[ri]; 381 - float sr = dec->ring[ri + 1]; 382 - dec->ring_read++; 380 + double spd = dec->speed; 381 + if (spd < -4.0) spd = -4.0; 382 + if (spd > 4.0) spd = 4.0; 383 + int64_t avail = dec->ring_write - dec->ring_read; 384 + if (avail <= 1) continue; 385 + // Fractional ring position for interpolation 386 + double frac_pos = dec->ring_frac; 387 + int64_t base = dec->ring_read; 388 + int64_t idx0 = base + (int64_t)frac_pos; 389 + if (idx0 < base || idx0 + 1 >= dec->ring_write) { 390 + // Not enough data — skip 391 + continue; 392 + } 393 + double t = frac_pos - (int64_t)frac_pos; 394 + int ri0 = (idx0 % dec->ring_size) * 2; 395 + int ri1 = ((idx0 + 1) % dec->ring_size) * 2; 396 + float sl = dec->ring[ri0] * (1.0f - (float)t) + dec->ring[ri1] * (float)t; 397 + float sr = dec->ring[ri0 + 1] * (1.0f - (float)t) + dec->ring[ri1 + 1] * (float)t; 398 + // Advance fractional position by speed 399 + dec->ring_frac += spd; 400 + // Consume whole samples from ring 401 + int consumed = (int)dec->ring_frac; 402 + if (consumed > 0) { 403 + dec->ring_read += consumed; 404 + dec->ring_frac -= consumed; 405 + } else if (consumed < 0) { 406 + // Reverse: clamp to not go before ring_read 407 + // (reverse scratching won't replay old audio, just stops) 408 + dec->ring_frac = 0; 409 + } 383 410 // Crossfader: 0.0 = full deck A, 1.0 = full deck B 384 411 float cf = (d == 0) 385 412 ? (1.0f - audio->crossfader)
+39 -6
fedac/native/src/wifi.c
··· 425 425 } 426 426 wifi_log(wifi, "Portal URL: %s", portal_url); 427 427 428 - // Step 3: Extract form action and submit it 429 - // Many portals (GettyLink, hotel WiFi) have a form 430 - // with action URL — POST to it to accept terms 428 + // Step 3: Try multiple accept strategies 429 + // Strategy A: ClearPass/Aruba — change cmd=login to cmd=authenticate 430 + { 431 + char auth_url[512] = ""; 432 + const char *cmd_pos = strstr(portal_url, "cmd=login"); 433 + if (cmd_pos) { 434 + int prefix_len = (int)(cmd_pos - portal_url); 435 + snprintf(auth_url, sizeof(auth_url), "%.*scmd=authenticate%s", 436 + prefix_len, portal_url, cmd_pos + 9); 437 + wifi_log(wifi, "ClearPass: trying %s", auth_url); 438 + snprintf(portal_cmd, sizeof(portal_cmd), 439 + "curl -skL -o /dev/null -w '%%{http_code}' " 440 + "--max-time 10 '%s' 2>/dev/null", auth_url); 441 + FILE *cf2 = popen(portal_cmd, "r"); 442 + if (cf2) { 443 + char cc[8] = ""; 444 + if (fgets(cc, sizeof(cc), cf2)) cc[strcspn(cc, "\n")] = 0; 445 + pclose(cf2); 446 + wifi_log(wifi, "ClearPass auth response: HTTP %s", cc); 447 + } 448 + } 449 + } 450 + // Strategy B: Extract HTML form action and POST 431 451 snprintf(portal_cmd, sizeof(portal_cmd), 432 452 "sh -c '" 433 453 "ACTION=$(grep -oi \"action=\\\"[^\\\"]*\\\"\" /tmp/portal_page.html 2>/dev/null " ··· 437 457 " HOST=$(echo \"%s\" | sed \"s|^\\(https\\?://[^/]*\\).*|\\1|\"); " 438 458 " URL=\"${HOST}${ACTION}\" ;; " 439 459 " *) URL=\"%s\" ;; esac; " 440 - " curl -sL -o /dev/null -w \"%%{http_code}\" " 460 + " curl -skL -o /dev/null -w \"%%{http_code}\" " 441 461 " --max-time 10 -X POST \"$URL\" 2>/dev/null; " 442 462 "else " 443 - " curl -sL -o /dev/null -w \"%%{http_code}\" " 463 + " curl -skL -o /dev/null -w \"%%{http_code}\" " 444 464 " --max-time 10 \"%s\" 2>/dev/null; " 445 465 "fi'", 446 466 portal_url, portal_url, portal_url); ··· 450 470 if (fgets(acode, sizeof(acode), af)) 451 471 acode[strcspn(acode, "\n")] = 0; 452 472 pclose(af); 453 - wifi_log(wifi, "Portal submit response: HTTP %s", acode); 473 + wifi_log(wifi, "Portal form submit: HTTP %s", acode); 474 + } 475 + // Strategy C: POST to portal URL with common accept params 476 + snprintf(portal_cmd, sizeof(portal_cmd), 477 + "curl -skL -o /dev/null -w '%%{http_code}' " 478 + "--max-time 10 -X POST " 479 + "-d 'accept=true&cmd=authenticate&Login=Login' " 480 + "'%s' 2>/dev/null", portal_url); 481 + FILE *pf2 = popen(portal_cmd, "r"); 482 + if (pf2) { 483 + char pc[8] = ""; 484 + if (fgets(pc, sizeof(pc), pf2)) pc[strcspn(pc, "\n")] = 0; 485 + pclose(pf2); 486 + wifi_log(wifi, "Portal POST accept: HTTP %s", pc); 454 487 } 455 488 456 489 // Step 4: Re-check connectivity