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>
This commit is contained in:
+179
-28
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user