diff --git a/CLAUDE.md b/CLAUDE.md index 42d4b82..87e69a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - 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: GET done (source, state, track, position as the app shows it, volume, can). Next: transport POSTs, seek/volume, source override/url. + - [ ] /api/player: GET and transport (play/pause/toggle/stop/next/prev) done. Next: seek/volume, source override/url. - [ ] 8. Mono sum, gain, polish. ## Phase 2 (parked) diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index 617d908..c8799a6 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -32,8 +32,10 @@ esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_p // the client credentials are set; otherwise not advertised and a running session is ended. // A new device name ends the session and re-advertises. void spotify_apply(void); -// Pause/resume the running session (the Spotify app follows). No-op without a session. -void spotify_pause(bool pause); +typedef enum { SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV } spotify_cmd_t; +// Control the running session (the Spotify app follows). Runs on the session task; waits until it +// has run (max 1 s). Returns false without a session. +bool spotify_command(spotify_cmd_t cmd); // Suspend: end any running session (waits until its tasks are gone, max ~3 s) and refuse new ones. // Resume: back to what the config says. Used around firmware uploads. void spotify_suspend(bool suspend); diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index 37ba088..d423b4c 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -56,6 +56,11 @@ static std::deque s_bounds; static std::string s_last_id; // track of the most recent data callback static std::atomic s_depleted{false}; +static std::mutex s_cmd_mutex; +static std::deque s_cmds; // from spotify_command(), run by the session loop +static uint32_t s_cmd_queued; // commands queued so far (s_cmd_mutex) +static std::atomic s_cmd_done{0}; // commands run so far + static std::mutex s_track_mutex; static cspot::TrackInfo s_track; // audible track (TRACK_INFO), guarded by s_track_mutex @@ -124,13 +129,51 @@ extern "C" void spotify_set_volume(int pct) } } -extern "C" void spotify_pause(bool pause) +extern "C" bool spotify_command(spotify_cmd_t cmd) { - auto h = s_handler; - if (h) { - h->setPause(pause); - ESP_LOGI(TAG, "%s", pause ? "paused" : "resumed"); + if (!s_handler) { + return false; } + uint32_t seq; + { + std::lock_guard lock(s_cmd_mutex); + s_cmds.push_back(cmd); + seq = ++s_cmd_queued; + } + // Wait until it has run (the loop runs every <= 200 ms), so callers can report the new state. + for (int i = 0; i < 50 && (int32_t)(s_cmd_done - seq) < 0 && s_handler; i++) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + return true; +} + +// Session loop: run queued commands on the task that also handles Spotify's frames. +static void run_commands(const std::shared_ptr &handler) +{ + std::deque cmds; + uint32_t seq; + { + std::lock_guard lock(s_cmd_mutex); + cmds.swap(s_cmds); + seq = s_cmd_queued; + } + for (auto cmd : cmds) { + switch (cmd) { + case SPOTIFY_CMD_PLAY: + case SPOTIFY_CMD_PAUSE: + handler->setPause(cmd == SPOTIFY_CMD_PAUSE); // notifies the app, sends PLAY_PAUSE + break; + case SPOTIFY_CMD_NEXT: + case SPOTIFY_CMD_PREV: + // The new track's load notifies the app and flushes our buffer (PLAYBACK_START). + if (!(cmd == SPOTIFY_CMD_NEXT ? handler->nextSong() : handler->previousSong())) { + ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous"); + } + break; + } + ESP_LOGI(TAG, "command %d", (int)cmd); + } + s_cmd_done = seq; } extern "C" bool spotify_session_active(void) @@ -394,8 +437,14 @@ static void session(void) ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str()); clear_boundaries(true); s_depleted = false; + { + std::lock_guard lock(s_cmd_mutex); + s_cmds.clear(); + s_cmd_done = s_cmd_queued; + } while (!s_stop) { ctx->session->handlePacket(); // waits at most 200 ms + run_commands(handler); check_playback(handler); } // Stops the queue and player tasks (waits for them), then closes the connection. diff --git a/docs/hardware-and-design-notes.md b/docs/hardware-and-design-notes.md index d3a57c4..b819555 100644 --- a/docs/hardware-and-design-notes.md +++ b/docs/hardware-and-design-notes.md @@ -42,6 +42,6 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44 - POST /api/player/seek {ms}; POST /api/player/volume {value} or {delta} - POST /api/player/source {source: spotify|hls|off|config}: runtime override, not saved; "config" returns to the configured mode - POST /api/player/url {url}: play an m3u8 now (runtime, not saved) -- On Spotify, commands go to cspot (the Spotify app stays in sync, including volume). On HLS (live): pause = stop fetching, resume = rejoin at live edge; next/prev/seek return 409 and `can` reflects that. +- On Spotify, commands go to cspot (the Spotify app stays in sync, including volume). stop = pause (the session stays). prev restarts the track after its first 3 s (cspot, like the apps). After next/prev the response can still show the old track; the new one follows within ~1 s. On HLS (live): pause/stop = stop fetching, resume = rejoin at live edge (a pause lasts until the next source switch); next/prev/seek return 409 and `can` reflects that. Tone/off, or Spotify without a session: transport returns 409. - Volume = runtime player volume; source.gain_db is a fixed trim on top. - No auth yet: LAN only. diff --git a/main/hls.h b/main/hls.h index be90560..78fc09e 100644 --- a/main/hls.h +++ b/main/hls.h @@ -15,4 +15,5 @@ esp_err_t hls_start(hls_sink_t sink); // fetching runs only when no reason holds it. #define HLS_SUSPEND_OTA (1 << 0) #define HLS_SUSPEND_FAILOVER (1 << 1) +#define HLS_SUSPEND_USER (1 << 2) // paused via the player API void hls_suspend(unsigned reason, bool suspend); diff --git a/main/player.c b/main/player.c index bb34f63..5f37a9f 100644 --- a/main/player.c +++ b/main/player.c @@ -37,6 +37,7 @@ 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) @@ -107,7 +108,8 @@ static size_t read_current(int32_t *buf, size_t frames) s_flush = false; s_playing = false; } - if (s_src == PLAYER_SRC_OFF || (s_src == PLAYER_SRC_SPOTIFY && s_paused)) { + 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; } @@ -158,6 +160,10 @@ static void select_source(player_src_t src) 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; @@ -198,6 +204,9 @@ 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 @@ -327,7 +336,8 @@ 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" : s_playing ? "playing" : "buffering"; + 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) @@ -373,7 +383,7 @@ static esp_err_t send_player(httpd_req_t *req) } cJSON_AddNumberToObject(o, "volume", s_volume_pct); cJSON *can = cJSON_AddObjectToObject(o, "can"); - cJSON_AddBoolToObject(can, "pause", sp); + cJSON_AddBoolToObject(can, "pause", sp || src == PLAYER_SRC_HLS); cJSON_AddBoolToObject(can, "next", sp); cJSON_AddBoolToObject(can, "prev", sp); cJSON_AddBoolToObject(can, "seek", sp); @@ -387,6 +397,69 @@ 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/ +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); @@ -416,10 +489,15 @@ esp_err_t player_init(void) if (xTaskCreate(control_task, "player_ctl", 3072, NULL, 4, NULL) != pdPASS) { return ESP_ERR_NO_MEM; } - static const httpd_uri_t get = { .uri = "/api/player", .method = HTTP_GET, .handler = player_get }; - err = web_register_uri(&get); - if (err != ESP_OK) { - return err; + 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); }