Compare commits

...

5 Commits

Author SHA1 Message Date
bsncubed 4a38254660 Step 7.5b3b: Spotify audio on AES67
- spotify_init(pcm_cb, event_cb): cspot's data callback hands 44.1 kHz
  stereo s16 PCM to the player and returns what was taken; the player's
  blocking ring write paces decoding to playback speed.
- Events: FLUSH/SEEK/PLAYBACK_START empty the ring (skip/seek sound at
  once); PLAY_PAUSE pauses output (silence, buffer kept, no underrun);
  VOLUME logged (applied in b4).
- player: Spotify via its own audio_out converter (44.1 -> 48 kHz);
  source_state playing / paused / buffering.
- Verified from a Mac: music on the AES67 stream (10 s: 0 gaps, no
  silent packets, RMS -9.6 dBFS, peak 0.0 dBFS), buffer 4.0 s full,
  0 underruns, pause/play/skip/seek events.
  Seen: first 3 connects "Can't connect to spotify servers", 4th worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:41:49 +10:00
bsncubed 5b0d7c9ba4 Step 7.5b3a: per-source PCM converter (audio_out), shared by HLS and Spotify
- main/audio_out: one converter instance per source (own rate converter
  state): s16 any rate/channels -> 48 kHz stereo int32 -> ring.
- player_write(src, ...) only writes for the active source, drops the
  rest; player_src_t is public in player.h.
- HLS decoder uses audio_out (no behaviour change).
- Verified: HLS still sample exact (441344 -> 480375 frames per
  10.008 s segment), buffer ~4 s, 0 underruns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:34:15 +10:00
bsncubed c240ac9a3c Step 7.5b2: Spotify Connect zeroconf and login work (PCM counted only)
- Zeroconf on our httpd (GET/POST /spotify_info, form body URL-decoded)
  and _spotify-connect._tcp via our mDNS (VERSION, CPath, Stack TXT).
- Session task: waits for the app's login blob, connects with the
  user's client ID/secret and the configured bitrate (96/160/320 ->
  OGG_VORBIS_*), SpircHandler, events logged; cspot exceptions caught.
  Starts when source.mode is spotify/auto and credentials are set.
- bell::bellGlobalLogger was NULL: every CSPOT_LOG crashed the board
  right after addUser (Load access fault in Session.cpp:67 /
  LoginBlob.cpp:58). cspot/bell logs now go to esp_log (UART + syslog);
  signed CDN URL tokens are cut from the log.
- esp_audio_codec's own Vorbis decoder clashed with bell's Tremor
  symbols: CONFIG_AUDIO_DECODER_VORBIS_SUPPORT / SIMPLE_DEC_OGG off.
- status.spotify_state from the session (waiting / connecting /
  connected / login failed / error / disabled / no client credentials).
- Verified from a Mac: device appears, "connected as <user>", access
  token fetched with the user's client credentials, track info, audio
  key, CDN URL (the step broken upstream), PCM decoded. Without
  backpressure a track decodes in ~26 s (b3 feeds the ring).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:26:36 +10:00
bsncubed 039ec75473 Step 7.5b1: cspot (philippe44 fork) builds and links on the P4
- external/cspot: git submodule, pinned to philippe44/cspot 3010349
  (bell ed2d6e9); GPL-3.0.
- components/spotify: project component wrapping cspot. bell trimmed to
  what cspot needs (Tremor Vorbis; no codec wrapper, sinks, MQTT, web
  server, fmt, regex; cJSON from IDF). Xtensa biquad assembly filtered
  out (P4 is RISC-V). CMAKE_POLICY_VERSION_MINIMUM 3.5 for CMake 4.
- Patches in components/spotify/patches, applied at configure time:
  nanopb generator works with protobuf >= 5 (MakeClass removed);
  URLParser.cpp missing <cstdio>/<cstring> for GCC 14.
- CONFIG_COMPILER_CXX_EXCEPTIONS=y (cspot needs exceptions).
- spotify_init() only builds a LoginBlob (link check).
- CLAUDE.md: submodule init and IDF-venv Python packages for nanopb.
- Verified on board: "cspot linked: device "P4 AES67", zeroconf info 588
  bytes"; HLS still playing, PTP locked; image 1.83 MB (70% free).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:25:10 +10:00
bsncubed 7bbda0a1e8 Step 7.5a: Spotify client credentials in config/UI, write-only secrets
- Core config: cfg_mark_secret(group, key). GET /api/config returns ""
  for secret keys; a POST with "" keeps the stored value, null clears it;
  cfg_get() returns the real value. Documented in aes67-core-base.md.
- source.spotify_client_id / spotify_client_secret (secret write-only):
  each user's own Spotify developer app, needed by the maintained cspot
  fork (philippe44/cspot) since Spotify's 2025 API restrictions.
- UI: client ID field, password field for the secret ("leave empty to
  keep"), link to developer.spotify.com; enabled for spotify/auto modes.
- status.spotify_state: "no client credentials" while either is missing.
- Verified: secret never appears in GET; UI-style re-save keeps it;
  survives reboot; null clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:16:42 +10:00
21 changed files with 645 additions and 81 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "external/cspot"]
path = external/cspot
url = https://github.com/philippe44/cspot.git
+1
View File
@@ -27,6 +27,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4
- Our board: **32 MB** flash (GigaDevice c8/4019). App slots must stay below 16 MB (cache mapping above 16 MB is experimental in IDF). - Our board: **32 MB** flash (GigaDevice c8/4019). App slots must stay below 16 MB (cache mapping above 16 MB is experimental in IDF).
- Rev < 3 also limits Espressif's prebuilt audio libraries: `esp_audio_codec` must stay < 2.6 and `esp_audio_effects` < 1.4 (newer versions use P4 assembly that needs rev >= 3; the build fails with a message saying so). Check this for any new Espressif binary component. - Rev < 3 also limits Espressif's prebuilt audio libraries: `esp_audio_codec` must stay < 2.6 and `esp_audio_effects` < 1.4 (newer versions use P4 assembly that needs rev >= 3; the build fails with a message saying so). Check this for any new Espressif binary component.
- Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`. - Embed `web/index.html` via `EMBED_TXTFILES` in `aes67_web`.
- cspot (Spotify, `external/cspot` git submodule, philippe44 fork, pinned): after cloning run `git submodule update --init external/cspot && git -C external/cspot submodule update --init cspot/bell`. Its nanopb code generator needs, in the IDF Python env: `python -m pip install protobuf grpcio-tools 'setuptools<81'` (after `. export.sh`). Our fixes to cspot/bell live in `components/spotify/patches/{cspot,bell}/*.patch` and are applied automatically at configure time; don't edit the submodule directly, add a patch.
- Flash over the network (normal way since step 2b; keep USB for recovery): - Flash over the network (normal way since step 2b; keep USB for recovery):
`curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota` `curl -f --data-binary @build/aes67_p4.bin -H 'Content-Type: application/octet-stream' http://p4-aes67/api/ota`
The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`. The board reboots into the new image on trial; check `GET /api/ota` shows the new version with `pending_verify: false`.
+45 -2
View File
@@ -13,6 +13,7 @@
#include "nvs_flash.h" #include "nvs_flash.h"
#define MAX_GROUPS 12 #define MAX_GROUPS 12
#define MAX_SECRETS 4
#define MAX_BODY 8192 #define MAX_BODY 8192
#define NVS_NAMESPACE "cfg" #define NVS_NAMESPACE "cfg"
@@ -24,6 +25,8 @@ typedef struct {
cJSON *values; cJSON *values;
cfg_validate_cb_t validate; cfg_validate_cb_t validate;
cfg_apply_cb_t apply; cfg_apply_cb_t apply;
char secrets[MAX_SECRETS][24]; // write-only keys
int n_secrets;
} cfg_group_t; } cfg_group_t;
static cfg_group_t s_groups[MAX_GROUPS]; static cfg_group_t s_groups[MAX_GROUPS];
@@ -220,6 +223,35 @@ esp_err_t cfg_register(const char *group, const char *defaults_json,
return ESP_OK; return ESP_OK;
} }
esp_err_t cfg_mark_secret(const char *group, const char *key)
{
if (!s_lock) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
cfg_group_t *g = find(group);
esp_err_t err = ESP_OK;
if (!g || !cJSON_IsString(cJSON_GetObjectItemCaseSensitive(g->defaults, key))) {
err = ESP_ERR_NOT_FOUND;
} else if (g->n_secrets >= MAX_SECRETS || strlen(key) >= sizeof(g->secrets[0])) {
err = ESP_ERR_NO_MEM;
} else {
strcpy(g->secrets[g->n_secrets++], key);
}
xSemaphoreGive(s_lock);
return err;
}
// Group values for the API: secrets blanked.
static cJSON *public_values(const cfg_group_t *g)
{
cJSON *v = cJSON_Duplicate(g->values, true);
for (int i = 0; i < g->n_secrets; i++) {
cJSON_ReplaceItemInObjectCaseSensitive(v, g->secrets[i], cJSON_CreateString(""));
}
return v;
}
cJSON *cfg_get(const char *group) cJSON *cfg_get(const char *group)
{ {
if (!s_lock) { if (!s_lock) {
@@ -270,7 +302,7 @@ static esp_err_t config_get(httpd_req_t *req)
cJSON *all = cJSON_CreateObject(); cJSON *all = cJSON_CreateObject();
xSemaphoreTake(s_lock, portMAX_DELAY); xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < s_group_n; i++) { for (int i = 0; i < s_group_n; i++) {
cJSON_AddItemToObject(all, s_groups[i].name, cJSON_Duplicate(s_groups[i].values, true)); cJSON_AddItemToObject(all, s_groups[i].name, public_values(&s_groups[i]));
} }
xSemaphoreGive(s_lock); xSemaphoreGive(s_lock);
esp_err_t err = web_send_json(req, all); esp_err_t err = web_send_json(req, all);
@@ -331,7 +363,18 @@ static esp_err_t config_post(httpd_req_t *req)
break; break;
} }
cand[i] = cJSON_Duplicate(g->values, true); cand[i] = cJSON_Duplicate(g->values, true);
ok = merge_known(cand[i], g->defaults, src, g->name, err, sizeof(err)); // Secrets: "" = unchanged (the API never returns the stored value), null = clear.
cJSON *in_g = cJSON_Duplicate(src, true);
for (int k = 0; k < g->n_secrets; k++) {
const cJSON *sv = cJSON_GetObjectItemCaseSensitive(in_g, g->secrets[k]);
if (cJSON_IsString(sv) && !sv->valuestring[0]) {
cJSON_DeleteItemFromObjectCaseSensitive(in_g, g->secrets[k]);
} else if (cJSON_IsNull(sv)) {
cJSON_ReplaceItemInObjectCaseSensitive(in_g, g->secrets[k], cJSON_CreateString(""));
}
}
ok = merge_known(cand[i], g->defaults, in_g, g->name, err, sizeof(err));
cJSON_Delete(in_g);
if (ok && g->validate) { if (ok && g->validate) {
char verr[96] = ""; char verr[96] = "";
ok = g->validate(cand[i], verr, sizeof(verr)); ok = g->validate(cand[i], verr, sizeof(verr));
+3
View File
@@ -18,6 +18,9 @@ esp_err_t cfg_override_defaults(const char *group, const char *json);
// Register a group. Stored values (NVS) are loaded immediately, merged over the defaults. // Register a group. Stored values (NVS) are loaded immediately, merged over the defaults.
esp_err_t cfg_register(const char *group, const char *defaults_json, esp_err_t cfg_register(const char *group, const char *defaults_json,
cfg_validate_cb_t validate, cfg_apply_cb_t apply); cfg_validate_cb_t validate, cfg_apply_cb_t apply);
// Write-only key (e.g. a password): GET /api/config returns "" for it; a POST with "" keeps the
// stored value, null clears it. cfg_get() still returns the real value. Call after cfg_register().
esp_err_t cfg_mark_secret(const char *group, const char *key);
// Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered. // Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered.
cJSON *cfg_get(const char *group); cJSON *cfg_get(const char *group);
// Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation. // Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation.
+49
View File
@@ -0,0 +1,49 @@
# Spotify Connect via cspot (philippe44 fork, git submodule external/cspot). GPL-3.0.
# Project component (not part of the reusable aes67_* core).
idf_component_register(SRCS "spotify.cpp"
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web espressif__mdns mbedtls json pthread lwip esp_timer esp_http_server)
# bell: only what cspot needs (Tremor Vorbis inside; no codec wrapper, sinks, MQTT, web server).
set(BELL_DISABLE_CODECS ON CACHE BOOL "" FORCE)
set(BELL_DISABLE_SINKS ON CACHE BOOL "" FORCE)
set(BELL_DISABLE_MQTT ON CACHE BOOL "" FORCE)
set(BELL_DISABLE_WEBSERVER ON CACHE BOOL "" FORCE)
set(BELL_DISABLE_FMT ON CACHE BOOL "" FORCE)
set(BELL_DISABLE_REGEX ON CACHE BOOL "" FORCE)
set(BELL_ONLY_CJSON ON CACHE BOOL "" FORCE)
set(BELL_EXTERNAL_CJSON idf::json CACHE STRING "" FORCE)
option(BUILD_TESTING OFF)
# Our fixes to the pinned cspot / bell sources (newer GCC, protobuf, CMake), kept as patch files:
# patches/cspot/*.patch apply in external/cspot, patches/bell/*.patch in external/cspot/cspot/bell.
# Each is applied once (skipped when it already reverse-applies).
function(spotify_apply_patches dir repo)
file(GLOB patches "${CMAKE_CURRENT_LIST_DIR}/patches/${dir}/*.patch")
list(SORT patches)
foreach(p ${patches})
execute_process(COMMAND git apply --reverse --check "${p}" WORKING_DIRECTORY "${repo}"
RESULT_VARIABLE applied OUTPUT_QUIET ERROR_QUIET)
if(NOT applied EQUAL 0)
execute_process(COMMAND git apply "${p}" WORKING_DIRECTORY "${repo}" RESULT_VARIABLE rc)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "spotify: patch does not apply: ${p}")
endif()
message(STATUS "spotify: applied ${p}")
endif()
endforeach()
endfunction()
spotify_apply_patches(cspot "${PROJECT_DIR}/external/cspot")
spotify_apply_patches(bell "${PROJECT_DIR}/external/cspot/cspot/bell")
# cspot/bell declare cmake_minimum_required(2.8.12); CMake 4 rejects < 3.5 unless told otherwise.
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
add_subdirectory(${PROJECT_DIR}/external/cspot/cspot ${CMAKE_CURRENT_BINARY_DIR}/cspot)
# bell adds Xtensa (ESP32) assembly for a DSP biquad; the P4 is RISC-V and cspot does not use it.
get_target_property(BELL_SRCS bell SOURCES)
list(FILTER BELL_SRCS EXCLUDE REGEX "\\.S$")
set_target_properties(bell PROPERTIES SOURCES "${BELL_SRCS}")
target_link_libraries(${COMPONENT_LIB} PRIVATE cspot)
target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++20)
+32
View File
@@ -0,0 +1,32 @@
// Spotify Connect (cspot). Project code; GPL-3.0 through cspot.
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
SPOTIFY_EV_PLAY, // playback (re)started or resumed
SPOTIFY_EV_PAUSE,
SPOTIFY_EV_FLUSH, // skip, seek, new track: drop buffered audio
SPOTIFY_EV_VOLUME, // value: 0..65535
} spotify_event_t;
// 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 void (*spotify_event_cb_t)(spotify_event_t ev, int value);
// Registers the zeroconf endpoints (/spotify_info) and, when source.mode is spotify/auto and the
// client credentials are set, advertises the device and starts the session task.
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event);
// Current state for status.spotify_state.
const char *spotify_state(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,18 @@
diff --git a/external/nanopb/generator/nanopb_generator.py b/external/nanopb/generator/nanopb_generator.py
index e40f18b..64dad5e 100755
--- a/external/nanopb/generator/nanopb_generator.py
+++ b/external/nanopb/generator/nanopb_generator.py
@@ -1463,7 +1463,12 @@ class Message(ProtoElement):
optional_only.ClearField(str('extension'))
optional_only.ClearField(str('enum_type'))
desc = google.protobuf.descriptor.MakeDescriptor(optional_only)
- msg = reflection.MakeClass(desc)()
+ # protobuf >= 5 removed reflection.MakeClass (patch for this project)
+ try:
+ msg = reflection.MakeClass(desc)()
+ except AttributeError:
+ from google.protobuf import message_factory
+ msg = message_factory.GetMessageClass(desc)()
for field in optional_only.field:
if field.type == FieldD.TYPE_STRING:
@@ -0,0 +1,13 @@
diff --git a/main/io/URLParser.cpp b/main/io/URLParser.cpp
index 4a50ab1..af679ec 100644
--- a/main/io/URLParser.cpp
+++ b/main/io/URLParser.cpp
@@ -1,5 +1,8 @@
#include "URLParser.h"
+#include <cstdio> // sscanf (GCC 14 / ESP-IDF 5.5: not included transitively)
+#include <cstring> // strstr
+
namespace bell {
#ifdef BELL_DISABLE_REGEX
+285
View File
@@ -0,0 +1,285 @@
#include "spotify.h"
#include <atomic>
#include <cstring>
#include <exception>
#include <map>
#include <memory>
#include <string>
#include "BellLogger.h"
#include "CSpotContext.h"
#include "LoginBlob.h"
#include "SpircHandler.h"
#include "TrackPlayer.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mdns.h"
extern "C" {
#include "aes67_cfg.h"
#include "aes67_web.h"
}
#define BODY_MAX 8192
static const char *TAG = "spotify";
static std::shared_ptr<cspot::LoginBlob> s_blob;
static std::atomic<bool> s_got_blob{false};
static std::atomic<const char *> s_state{"disabled"};
static std::atomic<uint64_t> s_pcm_bytes{0};
static spotify_pcm_cb_t s_pcm_cb;
static spotify_event_cb_t s_event_cb;
const char *spotify_state(void)
{
return s_state.load();
}
/* ----- cspot/bell logging -> esp_log (UART + syslog) ----- */
// cspot logs through bell::bellGlobalLogger; without one, CSPOT_LOG dereferences NULL.
class EspLogger : public bell::AbstractLogger {
static void out(esp_log_level_t lvl, const std::string &file, int line, const std::string &sub,
const char *fmt, va_list ap)
{
char msg[256];
vsnprintf(msg, sizeof(msg), fmt, ap);
// CDN URLs carry signed tokens: keep them out of the log (syslog may leave the box).
char *q = strstr(msg, "?__token__=");
if (q) {
strcpy(q, "?__token__=...");
}
size_t slash = file.find_last_of('/');
const char *base = file.c_str() + (slash == std::string::npos ? 0 : slash + 1);
ESP_LOG_LEVEL(lvl, sub.empty() ? "cspot" : sub.c_str(), "%s:%d: %s", base, line, msg);
}
public:
void debug(std::string file, int line, std::string sub, const char *fmt, ...) override
{
va_list ap;
va_start(ap, fmt);
out(ESP_LOG_DEBUG, file, line, sub, fmt, ap);
va_end(ap);
}
void info(std::string file, int line, std::string sub, const char *fmt, ...) override
{
va_list ap;
va_start(ap, fmt);
out(ESP_LOG_INFO, file, line, sub, fmt, ap);
va_end(ap);
}
void error(std::string file, int line, std::string sub, const char *fmt, ...) override
{
va_list ap;
va_start(ap, fmt);
out(ESP_LOG_ERROR, file, line, sub, fmt, ap);
va_end(ap);
}
};
/* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */
static esp_err_t info_get(httpd_req_t *req)
{
std::string json = s_blob->buildZeroconfInfo();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.data(), json.size());
}
static std::string url_decode(const char *s, size_t n)
{
std::string out;
for (size_t i = 0; i < n; i++) {
if (s[i] == '+') {
out += ' ';
} else if (s[i] == '%' && i + 2 < n && isxdigit((unsigned char)s[i + 1]) && isxdigit((unsigned char)s[i + 2])) {
char hex[3] = { s[i + 1], s[i + 2], 0 };
out += (char)strtol(hex, nullptr, 16);
i += 2;
} else {
out += s[i];
}
}
return out;
}
// The Spotify app posts its login blob (action=addUser&userName=...&blob=...&clientKey=...).
static esp_err_t info_post(httpd_req_t *req)
{
if (req->content_len == 0 || req->content_len > BODY_MAX) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad body");
}
std::string body(req->content_len, '\0');
size_t got = 0;
while (got < body.size()) {
int r = httpd_req_recv(req, body.data() + got, body.size() - got);
if (r <= 0) {
return ESP_FAIL;
}
got += r;
}
std::map<std::string, std::string> query;
size_t pos = 0;
while (pos < body.size()) {
size_t amp = body.find('&', pos), end = amp == std::string::npos ? body.size() : amp;
size_t eq = body.find('=', pos);
if (eq != std::string::npos && eq < end) {
query[url_decode(&body[pos], eq - pos)] = url_decode(&body[eq + 1], end - eq - 1);
}
pos = end + 1;
}
ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str());
s_blob->loadZeroconfQuery(query);
s_got_blob = true;
static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}";
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, ok, sizeof(ok) - 1);
}
/* ----- session ----- */
static const char *event_name(cspot::SpircHandler::EventType t)
{
using E = cspot::SpircHandler::EventType;
switch (t) {
case E::PLAY_PAUSE: return "PLAY_PAUSE";
case E::VOLUME: return "VOLUME";
case E::TRACK_INFO: return "TRACK_INFO";
case E::DISC: return "DISC";
case E::NEXT: return "NEXT";
case E::PREV: return "PREV";
case E::SEEK: return "SEEK";
case E::DEPLETED: return "DEPLETED";
case E::FLUSH: return "FLUSH";
case E::PLAYBACK_START: return "PLAYBACK_START";
}
return "?";
}
static void session(void)
{
cJSON *src = cfg_get("source");
std::string client_id = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring;
std::string client_secret = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring;
int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble;
cJSON_Delete(src);
auto ctx = cspot::Context::createFromBlob(s_blob);
ctx->config.clientId = client_id;
ctx->config.clientSecret = client_secret;
ctx->config.audioFormat = bitrate >= 320 ? AudioFormat_OGG_VORBIS_320
: bitrate >= 160 ? AudioFormat_OGG_VORBIS_160 : AudioFormat_OGG_VORBIS_96;
s_state = "connecting";
ctx->session->connectWithRandomAp();
auto token = ctx->session->authenticate(s_blob);
if (token.empty()) {
s_state = "login failed";
ESP_LOGE(TAG, "login failed (check the client ID/secret and that the account is Premium)");
return;
}
ctx->session->startTask();
auto handler = std::make_shared<cspot::SpircHandler>(ctx);
handler->subscribeToMercury();
// 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.
handler->getTrackPlayer()->setDataCallback([](uint8_t *data, size_t len, std::string_view id) -> size_t {
size_t frames = len / 4;
size_t taken = s_pcm_cb ? s_pcm_cb(reinterpret_cast<const int16_t *>(data), frames) : frames;
s_pcm_bytes += taken * 4;
return taken * 4;
});
handler->setEventHandler([](std::unique_ptr<cspot::SpircHandler::Event> ev) {
using E = cspot::SpircHandler::EventType;
switch (ev->eventType) {
case E::TRACK_INFO: {
auto &ti = std::get<cspot::TrackInfo>(ev->data);
ESP_LOGI(TAG, "track: %s - %s (%lu ms)", ti.artist.c_str(), ti.name.c_str(), (unsigned long)ti.duration);
return;
}
case E::PLAY_PAUSE:
if (s_event_cb) {
s_event_cb(std::get<bool>(ev->data) ? SPOTIFY_EV_PAUSE : SPOTIFY_EV_PLAY, 0);
}
break;
case E::FLUSH:
case E::SEEK:
case E::PLAYBACK_START:
if (s_event_cb) {
s_event_cb(SPOTIFY_EV_FLUSH, 0);
if (ev->eventType == E::PLAYBACK_START) {
s_event_cb(SPOTIFY_EV_PLAY, 0);
}
}
break;
case E::VOLUME:
if (s_event_cb) {
s_event_cb(SPOTIFY_EV_VOLUME, std::get<int>(ev->data));
}
break;
default:
break;
}
ESP_LOGI(TAG, "event %s", event_name(ev->eventType));
});
s_state = "connected";
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
while (true) {
ctx->session->handlePacket();
}
}
static void spotify_task(void *arg)
{
while (true) {
s_state = "waiting for Spotify app";
while (!s_got_blob) {
vTaskDelay(pdMS_TO_TICKS(500));
}
s_got_blob = false;
try {
session();
} catch (const std::exception &e) {
s_state = "error";
ESP_LOGE(TAG, "session ended: %s", e.what());
}
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event)
{
s_pcm_cb = pcm;
s_event_cb = event;
if (!bell::bellGlobalLogger) {
bell::bellGlobalLogger = new EspLogger();
}
cJSON *src = cfg_get("source");
std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring;
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
cJSON_Delete(src);
if (mode != "spotify" && mode != "auto") {
s_state = "disabled (source mode)";
return ESP_OK;
}
if (!creds) {
s_state = "no client credentials";
ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)");
return ESP_OK;
}
s_blob = std::make_shared<cspot::LoginBlob>(name);
static const httpd_uri_t get = { .uri = "/spotify_info", .method = HTTP_GET, .handler = info_get, .user_ctx = nullptr };
static const httpd_uri_t post = { .uri = "/spotify_info", .method = HTTP_POST, .handler = info_post, .user_ctx = nullptr };
web_register_uri(&get);
web_register_uri(&post);
mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } };
esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3);
ESP_LOGI(TAG, "Spotify Connect \"%s\" advertised (%s)", name.c_str(), esp_err_to_name(err));
return xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
}
+1
View File
@@ -25,6 +25,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
## Core API ## Core API
- GET/POST /api/config (full JSON; POST validates, stores to NVS, applies live where possible, returns 4xx with message on bad values) - GET/POST /api/config (full JSON; POST validates, stores to NVS, applies live where possible, returns 4xx with message on bad values)
- Write-only keys (`cfg_mark_secret`, e.g. passwords/client secrets): GET returns "" for them; a POST with "" keeps the stored value, null clears it. Stored in NVS in plain text (NVS encryption is not enabled).
- GET /api/status -> {model?, fw?, power?, ip, aes67_ip, inet_ip, inet_vlan, mac, link, tx_packets, underruns, uptime_s, heap_free, psram_free, temps:[…], ptp:{…}, …project fields} - GET /api/status -> {model?, fw?, power?, ip, aes67_ip, inet_ip, inet_vlan, mac, link, tx_packets, underruns, uptime_s, heap_free, psram_free, temps:[…], ptp:{…}, …project fields}
- model, fw, power are optional; rows only appear when present (power e.g. "PoE" / "USB" if the board can detect it). - model, fw, power are optional; rows only appear when present (power e.g. "PoE" / "USB" if the board can detect it).
- GET /stream.sdp, POST /api/reboot, POST /api/log/test - GET /stream.sdp, POST /api/reboot, POST /api/log/test
+2 -1
View File
@@ -30,7 +30,8 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44
- Firmware needs Spotify events: cspot connect, disconnect, play, pause. - Firmware needs Spotify events: cspot connect, disconnect, play, pause.
## Project config group ## Project config group
- source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause} - source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause, spotify_client_id, spotify_client_secret}
- spotify_client_id / spotify_client_secret: each user's own Spotify developer app (developer.spotify.com, Premium account), needed by the maintained cspot fork (philippe44/cspot) since Spotify's 2025 API restrictions. The secret is write-only (never returned by GET /api/config). spotify_state reports "no client credentials" while either is missing.
- Project status fields: active_source, source_state, spotify_state, buffer_ms - Project status fields: active_source, source_state, spotify_state, buffer_ms
- mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence. - mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence.
Vendored Submodule
+1
Submodule external/cspot added at 3010349b46
+2 -2
View File
@@ -1,5 +1,5 @@
idf_component_register(SRCS "main.c" "project_cfg.c" "player.c" "audio_ring.c" "hls.c" "decoder.c" idf_component_register(SRCS "main.c" "project_cfg.c" "player.c" "audio_ring.c" "hls.c" "decoder.c" "audio_out.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_app_format esp_hw_support heap esp_http_client mbedtls esp_timer REQUIRES esp_app_format esp_hw_support heap esp_http_client mbedtls esp_timer
aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap
aes67_syslog aes67_tx aes67_web) aes67_syslog aes67_tx aes67_web spotify)
+90
View File
@@ -0,0 +1,90 @@
#include "audio_out.h"
#include <stdlib.h>
#include "esp_ae_rate_cvt.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#define OUT_RATE 48000
#define CONV_FRAMES 4096 // per rate converter call
static const char *TAG = "audio_out";
struct audio_conv {
player_src_t src;
const char *name;
esp_ae_rate_cvt_handle_t cvt; // NULL: no conversion (source is 48 kHz)
uint32_t rate; // input rate the converter was opened for
int32_t *in32, *out32; // stereo int32 work buffers (PSRAM)
};
audio_conv_t *audio_conv_create(player_src_t src, const char *name)
{
audio_conv_t *c = calloc(1, sizeof(*c));
if (!c) {
return NULL;
}
c->src = src;
c->name = name;
c->in32 = heap_caps_malloc(CONV_FRAMES * 2 * sizeof(int32_t), MALLOC_CAP_SPIRAM);
c->out32 = heap_caps_malloc(CONV_FRAMES * 2 * 2 * sizeof(int32_t), MALLOC_CAP_SPIRAM);
if (!c->in32 || !c->out32) {
free(c->in32);
free(c->out32);
free(c);
return NULL;
}
return c;
}
static void set_rate(audio_conv_t *c, uint32_t rate)
{
if (c->cvt) {
esp_ae_rate_cvt_close(c->cvt);
c->cvt = NULL;
}
c->rate = rate;
if (rate == OUT_RATE) {
return;
}
esp_ae_rate_cvt_cfg_t cfg = {
.src_rate = rate, .dest_rate = OUT_RATE, .channel = 2, .bits_per_sample = 32,
.complexity = 3, .perf_type = ESP_AE_RATE_CVT_PERF_TYPE_SPEED,
};
if (esp_ae_rate_cvt_open(&cfg, &c->cvt) != ESP_AE_ERR_OK) {
ESP_LOGE(TAG, "%s: rate converter %lu -> %d Hz: open failed", c->name, (unsigned long)rate, OUT_RATE);
} else {
ESP_LOGI(TAG, "%s: rate converter %lu -> %d Hz", c->name, (unsigned long)rate, OUT_RATE);
}
}
size_t audio_conv_write(audio_conv_t *c, const int16_t *pcm, uint32_t frames, int ch, uint32_t rate)
{
if (rate != c->rate) {
set_rate(c, rate);
}
size_t produced = 0;
while (frames) {
uint32_t n = frames < CONV_FRAMES ? frames : CONV_FRAMES;
for (uint32_t i = 0; i < n; i++) { // to stereo int32 (full scale = INT32_MAX)
int32_t l = (int32_t)pcm[i * ch] << 16;
c->in32[i * 2] = l;
c->in32[i * 2 + 1] = ch > 1 ? (int32_t)pcm[i * ch + 1] << 16 : l;
}
const int32_t *out = c->in32;
uint32_t out_n = n;
if (c->cvt) {
out_n = CONV_FRAMES * 2;
if (esp_ae_rate_cvt_process(c->cvt, c->in32, n, c->out32, &out_n) != ESP_AE_ERR_OK) {
ESP_LOGW(TAG, "%s: rate conversion failed", c->name);
return produced;
}
out = c->out32;
}
produced += player_write(c->src, out, out_n);
pcm += n * ch;
frames -= n;
}
return produced;
}
+15
View File
@@ -0,0 +1,15 @@
// PCM from a source (s16, any rate/channels) -> 48 kHz stereo int32 -> player ring.
// One converter per source: each keeps its own resampler state.
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "player.h"
typedef struct audio_conv audio_conv_t;
audio_conv_t *audio_conv_create(player_src_t src, const char *name);
// Blocks while the ring is full; dropped when src is not the active source.
// Returns the 48 kHz frames produced.
size_t audio_conv_write(audio_conv_t *c, const int16_t *pcm, uint32_t frames, int channels, uint32_t rate);
+6 -55
View File
@@ -6,19 +6,15 @@
#include "esp_audio_dec_default.h" #include "esp_audio_dec_default.h"
#include "esp_audio_simple_dec.h" #include "esp_audio_simple_dec.h"
#include "esp_audio_simple_dec_default.h" #include "esp_audio_simple_dec_default.h"
#include "esp_ae_rate_cvt.h" #include "audio_out.h"
#include "player.h"
#include "esp_heap_caps.h" #include "esp_heap_caps.h"
#include "esp_log.h" #include "esp_log.h"
static const char *TAG = "decoder"; static const char *TAG = "decoder";
#define OUT_RATE 48000 #define OUT_RATE 48000
#define CONV_FRAMES 4096 // per rate converter call
static esp_ae_rate_cvt_handle_t s_cvt; // NULL: no conversion (source is 48 kHz) static audio_conv_t *s_conv; // HLS: s16 -> 48 kHz -> ring
static uint32_t s_cvt_rate; // input rate the converter was opened for
static int32_t *s_in32, *s_out32; // stereo int32 work buffers (PSRAM)
static uint64_t s_seg_out; // 48 kHz frames written this segment static uint64_t s_seg_out; // 48 kHz frames written this segment
static esp_audio_simple_dec_handle_t s_dec; static esp_audio_simple_dec_handle_t s_dec;
@@ -45,51 +41,6 @@ static void report_segment(void)
s_seg_count++; s_seg_count++;
} }
// Decoded PCM (s16, any channel count) -> stereo int32 -> 48 kHz -> ring.
static void output_pcm(const int16_t *pcm, uint32_t frames)
{
int ch = s_info.channel;
if (s_info.sample_rate != s_cvt_rate) {
if (s_cvt) {
esp_ae_rate_cvt_close(s_cvt);
s_cvt = NULL;
}
s_cvt_rate = s_info.sample_rate;
if (s_cvt_rate != OUT_RATE) {
esp_ae_rate_cvt_cfg_t cfg = {
.src_rate = s_cvt_rate, .dest_rate = OUT_RATE, .channel = 2, .bits_per_sample = 32,
.complexity = 3, .perf_type = ESP_AE_RATE_CVT_PERF_TYPE_SPEED,
};
if (esp_ae_rate_cvt_open(&cfg, &s_cvt) != ESP_AE_ERR_OK) {
ESP_LOGE(TAG, "rate converter %lu -> %d Hz: open failed", (unsigned long)s_cvt_rate, OUT_RATE);
} else {
ESP_LOGI(TAG, "rate converter %lu -> %d Hz", (unsigned long)s_cvt_rate, OUT_RATE);
}
}
}
while (frames) {
uint32_t n = frames < CONV_FRAMES ? frames : CONV_FRAMES;
for (uint32_t i = 0; i < n; i++) { // to stereo int32 (full scale = INT32_MAX)
int32_t l = (int32_t)pcm[i * ch] << 16;
s_in32[i * 2] = l;
s_in32[i * 2 + 1] = ch > 1 ? (int32_t)pcm[i * ch + 1] << 16 : l;
}
const int32_t *out = s_in32;
uint32_t out_n = n;
if (s_cvt) {
out_n = CONV_FRAMES * 2;
if (esp_ae_rate_cvt_process(s_cvt, s_in32, n, s_out32, &out_n) != ESP_AE_ERR_OK) {
ESP_LOGW(TAG, "rate conversion failed");
return;
}
out = s_out32;
}
s_seg_out += player_write(out, out_n);
pcm += n * ch;
frames -= n;
}
}
// Feed ADTS-AAC elementary stream bytes to the decoder. // Feed ADTS-AAC elementary stream bytes to the decoder.
static void decode_es(const uint8_t *data, size_t len) static void decode_es(const uint8_t *data, size_t len)
{ {
@@ -121,7 +72,8 @@ static void decode_es(const uint8_t *data, size_t len)
uint32_t n = out.decoded_size / (s_info.channel * s_info.bits_per_sample / 8); uint32_t n = out.decoded_size / (s_info.channel * s_info.bits_per_sample / 8);
s_seg_frames += n; s_seg_frames += n;
if (s_info.bits_per_sample == 16) { if (s_info.bits_per_sample == 16) {
output_pcm((const int16_t *)out.buffer, n); s_seg_out += audio_conv_write(s_conv, (const int16_t *)out.buffer, n, s_info.channel,
s_info.sample_rate);
} }
} }
raw.buffer += raw.consumed; raw.buffer += raw.consumed;
@@ -228,7 +180,6 @@ esp_err_t decoder_init(void)
return ESP_FAIL; return ESP_FAIL;
} }
s_pcm = heap_caps_malloc(s_pcm_size, MALLOC_CAP_SPIRAM); s_pcm = heap_caps_malloc(s_pcm_size, MALLOC_CAP_SPIRAM);
s_in32 = heap_caps_malloc(CONV_FRAMES * 2 * sizeof(int32_t), MALLOC_CAP_SPIRAM); s_conv = audio_conv_create(PLAYER_SRC_HLS, "hls");
s_out32 = heap_caps_malloc(CONV_FRAMES * 2 * 2 * sizeof(int32_t), MALLOC_CAP_SPIRAM); return s_pcm && s_conv ? ESP_OK : ESP_ERR_NO_MEM;
return s_pcm && s_in32 && s_out32 ? ESP_OK : ESP_ERR_NO_MEM;
} }
+53 -17
View File
@@ -5,9 +5,11 @@
#include "aes67_cfg.h" #include "aes67_cfg.h"
#include "aes67_tx.h" #include "aes67_tx.h"
#include "aes67_web.h" #include "aes67_web.h"
#include "audio_out.h"
#include "audio_ring.h" #include "audio_ring.h"
#include "decoder.h" #include "decoder.h"
#include "hls.h" #include "hls.h"
#include "spotify.h"
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -19,17 +21,18 @@
static const char *TAG = "player"; static const char *TAG = "player";
typedef enum { SRC_TONE, SRC_OFF, SRC_HLS, SRC_SPOTIFY } src_t;
static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" };
static volatile src_t s_src = SRC_OFF; static volatile player_src_t s_src = PLAYER_SRC_OFF;
static volatile bool s_playing; // ring output running (after prefill) 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_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;
static src_t mode_of(const char *m) static player_src_t mode_of(const char *m)
{ {
return !strcmp(m, "tone") ? SRC_TONE : !strcmp(m, "hls") ? SRC_HLS : return !strcmp(m, "tone") ? PLAYER_SRC_TONE : !strcmp(m, "hls") ? PLAYER_SRC_HLS :
!strcmp(m, "spotify") ? SRC_SPOTIFY : !strcmp(m, "auto") ? SRC_HLS /* failover: step 7 */ : SRC_OFF; !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. // AES67 TX pull callback (TX task, must not block). Silence while buffering or off: not an underrun.
@@ -40,7 +43,7 @@ static size_t player_read(int32_t *buf, size_t frames)
s_flush = false; s_flush = false;
s_playing = false; s_playing = false;
} }
if (s_src == SRC_OFF || s_src == SRC_SPOTIFY) { // Spotify: step 7 (cspot) if (s_src == PLAYER_SRC_OFF || (s_src == PLAYER_SRC_SPOTIFY && s_paused)) {
memset(buf, 0, frames * CHANNELS * sizeof(int32_t)); memset(buf, 0, frames * CHANNELS * sizeof(int32_t));
return frames; return frames;
} }
@@ -60,25 +63,25 @@ static size_t player_read(int32_t *buf, size_t frames)
void player_apply(const cJSON *source) void player_apply(const cJSON *source)
{ {
src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring); player_src_t src = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring);
if (src == s_src) { if (src == s_src) {
return; return;
} }
s_src = src; s_src = src;
s_flush = true; s_flush = true;
// The test tone is TX's own (phase-locked to PTP); everything else goes through player_read. // The test tone is TX's own (phase-locked to PTP); everything else goes through player_read.
aes67_tx_set_source(src == SRC_TONE ? NULL : player_read); aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read);
ESP_LOGI(TAG, "source: %s", SRC_NAME[src]); ESP_LOGI(TAG, "source: %s", SRC_NAME[src]);
} }
// Source side (HLS task): write converted 48 kHz frames, waiting for space at playback speed. // Source side: write converted 48 kHz frames, waiting for space at playback speed.
// Gives up when the source is no longer HLS. // Frames of a source that is not active are dropped.
size_t player_write(const int32_t *frames, size_t n) size_t player_write(player_src_t src, const int32_t *frames, size_t n)
{ {
size_t done = 0; size_t done = 0;
while (done < n) { while (done < n) {
if (s_src != SRC_HLS) { if (s_src != src) {
return done; return n; // not the active source: drop
} }
size_t w = audio_ring_write(frames + done * CHANNELS, n - done); size_t w = audio_ring_write(frames + done * CHANNELS, n - done);
if (!w) { if (!w) {
@@ -89,13 +92,41 @@ size_t player_write(const int32_t *frames, size_t n)
return done; 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:
ESP_LOGI(TAG, "Spotify volume %d%% (applied in step 7.5b4)", value * 100 / 65535);
break;
}
}
static void player_status(cJSON *st) static void player_status(cJSON *st)
{ {
const char *state = s_src == SRC_TONE ? "playing" : s_src == SRC_OFF ? "idle" : const char *state = s_src == PLAYER_SRC_TONE ? "playing" : s_src == PLAYER_SRC_OFF ? "idle" :
s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering"; s_src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering";
cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]); cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]);
cJSON_AddStringToObject(st, "source_state", state); cJSON_AddStringToObject(st, "source_state", state);
cJSON_AddStringToObject(st, "spotify_state", "not implemented"); cJSON_AddStringToObject(st, "spotify_state", spotify_state());
cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE));
} }
@@ -107,13 +138,18 @@ esp_err_t player_init(void)
return err; return err;
} }
cJSON *src = cfg_get("source"); cJSON *src = cfg_get("source");
s_src = (src_t)-1; s_src = (player_src_t)-1;
player_apply(src); player_apply(src);
cJSON_Delete(src); cJSON_Delete(src);
err = decoder_init(); err = decoder_init();
if (err != ESP_OK) { if (err != ESP_OK) {
return err; 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);
err = hls_start(decoder_feed); err = hls_start(decoder_feed);
if (err != ESP_OK) { if (err != ESP_OK) {
return err; return err;
+4 -1
View File
@@ -7,8 +7,11 @@
#include "cJSON.h" #include "cJSON.h"
#include "esp_err.h" #include "esp_err.h"
typedef enum { PLAYER_SRC_TONE, PLAYER_SRC_OFF, PLAYER_SRC_HLS, PLAYER_SRC_SPOTIFY } player_src_t;
esp_err_t player_init(void); esp_err_t player_init(void);
// Apply the "source" config group (mode etc.). // Apply the "source" config group (mode etc.).
void player_apply(const cJSON *source); void player_apply(const cJSON *source);
// Source side: write 48 kHz stereo int32 frames, blocking while the ring is full. // Source side: write 48 kHz stereo int32 frames, blocking while the ring is full.
size_t player_write(const int32_t *frames, size_t n); // Frames from a source that is not the active one are dropped (returns n).
size_t player_write(player_src_t src, const int32_t *frames, size_t n);
+6 -1
View File
@@ -7,7 +7,8 @@
static const char SOURCE_DEFAULTS[] = static const char SOURCE_DEFAULTS[] =
"{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\"," "{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\","
"\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false}"; "\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false,"
"\"spotify_client_id\":\"\",\"spotify_client_secret\":\"\"}";
static bool source_validate(const cJSON *g, char *err, size_t n) static bool source_validate(const cJSON *g, char *err, size_t n)
{ {
@@ -17,6 +18,8 @@ static bool source_validate(const cJSON *g, char *err, size_t n)
cfg_check_str(g, "spotify_name", 1, 63, err, n) && cfg_check_str(g, "spotify_name", 1, 63, err, n) &&
cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) && cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) &&
cfg_check_str(g, "hls_url", 0, 255, err, n) && cfg_check_str(g, "hls_url", 0, 255, err, n) &&
cfg_check_str(g, "spotify_client_id", 0, 64, err, n) &&
cfg_check_str(g, "spotify_client_secret", 0, 64, err, n) &&
cfg_check_num(g, "gain_db", -60, 12, err, n) && cfg_check_num(g, "gain_db", -60, 12, err, n) &&
cfg_check_int(g, "failover_delay_s", 0, 3600, err, n); cfg_check_int(g, "failover_delay_s", 0, 3600, err, n);
} }
@@ -31,4 +34,6 @@ void project_cfg_defaults(void)
void project_cfg_register(void) void project_cfg_register(void)
{ {
ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply)); ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply));
// Each user's own Spotify developer app (developer.spotify.com); the secret is write-only.
ESP_ERROR_CHECK(cfg_mark_secret("source", "spotify_client_secret"));
} }
+8
View File
@@ -28,3 +28,11 @@ CONFIG_LWIP_MAX_SOCKETS=16
# 32 MB PSRAM in the P4 package (hex mode, 200 MHz; 250 MHz needs rev >= 3). Needed for step 7: # 32 MB PSRAM in the P4 package (hex mode, 200 MHz; 250 MHz needs rev >= 3). Needed for step 7:
# HLS segment buffers, cspot, decoders. malloc() puts blocks >16 KB in PSRAM; DMA buffers stay internal. # HLS segment buffers, cspot, decoders. malloc() puts blocks >16 KB in PSRAM; DMA buffers stay internal.
CONFIG_SPIRAM=y CONFIG_SPIRAM=y
# cspot (Spotify Connect, step 7.5) needs C++ exceptions.
CONFIG_COMPILER_CXX_EXCEPTIONS=y
# esp_audio_codec's Vorbis decoder exports the same symbols (vorbis_info_init, ...) as cspot's
# Tremor (bell): keep it out of the link. HLS only needs AAC.
# CONFIG_AUDIO_DECODER_VORBIS_SUPPORT is not set
# CONFIG_AUDIO_SIMPLE_DEC_OGG_SUPPORT is not set
+7 -1
View File
@@ -71,6 +71,11 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<label><span>Spotify device name</span><input name="source.spotify_name"></label> <label><span>Spotify device name</span><input name="source.spotify_name"></label>
<label><span>Spotify bitrate</span><select name="source.spotify_bitrate"> <label><span>Spotify bitrate</span><select name="source.spotify_bitrate">
<option>96</option><option>160</option><option>320</option></select></label> <option>96</option><option>160</option><option>320</option></select></label>
<label><span>Spotify client ID</span><input name="source.spotify_client_id" autocomplete="off"></label>
<label><span>Spotify client secret</span><input name="source.spotify_client_secret" type="password"
autocomplete="new-password" placeholder="stored; leave empty to keep"></label>
<small>Your own app from <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a>
(Premium account). The secret is never shown again once saved.</small>
<label><span>Stream URL</span><input name="source.hls_url" type="url" placeholder="https://…/playlist.m3u8"></label> <label><span>Stream URL</span><input name="source.hls_url" type="url" placeholder="https://…/playlist.m3u8"></label>
<label><span>Autoplay stream on boot</span><input name="source.autoplay" type="checkbox"></label> <label><span>Autoplay stream on boot</span><input name="source.autoplay" type="checkbox"></label>
<label><span>Output gain (dB)</span><input name="source.gain_db" type="number" min="-60" max="12" step="0.5"></label> <label><span>Output gain (dB)</span><input name="source.gain_db" type="number" min="-60" max="12" step="0.5"></label>
@@ -208,7 +213,7 @@ const PROJECT = {
title: 'P4 AES67 Streamer', title: 'P4 AES67 Streamer',
def: { def: {
source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0, source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0,
failover_delay_s:5,failover_on_pause:false}, failover_delay_s:5,failover_on_pause:false,spotify_client_id:'',spotify_client_secret:''},
aes67:{name:'P4 AES67'}, aes67:{name:'P4 AES67'},
net:{hostname:'p4-aes67'} net:{hostname:'p4-aes67'}
}, },
@@ -216,6 +221,7 @@ const PROJECT = {
const m = f['source.mode'].value; const m = f['source.mode'].value;
const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto'; const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto';
f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp; f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp;
f['source.spotify_client_id'].disabled = f['source.spotify_client_secret'].disabled = !sp;
f['source.hls_url'].disabled = !hl; f['source.hls_url'].disabled = !hl;
f['source.autoplay'].disabled = m !== 'hls'; f['source.autoplay'].disabled = m !== 'hls';
f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto'; f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto';