Skip to content

Commit 48b7d5a

Browse files
committed
refactor(core): retire studio solo bridge
1 parent abab55a commit 48b7d5a

5 files changed

Lines changed: 35 additions & 170 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,8 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG
4343
import { TransportClock } from "./clock";
4444
import { WebAudioTransport } from "./webAudioTransport";
4545
import {
46-
audioGroupOf,
4746
ensureAudioGroupInertStyle,
4847
HF_AUDIO_GROUP_TAG,
49-
isAudibleUnderSolo,
5048
isMemberGroupHidden,
5149
} from "../audioGroups";
5250
import { clampNativeMediaVolume } from "../audioGain";
@@ -189,15 +187,7 @@ export function initSandboxRuntimeModular(): void {
189187
void webAudio.init().then((ok) => {
190188
webAudioReady = ok;
191189
});
192-
// Keep Studio's session-only "Hear only this" bridge alive until the solo
193-
// controls are removed later in the stack. It must not be serialized into
194-
// the composition because solo is preview state, not authored state.
195-
let soloedIds: ReadonlySet<string> = new Set();
196190
window.__hf = window.__hf || {};
197-
window.__hf.setAudioSolo = (ids) => {
198-
soloedIds = new Set(ids);
199-
webAudio.setSolo(soloedIds);
200-
};
201191
/** Hidden by an ancestor, or by the BUS this clip belongs to. The bus is
202192
* never an ancestor — membership is on the member's `data-audio-group` — so
203193
* `closest()` alone could not see a muted group, which the render drops. */
@@ -2122,7 +2112,6 @@ export function initSandboxRuntimeModular(): void {
21222112
webAudio.setElementVolume(el, authorVolume),
21232113
isWebAudioOwned: (el) => webAudio.ownsElement(el),
21242114
isWebAudioRouted: (el) => webAudio.routesElement(el),
2125-
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
21262115
onAutoplayBlocked: () => {
21272116
if (state.mediaAutoplayBlockedPosted) return;
21282117
state.mediaAutoplayBlockedPosted = true;

packages/core/src/runtime/media.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,6 @@ export function syncRuntimeMedia(params: {
224224
/** Native media routed through WebAudio keeps its upstream element volume at
225225
* unity; do not mistake that transport write for an authored volume edit. */
226226
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
227-
/** "Hear only this" gate for the HTMLMedia fallback path. WebAudio-owned
228-
* sources apply the same predicate on their dedicated solo gain. */
229-
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
230227
forceSync?: boolean;
231228
}): void {
232229
const forceMuteAll = !!(params.outputMuted || params.userMuted);
@@ -343,9 +340,7 @@ export function syncRuntimeMedia(params: {
343340
// fallback played at full level.
344341
const silencedByHidden =
345342
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
346-
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
347-
const effectiveVolume =
348-
silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
343+
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
349344
el.volume = effectiveVolume;
350345
lastRuntimeAppliedVolume.set(el, effectiveVolume);
351346
params.onElementVolume?.(el, effectiveVolume, authorVolume);

packages/core/src/runtime/webAudioTransport.test.ts

Lines changed: 27 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,11 @@ function createMockAudioContext(currentTime = 100) {
1818
}),
1919
_fireEnded: () => endedListeners.forEach((cb) => cb()),
2020
};
21-
// Every createGain call returns a distinct node. Sharing one hides graph
22-
// errors because the solo stage can overwrite the authored-volume stage.
23-
const gainNodes: Array<{
24-
gain: { value: number };
25-
connect: ReturnType<typeof vi.fn>;
26-
disconnect: ReturnType<typeof vi.fn>;
27-
}> = [];
28-
const makeGain = () => {
29-
const node = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
30-
gainNodes.push(node);
31-
return node;
21+
const gainNode = {
22+
gain: { value: 1 },
23+
connect: vi.fn(),
24+
disconnect: vi.fn(),
3225
};
33-
const gainNode = makeGain();
34-
let served = 0;
3526
const mediaElementSourceNode = {
3627
connect: vi.fn(),
3728
disconnect: vi.fn(),
@@ -46,11 +37,11 @@ function createMockAudioContext(currentTime = 100) {
4637
resume: vi.fn(),
4738
createBufferSource: vi.fn(() => sourceNode),
4839
createMediaElementSource: vi.fn(() => mediaElementSourceNode),
49-
createGain: vi.fn(() => (served++ === 0 ? gainNode : makeGain())),
40+
createGain: vi.fn(() => gainNode),
5041
destination: {},
5142
close: vi.fn(),
5243
};
53-
return { ctx, sourceNode, mediaElementSourceNode, gainNode, gainNodes, masterGain, startFn };
44+
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, startFn };
5445
}
5546

5647
function setupTransport(currentTime = 100) {
@@ -121,9 +112,7 @@ describe("WebAudioTransport", () => {
121112
expect(mock.ctx.createMediaElementSource).toHaveBeenCalledWith(mockEl);
122113
expect(mock.ctx.createBufferSource).not.toHaveBeenCalled();
123114
expect(mock.mediaElementSourceNode.connect).toHaveBeenCalled();
124-
const [volumeGain, soloGain] = mock.gainNodes;
125-
expect(volumeGain?.connect).toHaveBeenCalledWith(soloGain);
126-
expect(soloGain?.connect).toHaveBeenCalledWith(mock.masterGain);
115+
expect(mock.gainNode.connect).toHaveBeenCalledWith(mock.masterGain);
127116
expect(mockEl.muted).toBe(false);
128117
expect(mockEl.volume).toBe(1);
129118
expect(mock.gainNode.gain.value).toBe(0.8);
@@ -748,15 +737,15 @@ describe("WebAudioTransport", () => {
748737
document.body.innerHTML = "";
749738
});
750739

751-
it("routes an ungrouped clip to master through its own solo stage", async () => {
740+
it("routes an ungrouped clip straight to master through its own gain", async () => {
752741
const { transport, mock, gen } = setupGroupTransport();
753742

754743
await scheduleGrouped(transport, gen, "lone");
755744

756-
expect(mock.gainNodes).toHaveLength(2);
757-
const [clipGain, soloGain] = mock.gainNodes;
758-
expect(clipGain!.connect).toHaveBeenCalledWith(soloGain);
759-
expect(soloGain!.connect).toHaveBeenCalledWith(mock.masterGain);
745+
// One gain per clip now that solo is gone — it goes straight to master.
746+
expect(mock.gainNodes).toHaveLength(1);
747+
const [clipGain] = mock.gainNodes;
748+
expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain);
760749
});
761750

762751
// The media-element transport is the PRIMARY path for audio — the runtime
@@ -801,9 +790,7 @@ describe("WebAudioTransport", () => {
801790
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
802791

803792
const clipGain = mock.gainNodes[0]!;
804-
const soloGain = mock.gainNodes[5]!;
805-
expect(clipGain.connect).toHaveBeenCalledWith(soloGain);
806-
expect(soloGain.connect).toHaveBeenCalledWith(firstGroupInput(mock));
793+
expect(clipGain.connect).toHaveBeenCalledWith(firstGroupInput(mock));
807794
expect(clipGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
808795
});
809796

@@ -813,9 +800,8 @@ describe("WebAudioTransport", () => {
813800

814801
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
815802

816-
expect(mock.gainNodes).toHaveLength(2);
817-
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.gainNodes[1]);
818-
expect(mock.gainNodes[1]!.connect).toHaveBeenCalledWith(mock.masterGain);
803+
expect(mock.gainNodes).toHaveLength(1);
804+
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
819805
});
820806

821807
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
@@ -824,23 +810,20 @@ describe("WebAudioTransport", () => {
824810
await scheduleGrouped(transport, gen, "a", "vo");
825811
await scheduleGrouped(transport, gen, "b", "vo");
826812

827-
// Creation order for a: gain(0), group bus(1–4), solo(5). Then b uses
828-
// gain(6), solo(7); both solo stages feed the one shared group input.
829-
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8);
813+
// Creation order for a: a-gain(0), groupInput(1), groupOutput(2),
814+
// muteGain(3), fader(4) — the group bus is built lazily inside a's
815+
// schedule call. Then b: b-gain(5).
816+
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(6);
830817
const aGain = mock.gainNodes[0]!;
831-
const aSolo = mock.gainNodes[5]!;
832818
const groupInput = firstGroupInput(mock);
833819
const groupOutput = mock.gainNodes[2]!;
834820
const muteGain = mock.gainNodes[3]!;
835821
const fader = mock.gainNodes[4]!;
836-
const bGain = mock.gainNodes[6]!;
837-
const bSolo = mock.gainNodes[7]!;
838-
839-
// Both members feed their own solo stage, then the shared bus.
840-
expect(aGain.connect).toHaveBeenCalledWith(aSolo);
841-
expect(bGain.connect).toHaveBeenCalledWith(bSolo);
842-
expect(aSolo.connect).toHaveBeenCalledWith(groupInput);
843-
expect(bSolo.connect).toHaveBeenCalledWith(groupInput);
822+
const bGain = mock.gainNodes[5]!;
823+
824+
// Both members feed the shared bus — neither connects straight to master.
825+
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
826+
expect(bGain.connect).toHaveBeenCalledWith(groupInput);
844827
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
845828
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
846829

@@ -860,11 +843,11 @@ describe("WebAudioTransport", () => {
860843
const { transport, mock, gen } = setupGroupTransport();
861844

862845
await scheduleGrouped(transport, gen, "a", "vo");
863-
const gainCountAfterFirst = mock.gainNodes.length;
846+
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group input/output/mute/fader
864847
await scheduleGrouped(transport, gen, "b", "vo");
865848

866-
// Only b's volume and solo gains are new — no second group bus minted.
867-
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
849+
// Only b's own gain is new — no second group bus minted.
850+
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
868851
});
869852

870853
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
@@ -930,45 +913,6 @@ describe("WebAudioTransport", () => {
930913
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
931914
});
932915

933-
describe('solo — "Hear only this" compatibility bridge', () => {
934-
it("silences non-soloed members without attenuating their shared group bus", async () => {
935-
const { transport, mock, gen } = setupGroupTransport();
936-
await scheduleGrouped(transport, gen, "a", "vo");
937-
await scheduleGrouped(transport, gen, "b", "vo");
938-
939-
transport.setSolo(new Set(["a"]));
940-
941-
expect(mock.gainNodes[5]!.gain.value).toBe(1);
942-
expect(mock.gainNodes[7]!.gain.value).toBe(0);
943-
expect(firstGroupInput(mock).gain.value).toBe(1);
944-
});
945-
946-
it("soloing a group keeps every member of that group audible", async () => {
947-
const { transport, mock, gen } = setupGroupTransport();
948-
await scheduleGrouped(transport, gen, "a", "vo");
949-
await scheduleGrouped(transport, gen, "b", "vo");
950-
951-
transport.setSolo(new Set(["vo"]));
952-
953-
expect(mock.gainNodes[5]!.gain.value).toBe(1);
954-
expect(mock.gainNodes[7]!.gain.value).toBe(1);
955-
});
956-
957-
it("applies an existing solo to newly scheduled clips and restores them when cleared", async () => {
958-
const { transport, mock, gen } = setupGroupTransport();
959-
transport.setSolo(new Set(["a"]));
960-
await scheduleGrouped(transport, gen, "a");
961-
await scheduleGrouped(transport, gen, "b");
962-
963-
expect(mock.gainNodes[1]!.gain.value).toBe(1);
964-
expect(mock.gainNodes[3]!.gain.value).toBe(0);
965-
966-
transport.setSolo(new Set());
967-
expect(mock.gainNodes[1]!.gain.value).toBe(1);
968-
expect(mock.gainNodes[3]!.gain.value).toBe(1);
969-
});
970-
});
971-
972916
// Surviving stopAll() is the point of the bus — and the trap. Its envelopes
973917
// were booked against the FIRST pass's absolute context times, so a replay
974918
// or a seek left the fader holding that pass's last value: 0 after a

packages/core/src/runtime/webAudioTransport.ts

Lines changed: 7 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ import {
66
type AutomationTiming,
77
} from "../audio/audioFxAutomation.js";
88
import { VOLUME_RANGE } from "../audioAutomation.js";
9-
import {
10-
audioGroupOf,
11-
isAudibleUnderSolo,
12-
readAudioGroupVolume,
13-
resolveGroupElement,
14-
} from "../audioGroups.js";
9+
import { audioGroupOf, readAudioGroupVolume, resolveGroupElement } from "../audioGroups.js";
1510
import { swallow } from "./diagnostics";
1611
import { clampAudioGain } from "../audioGain.js";
1712
import { getDebugSurface } from "./globals.js";
@@ -111,8 +106,6 @@ function scheduleVolumeLane(
111106
type ScheduledSourceBase = {
112107
el: HTMLMediaElement;
113108
gainNode: GainNode;
114-
/** Dedicated solo stage so toggles never overwrite authored volume ramps. */
115-
soloGain: GainNode;
116109
/** FX chain spliced between source and gain, when the element carries one. */
117110
fx?: ElementFxHandle | null;
118111
compositionStart: number;
@@ -176,8 +169,6 @@ export class WebAudioTransport {
176169
private _rate = 1;
177170
private _paused = true;
178171
private _playGeneration = 0;
179-
// Session-only preview state pushed by Studio. Never serialized.
180-
private _soloed: ReadonlySet<string> = new Set();
181172

182173
async init(): Promise<boolean> {
183174
try {
@@ -251,26 +242,6 @@ export class WebAudioTransport {
251242
return this._playGeneration;
252243
}
253244

254-
/** Connect one source through its own solo stage and then into its group bus
255-
* (or master for an ungrouped clip). Resolving the group first preserves one
256-
* shared bus while solo remains strictly per member. */
257-
private connectThroughSolo(
258-
ctx: AudioContext,
259-
masterGain: GainNode,
260-
el: HTMLMediaElement,
261-
gainNode: GainNode,
262-
timing: { scheduledAt: number; compositionTime: number; rate: number },
263-
): GainNode {
264-
const destination =
265-
this.resolveDestination(el, timing.scheduledAt, timing.compositionTime, timing.rate) ??
266-
masterGain;
267-
const soloGain = ctx.createGain();
268-
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
269-
gainNode.connect(soloGain);
270-
soloGain.connect(destination);
271-
return soloGain;
272-
}
273-
274245
/**
275246
* Route the browser's pitch-preserving HTMLMediaElement transport through the
276247
* same FX, automation, element-gain, and master graph used by final audio.
@@ -311,11 +282,9 @@ export class WebAudioTransport {
311282
// its fallback), so routing it at master would have left every grouped
312283
// track bypassing the bus whose whole premise is that a group is one
313284
// signal.
314-
const soloGain = this.connectThroughSolo(this._ctx, this._masterGain, el, gainNode, {
315-
scheduledAt,
316-
compositionTime,
317-
rate: safeRate,
318-
});
285+
gainNode.connect(
286+
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
287+
);
319288
scheduleVolumeLane(el, gainNode, timing);
320289

321290
this._rate = safeRate;
@@ -328,7 +297,6 @@ export class WebAudioTransport {
328297
sourceNode,
329298
sourceKind: "media-element",
330299
gainNode,
331-
soloGain,
332300
compositionStart,
333301
mediaStart: _mediaStart,
334302
scheduledAt,
@@ -515,7 +483,6 @@ export class WebAudioTransport {
515483
sourceNode.disconnect();
516484
scheduled.fx?.dispose();
517485
scheduled.gainNode.disconnect();
518-
scheduled.soloGain.disconnect();
519486
} catch {
520487
// Already torn down.
521488
}
@@ -568,11 +535,9 @@ export class WebAudioTransport {
568535
// output — the same order the offline render uses. Preview and render run
569536
// the identical graph builders, so what is heard here is what is written.
570537
const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing);
571-
const soloGain = this.connectThroughSolo(this._ctx, this._masterGain, el, gainNode, {
572-
scheduledAt,
573-
compositionTime,
574-
rate: safeRate,
575-
});
538+
gainNode.connect(
539+
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
540+
);
576541

577542
scheduleVolumeLane(el, gainNode, timing);
578543

@@ -594,7 +559,6 @@ export class WebAudioTransport {
594559
sourceNode.disconnect();
595560
fx?.dispose();
596561
gainNode.disconnect();
597-
soloGain.disconnect();
598562
return null;
599563
}
600564

@@ -608,7 +572,6 @@ export class WebAudioTransport {
608572
sourceNode,
609573
sourceKind: "buffer",
610574
gainNode,
611-
soloGain,
612575
compositionStart,
613576
mediaStart,
614577
scheduledAt,
@@ -690,7 +653,6 @@ export class WebAudioTransport {
690653
source.sourceNode.disconnect();
691654
source.fx?.dispose();
692655
source.gainNode.disconnect();
693-
source.soloGain.disconnect();
694656
} catch {
695657
// already stopped
696658
}
@@ -735,25 +697,6 @@ export class WebAudioTransport {
735697
this.applyMasterGain();
736698
}
737699

738-
/** Update every active source without rebuilding the graph. Group ids solo
739-
* all members through the shared audibility predicate. */
740-
setSolo(soloed: ReadonlySet<string>): void {
741-
this._soloed = soloed;
742-
for (const source of this._activeSources) {
743-
try {
744-
source.soloGain.gain.value = isAudibleUnderSolo(
745-
this._soloed,
746-
source.el.id,
747-
audioGroupOf(source.el),
748-
)
749-
? 1
750-
: 0;
751-
} catch (err) {
752-
swallow("webAudioTransport.setSolo", err);
753-
}
754-
}
755-
}
756-
757700
private applyMasterGain(): void {
758701
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
759702
}

0 commit comments

Comments
 (0)