From 122e46e8629dd7dc8683efc3c4992e49c3db71e2 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 23:37:05 +1000 Subject: [PATCH] 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 --- components/aes67_tx/aes67_tx.c | 30 ++++++++++++++++++++++++++ components/aes67_tx/include/aes67_tx.h | 3 +++ docs/aes67-core-base.md | 2 +- docs/hardware-and-design-notes.md | 6 +++--- main/player.c | 26 ++++++++++++++-------- main/player.h | 4 ++-- main/project_cfg.c | 2 +- web/index.html | 1 + 8 files changed, 58 insertions(+), 16 deletions(-) diff --git a/components/aes67_tx/aes67_tx.c b/components/aes67_tx/aes67_tx.c index 46dd122..ddd346e 100644 --- a/components/aes67_tx/aes67_tx.c +++ b/components/aes67_tx/aes67_tx.c @@ -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. // 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 // L/R comes out at the same level. static void to_mono(int32_t *buf, int frames, bool sum) diff --git a/components/aes67_tx/include/aes67_tx.h b/components/aes67_tx/include/aes67_tx.h index 39d5559..3c6edd4 100644 --- a/components/aes67_tx/include/aes67_tx.h +++ b/components/aes67_tx/include/aes67_tx.h @@ -19,3 +19,6 @@ esp_err_t aes67_tx_init(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. 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); diff --git a/docs/aes67-core-base.md b/docs/aes67-core-base.md index 684e884..b9829e6 100644 --- a/docs/aes67-core-base.md +++ b/docs/aes67-core-base.md @@ -142,7 +142,7 @@ components/ aes67_board/ board pin profiles (waveshare_esp32p4_eth.h, …), eth init 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_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_syslog/ remote syslog aes67_health/ uptime, heap/PSRAM, temperature sensors (temps[] with min/max/warn) diff --git a/docs/hardware-and-design-notes.md b/docs/hardware-and-design-notes.md index b819555..e40231c 100644 --- a/docs/hardware-and-design-notes.md +++ b/docs/hardware-and-design-notes.md @@ -18,7 +18,7 @@ Core AES67 parts (PTP, TX, SDP/SAP, network/VLAN, syslog, web UI base, config sc ## 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 -- 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. - 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. @@ -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. ## 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. - 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) - 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}} diff --git a/main/player.c b/main/player.c index e32402b..90358ae 100644 --- a/main/player.c +++ b/main/player.c @@ -27,9 +27,9 @@ static const char *TAG = "player"; -typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO } mode_t_; -static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; -static const char *const MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto" }; +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", "pink" }; +static const char *const MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto", "pink" }; static volatile mode_t_ s_mode = MODE_OFF; 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) { 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) ----- */ @@ -168,8 +168,14 @@ static size_t player_read(int32_t *buf, size_t frames) return got; } -// Set the source to play. Tone comes from TX itself (phase-locked to PTP), so switches from/to it -// are immediate; everything else fades through player_read. +// Test signals: straight from the core to TX, not through the ring, volume or gain. +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) { if (src == s_target) { @@ -180,10 +186,11 @@ static void select_source(player_src_t src) s_hls_paused = 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_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; } 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); switch (mode) { 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_SPOTIFY: select_source(PLAYER_SRC_SPOTIFY); break; 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) { - 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 && s_paused ? "paused" : src == PLAYER_SRC_HLS && s_hls_paused ? "paused" : s_playing ? "playing" : "buffering"; diff --git a/main/player.h b/main/player.h index 3ba8fe6..acdad76 100644 --- a/main/player.h +++ b/main/player.h @@ -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 #include @@ -7,7 +7,7 @@ #include "cJSON.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); // Apply the "source" config group (mode etc.). diff --git a/main/project_cfg.c b/main/project_cfg.c index fc11178..8ecbd37 100644 --- a/main/project_cfg.c +++ b/main/project_cfg.c @@ -12,7 +12,7 @@ static const char SOURCE_DEFAULTS[] = 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 }; return cfg_check_enum(g, "mode", modes, err, n) && cfg_check_str(g, "spotify_name", 1, 63, err, n) && diff --git a/web/index.html b/web/index.html index 786caa7..9925965 100644 --- a/web/index.html +++ b/web/index.html @@ -66,6 +66,7 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next +