feat(radio): Web Audio EQ on HTML5 streams (fixes #1276) (#1284)

This commit is contained in:
cucadmuh
2026-07-13 15:36:16 +03:00
committed by GitHub
parent efb8e5782c
commit 52d927ca0b
8 changed files with 549 additions and 65 deletions
+6
View File
@@ -296,6 +296,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Windows `.msi` builds no longer fail on channel versions like `1.50.0-dev` — WiX requires a numeric fourth version field, so the bundler maps `-dev` / `-rc.N` to numeric semver in `bundle.windows.wix.version` while Settings → About still shows the real package version.
* Release builds no longer warn that the album feature barrel defeats a lazy import in the new-albums easter egg (direct import of the export helper).
### Internet Radio — equalizer presets now apply to radio playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1284](https://github.com/Psychotoxical/psysonic/pull/1284)**
* Internet Radio playback stayed on HTML5 after v1.32, but EQ changes only reached the Rust engine used for library tracks — toggling EQ or switching presets had no effect on a live station. Radio now routes through a Web Audio 10-band graph on the same `<audio>` element when EQ is enabled; preset and slider changes update filters in place without restarting the stream.
## [1.49.0] - 2026-06-29
+1
View File
@@ -201,6 +201,7 @@ const CONTRIBUTOR_ENTRIES = [
'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)',
'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)',
'Track lists — optional album cover thumbnails via standard cover pipeline; queue rows use playback scope (PR #1280)',
'Internet Radio — Web Audio EQ on HTML5 streams; presets apply without reconnect (PR #1284)',
],
},
{
@@ -5,6 +5,8 @@ import { setupMprisSync } from '@/features/playback/store/audioListenerSetup/mpr
import { setupRadioMprisMetadata } from '@/features/playback/store/audioListenerSetup/radioMprisMetadata';
import { setupDiscordPresence } from '@/features/playback/store/audioListenerSetup/discordPresence';
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
import { bindRadioEqAttachOnEnable } from '@/features/playback/store/radioPlayer';
import { bindRadioEqStore } from '@/features/playback/utils/audio/radioEqGraph';
/**
* Set up Tauri event listeners for the Rust audio engine.
@@ -23,6 +25,8 @@ export function initAudioListeners(): () => void {
const stopRadioMprisMetadata = setupRadioMprisMetadata();
const stopDiscordPresence = setupDiscordPresence();
const stopEqDeviceSync = setupEqDeviceSync();
const stopRadioEqStore = bindRadioEqStore();
const stopRadioEqAttach = bindRadioEqAttachOnEnable();
return () => {
stopAuthSync();
@@ -31,5 +35,7 @@ export function initAudioListeners(): () => void {
stopEngineListeners();
stopRadioMprisMetadata();
stopEqDeviceSync();
stopRadioEqStore();
stopRadioEqAttach();
};
}
+7 -2
View File
@@ -21,6 +21,7 @@ import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSy
import {
clearRadioReconnectTimer,
playRadioStream,
prepareRadioPlaybackFromUserGesture,
setRadioVolume,
} from '@/features/playback/store/radioPlayer';
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
@@ -68,6 +69,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
return {
playRadio: async (station) => {
const { volume } = get();
prepareRadioPlaybackFromUserGesture();
bumpPlayGeneration();
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
@@ -84,11 +86,14 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
.catch(() => station.streamUrl);
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
try {
await playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor));
} catch (err: unknown) {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
return;
}
set({
currentRadio: station,
currentTrack: null,
+29 -11
View File
@@ -12,16 +12,33 @@ const hoisted = vi.hoisted(() => {
showToastMock: vi.fn(),
playerSetStateMock: vi.fn(),
playerStateGet,
eqEnabled: false,
};
});
vi.mock('@/lib/dom/toast', () => ({ showToast: hoisted.showToastMock }));
vi.mock('@/store/eqStore', () => ({
useEqStore: {
getState: () => ({ enabled: hoisted.eqEnabled, gains: [], preGain: 0 }),
subscribe: vi.fn(() => vi.fn()),
},
}));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: hoisted.playerStateGet,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/playback/utils/audio/radioEqGraph', () => ({
applyRadioEqSettings: vi.fn(),
applyRadioOutputVolume: vi.fn(),
isRadioEqGraphActive: vi.fn(() => false),
resumeRadioEqContext: vi.fn(() => Promise.resolve()),
setRadioEqMasterVolume: vi.fn(),
shouldUseRadioEqGraph: vi.fn(() => hoisted.eqEnabled),
tryAttachRadioEqGraph: vi.fn(() => Promise.resolve(false)),
warmRadioEqContextFromUserGesture: vi.fn(),
}));
import {
_radioAudioForTest,
@@ -42,6 +59,7 @@ let pausedSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
hoisted.eqEnabled = false;
hoisted.showToastMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.playerStateGet.mockReset();
@@ -49,9 +67,6 @@ beforeEach(() => {
pauseSpy = vi.spyOn(audio, 'pause').mockImplementation(() => {});
playSpy = vi.spyOn(audio, 'play').mockResolvedValue(undefined as never);
loadSpy = vi.spyOn(audio, 'load').mockImplementation(() => {});
// jsdom defaults audio.paused to true; the reconnect path assumes the
// stream was actively playing, so default the getter to false and let
// individual tests override it where they need to assert pause behaviour.
pausedSpy = vi.spyOn(audio, 'paused', 'get').mockReturnValue(false);
});
@@ -81,6 +96,15 @@ describe('playRadioStream', () => {
await playRadioStream('https://x/y', -0.5);
expect(audio.volume).toBe(0);
});
it('does not show error toast when switching station URL', async () => {
await playRadioStream('https://x/y', 0.5);
hoisted.showToastMock.mockClear();
await playRadioStream('https://x/z', 0.5);
Object.defineProperty(audio, 'error', { value: { code: 1 }, configurable: true });
audio.dispatchEvent(new Event('error'));
expect(hoisted.showToastMock).not.toHaveBeenCalled();
});
});
describe('pauseRadio / resumeRadio', () => {
@@ -100,8 +124,8 @@ describe('pauseRadio / resumeRadio', () => {
expect(audio.src).toBe(before);
});
it('resume delegates to audio.play', () => {
void resumeRadio();
it('resume delegates to audio.play', async () => {
await resumeRadio();
expect(playSpy).toHaveBeenCalled();
});
});
@@ -111,8 +135,6 @@ describe('stopRadio', () => {
await playRadioStream('https://x/y', 0.5);
stopRadio();
expect(pauseSpy).toHaveBeenCalled();
// JSdom resolves an empty src against the page URL, so assert via the
// observable HTMLAudioElement attribute instead.
expect(audio.getAttribute('src')).toBe('');
});
@@ -182,7 +204,6 @@ describe('event listeners', () => {
audio.dispatchEvent(new Event('stalled'));
vi.advanceTimersByTime(4000);
}
// Sixth stall → counter at max, gives up with toast + state clear.
audio.dispatchEvent(new Event('stalled'));
expect(hoisted.showToastMock).toHaveBeenCalledWith('Radio stream disconnected', 4000, 'error');
const calls = hoisted.playerSetStateMock.mock.calls;
@@ -192,13 +213,10 @@ describe('event listeners', () => {
it('"playing" resets the reconnect counter (next stall starts fresh)', () => {
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
// Push counter to 1 via one stall cycle
audio.dispatchEvent(new Event('stalled'));
vi.advanceTimersByTime(4000);
// Successful 'playing' resets
audio.dispatchEvent(new Event('playing'));
loadSpy.mockClear();
// Next stall: should schedule again (counter is 0)
audio.dispatchEvent(new Event('stalled'));
vi.advanceTimersByTime(4000);
expect(loadSpy).toHaveBeenCalledTimes(1);
+156 -52
View File
@@ -1,62 +1,100 @@
import { showToast } from '@/lib/dom/toast';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useEqStore } from '@/store/eqStore';
import {
applyRadioEqSettings,
applyRadioOutputVolume,
isRadioEqGraphActive,
resumeRadioEqContext,
setRadioEqMasterVolume,
shouldUseRadioEqGraph,
tryAttachRadioEqGraph,
warmRadioEqContextFromUserGesture,
} from '@/features/playback/utils/audio/radioEqGraph';
/**
* Internet radio streams play through a native HTMLAudioElement rather
* than the Rust/Symphonia engine — the browser handles reconnect logic,
* codec negotiation (MP3, AAC, HE-AAC, OGG), and ICY headers for free.
* Internet radio streams play through a native HTMLAudioElement — the browser
* handles reconnect logic, codec negotiation (AAC, HE-AAC, HLS), and ICY headers.
*
* This module owns:
* - the singleton `<audio>` element used for radio
* - the bounded stalled-reconnect retry loop (MAX_RADIO_RECONNECTS)
* - the suppression flag (`radioStopping`) that stops the error
* listener from clobbering store state when the caller asked for a
* clean stop
*
* Callers drive playback through `playRadioStream` / `pauseRadio` /
* `resumeRadio` / `stopRadio` / `setRadioVolume`. The event listeners
* write the store state directly when the browser decides the stream
* is dead (ended / error / unrecoverable stall).
* When EQ is enabled and Web Audio attaches successfully, a 10-band peaking
* chain is inserted via `createMediaElementSource` (issue #1276). EQ toggles
* and preset changes update filter nodes in place — the stream is not restarted.
* With EQ off the element keeps its native output path (no graph hijack).
*/
const radioAudio = new Audio();
radioAudio.preload = 'none';
let suppressHtml5RadioErrors = false;
/** True between `play()` and the first `playing` event for the current load. */
let radioAwaitingFirstFrame = false;
let radioStopping = false;
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let radioReconnectCount = 0;
let lastVolume = 1;
let radioGraphActive = false;
let eqAttachUnsub: (() => void) | null = null;
const MEDIA_ERR_ABORTED = typeof MediaError !== 'undefined' ? MediaError.MEDIA_ERR_ABORTED : 1;
const MAX_RADIO_RECONNECTS = 5;
const RECONNECT_DELAY_MS = 4000;
function clampElementVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
function applyOutputVolume(volume: number): void {
lastVolume = volume;
applyRadioOutputVolume(volume, radioGraphActive);
if (radioGraphActive && isRadioEqGraphActive()) {
radioAudio.volume = 1;
return;
}
radioAudio.volume = clampElementVolume(volume);
}
export function clearRadioReconnectTimer(): void {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
}
/** Call synchronously from a user-gesture handler before any `await` in `playRadio`. */
export function prepareRadioPlaybackFromUserGesture(): void {
warmRadioEqContextFromUserGesture();
}
radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer();
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
});
radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; radioReconnectCount = 0; return; }
if (radioStopping) {
radioStopping = false;
suppressHtml5RadioErrors = false;
radioAwaitingFirstFrame = false;
return;
}
const aborted = radioAudio.error?.code === MEDIA_ERR_ABORTED;
if (suppressHtml5RadioErrors && (aborted || !radioAwaitingFirstFrame)) {
suppressHtml5RadioErrors = false;
return;
}
suppressHtml5RadioErrors = false;
radioAwaitingFirstFrame = false;
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error');
});
// Playing: stream is delivering audio — reset the reconnect counter.
radioAudio.addEventListener('playing', () => {
suppressHtml5RadioErrors = false;
radioAwaitingFirstFrame = false;
radioReconnectCount = 0;
void resumeRadioEqContext();
});
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
// On macOS/WKWebView, reassigning src during a stall can itself trigger
// another stall event before the new connection is established. The
// radioReconnectTimer guard prevents stacking, and MAX_RADIO_RECONNECTS
// ensures we don't loop forever on a dead stream.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
if (radioAudio.paused) return; // user paused — reconnect would resume against intent
if (radioReconnectTimer) return;
if (radioAudio.paused) return;
if (radioReconnectCount >= MAX_RADIO_RECONNECTS) {
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
@@ -66,75 +104,141 @@ radioAudio.addEventListener('stalled', () => {
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
if (radioAudio.paused) return; // user paused while we were waiting
if (radioAudio.paused) return;
radioReconnectCount++;
// Use load() + play() instead of src reassignment — more reliable on
// macOS WKWebView where setting src can fire a premature error event.
radioAudio.load();
radioAudio.play().catch(console.error);
}, RECONNECT_DELAY_MS);
});
// Waiting: browser is rebuffering — normal for live streams, no action needed.
radioAudio.addEventListener('waiting', () => {
console.debug('[psysonic] radio: buffering');
});
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
/**
* Start a new stream. Resets the reconnect counter, sets src + volume,
* and fires play. The returned promise rejects with the audio-element's
* error so callers can surface it (the runtime currently uses this to
* show a toast and clear `currentRadio` state).
*/
export function playRadioStream(streamUrl: string, volume: number): Promise<void> {
radioReconnectCount = 0;
radioAudio.src = streamUrl;
radioAudio.volume = Math.max(0, Math.min(1, volume));
return radioAudio.play();
async function maybeAttachEqGraph(): Promise<boolean> {
if (isRadioEqGraphActive()) {
radioGraphActive = true;
await resumeRadioEqContext().catch(() => {});
return true;
}
if (!shouldUseRadioEqGraph() || radioGraphActive) return false;
radioGraphActive = await tryAttachRadioEqGraph(radioAudio);
if (!radioGraphActive) {
radioAudio.removeAttribute('crossorigin');
console.warn('[psysonic] radio EQ unavailable — playing without Web Audio graph');
}
return radioGraphActive;
}
export async function playRadioStream(streamUrl: string, volume: number): Promise<void> {
radioReconnectCount = 0;
radioGraphActive = isRadioEqGraphActive();
suppressHtml5RadioErrors = true;
radioAwaitingFirstFrame = true;
if (shouldUseRadioEqGraph() || isRadioEqGraphActive()) {
radioAudio.crossOrigin = 'anonymous';
} else {
radioAudio.removeAttribute('crossorigin');
}
radioAudio.src = streamUrl;
if (shouldUseRadioEqGraph()) {
await maybeAttachEqGraph();
}
applyOutputVolume(volume);
if (radioGraphActive) {
await resumeRadioEqContext().catch(() => {});
}
try {
await radioAudio.play();
} catch (err) {
radioAwaitingFirstFrame = false;
suppressHtml5RadioErrors = false;
throw err;
}
}
/** Soft pause — keeps the src loaded so resume can pick up cheaply. */
export function pauseRadio(): void {
// A reconnect timer may be pending from a previous 'stalled' event. Cancel
// it so it can't fire play() against the user's pause intent (issue #779).
clearRadioReconnectTimer();
radioAudio.pause();
}
/** Soft resume — re-plays the loaded src without reconnect. */
export function resumeRadio(): Promise<void> {
export async function resumeRadio(): Promise<void> {
warmRadioEqContextFromUserGesture();
if (usePlayerStore.getState().currentRadio && shouldUseRadioEqGraph()) {
await maybeAttachEqGraph();
if (radioGraphActive) applyOutputVolume(lastVolume);
}
await resumeRadioEqContext().catch(() => {});
return radioAudio.play();
}
/**
* Full stop. Marks the next 'error' event as expected (so the listener
* doesn't show an error toast), pauses, and clears the src so the
* browser releases the underlying network resources.
*/
export function stopRadio(): void {
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
radioAudio.removeAttribute('crossorigin');
radioGraphActive = false;
radioAwaitingFirstFrame = false;
clearRadioReconnectTimer();
radioReconnectCount = 0;
}
export function setRadioVolume(volume: number): void {
radioAudio.volume = Math.max(0, Math.min(1, volume));
applyOutputVolume(volume);
}
/**
* When EQ is enabled during an active radio session, attach the Web Audio graph
* in place (no stream restart). Filter updates are handled by `bindRadioEqStore`.
*/
export function bindRadioEqAttachOnEnable(): () => void {
if (eqAttachUnsub) {
return () => {
eqAttachUnsub?.();
eqAttachUnsub = null;
};
}
eqAttachUnsub = useEqStore.subscribe((state, prev) => {
if (!state.enabled || prev.enabled) return;
const player = usePlayerStore.getState();
if (!player.currentRadio || radioGraphActive) return;
radioAudio.crossOrigin = 'anonymous';
void maybeAttachEqGraph().then(() => {
if (radioGraphActive) {
applyOutputVolume(lastVolume);
const { gains, enabled, preGain } = useEqStore.getState();
applyRadioEqSettings(gains, enabled, preGain);
}
});
});
return () => {
eqAttachUnsub?.();
eqAttachUnsub = null;
};
}
/** Test-only access to the underlying audio element + reset hook. */
export function _radioAudioForTest(): HTMLAudioElement {
return radioAudio;
}
export function _resetRadioPlayerForTest(): void {
radioStopping = false;
suppressHtml5RadioErrors = false;
radioAwaitingFirstFrame = false;
radioReconnectCount = 0;
radioGraphActive = false;
lastVolume = 1;
clearRadioReconnectTimer();
radioAudio.pause();
radioAudio.src = '';
radioAudio.removeAttribute('crossorigin');
setRadioEqMasterVolume(1);
}
export function _radioGraphActiveForTest(): boolean {
return radioGraphActive;
}
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetRadioEqGraphForTest,
applyRadioEqSettings,
clampBandGain,
clampPreGainDb,
dbToLinear,
setRadioEqMasterVolume,
tryAttachRadioEqGraph,
_radioEqGraphForTest,
} from '@/features/playback/utils/audio/radioEqGraph';
describe('radioEqGraph helpers', () => {
it('dbToLinear converts 0 dB to unity', () => {
expect(dbToLinear(0)).toBeCloseTo(1);
expect(dbToLinear(6)).toBeCloseTo(1.995, 2);
expect(dbToLinear(-6)).toBeCloseTo(0.501, 2);
});
it('clampBandGain matches Rust EQ limits', () => {
expect(clampBandGain(-20)).toBe(-12);
expect(clampBandGain(20)).toBe(12);
expect(clampBandGain(3)).toBe(3);
});
it('clampPreGainDb matches eqStore limits', () => {
expect(clampPreGainDb(-40)).toBe(-30);
expect(clampPreGainDb(10)).toBe(6);
});
});
describe('radioEqGraph with mocked Web Audio', () => {
class MockGain {
gain = { value: 1 };
connect() { return this; }
}
class MockBiquad {
type = 'peaking';
frequency = { value: 1000 };
Q = { value: 1 };
gain = { value: 0 };
connect() { return this; }
}
class MockContext {
state: AudioContextState = 'running';
sampleRate = 48_000;
destination = {};
createGain() { return new MockGain(); }
createBiquadFilter() { return new MockBiquad(); }
createMediaElementSource() {
return { connect() { return undefined; } };
}
resume = async () => { this.state = 'running'; };
close = async () => {};
}
afterEach(() => {
_resetRadioEqGraphForTest();
delete (window as unknown as { AudioContext?: typeof AudioContext }).AudioContext;
});
it('builds graph and routes master volume through headroom gain', async () => {
(window as unknown as { AudioContext: typeof AudioContext }).AudioContext =
MockContext as unknown as typeof AudioContext;
const audio = document.createElement('audio');
expect(await tryAttachRadioEqGraph(audio)).toBe(true);
setRadioEqMasterVolume(0.5);
const g = _radioEqGraphForTest();
expect(g?.masterGain.gain.value).toBeCloseTo(0.5 * 0.891_254, 5);
});
it('applies bypass when EQ disabled', async () => {
(window as unknown as { AudioContext: typeof AudioContext }).AudioContext =
MockContext as unknown as typeof AudioContext;
const audio = document.createElement('audio');
await tryAttachRadioEqGraph(audio);
applyRadioEqSettings([6, 6, 6, 0, 0, 0, 0, 0, 0, 0], false, 3);
const g = _radioEqGraphForTest();
expect(g?.preGainNode.gain.value).toBe(1);
for (const band of g?.bands ?? []) {
expect(band.gain.value).toBe(0);
}
});
it('applies band + pre-gain when enabled', async () => {
(window as unknown as { AudioContext: typeof AudioContext }).AudioContext =
MockContext as unknown as typeof AudioContext;
const audio = document.createElement('audio');
await tryAttachRadioEqGraph(audio);
applyRadioEqSettings([4, 0, 0, 0, 0, 0, 0, 0, 0, 0], true, -3);
const g = _radioEqGraphForTest();
expect(g?.preGainNode.gain.value).toBeCloseTo(dbToLinear(-3), 5);
expect(g?.bands[0]?.gain.value).toBe(4);
});
});
@@ -0,0 +1,241 @@
import { EQ_BANDS, useEqStore } from '@/store/eqStore';
/** Matches Rust `MASTER_HEADROOM` in psysonic-audio helpers. */
export const RADIO_MASTER_HEADROOM = 0.891_254;
const EQ_Q = 1.41;
type RadioEqGraph = {
context: AudioContext;
source: MediaElementAudioSourceNode;
preGainNode: GainNode;
bands: BiquadFilterNode[];
masterGain: GainNode;
};
let graph: RadioEqGraph | null = null;
let sharedContext: AudioContext | null = null;
let pendingMasterVolume = 1;
let eqStoreUnsub: (() => void) | null = null;
function clampVolume(v: number): number {
return Math.max(0, Math.min(1, v));
}
function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
function clampBandGain(db: number): number {
return Math.max(-12, Math.min(12, db));
}
function clampPreGainDb(db: number): number {
return Math.max(-30, Math.min(6, db));
}
function clampCenterFreq(hz: number, sampleRate: number): number {
return Math.max(20, Math.min(hz, sampleRate / 2 - 100));
}
function audioContextCtor(): typeof AudioContext | null {
if (typeof window === 'undefined') return null;
return window.AudioContext
?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
?? null;
}
export function isRadioEqAvailable(): boolean {
return audioContextCtor() != null;
}
export function isRadioEqGraphActive(): boolean {
return graph != null;
}
function syncMasterGain(g: RadioEqGraph): void {
g.masterGain.gain.value = clampVolume(pendingMasterVolume) * RADIO_MASTER_HEADROOM;
}
function applyEqToGraph(g: RadioEqGraph, gains: number[], enabled: boolean, preGainDb: number): void {
const sampleRate = g.context.sampleRate;
if (!enabled) {
g.preGainNode.gain.value = 1;
for (const band of g.bands) {
band.gain.value = 0;
}
return;
}
g.preGainNode.gain.value = dbToLinear(clampPreGainDb(preGainDb));
for (let i = 0; i < g.bands.length; i++) {
const node = g.bands[i]!;
node.frequency.value = clampCenterFreq(EQ_BANDS[i]!.freq, sampleRate);
node.Q.value = EQ_Q;
node.gain.value = clampBandGain(gains[i] ?? 0);
}
}
function getOrCreateContext(): AudioContext | null {
if (graph) return graph.context;
if (sharedContext) return sharedContext;
const Ctx = audioContextCtor();
if (!Ctx) return null;
try {
sharedContext = new Ctx({ latencyHint: 'playback' });
return sharedContext;
} catch {
return null;
}
}
/**
* Resume AudioContext synchronously from a user-gesture handler (before any
* `await`) so WebKit/WebView2 allow playback through the graph later.
*/
export function warmRadioEqContextFromUserGesture(): void {
const context = getOrCreateContext();
if (!context) return;
if (context.state === 'suspended') {
void context.resume();
}
}
export async function resumeRadioEqContext(): Promise<void> {
if (graph?.context.state === 'suspended') {
await graph.context.resume();
}
}
/**
* Wire Web Audio EQ for the radio `<audio>` element. Only call when EQ is
* **enabled** — `createMediaElementSource` hijacks element output; on streams
* without CORS that path is silent, so EQ-off keeps the native element path.
*
* Set `crossOrigin="anonymous"` on the element before assigning `src`.
*/
export async function tryAttachRadioEqGraph(audio: HTMLAudioElement): Promise<boolean> {
// Element output is permanently hijacked once the graph exists — never report
// "inactive" or callers fall back to element.volume (silent / wrong routing).
if (graph) {
syncMasterGain(graph);
await resumeRadioEqContext().catch(() => {});
return true;
}
const context = getOrCreateContext();
if (!context) return false;
try {
await context.resume();
} catch {
return false;
}
if (context.state !== 'running') return false;
audio.crossOrigin = 'anonymous';
try {
const preGainNode = context.createGain();
const bands = EQ_BANDS.map((band) => {
const node = context.createBiquadFilter();
node.type = 'peaking';
node.frequency.value = clampCenterFreq(band.freq, context.sampleRate);
node.Q.value = EQ_Q;
node.gain.value = 0;
return node;
});
const masterGain = context.createGain();
let tail: AudioNode = preGainNode;
for (const band of bands) {
tail.connect(band);
tail = band;
}
tail.connect(masterGain);
masterGain.connect(context.destination);
// Create the source last — once it exists the element output is hijacked.
const source = context.createMediaElementSource(audio);
source.connect(preGainNode);
graph = { context, source, preGainNode, bands, masterGain };
const { gains, enabled, preGain: preGainDb } = useEqStore.getState();
applyEqToGraph(graph, gains, enabled, preGainDb);
syncMasterGain(graph);
return true;
} catch (err) {
console.warn('[psysonic] radio Web Audio EQ attach failed:', err);
audio.removeAttribute('crossorigin');
graph = null;
return false;
}
}
export function applyRadioEqSettings(gains: number[], enabled: boolean, preGainDb: number): void {
if (!graph) return;
applyEqToGraph(graph, gains, enabled, preGainDb);
}
export function setRadioEqMasterVolume(volume: number): void {
pendingMasterVolume = clampVolume(volume);
if (graph) syncMasterGain(graph);
}
/** Route loudness through the graph when attached; otherwise native element volume. */
export function applyRadioOutputVolume(volume: number, graphActive: boolean): void {
setRadioEqMasterVolume(volume);
if (graphActive && graph) {
return;
}
}
export function shouldUseRadioEqGraph(): boolean {
return useEqStore.getState().enabled;
}
/**
* Live EQ store → Web Audio graph. Toggling EQ/presets/sliders updates filter
* nodes instantly — no stream restart (issue #1276).
*/
export function bindRadioEqStore(): () => void {
if (!eqStoreUnsub) {
const push = (): void => {
const { gains, enabled, preGain } = useEqStore.getState();
applyRadioEqSettings(gains, enabled, preGain);
};
push();
eqStoreUnsub = useEqStore.subscribe((state, prev) => {
if (
state.gains !== prev.gains
|| state.enabled !== prev.enabled
|| state.preGain !== prev.preGain
) {
push();
}
});
}
return () => {
eqStoreUnsub?.();
eqStoreUnsub = null;
};
}
export function _radioEqGraphForTest(): RadioEqGraph | null {
return graph;
}
export function _resetRadioEqGraphForTest(): void {
eqStoreUnsub?.();
eqStoreUnsub = null;
if (graph) {
void graph.context.close();
graph = null;
}
if (sharedContext) {
void sharedContext.close();
sharedContext = null;
}
pendingMasterVolume = 1;
}
export { clampBandGain, clampPreGainDb, dbToLinear };