8485ecb5b7
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>
504 lines
18 KiB
C
504 lines
18 KiB
C
#include "player.h"
|
|
|
|
#include <math.h>
|
|
#include <string.h>
|
|
|
|
#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 volatile bool s_hls_paused; // HLS paused via the API: not fetching, silence
|
|
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) ||
|
|
(s_src == PLAYER_SRC_HLS && s_hls_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 (s_hls_paused) { // an API pause of HLS lasts until the next switch
|
|
s_hls_paused = false;
|
|
hls_suspend(HLS_SUSPEND_USER, false);
|
|
}
|
|
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 (src == PLAYER_SRC_HLS && s_hls_paused) {
|
|
return n; // paused: let the decoder finish the segment it is on
|
|
}
|
|
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 const char *source_state(player_src_t src)
|
|
{
|
|
return 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" :
|
|
src == PLAYER_SRC_HLS && s_hls_paused ? "paused" : s_playing ? "playing" : "buffering";
|
|
}
|
|
|
|
static void player_status(cJSON *st)
|
|
{
|
|
player_src_t src = s_src;
|
|
const char *state = source_state(src);
|
|
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));
|
|
}
|
|
|
|
/* ----- Player API ----- */
|
|
|
|
static void add_str_or_null(cJSON *o, const char *key, const char *s)
|
|
{
|
|
if (s && s[0]) {
|
|
cJSON_AddStringToObject(o, key, s);
|
|
} else {
|
|
cJSON_AddNullToObject(o, key);
|
|
}
|
|
}
|
|
|
|
// Same JSON for GET /api/player and every POST.
|
|
static esp_err_t send_player(httpd_req_t *req)
|
|
{
|
|
player_src_t src = s_src;
|
|
spotify_track_t t;
|
|
bool sp = src == PLAYER_SRC_SPOTIFY && spotify_now_playing(&t);
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "source", SRC_NAME[src]);
|
|
cJSON_AddNullToObject(o, "forced");
|
|
cJSON_AddStringToObject(o, "state", src == PLAYER_SRC_OFF ? "stopped" : source_state(src));
|
|
add_str_or_null(o, "artist", sp ? t.artist : NULL);
|
|
add_str_or_null(o, "title", sp ? t.title : NULL);
|
|
add_str_or_null(o, "album", sp ? t.album : NULL);
|
|
if (sp && t.duration_ms) {
|
|
cJSON_AddNumberToObject(o, "position_ms", t.position_ms);
|
|
cJSON_AddNumberToObject(o, "duration_ms", t.duration_ms);
|
|
} else {
|
|
cJSON_AddNullToObject(o, "position_ms");
|
|
cJSON_AddNullToObject(o, "duration_ms");
|
|
}
|
|
cJSON_AddNumberToObject(o, "volume", s_volume_pct);
|
|
cJSON *can = cJSON_AddObjectToObject(o, "can");
|
|
cJSON_AddBoolToObject(can, "pause", sp || src == PLAYER_SRC_HLS);
|
|
cJSON_AddBoolToObject(can, "next", sp);
|
|
cJSON_AddBoolToObject(can, "prev", sp);
|
|
cJSON_AddBoolToObject(can, "seek", sp);
|
|
esp_err_t err = web_send_json(req, o);
|
|
cJSON_Delete(o);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t player_get(httpd_req_t *req)
|
|
{
|
|
return send_player(req);
|
|
}
|
|
|
|
static void hls_pause(bool pause)
|
|
{
|
|
if (pause == s_hls_paused) {
|
|
return;
|
|
}
|
|
s_hls_paused = pause;
|
|
hls_suspend(HLS_SUSPEND_USER, pause);
|
|
s_flush = true; // live: resume rejoins at the live edge, never plays the old buffer
|
|
ESP_LOGI(TAG, "hls %s", pause ? "paused" : "resumed");
|
|
}
|
|
|
|
// Transport on the active source. Returns NULL, or the reason it can't be done (409).
|
|
static const char *transport(const char *cmd)
|
|
{
|
|
player_src_t src = s_src;
|
|
bool pause = !strcmp(cmd, "pause") || !strcmp(cmd, "stop");
|
|
bool play = !strcmp(cmd, "play");
|
|
bool next = !strcmp(cmd, "next"), prev = !strcmp(cmd, "prev");
|
|
if (!strcmp(cmd, "toggle")) {
|
|
bool paused = src == PLAYER_SRC_SPOTIFY ? s_paused : s_hls_paused;
|
|
pause = !paused;
|
|
play = paused;
|
|
}
|
|
if (src == PLAYER_SRC_SPOTIFY) {
|
|
spotify_cmd_t c = pause ? SPOTIFY_CMD_PAUSE : play ? SPOTIFY_CMD_PLAY :
|
|
next ? SPOTIFY_CMD_NEXT : SPOTIFY_CMD_PREV;
|
|
return spotify_command(c) ? NULL : "no Spotify session";
|
|
}
|
|
if (src == PLAYER_SRC_HLS) {
|
|
if (next || prev) {
|
|
return "live stream: no next/previous";
|
|
}
|
|
hls_pause(pause);
|
|
return NULL;
|
|
}
|
|
return "no player source active";
|
|
}
|
|
|
|
// POST /api/player/<cmd>
|
|
static esp_err_t player_post(httpd_req_t *req)
|
|
{
|
|
static const char *const TRANSPORT[] = { "play", "pause", "toggle", "stop", "next", "prev" };
|
|
const char *cmd = req->uri + strlen("/api/player/");
|
|
char name[16];
|
|
size_t len = strcspn(cmd, "?");
|
|
if (len >= sizeof(name)) {
|
|
return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command");
|
|
}
|
|
memcpy(name, cmd, len);
|
|
name[len] = '\0';
|
|
for (int i = 0; i < sizeof(TRANSPORT) / sizeof(TRANSPORT[0]); i++) {
|
|
if (!strcmp(name, TRANSPORT[i])) {
|
|
const char *err = transport(name);
|
|
if (err) {
|
|
httpd_resp_set_status(req, "409 Conflict");
|
|
return httpd_resp_sendstr(req, err);
|
|
}
|
|
return send_player(req);
|
|
}
|
|
}
|
|
return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command");
|
|
}
|
|
|
|
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;
|
|
}
|
|
static const httpd_uri_t uris[] = {
|
|
{ .uri = "/api/player", .method = HTTP_GET, .handler = player_get },
|
|
{ .uri = "/api/player/*", .method = HTTP_POST, .handler = player_post },
|
|
};
|
|
for (int i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) {
|
|
err = web_register_uri(&uris[i]);
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
}
|
|
return status_register(player_status);
|
|
}
|