feat(studio,core): mute groups, and hear-only-this that cannot reach the export

B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:31 -07:00
co-authored by Claude Sonnet 5
parent e1c9b50948
commit 301cb8b51f
23 changed files with 941 additions and 235 deletions
+32
View File
@@ -42,6 +42,7 @@ import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { HF_AUDIO_GROUP_TAG, audioGroupOf, isAudibleUnderSolo } from "../audioGroups";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import type {
@@ -177,6 +178,20 @@ export function initSandboxRuntimeModular(): void {
void webAudio.init().then((ok) => {
webAudioReady = ok;
});
// Studio's "Hear only this" push channel — session-only, so it rides a
// dedicated `__hf` field (mirrors `colorGrading`'s lazy-init pattern) rather
// than a DOM attribute: solo must never be written to the document (design
// doc §2.2 / the export-safety guarantee), so there is nothing here for
// `syncTimedElementVisibility`'s attribute-diffing to key off. Kept in this
// closure too (not just inside `webAudio`) so `syncRuntimeMedia`'s
// HTMLMedia-fallback path (video/non-transport audio) can apply the same
// predicate per tick, the same split A2 used for `data-hidden`.
let soloedIds: ReadonlySet<string> = new Set();
window.__hf = window.__hf || {};
window.__hf.setAudioSolo = (ids) => {
soloedIds = new Set(ids);
webAudio.setSolo(soloedIds);
};
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
@@ -1924,6 +1939,21 @@ export function initSandboxRuntimeModular(): void {
const nodeAffectsAudio = (node: HTMLElement): boolean =>
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;
// An `<hf-audio-group>` carries no `data-start`, so it is never among
// `visibilityNodes` above — group mute needs its own small diff pass.
// Preview-side only (render reads the group's `data-hidden` directly at
// export time, per B4); this just keeps the live WebAudio group bus in
// sync with a `data-hidden` toggle made mid-playback.
const groupHiddenLast = new WeakMap<Element, boolean>();
const syncAudioGroupMute = () => {
for (const groupEl of document.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
const hidden = groupEl.hasAttribute("data-hidden");
if (groupHiddenLast.get(groupEl) === hidden) continue;
groupHiddenLast.set(groupEl, hidden);
if (groupEl.id) webAudio.setGroupMuted(groupEl.id, hidden);
}
};
const syncTimedElementVisibility = (
currentTime: number,
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
@@ -1988,6 +2018,7 @@ export function initSandboxRuntimeModular(): void {
scheduleWebAudioForActiveClips();
}
hiddenAudioDirty = false;
syncAudioGroupMute();
};
const syncMediaForCurrentState = () => {
@@ -2048,6 +2079,7 @@ export function initSandboxRuntimeModular(): void {
webAudio.setElementVolume(el, authorVolume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
isWebAudioRouted: (el) => webAudio.routesElement(el),
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
+10 -1
View File
@@ -217,6 +217,11 @@ export function syncRuntimeMedia(params: {
/** Native media routed through WebAudio keeps its upstream element volume at
* unity; do not mistake that transport write for an authored volume edit. */
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
/** "Hear only this" gate for the HTMLMedia fallback path (video / any audio
* not owned by the Web Audio transport, which applies its own dedicated
* solo gain instead see `WebAudioTransport.setSolo`). Absent when solo
* isn't wired up at all, which reads as "always audible". */
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
forceSync?: boolean;
}): void {
const forceMuteAll = !!(params.outputMuted || params.userMuted);
@@ -318,7 +323,11 @@ export function syncRuntimeMedia(params: {
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
// it); preview must match. Folded into the per-tick volume, not
// el.muted (RULES trap: el.muted is the transport's ownership flag).
const effectiveVolume = el.closest("[data-hidden]") ? 0 : clampVolume(authorVolume * userVol);
// Solo rides the same fold for the same reason — never el.muted, and
// never touching any attribute (it is session-only, unlike hidden).
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
const effectiveVolume =
el.closest("[data-hidden]") || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume, authorVolume);
@@ -706,23 +706,26 @@ describe("WebAudioTransport", () => {
return el;
}
/** The group's own input gain is built lazily on the first member
* index 1 in creation order (that member's gain is index 0). */
/** The group's own input gain is built lazily on the first member index
* 2 in creation order (that member's own gain is 0, its solo gain 1). */
const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
mock.gainNodes[1]!;
mock.gainNodes[2]!;
beforeEach(() => {
document.body.innerHTML = "";
});
it("routes an ungrouped member straight to master, unchanged", async () => {
it("routes an ungrouped member straight to master, through its own solo gain", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "solo");
// One gain node — the member's own — connected directly to master.
expect(mock.gainNodes).toHaveLength(1);
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
// Member gain, then its dedicated solo gain (B5) — never straight to master.
expect(mock.gainNodes).toHaveLength(2);
const [memberGain, soloGain] = mock.gainNodes;
expect(memberGain!.connect).toHaveBeenCalledWith(soloGain);
expect(memberGain!.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(soloGain!.connect).toHaveBeenCalledWith(mock.masterGain);
});
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
@@ -731,26 +734,33 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
// group's own input/output gain pair (B7's meter taps `output`),
// built inside a's schedule call.
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[2]!;
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
// muteGain(4) — the group bus is built lazily inside a's schedule call.
// Then b: b-gain(5), b-solo(6).
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(7);
const aGain = mock.gainNodes[0]!;
const bGain = mock.gainNodes[3]!;
const aSolo = mock.gainNodes[1]!;
const groupInput = firstGroupInput(mock);
const groupOutput = mock.gainNodes[3]!;
const muteGain = mock.gainNodes[4]!;
const bGain = mock.gainNodes[5]!;
const bSolo = mock.gainNodes[6]!;
// Neither member connects straight to master — both feed the shared bus.
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
expect(bGain.connect).toHaveBeenCalledWith(groupInput);
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
// Each member feeds its own solo gain, and both solo gains feed the
// shared bus — neither connects straight to master.
expect(aGain.connect).toHaveBeenCalledWith(aSolo);
expect(bGain.connect).toHaveBeenCalledWith(bSolo);
expect(aSolo.connect).toHaveBeenCalledWith(groupInput);
expect(bSolo.connect).toHaveBeenCalledWith(groupInput);
expect(aSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(bSolo.connect).not.toHaveBeenCalledWith(mock.masterGain);
// The bus's input never reaches master directly — it lands on the
// output gain (the dry passthrough, since neither member's group has a
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
// The bus's input never reaches master directly — it lands on the mute
// gain (B5) first (the dry passthrough, since neither member's group has
// a chain-bearing `<hf-audio-group>`), then the output gain, then master.
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
expect(groupInput.connect).toHaveBeenCalledWith(muteGain);
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
@@ -758,11 +768,11 @@ describe("WebAudioTransport", () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group-input
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + a-solo + group-input/output/mute
await scheduleGrouped(transport, gen, "b", "vo");
// Only b's own gain is new — no second group-input gain minted.
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
// Only b's own gain and its solo gain are new — no second group bus minted.
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
});
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
@@ -770,8 +780,10 @@ describe("WebAudioTransport", () => {
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
const groupOutput = mock.gainNodes[2]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
const muteGain = mock.gainNodes[4]!;
const groupOutput = mock.gainNodes[3]!;
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(muteGain);
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
});
@@ -808,6 +820,106 @@ describe("WebAudioTransport", () => {
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
});
describe('solo — "Hear only this" (B5)', () => {
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;
const groupInput = firstGroupInput(mock);
transport.setSolo(new Set(["other-clip"]));
expect(aSolo.gain.value).toBe(0);
expect(bSolo.gain.value).toBe(0);
// The group's own bus is never attenuated by solo — only the member
// gain stage is (design doc §2.2: "never ancestors").
expect(groupInput.gain.value).toBe(1);
});
it("soloing a member of a group leaves the group's gain untouched, and only that member is audible", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;
const groupInput = firstGroupInput(mock);
transport.setSolo(new Set(["a"]));
expect(aSolo.gain.value).toBe(1);
expect(bSolo.gain.value).toBe(0); // sibling stays silent
expect(groupInput.gain.value).toBe(1); // group bus itself untouched
});
it("soloing the GROUP id makes every member audible (group solo = members solo)", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
await scheduleGrouped(transport, gen, "b", "vo");
const aSolo = mock.gainNodes[1]!;
const bSolo = mock.gainNodes[6]!;
transport.setSolo(new Set(["vo"]));
expect(aSolo.gain.value).toBe(1);
expect(bSolo.gain.value).toBe(1);
});
it("clearing solo (empty set) restores every member to audible", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const aSolo = mock.gainNodes[1]!;
transport.setSolo(new Set(["other"]));
expect(aSolo.gain.value).toBe(0);
transport.setSolo(new Set());
expect(aSolo.gain.value).toBe(1);
});
it("a newly scheduled member picks up an already-active solo immediately", async () => {
const { transport, mock, gen } = setupGroupTransport();
transport.setSolo(new Set(["a"]));
await scheduleGrouped(transport, gen, "a");
await scheduleGrouped(transport, gen, "b");
expect(mock.gainNodes[1]!.gain.value).toBe(1); // a's own solo gain
expect(mock.gainNodes[3]!.gain.value).toBe(0); // b's own solo gain
});
});
describe("group mute (B5)", () => {
it("a group created with data-hidden already set starts muted (mute gain at 0)", async () => {
document.body.innerHTML = `<hf-audio-group id="vo" data-hidden></hf-audio-group>`;
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const muteGain = mock.gainNodes[4]!;
expect(muteGain.gain.value).toBe(0);
});
it("setGroupMuted toggles the mute gain on an active group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const muteGain = mock.gainNodes[4]!;
expect(muteGain.gain.value).toBe(1);
transport.setGroupMuted("vo", true);
expect(muteGain.gain.value).toBe(0);
transport.setGroupMuted("vo", false);
expect(muteGain.gain.value).toBe(1);
});
it("setGroupMuted on a group with no active member is a no-op, not a throw", () => {
const { transport } = setupGroupTransport();
expect(() => transport.setGroupMuted("never-played", true)).not.toThrow();
});
});
describe("groupLevel meter (B7)", () => {
it("groupLevel returns null for an unknown/idle group id", () => {
const { transport } = setupGroupTransport();
+78 -8
View File
@@ -5,7 +5,7 @@ import {
type AutomationTiming,
} from "../audio/audioFxAutomation.js";
import { VOLUME_RANGE } from "../audioAutomation.js";
import { audioGroupOf } from "../audioGroups.js";
import { audioGroupOf, isAudibleUnderSolo } from "../audioGroups.js";
import { swallow } from "./diagnostics";
import { clampAudioGain } from "../audioGain.js";
import { getDebugSurface } from "./globals.js";
@@ -88,6 +88,11 @@ function scheduleVolumeLane(
type ScheduledSourceBase = {
el: HTMLMediaElement;
gainNode: GainNode;
/** Solo ("Hear only this") attenuation dedicated node, parallel to the
* volume gain, so a solo toggle never fights `scheduleVolumeLane`'s ramps
* on the same param (same hazard B5's group-mute gain was split out to
* avoid). 0 while silenced by an active solo elsewhere, 1 otherwise. */
soloGain: GainNode;
/** FX chain spliced between source and gain, when the element carries one. */
fx?: ElementFxHandle | null;
compositionStart: number;
@@ -129,7 +134,13 @@ export class WebAudioTransport {
// a group does not rebuild its chain; only `destroy()` disposes these.
private _groups = new Map<
string,
{ input: GainNode; analyser: AnalyserNode; levelBuf: Float32Array; dispose(): void }
{
input: GainNode;
muteGain: GainNode;
analyser: AnalyserNode;
levelBuf: Float32Array;
dispose(): void;
}
>();
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
// composition time was `_rateAnchorComp`, and time has been advancing at
@@ -139,6 +150,10 @@ export class WebAudioTransport {
private _rate = 1;
private _paused = true;
private _playGeneration = 0;
// Session-only "Hear only this" set (clip ids and group ids). Never read
// from or written to any attribute — studio pushes it in directly via
// `setSolo`; see `isAudibleUnderSolo` for the exact predicate.
private _soloed: ReadonlySet<string> = new Set();
async init(): Promise<boolean> {
try {
@@ -300,10 +315,10 @@ export class WebAudioTransport {
// Stable point the FX chain (or, when there's none, the dry passthrough —
// see `attachElementFxChain`'s `detach()`) always lands on before master,
// regardless of whether a chain is attached/detached/rebuilt later. B7's
// meter taps here. B5's group mute gain MUST splice in before `output`
// (between the FX chain and here), never after — the meter is defined to
// read the group's true, honestly-muted level (design doc §5), and this
// node is that contract's anchor.
// meter taps here. The mute gain splices in BEFORE `output` (between the
// FX chain and here), never after — the meter is defined to read the
// group's true, honestly-muted level (design doc §5), and this node is
// that contract's anchor.
const output = this._ctx.createGain();
output.connect(this._masterGain);
const analyser = this._ctx.createAnalyser();
@@ -311,23 +326,28 @@ export class WebAudioTransport {
output.connect(analyser);
const groupEl = doc.getElementById(groupId);
const muteGain = this._ctx.createGain();
muteGain.gain.value = groupEl?.hasAttribute("data-hidden") ? 0 : 1;
muteGain.connect(output);
const fx = attachElementFxChain(
this._ctx,
groupEl ?? { getAttribute: () => null },
input,
output,
muteGain,
timing,
);
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
this._groups.set(groupId, {
input,
muteGain,
analyser,
levelBuf: new Float32Array(analyser.fftSize),
dispose: () => {
try {
fx?.dispose();
input.disconnect();
muteGain.disconnect();
output.disconnect();
analyser.disconnect();
} catch {
@@ -338,6 +358,24 @@ export class WebAudioTransport {
return input;
}
/**
* Group mute, preview side a separate gain from `input`'s volume fader
* (B7) so a mute toggle never fights `scheduleVolumeLane`'s ramps on the
* same param (the same hazard the design doc flags for §2.1). A no-op
* until the group has an active member: at that point `groupInput` reads
* the element's own `data-hidden` for its initial value, so there is
* nothing to catch up on here.
*/
setGroupMuted(groupId: string, muted: boolean): void {
const group = this._groups.get(groupId);
if (!group) return;
try {
group.muteGain.gain.value = muted ? 0 : 1;
} catch (err) {
swallow("webAudioTransport.setGroupMuted", err);
}
}
/** Every group id currently routing audio (built lazily by `groupInput`
* a group with no active member yet has no entry here). */
groupIds(): string[] {
@@ -401,6 +439,7 @@ export class WebAudioTransport {
sourceNode.disconnect();
scheduled.fx?.dispose();
scheduled.gainNode.disconnect();
scheduled.soloGain.disconnect();
} catch {
// Already torn down.
}
@@ -453,7 +492,10 @@ export class WebAudioTransport {
// output — the same order the offline render uses. Preview and render run
// the identical graph builders, so what is heard here is what is written.
const fx = attachElementFxChain(this._ctx, el, sourceNode, gainNode, timing);
gainNode.connect(
const soloGain = this._ctx.createGain();
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
gainNode.connect(soloGain);
soloGain.connect(
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
);
@@ -477,6 +519,7 @@ export class WebAudioTransport {
sourceNode.disconnect();
fx?.dispose();
gainNode.disconnect();
soloGain.disconnect();
return null;
}
@@ -490,6 +533,7 @@ export class WebAudioTransport {
sourceNode,
sourceKind: "buffer",
gainNode,
soloGain,
compositionStart,
mediaStart,
scheduledAt,
@@ -561,6 +605,7 @@ export class WebAudioTransport {
source.sourceNode.disconnect();
source.fx?.dispose();
source.gainNode.disconnect();
source.soloGain.disconnect();
} catch {
// already stopped
}
@@ -609,6 +654,31 @@ export class WebAudioTransport {
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
}
/**
* Push the current "Hear only this" set and re-evaluate every active
* source's solo gain against it a gain-stage update, never a graph
* rebuild (rule 3 of B5's step doc). Group buses are never touched here:
* per `isAudibleUnderSolo`, a group is never attenuated by solo, so a
* soloed member's path through its (unattenuated) group stays open by
* construction.
*/
setSolo(soloed: ReadonlySet<string>): void {
this._soloed = soloed;
for (const source of this._activeSources) {
try {
source.soloGain.gain.value = isAudibleUnderSolo(
this._soloed,
source.el.id,
audioGroupOf(source.el),
)
? 1
: 0;
} catch (err) {
swallow("webAudioTransport.setSolo", err);
}
}
}
isActive(): boolean {
return this._activeSources.length > 0 && !this._paused;
}
+6
View File
@@ -37,6 +37,12 @@ declare global {
onSwallowed?: (label: string, err: unknown) => void;
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
duration?: number;
/**
* Studio's "Hear only this" push: the full set of soloed clip/group ids,
* replaced wholesale on every change. Session-only by design never
* read from or written to any document attribute.
*/
setAudioSolo?: (ids: readonly string[]) => void;
};
__playerReady?: boolean;
__renderReady?: boolean;