Commit Graph

24 Commits

Author SHA1 Message Date
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
bsncubed 8b3bd39857 Step 5 (syslog): esp_log to remote syslog
- aes67_syslog: esp_log_set_vprintf hook chained to the UART; formats
  into a static buffer under a zero-timeout mutex (drop and count when
  busy), strips ANSI codes, parses level/tag, filters by log.level and
  queues (48 messages, non-blocking). Low-priority sender task: RFC 5424
  (<PRI>1 - HOST APP - - - MSG, no timestamp yet) or RFC 3164
  (<PRI>HOST TAG: MSG), PRI = facility*8 + severity; log.host as IP or
  resolved name; config applies live. Messages wait in the queue until
  the interface has an IP. POST /api/log/test sends an info message
  regardless of the level filter.
- Initialised first in app_main so boot messages are captured; the UDP
  socket is created only once the interface is up (creating it before
  esp_netif_init crashed at boot and the bootloader rolled back).
- Verified with a UDP listener: 5424 and 3164 formats, PRI 134/132,
  boot messages delivered after the IP came up, shutdown messages sent
  before reboot, level filter, test message, host by DNS name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:05:34 +10:00
bsncubed bae6a8a2f9 Step 4.2: PTP-paced AES67 RTP sender with 1 kHz test tone
- aes67_tx: TX task woken by an esp_timer every packet time; sends every
  packet whose last sample is in the past (PTP time), RTP ts =
  (sample index from PTP + clk_offset) mod 2^32, packets aligned to
  multiples of the packet size. Sends only while PTP is locked; resyncs
  on lock, clock step or > 20 ms lag. L24/L16 big-endian, TTL/DSCP/
  multicast IF from config; a config save restarts TX.
- Built-in 1 kHz tone at -18 dBFS, phase from the PTP sample index;
  aes67_tx_set_source() pull callback for later sources (underruns
  counted, padded with silence). status: tx_packets, underruns.
- Verified with a receiver script on the PC: 1 ms L24 stereo 1000/s,
  0 gaps, ts step 48, tone -18.00 dBFS within 1.2 LSB of the ideal
  PTP-phased sine; also L16 mono 0.333 ms, 0.125 ms (8000/s) and 4 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:43:12 +10:00
bsncubed 3d05768f00 Step 4.1: /stream.sdp from the live config and PTP GM
- aes67_sdp_sap: aes67_sdp_build() builds the AES67 SDP (RFC 4566 +
  RFC 7273) with the same lines and order as the web UI preview;
  GET /stream.sdp serves it as application/sdp.
- aes67_ptp: public aes67_ptp_gm_id(), aes67_ptp_locked(),
  aes67_ptp_now_ns(). Clock IDs are now uppercase (RFC 7273 style), also
  in status.ptp, which the UI copies into ts-refclk.
- aes67_net: aes67_net_netif() returns the AES67 interface.
- Verified on board: /stream.sdp byte-identical to the UI's sdp() for
  the same config/status; mono, L16, ptime 0.333, ttl 8, clk_offset
  4294967295 and session_ver all follow the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:38:55 +10:00
bsncubed 6090ceb239 Step 3.1: EMAC hardware timestamps for PTP over UDP/IPv4
- aes67_ptp/ptp_hw.c: start the EMAC IEEE 1588 clock (IDF
  ETH_MAC_ESP_CMD_PTP_ENABLE) and additionally enable snapshots for
  PTP over UDP/IPv4 (IDF only enables PTP over Ethernet/L2). An RX hook
  on the driver's info input path records {type, seq, sourcePortId, HW
  timestamp} of port-319 PTPv2 event messages, then hands every frame to
  lwIP unchanged.
- aes67_ptp.c: temporary 3.1 logger joins 224.0.1.129:319/320 and pairs
  Sync HW RX timestamps with Follow_Up origin timestamps.
- Checked against linuxptp ptp4l (-4 -E -H, Intel igb) as GM: every Sync
  has a HW timestamp; HW Sync intervals track the GM intervals with a
  constant -39.8 us/s (+-0.3 us), i.e. local clock -39.8 ppm vs GM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:44:11 +10:00
bsncubed 2403a6b64b Step 2b: firmware upload (/api/ota) with image checks and rollback self-test
- aes67_ota: GET /api/ota (version, project, build_date, idf, running,
  previous, previous_version, pending_verify, can_rollback), POST
  /api/ota streamed into the inactive slot (4 KiB chunks), POST
  /api/ota/confirm and /api/ota/rollback.
- Rejected before any flash write: bad magic, wrong chip, wrong
  project_name, chip revision outside the image's min/max range.
  esp_ota_end verifies the whole image.
- Self-test on a pending image: IP address plus HTTP 200 from our own
  /api/status within 60 s marks it valid; otherwise mark invalid and
  reboot into the previous slot. A crash before that is rolled back by
  the bootloader.
- CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL (test builds only) forces a
  failed self-test.
- aes67_web: web_reboot_later() shared by /api/reboot and OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:20:04 +10:00
bsncubed c2621f192a Step 2.2: config registry and NVS store, GET/POST /api/config
- aes67_web/cfg.c: cfg_register(group, defaults, validate, apply),
  cfg_override_defaults() for project defaults, cfg_get(). Stored values
  are loaded on register and merged over defaults (known keys, matching
  types). NVS keeps only keys that differ from defaults, one JSON string
  per group, so new firmware defaults still reach untouched settings.
- POST /api/config: type check against defaults, per-group validation,
  nothing stored unless every group passes; 400 "group.key: reason".
  Unknown keys/groups are ignored. Returns the new config.
- Core groups registered by their components with the UI's ranges:
  ptp (aes67_ptp), aes67 (aes67_tx, incl. multicast range and mono_sum
  => 1 channel), net/inet (aes67_net), log (aes67_syslog).
- Project: PROJECT.def overrides (hostname p4-aes67, name P4 AES67) and
  the "source" group in main/project_cfg.c.
- Config is stored only; nothing is applied live yet.
- Verified on board: defaults, valid save, 8 rejected values (nothing
  stored), persistence across reboot, restore to defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:00:55 +10:00
bsncubed 35275b8ccf Step 2.1: web server, embedded UI, /api/status, /api/reboot
- aes67_web: httpd (wildcard URI matching), serves web/index.html embedded
  via EMBED_TXTFILES, status registry (status_register) and route registry
  (web_register_uri), GET /api/status, POST /api/reboot (restarts 0.5 s
  after the response).
- Status fields come from each core component: aes67_net (ip, aes67_ip,
  mac, link), aes67_health (fw, uptime_s, heap_free, psram_free),
  aes67_tx stub (tx_packets, underruns = 0). No ptp object yet, so the UI
  shows "no data".
- Verified on board: / serves index.html byte-identical (27601 B),
  /api/status returns live values, reboot via API returns in ~12 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:56:06 +10:00
bsncubed 69b4e18118 Step 1: Ethernet up (IP101/RMII), DHCP, link and IP logged
- aes67_board: Waveshare ESP32-P4-ETH pin profile and EMAC + IP101 driver
  install (PHY addr 1, reset GPIO51, ref clock in on GPIO50).
- aes67_net: Ethernet netif with DHCP client; logs link up/down (speed,
  duplex, MAC) and the DHCP lease.
- Verified on board: 100 Mbps full duplex, DHCP lease in ~8 s, ping
  0% loss, ~0.2 ms RTT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:52:08 +10:00
bsncubed bbfd3506bf Step 0: IDF project skeleton for esp32p4 with core component stubs
- Board checked with esptool: ESP32-P4 rev v1.3 (engineering silicon),
  32 MB GigaDevice flash. Rev <3 support and 32 MB set in sdkconfig.defaults.
- OTA partition layout: nvs, otadata, phy_init, ota_0/ota_1 (6 MB each,
  below 16 MB), coredump; no factory app. Partition table at 0x10000 so
  the bootloader can grow.
- App rollback enabled in the bootloader from the start.
- PROJECT_VER from git describe --tags --always --dirty.
- Empty stubs for the nine aes67_* core components; main logs version
  and chip revision.
- Built with ESP-IDF v5.5.5; not yet flashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:47:21 +10:00