Pink noise as a source (mode "pink")

Core: aes67_tx_pink_noise(), a ready-made TX source: xorshift32 white noise
through Paul Kellet's pink filter, -18 dBFS RMS (peaks about -6 dBFS), same
on L and R. The player uses it like the tone (straight to TX, no volume or
gain). Checked on the board: -18 dBFS RMS, octave bands 63 Hz-4 kHz flat
within +-0.4 dB, L = R.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 23:37:05 +10:00
parent bbeb7d3fd2
commit 122e46e862
8 changed files with 58 additions and 16 deletions
+30
View File
@@ -170,6 +170,36 @@ static int64_t samples_to_ns(int64_t s, int rate)
// 1 kHz tone, phase from the PTP sample index, so every sender's tone lines up. // 1 kHz tone, phase from the PTP sample index, so every sender's tone lines up.
// One period is rate / 1000 samples (48 at 48 kHz, 96 at 96 kHz): precomputed table. // One period is rate / 1000 samples (48 at 48 kHz, 96 at 96 kHz): precomputed table.
// Pink noise: xorshift32 white noise through Paul Kellet's refined pink filter (within 0.05 dB
// above 9 Hz). The scale gives -18 dBFS RMS (measured over 10 s; peaks about -6 dBFS).
#define PINK_SCALE 0.0714f
size_t aes67_tx_pink_noise(int32_t *buf, size_t frames)
{
static uint32_t rnd = 0x12345678;
static float b0, b1, b2, b3, b4, b5, b6;
for (size_t i = 0; i < frames; i++) {
rnd ^= rnd << 13;
rnd ^= rnd >> 17;
rnd ^= rnd << 5;
float w = (int32_t)rnd * (1.0f / 2147483648.0f); // -1..1
b0 = 0.99886f * b0 + w * 0.0555179f;
b1 = 0.99332f * b1 + w * 0.0750759f;
b2 = 0.96900f * b2 + w * 0.1538520f;
b3 = 0.86650f * b3 + w * 0.3104856f;
b4 = 0.55000f * b4 + w * 0.5329522f;
b5 = -0.7616f * b5 - w * 0.0168980f;
float pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362f;
b6 = w * 0.115926f;
float x = pink * PINK_SCALE;
x = x > 0.999f ? 0.999f : x < -0.999f ? -0.999f : x; // never reached in practice
int32_t v = (int32_t)(x * 2147483647.0f);
buf[i * AES67_TX_SRC_CHANNELS] = v;
buf[i * AES67_TX_SRC_CHANNELS + 1] = v;
}
return frames;
}
// Stereo source -> 1-channel stream, in place. The sum is in 64 bit, so it can't clip; identical // Stereo source -> 1-channel stream, in place. The sum is in 64 bit, so it can't clip; identical
// L/R comes out at the same level. // L/R comes out at the same level.
static void to_mono(int32_t *buf, int frames, bool sum) static void to_mono(int32_t *buf, int frames, bool sum)
+3
View File
@@ -19,3 +19,6 @@ esp_err_t aes67_tx_init(void);
esp_err_t aes67_tx_start(void); esp_err_t aes67_tx_start(void);
// Set the audio source. NULL = built-in 1 kHz test tone at -18 dBFS, phase-locked to PTP time. // Set the audio source. NULL = built-in 1 kHz test tone at -18 dBFS, phase-locked to PTP time.
void aes67_tx_set_source(aes67_tx_read_cb_t read); void aes67_tx_set_source(aes67_tx_read_cb_t read);
// Ready-made source: pink noise at -18 dBFS RMS (peaks about -6 dBFS), same on L and R.
// Pass it to aes67_tx_set_source(). Keeps state: use for one stream only.
size_t aes67_tx_pink_noise(int32_t *buf, size_t frames);
+1 -1
View File
@@ -142,7 +142,7 @@ components/
aes67_board/ board pin profiles (waveshare_esp32p4_eth.h, …), eth init aes67_board/ board pin profiles (waveshare_esp32p4_eth.h, …), eth init
aes67_net/ netif setup, optional VLAN split, DSCP/PCP helpers aes67_net/ netif setup, optional VLAN split, DSCP/PCP helpers
aes67_ptp/ PTPv2 ordinary clock: BMCA, servo, master, multicast/hybrid, EMAC HW timestamps, stats aes67_ptp/ PTPv2 ordinary clock: BMCA, servo, master, multicast/hybrid, EMAC HW timestamps, stats
aes67_tx/ RTP sender task, PTP-paced, pull callback: size_t (*read)(int32_t *buf, size_t frames), always stereo (AES67_TX_SRC_CHANNELS); TX maps it to the stream (mono: L, or (L+R)/2 with mono_sum) aes67_tx/ RTP sender task, PTP-paced, pull callback: size_t (*read)(int32_t *buf, size_t frames), always stereo (AES67_TX_SRC_CHANNELS); TX maps it to the stream (mono: L, or (L+R)/2 with mono_sum); built-in test signals: 1 kHz tone at -18 dBFS phase-locked to PTP (source NULL), pink noise at -18 dBFS RMS (aes67_tx_pink_noise)
aes67_sdp_sap/ SDP builder/parser + SAP announcer aes67_sdp_sap/ SDP builder/parser + SAP announcer
aes67_syslog/ remote syslog aes67_syslog/ remote syslog
aes67_health/ uptime, heap/PSRAM, temperature sensors (temps[] with min/max/warn) aes67_health/ uptime, heap/PSRAM, temperature sensors (temps[] with min/max/warn)
+3 -3
View File
@@ -18,7 +18,7 @@ Core AES67 parts (PTP, TX, SDP/SAP, network/VLAN, syslog, web UI base, config sc
## Project pipeline ## Project pipeline
source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44.1k->48k -> PSRAM ring buffer -> volume/gain -> aes67_tx pull callback source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44.1k->48k -> PSRAM ring buffer -> volume/gain -> aes67_tx pull callback
- Volume is applied after the ring (in the pull callback) so a change is heard at once, not one ring length (4 s) later; ramped over one packet to avoid zipper noise. Curve: amplitude = (volume/100)^3 (about -18 dB at 50 %, 0 = mute). The built-in test tone (mode "tone") is not affected. - Volume is applied after the ring (in the pull callback) so a change is heard at once, not one ring length (4 s) later; ramped over one packet to avoid zipper noise. Curve: amplitude = (volume/100)^3 (about -18 dB at 50 %, 0 = mute). The built-in test signals (modes "tone", "pink") are not affected.
- Spotify Connect: cspot (feelfreelinux/cspot), needs Premium, zeroconf via mDNS (on the internet interface when VLAN split). Spotify occasionally changes auth for 3rd-party clients — expect breakage risk. - Spotify Connect: cspot (feelfreelinux/cspot), needs Premium, zeroconf via mDNS (on the internet interface when VLAN split). Spotify occasionally changes auth for 3rd-party clients — expect breakage risk.
- HLS: HTTPS (mbedTLS), m3u8 parse (master->media playlist), segment fetch, TS demux or fMP4/ADTS, AAC decode (esp_audio_codec / esp-gmf). - HLS: HTTPS (mbedTLS), m3u8 parse (master->media playlist), segment fetch, TS demux or fMP4/ADTS, AAC decode (esp_audio_codec / esp-gmf).
- Stereo L24/48k at 1 ms is about 3 Mbit/s on the wire; 100 Mb has plenty of headroom. - Stereo L24/48k at 1 ms is about 3 Mbit/s on the wire; 100 Mb has plenty of headroom.
@@ -31,10 +31,10 @@ 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, spotify_client_id, spotify_client_secret} - source: {mode: spotify|hls|auto|tone|pink|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. - 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). "pink": the core's pink noise, -18 dBFS RMS (peaks about -6 dBFS), same on L and R (level/EQ checks). Neither is affected by volume or gain. "off": silence.
## Player control API (project routes) ## Player control API (project routes)
- GET /api/player -> {source, forced, state(playing|paused|stopped|buffering|idle), artist, title, album, position_ms, duration_ms, volume(0-100), can:{pause,next,prev,seek}} - GET /api/player -> {source, forced, state(playing|paused|stopped|buffering|idle), artist, title, album, position_ms, duration_ms, volume(0-100), can:{pause,next,prev,seek}}
+17 -9
View File
@@ -27,9 +27,9 @@
static const char *TAG = "player"; static const char *TAG = "player";
typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO } mode_t_; typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO, MODE_PINK } mode_t_;
static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify", "pink" };
static const char *const MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto" }; static const char *const MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto", "pink" };
static volatile mode_t_ s_mode = MODE_OFF; static volatile mode_t_ s_mode = MODE_OFF;
static volatile player_src_t s_src = PLAYER_SRC_OFF; // what is playing now static volatile player_src_t s_src = PLAYER_SRC_OFF; // what is playing now
@@ -110,7 +110,7 @@ static void apply_fade(int32_t *buf, size_t frames, int *left, bool in)
static mode_t_ mode_of(const char *m) static mode_t_ mode_of(const char *m)
{ {
return !strcmp(m, "tone") ? MODE_TONE : !strcmp(m, "hls") ? MODE_HLS : !strcmp(m, "spotify") ? MODE_SPOTIFY : return !strcmp(m, "tone") ? MODE_TONE : !strcmp(m, "hls") ? MODE_HLS : !strcmp(m, "spotify") ? MODE_SPOTIFY :
!strcmp(m, "auto") ? MODE_AUTO : MODE_OFF; !strcmp(m, "auto") ? MODE_AUTO : !strcmp(m, "pink") ? MODE_PINK : MODE_OFF;
} }
/* ----- output (AES67 TX pull callback, TX task, must not block) ----- */ /* ----- output (AES67 TX pull callback, TX task, must not block) ----- */
@@ -168,8 +168,14 @@ static size_t player_read(int32_t *buf, size_t frames)
return got; return got;
} }
// Set the source to play. Tone comes from TX itself (phase-locked to PTP), so switches from/to it // Test signals: straight from the core to TX, not through the ring, volume or gain.
// are immediate; everything else fades through player_read. static bool is_test_signal(player_src_t src)
{
return src == PLAYER_SRC_TONE || src == PLAYER_SRC_PINK;
}
// Set the source to play. Test signals come from the core (the tone phase-locked to PTP), so
// switches from/to them are immediate; everything else fades through player_read.
static void select_source(player_src_t src) static void select_source(player_src_t src)
{ {
if (src == s_target) { if (src == s_target) {
@@ -180,10 +186,11 @@ static void select_source(player_src_t src)
s_hls_paused = false; s_hls_paused = false;
hls_suspend(HLS_SUSPEND_USER, false); hls_suspend(HLS_SUSPEND_USER, false);
} }
if (src == PLAYER_SRC_TONE || s_src == PLAYER_SRC_TONE) { if (is_test_signal(src) || is_test_signal(s_src)) {
s_target = s_src = src; s_target = s_src = src;
s_flush = true; s_flush = true;
aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read); aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : src == PLAYER_SRC_PINK ? aes67_tx_pink_noise :
player_read);
return; return;
} }
s_target = src; // player_read fades out and switches s_target = src; // player_read fades out and switches
@@ -213,6 +220,7 @@ static void apply_mode(void)
hls_suspend(HLS_SUSPEND_FAILOVER, mode == MODE_AUTO); hls_suspend(HLS_SUSPEND_FAILOVER, mode == MODE_AUTO);
switch (mode) { switch (mode) {
case MODE_TONE: select_source(PLAYER_SRC_TONE); break; case MODE_TONE: select_source(PLAYER_SRC_TONE); break;
case MODE_PINK: select_source(PLAYER_SRC_PINK); break;
case MODE_HLS: select_source(PLAYER_SRC_HLS); break; case MODE_HLS: select_source(PLAYER_SRC_HLS); break;
case MODE_SPOTIFY: select_source(PLAYER_SRC_SPOTIFY); break; case MODE_SPOTIFY: select_source(PLAYER_SRC_SPOTIFY); break;
case MODE_AUTO: select_source(PLAYER_SRC_SPOTIFY); break; // Spotify inactive: HLS after the delay case MODE_AUTO: select_source(PLAYER_SRC_SPOTIFY); break; // Spotify inactive: HLS after the delay
@@ -369,7 +377,7 @@ static void on_ota(bool starting)
static const char *source_state(player_src_t src) static const char *source_state(player_src_t src)
{ {
return src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" : return is_test_signal(src) ? "playing" : src == PLAYER_SRC_OFF ? "idle" :
src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" : src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" :
src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" :
src == PLAYER_SRC_HLS && s_hls_paused ? "paused" : s_playing ? "playing" : "buffering"; src == PLAYER_SRC_HLS && s_hls_paused ? "paused" : s_playing ? "playing" : "buffering";
+2 -2
View File
@@ -1,4 +1,4 @@
// Project player: picks the audio source for AES67 TX (test tone, silence, HLS, Spotify). // Project player: picks the audio source for AES67 TX (test tone, pink noise, silence, HLS, Spotify).
#pragma once #pragma once
#include <stddef.h> #include <stddef.h>
@@ -7,7 +7,7 @@
#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; typedef enum { PLAYER_SRC_TONE, PLAYER_SRC_OFF, PLAYER_SRC_HLS, PLAYER_SRC_SPOTIFY, PLAYER_SRC_PINK } 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.).
+1 -1
View File
@@ -12,7 +12,7 @@ static const char SOURCE_DEFAULTS[] =
static bool source_validate(const cJSON *g, char *err, size_t n) static bool source_validate(const cJSON *g, char *err, size_t n)
{ {
static const char *const modes[] = { "spotify", "hls", "auto", "tone", "off", NULL }; static const char *const modes[] = { "spotify", "hls", "auto", "tone", "pink", "off", NULL };
static const double bitrates[] = { 96, 160, 320 }; static const double bitrates[] = { 96, 160, 320 };
return cfg_check_enum(g, "mode", modes, err, n) && return cfg_check_enum(g, "mode", modes, err, n) &&
cfg_check_str(g, "spotify_name", 1, 63, err, n) && cfg_check_str(g, "spotify_name", 1, 63, err, n) &&
+1
View File
@@ -66,6 +66,7 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<option value="hls">HLS / M3U8 URL</option> <option value="hls">HLS / M3U8 URL</option>
<option value="auto">Spotify, fail over to HLS</option> <option value="auto">Spotify, fail over to HLS</option>
<option value="tone">Test tone (1 kHz, -18 dBFS)</option> <option value="tone">Test tone (1 kHz, -18 dBFS)</option>
<option value="pink">Pink noise (-18 dBFS RMS)</option>
<option value="off">Off (silence)</option></select></label> <option value="off">Off (silence)</option></select></label>
<label><span>Failover delay (s)</span><input name="source.failover_delay_s" type="number" min="0" max="3600"></label> <label><span>Failover delay (s)</span><input name="source.failover_delay_s" type="number" min="0" max="3600"></label>
<label><span>Also fail over when paused</span><input name="source.failover_on_pause" type="checkbox"></label> <label><span>Also fail over when paused</span><input name="source.failover_on_pause" type="checkbox"></label>