fix(core,studio): silence hidden audio in preview, and call it mute

Preview scheduled every audio[data-start] regardless of data-hidden, so a
hidden audio track was silent in the export but audible in preview — render
was already correct, this was a preview-only parity bug. Web Audio scheduling
now skips (and re-syncs on toggle) any audio clip under a data-hidden
ancestor; the HTMLMedia per-tick volume path folds the same check into
effectiveVolume without touching el.muted (transport-owned). Ships unflagged
since it's a bugfix restoring parity.

Also relabels the eye as Mute/Muted on audio-only track rows (icon,
strikethrough label, undo-history copy), gated behind the new
audio-track-mute canary — the relabel is a copy/UX change, kept separate from
the behavior fix above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:08:25 -07:00
co-authored by Claude Sonnet 5
parent 47fae4d49c
commit 58cb979f4d
8 changed files with 285 additions and 11 deletions
@@ -194,6 +194,87 @@ describe("toggleTimelineTrackHidden", () => {
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
});
it("labels an audio-only track Mute/Unmute instead of Hide/Show", async () => {
const files = new Map([
["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
]);
stubProjectFiles(files);
const recordEdit = vi.fn();
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
track: 0,
hidden: true,
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
});
it("labels unmuting an audio-only track back on", async () => {
const files = new Map([
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
]);
stubProjectFiles(files);
const recordEdit = vi.fn();
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [
element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio", hidden: true }),
],
track: 0,
hidden: false,
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Unmute track 1");
});
it("keeps Hide/Show wording for a mixed (audio + visual) track", async () => {
const files = new Map([
[
"index.html",
`<div id="voiceover" data-start="0" data-duration="2"></div>
<div id="caption" data-start="0" data-duration="2"></div>`,
],
]);
stubProjectFiles(files);
const recordEdit = vi.fn();
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [
element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" }),
element({ id: "caption", domId: "caption", track: 0, tag: "div" }),
],
track: 0,
hidden: true,
previewIframe: null,
writeProjectFile: async () => {},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
});
});
describe("toggleTimelineElementHidden", () => {
@@ -7,6 +7,7 @@ import {
trackDisplaySuffix,
} from "../player/components/timelineTrackDisplay";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { isAudioTimelineElement } from "../utils/timelineInspector";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
@@ -218,12 +219,21 @@ export async function toggleTimelineTrackHidden({
const suffix = trackDisplaySuffix(
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
);
const trackElements = timelineElements.filter((element) => element.track === track);
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
const label = isAudioOnlyTrack
? hidden
? `Mute track${suffix}`
: `Unmute track${suffix}`
: hidden
? `Hide track${suffix}`
: `Show track${suffix}`;
return setElementsHidden({
projectId,
activeCompPath,
elements: timelineElements.filter((element) => element.track === track),
elements: trackElements,
hidden,
label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
label,
previewIframe,
writeProjectFile,
recordEdit,
@@ -1,5 +1,6 @@
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { isCanaryEnabled } from "../../telemetry/canary";
import { Music } from "../../icons/SystemIcons";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -61,24 +62,39 @@ interface TimelineTrackHeaderProps {
onSeek?: (time: number) => void;
}
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
// Gated: the relabel ships behind the canary, unlike the preview fix.
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
if (showAsMute) return hidden ? "Muted" : "Mute";
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
}
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
return <Icon size={14} weight="bold" aria-hidden="true" />;
}
function VisibilityButton({
hidden,
trackNumber,
trackDisplayNumber,
visible,
isAudioTrack,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
trackDisplayNumber: number | null;
visible: boolean;
isAudioTrack?: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
// Display number in the text, real key in the callback. The two must not be
// conflated in either direction.
const suffix = trackDisplaySuffix(trackDisplayNumber);
const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
return (
<button
type="button"
@@ -93,11 +109,7 @@ function VisibilityButton({
void onToggle?.(trackNumber, !hidden);
}}
>
{hidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
{visibilityButtonIcon(showAsMute, hidden)}
</button>
);
}
@@ -129,7 +141,14 @@ function PlainTrackHeader({
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
<span
className={`min-w-0 flex-1 truncate text-[11px] ${
isAudioTrack && isTrackHidden && isCanaryEnabled("audio-track-mute")
? "line-through"
: ""
}`}
title={trackLabel}
>
{trackLabel}
</span>
)}
@@ -139,6 +158,7 @@ function PlainTrackHeader({
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
</>
@@ -406,6 +426,7 @@ function AutomationLaneHeaderRow({
);
}
// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
trackNumber,
trackDisplayNumber,
@@ -515,6 +536,7 @@ export function TimelineTrackHeader({
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
</LayerDisclosureRow>