Commit Graph

20 Commits

Author SHA1 Message Date
bsncubed c9e06da47c aes67_tx: mono sum; the source always delivers stereo
The pull callback's contract said "stream channels", but the player always
delivers stereo, so a 1-channel stream went out as L,R,L,R at half speed.
Sources now always deliver AES67_TX_SRC_CHANNELS (2); TX maps to the stream:
2 ch as is, 1 ch = (L+R)/2 in 64 bit with mono_sum (can't clip), else L.
Checked: mono packets 156 B, 1000/s, 48 samples each, SDP L24/48000/1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:26:48 +10:00
bsncubed e5c3c0ad8a Player: runtime source override and play-a-URL on /api/player
The player now owns the effective mode (config or override) and passes it
down: spotify_apply(enable) and hls_set_url(url) instead of both reading
source.mode from the config. /api/player/source forces spotify|hls|off
(config returns to the saved mode), /api/player/url plays an m3u8 now;
neither is saved. GET reports it in `forced`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:15:38 +10:00
bsncubed a55a3ce14a Player: seek and volume on /api/player
seek {ms} runs on the Spotify session task like a seek from the app (buffer
flushed, app notified). volume {value}|{delta} sets the player volume and
pushes it to the session, so the app's slider follows; setRemoteVolume now
also runs on the session task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:04:11 +10:00
bsncubed 8485ecb5b7 Player: transport commands on /api/player
play/pause/toggle/stop/next/prev. Spotify commands run on the session task
(same thread as cspot's frame handling) and the POST waits until they ran,
so it answers the new state. HLS pause stops fetching and resume rejoins at
the live edge; next/prev on HLS and anything on tone/off return 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:59:14 +10:00
bsncubed 9e1ca98a5d Player: GET /api/player with Spotify now-playing info
Source, state, artist/title/album, position and duration (from cspot's
playback state, so it matches the app), volume and `can`. cspot patch 0004
adds read getters for the playback state and context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:48:00 +10:00
bsncubed c6ecbbeb25 Player: auto mode fails over between Spotify and HLS
No Spotify session (or paused, with failover_on_pause) for failover_delay_s
switches to HLS; Spotify playing switches back within ~1 s. Switches fade
out/in over 30 ms and flush the ring. HLS is suspended while Spotify plays.
cspot is held back instead of drained while it isn't Spotify's turn: it keeps
decoding on pause, so dropping its frames let it race through the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:36:11 +10:00
bsncubed b8702e6faa Spotify: tell cspot when a new track becomes audible
The Mac dropped the device soon after the first track change: we never
called SpircHandler::notifyAudioReachedPlayback(), so cspot's queue did
not advance and the state sent to Spotify stalled.
- The data callback records a boundary (output write position, track
  id) when the track id changes; the session loop (<= 200 ms) calls
  notifyAudioReachedPlayback(id) once playback (ring read position)
  passes it, and notifyAudioEnded() after DEPLETED once the buffer is
  empty. PLAYBACK_START clears boundaries (next data is a new one),
  seek/flush drop pending ones.
- audio_ring exposes its write/read frame counters (wrap-safe compare).
- Verified: 10 min on the Mac without a disconnect, every track change
  notified ("track audible, Spotify notified"), app shows the playing
  track (switches up to ~3-5 s early), 0 underruns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:47:28 +10:00
bsncubed f0fe53d5d1 Spotify: end the session for OTA; app volume starts at the player's
- OTA: the player now ends the Spotify session (spotify_suspend: stop
  flag, waits until the session's tasks are gone, refuses new ones)
  instead of pausing it, and suspends HLS. Pausing was not enough (an
  upload still failed once with a paused session). Verified twice with a
  session running for minutes: "Disconnecting mercury session / session
  ended / suspended", installed in ~26 s, clean reboot.
- Volume: createFromBlob() starts cspot at volume 0, which the app
  showed while we played at 100 %. The session now starts with the
  player's volume (spotify_set_volume); volumes coming from the app are
  stored exactly, without echo. Verified: slider starts at 100 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:15:29 +10:00
bsncubed 0aac7bc339 Spotify follows source mode, name and credentials live
- spotify_init() sets up the logger, zeroconf routes and session task
  once; spotify_apply() (at boot and on every source save) enables or
  disables: enabled = mode spotify/auto + credentials. Disable removes
  the mDNS entry, zeroconf answers 404 and a running session ends
  (loop exits within 200 ms, SpircHandler::disconnect stops the queue
  and player tasks). A new device name ends the session and
  re-advertises. The login blob is swapped under a mutex.
- Verified: hls/spotify/rename switch the mDNS entry and /spotify_info
  (404/200) live; switching to hls during Spotify playback ended the
  session in < 2 s, HLS played after ~5 s, internal heap 221 -> 365 KB
  (cspot frees everything).
- CLAUDE.md: OTA-with-session findings (intermittent, flash-write stalls
  seen as TX resyncs during uploads), serial-port reset caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:43:16 +10:00
bsncubed 8ba4de5eda OTA: quiet the audio sources during a firmware upload
An upload during an active Spotify session crawled (~10 kB/s, 197 s)
and ended in a reset (seen twice). Workaround:
- aes67_ota_on_update(cb): cb(true) right before an accepted upload
  writes flash, cb(false) if it then fails.
- The player pauses Spotify (spotify_pause -> SpircHandler::setPause, the
  app follows) and suspends HLS fetching (hls_suspend); resumed if the
  upload fails. AES67 TX keeps running (silence).
- Verified: upload during Spotify playback paused the session and
  installed in 25.8 s (normal for 2 MB), clean reboot and self-test.
  Root cause still open (noted in CLAUDE.md with the other cspot items).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:02:03 +10:00
bsncubed cd747aec40 Step 7.5b4a: player volume (Spotify app slider), applied after the ring
- player: volume 0..100 %, amplitude = (v/100)^3 (about -18 dB at 50 %,
  0 = mute), applied in the TX pull callback after the ring so a change
  is heard at once (the ring holds 4 s); ramped over one packet to avoid
  zipper noise. Spotify VOLUME events (0..65535) set it.
- Docs: pipeline order ring -> volume/gain, curve described.
- Verified from a Mac: RMS followed the slider immediately (100 % about
  -10 dBFS; steps to about -24.5 and -28 dBFS match the cubic curve for
  ~57 % / ~50 %); user: "sounds about right". Mute at 0 % not yet
  confirmed (check via /api/player/volume later).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:52:57 +10:00
bsncubed 4a38254660 Step 7.5b3b: Spotify audio on AES67
- spotify_init(pcm_cb, event_cb): cspot's data callback hands 44.1 kHz
  stereo s16 PCM to the player and returns what was taken; the player's
  blocking ring write paces decoding to playback speed.
- Events: FLUSH/SEEK/PLAYBACK_START empty the ring (skip/seek sound at
  once); PLAY_PAUSE pauses output (silence, buffer kept, no underrun);
  VOLUME logged (applied in b4).
- player: Spotify via its own audio_out converter (44.1 -> 48 kHz);
  source_state playing / paused / buffering.
- Verified from a Mac: music on the AES67 stream (10 s: 0 gaps, no
  silent packets, RMS -9.6 dBFS, peak 0.0 dBFS), buffer 4.0 s full,
  0 underruns, pause/play/skip/seek events.
  Seen: first 3 connects "Can't connect to spotify servers", 4th worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:41:49 +10:00
bsncubed 5b0d7c9ba4 Step 7.5b3a: per-source PCM converter (audio_out), shared by HLS and Spotify
- main/audio_out: one converter instance per source (own rate converter
  state): s16 any rate/channels -> 48 kHz stereo int32 -> ring.
- player_write(src, ...) only writes for the active source, drops the
  rest; player_src_t is public in player.h.
- HLS decoder uses audio_out (no behaviour change).
- Verified: HLS still sample exact (441344 -> 480375 frames per
  10.008 s segment), buffer ~4 s, 0 underruns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:34:15 +10:00
bsncubed c240ac9a3c Step 7.5b2: Spotify Connect zeroconf and login work (PCM counted only)
- Zeroconf on our httpd (GET/POST /spotify_info, form body URL-decoded)
  and _spotify-connect._tcp via our mDNS (VERSION, CPath, Stack TXT).
- Session task: waits for the app's login blob, connects with the
  user's client ID/secret and the configured bitrate (96/160/320 ->
  OGG_VORBIS_*), SpircHandler, events logged; cspot exceptions caught.
  Starts when source.mode is spotify/auto and credentials are set.
- bell::bellGlobalLogger was NULL: every CSPOT_LOG crashed the board
  right after addUser (Load access fault in Session.cpp:67 /
  LoginBlob.cpp:58). cspot/bell logs now go to esp_log (UART + syslog);
  signed CDN URL tokens are cut from the log.
- esp_audio_codec's own Vorbis decoder clashed with bell's Tremor
  symbols: CONFIG_AUDIO_DECODER_VORBIS_SUPPORT / SIMPLE_DEC_OGG off.
- status.spotify_state from the session (waiting / connecting /
  connected / login failed / error / disabled / no client credentials).
- Verified from a Mac: device appears, "connected as <user>", access
  token fetched with the user's client credentials, track info, audio
  key, CDN URL (the step broken upstream), PCM decoded. Without
  backpressure a track decodes in ~26 s (b3 feeds the ring).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:26:36 +10:00
bsncubed 039ec75473 Step 7.5b1: cspot (philippe44 fork) builds and links on the P4
- external/cspot: git submodule, pinned to philippe44/cspot 3010349
  (bell ed2d6e9); GPL-3.0.
- components/spotify: project component wrapping cspot. bell trimmed to
  what cspot needs (Tremor Vorbis; no codec wrapper, sinks, MQTT, web
  server, fmt, regex; cJSON from IDF). Xtensa biquad assembly filtered
  out (P4 is RISC-V). CMAKE_POLICY_VERSION_MINIMUM 3.5 for CMake 4.
- Patches in components/spotify/patches, applied at configure time:
  nanopb generator works with protobuf >= 5 (MakeClass removed);
  URLParser.cpp missing <cstdio>/<cstring> for GCC 14.
- CONFIG_COMPILER_CXX_EXCEPTIONS=y (cspot needs exceptions).
- spotify_init() only builds a LoginBlob (link check).
- CLAUDE.md: submodule init and IDF-venv Python packages for nanopb.
- Verified on board: "cspot linked: device "P4 AES67", zeroconf info 588
  bytes"; HLS still playing, PTP locked; image 1.83 MB (70% free).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:25:10 +10:00
bsncubed 7bbda0a1e8 Step 7.5a: Spotify client credentials in config/UI, write-only secrets
- Core config: cfg_mark_secret(group, key). GET /api/config returns ""
  for secret keys; a POST with "" keeps the stored value, null clears it;
  cfg_get() returns the real value. Documented in aes67-core-base.md.
- source.spotify_client_id / spotify_client_secret (secret write-only):
  each user's own Spotify developer app, needed by the maintained cspot
  fork (philippe44/cspot) since Spotify's 2025 API restrictions.
- UI: client ID field, password field for the secret ("leave empty to
  keep"), link to developer.spotify.com; enabled for spotify/auto modes.
- status.spotify_state: "no client credentials" while either is missing.
- Verified: secret never appears in GET; UI-style re-save keeps it;
  survives reboot; null clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:16:42 +10:00
bsncubed 3ebb27e4bb Step 7.4: HLS audio on AES67 (44.1 -> 48 kHz into the ring)
- decoder: s16 PCM -> stereo int32 -> esp_ae_rate_cvt 44.1 -> 48 kHz
  (32-bit, complexity 3; bypassed at 48 kHz; mono duplicated) -> ring.
  esp_audio_effects pinned to ~1.3.0 (1.4+ needs P4 rev >= 3).
- hls: each segment is downloaded completely into PSRAM (max 4 MB), then
  decoded; the connection is not held open while the decoder waits for
  ring space at playback speed.
- player: player_write() blocks while the ring is full and gives up when
  the source changes; the temporary 440 Hz producer is removed.
- Verified with Triple J Hottest: 441344 -> 480375 frames (10.008 s) and
  440320 -> 479260 (9.985 s) per segment; RTP 15000 packets, 0 gaps,
  peak -10 dBFS / RMS -22 dBFS; ring ~4.0 s, 0 underruns over ~50 s;
  heap 422 KB, PSRAM 27.5 MB free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:52:19 +10:00
bsncubed 8a6cb53e3b Step 7.3: decode HLS TS segments to PCM (own TS demux + AAC decoder)
- main/decoder: MPEG-TS demux (packets reassembled across HTTP chunks,
  PAT -> PMT -> first ADTS-AAC PID, PES headers stripped) feeding the
  esp_audio_codec simple AAC decoder (ADTS, AAC-Plus enabled for HE-AAC
  v1/v2 variants). 7.3 only counts and logs PCM per segment.
- esp_audio_codec pinned to ~2.5.0: 2.6+ needs P4 rev >= 3 (this board
  is rev 1.3). Noted in CLAUDE.md, also for esp_audio_effects < 1.4.
- The library's combined TS decoder lost ~8% of the frames (segments
  decoded to 7.9-9.6 s, "decode error -1"); with the own demux every
  segment is sample exact: 441344 / 440320 frames = 10.008 / 9.985 s,
  matching EXTINF 10.0078 / 9.9846 (431 / 430 AAC frames), no errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:48:12 +10:00
bsncubed 852c0ffa0f Step 7.2: HLS client fetches live segments (log only)
- main/hls: task active while source.mode is hls/auto and hls_url is
  set. esp_http_client over HTTPS (IDF certificate bundle), redirects
  followed (max 5), streamed reads in 4 KB chunks to a sink callback.
- Master playlist: highest BANDWIDTH variant; URL resolution for
  absolute, host-relative and path-relative references; CRLF tolerant.
- Media playlist: TARGETDURATION, MEDIA-SEQUENCE, segments; start 3
  segments behind the live edge, fetch each new one in order, skip ahead
  if the window moved past us, reload after target/2 when nothing is new.
- Verified with Triple J Hottest (ABC, Akamai): 252 kbit/s AAC-LC
  variant chosen, 3 back-fill segments then one new ~10 s segment at a
  time, ~303 KB in ~1.25 s each; heap 439 KB, PSRAM 31.9 MB free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:40:18 +10:00
bsncubed d3897f3179 Step 7.1: player plumbing (PSRAM ring -> AES67 TX), test tone mode
- main/audio_ring: SPSC ring of interleaved int32 frames in PSRAM
  (4 s at 48 kHz stereo), lock-free with acquire/release counters; the
  TX pull callback never blocks.
- main/player: source selection from source.mode and the pull callback
  for aes67_tx. tone -> the core's PTP-phased 1 kHz tone; off -> silence;
  hls/spotify -> ring with 1 s prefill (silence while buffering is not an
  underrun; running dry is, and prefills again). Status fields
  active_source, source_state, spotify_state, buffer_ms. source config
  applies live.
- New source.mode "tone" (validation, UI dropdown, doc) for commissioning.
- Temporary 440 Hz producer in hls mode (until the HLS player exists).
- Verified: tone 999.7 Hz -18 dBFS; off silent; hls 440.0 Hz with max
  sample step 60816 (ideal sine 60825, i.e. no discontinuities), buffer
  3983 ms, 0 underruns; spotify silent/not implemented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:36:15 +10:00