Add PUT /api/stream/phone: raw PCM audio push from a phone app
New endpoint accepts chunked, header-less, 16-bit LE stereo PCM @ 48 kHz in the request body and writes it straight to the shared I2S sink for as long as the connection stays open. Chosen deliberately unencoded (no MP3/AAC/Opus decode) to keep this path as simple and low-risk as possible — the companion app controls the encoding on its side. Extracted the I2S TX channel that used to be owned outright by web_radio_stream.cpp into main/esp32_i2s_sink.cpp, shared by both producers with a simple tryAcquire()/release() exclusivity guard — web radio streaming and a phone PCM stream would otherwise fight over the same physical wire. web_radio_stream.cpp now acquires/releases around each streamWhileEnabled() cycle instead of owning the channel itself. net::PhoneStreamSink is a plain function-pointer struct (not a class hierarchy) threaded through NetBootstrap::start() -> SetupWebServer::start() -> HttpRouteContext, so components/net stays free of I2S driver headers; main/phone_stream.cpp supplies the concrete functions (bound to esp32_i2s_sink) and does the int16->ADAU 32-bit-slot conversion, batched per chunk rather than per sample for the same reason as the web radio stutter fix (6974095/7e65394 lineage). Verified by build only — not confirmed live yet (board disconnected this session); the actual phone app that will exercise this endpoint doesn't exist yet either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR
This commit is contained in:
@@ -86,6 +86,7 @@ public:
|
||||
* @param integration Application orchestration for preset recall.
|
||||
* @param ota Firmware OTA service for POST /api/system/ota.
|
||||
* @param webRadio Streaming config for GET/POST /api/streaming.
|
||||
* @param phoneStream I2S write-through for PUT /api/stream/phone.
|
||||
* @param companionChips Boot flags exposed on GET /api/health.
|
||||
* @param deviceIdentity EEPROM-derived SSID, hostname, and serial.
|
||||
* @return NetBootstrap on success, or a NetError.
|
||||
@@ -101,6 +102,7 @@ public:
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity);
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
#include "net/NetState.hpp"
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
namespace audio {
|
||||
@@ -58,6 +60,31 @@ struct httpd_handle;
|
||||
|
||||
namespace net {
|
||||
|
||||
/**
|
||||
* @brief PhoneStreamSink — plain function pointers over the shared I2S
|
||||
* TX channel, so net/ (protocol-only) never includes I2S driver
|
||||
* headers directly; main/esp32_i2s_sink.cpp supplies them.
|
||||
*
|
||||
* @dname PhoneStreamSink
|
||||
* @return n/a (type)
|
||||
* @pubstate All members are free functions with process lifetime; no
|
||||
* per-instance state.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-18
|
||||
*/
|
||||
struct PhoneStreamSink {
|
||||
/** Claim exclusive use of the sink; false if already held (e.g. by
|
||||
* the web radio stream). */
|
||||
bool (*tryAcquire)();
|
||||
/** Release a previously acquired claim. */
|
||||
void (*release)();
|
||||
/** Write one chunk of interleaved 16-bit stereo PCM @ 48 kHz.
|
||||
* @return false on an I2S write error. */
|
||||
bool (*writePcm16Stereo)(const std::int16_t* interleaved,
|
||||
std::size_t frameCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief HttpRouteContext — dependencies injected into HTTP handlers.
|
||||
*
|
||||
@@ -78,6 +105,7 @@ struct HttpRouteContext {
|
||||
integration::IntegrationService* integration; ///< Preset recall orchestration.
|
||||
ota::OtaService* ota; ///< Firmware OTA streaming.
|
||||
webradio::WebRadioService* webRadio; ///< Streaming config REST routes.
|
||||
PhoneStreamSink* phoneStream; ///< PUT /api/stream/phone I2S write-through.
|
||||
core::CompanionChipStatus companionChips; ///< Boot flags for /api/health.
|
||||
core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity.
|
||||
};
|
||||
@@ -158,6 +186,7 @@ public:
|
||||
* @param integration Application orchestration for preset recall.
|
||||
* @param ota Firmware OTA service for POST /api/system/ota.
|
||||
* @param webRadio Streaming config for GET/POST /api/streaming.
|
||||
* @param phoneStream I2S write-through for PUT /api/stream/phone.
|
||||
* @param companionChips Boot flags for GET /api/health.
|
||||
* @param deviceIdentity Unit identity for /api/health serialNumber.
|
||||
* @return Ok on success, or NetError::HttpServerStartFailed.
|
||||
@@ -174,6 +203,7 @@ public:
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity);
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity)
|
||||
{
|
||||
@@ -119,7 +120,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
if (auto webResult =
|
||||
webServer.start(store, NetState::SoftApSetup, tuner, audio,
|
||||
bluetooth, stations, integration, ota, webRadio,
|
||||
companionChips, deviceIdentity);
|
||||
phoneStream, companionChips, deviceIdentity);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -156,6 +157,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity)
|
||||
{
|
||||
@@ -186,7 +188,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
if (auto webResult =
|
||||
webServer.start(store, NetState::StaConnected, tuner, audio,
|
||||
bluetooth, stations, integration, ota, webRadio,
|
||||
companionChips, deviceIdentity);
|
||||
phoneStream, companionChips, deviceIdentity);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -214,6 +216,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity)
|
||||
{
|
||||
@@ -227,7 +230,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
|
||||
if (store.hasWifiCredentials()) {
|
||||
auto staResult = startStaMode(store, tuner, audio, bluetooth, stations,
|
||||
integration, ota, webRadio,
|
||||
integration, ota, webRadio, phoneStream,
|
||||
companionChips, deviceIdentity);
|
||||
if (staResult) {
|
||||
return staResult;
|
||||
@@ -240,7 +243,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
}
|
||||
|
||||
return startSetupMode(store, tuner, audio, bluetooth, stations, integration,
|
||||
ota, webRadio, companionChips, deviceIdentity);
|
||||
ota, webRadio, phoneStream, companionChips,
|
||||
deviceIdentity);
|
||||
}
|
||||
|
||||
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -97,6 +98,7 @@ extern const uint8_t index_html_gz_end[] asm(
|
||||
.integration = nullptr,
|
||||
.ota = nullptr,
|
||||
.webRadio = nullptr,
|
||||
.phoneStream = nullptr,
|
||||
.companionChips = {},
|
||||
.deviceIdentity = core::DeviceIdentity::unknown(),
|
||||
};
|
||||
@@ -924,6 +926,74 @@ esp_err_t dspParamPutHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief phoneStreamPutHandler — receive raw PCM audio from a phone app.
|
||||
*
|
||||
* @dname phoneStreamPutHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate blocks for the lifetime of the connection, writing each chunk
|
||||
* straight to the shared I2S sink; not part of AudioProfile,
|
||||
* nothing persisted.
|
||||
*
|
||||
* Body: raw interleaved 16-bit little-endian PCM, stereo, 48 kHz, no
|
||||
* header or framing — just samples. Send with chunked transfer encoding
|
||||
* (no Content-Length needed) and close the connection to stop the stream.
|
||||
* Rejected with 409 if the web radio stream (or another phone stream)
|
||||
* currently owns the I2S sink.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-18
|
||||
*/
|
||||
esp_err_t phoneStreamPutHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->phoneStream == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
if (!ctx->phoneStream->tryAcquire()) {
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
constexpr std::size_t kFrameBytes = 4U; // int16 left + int16 right
|
||||
std::array<char, 4096> chunk{};
|
||||
std::array<std::int16_t, chunk.size() / sizeof(std::int16_t)> frames{};
|
||||
std::size_t leftover = 0U; // always < kFrameBytes between reads
|
||||
|
||||
bool ok = true;
|
||||
while (true) {
|
||||
const int n = httpd_req_recv(req, chunk.data() + leftover,
|
||||
chunk.size() - leftover);
|
||||
if (n <= 0) {
|
||||
break; // client closed the connection (or a real error) — done
|
||||
}
|
||||
const std::size_t available =
|
||||
leftover + static_cast<std::size_t>(n);
|
||||
const std::size_t usable = available - (available % kFrameBytes);
|
||||
if (usable > 0U) {
|
||||
std::memcpy(frames.data(), chunk.data(), usable);
|
||||
if (!ctx->phoneStream->writePcm16Stereo(frames.data(),
|
||||
usable / kFrameBytes)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
leftover = available - usable;
|
||||
if (leftover > 0U) {
|
||||
std::memmove(chunk.data(), chunk.data() + usable, leftover);
|
||||
}
|
||||
}
|
||||
|
||||
ctx->phoneStream->release();
|
||||
if (!ok) {
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief streamingGetHandler — serve GET /api/streaming as JSON.
|
||||
*
|
||||
@@ -1794,6 +1864,7 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
integration::IntegrationService& integration,
|
||||
ota::OtaService& ota,
|
||||
webradio::WebRadioService& webRadio,
|
||||
PhoneStreamSink& phoneStream,
|
||||
core::CompanionChipStatus companionChips,
|
||||
const core::DeviceIdentity& deviceIdentity)
|
||||
{
|
||||
@@ -1817,6 +1888,7 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
routeContext.integration = &integration;
|
||||
routeContext.ota = &ota;
|
||||
routeContext.webRadio = &webRadio;
|
||||
routeContext.phoneStream = &phoneStream;
|
||||
routeContext.companionChips = companionChips;
|
||||
routeContext.deviceIdentity = deviceIdentity;
|
||||
|
||||
@@ -1989,6 +2061,14 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &dspParamUri);
|
||||
|
||||
const httpd_uri_t phoneStreamUri = {
|
||||
.uri = "/api/stream/phone",
|
||||
.method = HTTP_PUT,
|
||||
.handler = phoneStreamPutHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &phoneStreamUri);
|
||||
|
||||
const httpd_uri_t streamingGetUri = {
|
||||
.uri = "/api/streaming",
|
||||
.method = HTTP_GET,
|
||||
|
||||
Reference in New Issue
Block a user