#include "player.h" #include #include #include "aes67_cfg.h" #include "aes67_ota.h" #include "aes67_tx.h" #include "aes67_web.h" #include "audio_out.h" #include "audio_ring.h" #include "decoder.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 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 static volatile int s_volume_pct = 100; // 0..100 % -> amplitude: cubic curve (about -18 dB at 50 %, 0 % = mute). static void set_volume(int pct) { pct = pct < 0 ? 0 : pct > 100 ? 100 : pct; float r = pct / 100.0f; s_volume_pct = pct; s_target_gain = r * r * r; } // Apply the volume, ramping linearly from the previous gain over this buffer (no zipper noise). static void apply_gain(int32_t *buf, size_t frames) { float target = s_target_gain; if (s_gain == target && target == 1.0f) { return; } float step = (target - s_gain) / frames; for (size_t i = 0; i < frames; i++) { s_gain += step; buf[i * 2] = (int32_t)(buf[i * 2] * s_gain); buf[i * 2 + 1] = (int32_t)(buf[i * 2 + 1] * s_gain); } s_gain = target; } // 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) { 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); } } 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(); s_flush = false; s_playing = false; } if (s_src == PLAYER_SRC_OFF || (s_src == PLAYER_SRC_SPOTIFY && s_paused)) { memset(buf, 0, frames * CHANNELS * sizeof(int32_t)); return frames; } if (!s_playing) { if (audio_ring_level() < PREFILL_FRAMES) { memset(buf, 0, frames * CHANNELS * sizeof(int32_t)); return frames; } s_playing = true; s_fade_in = FADE_FRAMES; // (re)start without a click } size_t got = audio_ring_read(buf, frames); 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(); } 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_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 // 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) { 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) { vTaskDelay(pdMS_TO_TICKS(20)); } done += w; } return done; } /* ----- 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) { 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) { 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) { s_flush = true; // skip/seek: don't play out the old buffer } break; case SPOTIFY_EV_VOLUME: { // Spotify sends 0..65535; the app's slider stays the master of the player volume. int pct = (value * 100 + 32767) / 65535; set_volume(pct); float g = s_target_gain; ESP_LOGI(TAG, "volume %d%% (%.1f dB)", pct, g > 0 ? 20.0f * log10f(g) : -INFINITY); break; } } } /* ----- 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 // device reboots after a good upload anyway; after a failed one the sources come back (Spotify // waits for the app to reconnect). static void on_ota(bool starting) { if (starting) { spotify_suspend(true); hls_suspend(HLS_SUSPEND_OTA, true); ESP_LOGI(TAG, "firmware update: Spotify session ended, HLS suspended"); } else { hls_suspend(HLS_SUSPEND_OTA, false); spotify_suspend(false); ESP_LOGI(TAG, "firmware update failed: sources resumed"); } } static void player_status(cJSON *st) { 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)); } esp_err_t player_init(void) { esp_err_t err = audio_ring_init(RING_FRAMES, CHANNELS); if (err != ESP_OK) { 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_mode = (mode_t_)-1; player_apply(src); cJSON_Delete(src); err = decoder_init(); if (err != ESP_OK) { return err; } s_spotify_conv = audio_conv_create(PLAYER_SRC_SPOTIFY, "spotify"); if (!s_spotify_conv) { return ESP_ERR_NO_MEM; } spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos); aes67_ota_on_update(on_ota); 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); }