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 16:39:24 -07:00
co-authored by Claude Sonnet 5
parent e966311627
commit adfdb69a78
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,