From 1f295fb9270a913327e0751b16803318f02a09b4 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:52:43 +0300 Subject: [PATCH 1/2] fix(music): stream AAC on macOS once the equalizer or crossfade is in use --- src/lib/audio/musicAudioEngine.eq.test.ts | 42 +++++++++-- src/lib/audio/musicAudioEngine.test.ts | 14 ++-- src/lib/audio/musicAudioEngine.ts | 69 ++++++++++++++----- src/lib/audio/musicAudioSettings.ts | 1 + src/lib/audio/webAudioFormats.test.ts | 23 +++++++ src/lib/audio/webAudioFormats.ts | 11 +++ src/lib/musicPreload.ts | 4 +- src/lib/musicStreamResolution.ts | 7 +- .../useMusicPlayerStore.playback.test.ts | 23 +++++++ src/store/useMusicPlayerStore.queue.test.ts | 9 ++- src/store/useMusicPlayerStore.ts | 27 ++++++-- 11 files changed, 189 insertions(+), 41 deletions(-) create mode 100644 src/lib/audio/webAudioFormats.test.ts create mode 100644 src/lib/audio/webAudioFormats.ts diff --git a/src/lib/audio/musicAudioEngine.eq.test.ts b/src/lib/audio/musicAudioEngine.eq.test.ts index 075d27a6..c24ab307 100644 --- a/src/lib/audio/musicAudioEngine.eq.test.ts +++ b/src/lib/audio/musicAudioEngine.eq.test.ts @@ -4,6 +4,14 @@ import { graphSpec, EQ_GRAPH_OFF } from "./eq/graphPlan"; import { defaultEqState, selectPreset } from "./eq/stateOps"; import { musicAudioEngine } from "./musicAudioEngine"; +const platform = vi.hoisted(() => ({ macos: false })); +vi.mock("../platform", () => ({ + IS_LINUX_RUNTIME: false, + get IS_MACOS_RUNTIME() { + return platform.macos; + }, +})); + // A minimal WebAudio stand-in: enough to see which nodes exist and what they were told. class FakeParam { constructor(public value = 0) {} @@ -92,6 +100,7 @@ beforeEach(() => { }); afterEach(() => { + platform.macos = false; vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -124,8 +133,8 @@ describe("music EQ graph", () => { expect(ctx.biquads[0]!.gain.value).toBe(rock.bands[0]!.gain); expect(ctx.biquads[5]!.gain.value).toBe(0); - await musicAudioEngine.load("http://127.0.0.1/stream/a"); - musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null); + await musicAudioEngine.load("http://127.0.0.1/stream/a", null); + musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null, null); Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); expect(musicAudioEngine.activatePreloaded("b")).toBe(true); @@ -138,6 +147,27 @@ describe("music EQ graph", () => { expect(ctx.biquads.every((f) => f.gain.value === 0)).toBe(true); }); + it("asks for AAC on macOS, and keeps a WebM song it cannot hear on the element's volume", async () => { + platform.macos = true; + const pair = elements(); + musicAudioEngine.attach(pair); + musicAudioEngine.setEq(EQ_GRAPH_OFF, false); + expect(musicAudioEngine.wantsAac()).toBe(false); + musicAudioEngine.setEq(rock, true); + expect(musicAudioEngine.wantsAac()).toBe(true); + + await musicAudioEngine.load("http://127.0.0.1/stream/opus", 'audio/webm; codecs="opus"'); + await musicAudioEngine.play(); + musicAudioEngine.setLoudness(null, true); + musicAudioEngine.setVolume(0.5); + expect(musicAudioEngine.activeBypassesGraph()).toBe(true); + expect(pair[0].volume).toBeCloseTo(0.25, 2); + + await musicAudioEngine.load("http://127.0.0.1/stream/aac", 'audio/mp4; codecs="mp4a.40.2"'); + expect(musicAudioEngine.activeBypassesGraph()).toBe(false); + expect(pair[0].volume).toBe(1); + }); + it("starts the element even when the context will not resume", async () => { vi.useFakeTimers(); const pair = elements(); @@ -175,8 +205,8 @@ describe("music EQ graph", () => { Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); musicAudioEngine.setCrossfadeEnabled(true); const ctx = lastContext(); - await musicAudioEngine.load("http://127.0.0.1/stream/a"); - musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null); + await musicAudioEngine.load("http://127.0.0.1/stream/a", null); + musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null, null); Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); musicAudioEngine.armCrossfade(4); @@ -205,8 +235,8 @@ describe("crossfade without WebAudio", () => { engine.setCrossfadeEnabled(true); expect(engine.isEqAvailable()).toBe(false); - await engine.load("http://127.0.0.1/stream/a"); - engine.preload("b", "http://127.0.0.1/stream/b", null); + await engine.load("http://127.0.0.1/stream/a", null); + engine.preload("b", "http://127.0.0.1/stream/b", null, null); Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); engine.armCrossfade(2); diff --git a/src/lib/audio/musicAudioEngine.test.ts b/src/lib/audio/musicAudioEngine.test.ts index 085178c3..fcd879a4 100644 --- a/src/lib/audio/musicAudioEngine.test.ts +++ b/src/lib/audio/musicAudioEngine.test.ts @@ -28,8 +28,8 @@ beforeEach(() => { describe("gapless standby element", () => { it("buffers the next track on the element that is not playing", async () => { - await musicAudioEngine.load("http://127.0.0.1/stream/current"); - musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", -4); + await musicAudioEngine.load("http://127.0.0.1/stream/current", null); + musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", -4, null); expect(musicAudioEngine.getActiveElement()).toBe(pair[0]); expect(pair[1].getAttribute("src")).toBe("http://127.0.0.1/stream/next"); @@ -38,8 +38,8 @@ describe("gapless standby element", () => { }); it("swaps only for the track it holds, and empties the one that played", async () => { - await musicAudioEngine.load("http://127.0.0.1/stream/current"); - musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", -4); + await musicAudioEngine.load("http://127.0.0.1/stream/current", null); + musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", -4, null); bufferedEnough(pair[1]); expect(musicAudioEngine.activatePreloaded("other")).toBe(false); @@ -53,15 +53,15 @@ describe("gapless standby element", () => { }); it("never swaps in a preload that has no audio to play yet", async () => { - await musicAudioEngine.load("http://127.0.0.1/stream/current"); - musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null); + await musicAudioEngine.load("http://127.0.0.1/stream/current", null); + musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null, null); expect(musicAudioEngine.activatePreloaded("next")).toBe(false); expect(musicAudioEngine.getActiveElement()).toBe(pair[0]); }); it("reports and drops what the standby element held", () => { - musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null); + musicAudioEngine.preload("next", "http://127.0.0.1/stream/next", null, null); expect(musicAudioEngine.clearPreload()).toBe("next"); expect(pair[1].getAttribute("src")).toBeNull(); expect(musicAudioEngine.clearPreload()).toBeNull(); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 34ad9921..60d8f523 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -1,10 +1,11 @@ import { loudnessFactor, MUSIC_MAX_CUT_DB } from "../loudness"; -import { IS_LINUX_RUNTIME } from "../platform"; +import { IS_LINUX_RUNTIME, IS_MACOS_RUNTIME } from "../platform"; import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext"; import { fadeInGain, fadeOutGain, resetFade, scheduleFade, steppedFade } from "./crossfadeRamp"; import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; import { volumeGain } from "./volumeCurve"; +import { webAudioHears } from "./webAudioFormats"; /** * One of the two