From 7b10657c8a9c7fe7a23257270d162d69168aab07 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 14 Aug 2026 12:57:50 -0700 Subject: [PATCH] fix(core,studio): silence hidden audio in preview, and call it mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/canaryRegistry.ts | 10 ++ packages/core/src/runtime/init.test.ts | 95 +++++++++++++++++++ packages/core/src/runtime/init.ts | 15 +++ packages/core/src/runtime/media.test.ts | 38 ++++++++ packages/core/src/runtime/media.ts | 5 +- .../src/hooks/timelineTrackVisibility.test.ts | 81 ++++++++++++++++ .../src/hooks/timelineTrackVisibility.ts | 14 ++- .../player/components/TimelineTrackHeader.tsx | 38 ++++++-- 8 files changed, 285 insertions(+), 11 deletions(-) diff --git a/packages/core/src/canaryRegistry.ts b/packages/core/src/canaryRegistry.ts index ce3c0ee7ec..93bb913b60 100644 --- a/packages/core/src/canaryRegistry.ts +++ b/packages/core/src/canaryRegistry.ts @@ -89,6 +89,16 @@ export const CANARIES: readonly CanaryDefinition[] = [ owner: "vance", sunsetAfter: "2026-11-15", }, + { + name: "audio-track-mute", + percentage: 0, + description: + "Label the visibility control as Mute on audio tracks, and make preview " + + "silence data-hidden audio the way the render already does. Fixes a " + + "shipped preview/export mismatch, so it is gated separately.", + owner: "vance", + sunsetAfter: "2026-12-15", + }, ] as const; export function findCanary(name: string): CanaryDefinition | undefined { diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index cc59b76045..79c6f7aa93 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; +import { WebAudioTransport } from "./webAudioTransport"; import type { RuntimeTimelineLike } from "./types"; function createMockTimeline(duration: number): RuntimeTimelineLike { @@ -1260,6 +1261,100 @@ describe("initSandboxRuntimeModular", () => { expect(hiddenClip.style.display).toBe(""); }); + it("excludes a data-hidden audio clip from Web Audio scheduling", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const hiddenAudio = document.createElement("audio"); + hiddenAudio.setAttribute("data-start", "0"); + hiddenAudio.setAttribute("data-duration", "10"); + hiddenAudio.setAttribute("data-hidden", ""); + hiddenAudio.load = () => {}; + hiddenAudio.play = vi.fn(() => Promise.resolve()); + root.appendChild(hiddenAudio); + + const audibleAudio = document.createElement("audio"); + audibleAudio.setAttribute("data-start", "0"); + audibleAudio.setAttribute("data-duration", "10"); + audibleAudio.load = () => {}; + audibleAudio.play = vi.fn(() => Promise.resolve()); + root.appendChild(audibleAudio); + + window.__timelines = { main: createMockTimeline(10) }; + initSandboxRuntimeModular(); + + const decodeSpy = vi + .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + .mockResolvedValue(null); + + const player = window.__player; + player?.play(); + player?.seek(0); + + expect(decodeSpy).toHaveBeenCalledTimes(1); + expect(decodeSpy.mock.calls[0]?.[0]).toBe(audibleAudio); + }); + + it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + // Two separately-toggled audio clips (not a wrapper div — the visibility + // sweep only walks [data-start] nodes, so the attribute must sit on each + // timed element itself, matching how the eye button hides per-element). + const audioA = document.createElement("audio"); + audioA.setAttribute("data-start", "0"); + audioA.setAttribute("data-duration", "10"); + audioA.setAttribute("data-hidden", ""); + audioA.load = () => {}; + audioA.play = vi.fn(() => Promise.resolve()); + root.appendChild(audioA); + + const audioB = document.createElement("audio"); + audioB.setAttribute("data-start", "0"); + audioB.setAttribute("data-duration", "10"); + audioB.setAttribute("data-hidden", ""); + audioB.load = () => {}; + audioB.play = vi.fn(() => Promise.resolve()); + root.appendChild(audioB); + + window.__timelines = { main: createMockTimeline(10) }; + initSandboxRuntimeModular(); + + const player = window.__player; + // play() alone (no seek) already runs one visibility pass while the clock + // is playing, registering both clips as hidden — the baseline this test + // toggles away from. + player?.play(); + + const decodeSpy = vi + .spyOn(WebAudioTransport.prototype, "decodeAudioElement") + .mockResolvedValue(null); + const generationSpy = vi.spyOn(WebAudioTransport.prototype, "startGeneration"); + + // Both become visible in the SAME sync pass — must still be one reschedule. + // keepPlaying: a plain seek() pauses the clock before re-syncing visibility, + // which would make the hiddenAudioDirty branch's isPlaying() gate a no-op. + audioA.removeAttribute("data-hidden"); + audioB.removeAttribute("data-hidden"); + player?.seek(1, { keepPlaying: true }); + + expect(generationSpy).toHaveBeenCalledTimes(1); + expect(decodeSpy).toHaveBeenCalledTimes(2); + }); + it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => { withStudioIframe(() => { const root = document.createElement("div"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..01d44e7d73 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1917,6 +1917,13 @@ export function initSandboxRuntimeModular(): void { }; const dataHiddenDisplayRestores = new WeakMap(); const dataHiddenDisplayNodes = new WeakSet(); + // A data-hidden toggle on (or affecting) an audio element must re-schedule + // WebAudio playback so the hidden clip's source is dropped/restored mid- + // playback. Batched to one call per syncTimedElementVisibility pass, not + // one per toggled node (schedulePlayback replaces the whole active set). + let hiddenAudioDirty = false; + const nodeAffectsAudio = (node: HTMLElement): boolean => + node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null; const syncTimedElementVisibility = ( currentTime: number, @@ -1930,6 +1937,7 @@ export function initSandboxRuntimeModular(): void { if (!dataHiddenDisplayNodes.has(rawNode)) { dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display")); dataHiddenDisplayNodes.add(rawNode); + if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true; } rawNode.style.display = "none"; if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { @@ -1947,6 +1955,7 @@ export function initSandboxRuntimeModular(): void { } dataHiddenDisplayRestores.delete(rawNode); dataHiddenDisplayNodes.delete(rawNode); + if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true; } let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime); @@ -1976,6 +1985,10 @@ export function initSandboxRuntimeModular(): void { rawNode.style.display = "none"; } } + if (hiddenAudioDirty && clock.isPlaying()) { + scheduleWebAudioForActiveClips(); + } + hiddenAudioDirty = false; }; const syncMediaForCurrentState = () => { @@ -2921,6 +2934,7 @@ export function initSandboxRuntimeModular(): void { let foundActive = false; for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; + if (rawEl.closest("[data-hidden]")) continue; const start = Number.parseFloat(rawEl.dataset.start ?? ""); const durAttr = Number.parseFloat(rawEl.dataset.duration ?? ""); const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity; @@ -3031,6 +3045,7 @@ export function initSandboxRuntimeModular(): void { const audioEls = document.querySelectorAll("audio[data-start]"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; + if (rawEl.closest("[data-hidden]")) continue; const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); if (!Number.isFinite(compStart)) continue; const mediaStart = diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index bf697161b8..385c62d17e 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -460,6 +460,44 @@ describe("syncRuntimeMedia", () => { expect(clip.el.play).toHaveBeenCalled(); }); + describe("data-hidden silences preview volume", () => { + it("zeroes effective volume for a clip under a data-hidden ancestor", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + const hiddenAncestor = document.createElement("div"); + hiddenAncestor.setAttribute("data-hidden", ""); + document.body.appendChild(hiddenAncestor); + hiddenAncestor.appendChild(clip.el); + + let seen = -1; + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 1, + playing: true, + playbackRate: 1, + onElementVolume: (_el, v) => { + seen = v; + }, + }); + + expect(seen).toBe(0); + }); + + it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + const hiddenAncestor = document.createElement("div"); + hiddenAncestor.setAttribute("data-hidden", ""); + document.body.appendChild(hiddenAncestor); + hiddenAncestor.appendChild(clip.el); + clip.el.muted = false; + + syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1 }); + + expect(clip.el.muted).toBe(false); + }); + }); + describe("play() storm guard (unplayable elements)", () => { it("does not play() an element with a media error", () => { const clip = createMockClip({ start: 0, end: 10 }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 40ae5ec176..72df53382e 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -309,7 +309,10 @@ export function syncRuntimeMedia(params: { authorVolume = fallbackAuthorVolume; } - const effectiveVolume = clampVolume(authorVolume * userVol); + // 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); el.volume = effectiveVolume; lastRuntimeAppliedVolume.set(el, effectiveVolume); params.onElementVolume?.(el, effectiveVolume); diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts index 24d114b98e..6e76f2a376 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts @@ -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", `
`], + ]); + 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", `
`], + ]); + 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", + `
+
`, + ], + ]); + 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", () => { diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index 7860ad38b3..96c2df2040 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -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, diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 3d043fbd1b..44f0d475f1 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -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