fix(studio): play preview audio at speeds above 1x (#2691)

* fix(studio): play preview audio at speeds above 1x

The player force-muted preview audio whenever playback rate exceeded 1x
and disabled the mute button (and the M shortcut) in that state. Users
changing speed to 2x heard nothing, and clicking unmute did nothing.

Media elements play audio fine at any rate (pitch preserved by default),
so remove the special case: audio now follows only the user's mute
toggle at every speed. Drops the shouldMutePreviewAudio helper and the
audioAutoMuted UI/keyboard gating that existed solely for it.

* test(studio): update seek audio test for unmuted-above-1x behavior

useTimelinePlayer.seek.test.ts still asserted the old force-mute: at 2x
with the user unmuted it expected a set-muted{muted:true} message. With
audio now following only the user's toggle, the preview receives
set-muted{muted:false}. Update the assertion and title to match.
This commit is contained in:
Miguel Ángel
2026-07-21 14:32:13 +02:00
committed by GitHub
parent 853256403b
commit 07965e9fe9
8 changed files with 18 additions and 66 deletions
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveSeekPercent } from "./PlayerControls"; import { resolveSeekPercent } from "./PlayerControls";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
describe("resolveSeekPercent", () => { describe("resolveSeekPercent", () => {
it("returns 0 when the track width is invalid", () => { it("returns 0 when the track width is invalid", () => {
@@ -19,19 +18,3 @@ describe("resolveSeekPercent", () => {
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25); expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
}); });
}); });
describe("shouldMutePreviewAudio", () => {
it("mutes when the user toggled audio off", () => {
expect(shouldMutePreviewAudio(true, 1)).toBe(true);
});
it("auto-mutes above 1x playback", () => {
expect(shouldMutePreviewAudio(false, 1.5)).toBe(true);
expect(shouldMutePreviewAudio(false, 2)).toBe(true);
});
it("keeps audio on at 1x or slower when the user has not muted it", () => {
expect(shouldMutePreviewAudio(false, 1)).toBe(false);
expect(shouldMutePreviewAudio(false, 0.5)).toBe(false);
});
});
@@ -2,7 +2,6 @@ import { useRef, useCallback, useEffect, memo } from "react";
import gsap from "gsap"; import gsap from "gsap";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time"; import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
import { usePlayerStore } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry"; import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui"; import { Tooltip } from "../../components/ui";
@@ -60,40 +59,30 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
const MuteButton = memo(function MuteButton({ const MuteButton = memo(function MuteButton({
audioMuted, audioMuted,
audioAutoMuted,
effectiveAudioMuted,
controlsDisabled, controlsDisabled,
setAudioMuted, setAudioMuted,
}: { }: {
audioMuted: boolean; audioMuted: boolean;
audioAutoMuted: boolean;
effectiveAudioMuted: boolean;
controlsDisabled: boolean; controlsDisabled: boolean;
setAudioMuted: (v: boolean) => void; setAudioMuted: (v: boolean) => void;
}) { }) {
const label = audioAutoMuted const label = audioMuted ? "Unmute audio" : "Mute audio";
? "Audio muted above 1x speed"
: audioMuted
? "Unmute audio"
: "Mute audio";
return ( return (
<Tooltip label={label}> <Tooltip label={label}>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (!audioAutoMuted) { trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted }); setAudioMuted(!audioMuted);
setAudioMuted(!audioMuted);
}
}} }}
disabled={controlsDisabled || audioAutoMuted} disabled={controlsDisabled}
aria-label={label} aria-label={label}
aria-pressed={effectiveAudioMuted} aria-pressed={audioMuted}
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${ className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
effectiveAudioMuted audioMuted
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30" ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800" : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
} ${audioAutoMuted ? "opacity-70" : ""}`} }`}
> >
<svg <svg
width="13" width="13"
@@ -107,7 +96,7 @@ const MuteButton = memo(function MuteButton({
aria-hidden="true" aria-hidden="true"
> >
<path d="M11 5 6 9H3v6h3l5 4V5Z" /> <path d="M11 5 6 9H3v6h3l5 4V5Z" />
{effectiveAudioMuted ? ( {audioMuted ? (
<> <>
<path d="m19 9-6 6" /> <path d="m19 9-6 6" />
<path d="m13 9 6 6" /> <path d="m13 9 6 6" />
@@ -383,8 +372,6 @@ export const PlayerControls = memo(function PlayerControls({
const durationRef = useRef(duration); const durationRef = useRef(duration);
durationRef.current = duration; durationRef.current = duration;
const controlsDisabled = disabled || !timelineReady; const controlsDisabled = disabled || !timelineReady;
const audioAutoMuted = playbackRate > 1;
const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
useEffect(() => { useEffect(() => {
if (!timeDisplayRef.current) return; if (!timeDisplayRef.current) return;
@@ -486,8 +473,6 @@ export const PlayerControls = memo(function PlayerControls({
<MuteButton <MuteButton
audioMuted={audioMuted} audioMuted={audioMuted}
audioAutoMuted={audioAutoMuted}
effectiveAudioMuted={effectiveAudioMuted}
controlsDisabled={controlsDisabled} controlsDisabled={controlsDisabled}
setAudioMuted={setAudioMuted} setAudioMuted={setAudioMuted}
/> />
@@ -189,7 +189,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
expect(usePlayerStore.getState().audioMuted).toBe(false); expect(usePlayerStore.getState().audioMuted).toBe(false);
}); });
it("M does NOT toggle audioMuted above 1x playback (matches button gating)", () => { it("M toggles audioMuted above 1x playback too", () => {
const { dispatch } = setupHook(); const { dispatch } = setupHook();
usePlayerStore.setState({ playbackRate: 2, audioMuted: false }); usePlayerStore.setState({ playbackRate: 2, audioMuted: false });
@@ -197,7 +197,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
dispatch(keydown({ code: "KeyM", key: "m" })); dispatch(keydown({ code: "KeyM", key: "m" }));
}); });
expect(usePlayerStore.getState().audioMuted).toBe(false); expect(usePlayerStore.getState().audioMuted).toBe(true);
}); });
it("Shift+L toggles loopEnabled without starting forward shuttle", () => { it("Shift+L toggles loopEnabled without starting forward shuttle", () => {
@@ -116,10 +116,7 @@ export function usePlaybackKeyboard({
if (key === "m") { if (key === "m") {
e.preventDefault(); e.preventDefault();
const state = usePlayerStore.getState(); const state = usePlayerStore.getState();
// Audio is force-muted above 1x playback — match the mute button's gating. state.setAudioMuted(!state.audioMuted);
if (state.playbackRate <= 1) {
state.setAudioMuted(!state.audioMuted);
}
return; return;
} }
if (key === "l" && e.shiftKey) { if (key === "l" && e.shiftKey) {
@@ -211,7 +211,7 @@ describe("useTimelinePlayer seek hydration", () => {
}); });
describe("useTimelinePlayer audio controls (#835)", () => { describe("useTimelinePlayer audio controls (#835)", () => {
it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => { it("applies playback-rate changes immediately and keeps audio unmuted above 1x", () => {
const { api, root } = renderTimelinePlayerHarness(); const { api, root } = renderTimelinePlayerHarness();
const postMessage = vi.fn(); const postMessage = vi.fn();
const timeScale = vi.fn(); const timeScale = vi.fn();
@@ -244,7 +244,7 @@ describe("useTimelinePlayer audio controls (#835)", () => {
source: "hf-parent", source: "hf-parent",
type: "control", type: "control",
action: "set-muted", action: "set-muted",
muted: true, muted: false,
}), }),
"*", "*",
); );
@@ -37,11 +37,7 @@ import {
parseTimelineFromDOM, parseTimelineFromDOM,
} from "../lib/timelineDOM"; } from "../lib/timelineDOM";
import { normalizeToZones } from "../components/timelineZones"; import { normalizeToZones } from "../components/timelineZones";
import { import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
setPreviewMediaMuted,
setPreviewPlaybackRate,
shouldMutePreviewAudio,
} from "../lib/timelineIframeHelpers";
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub"; import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe"; import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
@@ -241,13 +237,9 @@ export function useTimelinePlayer() {
} }
} catch {} } catch {}
}, []); }, []);
const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => { const applyPreviewAudioState = useCallback(() => {
const { audioMuted, playbackRate } = usePlayerStore.getState(); const { audioMuted } = usePlayerStore.getState();
const effectivePlaybackRate = playbackRateOverride ?? playbackRate; setPreviewMediaMuted(iframeRef.current, audioMuted);
setPreviewMediaMuted(
iframeRef.current,
shouldMutePreviewAudio(audioMuted, effectivePlaybackRate),
);
}, []); }, []);
const play = useCallback(() => { const play = useCallback(() => {
stopRAFLoop(); stopRAFLoop();
@@ -285,7 +277,7 @@ export function useTimelinePlayer() {
if (initialTime !== adapter.getTime()) adapter.seek(initialTime); if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
const speed = Math.max(0.1, Math.min(4, rate)); const speed = Math.max(0.1, Math.min(4, rate));
applyPlaybackRate(speed); applyPlaybackRate(speed);
applyPreviewAudioState(speed); applyPreviewAudioState();
let startTime = initialTime; let startTime = initialTime;
let startedAt = performance.now(); let startedAt = performance.now();
@@ -55,7 +55,6 @@ export {
autoHealMissingCompositionIds, autoHealMissingCompositionIds,
setPreviewMediaMuted, setPreviewMediaMuted,
setPreviewPlaybackRate, setPreviewPlaybackRate,
shouldMutePreviewAudio,
resolveIframe, resolveIframe,
buildMissingCompositionElements, buildMissingCompositionElements,
} from "./timelineIframeHelpers"; } from "./timelineIframeHelpers";
@@ -111,10 +111,6 @@ function postPreviewControl(
postRuntimeControlMessage(iframe.contentWindow, action, payload); postRuntimeControlMessage(iframe.contentWindow, action, payload);
} }
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
return audioMuted || playbackRate > 1;
}
export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: boolean): void { export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: boolean): void {
if (!iframe) return; if (!iframe) return;
try { try {