diff --git a/CLAUDE.md b/CLAUDE.md index 03fbc72..6e68e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,10 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - Track display in the app can switch ~3-5 s early. - Mute at 0 % volume not yet confirmed. - Consider offering the delayed-Pong fix upstream (philippe44/cspot). - - [ ] failover (auto mode) + /api/player + - An old, long-idle session can get out of sync with the app: it sends empty Load frames ("No tracks in frame") instead of Pause/Play, so controls do nothing. Quitting and reopening Spotify on the Mac fixes it. + - [x] failover (auto mode): no session or (with `failover_on_pause`) paused for `failover_delay_s` -> HLS; Spotify playing -> Spotify within ~1 s; 30 ms fades; HLS suspended while Spotify plays; cspot is held back (not drained) while it isn't Spotify's turn, so it resumes where it paused. Verified: no session -> HLS, play -> Spotify, pause stays (on_pause off), pause -> HLS after 5 s (on_pause on), play -> Spotify at the paused position. + - Open: `aes67_tx resync` (TX 20-27 ms late) ~11 s after each switch to Spotify (seen 3 times); clicks at switches not yet checked in a recording; HLS start sometimes hits CDN read timeouts. + - [ ] /api/player - [ ] 8. Mono sum, gain, polish. ## Phase 2 (parked) diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index 97ad67e..520881d 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -40,6 +40,8 @@ void spotify_suspend(bool suspend); // The player's volume (0..100 %): reported to the Spotify app when a session starts (else the app // shows 0 while we play at the player's level), and pushed to a running session. void spotify_set_volume(int pct); +// A session is running (an app is connected to this device). +bool spotify_session_active(void); // Current state for status.spotify_state. const char *spotify_state(void); diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index aa337b5..690d459 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -129,6 +129,11 @@ extern "C" void spotify_pause(bool pause) } } +extern "C" bool spotify_session_active(void) +{ + return s_handler != nullptr; +} + const char *spotify_state(void) { return s_state.load(); diff --git a/main/hls.c b/main/hls.c index 5a8a3b9..57e3f61 100644 --- a/main/hls.c +++ b/main/hls.c @@ -36,11 +36,15 @@ static media_pl_t *s_pl; // parsed media playlist (PSRAM) static char s_url[URL_MAX]; // configured URL static char s_media_url[URL_MAX]; static uint8_t *s_seg; // current segment (PSRAM) -static volatile bool s_suspended; +static volatile unsigned s_suspended; // HLS_SUSPEND_* bits -void hls_suspend(bool suspend) +void hls_suspend(unsigned reason, bool suspend) { - s_suspended = suspend; + if (suspend) { + __atomic_or_fetch(&s_suspended, reason, __ATOMIC_SEQ_CST); + } else { + __atomic_and_fetch(&s_suspended, ~reason, __ATOMIC_SEQ_CST); + } } // hls_url when source.mode needs HLS, else "". diff --git a/main/hls.h b/main/hls.h index ed55de5..be90560 100644 --- a/main/hls.h +++ b/main/hls.h @@ -11,5 +11,8 @@ typedef bool (*hls_sink_t)(const uint8_t *data, size_t len, bool segment_start); esp_err_t hls_start(hls_sink_t sink); -// Stop fetching (current download is abandoned) until resumed. -void hls_suspend(bool suspend); +// Stop fetching (current download is abandoned) until resumed. Reasons are independent: +// fetching runs only when no reason holds it. +#define HLS_SUSPEND_OTA (1 << 0) +#define HLS_SUSPEND_FAILOVER (1 << 1) +void hls_suspend(unsigned reason, bool suspend); diff --git a/main/player.c b/main/player.c index f265b91..88f2e1e 100644 --- a/main/player.c +++ b/main/player.c @@ -10,26 +10,43 @@ #include "audio_out.h" #include "audio_ring.h" #include "decoder.h" -#include "hls.h" -#include "spotify.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "hls.h" +#include "spotify.h" #define RATE 48000 #define CHANNELS 2 #define RING_FRAMES (4 * RATE) // 4 s in PSRAM #define PREFILL_FRAMES (RATE) // 1 s before (re)starting output +#define FADE_FRAMES (RATE * 30 / 1000) // 30 ms ramp at a source switch +#define SPOTIFY_WAIT_MS 100 // longest the Spotify data callback may wait (cspot holds a lock) +#define CONTROL_MS 250 static const char *TAG = "player"; +typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO } mode_t_; static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; -static volatile player_src_t s_src = PLAYER_SRC_OFF; +static volatile mode_t_ s_mode = MODE_OFF; +static volatile player_src_t s_src = PLAYER_SRC_OFF; // what is playing now +static volatile player_src_t s_target = PLAYER_SRC_OFF; // switch to this (TX task, with fades) static volatile bool s_playing; // ring output running (after prefill) static volatile bool s_flush; // consumer drops buffered audio on the next read static volatile bool s_paused; // Spotify paused: silence, buffer kept, no underrun +static volatile bool s_sp_playing; // Spotify has started playback and is not paused static audio_conv_t *s_spotify_conv; + +// Failover (mode auto) +static volatile int s_failover_delay_s = 5; +static volatile bool s_failover_on_pause; + +// TX task only +static int s_fade_out; // frames left in the fade-out before a switch +static int s_fade_in; // frames left in the fade-in after a switch + // Player volume, applied after the ring so a change is heard at once (not a ring length later). static volatile float s_target_gain = 1.0f; static float s_gain = 1.0f; // TX task only; ramps to s_target_gain @@ -60,14 +77,30 @@ static void apply_gain(int32_t *buf, size_t frames) s_gain = target; } -static player_src_t mode_of(const char *m) +// Linear ramp over a fade: position counts down from FADE_FRAMES; fade-in rises, fade-out falls. +static void apply_fade(int32_t *buf, size_t frames, int *left, bool in) { - return !strcmp(m, "tone") ? PLAYER_SRC_TONE : !strcmp(m, "hls") ? PLAYER_SRC_HLS : - !strcmp(m, "spotify") ? PLAYER_SRC_SPOTIFY : !strcmp(m, "auto") ? PLAYER_SRC_HLS /* failover: step 7 */ : PLAYER_SRC_OFF; + for (size_t i = 0; i < frames && *left > 0; i++, (*left)--) { + float g = (float)(*left) / FADE_FRAMES; // 1 -> 0 over the fade + if (in) { + g = 1.0f - g; + } + buf[i * 2] = (int32_t)(buf[i * 2] * g); + buf[i * 2 + 1] = (int32_t)(buf[i * 2 + 1] * g); + } } -// AES67 TX pull callback (TX task, must not block). Silence while buffering or off: not an underrun. -static size_t player_read(int32_t *buf, size_t frames) +static mode_t_ mode_of(const char *m) +{ + return !strcmp(m, "tone") ? MODE_TONE : !strcmp(m, "hls") ? MODE_HLS : !strcmp(m, "spotify") ? MODE_SPOTIFY : + !strcmp(m, "auto") ? MODE_AUTO : MODE_OFF; +} + +/* ----- output (AES67 TX pull callback, TX task, must not block) ----- */ + +// One buffer of the current source: ring audio, or silence while off / paused / buffering. +// Returns the frames produced (fewer = underrun). +static size_t read_current(int32_t *buf, size_t frames) { if (s_flush) { audio_ring_flush(); @@ -84,39 +117,93 @@ static size_t player_read(int32_t *buf, size_t frames) return frames; } s_playing = true; + s_fade_in = FADE_FRAMES; // (re)start without a click } size_t got = audio_ring_read(buf, frames); - apply_gain(buf, got); if (got < frames) { s_playing = false; // ran dry: underrun (counted by TX), prefill again } return got; } +static size_t player_read(int32_t *buf, size_t frames) +{ + // A switch is due: fade the current source out, then switch (ring flushed, new source prefills). + if (s_target != s_src && s_fade_out == 0) { + s_fade_out = FADE_FRAMES; + } + size_t got = read_current(buf, frames); + if (s_fade_in > 0) { + apply_fade(buf, got, &s_fade_in, true); + } + if (s_fade_out > 0) { + apply_fade(buf, got, &s_fade_out, false); + if (s_fade_out == 0 || got < frames) { + s_fade_out = 0; + audio_ring_flush(); // before s_src changes: writers only write for the active source + s_playing = false; + s_flush = false; + s_src = s_target; + } + } + apply_gain(buf, got); + return got; +} + +// Set the source to play. Tone comes from TX itself (phase-locked to PTP), so switches from/to it +// are immediate; everything else fades through player_read. +static void select_source(player_src_t src) +{ + if (src == s_target) { + return; + } + ESP_LOGI(TAG, "source: %s -> %s", SRC_NAME[s_src], SRC_NAME[src]); + if (src == PLAYER_SRC_TONE || s_src == PLAYER_SRC_TONE) { + s_target = s_src = src; + s_flush = true; + aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read); + return; + } + s_target = src; // player_read fades out and switches +} + void player_apply(const cJSON *source) { if (s_spotify_conv) { // after player_init: Spotify follows mode / name / credentials live spotify_apply(); } - player_src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring); - if (src == s_src) { + s_failover_delay_s = (int)cJSON_GetObjectItemCaseSensitive(source, "failover_delay_s")->valuedouble; + s_failover_on_pause = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(source, "failover_on_pause")); + mode_t_ mode = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring); + if (mode == s_mode) { return; } - s_src = src; - s_flush = true; - // The test tone is TX's own (phase-locked to PTP); everything else goes through player_read. - aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read); - ESP_LOGI(TAG, "source: %s", SRC_NAME[src]); + s_mode = mode; + // HLS fetches only in hls mode, or in auto while it is the active source (control task). + hls_suspend(HLS_SUSPEND_FAILOVER, mode == MODE_AUTO); + switch (mode) { + case MODE_TONE: select_source(PLAYER_SRC_TONE); break; + case MODE_HLS: select_source(PLAYER_SRC_HLS); break; + case MODE_SPOTIFY: select_source(PLAYER_SRC_SPOTIFY); break; + case MODE_AUTO: select_source(PLAYER_SRC_SPOTIFY); break; // Spotify inactive: HLS after the delay + default: select_source(PLAYER_SRC_OFF); break; + } } -// Source side: write converted 48 kHz frames, waiting for space at playback speed. -// Frames of a source that is not active are dropped. +/* ----- source side ----- */ + +// Write converted 48 kHz frames, waiting for space at playback speed. Frames of a source that is +// neither playing nor about to play are dropped (returns n). size_t player_write(player_src_t src, const int32_t *frames, size_t n) { size_t done = 0; while (done < n) { if (s_src != src) { - return n; // not the active source: drop + if (s_target != src) { + return n; // not this source's turn: drop + } + vTaskDelay(pdMS_TO_TICKS(5)); // switch in progress (fade-out, ~30 ms) + continue; } size_t w = audio_ring_write(frames + done * CHANNELS, n - done); if (!w) { @@ -129,10 +216,29 @@ size_t player_write(player_src_t src, const int32_t *frames, size_t n) /* ----- Spotify ----- */ +// cspot calls this holding a lock that stopping the session also needs: never wait long. Take only +// what fits in the ring; return 0 after SPOTIFY_WAIT_MS and cspot retries (it can stop in between). +// When it isn't Spotify's turn, hold cspot back the same way: it doesn't stop decoding on pause, so +// dropping frames would let it race through the queue at full speed (CPU and network), starving HLS. static size_t spotify_pcm(const int16_t *pcm, size_t frames) { - audio_conv_write(s_spotify_conv, pcm, frames, 2, 44100); // blocks while the ring is full - return frames; + int64_t until = esp_timer_get_time() + SPOTIFY_WAIT_MS * 1000LL; + while (true) { + if (s_src == PLAYER_SRC_SPOTIFY) { + // 44.1 kHz in -> about 1.09x as many 48 kHz frames out; keep a margin + size_t fit = audio_ring_space() * 44100 / 48000; + fit = fit > 64 ? fit - 64 : 0; + size_t n = frames < fit ? frames : fit; + if (n) { + audio_conv_write(s_spotify_conv, pcm, n, 2, 44100); + return n; + } + } + if (esp_timer_get_time() >= until) { + return 0; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } } static void spotify_event(spotify_event_t ev, int value) @@ -140,9 +246,11 @@ static void spotify_event(spotify_event_t ev, int value) switch (ev) { case SPOTIFY_EV_PLAY: s_paused = false; + s_sp_playing = true; break; case SPOTIFY_EV_PAUSE: s_paused = true; + s_sp_playing = false; break; case SPOTIFY_EV_FLUSH: if (s_src == PLAYER_SRC_SPOTIFY) { @@ -160,6 +268,43 @@ static void spotify_event(spotify_event_t ev, int value) } } +/* ----- failover (mode auto) ----- */ + +// Spotify stays advertised; HLS plays whenever Spotify is inactive for failover_delay_s, and Spotify +// takes over as soon as it plays. HLS is stopped while Spotify plays (no bandwidth/CPU). +static void control_task(void *arg) +{ + int64_t inactive_since = esp_timer_get_time(); + while (true) { + vTaskDelay(pdMS_TO_TICKS(CONTROL_MS)); + if (s_mode != MODE_AUTO) { + inactive_since = esp_timer_get_time(); + continue; + } + bool session = spotify_session_active(); + if (!session) { + s_sp_playing = false; // session ended (app left, error, disabled) + } + bool playing = session && s_sp_playing; + // Inactive: no session; with failover_on_pause also a connected but paused/stopped one. + bool inactive = !session || (s_failover_on_pause && !playing); + int64_t now = esp_timer_get_time(); + if (!inactive) { + inactive_since = now; + } + if (playing && s_target != PLAYER_SRC_SPOTIFY) { + ESP_LOGI(TAG, "auto: Spotify is playing, switching to it"); + hls_suspend(HLS_SUSPEND_FAILOVER, true); + select_source(PLAYER_SRC_SPOTIFY); + } else if (inactive && s_target == PLAYER_SRC_SPOTIFY && + now - inactive_since >= (int64_t)s_failover_delay_s * 1000000) { + ESP_LOGI(TAG, "auto: Spotify inactive for %d s, failing over to HLS", s_failover_delay_s); + hls_suspend(HLS_SUSPEND_FAILOVER, false); + select_source(PLAYER_SRC_HLS); + } + } +} + // Firmware upload: flash writes stall tasks for > 20 ms at a time; with a Spotify session running, // uploads crawled and sometimes ended in a reset (pausing was not enough). End the session (its // tasks and connections are gone before the first write) and stop HLS fetching meanwhile. The @@ -169,10 +314,10 @@ static void on_ota(bool starting) { if (starting) { spotify_suspend(true); - hls_suspend(true); + hls_suspend(HLS_SUSPEND_OTA, true); ESP_LOGI(TAG, "firmware update: Spotify session ended, HLS suspended"); } else { - hls_suspend(false); + hls_suspend(HLS_SUSPEND_OTA, false); spotify_suspend(false); ESP_LOGI(TAG, "firmware update failed: sources resumed"); } @@ -180,9 +325,11 @@ static void on_ota(bool starting) static void player_status(cJSON *st) { - const char *state = s_src == PLAYER_SRC_TONE ? "playing" : s_src == PLAYER_SRC_OFF ? "idle" : - s_src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering"; - cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]); + player_src_t src = s_src; + const char *state = src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" : + src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" : + src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering"; + cJSON_AddStringToObject(st, "active_source", SRC_NAME[src]); cJSON_AddStringToObject(st, "source_state", state); cJSON_AddStringToObject(st, "spotify_state", spotify_state()); cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); @@ -195,8 +342,9 @@ esp_err_t player_init(void) ESP_LOGE(TAG, "ring buffer: %s", esp_err_to_name(err)); return err; } + aes67_tx_set_source(player_read); cJSON *src = cfg_get("source"); - s_src = (player_src_t)-1; + s_mode = (mode_t_)-1; player_apply(src); cJSON_Delete(src); err = decoder_init(); @@ -209,9 +357,12 @@ esp_err_t player_init(void) } spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos); aes67_ota_on_update(on_ota); - err = hls_start(decoder_feed); + err = hls_start(decoder_feed); if (err != ESP_OK) { return err; } + if (xTaskCreate(control_task, "player_ctl", 3072, NULL, 4, NULL) != pdPASS) { + return ESP_ERR_NO_MEM; + } return status_register(player_status); }