mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio,core)!: remove solo and the group meter (#3454)
* feat(studio,core)!: remove solo and the group meter * docs(audio): keep removal rationale current * refactor(core): retire studio solo bridge
This commit is contained in:
@@ -232,24 +232,3 @@ export function ensureAudioGroupInertStyle(doc: Document): void {
|
||||
style.textContent = `${HF_AUDIO_GROUP_TAG}{display:none!important}`;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
/** Compatibility bridge until the Studio solo controls are removed later in the stack. */
|
||||
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));
|
||||
}
|
||||
|
||||
/** Compatibility bridge until the Studio solo controls are removed later in the stack. */
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -43,10 +43,8 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import {
|
||||
audioGroupOf,
|
||||
ensureAudioGroupInertStyle,
|
||||
HF_AUDIO_GROUP_TAG,
|
||||
isAudibleUnderSolo,
|
||||
isMemberGroupHidden,
|
||||
} from "../audioGroups";
|
||||
import { clampNativeMediaVolume } from "../audioGain";
|
||||
@@ -189,15 +187,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
void webAudio.init().then((ok) => {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
// Keep Studio's session-only "Hear only this" bridge alive until the solo
|
||||
// controls are removed later in the stack. It must not be serialized into
|
||||
// the composition because solo is preview state, not authored state.
|
||||
let soloedIds: ReadonlySet<string> = new Set();
|
||||
window.__hf = window.__hf || {};
|
||||
window.__hf.setAudioSolo = (ids) => {
|
||||
soloedIds = new Set(ids);
|
||||
webAudio.setSolo(soloedIds);
|
||||
};
|
||||
/** Hidden by an ancestor, or by the BUS this clip belongs to. The bus is
|
||||
* never an ancestor — membership is on the member's `data-audio-group` — so
|
||||
* `closest()` alone could not see a muted group, which the render drops. */
|
||||
@@ -2122,7 +2112,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)),
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
|
||||
@@ -224,9 +224,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. WebAudio-owned
|
||||
* sources apply the same predicate on their dedicated solo gain. */
|
||||
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
|
||||
forceSync?: boolean;
|
||||
}): void {
|
||||
const forceMuteAll = !!(params.outputMuted || params.userMuted);
|
||||
@@ -343,9 +340,7 @@ export function syncRuntimeMedia(params: {
|
||||
// fallback played at full level.
|
||||
const silencedByHidden =
|
||||
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
|
||||
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);
|
||||
|
||||
@@ -18,20 +18,11 @@ function createMockAudioContext(currentTime = 100) {
|
||||
}),
|
||||
_fireEnded: () => endedListeners.forEach((cb) => cb()),
|
||||
};
|
||||
// Every createGain call returns a distinct node. Sharing one hides graph
|
||||
// errors because the solo stage can overwrite the authored-volume stage.
|
||||
const gainNodes: Array<{
|
||||
gain: { value: number };
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const makeGain = () => {
|
||||
const node = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||
gainNodes.push(node);
|
||||
return node;
|
||||
const gainNode = {
|
||||
gain: { value: 1 },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
const gainNode = makeGain();
|
||||
let served = 0;
|
||||
const mediaElementSourceNode = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
@@ -46,11 +37,11 @@ function createMockAudioContext(currentTime = 100) {
|
||||
resume: vi.fn(),
|
||||
createBufferSource: vi.fn(() => sourceNode),
|
||||
createMediaElementSource: vi.fn(() => mediaElementSourceNode),
|
||||
createGain: vi.fn(() => (served++ === 0 ? gainNode : makeGain())),
|
||||
createGain: vi.fn(() => gainNode),
|
||||
destination: {},
|
||||
close: vi.fn(),
|
||||
};
|
||||
return { ctx, sourceNode, mediaElementSourceNode, gainNode, gainNodes, masterGain, startFn };
|
||||
return { ctx, sourceNode, mediaElementSourceNode, gainNode, masterGain, startFn };
|
||||
}
|
||||
|
||||
function setupTransport(currentTime = 100) {
|
||||
@@ -121,9 +112,7 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.ctx.createMediaElementSource).toHaveBeenCalledWith(mockEl);
|
||||
expect(mock.ctx.createBufferSource).not.toHaveBeenCalled();
|
||||
expect(mock.mediaElementSourceNode.connect).toHaveBeenCalled();
|
||||
const [volumeGain, soloGain] = mock.gainNodes;
|
||||
expect(volumeGain?.connect).toHaveBeenCalledWith(soloGain);
|
||||
expect(soloGain?.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(mock.gainNode.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(mockEl.muted).toBe(false);
|
||||
expect(mockEl.volume).toBe(1);
|
||||
expect(mock.gainNode.gain.value).toBe(0.8);
|
||||
@@ -748,15 +737,15 @@ describe("WebAudioTransport", () => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("routes an ungrouped clip to master through its own solo stage", async () => {
|
||||
it("routes an ungrouped clip straight to master through its own gain", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
await scheduleGrouped(transport, gen, "lone");
|
||||
|
||||
expect(mock.gainNodes).toHaveLength(2);
|
||||
const [clipGain, soloGain] = mock.gainNodes;
|
||||
expect(clipGain!.connect).toHaveBeenCalledWith(soloGain);
|
||||
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);
|
||||
});
|
||||
|
||||
// The media-element transport is the PRIMARY path for audio — the runtime
|
||||
@@ -801,9 +790,7 @@ describe("WebAudioTransport", () => {
|
||||
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
|
||||
|
||||
const clipGain = mock.gainNodes[0]!;
|
||||
const soloGain = mock.gainNodes[5]!;
|
||||
expect(clipGain.connect).toHaveBeenCalledWith(soloGain);
|
||||
expect(soloGain.connect).toHaveBeenCalledWith(firstGroupInput(mock));
|
||||
expect(clipGain.connect).toHaveBeenCalledWith(firstGroupInput(mock));
|
||||
expect(clipGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
@@ -813,9 +800,8 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
|
||||
|
||||
expect(mock.gainNodes).toHaveLength(2);
|
||||
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.gainNodes[1]);
|
||||
expect(mock.gainNodes[1]!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(mock.gainNodes).toHaveLength(1);
|
||||
expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
it("two members of the same group land on ONE shared group gain, not master directly", async () => {
|
||||
@@ -824,23 +810,20 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Creation order for a: gain(0), group bus(1–4), solo(5). Then b uses
|
||||
// gain(6), solo(7); both solo stages feed the one shared group input.
|
||||
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[5]!;
|
||||
const groupInput = firstGroupInput(mock);
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
const muteGain = mock.gainNodes[3]!;
|
||||
const fader = mock.gainNodes[4]!;
|
||||
const bGain = mock.gainNodes[6]!;
|
||||
const bSolo = mock.gainNodes[7]!;
|
||||
const bGain = mock.gainNodes[5]!;
|
||||
|
||||
// Both members feed their own solo stage, then the shared bus.
|
||||
expect(aGain.connect).toHaveBeenCalledWith(aSolo);
|
||||
expect(bGain.connect).toHaveBeenCalledWith(bSolo);
|
||||
expect(aSolo.connect).toHaveBeenCalledWith(groupInput);
|
||||
expect(bSolo.connect).toHaveBeenCalledWith(groupInput);
|
||||
// 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);
|
||||
|
||||
@@ -860,11 +843,11 @@ describe("WebAudioTransport", () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const gainCountAfterFirst = mock.gainNodes.length;
|
||||
const gainCountAfterFirst = mock.gainNodes.length; // a-gain + group input/output/mute/fader
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Only b's volume and solo gains 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 () => {
|
||||
@@ -930,45 +913,6 @@ describe("WebAudioTransport", () => {
|
||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('solo — "Hear only this" compatibility bridge', () => {
|
||||
it("silences non-soloed members without attenuating their shared group bus", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
transport.setSolo(new Set(["a"]));
|
||||
|
||||
expect(mock.gainNodes[5]!.gain.value).toBe(1);
|
||||
expect(mock.gainNodes[7]!.gain.value).toBe(0);
|
||||
expect(firstGroupInput(mock).gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("soloing a group keeps every member of that group audible", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
transport.setSolo(new Set(["vo"]));
|
||||
|
||||
expect(mock.gainNodes[5]!.gain.value).toBe(1);
|
||||
expect(mock.gainNodes[7]!.gain.value).toBe(1);
|
||||
});
|
||||
|
||||
it("applies an existing solo to newly scheduled clips and restores them when cleared", 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);
|
||||
expect(mock.gainNodes[3]!.gain.value).toBe(0);
|
||||
|
||||
transport.setSolo(new Set());
|
||||
expect(mock.gainNodes[1]!.gain.value).toBe(1);
|
||||
expect(mock.gainNodes[3]!.gain.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Surviving stopAll() is the point of the bus — and the trap. Its envelopes
|
||||
// were booked against the FIRST pass's absolute context times, so a replay
|
||||
// or a seek left the fader holding that pass's last value: 0 after a
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
type AutomationTiming,
|
||||
} from "../audio/audioFxAutomation.js";
|
||||
import { VOLUME_RANGE } from "../audioAutomation.js";
|
||||
import {
|
||||
audioGroupOf,
|
||||
isAudibleUnderSolo,
|
||||
readAudioGroupVolume,
|
||||
resolveGroupElement,
|
||||
} from "../audioGroups.js";
|
||||
import { audioGroupOf, readAudioGroupVolume, resolveGroupElement } from "../audioGroups.js";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { clampAudioGain } from "../audioGain.js";
|
||||
import { getDebugSurface } from "./globals.js";
|
||||
@@ -111,8 +106,6 @@ function scheduleVolumeLane(
|
||||
type ScheduledSourceBase = {
|
||||
el: HTMLMediaElement;
|
||||
gainNode: GainNode;
|
||||
/** Dedicated solo stage so toggles never overwrite authored volume ramps. */
|
||||
soloGain: GainNode;
|
||||
/** FX chain spliced between source and gain, when the element carries one. */
|
||||
fx?: ElementFxHandle | null;
|
||||
compositionStart: number;
|
||||
@@ -176,8 +169,6 @@ export class WebAudioTransport {
|
||||
private _rate = 1;
|
||||
private _paused = true;
|
||||
private _playGeneration = 0;
|
||||
// Session-only preview state pushed by Studio. Never serialized.
|
||||
private _soloed: ReadonlySet<string> = new Set();
|
||||
|
||||
async init(): Promise<boolean> {
|
||||
try {
|
||||
@@ -251,26 +242,6 @@ export class WebAudioTransport {
|
||||
return this._playGeneration;
|
||||
}
|
||||
|
||||
/** Connect one source through its own solo stage and then into its group bus
|
||||
* (or master for an ungrouped clip). Resolving the group first preserves one
|
||||
* shared bus while solo remains strictly per member. */
|
||||
private connectThroughSolo(
|
||||
ctx: AudioContext,
|
||||
masterGain: GainNode,
|
||||
el: HTMLMediaElement,
|
||||
gainNode: GainNode,
|
||||
timing: { scheduledAt: number; compositionTime: number; rate: number },
|
||||
): GainNode {
|
||||
const destination =
|
||||
this.resolveDestination(el, timing.scheduledAt, timing.compositionTime, timing.rate) ??
|
||||
masterGain;
|
||||
const soloGain = ctx.createGain();
|
||||
soloGain.gain.value = isAudibleUnderSolo(this._soloed, el.id, audioGroupOf(el)) ? 1 : 0;
|
||||
gainNode.connect(soloGain);
|
||||
soloGain.connect(destination);
|
||||
return soloGain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the browser's pitch-preserving HTMLMediaElement transport through the
|
||||
* same FX, automation, element-gain, and master graph used by final audio.
|
||||
@@ -311,11 +282,9 @@ export class WebAudioTransport {
|
||||
// its fallback), so routing it at master would have left every grouped
|
||||
// track bypassing the bus whose whole premise is that a group is one
|
||||
// signal.
|
||||
const soloGain = this.connectThroughSolo(this._ctx, this._masterGain, el, gainNode, {
|
||||
scheduledAt,
|
||||
compositionTime,
|
||||
rate: safeRate,
|
||||
});
|
||||
gainNode.connect(
|
||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||
);
|
||||
scheduleVolumeLane(el, gainNode, timing);
|
||||
|
||||
this._rate = safeRate;
|
||||
@@ -328,7 +297,6 @@ export class WebAudioTransport {
|
||||
sourceNode,
|
||||
sourceKind: "media-element",
|
||||
gainNode,
|
||||
soloGain,
|
||||
compositionStart,
|
||||
mediaStart: _mediaStart,
|
||||
scheduledAt,
|
||||
@@ -515,7 +483,6 @@ export class WebAudioTransport {
|
||||
sourceNode.disconnect();
|
||||
scheduled.fx?.dispose();
|
||||
scheduled.gainNode.disconnect();
|
||||
scheduled.soloGain.disconnect();
|
||||
} catch {
|
||||
// Already torn down.
|
||||
}
|
||||
@@ -568,11 +535,9 @@ 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.connectThroughSolo(this._ctx, this._masterGain, el, gainNode, {
|
||||
scheduledAt,
|
||||
compositionTime,
|
||||
rate: safeRate,
|
||||
});
|
||||
gainNode.connect(
|
||||
this.resolveDestination(el, scheduledAt, compositionTime, safeRate) ?? this._masterGain,
|
||||
);
|
||||
|
||||
scheduleVolumeLane(el, gainNode, timing);
|
||||
|
||||
@@ -594,7 +559,6 @@ export class WebAudioTransport {
|
||||
sourceNode.disconnect();
|
||||
fx?.dispose();
|
||||
gainNode.disconnect();
|
||||
soloGain.disconnect();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -608,7 +572,6 @@ export class WebAudioTransport {
|
||||
sourceNode,
|
||||
sourceKind: "buffer",
|
||||
gainNode,
|
||||
soloGain,
|
||||
compositionStart,
|
||||
mediaStart,
|
||||
scheduledAt,
|
||||
@@ -690,7 +653,6 @@ export class WebAudioTransport {
|
||||
source.sourceNode.disconnect();
|
||||
source.fx?.dispose();
|
||||
source.gainNode.disconnect();
|
||||
source.soloGain.disconnect();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
@@ -735,25 +697,6 @@ export class WebAudioTransport {
|
||||
this.applyMasterGain();
|
||||
}
|
||||
|
||||
/** Update every active source without rebuilding the graph. Group ids solo
|
||||
* all members through the shared audibility predicate. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyMasterGain(): void {
|
||||
if (this._masterGain) this._masterGain.gain.value = this._masterMuted ? 0 : this._masterVolume;
|
||||
}
|
||||
|
||||
-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;
|
||||
};
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
|
||||
* or null when the group is idle/unknown — never zero for "not playing yet".
|
||||
*
|
||||
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
|
||||
* cadence (only while playing — see `postGroupLevels` in init.ts), this just
|
||||
* throttles the re-render, it does not add its own polling loop.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
|
||||
|
||||
const THROTTLE_MS = 33;
|
||||
|
||||
export function useGroupLevel(groupId: string): GroupLevelReading | null {
|
||||
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
const unsubscribe = groupLevels.subscribe((levels) => {
|
||||
latestRef.current = levels.get(groupId) ?? null;
|
||||
if (!timerId) {
|
||||
timerId = setTimeout(() => {
|
||||
timerId = 0;
|
||||
forceRender((v) => v + 1);
|
||||
}, THROTTLE_MS);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
return latestRef.current;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { groupLevels } from "../store/groupLevels";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
groupLevels.notify(new Map());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
|
||||
const onVolumeChange = vi.fn();
|
||||
const onVolumeCommit = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineGroupBusStrip
|
||||
groupId="vo"
|
||||
volume={1}
|
||||
memberLabels={["vo-1", "vo-2"]}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeCommit={onVolumeCommit}
|
||||
theme={defaultTimelineTheme}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { onVolumeChange, onVolumeCommit };
|
||||
}
|
||||
|
||||
function slider(): HTMLInputElement {
|
||||
return container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
}
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
/** React tracks the DOM's own value setter to detect real changes — a plain
|
||||
* `input.value = ...` assignment is invisible to it, so onChange never
|
||||
* fires. Go through the native setter, same as this codebase's other
|
||||
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
|
||||
function setSliderValue(input: HTMLInputElement, value: string) {
|
||||
nativeInputValueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("TimelineGroupBusStrip", () => {
|
||||
it('renders "Holds …" from the member labels, comma-joined', () => {
|
||||
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
|
||||
expect(container.textContent).toContain("Holds vo-1, vo-2");
|
||||
});
|
||||
|
||||
it("falls back to a neutral line when a group has no members yet", () => {
|
||||
renderStrip({ memberLabels: [] });
|
||||
expect(container.textContent).toContain("Holds nothing yet");
|
||||
});
|
||||
|
||||
it("live-writes on every drag tick, but only commits once on release", () => {
|
||||
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
|
||||
const input = slider();
|
||||
|
||||
act(() => setSliderValue(input, "1.2"));
|
||||
act(() => setSliderValue(input, "1.5"));
|
||||
expect(onVolumeChange).toHaveBeenCalledTimes(2);
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
|
||||
expect(onVolumeCommit).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
input.value = "1.5";
|
||||
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
||||
});
|
||||
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
|
||||
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
|
||||
});
|
||||
|
||||
it("clamps the volume slider to the 0..2 range", () => {
|
||||
const { onVolumeChange } = renderStrip();
|
||||
const input = slider();
|
||||
expect(input.min).toBe("0");
|
||||
expect(input.max).toBe("2");
|
||||
|
||||
act(() => setSliderValue(input, "2"));
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
// Clipping stops — the warning must still hold for a couple seconds.
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2001);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip({ volume: 1.5 });
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent ?? "").not.toMatch(/dB/i);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* B7: the group's own volume slider + a living level bar + "Holds …" — the
|
||||
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
|
||||
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
|
||||
* the sound, and the words "Too loud" when it clips.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGroupLevel } from "../../hooks/useGroupLevel";
|
||||
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
|
||||
/** How long "Too loud" stays lit after the last clipped block. */
|
||||
const CLIP_HOLD_MS = 2000;
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
return Math.min(2, Math.max(0, value));
|
||||
}
|
||||
|
||||
interface TimelineGroupBusStripProps {
|
||||
groupId: string;
|
||||
volume: number;
|
||||
memberLabels: readonly string[];
|
||||
onVolumeChange: (value: number) => void;
|
||||
onVolumeCommit: (value: number) => void;
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
export function TimelineGroupBusStrip({
|
||||
groupId,
|
||||
volume,
|
||||
memberLabels,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
theme,
|
||||
}: TimelineGroupBusStripProps) {
|
||||
const [dragValue, setDragValue] = useState<number | null>(null);
|
||||
const reading = useGroupLevel(groupId);
|
||||
const [clipped, setClipped] = useState(false);
|
||||
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reading?.clipped) return;
|
||||
setClipped(true);
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
|
||||
}, [reading?.clipped]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const shownVolume = dragValue ?? volume;
|
||||
const level = Math.min(1, reading?.level ?? 0);
|
||||
const holdsText =
|
||||
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
|
||||
style={{ top: TRACK_H, height: STRIP_H }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Group volume"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={shownVolume}
|
||||
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
|
||||
onChange={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(next);
|
||||
onVolumeChange(next);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(null);
|
||||
onVolumeCommit(next);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
||||
style={{ background: theme.gutterBorder }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${level * 100}%`,
|
||||
background: clipped ? "#ff5c5c" : "#3CE6AC",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
||||
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
||||
{holdsText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
|
||||
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
|
||||
* toggles membership without disturbing the rest of the set.
|
||||
*/
|
||||
export function TimelineSoloButton({
|
||||
isSoloed,
|
||||
onToggle,
|
||||
}: {
|
||||
isSoloed: boolean;
|
||||
onToggle: (options?: { add?: boolean }) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-pressed={isSoloed}
|
||||
aria-label="Hear only this"
|
||||
title="Hear only this"
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle({ add: event.metaKey || event.ctrlKey });
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">⌗</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -769,9 +769,6 @@ describe("TimelineTrackHeader", () => {
|
||||
domId: "voice-2",
|
||||
};
|
||||
|
||||
// The set is pushed straight into the runtime, which compares it against
|
||||
// `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
|
||||
// false for every element, and soloing silences the whole preview.
|
||||
// A member row is `aria-level="2"`, and without this it looked identical to
|
||||
// every top-level row — the nesting existed for a screen reader and not for
|
||||
// an eye. B2's design called for the accent rail; only the semantics shipped.
|
||||
|
||||
@@ -7,7 +7,6 @@ export const GUTTER = 32;
|
||||
export const LABEL_COL_W = 232;
|
||||
export const TRACK_H = 48;
|
||||
export const LANE_H = 28;
|
||||
export const STRIP_H = 40; // compatibility bridge; removed later in the stack
|
||||
export const RULER_H = 24;
|
||||
export const CLIP_Y = 3;
|
||||
export const CLIP_HANDLE_W = 18;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "./playerStore";
|
||||
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
|
||||
import * as studioFileHistory from "../../utils/studioFileHistory";
|
||||
|
||||
describe("audioSoloSlice", () => {
|
||||
it("is exclusive by default, and clicking the only soloed element again clears it", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
|
||||
usePlayerStore.getState().toggleSolo("b");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||
usePlayerStore.getState().toggleSolo("b");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
|
||||
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||
});
|
||||
|
||||
it("clearSolo empties the set", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().clearSolo();
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("export-safety: solo never touches an attribute or the save path", () => {
|
||||
it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
|
||||
usePlayerStore.getState().clearSolo();
|
||||
const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
|
||||
const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
|
||||
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||
usePlayerStore.getState().clearSolo();
|
||||
|
||||
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||
expect(removeAttributeSpy).not.toHaveBeenCalled();
|
||||
setAttributeSpy.mockRestore();
|
||||
removeAttributeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
|
||||
usePlayerStore.getState().clearSolo();
|
||||
const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
|
||||
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
usePlayerStore.getState().clearSolo();
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
saveSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAudibleUnderSolo — the three-bullet rule", () => {
|
||||
it("everything is audible when no solo is active", () => {
|
||||
expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
|
||||
expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("a soloed element is audible", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("a sibling of a soloed element is silent", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
|
||||
});
|
||||
|
||||
it("a member of a soloed group is audible (group solo = members solo)", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
|
||||
// clip-a is soloed directly; clip-b shares its group but is not itself
|
||||
// soloed and the group itself is not soloed — clip-b stays silent.
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
|
||||
expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGroupHalfLitUnderSolo", () => {
|
||||
it("is false when no solo is active", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the group itself is soloed (fully lit, not half)", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a member is soloed but the group is not", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when an unrelated element is soloed", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* "Hear only this" — session-only monitoring override, never persisted.
|
||||
*
|
||||
* Holds clip ids and group ids (never track numbers, per the design doc: solo
|
||||
* is a per-element/per-group concept, not a row-position one). Exclusive by
|
||||
* default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
|
||||
* member without disturbing the rest. Never written to any attribute or
|
||||
* document — the export-safety guarantee this slice exists to hold.
|
||||
*/
|
||||
import type { StoreApi } from "zustand";
|
||||
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
|
||||
|
||||
export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
|
||||
|
||||
export interface AudioSoloSlice {
|
||||
soloed: ReadonlySet<string>;
|
||||
toggleSolo: (id: string, options?: { add?: boolean }) => void;
|
||||
clearSolo: () => void;
|
||||
}
|
||||
|
||||
export function createAudioSoloSlice(
|
||||
set: StoreApi<AudioSoloSlice>["setState"],
|
||||
get: StoreApi<AudioSoloSlice>["getState"],
|
||||
): AudioSoloSlice {
|
||||
return {
|
||||
soloed: new Set(),
|
||||
toggleSolo: (id, options) => {
|
||||
const current = get().soloed;
|
||||
if (options?.add) {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
set({ soloed: next });
|
||||
return;
|
||||
}
|
||||
// Exclusive click: soloing the only-soloed element again clears it;
|
||||
// otherwise it replaces the set.
|
||||
const isOnlyMember = current.size === 1 && current.has(id);
|
||||
set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
|
||||
},
|
||||
clearSolo: () => set({ soloed: new Set() }),
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
|
||||
/** A group's live meter reading, or absent when idle/unknown (never zero). */
|
||||
export type GroupLevelReading = { level: number; clipped: boolean };
|
||||
|
||||
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
|
||||
|
||||
export const groupLevels = {
|
||||
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
|
||||
latest = levels;
|
||||
listeners.forEach((listener) => listener(levels));
|
||||
},
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
get: () => latest,
|
||||
};
|
||||
|
||||
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
|
||||
export function parseGroupLevelsMessage(
|
||||
data: unknown,
|
||||
): ReadonlyMap<string, GroupLevelReading> | null {
|
||||
const levels = (data as { levels?: unknown } | null)?.levels;
|
||||
if (!Array.isArray(levels)) return null;
|
||||
return new Map(
|
||||
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
|
||||
entry.groupId,
|
||||
{ level: entry.level, clipped: entry.clipped },
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
|
||||
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
||||
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
||||
import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
|
||||
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
export { liveTime } from "./liveTime";
|
||||
@@ -52,12 +51,7 @@ function resolveElementSelection(
|
||||
}
|
||||
|
||||
interface PlayerState
|
||||
extends
|
||||
KeyframeSlice,
|
||||
AutomationSelectionSlice,
|
||||
ThumbnailSlice,
|
||||
AudioSoloSlice,
|
||||
EditingModeSlice {
|
||||
extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, EditingModeSlice {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -325,7 +319,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
...createThumbnailSlice(set),
|
||||
|
||||
...createAutomationSelectionSlice(set),
|
||||
...createAudioSoloSlice(set, get),
|
||||
...createEditingModeSlice(set),
|
||||
|
||||
activeKeyframePct: null,
|
||||
|
||||
@@ -125,6 +125,34 @@ export const ALLOWED_DELETIONS = new Map([
|
||||
"packages/studio/src/hooks/useAudioSoloBridge.ts",
|
||||
"#3453 removes the obsolete solo bridge after its last consumer leaves",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/hooks/useGroupLevel.ts",
|
||||
"#3454 deliberately removes the group level meter with the group volume strip",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx",
|
||||
"#3454 deliberately removes the group volume and level-meter strip and its tests",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/components/TimelineGroupBusStrip.tsx",
|
||||
"#3454 deliberately removes the group volume and level-meter strip",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/components/TimelineSoloButton.tsx",
|
||||
"#3454 deliberately removes track and group solo controls",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/store/audioSoloSlice.test.ts",
|
||||
"#3454 deliberately removes session solo state and its tests",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/store/audioSoloSlice.ts",
|
||||
"#3454 deliberately removes session solo state",
|
||||
],
|
||||
[
|
||||
"packages/studio/src/player/store/groupLevels.ts",
|
||||
"#3454 deliberately removes group level-meter state",
|
||||
],
|
||||
]);
|
||||
|
||||
export function parseBase(argv, fallback = "origin/main") {
|
||||
|
||||
Reference in New Issue
Block a user