mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user