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 (
+ {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")}
+
+
{
).toBeNull();
});
+ it("passes the selected role into the player section filter", () => {
+ 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();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ const playerSections = screen.getByRole("group", {
+ name: "Playable sections for Lead Vocal",
+ });
+ expect(within(playerSections).getByRole("button", { name: /verse/i })).toBeTruthy();
+ expect(within(playerSections).queryByRole("button", { name: /chorus/i })).toBeNull();
+ });
+
+ it("clears a role that is absent after replacing the song", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const replacement = createDemoRehearsalSong();
+ replacement.sections = replacement.sections.map((section) => ({
+ ...section,
+ roles: section.roles.filter((role) => role.id !== "lead-vocal"),
+ }));
+
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+ expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent(
+ "Showing sections that include Lead Vocal.",
+ );
+
+ rerender();
+
+ expect(screen.queryByTestId("rehearsal-loop-role-filter")).toBeNull();
+ expect(
+ screen.getByRole("tab", { name: "All Roles", selected: true }),
+ ).toBeTruthy();
+ expect(screen.getByTestId("rehearsal-loop-next-action")).not.toHaveTextContent(
+ /No playable sections include Lead Vocal/i,
+ );
+ });
+
it("enables bass transcription from selected role metadata rather than role id text", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 80612ad8c..ce269580a 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, memo, type MouseEvent } from "react";
+import { useState, useMemo, memo, useEffect, type MouseEvent } from "react";
import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
@@ -146,6 +146,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
return map;
}, [song]);
+ const resolvedActiveRole = activeRole && roleMap.has(activeRole) ? activeRole : null;
+
+ useEffect(() => {
+ if (activeRole && !roleMap.has(activeRole)) {
+ setActiveRole(null);
+ }
+ }, [activeRole, roleMap]);
+
const allRoles = useMemo(() => {
// Performance: Avoid O(N) allocation of intermediate array from Array.from() before mapping
const roles: { id: string; name: string }[] = [];
@@ -157,11 +165,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
// Performance: use the cached roleMap so activeRoleDetails does not rescan sections and roles on every render.
const activeRoleDetails = useMemo(() => {
- if (!activeRole) return undefined;
- return roleMap.get(activeRole);
- }, [activeRole, roleMap]);
+ if (!resolvedActiveRole) return undefined;
+ return roleMap.get(resolvedActiveRole);
+ }, [resolvedActiveRole, roleMap]);
const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
- const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]);
+ const firstRange = useMemo(() => firstRangeSqueeze(song, resolvedActiveRole), [resolvedActiveRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(
t(firstRange.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"),
@@ -176,13 +184,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
- if (!activeRole || !onSongUpdate) return;
+ if (!resolvedActiveRole || !onSongUpdate) return;
// Performance: Use shallow copying to avoid expensive structuredClone
const nextSong = {
...song,
sections: song.sections.map(section => {
- const roleIndex = section.roles.findIndex(r => r.id === activeRole);
+ const roleIndex = section.roles.findIndex(r => r.id === resolvedActiveRole);
if (roleIndex === -1) return section;
const nextRoles = [...section.roles];
@@ -221,12 +229,12 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
[collaborationApprovals.length, collaborationAssignments.length, collaborationComments.length]
);
const activeRoleAssignments = useMemo(
- () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === activeRole),
- [activeRole, collaborationAssignments]
+ () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === resolvedActiveRole),
+ [resolvedActiveRole, collaborationAssignments]
);
const activeRoleComments = useMemo(
- () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === activeRole),
- [activeRole, collaborationComments]
+ () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === resolvedActiveRole),
+ [resolvedActiveRole, collaborationComments]
);
const roleHarmonicExplanation =
nonBlankText(activeRoleDetails?.harmonicExplanation) ??
@@ -367,8 +375,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -380,15 +391,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {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}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.",