mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
feat(studio,core)!: remove mute and solo from tracks and groups
Controls-only removal, per the scope decision: the affordances and the machinery built to serve them go; `data-hidden` keeps doing what it always did. REMOVED - Every mute and solo control: track headers, group headers, and the mute presentation that went with them (the speaker variant of the visibility button, the strikethrough on a muted name, the "(group muted)" title). - Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`, `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the transport's per-source solo gain, `isAudibleUnderSolo` / `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four modules deleted outright. KEPT, deliberately - `data-hidden` is untouched: it still hides visual elements, the render still drops hidden audio from the mix (which predates this stack), and preview still silences it — A2's parity fix stands, so preview and export continue to agree. - Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays, because `data-hidden` on a group still has to reach the preview bus. Only the button that wrote it is gone. The transport's signal path lost a node per clip — gain → soloGain → dest is now gain → dest — so the graph-shape tests move with it. Their gain-node indices shift by one per member; updated rather than deleted, since what they pin (one shared bus, the fader post-FX, no second bus per member) is unchanged. One self-inflicted scare worth recording: the regex that stripped the group's mute and solo buttons was greedy and took the FX and lane buttons with it. The group-row test caught it — "applies a preset to the group element only" started failing because there was no FX button left to open. Restored from HEAD. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full `bun run test` green.
This commit is contained in:
@@ -133,35 +133,3 @@ export function audioGroupOf(el: Element): string | null {
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return null;
|
||||
return typeof el.getAttribute === "function" ? el.getAttribute(HF_AUDIO_GROUP_ATTR) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo ("Hear only this") predicate — shared by the studio store (which owns
|
||||
* the `soloed` set and the UI's lit/half-lit state) and the preview transport
|
||||
* (which turns it into gain). An element is audible while any solo is active
|
||||
* only if IT is soloed, or its OWN group is soloed (group solo = members
|
||||
* solo). There is no "ancestor" to reach up to in this data model — a group
|
||||
* bus is never itself attenuated by solo, so a soloed member's path through
|
||||
* its group stays open by construction; this predicate only ever gates the
|
||||
* member's own gain. No solo active at all is the one path that returns true
|
||||
* unconditionally.
|
||||
*/
|
||||
export function isAudibleUnderSolo(
|
||||
soloed: ReadonlySet<string>,
|
||||
id: string,
|
||||
groupId?: string | null,
|
||||
): boolean {
|
||||
if (soloed.size === 0) return true;
|
||||
if (soloed.has(id)) return true;
|
||||
return Boolean(groupId && soloed.has(groupId));
|
||||
}
|
||||
|
||||
/** Half-lit: this group itself isn't soloed, but at least one of its members
|
||||
* is — the display-only signal that "some of what's under here still plays". */
|
||||
export function isGroupHalfLitUnderSolo(
|
||||
soloed: ReadonlySet<string>,
|
||||
groupId: string,
|
||||
memberIds: readonly string[],
|
||||
): boolean {
|
||||
if (soloed.size === 0 || soloed.has(groupId)) return false;
|
||||
return memberIds.some((id) => soloed.has(id));
|
||||
}
|
||||
|
||||
@@ -42,7 +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 { HF_AUDIO_GROUP_TAG } from "../audioGroups";
|
||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
||||
import type {
|
||||
@@ -178,20 +178,7 @@ 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);
|
||||
};
|
||||
// Canary states the HOST resolved, keyed by registry name. Core cannot
|
||||
// resolve one itself — bucketing needs an install id it has no access to —
|
||||
// so every runtime-visible flag arrives through this one channel rather than
|
||||
@@ -2116,7 +2103,6 @@ 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)),
|
||||
silenceHiddenAudio: silenceHiddenAudioEnabled(),
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
|
||||
@@ -217,11 +217,6 @@ 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;
|
||||
/** Silence media under a `data-hidden` ancestor, matching the render. Opt-in:
|
||||
* the host pushes it via `__hf.setCanaries` when the `audio-track-mute`
|
||||
* canary is on. Absent/false = the shipped behaviour (hidden audio still
|
||||
@@ -329,16 +324,11 @@ export function syncRuntimeMedia(params: {
|
||||
// it); preview matches once the host opts in (`silenceHiddenAudio`, the
|
||||
// `audio-track-mute` canary — see init.ts). Folded into the per-tick
|
||||
// volume, not el.muted (RULES trap: el.muted is the transport's ownership
|
||||
// flag). Solo rides the same fold for the same reason — never el.muted,
|
||||
// and never touching any attribute (it is session-only, unlike hidden) —
|
||||
// but is NOT gated: it is a session control with no shipped behaviour to
|
||||
// preserve.
|
||||
// flag).
|
||||
const silencedByHidden = params.silenceHiddenAudio
|
||||
? el.closest("[data-hidden]") !== null
|
||||
: false;
|
||||
const silencedBySolo = params.isAudibleUnderSolo ? !params.isAudibleUnderSolo(el) : false;
|
||||
const effectiveVolume =
|
||||
silencedByHidden || silencedBySolo ? 0 : clampVolume(authorVolume * userVol);
|
||||
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
|
||||
el.volume = effectiveVolume;
|
||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
||||
|
||||
@@ -724,25 +724,23 @@ describe("WebAudioTransport", () => {
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
* 1 in creation order (that member's own gain is 0). */
|
||||
const firstGroupInput = (mock: ReturnType<typeof createGroupMockAudioContext>) =>
|
||||
mock.gainNodes[2]!;
|
||||
mock.gainNodes[1]!;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("routes an ungrouped member straight to master, through its own solo gain", async () => {
|
||||
it("routes an ungrouped clip straight to master through its own gain", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
await scheduleGrouped(transport, gen, "solo");
|
||||
await scheduleGrouped(transport, gen, "lone");
|
||||
|
||||
// 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);
|
||||
// One gain per clip now that solo is gone — it goes straight to master.
|
||||
expect(mock.gainNodes).toHaveLength(1);
|
||||
const [clipGain] = mock.gainNodes;
|
||||
expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
|
||||
@@ -751,27 +749,22 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Creation order for a: a-gain(0), a-solo(1), groupInput(2), groupOutput(3),
|
||||
// muteGain(4), fader(5) — the group bus is built lazily inside a's
|
||||
// schedule call. Then b: b-gain(6), b-solo(7).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(8);
|
||||
// Creation order for a: a-gain(0), groupInput(1), groupOutput(2),
|
||||
// muteGain(3), fader(4) — the group bus is built lazily inside a's
|
||||
// schedule call. Then b: b-gain(5).
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(6);
|
||||
const aGain = mock.gainNodes[0]!;
|
||||
const aSolo = mock.gainNodes[1]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
const groupOutput = mock.gainNodes[3]!;
|
||||
const muteGain = mock.gainNodes[4]!;
|
||||
const fader = mock.gainNodes[5]!;
|
||||
const bGain = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
const bGain = mock.gainNodes[5]!;
|
||||
|
||||
// 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);
|
||||
// Both members feed the shared bus — neither connects straight to master.
|
||||
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
||||
expect(bGain.connect).toHaveBeenCalledWith(groupInput);
|
||||
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
|
||||
// The bus's input never reaches master directly. It runs through the
|
||||
// chain (dry here — neither member's group has a chain-bearing
|
||||
@@ -789,11 +782,11 @@ describe("WebAudioTransport", () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + a-solo + group-input/output/mute
|
||||
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group input/output/mute/fader
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Only b's own gain and its solo gain are new — no second group bus minted.
|
||||
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 2);
|
||||
// Only b's own gain is new — no second group bus minted.
|
||||
expect(mock.gainNodes.length).toBe(gainCountAfterFirst + 1);
|
||||
});
|
||||
|
||||
it("a group id with no matching <hf-audio-group> element still gets a flat bus", async () => {
|
||||
@@ -801,9 +794,9 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
|
||||
|
||||
const muteGain = mock.gainNodes[4]!;
|
||||
const groupOutput = mock.gainNodes[3]!;
|
||||
const fader = mock.gainNodes[5]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(fader);
|
||||
expect(fader.connect).toHaveBeenCalledWith(muteGain);
|
||||
expect(muteGain.connect).toHaveBeenCalledWith(groupOutput);
|
||||
@@ -822,7 +815,7 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull();
|
||||
|
||||
expect(mock.gainNodes[5]!.gain.value).toBeCloseTo(0.4, 6);
|
||||
expect(mock.gainNodes[4]!.gain.value).toBeCloseTo(0.4, 6);
|
||||
});
|
||||
|
||||
it("leaves the fader at unity when the group carries no data-volume", async () => {
|
||||
@@ -832,7 +825,7 @@ describe("WebAudioTransport", () => {
|
||||
// No throw wiring the group's automation reader against a real
|
||||
// <hf-audio-group> element that carries no fx/automation attrs.
|
||||
await expect(scheduleGrouped(transport, gen, "a", "vo")).resolves.not.toBeNull();
|
||||
expect(mock.gainNodes[5]!.gain.value).toBe(1);
|
||||
expect(mock.gainNodes[4]!.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("destroy() disposes every group bus", async () => {
|
||||
@@ -867,7 +860,7 @@ describe("WebAudioTransport", () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[5]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
|
||||
// Something moved the fader mid-pass (a ramp reaching its last point).
|
||||
fader.gain.value = 0;
|
||||
@@ -891,7 +884,7 @@ describe("WebAudioTransport", () => {
|
||||
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const fader = mock.gainNodes[5]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
fader.gain.cancelScheduledValues = vi.fn(() => {
|
||||
throw new Error("param is not schedulable");
|
||||
});
|
||||
@@ -906,76 +899,6 @@ describe("WebAudioTransport", () => {
|
||||
expect(fader.gain.value).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
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[7]!;
|
||||
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[7]!;
|
||||
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[7]!;
|
||||
|
||||
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>`;
|
||||
@@ -983,14 +906,14 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
const muteGain = mock.gainNodes[4]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
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]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
expect(muteGain.gain.value).toBe(1);
|
||||
|
||||
transport.setGroupMuted("vo", true);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||
import { audioGroupOf, isAudibleUnderSolo, readAudioGroupVolume } from "../audioGroups.js";
|
||||
import { audioGroupOf, readAudioGroupVolume } from "../audioGroups.js";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
@@ -101,11 +101,6 @@ 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;
|
||||
@@ -171,10 +166,6 @@ 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 {
|
||||
@@ -506,7 +497,6 @@ export class WebAudioTransport {
|
||||
sourceNode.disconnect();
|
||||
scheduled.fx?.dispose();
|
||||
scheduled.gainNode.disconnect();
|
||||
scheduled.soloGain.disconnect();
|
||||
} catch {
|
||||
// Already torn down.
|
||||
}
|
||||
@@ -559,10 +549,7 @@ 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);
|
||||
const soloGain = this._ctx.createGain();
|
||||
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
|
||||
gainNode.connect(soloGain);
|
||||
soloGain.connect(
|
||||
gainNode.connect(
|
||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||
);
|
||||
|
||||
@@ -586,7 +573,6 @@ export class WebAudioTransport {
|
||||
sourceNode.disconnect();
|
||||
fx?.dispose();
|
||||
gainNode.disconnect();
|
||||
soloGain.disconnect();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -600,7 +586,6 @@ export class WebAudioTransport {
|
||||
sourceNode,
|
||||
sourceKind: "buffer",
|
||||
gainNode,
|
||||
soloGain,
|
||||
compositionStart,
|
||||
mediaStart,
|
||||
scheduledAt,
|
||||
@@ -682,7 +667,6 @@ export class WebAudioTransport {
|
||||
source.sourceNode.disconnect();
|
||||
source.fx?.dispose();
|
||||
source.gainNode.disconnect();
|
||||
source.soloGain.disconnect();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
@@ -731,31 +715,6 @@ 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
@@ -37,12 +37,6 @@ 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;
|
||||
/**
|
||||
* Canary states resolved by the HOST and pushed in, because core cannot
|
||||
* resolve one itself: bucketing needs an install id, which lives in the
|
||||
|
||||
Reference in New Issue
Block a user