A social internet radio platform built on AT Protocol. atradio.fm
atproto radio
7

Configure Feed

Select the types of activity you want to include in your feed.

fix(web): keep the Rockbox DSP/EQ in the signal path + match CLI compressor

The EQ/DSP had no audible effect on most stations. Cross-origin radio hosts
rarely send CORS headers, so the decoder worker's direct fetch was blocked, the
engine errored, and playback silently fell back to a bare <audio> element that
is not in the Rockbox DSP graph — bypassing EQ, bass, crossfeed, everything.
The CLI decodes natively (no CORS), so it always applied the full chain.

- proxiedStreamUrl now routes every absolute http(s) stream through the
CORS-enabled /api/stream proxy (except HLS and relative/same-origin URLs), so
the worker can always fetch it and the DSP stays in the path.
- Fix the compressor params to match the CLI: makeup=1, ratio as the 0–3 index
(compRatioIndex), knee=2, release=200ms, attack=5ms — was makeup=0, raw ratio,
and zeroed knee/attack/release. Shared via applyCompressor().

The EQ gain math itself was already correct/identical to the CLI (both
rockbox-dsp versions apply the ×10 fixed-point convention internally).

Tsiry Sandratraina (Jul 17, 2026, 8:36 PM +0300) 93e71ba4 345714de

+63 -37
+24 -1
apps/web/src/atoms/audioSettings.ts
··· 112 112 }; 113 113 } 114 114 115 + /** Map a plain N:1 compressor ratio (2/4/6/10) to the engine's 0–3 ratio 116 + * index, matching the CLI (rockbox-playback `ratio_index`). */ 117 + export function compRatioIndex(ratio: number): number { 118 + if (ratio <= 2) return 0; // 2:1 119 + if (ratio <= 4) return 1; // 4:1 120 + if (ratio <= 6) return 2; // 6:1 121 + return 3; // 10:1 122 + } 123 + 124 + /** 125 + * Apply the compressor with the CLI-matched characteristics: auto makeup-gain 126 + * on, 2 dB soft knee, 5 ms attack, 200 ms release, and the ratio as a 0–3 index 127 + * (not the raw N:1). `threshold` of 0 disables the stage. 128 + * Signature: `setCompressor(threshold, makeup, ratioIndex, knee, releaseMs, attackMs)`. 129 + */ 130 + export function applyCompressor( 131 + p: RockboxPlayer, 132 + threshold: number, 133 + ratio: number, 134 + ) { 135 + p.setCompressor(threshold, 1, compRatioIndex(ratio), 2, 200, 5); 136 + } 137 + 115 138 /** Push every persisted setting to the engine (call once it's ready). */ 116 139 export function applyAudioSettings(p: RockboxPlayer, s: AudioSettings) { 117 140 p.setEqEnabled(s.eqEnabled); ··· 121 144 p.setCrossfeed(s.crossfeedMode, Math.round(s.crossfeedDirect * 10)); 122 145 p.setPbe(s.pbe, -Math.round(s.pbePrecut * 10)); 123 146 p.setSurround(s.surroundDelay, s.surroundBalance, 0, 0); 124 - p.setCompressor(s.compThreshold, 0, s.compRatio, 0, 0, 0); 147 + applyCompressor(p, s.compThreshold, s.compRatio); 125 148 p.setChannelMode(s.channelMode); 126 149 p.setStereoWidth(s.stereoWidth); 127 150 }
+2 -1
apps/web/src/components/AudioSettingsModal.tsx
··· 11 11 import { ModalCloseButton } from "./ModalCloseButton"; 12 12 import { 13 13 EQ_CUTOFFS, 14 + applyCompressor, 14 15 bassAtom, 15 16 channelModeAtom, 16 17 compRatioAtom, ··· 227 228 const surround = (delay: number, balance: number) => 228 229 apply((p) => p.setSurround(delay, balance, 0, 0)); 229 230 const compressor = (thr: number, ratio: number) => 230 - apply((p) => p.setCompressor(thr, 0, ratio, 0, 0, 0)); 231 + apply((p) => applyCompressor(p, thr, ratio)); 231 232 232 233 return ( 233 234 <Modal state={state}>
+20 -21
apps/web/src/lib/audio/resolve.test.ts
··· 27 27 }); 28 28 29 29 describe("proxiedStreamUrl", () => { 30 - it("proxies .pls/.m3u playlists so the server can unwrap them (any scheme)", () => { 31 - setProtocol("http:"); // even on an http page, playlists must be unwrapped 32 - for (const url of [ 33 - "https://host/stream.pls", 34 - "http://host/stream.m3u", 35 - "https://host/stream.PLS?x=1", 36 - ]) { 30 + // Every absolute http(s) stream is proxied so the Rockbox decoder worker can 31 + // fetch it cross-origin (CORS) and the DSP/EQ stays in the signal path. 32 + it("proxies every absolute http(s) stream (any scheme / page protocol)", () => { 33 + for (const [page, url] of [ 34 + ["https:", "https://host/stream.aac"], 35 + ["https:", "http://host/stream.aac"], // mixed content 36 + ["http:", "http://host/stream.aac"], // dev 37 + ["http:", "https://host/stream.pls"], // playlist, server unwraps 38 + ["https:", "https://host/stream.PLS?x=1"], 39 + ] as const) { 40 + setProtocol(page); 37 41 expect(proxiedStreamUrl(url)).toBe( 38 42 `${APPVIEW_URL}/api/stream?url=${encodeURIComponent(url)}`, 39 43 ); 40 44 } 41 45 }); 42 46 43 - it("proxies http streams on an https page (mixed content)", () => { 47 + it("never proxies HLS .m3u8 (segments resolve against the manifest origin)", () => { 44 48 setProtocol("https:"); 45 - const url = "http://host/stream.aac"; 46 - expect(proxiedStreamUrl(url)).toBe( 47 - `${APPVIEW_URL}/api/stream?url=${encodeURIComponent(url)}`, 49 + expect(proxiedStreamUrl("https://host/live.m3u8")).toBe( 50 + "https://host/live.m3u8", 48 51 ); 49 - }); 50 - 51 - it("leaves https direct streams untouched", () => { 52 - setProtocol("https:"); 53 - expect(proxiedStreamUrl("https://host/stream.aac")).toBe( 54 - "https://host/stream.aac", 52 + expect(proxiedStreamUrl("http://host/live.m3u8?token=1")).toBe( 53 + "http://host/live.m3u8?token=1", 55 54 ); 56 55 }); 57 56 58 - it("does not proxy http direct streams on an http page (dev)", () => { 59 - setProtocol("http:"); 60 - expect(proxiedStreamUrl("http://host/stream.aac")).toBe( 61 - "http://host/stream.aac", 57 + it("leaves relative / same-origin urls untouched (e.g. the TuneIn probe)", () => { 58 + setProtocol("https:"); 59 + expect(proxiedStreamUrl("/api/tunein/Tune.ashx?id=s1")).toBe( 60 + "/api/tunein/Tune.ashx?id=s1", 62 61 ); 63 62 }); 64 63 });
+17 -14
apps/web/src/lib/audio/resolve.ts
··· 10 10 const isHlsUrl = (url: string) => /\.m3u8(\?|$)/i.test(url); 11 11 12 12 /** 13 - * Route an `http://` stream through our https API proxy so it isn't blocked as 14 - * mixed content when the app itself is served over https. `https://` URLs and 15 - * (dev) http pages are returned unchanged, so playback stays direct wherever it 16 - * safely can. HLS manifests are never proxied — their segment URIs would then 17 - * resolve against the proxy origin — so callers should skip HLS. 13 + * Route a stream through our CORS-enabled `/api/stream` proxy. 14 + * 15 + * Every absolute `http(s)` stream is proxied. This is what keeps the Rockbox 16 + * DSP/EQ in the signal path: the decoder worker fetches the stream itself, and 17 + * cross-origin radio hosts almost never send `Access-Control-Allow-Origin`, so a 18 + * direct fetch is CORS-blocked — the engine then errors and playback silently 19 + * falls back to a bare `<audio>` element that bypasses the whole DSP chain 20 + * (no EQ, no bass, nothing). Proxying makes the fetch same-origin/CORS-OK so the 21 + * engine can always decode, and it also unwraps `.pls`/`.m3u` playlists. 22 + * 23 + * Not proxied: 24 + * - HLS `.m3u8` — segment URIs must resolve against the manifest's own origin 25 + * (callers also branch on `isHls` first). 26 + * - relative / same-origin URLs (e.g. the TuneIn `/api/tunein/…` probe) — they 27 + * are already CORS-safe and return a finite body, not a live stream. 18 28 */ 19 29 export function proxiedStreamUrl(url: string): string { 20 - const isHttp = /^http:\/\//i.test(url); 21 - const isPlaylist = /\.(pls|m3u)(\?|$)/i.test(url); 22 - const pageHttps = 23 - typeof window !== "undefined" && window.location.protocol === "https:"; 24 - // Route through the proxy when either an `http://` stream would be blocked as 25 - // mixed content on an https page, or the URL is a `.pls`/`.m3u` playlist the 26 - // proxy must unwrap into a real stream (the decoder can't play a playlist 27 - // file). HLS `.m3u8` is never proxied — callers branch on `isHls` first. 28 - if (!((isHttp && pageHttps) || isPlaylist)) return url; 30 + if (isHlsUrl(url)) return url; 31 + if (!/^https?:\/\//i.test(url)) return url; 29 32 return `${APPVIEW_URL}/api/stream?url=${encodeURIComponent(url)}`; 30 33 } 31 34