Compare commits
5 Commits
c0e0388ff0
...
4a38254660
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a38254660 | |||
| 5b0d7c9ba4 | |||
| c240ac9a3c | |||
| 039ec75473 | |||
| 7bbda0a1e8 |
@@ -0,0 +1,3 @@
|
||||
[submodule "external/cspot"]
|
||||
path = external/cspot
|
||||
url = https://github.com/philippe44/cspot.git
|
||||
@@ -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).
|
||||
- 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`.
|
||||
- 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):
|
||||
`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`.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#define MAX_GROUPS 12
|
||||
#define MAX_SECRETS 4
|
||||
#define MAX_BODY 8192
|
||||
#define NVS_NAMESPACE "cfg"
|
||||
|
||||
@@ -24,6 +25,8 @@ typedef struct {
|
||||
cJSON *values;
|
||||
cfg_validate_cb_t validate;
|
||||
cfg_apply_cb_t apply;
|
||||
char secrets[MAX_SECRETS][24]; // write-only keys
|
||||
int n_secrets;
|
||||
} cfg_group_t;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!s_lock) {
|
||||
@@ -270,7 +302,7 @@ static esp_err_t config_get(httpd_req_t *req)
|
||||
cJSON *all = cJSON_CreateObject();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
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);
|
||||
esp_err_t err = web_send_json(req, all);
|
||||
@@ -331,7 +363,18 @@ static esp_err_t config_post(httpd_req_t *req)
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
char verr[96] = "";
|
||||
ok = g->validate(cand[i], verr, sizeof(verr));
|
||||
|
||||
@@ -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.
|
||||
esp_err_t cfg_register(const char *group, const char *defaults_json,
|
||||
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.
|
||||
cJSON *cfg_get(const char *group);
|
||||
// Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
|
||||
|
||||
## Core API
|
||||
- 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}
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
- mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence.
|
||||
|
||||
|
||||
+1
Submodule external/cspot added at 3010349b46
+2
-2
@@ -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 "."
|
||||
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_syslog aes67_tx aes67_web)
|
||||
aes67_syslog aes67_tx aes67_web spotify)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -6,19 +6,15 @@
|
||||
#include "esp_audio_dec_default.h"
|
||||
#include "esp_audio_simple_dec.h"
|
||||
#include "esp_audio_simple_dec_default.h"
|
||||
#include "esp_ae_rate_cvt.h"
|
||||
#include "player.h"
|
||||
#include "audio_out.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "decoder";
|
||||
|
||||
#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 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 audio_conv_t *s_conv; // HLS: s16 -> 48 kHz -> ring
|
||||
static uint64_t s_seg_out; // 48 kHz frames written this segment
|
||||
|
||||
static esp_audio_simple_dec_handle_t s_dec;
|
||||
@@ -45,51 +41,6 @@ static void report_segment(void)
|
||||
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.
|
||||
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);
|
||||
s_seg_frames += n;
|
||||
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;
|
||||
@@ -228,7 +180,6 @@ esp_err_t decoder_init(void)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
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_out32 = heap_caps_malloc(CONV_FRAMES * 2 * 2 * sizeof(int32_t), MALLOC_CAP_SPIRAM);
|
||||
return s_pcm && s_in32 && s_out32 ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
s_conv = audio_conv_create(PLAYER_SRC_HLS, "hls");
|
||||
return s_pcm && s_conv ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
+54
-18
@@ -5,9 +5,11 @@
|
||||
#include "aes67_cfg.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"
|
||||
@@ -19,17 +21,18 @@
|
||||
|
||||
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 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_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 :
|
||||
!strcmp(m, "spotify") ? SRC_SPOTIFY : !strcmp(m, "auto") ? SRC_HLS /* failover: step 7 */ : SRC_OFF;
|
||||
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.
|
||||
@@ -40,7 +43,7 @@ static size_t player_read(int32_t *buf, size_t frames)
|
||||
s_flush = 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));
|
||||
return frames;
|
||||
}
|
||||
@@ -60,25 +63,25 @@ static size_t player_read(int32_t *buf, size_t frames)
|
||||
|
||||
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) {
|
||||
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 == SRC_TONE ? NULL : player_read);
|
||||
aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read);
|
||||
ESP_LOGI(TAG, "source: %s", SRC_NAME[src]);
|
||||
}
|
||||
|
||||
// Source side (HLS task): write converted 48 kHz frames, waiting for space at playback speed.
|
||||
// Gives up when the source is no longer HLS.
|
||||
size_t player_write(const int32_t *frames, size_t n)
|
||||
// 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_HLS) {
|
||||
return done;
|
||||
if (s_src != src) {
|
||||
return n; // not the active source: drop
|
||||
}
|
||||
size_t w = audio_ring_write(frames + done * CHANNELS, n - done);
|
||||
if (!w) {
|
||||
@@ -89,13 +92,41 @@ size_t player_write(const int32_t *frames, size_t n)
|
||||
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)
|
||||
{
|
||||
const char *state = s_src == SRC_TONE ? "playing" : s_src == SRC_OFF ? "idle" :
|
||||
s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering";
|
||||
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", "not implemented");
|
||||
cJSON_AddStringToObject(st, "spotify_state", spotify_state());
|
||||
cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE));
|
||||
}
|
||||
|
||||
@@ -107,14 +138,19 @@ esp_err_t player_init(void)
|
||||
return err;
|
||||
}
|
||||
cJSON *src = cfg_get("source");
|
||||
s_src = (src_t)-1;
|
||||
s_src = (player_src_t)-1;
|
||||
player_apply(src);
|
||||
cJSON_Delete(src);
|
||||
err = decoder_init();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
err = hls_start(decoder_feed);
|
||||
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);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
+4
-1
@@ -7,8 +7,11 @@
|
||||
#include "cJSON.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);
|
||||
// Apply the "source" config group (mode etc.).
|
||||
void player_apply(const cJSON *source);
|
||||
// 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
@@ -7,7 +7,8 @@
|
||||
|
||||
static const char SOURCE_DEFAULTS[] =
|
||||
"{\"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)
|
||||
{
|
||||
@@ -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_num_in(g, "spotify_bitrate", bitrates, 3, 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_int(g, "failover_delay_s", 0, 3600, err, n);
|
||||
}
|
||||
@@ -31,4 +34,6 @@ void project_cfg_defaults(void)
|
||||
void project_cfg_register(void)
|
||||
{
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
# HLS segment buffers, cspot, decoders. malloc() puts blocks >16 KB in PSRAM; DMA buffers stay internal.
|
||||
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
@@ -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 bitrate</span><select name="source.spotify_bitrate">
|
||||
<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>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>
|
||||
@@ -208,7 +213,7 @@ const PROJECT = {
|
||||
title: 'P4 AES67 Streamer',
|
||||
def: {
|
||||
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'},
|
||||
net:{hostname:'p4-aes67'}
|
||||
},
|
||||
@@ -216,6 +221,7 @@ const PROJECT = {
|
||||
const m = f['source.mode'].value;
|
||||
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_client_id'].disabled = f['source.spotify_client_secret'].disabled = !sp;
|
||||
f['source.hls_url'].disabled = !hl;
|
||||
f['source.autoplay'].disabled = m !== 'hls';
|
||||
f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto';
|
||||
|
||||
Reference in New Issue
Block a user