Skip to content
5 changes: 5 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2743,6 +2743,11 @@ export function initSandboxRuntimeModular(): void {
};
window.__hf.leasePausedMedia = leasePausedMedia;
window.__hf.releasePausedMedia = releasePausedMedia;
window.__hf.audioMeter = {
start: () => webAudio.startMetering(),
stop: () => webAudio.stopMetering(),
read: () => webAudio.readLevels(),
};

// Same predicate the media cache uses, so the paused side sees exactly the
// media the transport drives. Reads attributes only; no cache rebuild.
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/runtime/levelTap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface StereoLevel {
l: number;
r: number;
}

export interface LevelTap {
/** Peak of the last analyser window per channel, linear 0..1+. */
read(): StereoLevel;
dispose(): void;
}

const WINDOW = 2048;

function peak(analyser: AnalyserNode, buf: Float32Array<ArrayBuffer>): number {
analyser.getFloatTimeDomainData(buf);
let max = 0;
for (let i = 0; i < buf.length; i++) max = Math.max(max, Math.abs(buf[i] ?? 0));
return max;
}

/** A side branch off `source`; the explicit-stereo gain up-mixes mono to both channels. */
export function createLevelTap(ctx: BaseAudioContext, source: AudioNode): LevelTap {
const mix = ctx.createGain();
mix.channelCount = 2;
mix.channelCountMode = "explicit";
const splitter = ctx.createChannelSplitter(2);
const left = ctx.createAnalyser();
const right = ctx.createAnalyser();
left.fftSize = right.fftSize = WINDOW;
source.connect(mix);
mix.connect(splitter);
splitter.connect(left, 0);
splitter.connect(right, 1);
const bufL = new Float32Array(new ArrayBuffer(WINDOW * 4));
const bufR = new Float32Array(new ArrayBuffer(WINDOW * 4));
return {
read: () => ({ l: peak(left, bufL), r: peak(right, bufR) }),
dispose: () => {
try {
source.disconnect(mix);
mix.disconnect();
splitter.disconnect();
} catch {
// Source already torn down.
}
},
};
}
138 changes: 129 additions & 9 deletions packages/core/src/runtime/webAudioTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ function createMockAudioContext(currentTime = 100) {
gain: { value: 1 },
connect: vi.fn(),
};
const monitorGain = {
gain: { value: 1 },
connect: vi.fn(),
};
const ctx = {
currentTime,
state: "running",
Expand All @@ -41,14 +45,15 @@ function createMockAudioContext(currentTime = 100) {
destination: {},
close: vi.fn(),
};
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, startFn };
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, monitorGain, startFn };
}

function setupTransport(currentTime = 100) {
const transport = new WebAudioTransport();
const mock = createMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
(transport as unknown as { _monitorGain: unknown })._monitorGain = mock.monitorGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}
Expand Down Expand Up @@ -87,12 +92,12 @@ describe("WebAudioTransport author gain vs user volume", () => {

it("keeps the user's master volume spec-clamped — it is a fader, not a gain", () => {
const transport = new WebAudioTransport();
const master = { gain: { value: 1 }, connect: vi.fn() };
(transport as unknown as { _masterGain: unknown })._masterGain = master;
const monitor = { gain: { value: 1 }, connect: vi.fn() };
(transport as unknown as { _monitorGain: unknown })._monitorGain = monitor;

transport.setVolume(99);

expect(master.gain.value).toBe(1);
expect(monitor.gain.value).toBe(1);
});
});

Expand Down Expand Up @@ -341,11 +346,13 @@ describe("WebAudioTransport", () => {
const { transport, mock } = setupTransport();
transport.setVolume(0.4);
transport.setMuted(true);
expect(mock.masterGain.gain.value).toBe(0);
expect(mock.monitorGain.gain.value).toBe(0);
expect(mock.masterGain.gain.value).toBe(1);

transport.setMuted(false);

expect(mock.masterGain.gain.value).toBe(0.4);
expect(mock.monitorGain.gain.value).toBe(0.4);
expect(mock.masterGain.gain.value).toBe(1);
});

it("applies author and user volume once in separate gain layers", async () => {
Expand All @@ -354,8 +361,11 @@ describe("WebAudioTransport", () => {
transport.setVolume(0.5);

expect(mock.gainNode.gain.value).toBe(0.8);
expect(mock.masterGain.gain.value).toBe(0.5);
expect(mock.gainNode.gain.value * mock.masterGain.gain.value).toBeCloseTo(0.4);
expect(mock.masterGain.gain.value).toBe(1);
expect(mock.monitorGain.gain.value).toBe(0.5);
expect(
mock.gainNode.gain.value * mock.masterGain.gain.value * mock.monitorGain.gain.value,
).toBeCloseTo(0.4);
});

describe("ownsElement (per-element mute gate)", () => {
Expand Down Expand Up @@ -738,6 +748,7 @@ describe("WebAudioTransport", () => {
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
}[] = [];
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const monitorGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const mediaElementSource = { connect: vi.fn(), disconnect: vi.fn() };
const ctx = {
currentTime,
Expand Down Expand Up @@ -784,21 +795,23 @@ describe("WebAudioTransport", () => {
analysers.push(node);
return node;
}),
createChannelSplitter: vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn() })),
// The media-element route needs this as much as the decoded one: without
// it `scheduleMediaElementPlayback` throws and its catch returns null,
// which reads as "the member did not play" rather than a missing stub.
createMediaElementSource: vi.fn(() => mediaElementSource),
destination: {},
close: vi.fn(),
};
return { ctx, gainNodes, analysers, masterGain, mediaElementSource };
return { ctx, gainNodes, analysers, masterGain, monitorGain, mediaElementSource };
}

function setupGroupTransport(currentTime = 100) {
const transport = new WebAudioTransport();
const mock = createGroupMockAudioContext(currentTime);
(transport as unknown as { _ctx: unknown })._ctx = mock.ctx;
(transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain;
(transport as unknown as { _monitorGain: unknown })._monitorGain = mock.monitorGain;
const gen = transport.startGeneration();
return { transport, mock, gen };
}
Expand Down Expand Up @@ -1085,6 +1098,113 @@ describe("WebAudioTransport", () => {
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
});
});

describe("level metering", () => {
const addGroup = (id: string) => {
document.body.insertAdjacentHTML(
"beforeend",
`<hf-audio-group id="${id}"></hf-audio-group>`,
);
};
const setLevel = (analyser: { getFloatTimeDomainData: unknown }, peak: number) => {
(analyser.getFloatTimeDomainData as ReturnType<typeof vi.fn>).mockImplementation(
(buf: Float32Array) => buf.fill(0).fill(-peak, 3, 4),
);
};

it("creates nothing until startMetering", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
expect(mock.ctx.createAnalyser).not.toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
});

it("attaches the master tap once init() lands, even when startMetering ran while the context was still warming up", async () => {
const mock = createGroupMockAudioContext();
class MockAudioContext {
constructor() {
return mock.ctx as unknown as MockAudioContext;
}
}
vi.stubGlobal("AudioContext", MockAudioContext);
const transport = new WebAudioTransport();

transport.startMetering();
expect(mock.ctx.createAnalyser).not.toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });

const ok = await transport.init();

expect(ok).toBe(true);
expect(mock.ctx.createAnalyser).toHaveBeenCalled();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
vi.unstubAllGlobals();
});

it("taps master and each group as side branches, once however often it starts", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const groupOutput = mock.gainNodes[2]!;
const connectsBefore = groupOutput.connect.mock.calls.length;

transport.startMetering();
transport.startMetering();

expect(mock.analysers).toHaveLength(4);
expect(groupOutput.connect).toHaveBeenCalledTimes(connectsBefore + 1);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
expect(mock.masterGain.connect).toHaveBeenCalledTimes(1);
expect(mock.monitorGain.connect).not.toHaveBeenCalled();
});

it("keeps program taps off the monitor fader", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
transport.startMetering();
transport.setVolume(0.5);
transport.setMuted(true);
const groupOutput = mock.gainNodes.find((n) =>
n.connect.mock.calls.some((call) => call[0] === mock.masterGain),
);
expect(mock.masterGain.gain.value).toBe(1);
expect(groupOutput?.gain.value).toBe(1);
expect(mock.monitorGain.gain.value).toBe(0);
});

it("reads the peak per channel and stops cleanly", async () => {
addGroup("vo");
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
transport.startMetering();
const [masterL, , groupL, groupR] = mock.analysers;
setLevel(masterL!, 0.5);
setLevel(groupL!, 0.25);
setLevel(groupR!, 0.125);

const levels = transport.readLevels();
expect(levels.master).toEqual({ l: 0.5, r: 0 });
expect(levels.groups.vo).toEqual({ l: 0.25, r: 0.125 });

transport.stopMetering();
expect(transport.readLevels()).toEqual({ master: { l: 0, r: 0 }, groups: {} });
expect(mock.gainNodes[2]!.disconnect).toHaveBeenCalled();
});

it("a group built after start is metered, and one removed from the document leaves the read", async () => {
const { transport, mock, gen } = setupGroupTransport();
transport.startMetering();
addGroup("late");
await scheduleGrouped(transport, gen, "a", "late");
expect(Object.keys(transport.readLevels().groups)).toEqual(["late"]);

document.querySelector("hf-audio-group")!.remove();
expect(transport.readLevels().groups).toEqual({});
expect(mock.analysers).toHaveLength(4);
});
});
});

describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
Expand Down
Loading
Loading