diff --git a/AGENTS.md b/AGENTS.md index 55023b6a2..431188c83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. -- The ready workspace must name a next rehearsal action. Tonight's first playable section loop (count-in, pause, stop) is the #961 transport slice; stem playback and pitch-preserving rate remain later work. +- The ready workspace must name a next rehearsal action. Tonight's first playable section loop (count-in, pause, stop) is the #961 transport slice; #1063 adds bounded pitch-preserving playback rate, while stem playback remains later work. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index de75b99cf..d890e25de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Tonight's rehearsal map now arms the first valid section map-clock loop, runs a tempo count-in, and names the next start, pause, stop, or choose-local-song action without claiming decoded audio playback; admitted section timing and picker copy use the same descriptor-snapshotted transport window. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Move between playable section cues with Left and Right Arrow and keep the selected cue focused. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed @@ -15,6 +16,8 @@ ### Fixed +- Kept the rehearsal player section picker aligned with the selected player or + vocal role while preserving the full song-form roadmap. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ## [0.1.3] - 2026-04-29 @@ -75,4 +78,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 3ac05a7f6..0cae5a010 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { RehearsalPlayer } from "./RehearsalPlayer"; @@ -10,6 +11,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 +33,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 +59,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", () => { @@ -108,6 +129,273 @@ describe("RehearsalPlayer", () => { ).not.toMatch(/Count in 4 beats/i); }); + it("limits the section picker to sections containing the active role", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + render( + , + ); + + expect( + screen.getByRole("group", { name: "Playable sections for Lead Vocal" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /verse/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /chorus/i })).toBeNull(); + expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent( + "Showing sections that include Lead Vocal.", + ); + }); + + it("moves section cues with the arrow keys and keeps the selected cue focused", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [song.sections[0]!, chorus]; + + render(); + + const verse = screen.getByRole("button", { name: /verse/i }); + const chorusButton = screen.getByRole("button", { name: /chorus/i }); + expect( + screen.getByTestId("rehearsal-loop-keyboard-hint"), + ).toHaveTextContent("Use Left and Right Arrow to move between section cues."); + + chorusButton.focus(); + fireEvent.keyDown(chorusButton, { key: "ArrowLeft" }); + expect(verse).toHaveFocus(); + + fireEvent.keyDown(verse, { key: "ArrowRight" }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + expect(chorusButton).toHaveFocus(); + + fireEvent.keyDown(chorusButton, { key: "ArrowRight" }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + fireEvent.keyDown(chorusButton, { key: "ArrowLeft" }); + expect(verse).toHaveAttribute("aria-pressed", "true"); + expect(verse).toHaveFocus(); + }); + + it("lets the selected cue keep a manual range correction in the song map", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + expect(screen.getByTestId("rehearsal-loop-boundary-editor")).toHaveTextContent( + "Manual cue correction", + ); + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "12" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange).toEqual({ + start: 12, + end: 30, + }); + expect(song.sections[0]!.timeRange.start).toBe(10); + }); + + it("rejects a boundary correction that would invert the selected cue", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + const end = screen.getByRole("spinbutton", { + name: "End time (seconds)", + }); + fireEvent.change(end, { target: { value: "5" } }); + fireEvent.blur(end); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(end).toHaveValue(30); + expect(end).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByTestId("rehearsal-loop-boundary-editor")).toHaveTextContent( + "with the end after the start", + ); + }); + + it("keeps focus on the next boundary field after a Tab correction", async () => { + setNavigatorLanguage("en-US"); + const user = userEvent.setup(); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + const end = screen.getByRole("spinbutton", { + name: "End time (seconds)", + }); + await user.click(start); + await user.clear(start); + await user.type(start, "12"); + await user.tab(); + + expect(end).toHaveFocus(); + expect(start).toHaveValue(12); + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange.start).toBe(12); + }); + + it("keeps the selected loop by section ID when an earlier section is filtered out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.id = "verse-no-lead-vocal"; + verse.roles = verse.roles.filter((role) => role.id !== "lead-vocal"); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /chorus/i })); + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + + rerender( + , + ); + + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Map chorus from 0:40–1:04/i, + ); + }); + + it("keeps duplicate cue identities distinct for navigation and correction", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const duplicate = { + ...song.sections[0]!, + label: "verse copy", + timeRange: { ...song.sections[0]!.timeRange }, + }; + song.sections = [song.sections[0]!, duplicate]; + const onSongUpdate = vi.fn(); + render(); + + const sectionButtons = screen + .getAllByRole("button") + .filter((button) => button.id.startsWith("rehearsal-loop-section-")); + expect(sectionButtons).toHaveLength(2); + expect(sectionButtons[0]).toHaveAttribute("aria-pressed", "true"); + expect(sectionButtons[1]).toHaveAttribute("aria-pressed", "false"); + + sectionButtons[0]!.focus(); + fireEvent.keyDown(sectionButtons[0]!, { key: "ArrowRight" }); + + expect(sectionButtons[1]).toHaveFocus(); + expect(sectionButtons[0]).toHaveAttribute("aria-pressed", "false"); + expect(sectionButtons[1]).toHaveAttribute("aria-pressed", "true"); + + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "12" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange.start).toBe(10); + expect(onSongUpdate.mock.calls[0]![0].sections[1]!.timeRange.start).toBe(12); + }); + + it("preserves the selected cue when an earlier section is inserted", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [song.sections[0]!, chorus]; + const onSongUpdate = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /chorus/i })); + const inserted = structuredClone(song.sections[0]!); + inserted.id = "intro-1"; + inserted.label = "intro"; + inserted.timeRange = { start: 0, end: 5 }; + const updatedSong = { ...song, sections: [inserted, ...song.sections] }; + rerender( + , + ); + + const chorusButton = screen.getByRole("button", { name: /chorus/i }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "42" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[1]!.timeRange.start).toBe(10); + expect(onSongUpdate.mock.calls[0]![0].sections[2]!.timeRange.start).toBe(42); + rerender( + , + ); + expect(screen.getByRole("button", { name: /chorus/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("explains when the active role has no playable sections", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = []; + + render( + , + ); + + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + "No playable sections include Lead Vocal yet.", + ); + expect(screen.queryByRole("group")).toBeNull(); + }); + it("stops active count-in and loop ticking when local-audio authority is revoked", () => { setNavigatorLanguage("en-US"); vi.useFakeTimers(); @@ -323,6 +611,177 @@ describe("RehearsalPlayer", () => { ); }); + it.each([ + { rate: "0.75", beforeLoopMs: 2000, remainingMs: 700 }, + { rate: "1.25", beforeLoopMs: 1500, remainingMs: 200 }, + ])( + "keeps the count-in aligned with playback rate $rate", + ({ rate, beforeLoopMs, remainingMs }) => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: rate }, + }); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + + act(() => { + vi.advanceTimersByTime(beforeLoopMs); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/looping/i); + + act(() => { + vi.advanceTimersByTime(remainingMs); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + }, + ); + + it("scales and reschedules section boundaries when playback rate changes", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + render( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + act(() => { + vi.advanceTimersByTime(1600); + }); + + expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBeCloseTo(16_000, 5); + + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "0.75" }, + }); + + expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBeCloseTo( + 20_000 / 0.75, + 5, + ); + }); + + it("keeps the remaining count-in beat when playback rate changes", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(400); + }); + + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "0.75" }, + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 90 BPM/i); + + act(() => { + vi.advanceTimersByTime(50); + }); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + + act(() => { + vi.advanceTimersByTime(369); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + act(() => { + vi.advanceTimersByTime(1); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 3 beats at 150 BPM/i); + }); + + 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 45edaa778..bf420cd91 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -4,9 +4,14 @@ import { useMemo, useRef, useState, + type FocusEvent, + type KeyboardEvent, type ReactElement, } from "react"; -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalSong, +} from "@bandscope/shared-types"; import { convertFileSrc } from "@tauri-apps/api/core"; import { Button } from "@/components/ui/button"; import { @@ -21,6 +26,8 @@ import { formatRehearsalClock, nextActionTemplateKey, nextActionValues, + isRehearsalPlaybackRate, + rehearsalPlaybackRates, reduceRehearsalTransport, resolveLoopWindows, type RehearsalLoopWindow, @@ -29,8 +36,11 @@ import { interface RehearsalPlayerProps { song: RehearsalSong; + onSongUpdate?: (song: RehearsalSong) => void; hasLocalAudio?: boolean; audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; startNonce?: number; } @@ -79,6 +89,7 @@ function hasSameLoopTiming( next: RehearsalLoopWindow, ): boolean { return ( + current.selectionKey === next.selectionKey && current.sectionId === next.sectionId && current.startSeconds === next.startSeconds && current.endSeconds === next.endSeconds && @@ -87,19 +98,79 @@ function hasSameLoopTiming( ); } +/** Return a stable selection key when analysis emits duplicate section IDs. */ +function loopSelectionKey(loop: RehearsalLoopWindow): string { + return loop.selectionKey; +} + /** Render tonight's first section loop with a count-in and a named next action. */ export function RehearsalPlayer({ song, + onSongUpdate, hasLocalAudio = false, audioSourcePath = null, + activeRole = null, + activeRoleName = null, startNonce = 0, }: RehearsalPlayerProps): ReactElement { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const playableLoops = useMemo(() => resolveLoopWindows(song), [song]); - const [selectedSectionIndex, setSelectedSectionIndex] = useState(0); - const selectedRendererIndex = playableLoops[selectedSectionIndex] - ? selectedSectionIndex - : 0; + const playableLoops = useMemo( + () => resolveLoopWindows(song, activeRole), + [activeRole, song], + ); + const [selectedLoopKey, setSelectedLoopKey] = useState(null); + const [boundaryError, setBoundaryError] = useState(false); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; + const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; + const [boundaryDraft, setBoundaryDraft] = useState(() => ({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + })); + useEffect(() => { + setBoundaryError(false); + setBoundaryDraft({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + }); + }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); + const handleSectionKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + const focusedIndex = Number(event.currentTarget.dataset.loopIndex); + const selectedIndex = selectedLoop + ? playableLoops.indexOf(selectedLoop) + : -1; + const currentIndex = + Number.isSafeInteger(focusedIndex) && + focusedIndex >= 0 && + focusedIndex < playableLoops.length + ? focusedIndex + : selectedIndex; + const nextIndex = + currentIndex + (event.key === "ArrowRight" ? 1 : -1); + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= playableLoops.length + ) { + return; + } + event.preventDefault(); + const nextLoop = playableLoops[nextIndex]; + setSelectedLoopKey(loopSelectionKey(nextLoop)); + document + .getElementById( + `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, + ) + ?.focus(); + }, + [playableLoops, selectedLoop], + ); const [transport, setTransport] = useState(() => reduceRehearsalTransport(createIdleTransportState(), { type: "arm", @@ -108,6 +179,11 @@ export function RehearsalPlayer({ ); const lastHandledStartNonce = useRef(0); const restartAudioOnLoopRef = useRef(false); + const countInBeatRef = useRef<{ + durationMs: number; + startedAt: number; + remainingBeats: number; + } | null>(null); const audioRef = useRef(null); const audioSourceUrl = useMemo( () => resolveAudioSourceUrl(audioSourcePath), @@ -182,24 +258,42 @@ export function RehearsalPlayer({ }, [audioSourceUrl, hasNativeAudioConversionError]); useEffect(() => { - const nextLoop = playableLoops[selectedRendererIndex] ?? null; setTransport((current) => { if ( current.loop && - nextLoop && - hasSameLoopTiming(current.loop, nextLoop) + selectedLoop && + hasSameLoopTiming(current.loop, selectedLoop) ) { if ( - current.loop.sectionLabel === nextLoop.sectionLabel && - current.loop.tempoAssumed === nextLoop.tempoAssumed + current.loop.sectionLabel === selectedLoop.sectionLabel && + current.loop.tempoAssumed === selectedLoop.tempoAssumed && + current.loop.sourceIndex === selectedLoop.sourceIndex ) { return current; } - return { ...current, loop: nextLoop }; + return { ...current, loop: selectedLoop }; } - return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }); + return reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); }); - }, [playableLoops, selectedRendererIndex]); + }, [selectedLoop]); + + 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) { @@ -209,7 +303,6 @@ export function RehearsalPlayer({ if (!hasPlayableAudio) { return; } - const selectedLoop = playableLoops[selectedRendererIndex] ?? null; if (selectedLoop) { setPlaybackError(false); startAudio(selectedLoop, false); @@ -225,8 +318,7 @@ export function RehearsalPlayer({ startAudio, startNonce, hasPlayableAudio, - playableLoops, - selectedRendererIndex, + selectedLoop, ]); useEffect(() => { @@ -243,15 +335,53 @@ export function RehearsalPlayer({ useEffect(() => { if (transport.phase !== "counting-in" || !transport.loop) { + countInBeatRef.current = null; return undefined; } - const timer = window.setInterval(() => { - setTransport((current) => - reduceRehearsalTransport(current, { type: "beat" }), - ); - }, beatDurationMs(transport.loop.tempoBpm)); - return () => window.clearInterval(timer); - }, [transport.phase, transport.loop]); + const durationMs = + beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; + const now = performance.now(); + const previous = countInBeatRef.current; + const sameBeat = + previous?.remainingBeats === transport.countInRemainingBeats; + const elapsedMs = sameBeat + ? Math.max(0, now - previous.startedAt) + : 0; + const progress = sameBeat + ? Math.min(1, elapsedMs / previous.durationMs) + : 0; + countInBeatRef.current = { + durationMs, + startedAt: now, + remainingBeats: transport.countInRemainingBeats, + }; + let timer: number | undefined; + /** Schedule the next count-in beat without coupling it to React commits. */ + const scheduleBeat = (delayMs: number) => { + timer = window.setTimeout(() => { + const current = countInBeatRef.current; + if (!current || current.remainingBeats <= 0) { + return; + } + current.remainingBeats -= 1; + setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); + if (current.remainingBeats > 0) { + current.startedAt = performance.now(); + scheduleBeat(current.durationMs); + } + }, delayMs); + }; + scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [ + transport.loop, + transport.phase, + transport.playbackRate, + ]); useEffect(() => { if (!audioSourceUrl || !transport.loop) { @@ -297,6 +427,7 @@ export function RehearsalPlayer({ return undefined; } const loop = transport.loop; + const playbackRate = transport.playbackRate; let boundaryTimer: number | undefined; /** Cancel the pending media-clock boundary check. */ const clearBoundaryTimer = () => { @@ -337,7 +468,12 @@ export function RehearsalPlayer({ } else { scheduleLoopBoundary(); } - }, Math.min(remainingSeconds * 1000, 2_147_483_647)); + }, + Math.min( + (remainingSeconds / playbackRate) * 1000, + 2_147_483_647, + ), + ); }; /** Keep the map playhead aligned with the scoped audio element. */ const syncPlayhead = () => { @@ -365,13 +501,29 @@ export function RehearsalPlayer({ audio.removeEventListener("error", failPlayback); audio.removeEventListener("ended", failPlayback); }; - }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]); + }, [ + audioSourceUrl, + handlePlaybackError, + transport.phase, + transport.loop, + transport.playbackRate, + ]); const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); - const nextAction = fillRehearsalCopy( - t(actionKey as TranslationKey), - nextActionValues(transport), - ); + const nextAction = + activeRoleName && playableLoops.length === 0 + ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { + roleName: activeRoleName, + }) + : fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const sectionPickerLabel = activeRoleName + ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { + roleName: activeRoleName, + }) + : t("workspaceLoopSectionPickerLabel"); const canStart = transport.loop !== null && hasPlayableAudio && @@ -383,6 +535,84 @@ export function RehearsalPlayer({ transport.phase === "paused" ? t("workspaceLoopResume") : t("workspaceLoopStart"); + const handleBoundaryBlur = useCallback( + (boundary: "start" | "end", event: FocusEvent) => { + if (!selectedLoop || !onSongUpdate) { + return; + } + const rawValue = event.currentTarget.value.trim(); + const value = Number(rawValue); + const valid = + rawValue !== "" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_SECTION_TIME_SECONDS && + (boundary === "start" + ? value < selectedLoop.endSeconds + : value > selectedLoop.startSeconds); + if (!valid) { + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + setBoundaryError(true); + return; + } + + setBoundaryError(false); + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + if (value === currentValue) { + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + return; + } + + const sectionIndex = selectedLoop.sourceIndex; + const section = song.sections[sectionIndex]; + if ( + !section || + section.id !== selectedLoop.sectionId || + section.timeRange.start !== selectedLoop.startSeconds || + section.timeRange.end !== selectedLoop.endSeconds + ) { + return; + } + const nextSong = { + ...song, + sections: song.sections.map((currentSection, index) => + index === sectionIndex + ? { + ...section, + timeRange: { + ...section.timeRange, + [boundary]: value, + }, + } + : currentSection, + ), + }; + const nextLoop = + boundary === "start" + ? { ...selectedLoop, startSeconds: value } + : { ...selectedLoop, endSeconds: value }; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(value), + })); + setSelectedLoopKey(loopSelectionKey(nextLoop)); + onSongUpdate(nextSong); + }, + [onSongUpdate, selectedLoop, song], + ); return (
{nextAction}

+ {activeRoleName && playableLoops.length > 0 ? ( +

+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { + roleName: activeRoleName, + })} +

+ ) : null} {playableLoops.length > 0 ? (
{playableLoops.map((loop, index) => { - const selected = index === selectedRendererIndex; + const selectionKey = loopSelectionKey(loop); + const selected = + selectedLoop !== null && + selectionKey === loopSelectionKey(selectedLoop); return (
) : null} + {playableLoops.length > 1 ? ( +

+ {t("workspaceLoopSectionKeyboardHint")} +

+ ) : null} + {selectedLoop && onSongUpdate ? ( +
+
+

+ {t("workspaceLoopBoundaryTitle")} +

+ + {t("workspaceLoopBoundaryCorrectionBadge")} + +
+

+ {boundaryError + ? t("workspaceLoopBoundaryError") + : t("workspaceLoopBoundaryHint")} +

+
+ + +
+
+ ) : null} +
+ +

+ {t("workspaceLoopPlaybackRateHint")} +

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

Stem Player

-

{activeRoleDetails?.name ?? activeRole}

+

{activeRoleDetails?.name ?? resolvedActiveRole}

diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts index ae892fc3f..219b32724 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts @@ -25,6 +25,8 @@ describe("rehearsal transport descriptor authority", () => { }); expect(createLoopWindow(proxiedSection, song.tempo)).toEqual({ + sourceIndex: 0, + selectionKey: JSON.stringify([expectedId, 0]), sectionId: expectedId, sectionLabel: expectedLabel, startSeconds: expectedRange.start, diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts index 76405d57b..0310d4e0c 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts @@ -6,11 +6,14 @@ import { createLoopWindow, fillRehearsalCopy, formatRehearsalClock, + isRehearsalPlaybackRate, isPlayableLoopSection, nextActionTemplateKey, nextActionValues, + rehearsalPlaybackRates, reduceRehearsalTransport, resolveLoopWindow, + resolveLoopWindows, resolveRehearsalTempo, wrapPlayhead, } from "./rehearsalTransport"; @@ -57,6 +60,27 @@ describe("rehearsalTransport", () => { expect(window?.endSeconds).toBe(64); }); + it("filters loop windows to sections containing the selected role", () => { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + expect( + resolveLoopWindows(song, "lead-vocal").map((window) => window.sectionId), + ).toEqual(["verse-1"]); + expect( + resolveLoopWindows(song, "bass-guitar").map((window) => window.sectionId), + ).toEqual(["verse-1", "chorus-1"]); + expect(resolveLoopWindows(song).map((window) => window.sectionId)).toEqual([ + "verse-1", + "chorus-1", + ]); + }); + it("rejects a sparse hostile section array without scanning its declared length", () => { const song = createDemoRehearsalSong(); song.sections = new Array(0xffffffff) as typeof song.sections; @@ -80,6 +104,30 @@ describe("rehearsalTransport", () => { expect(beatDurationMs(120)).toBe(500); }); + it("keeps playback speed inside the supported media contract", () => { + const loop = resolveLoopWindow(createDemoRehearsalSong()); + const armed = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + + expect(rehearsalPlaybackRates()).toEqual([0.75, 1, 1.25]); + expect(isRehearsalPlaybackRate(0.75)).toBe(true); + expect(isRehearsalPlaybackRate(2)).toBe(false); + expect( + reduceRehearsalTransport(armed, { + type: "set-playback-rate", + rate: 0.75, + }).playbackRate, + ).toBe(0.75); + expect( + reduceRehearsalTransport(armed, { + type: "set-playback-rate", + rate: 2 as never, + }), + ).toBe(armed); + }); + it("counts in four beats then wraps the playhead inside the section", () => { const song = createDemoRehearsalSong(); const loop = resolveLoopWindow(song); diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts index b1a205ed7..c7689ab0b 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -8,6 +8,18 @@ const DEFAULT_REHEARSAL_TEMPO_BPM = 120; const DEFAULT_COUNT_IN_BEATS = 4; const MIN_REHEARSAL_TEMPO_BPM = 30; const MAX_REHEARSAL_TEMPO_BPM = 300; +const DEFAULT_REHEARSAL_PLAYBACK_RATE = 1; + +/** Documented playback-rate choices supported by the rehearsal media contract. */ +const REHEARSAL_PLAYBACK_RATES = [0.75, 1, 1.25] as const; + +/** Playback-rate value accepted by the rehearsal media contract. */ +export type RehearsalPlaybackRate = (typeof REHEARSAL_PLAYBACK_RATES)[number]; + +/** Return the supported playback-rate choices for the rehearsal control. */ +export function rehearsalPlaybackRates(): readonly RehearsalPlaybackRate[] { + return REHEARSAL_PLAYBACK_RATES; +} /** Documented rehearsal transport phases for the first section loop. */ export type RehearsalTransportPhase = @@ -15,6 +27,8 @@ export type RehearsalTransportPhase = /** Bounded loop window derived from one valid analyzed section. */ export interface RehearsalLoopWindow { + sourceIndex: number; + selectionKey: string; sectionId: string; sectionLabel: string; startSeconds: number; @@ -30,6 +44,7 @@ export interface RehearsalTransportState { loop: RehearsalLoopWindow | null; countInRemainingBeats: number; playheadSeconds: number; + playbackRate: RehearsalPlaybackRate; } /** Discrete transport commands that never inspect the filesystem. */ @@ -39,6 +54,7 @@ export type RehearsalTransportEvent = | { type: "beat" } | { type: "sync"; playheadSeconds: number } | { type: "tick"; deltaSeconds: number } + | { type: "set-playback-rate"; rate: RehearsalPlaybackRate } | { type: "pause" } | { type: "stop" }; @@ -54,6 +70,16 @@ export function isFiniteNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } +/** Return true only for playback rates supported by the rehearsal contract. */ +export function isRehearsalPlaybackRate( + value: unknown, +): value is RehearsalPlaybackRate { + return ( + typeof value === "number" && + REHEARSAL_PLAYBACK_RATES.includes(value as RehearsalPlaybackRate) + ); +} + /** Read one own data-property value without activating accessors or Proxy get traps. */ function ownDataValue(value: object, key: PropertyKey): unknown { try { @@ -142,6 +168,20 @@ function playableSectionSnapshot( }; } +/** Return whether a snapshotted section contains the selected rehearsal role. */ +function sectionContainsRole(section: object, roleId: string): boolean { + const roles = ownedDenseArray(ownDataValue(section, "roles")); + if (!roles) { + return false; + } + return roles.some( + (role) => + role !== null && + typeof role === "object" && + ownDataValue(role, "id") === roleId, + ); +} + /** Return whether a section exposes a usable closed loop window. */ export function isPlayableLoopSection( section: RehearsalSection | undefined | null, @@ -192,6 +232,7 @@ export function formatRehearsalClock(totalSeconds: number): string { export function createLoopWindow( section: RehearsalSection, tempo: unknown, + sourceIndex = 0, ): RehearsalLoopWindow | null { const snapshot = playableSectionSnapshot(section); if (!snapshot) { @@ -199,6 +240,11 @@ export function createLoopWindow( } const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo); return { + sourceIndex, + selectionKey: JSON.stringify([ + snapshot.id, + 0, + ]), sectionId: snapshot.id, sectionLabel: snapshot.label, startSeconds: snapshot.startSeconds, @@ -212,6 +258,7 @@ export function createLoopWindow( /** Snapshot every playable loop window from one untrusted song record. */ export function resolveLoopWindows( song: RehearsalSong | null | undefined, + roleId: string | null | undefined = null, ): RehearsalLoopWindow[] { if (!song || typeof song !== "object") { return []; @@ -221,12 +268,38 @@ export function resolveLoopWindows( return []; } const tempo = ownDataValue(song, "tempo"); - return sections.flatMap((section) => { + const selectedRoleId = + typeof roleId === "string" && roleId.trim() ? roleId : null; + const selectionOrdinals = new Map(); + return sections.flatMap((section, sourceIndex) => { if (!section || typeof section !== "object") { return []; } - const window = createLoopWindow(section as RehearsalSection, tempo); - return window ? [window] : []; + const sectionId = ownDataValue(section, "id"); + const ordinal = + typeof sectionId === "string" + ? (selectionOrdinals.get(sectionId) ?? 0) + : 0; + if (typeof sectionId === "string") { + selectionOrdinals.set(sectionId, ordinal + 1); + } + const window = createLoopWindow( + section as RehearsalSection, + tempo, + sourceIndex, + ); + if (!window) { + return []; + } + if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) { + return []; + } + return [ + { + ...window, + selectionKey: JSON.stringify([window.sectionId, ordinal]), + }, + ]; }); } @@ -254,6 +327,7 @@ export function createIdleTransportState(): RehearsalTransportState { loop: null, countInRemainingBeats: 0, playheadSeconds: 0, + playbackRate: DEFAULT_REHEARSAL_PLAYBACK_RATE, }; } @@ -286,6 +360,7 @@ export function reduceRehearsalTransport( loop: event.loop, countInRemainingBeats: event.loop.countInBeats, playheadSeconds: event.loop.startSeconds, + playbackRate: state.playbackRate, }; } case "start": { @@ -348,6 +423,12 @@ export function reduceRehearsalTransport( ), }; } + case "set-playback-rate": { + if (!isRehearsalPlaybackRate(event.rate)) { + return state; + } + return { ...state, playbackRate: event.rate }; + } case "pause": { if (state.phase !== "looping" && state.phase !== "counting-in") { return state; @@ -363,6 +444,7 @@ export function reduceRehearsalTransport( loop: state.loop, countInRemainingBeats: state.loop.countInBeats, playheadSeconds: state.loop.startSeconds, + playbackRate: state.playbackRate, }; } default: @@ -421,6 +503,6 @@ export function nextActionValues( start: formatRehearsalClock(state.loop.startSeconds), end: formatRehearsalClock(state.loop.endSeconds), beats: String(state.countInRemainingBeats || state.loop.countInBeats), - tempo: String(state.loop.tempoBpm), + tempo: String(state.loop.tempoBpm * state.playbackRate), }; } diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index c520ef61a..133a6ce7f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -152,12 +152,24 @@ "workspaceLoopRegionLabel": "Tonight's section loop", "workspaceLoopTitle": "Tonight's loop", "workspaceLoopSectionPickerLabel": "Playable sections", + "workspaceLoopSectionPickerForRole": "Playable sections for {roleName}", + "workspaceLoopSectionKeyboardHint": "Use Left and Right Arrow to move between section cues.", + "workspaceLoopBoundaryTitle": "Correct this cue's range", + "workspaceLoopBoundaryCorrectionBadge": "Manual cue correction", + "workspaceLoopBoundaryStartLabel": "Start time (seconds)", + "workspaceLoopBoundaryEndLabel": "End time (seconds)", + "workspaceLoopBoundaryHint": "Whole seconds update the song map and are included in the next project save.", + "workspaceLoopBoundaryError": "Use whole seconds from 0 to the limit, with the end after the start.", + "workspaceLoopRoleFilterHint": "Showing sections that include {roleName}.", + "workspaceLoopNoRoleSections": "No playable sections include {roleName} yet. Choose All Roles or map this role first.", "workspaceLoopStart": "Start the count-in", "workspaceLoopThisSection": "Start selected section loop", "workspaceLoopResume": "Continue rehearsal clock", "workspaceLoopPause": "Pause rehearsal clock", "workspaceLoopStop": "Stop and reset rehearsal clock", "workspaceLoopAudioError": "Could not play this local audio. Choose the song again and retry.", + "workspaceLoopPlaybackRateLabel": "Playback speed", + "workspaceLoopPlaybackRateHint": "Pitch stays natural when the audio engine supports it.", "workspaceLoopIdle": "Add a section with a start and end time, then loop it here.", "workspaceLoopArmedNoAudio": "Map {section} from {start}–{end}. Choose a local song first to start the rehearsal clock.", "workspaceLoopArmedWithAudio": "Map {section} from {start}–{end}. Start the count-in to run the rehearsal clock.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index e0d52893c..e4151ebbc 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -152,12 +152,24 @@ "workspaceLoopRegionLabel": "오늘 밤 구간 루프", "workspaceLoopTitle": "오늘 밤 루프", "workspaceLoopSectionPickerLabel": "연습할 구간", + "workspaceLoopSectionPickerForRole": "{roleName} 역할의 연습 구간", + "workspaceLoopSectionKeyboardHint": "왼쪽·오른쪽 화살표로 구간 큐를 이동하세요.", + "workspaceLoopBoundaryTitle": "이 큐의 구간 보정", + "workspaceLoopBoundaryCorrectionBadge": "수동 큐 보정", + "workspaceLoopBoundaryStartLabel": "시작 시각(초)", + "workspaceLoopBoundaryEndLabel": "끝 시각(초)", + "workspaceLoopBoundaryHint": "정수 초로 곡 지도를 보정하며 다음 프로젝트 저장에 포함됩니다.", + "workspaceLoopBoundaryError": "0부터 제한값까지의 정수 초를 입력하고 끝을 시작보다 뒤로 두세요.", + "workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.", + "workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.", "workspaceLoopStart": "카운트인 시작", "workspaceLoopThisSection": "선택한 구간 루프 시작", "workspaceLoopResume": "합주 시계 계속", "workspaceLoopPause": "합주 시계 일시정지", "workspaceLoopStop": "합주 시계 멈추고 초기화", "workspaceLoopAudioError": "이 로컬 오디오를 재생할 수 없습니다. 곡을 다시 선택한 뒤 재시도하세요.", + "workspaceLoopPlaybackRateLabel": "재생 속도", + "workspaceLoopPlaybackRateHint": "오디오 엔진이 지원하면 음정은 자연스럽게 유지됩니다.", "workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.", "workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.", "workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.",