mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSeekPercent } from "./PlayerControls";
|
||||
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
||||
|
||||
describe("resolveSeekPercent", () => {
|
||||
it("returns 0 when the track width is invalid", () => {
|
||||
@@ -19,19 +18,3 @@ describe("resolveSeekPercent", () => {
|
||||
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 { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
||||
import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
|
||||
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { Tooltip } from "../../components/ui";
|
||||
@@ -60,40 +59,30 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
|
||||
|
||||
const MuteButton = memo(function MuteButton({
|
||||
audioMuted,
|
||||
audioAutoMuted,
|
||||
effectiveAudioMuted,
|
||||
controlsDisabled,
|
||||
setAudioMuted,
|
||||
}: {
|
||||
audioMuted: boolean;
|
||||
audioAutoMuted: boolean;
|
||||
effectiveAudioMuted: boolean;
|
||||
controlsDisabled: boolean;
|
||||
setAudioMuted: (v: boolean) => void;
|
||||
}) {
|
||||
const label = audioAutoMuted
|
||||
? "Audio muted above 1x speed"
|
||||
: audioMuted
|
||||
? "Unmute audio"
|
||||
: "Mute audio";
|
||||
const label = audioMuted ? "Unmute audio" : "Mute audio";
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!audioAutoMuted) {
|
||||
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
||||
setAudioMuted(!audioMuted);
|
||||
}
|
||||
}}
|
||||
disabled={controlsDisabled || audioAutoMuted}
|
||||
disabled={controlsDisabled}
|
||||
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 ${
|
||||
effectiveAudioMuted
|
||||
audioMuted
|
||||
? "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"
|
||||
} ${audioAutoMuted ? "opacity-70" : ""}`}
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
@@ -107,7 +96,7 @@ const MuteButton = memo(function MuteButton({
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
||||
{effectiveAudioMuted ? (
|
||||
{audioMuted ? (
|
||||
<>
|
||||
<path d="m19 9-6 6" />
|
||||
<path d="m13 9 6 6" />
|
||||
@@ -383,8 +372,6 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const controlsDisabled = disabled || !timelineReady;
|
||||
const audioAutoMuted = playbackRate > 1;
|
||||
const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeDisplayRef.current) return;
|
||||
@@ -486,8 +473,6 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
<MuteButton
|
||||
audioMuted={audioMuted}
|
||||
audioAutoMuted={audioAutoMuted}
|
||||
effectiveAudioMuted={effectiveAudioMuted}
|
||||
controlsDisabled={controlsDisabled}
|
||||
setAudioMuted={setAudioMuted}
|
||||
/>
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
||||
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();
|
||||
usePlayerStore.setState({ playbackRate: 2, audioMuted: false });
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -116,10 +116,7 @@ export function usePlaybackKeyboard({
|
||||
if (key === "m") {
|
||||
e.preventDefault();
|
||||
const state = usePlayerStore.getState();
|
||||
// Audio is force-muted above 1x playback — match the mute button's gating.
|
||||
if (state.playbackRate <= 1) {
|
||||
state.setAudioMuted(!state.audioMuted);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "l" && e.shiftKey) {
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("useTimelinePlayer seek hydration", () => {
|
||||
});
|
||||
|
||||
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 postMessage = vi.fn();
|
||||
const timeScale = vi.fn();
|
||||
@@ -244,7 +244,7 @@ describe("useTimelinePlayer audio controls (#835)", () => {
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-muted",
|
||||
muted: true,
|
||||
muted: false,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
@@ -37,11 +37,7 @@ import {
|
||||
parseTimelineFromDOM,
|
||||
} from "../lib/timelineDOM";
|
||||
import { normalizeToZones } from "../components/timelineZones";
|
||||
import {
|
||||
setPreviewMediaMuted,
|
||||
setPreviewPlaybackRate,
|
||||
shouldMutePreviewAudio,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
@@ -241,13 +237,9 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => {
|
||||
const { audioMuted, playbackRate } = usePlayerStore.getState();
|
||||
const effectivePlaybackRate = playbackRateOverride ?? playbackRate;
|
||||
setPreviewMediaMuted(
|
||||
iframeRef.current,
|
||||
shouldMutePreviewAudio(audioMuted, effectivePlaybackRate),
|
||||
);
|
||||
const applyPreviewAudioState = useCallback(() => {
|
||||
const { audioMuted } = usePlayerStore.getState();
|
||||
setPreviewMediaMuted(iframeRef.current, audioMuted);
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
@@ -285,7 +277,7 @@ export function useTimelinePlayer() {
|
||||
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
|
||||
const speed = Math.max(0.1, Math.min(4, rate));
|
||||
applyPlaybackRate(speed);
|
||||
applyPreviewAudioState(speed);
|
||||
applyPreviewAudioState();
|
||||
let startTime = initialTime;
|
||||
let startedAt = performance.now();
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export {
|
||||
autoHealMissingCompositionIds,
|
||||
setPreviewMediaMuted,
|
||||
setPreviewPlaybackRate,
|
||||
shouldMutePreviewAudio,
|
||||
resolveIframe,
|
||||
buildMissingCompositionElements,
|
||||
} from "./timelineIframeHelpers";
|
||||
|
||||
@@ -111,10 +111,6 @@ function postPreviewControl(
|
||||
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 {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user