Spotify: tell cspot when a new track becomes audible
The Mac dropped the device soon after the first track change: we never
called SpircHandler::notifyAudioReachedPlayback(), so cspot's queue did
not advance and the state sent to Spotify stalled.
- The data callback records a boundary (output write position, track
id) when the track id changes; the session loop (<= 200 ms) calls
notifyAudioReachedPlayback(id) once playback (ring read position)
passes it, and notifyAudioEnded() after DEPLETED once the buffer is
empty. PLAYBACK_START clears boundaries (next data is a new one),
seek/flush drop pending ones.
- audio_ring exposes its write/read frame counters (wrap-safe compare).
- Verified: 10 min on the Mac without a disconnect, every track change
notified ("track audible, Spotify notified"), app shows the playing
track (switches up to ~3-5 s early), 0 underruns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,13 @@ typedef enum {
|
|||||||
// PCM from Spotify: 44.1 kHz stereo s16 interleaved. Return the frames taken (may block).
|
// PCM from Spotify: 44.1 kHz stereo s16 interleaved. Return the frames taken (may block).
|
||||||
typedef size_t (*spotify_pcm_cb_t)(const int16_t *pcm, size_t frames);
|
typedef size_t (*spotify_pcm_cb_t)(const int16_t *pcm, size_t frames);
|
||||||
typedef void (*spotify_event_cb_t)(spotify_event_t ev, int value);
|
typedef void (*spotify_event_cb_t)(spotify_event_t ev, int value);
|
||||||
|
// Output positions in frames (monotonic, wrap at 2^32): written into the output buffer so far, and
|
||||||
|
// played out so far. Used to tell Spotify when a new track actually becomes audible.
|
||||||
|
typedef uint32_t (*spotify_pos_cb_t)(void);
|
||||||
|
|
||||||
// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config.
|
// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config.
|
||||||
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event);
|
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_pos_cb_t written,
|
||||||
|
spotify_pos_cb_t played);
|
||||||
// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and
|
// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and
|
||||||
// the client credentials are set; otherwise not advertised and a running session is ended.
|
// the client credentials are set; otherwise not advertised and a running session is ended.
|
||||||
// A new device name ends the session and re-advertises.
|
// A new device name ends the session and re-advertises.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
|
#include <deque>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -40,6 +41,54 @@ static std::atomic<uint64_t> s_pcm_bytes{0};
|
|||||||
static std::shared_ptr<cspot::SpircHandler> s_handler; // running session, else null
|
static std::shared_ptr<cspot::SpircHandler> s_handler; // running session, else null
|
||||||
static spotify_pcm_cb_t s_pcm_cb;
|
static spotify_pcm_cb_t s_pcm_cb;
|
||||||
static spotify_event_cb_t s_event_cb;
|
static spotify_event_cb_t s_event_cb;
|
||||||
|
static spotify_pos_cb_t s_written, s_played;
|
||||||
|
|
||||||
|
// Track boundaries in the output buffer: when playback passes one, the new track is audible and
|
||||||
|
// Spotify must be told (notifyAudioReachedPlayback); without it the app's state stalls after the
|
||||||
|
// first track and it drops the device. Guarded by s_bound_mutex (data callback vs session loop).
|
||||||
|
struct Boundary {
|
||||||
|
uint32_t pos;
|
||||||
|
std::string id;
|
||||||
|
};
|
||||||
|
static std::mutex s_bound_mutex;
|
||||||
|
static std::deque<Boundary> s_bounds;
|
||||||
|
static std::string s_last_id; // track of the most recent data callback
|
||||||
|
static std::atomic<bool> s_depleted{false};
|
||||||
|
|
||||||
|
static void clear_boundaries(bool new_load)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_bound_mutex);
|
||||||
|
s_bounds.clear();
|
||||||
|
if (new_load) {
|
||||||
|
s_last_id.clear(); // the next data (new track) marks a boundary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session loop, every <= 200 ms.
|
||||||
|
static void check_playback(const std::shared_ptr<cspot::SpircHandler> &handler)
|
||||||
|
{
|
||||||
|
if (!s_written || !s_played) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint32_t played = s_played();
|
||||||
|
std::string reached;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_bound_mutex);
|
||||||
|
while (!s_bounds.empty() && (int32_t)(played - s_bounds.front().pos) >= 0) {
|
||||||
|
reached = s_bounds.front().id; // latest boundary passed wins
|
||||||
|
s_bounds.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reached.empty()) {
|
||||||
|
handler->notifyAudioReachedPlayback(reached);
|
||||||
|
ESP_LOGI(TAG, "track audible, Spotify notified");
|
||||||
|
}
|
||||||
|
if (s_depleted && played == s_written()) {
|
||||||
|
s_depleted = false;
|
||||||
|
handler->notifyAudioEnded();
|
||||||
|
ESP_LOGI(TAG, "playlist ended, Spotify notified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" void spotify_suspend(bool suspend)
|
extern "C" void spotify_suspend(bool suspend)
|
||||||
{
|
{
|
||||||
@@ -251,6 +300,14 @@ static void session(void)
|
|||||||
// PCM (44.1 kHz stereo s16) to the player; returning fewer bytes makes cspot retry the rest,
|
// PCM (44.1 kHz stereo s16) to the player; returning fewer bytes makes cspot retry the rest,
|
||||||
// so the player's blocking write paces decoding to playback speed.
|
// so the player's blocking write paces decoding to playback speed.
|
||||||
handler->getTrackPlayer()->setDataCallback([](uint8_t *data, size_t len, std::string_view id) -> size_t {
|
handler->getTrackPlayer()->setDataCallback([](uint8_t *data, size_t len, std::string_view id) -> size_t {
|
||||||
|
if (s_written) {
|
||||||
|
// First data of a new track: it becomes audible when playback reaches this position.
|
||||||
|
std::lock_guard<std::mutex> lock(s_bound_mutex);
|
||||||
|
if (id != s_last_id) {
|
||||||
|
s_last_id = std::string(id);
|
||||||
|
s_bounds.push_back({ s_written(), s_last_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
size_t frames = len / 4;
|
size_t frames = len / 4;
|
||||||
size_t taken = s_pcm_cb ? s_pcm_cb(reinterpret_cast<const int16_t *>(data), frames) : frames;
|
size_t taken = s_pcm_cb ? s_pcm_cb(reinterpret_cast<const int16_t *>(data), frames) : frames;
|
||||||
s_pcm_bytes += taken * 4;
|
s_pcm_bytes += taken * 4;
|
||||||
@@ -272,6 +329,10 @@ static void session(void)
|
|||||||
case E::FLUSH:
|
case E::FLUSH:
|
||||||
case E::SEEK:
|
case E::SEEK:
|
||||||
case E::PLAYBACK_START:
|
case E::PLAYBACK_START:
|
||||||
|
// The output buffer is dropped: pending boundaries are void. A new load (PLAYBACK_START)
|
||||||
|
// gets a new boundary with its first data; a seek stays within the same track.
|
||||||
|
clear_boundaries(ev->eventType == E::PLAYBACK_START);
|
||||||
|
s_depleted = false;
|
||||||
if (s_event_cb) {
|
if (s_event_cb) {
|
||||||
s_event_cb(SPOTIFY_EV_FLUSH, 0);
|
s_event_cb(SPOTIFY_EV_FLUSH, 0);
|
||||||
if (ev->eventType == E::PLAYBACK_START) {
|
if (ev->eventType == E::PLAYBACK_START) {
|
||||||
@@ -285,6 +346,9 @@ static void session(void)
|
|||||||
s_event_cb(SPOTIFY_EV_VOLUME, std::get<int>(ev->data));
|
s_event_cb(SPOTIFY_EV_VOLUME, std::get<int>(ev->data));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case E::DEPLETED:
|
||||||
|
s_depleted = true; // queue done: tell Spotify once the buffer has played out
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -293,8 +357,11 @@ static void session(void)
|
|||||||
s_handler = handler;
|
s_handler = handler;
|
||||||
s_state = "connected";
|
s_state = "connected";
|
||||||
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
||||||
|
clear_boundaries(true);
|
||||||
|
s_depleted = false;
|
||||||
while (!s_stop) {
|
while (!s_stop) {
|
||||||
ctx->session->handlePacket(); // waits at most 200 ms
|
ctx->session->handlePacket(); // waits at most 200 ms
|
||||||
|
check_playback(handler);
|
||||||
}
|
}
|
||||||
// Stops the queue and player tasks (waits for them), then closes the connection.
|
// Stops the queue and player tasks (waits for them), then closes the connection.
|
||||||
s_handler.reset();
|
s_handler.reset();
|
||||||
@@ -367,10 +434,13 @@ extern "C" void spotify_apply(void)
|
|||||||
s_enabled = true;
|
s_enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event)
|
extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_pos_cb_t written,
|
||||||
|
spotify_pos_cb_t played)
|
||||||
{
|
{
|
||||||
s_pcm_cb = pcm;
|
s_pcm_cb = pcm;
|
||||||
s_event_cb = event;
|
s_event_cb = event;
|
||||||
|
s_written = written;
|
||||||
|
s_played = played;
|
||||||
if (!bell::bellGlobalLogger) {
|
if (!bell::bellGlobalLogger) {
|
||||||
bell::bellGlobalLogger = new EspLogger();
|
bell::bellGlobalLogger = new EspLogger();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ size_t audio_ring_read(int32_t *frames, size_t n)
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t audio_ring_written(void)
|
||||||
|
{
|
||||||
|
return __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t audio_ring_read_pos(void)
|
||||||
|
{
|
||||||
|
return __atomic_load_n(&s_rd, __ATOMIC_ACQUIRE);
|
||||||
|
}
|
||||||
|
|
||||||
void audio_ring_flush(void)
|
void audio_ring_flush(void)
|
||||||
{
|
{
|
||||||
__atomic_store_n(&s_rd, __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE), __ATOMIC_RELEASE);
|
__atomic_store_n(&s_rd, __atomic_load_n(&s_wr, __ATOMIC_ACQUIRE), __ATOMIC_RELEASE);
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ size_t audio_ring_space(void); // frames that can be written
|
|||||||
size_t audio_ring_write(const int32_t *frames, size_t n); // producer; returns frames written
|
size_t audio_ring_write(const int32_t *frames, size_t n); // producer; returns frames written
|
||||||
size_t audio_ring_read(int32_t *frames, size_t n); // consumer; returns frames read
|
size_t audio_ring_read(int32_t *frames, size_t n); // consumer; returns frames read
|
||||||
void audio_ring_flush(void); // consumer side: drop everything buffered
|
void audio_ring_flush(void); // consumer side: drop everything buffered
|
||||||
|
// Monotonic frame counters (wrap at 2^32): frames written so far / read (played) so far.
|
||||||
|
uint32_t audio_ring_written(void);
|
||||||
|
uint32_t audio_ring_read_pos(void);
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@ esp_err_t player_init(void)
|
|||||||
if (!s_spotify_conv) {
|
if (!s_spotify_conv) {
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
spotify_init(spotify_pcm, spotify_event);
|
spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos);
|
||||||
aes67_ota_on_update(on_ota);
|
aes67_ota_on_update(on_ota);
|
||||||
err = hls_start(decoder_feed);
|
err = hls_start(decoder_feed);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
|
|||||||
Reference in New Issue
Block a user