f0fe53d5d1
- OTA: the player now ends the Spotify session (spotify_suspend: stop flag, waits until the session's tasks are gone, refuses new ones) instead of pausing it, and suspends HLS. Pausing was not enough (an upload still failed once with a paused session). Verified twice with a session running for minutes: "Disconnecting mercury session / session ended / suspended", installed in ~26 s, clean reboot. - Volume: createFromBlob() starts cspot at volume 0, which the app showed while we played at 100 %. The session now starts with the player's volume (spotify_set_volume); volumes coming from the app are stored exactly, without echo. Verified: slider starts at 100 %. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
7.1 KiB
C
218 lines
7.1 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 "hls.h"
|
|
#include "spotify.h"
|
|
#include "esp_log.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.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
|
|
|
|
static const char *TAG = "player";
|
|
|
|
static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" };
|
|
|
|
static volatile player_src_t s_src = PLAYER_SRC_OFF;
|
|
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 audio_conv_t *s_spotify_conv;
|
|
// 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;
|
|
}
|
|
|
|
static player_src_t mode_of(const char *m)
|
|
{
|
|
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;
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
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) {
|
|
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]);
|
|
}
|
|
|
|
// Source side: write converted 48 kHz frames, waiting for space at playback speed.
|
|
// Frames of a source that is not active are dropped.
|
|
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
|
|
}
|
|
size_t w = audio_ring_write(frames + done * CHANNELS, n - done);
|
|
if (!w) {
|
|
vTaskDelay(pdMS_TO_TICKS(20));
|
|
}
|
|
done += w;
|
|
}
|
|
return done;
|
|
}
|
|
|
|
/* ----- Spotify ----- */
|
|
|
|
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;
|
|
}
|
|
|
|
static void spotify_event(spotify_event_t ev, int value)
|
|
{
|
|
switch (ev) {
|
|
case SPOTIFY_EV_PLAY:
|
|
s_paused = false;
|
|
break;
|
|
case SPOTIFY_EV_PAUSE:
|
|
s_paused = true;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(true);
|
|
ESP_LOGI(TAG, "firmware update: Spotify session ended, HLS suspended");
|
|
} else {
|
|
hls_suspend(false);
|
|
spotify_suspend(false);
|
|
ESP_LOGI(TAG, "firmware update failed: sources resumed");
|
|
}
|
|
}
|
|
|
|
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]);
|
|
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;
|
|
}
|
|
cJSON *src = cfg_get("source");
|
|
s_src = (player_src_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);
|
|
aes67_ota_on_update(on_ota);
|
|
err = hls_start(decoder_feed);
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
return status_register(player_status);
|
|
}
|