From 8ab7e7db3e31393d736db7ea0a8dcd35431ef628 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 03:03:22 +0900 Subject: [PATCH 01/13] feat(player): add bounded rehearsal playback rates --- .../workspace/RehearsalPlayer.test.tsx | 60 +++++++++++++++++++ .../features/workspace/RehearsalPlayer.tsx | 52 ++++++++++++++++ .../workspace/rehearsalTransport.test.ts | 26 ++++++++ .../features/workspace/rehearsalTransport.ts | 33 ++++++++++ apps/desktop/src/locales/en/common.json | 2 + apps/desktop/src/locales/ko/common.json | 2 + 6 files changed, 175 insertions(+) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 3ac05a7f6..22bab447b 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -10,6 +10,10 @@ const originalTauriInternals = Object.getOwnPropertyDescriptor( window, "__TAURI_INTERNALS__", ); +const originalPreservesPitch = Object.getOwnPropertyDescriptor( + HTMLMediaElement.prototype, + "preservesPitch", +); const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); const audioSourcePath = "/Users/test/Music/rehearsal.wav"; @@ -28,6 +32,11 @@ function installPlayableAudioMocks() { }); vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + Object.defineProperty(HTMLMediaElement.prototype, "preservesPitch", { + configurable: true, + writable: true, + value: false, + }); const play = vi .spyOn(HTMLMediaElement.prototype, "play") .mockResolvedValue(undefined); @@ -49,6 +58,17 @@ describe("RehearsalPlayer", () => { delete (window as Window & { __TAURI_INTERNALS__?: unknown }) .__TAURI_INTERNALS__; } + if (originalPreservesPitch) { + Object.defineProperty( + HTMLMediaElement.prototype, + "preservesPitch", + originalPreservesPitch, + ); + } else { + delete (HTMLMediaElement.prototype as HTMLMediaElement & { + preservesPitch?: boolean; + }).preservesPitch; + } }); it("allows both platform Tauri asset origins in the media CSP", () => { @@ -323,6 +343,46 @@ describe("RehearsalPlayer", () => { ); }); + it("applies supported playback speed while preserving pitch when available", () => { + setNavigatorLanguage("en-US"); + const { play } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + const { rerender } = render( + , + ); + + const audio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + const rateSelect = screen.getByRole("combobox", { + name: /Playback speed/i, + }) as HTMLSelectElement; + expect(rateSelect.value).toBe("1"); + + fireEvent.change(rateSelect, { target: { value: "0.75" } }); + + expect(audio.playbackRate).toBe(0.75); + expect(audio.preservesPitch).toBe(true); + expect( + screen.getByText(/Pitch stays natural when the audio engine supports it/i), + ).toBeTruthy(); + expect(play).not.toHaveBeenCalled(); + + rerender( + , + ); + expect(audio.playbackRate).toBe(0.75); + }); + it("keeps a live loop running across unrelated song metadata updates", () => { setNavigatorLanguage("en-US"); vi.useFakeTimers(); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 53bdc3be9..2e597a140 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -21,6 +21,8 @@ import { formatRehearsalClock, nextActionTemplateKey, nextActionValues, + isRehearsalPlaybackRate, + rehearsalPlaybackRates, reduceRehearsalTransport, resolveLoopWindows, type RehearsalLoopWindow, @@ -188,6 +190,21 @@ export function RehearsalPlayer({ }); }, [playableLoops, selectedRendererIndex]); + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + try { + audio.playbackRate = transport.playbackRate; + if ("preservesPitch" in audio) { + audio.preservesPitch = true; + } + } catch { + handlePlaybackError(); + } + }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); + useEffect(() => { if (startNonce <= lastHandledStartNonce.current) { return; @@ -420,6 +437,41 @@ export function RehearsalPlayer({ })} ) : null} +
+ +

+ {t("workspaceLoopPlaybackRateHint")} +

+
- {activeRole && ( + {resolvedActiveRole && (

Stem Player

-

{activeRoleDetails?.name ?? activeRole}

+

{activeRoleDetails?.name ?? resolvedActiveRole}

) : null} + {playableLoops.length > 1 ? ( +

+ {t("workspaceLoopSectionKeyboardHint")} +

+ ) : null}