From 0eeec7baa0e1c6ed26fd427e3484ac7b418691e5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 23:15:23 +0000
Subject: [PATCH 01/70] feat(workspace): loop tonight's first section from the
map
Replace the coming-soon loop control with a fail-closed rehearsal
transport that arms the first valid section, counts in at the admitted
tempo, and names the next play, pause, or choose-local-song action.
Advances #961. Does not decode local audio, copy #783, or open a
parallel MIR lane.
---
AGENTS.md | 16 +
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 1 +
CLAUDE.md | 2 +-
.../workspace/RehearsalPlayer.test.tsx | 80 +++++
.../features/workspace/RehearsalPlayer.tsx | 239 ++++++++++++++
.../src/features/workspace/Workspace.test.tsx | 29 ++
.../src/features/workspace/Workspace.tsx | 19 +-
.../workspace/rehearsalTransport.test.ts | 127 ++++++++
.../features/workspace/rehearsalTransport.ts | 307 ++++++++++++++++++
apps/desktop/src/locales/en/common.json | 16 +-
apps/desktop/src/locales/ko/common.json | 16 +-
12 files changed, 845 insertions(+), 10 deletions(-)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.tsx
create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.test.ts
create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.ts
diff --git a/AGENTS.md b/AGENTS.md
index fca448ce9..ce03853d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,9 @@
# AGENTS.md
## 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.
- 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.
@@ -12,15 +14,19 @@
- Repository governance and Gitflow rules live in `docs/repository/governance.md`, `docs/repository/bootstrap-plan.md`, and `docs/repository/gitflow.md`.
## Security workflow
+
- Before writing PRDs, TRDs, UX copy, architecture changes, or implementation plans that touch risky boundaries, read `docs/security/app-security.md`.
- If a task touches files, URLs, subprocesses, ffmpeg or native tools, WebView, local backend or IPC, updates, model downloads, project formats, logs, telemetry, or exports, the result must include `Security Notes`.
- `Security Notes` should cover untrusted inputs, trust boundaries, allowlists or validation, safe failure, logging/privacy impact, and test points.
+
## Agent guidance (CWL governance)
+
This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working in this repo.
### Security & review gate
+
- Every PR runs a central **Security Scan** required gate: `osv-scan` + `dependency-review` (diff-scoped) and `trivy-fs` (repo-wide, CRITICAL/HIGH, fixable). It runs on every PR base, **including stacked PRs**. Gating is by the Security Scan **job result**.
- A failing `trivy-fs` is a **REAL finding, not a flake.** Read the job log (it prints each finding's rule id / severity / file) or the run's SARIF results, then **remediate**:
- This repo ships **no Dockerfile and no k8s manifests**, so findings are almost always dependency vulns. Bump the offending package in the relevant lockfile — `apps/desktop/src-tauri/Cargo.lock` (Rust/Tauri), `package-lock.json` (Node), or `uv.lock` / `services/analysis-engine` (Python).
@@ -30,10 +36,13 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- The org `code_scanning` ruleset is intentionally **CodeQL-only** (multiple code-scanning tools can't converge on one PR ref). Do **not** add tools to the `code_scanning` rule; enforcement stays on the Security Scan job.
### Code exploration
+
- This repo has **no `.codegraph/` index**, so use normal search (grep/find/ripgrep) to locate and understand code. If a `.codegraph/` directory is later added at the repo root, prefer CodeGraph (`codegraph explore ""`, or the code-review-graph MCP tools) **before** grep/find — it surfaces callers/callees/impact that text search misses.
+
## Supply chain workflow
+
- Before adding or changing dependencies, GitHub Actions, bundled binaries, or model artifacts, read `docs/security/dependency-policy.md`.
- New direct dependencies must include admission rationale covering purpose, dependency class, alternatives, maintainer trust, license fit, known security issues, transitive footprint, and BandScope release risk.
- Lockfiles, dependency review, audit, SBOM generation, and supplemental component inventory are mandatory and must not be skipped or loosened.
@@ -41,26 +50,31 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Use `FAILED` when repo-controlled supply-chain artifacts are missing; use `BLOCKED` only when GitHub permission, auth, network, or platform capability prevents enforcement.
## Cross-platform build workflow
+
- Before changing CI, packaging, release flows, or native desktop build settings, read `docs/security/cross-platform-build-policy.md`.
- Windows and macOS builds are required security controls for `develop`, `main`, and release validation.
- Protected-branch build checks for Windows and macOS must not be removed, downgraded, or treated as optional.
## GitHub bootstrap workflow
+
- Before declaring a GitHub task blocked, read `docs/workflow/github-bootstrap-execution-policy.md`.
- Missing local git state, missing GitHub repo, missing `main`, missing `develop`, or missing initial workflows are bootstrap conditions, not default blockers.
- For GitHub tasks, only use `BLOCKED` when the failure is caused by missing GitHub permissions, missing auth, missing network access, or platform-level feature limits.
## Setup commands
+
- Node: `npm install`
- Python: `uv sync --project services/analysis-engine --group dev`
## Build / Test commands
+
- Full harness check: `./scripts/harness/quickcheck.sh`
- Frontend tests: `npm run test --workspaces --if-present`
- Python tests: `uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100`
- Typecheck: `npm run typecheck --workspaces --if-present && uv run --project services/analysis-engine mypy src`
## Architecture references
+
- `ARCHITECTURE.md`
- `docs/engineering/acceptance-criteria.md`
- `docs/engineering/harness-engineering.md`
@@ -80,6 +94,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- `docs/plans/2026-03-10-bandscope-harness.md`
## Code style
+
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
@@ -87,6 +102,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
## Safety
+
- Do not add network-dependent runtime paths for local analysis.
- Treat YouTube import as policy-constrained and fallback-friendly.
- Treat files, URLs, metadata, model artifacts, and project files as untrusted input.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3302a6fc3..d27bb5686 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-03-11
+Last updated: 2026-08-21
## Brand source
@@ -86,6 +86,7 @@ Last updated: 2026-03-11
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
+ - a local rehearsal transport that arms the first valid section loop, counts in at the admitted tempo, and names the next play/pause/stop action without pretending disk audio is playing when no local song is loaded
## Confidence, edits, and provenance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eea696893..403727250 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Tonight's rehearsal map now arms the first valid section loop, runs a tempo count-in, and names the next play, pause, or choose-local-song action instead of a coming-soon control.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index 82c2c704a..52399239a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into
Three layers, decoupled through shared contracts:
-- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
+- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The workspace `RehearsalPlayer` owns tonight's first section loop and count-in clock; it does not decode local audio in this slice. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
new file mode 100644
index 000000000..d6318ccb9
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -0,0 +1,80 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language,
+ });
+}
+
+describe("RehearsalPlayer", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("names the first playable loop and asks for a local song before hearing it", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ render( );
+
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i);
+ expect(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ ).toBeTruthy();
+ });
+
+ it("counts in then loops the selected section on the map clock", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const song = createDemoRehearsalSong();
+ render( );
+
+ 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 120 BPM/i);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/verse is looping 0:10–0:30/i);
+
+ act(() => {
+ vi.advanceTimersByTime(1500);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"),
+ ).toContain("%");
+ });
+
+ it("stays fail-closed when no section has a usable window", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections = [];
+ render( );
+
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Add a section with a start and end time/i);
+ expect(
+ (
+ screen.getByRole("button", {
+ name: /Start the count-in/i,
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
new file mode 100644
index 000000000..4eedeada3
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -0,0 +1,239 @@
+import { useEffect, useMemo, useState, type ReactElement } from "react";
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { Button } from "@/components/ui/button";
+import {
+ createTranslator,
+ detectPreferredLocale,
+ type TranslationKey,
+} from "../../i18n";
+import {
+ beatDurationMs,
+ createIdleTransportState,
+ fillRehearsalCopy,
+ formatRehearsalClock,
+ isPlayableLoopSection,
+ nextActionTemplateKey,
+ nextActionValues,
+ reduceRehearsalTransport,
+ resolveLoopWindow,
+ type RehearsalTransportState,
+} from "./rehearsalTransport";
+
+interface RehearsalPlayerProps {
+ song: RehearsalSong;
+ hasLocalAudio?: boolean;
+ startNonce?: number;
+}
+
+const PLAYHEAD_TICK_SECONDS = 0.1;
+
+/** Documented. */
+function loopProgressPercent(state: RehearsalTransportState): number {
+ if (!state.loop) {
+ return 0;
+ }
+ const duration = state.loop.endSeconds - state.loop.startSeconds;
+ if (!(duration > 0)) {
+ return 0;
+ }
+ return Math.min(
+ 100,
+ Math.max(
+ 0,
+ ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100,
+ ),
+ );
+}
+
+/** Render tonight's first section loop with a count-in and a named next action. */
+export function RehearsalPlayer({
+ song,
+ hasLocalAudio = false,
+ startNonce = 0,
+}: RehearsalPlayerProps): ReactElement {
+ const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const playableSections = useMemo(
+ () =>
+ Array.isArray(song.sections)
+ ? song.sections.filter((section) => isPlayableLoopSection(section))
+ : [],
+ [song],
+ );
+ const [selectedSectionId, setSelectedSectionId] = useState(
+ playableSections[0]?.id ?? null,
+ );
+ const [transport, setTransport] = useState(() =>
+ reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop: resolveLoopWindow(song, playableSections[0]?.id),
+ }),
+ );
+
+ useEffect(() => {
+ const nextLoop = resolveLoopWindow(song, selectedSectionId);
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }),
+ );
+ }, [song, selectedSectionId]);
+
+ useEffect(() => {
+ if (startNonce <= 0) {
+ return;
+ }
+ setTransport((current) => {
+ const armed = current.loop
+ ? current
+ : reduceRehearsalTransport(current, {
+ type: "arm",
+ loop: resolveLoopWindow(song, selectedSectionId),
+ });
+ return reduceRehearsalTransport(armed, { type: "start" });
+ });
+ }, [startNonce, song, selectedSectionId]);
+
+ useEffect(() => {
+ if (transport.phase !== "counting-in" || !transport.loop) {
+ return undefined;
+ }
+ const timer = window.setInterval(() => {
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "beat" }),
+ );
+ }, beatDurationMs(transport.loop.tempoBpm));
+ return () => window.clearInterval(timer);
+ }, [transport.phase, transport.loop]);
+
+ useEffect(() => {
+ if (transport.phase !== "looping" || !transport.loop) {
+ return undefined;
+ }
+ const timer = window.setInterval(() => {
+ setTransport((current) =>
+ reduceRehearsalTransport(current, {
+ type: "tick",
+ deltaSeconds: PLAYHEAD_TICK_SECONDS,
+ }),
+ );
+ }, PLAYHEAD_TICK_SECONDS * 1000);
+ return () => window.clearInterval(timer);
+ }, [transport.phase, transport.loop]);
+
+ const actionKey = nextActionTemplateKey(transport, hasLocalAudio);
+ const nextAction = fillRehearsalCopy(
+ t(actionKey as TranslationKey),
+ nextActionValues(transport),
+ );
+ const canStart = transport.loop !== null;
+ const canPause =
+ transport.phase === "counting-in" || transport.phase === "looping";
+ const canStop = transport.phase !== "idle" && transport.loop !== null;
+ const startLabel =
+ transport.phase === "paused"
+ ? t("workspaceLoopResume")
+ : t("workspaceLoopStart");
+
+ return (
+
+
+ {t("workspaceLoopTitle")}
+
+
+ {nextAction}
+
+ {playableSections.length > 0 ? (
+
+ {playableSections.map((section) => {
+ const selected =
+ section.id === (transport.loop?.sectionId ?? selectedSectionId);
+ return (
+ setSelectedSectionId(section.id)}
+ >
+ {section.label} ·{" "}
+ {formatRehearsalClock(section.timeRange.start)}–
+ {formatRehearsalClock(section.timeRange.end)}
+
+ );
+ })}
+
+ ) : null}
+
+
+ {formatRehearsalClock(transport.playheadSeconds)} /{" "}
+ {formatRehearsalClock(transport.loop?.endSeconds ?? 0)}
+
+
+
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "start" }),
+ )
+ }
+ >
+ {startLabel}
+
+
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "pause" }),
+ )
+ }
+ >
+ {t("workspaceLoopPause")}
+
+
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "stop" }),
+ )
+ }
+ >
+ {t("workspaceLoopStop")}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index a3da5ffe6..92272c013 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -85,6 +85,35 @@ describe("Workspace", () => {
expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
+ it("puts tonight's first playable loop on the map before a role is chosen", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render( );
+
+ expect(screen.getByRole("region", { name: /Tonight's section loop/i })).toBeTruthy();
+ expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch(
+ /Loop verse from 0:10–0:30\. Choose a local song first/i
+ );
+ });
+
+ it("starts the section loop from the selected role instead of a coming-soon control", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar"
+ };
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+ fireEvent.click(screen.getByRole("button", { name: "Loop this section" }));
+
+ expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch(/Count in 4 beats at 120 BPM/i);
+ expect(screen.queryByRole("button", { name: /Loop section coming soon/i })).toBeNull();
+ });
+
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 71546b524..72108fe27 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
+import { RehearsalPlayer } from "./RehearsalPlayer";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -120,6 +121,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState(null);
+ const [loopStartNonce, setLoopStartNonce] = useState(0);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
// Extract all unique roles from the song's sections
@@ -333,6 +335,12 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+
+
@@ -364,14 +372,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
setLoopStartNonce((current) => current + 1)}
variant="outline"
- className="min-h-11 cursor-not-allowed border-white/10 bg-white/5 text-slate-400 opacity-70"
+ className="min-h-11 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
>
- Loop section
+ {t("workspaceLoopThisSection")}
{
+ it("rejects blank, inverted, and non-finite section windows before arming a loop", () => {
+ const song = createDemoRehearsalSong();
+ expect(isPlayableLoopSection(song.sections[0])).toBe(true);
+ expect(isPlayableLoopSection(undefined)).toBe(false);
+ song.sections[0]!.timeRange = { start: Number.NaN, end: 30 };
+ expect(createLoopWindow(song.sections[0]!, song.tempo)).toBeNull();
+ song.sections[0]!.timeRange = { start: 40, end: 10 };
+ expect(isPlayableLoopSection(song.sections[0])).toBe(false);
+ });
+
+ it("arms the first valid section and skips a requested invalid id", () => {
+ const song = createDemoRehearsalSong();
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ song.sections.push(chorus);
+ song.sections[0]!.timeRange = { start: Number.POSITIVE_INFINITY, end: 30 };
+ const window = resolveLoopWindow(song, "missing-section");
+ expect(window?.sectionId).toBe("chorus-1");
+ expect(window?.startSeconds).toBe(40);
+ expect(window?.endSeconds).toBe(64);
+ });
+
+ it("assumes 120 BPM when tempo is missing and keeps published tempo in range", () => {
+ expect(resolveRehearsalTempo(undefined)).toEqual({
+ tempoBpm: 120,
+ tempoAssumed: true,
+ });
+ expect(resolveRehearsalTempo(0)).toEqual({
+ tempoBpm: 120,
+ tempoAssumed: true,
+ });
+ expect(resolveRehearsalTempo(96)).toEqual({
+ tempoBpm: 96,
+ tempoAssumed: false,
+ });
+ expect(beatDurationMs(120)).toBe(500);
+ });
+
+ it("counts in four beats then wraps the playhead inside the section", () => {
+ const song = createDemoRehearsalSong();
+ const loop = resolveLoopWindow(song);
+ expect(loop).not.toBeNull();
+ let state = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ });
+ expect(nextActionTemplateKey(state, false)).toBe(
+ "workspaceLoopArmedNoAudio",
+ );
+ state = reduceRehearsalTransport(state, { type: "start" });
+ expect(state.phase).toBe("counting-in");
+ expect(state.countInRemainingBeats).toBe(4);
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ expect(state.phase).toBe("looping");
+ expect(state.playheadSeconds).toBe(loop!.startSeconds);
+ state = reduceRehearsalTransport(state, {
+ type: "tick",
+ deltaSeconds: loop!.endSeconds - loop!.startSeconds + 1.5,
+ });
+ expect(state.playheadSeconds).toBeCloseTo(loop!.startSeconds + 1.5);
+ expect(wrapPlayhead(loop!.endSeconds, loop!)).toBe(loop!.startSeconds);
+ });
+
+ it("pauses a live loop and names the next play action", () => {
+ const song = createDemoRehearsalSong();
+ const loop = resolveLoopWindow(song, song.sections[0]!.id);
+ let state = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ });
+ state = reduceRehearsalTransport(state, { type: "start" });
+ state = reduceRehearsalTransport(
+ { ...state, phase: "looping", countInRemainingBeats: 0 },
+ { type: "pause" },
+ );
+ expect(state.phase).toBe("paused");
+ expect(nextActionTemplateKey(state, true)).toBe("workspaceLoopPaused");
+ expect(
+ fillRehearsalCopy(
+ "Loop {section} from {start}–{end}.",
+ nextActionValues(state),
+ ),
+ ).toContain(song.sections[0]!.label);
+ state = reduceRehearsalTransport(state, { type: "stop" });
+ expect(state.phase).toBe("armed");
+ expect(state.playheadSeconds).toBe(loop!.startSeconds);
+ });
+
+ it("formats a safe clock and stays idle when no playable section exists", () => {
+ expect(formatRehearsalClock(Number.NaN)).toBe("0:00");
+ expect(formatRehearsalClock(125)).toBe("2:05");
+ const song = createDemoRehearsalSong();
+ song.sections = [];
+ expect(resolveLoopWindow(song)).toBeNull();
+ const idle = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop: null,
+ });
+ expect(nextActionTemplateKey(idle, true)).toBe("workspaceLoopIdle");
+ expect(reduceRehearsalTransport(idle, { type: "start" }).phase).toBe(
+ "idle",
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
new file mode 100644
index 000000000..9e255b28d
--- /dev/null
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -0,0 +1,307 @@
+import type { RehearsalSection, RehearsalSong } from "@bandscope/shared-types";
+
+const DEFAULT_REHEARSAL_TEMPO_BPM = 120;
+const DEFAULT_COUNT_IN_BEATS = 4;
+const MIN_REHEARSAL_TEMPO_BPM = 30;
+const MAX_REHEARSAL_TEMPO_BPM = 300;
+
+/** Documented rehearsal transport phases for the first section loop. */
+export type RehearsalTransportPhase =
+ "idle" | "armed" | "counting-in" | "looping" | "paused";
+
+/** Bounded loop window derived from one valid analyzed section. */
+export interface RehearsalLoopWindow {
+ sectionId: string;
+ sectionLabel: string;
+ startSeconds: number;
+ endSeconds: number;
+ tempoBpm: number;
+ tempoAssumed: boolean;
+ countInBeats: number;
+}
+
+/** Deterministic transport snapshot used by the rehearsal player. */
+export interface RehearsalTransportState {
+ phase: RehearsalTransportPhase;
+ loop: RehearsalLoopWindow | null;
+ countInRemainingBeats: number;
+ playheadSeconds: number;
+}
+
+/** Discrete transport commands that never inspect the filesystem. */
+export type RehearsalTransportEvent =
+ | { type: "arm"; loop: RehearsalLoopWindow | null }
+ | { type: "start" }
+ | { type: "beat" }
+ | { type: "tick"; deltaSeconds: number }
+ | { type: "pause" }
+ | { type: "stop" };
+
+/** Documented. */
+export function isFiniteNonNegativeNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
+}
+
+/** Return whether a section exposes a usable closed loop window. */
+export function isPlayableLoopSection(
+ section: RehearsalSection | undefined | null,
+): boolean {
+ if (!section || typeof section.id !== "string" || section.id.trim() === "") {
+ return false;
+ }
+ const start = section.timeRange?.start;
+ const end = section.timeRange?.end;
+ return (
+ isFiniteNonNegativeNumber(start) &&
+ isFiniteNonNegativeNumber(end) &&
+ end > start
+ );
+}
+
+/** Admit a published tempo or fall back to the labeled rehearsal default. */
+export function resolveRehearsalTempo(tempo: unknown): {
+ tempoBpm: number;
+ tempoAssumed: boolean;
+} {
+ if (
+ typeof tempo === "number" &&
+ Number.isFinite(tempo) &&
+ tempo >= MIN_REHEARSAL_TEMPO_BPM &&
+ tempo <= MAX_REHEARSAL_TEMPO_BPM
+ ) {
+ return { tempoBpm: tempo, tempoAssumed: false };
+ }
+ return { tempoBpm: DEFAULT_REHEARSAL_TEMPO_BPM, tempoAssumed: true };
+}
+
+/** Convert one beat at the admitted tempo into milliseconds. */
+export function beatDurationMs(tempoBpm: number): number {
+ const admitted = resolveRehearsalTempo(tempoBpm).tempoBpm;
+ return 60_000 / admitted;
+}
+
+/** Convert one beat at the admitted tempo into seconds. */
+export function beatDurationSeconds(tempoBpm: number): number {
+ return beatDurationMs(tempoBpm) / 1000;
+}
+
+/** Format a bounded clock as m:ss for the rehearsal map. */
+export function formatRehearsalClock(totalSeconds: number): string {
+ const safeSeconds = isFiniteNonNegativeNumber(totalSeconds)
+ ? totalSeconds
+ : 0;
+ const minutes = Math.floor(safeSeconds / 60);
+ const seconds = Math.floor(safeSeconds % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+/** Build a loop window from one section plus the song tempo. */
+export function createLoopWindow(
+ section: RehearsalSection,
+ tempo: unknown,
+): RehearsalLoopWindow | null {
+ if (!isPlayableLoopSection(section)) {
+ return null;
+ }
+ const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo);
+ return {
+ sectionId: section.id,
+ sectionLabel:
+ typeof section.label === "string" && section.label.trim()
+ ? section.label
+ : section.id,
+ startSeconds: section.timeRange.start,
+ endSeconds: section.timeRange.end,
+ tempoBpm,
+ tempoAssumed,
+ countInBeats: DEFAULT_COUNT_IN_BEATS,
+ };
+}
+
+/** Resolve the requested section, or the first valid section, as a loop window. */
+export function resolveLoopWindow(
+ song: RehearsalSong | null | undefined,
+ sectionId?: string | null,
+): RehearsalLoopWindow | null {
+ const sections = Array.isArray(song?.sections) ? song.sections : [];
+ if (typeof sectionId === "string" && sectionId.trim()) {
+ const requested = sections.find((section) => section.id === sectionId);
+ const requestedWindow = requested
+ ? createLoopWindow(requested, song?.tempo)
+ : null;
+ if (requestedWindow) {
+ return requestedWindow;
+ }
+ }
+ for (const section of sections) {
+ const window = createLoopWindow(section, song?.tempo);
+ if (window) {
+ return window;
+ }
+ }
+ return null;
+}
+
+/** Return the idle transport snapshot. */
+export function createIdleTransportState(): RehearsalTransportState {
+ return {
+ phase: "idle",
+ loop: null,
+ countInRemainingBeats: 0,
+ playheadSeconds: 0,
+ };
+}
+
+/** Wrap a looping playhead back to the loop start without overshooting the end. */
+export function wrapPlayhead(
+ playheadSeconds: number,
+ loop: RehearsalLoopWindow,
+): number {
+ const duration = loop.endSeconds - loop.startSeconds;
+ if (!(duration > 0) || !Number.isFinite(playheadSeconds)) {
+ return loop.startSeconds;
+ }
+ const elapsed = playheadSeconds - loop.startSeconds;
+ const wrapped = ((elapsed % duration) + duration) % duration;
+ return loop.startSeconds + wrapped;
+}
+
+/** Advance the transport without touching audio files or native paths. */
+export function reduceRehearsalTransport(
+ state: RehearsalTransportState,
+ event: RehearsalTransportEvent,
+): RehearsalTransportState {
+ switch (event.type) {
+ case "arm": {
+ if (!event.loop) {
+ return createIdleTransportState();
+ }
+ return {
+ phase: "armed",
+ loop: event.loop,
+ countInRemainingBeats: event.loop.countInBeats,
+ playheadSeconds: event.loop.startSeconds,
+ };
+ }
+ case "start": {
+ if (!state.loop) {
+ return state;
+ }
+ if (state.phase === "paused") {
+ return { ...state, phase: "looping" };
+ }
+ return {
+ ...state,
+ phase: "counting-in",
+ countInRemainingBeats: state.loop.countInBeats,
+ playheadSeconds: state.loop.startSeconds,
+ };
+ }
+ case "beat": {
+ if (state.phase !== "counting-in" || !state.loop) {
+ return state;
+ }
+ const remaining = state.countInRemainingBeats - 1;
+ if (remaining <= 0) {
+ return {
+ ...state,
+ phase: "looping",
+ countInRemainingBeats: 0,
+ playheadSeconds: state.loop.startSeconds,
+ };
+ }
+ return { ...state, countInRemainingBeats: remaining };
+ }
+ case "tick": {
+ if (state.phase !== "looping" || !state.loop) {
+ return state;
+ }
+ const delta = Number.isFinite(event.deltaSeconds)
+ ? Math.max(0, event.deltaSeconds)
+ : 0;
+ return {
+ ...state,
+ playheadSeconds: wrapPlayhead(
+ state.playheadSeconds + delta,
+ state.loop,
+ ),
+ };
+ }
+ case "pause": {
+ if (state.phase !== "looping" && state.phase !== "counting-in") {
+ return state;
+ }
+ return { ...state, phase: "paused" };
+ }
+ case "stop": {
+ if (!state.loop) {
+ return createIdleTransportState();
+ }
+ return {
+ phase: "armed",
+ loop: state.loop,
+ countInRemainingBeats: state.loop.countInBeats,
+ playheadSeconds: state.loop.startSeconds,
+ };
+ }
+ default:
+ return state;
+ }
+}
+
+/** Fill `{name}` placeholders in a rehearsal next-action template. */
+export function fillRehearsalCopy(
+ template: string,
+ values: Record,
+): string {
+ return template.replace(
+ /\{([a-zA-Z]+)\}/g,
+ (_match, name: string) => values[name] ?? "",
+ );
+}
+
+/** Choose the next-action template key for the current transport snapshot. */
+export function nextActionTemplateKey(
+ state: RehearsalTransportState,
+ hasLocalAudio: boolean,
+):
+ | "workspaceLoopIdle"
+ | "workspaceLoopArmedNoAudio"
+ | "workspaceLoopArmedWithAudio"
+ | "workspaceLoopCountingIn"
+ | "workspaceLoopPlaying"
+ | "workspaceLoopPaused" {
+ if (!state.loop || state.phase === "idle") {
+ return "workspaceLoopIdle";
+ }
+ if (state.phase === "counting-in") {
+ return "workspaceLoopCountingIn";
+ }
+ if (state.phase === "looping") {
+ return "workspaceLoopPlaying";
+ }
+ if (state.phase === "paused") {
+ return "workspaceLoopPaused";
+ }
+ return hasLocalAudio
+ ? "workspaceLoopArmedWithAudio"
+ : "workspaceLoopArmedNoAudio";
+}
+
+/** Build the placeholder map for the current loop window. */
+export function nextActionValues(
+ state: RehearsalTransportState,
+): Record {
+ if (!state.loop) {
+ return {};
+ }
+ return {
+ section: state.loop.sectionLabel,
+ start: formatRehearsalClock(state.loop.startSeconds),
+ end: formatRehearsalClock(state.loop.endSeconds),
+ beats: String(state.countInRemainingBeats || state.loop.countInBeats),
+ tempo: String(state.loop.tempoBpm),
+ };
+}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 39f716d50..32467027c 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -148,5 +148,19 @@
"practiceProgressRegionLabel": "Practice Progress",
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
- "increasePracticeProgressLabel": "Increase progress"
+ "increasePracticeProgressLabel": "Increase progress",
+ "workspaceLoopRegionLabel": "Tonight's section loop",
+ "workspaceLoopTitle": "Tonight's loop",
+ "workspaceLoopSectionPickerLabel": "Playable sections",
+ "workspaceLoopStart": "Start the count-in",
+ "workspaceLoopThisSection": "Loop this section",
+ "workspaceLoopResume": "Continue this loop",
+ "workspaceLoopPause": "Pause the loop",
+ "workspaceLoopStop": "Stop and reset",
+ "workspaceLoopIdle": "Add a section with a start and end time, then loop it here.",
+ "workspaceLoopArmedNoAudio": "Loop {section} from {start}–{end}. Choose a local song first so you can hear this loop.",
+ "workspaceLoopArmedWithAudio": "Loop {section} from {start}–{end}. Start the count-in to hear it.",
+ "workspaceLoopCountingIn": "Count in {beats} beats at {tempo} BPM, then {section} loops.",
+ "workspaceLoopPlaying": "{section} is looping {start}–{end}. Pause when you have the entrance.",
+ "workspaceLoopPaused": "{section} is paused on the loop. Press play to continue."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 371884abb..ef99ede55 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -148,5 +148,19 @@
"practiceProgressRegionLabel": "연습 진척도",
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
- "increasePracticeProgressLabel": "진척도 증가"
+ "increasePracticeProgressLabel": "진척도 증가",
+ "workspaceLoopRegionLabel": "오늘 밤 구간 루프",
+ "workspaceLoopTitle": "오늘 밤 루프",
+ "workspaceLoopSectionPickerLabel": "연습할 구간",
+ "workspaceLoopStart": "카운트인 시작",
+ "workspaceLoopThisSection": "이 구간 루프",
+ "workspaceLoopResume": "이 루프 이어가기",
+ "workspaceLoopPause": "루프 일시정지",
+ "workspaceLoopStop": "멈추고 처음으로",
+ "workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.",
+ "workspaceLoopArmedNoAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 이 루프를 들으려면 먼저 로컬 곡을 고르세요.",
+ "workspaceLoopArmedWithAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 카운트인을 시작해 들어 보세요.",
+ "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 {section} 구간이 루프됩니다.",
+ "workspaceLoopPlaying": "{section} 구간이 {start}–{end}에서 루프 중입니다. 입구가 잡히면 일시정지하세요.",
+ "workspaceLoopPaused": "{section} 구간 루프가 멈춰 있습니다. 재생을 눌러 이어 가세요."
}
From d329fbc9152d9abec7311ee2a51084fbcd9c63d6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 16:22:03 -0700
Subject: [PATCH 02/70] test(player): require explicit audio and one-shot start
authority
---
.../workspace/RehearsalPlayer.test.tsx | 72 +++++++++++++++++--
1 file changed, 68 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index d6318ccb9..049accfee 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -19,7 +19,7 @@ describe("RehearsalPlayer", () => {
vi.restoreAllMocks();
});
- it("names the first playable loop and asks for a local song before hearing it", () => {
+ it("names the first playable loop and blocks starting before local audio exists", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
render( );
@@ -28,8 +28,31 @@ describe("RehearsalPlayer", () => {
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i);
expect(
- screen.getByRole("button", { name: /Start the count-in/i }),
- ).toBeTruthy();
+ (
+ screen.getByRole("button", {
+ name: /Start the count-in/i,
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ });
+
+ it("does not let an external start request bypass missing local-audio authority", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Choose a local song first/i);
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).not.toMatch(/Count in 4 beats/i);
});
it("counts in then loops the selected section on the map clock", () => {
@@ -60,6 +83,47 @@ describe("RehearsalPlayer", () => {
).toContain("%");
});
+ it("does not restart the count-in when section selection changes under the same start nonce", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections = [
+ {
+ ...song.sections[0]!,
+ id: "verse-a",
+ label: "verse",
+ timeRange: { start: 10, end: 20 },
+ },
+ {
+ ...song.sections[0]!,
+ id: "chorus-b",
+ label: "chorus",
+ timeRange: { start: 20, end: 30 },
+ },
+ ];
+
+ render(
+ ,
+ );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 4 beats/i);
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /chorus.*0:20.*0:30/i }),
+ );
+
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Loop chorus from 0:20–0:30\. Start the count-in/i);
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).not.toMatch(/Count in 4 beats/i);
+ });
+
it("stays fail-closed when no section has a usable window", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -77,4 +141,4 @@ describe("RehearsalPlayer", () => {
).disabled,
).toBe(true);
});
-});
+});
\ No newline at end of file
From aa43a842d3f6033857ef9db3d014f487c88e8648 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 16:22:46 -0700
Subject: [PATCH 03/70] fix(player): bind starts to fresh audio-authorized
requests
---
.../features/workspace/RehearsalPlayer.tsx | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 4eedeada3..24bbc786e 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState, type ReactElement } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import {
@@ -68,6 +68,7 @@ export function RehearsalPlayer({
loop: resolveLoopWindow(song, playableSections[0]?.id),
}),
);
+ const lastHandledStartNonce = useRef(0);
useEffect(() => {
const nextLoop = resolveLoopWindow(song, selectedSectionId);
@@ -77,7 +78,11 @@ export function RehearsalPlayer({
}, [song, selectedSectionId]);
useEffect(() => {
- if (startNonce <= 0) {
+ if (startNonce <= lastHandledStartNonce.current) {
+ return;
+ }
+ lastHandledStartNonce.current = startNonce;
+ if (!hasLocalAudio) {
return;
}
setTransport((current) => {
@@ -89,7 +94,7 @@ export function RehearsalPlayer({
});
return reduceRehearsalTransport(armed, { type: "start" });
});
- }, [startNonce, song, selectedSectionId]);
+ }, [startNonce, hasLocalAudio, song, selectedSectionId]);
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
@@ -123,7 +128,7 @@ export function RehearsalPlayer({
t(actionKey as TranslationKey),
nextActionValues(transport),
);
- const canStart = transport.loop !== null;
+ const canStart = transport.loop !== null && hasLocalAudio;
const canPause =
transport.phase === "counting-in" || transport.phase === "looping";
const canStop = transport.phase !== "idle" && transport.loop !== null;
@@ -197,11 +202,14 @@ export function RehearsalPlayer({
disabled={!canStart}
aria-disabled={!canStart}
className="min-h-11 border-cyan-300/30 bg-cyan-300/15 font-semibold text-cyan-50 disabled:cursor-not-allowed disabled:opacity-70"
- onClick={() =>
+ onClick={() => {
+ if (!canStart) {
+ return;
+ }
setTransport((current) =>
reduceRehearsalTransport(current, { type: "start" }),
- )
- }
+ );
+ }}
>
{startLabel}
@@ -236,4 +244,4 @@ export function RehearsalPlayer({
);
-}
+}
\ No newline at end of file
From 58cf24947373184aaacca3928b366d70c3e9c9a7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 16:25:10 -0700
Subject: [PATCH 04/70] test(workspace): bind role loop action to local audio
---
.../src/features/workspace/Workspace.test.tsx | 156 ++++++++++++------
1 file changed, 108 insertions(+), 48 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 92272c013..8a2c62125 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -1,5 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
+import {
+ createDemoRehearsalSong,
+ type ProjectBootstrapSummary,
+ type RehearsalSong,
+} from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
import { EmptyState, LoadingState } from "./WorkspaceStates";
@@ -12,21 +16,37 @@ const originalRevokeObjectUrl = URL.revokeObjectURL;
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
- value: language
+ value: language,
});
}
+function createLocalSourceBootstrap(): ProjectBootstrapSummary {
+ return {
+ projectId: "project-1",
+ sourceMode: "reference",
+ projectRoot: "/tmp/bandscope/projects/project-1",
+ cacheRoot: "/tmp/bandscope/cache/project-1",
+ tempRoot: "/tmp/bandscope/temp/project-1",
+ source: {
+ sourcePath: "/Users/test/Music/late-night-set.wav",
+ fileName: "late-night-set.wav",
+ extension: "wav",
+ fileSizeBytes: 1_024_000,
+ },
+ };
+}
+
describe("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
vi.restoreAllMocks();
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: originalCreateObjectUrl
+ value: originalCreateObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: originalRevokeObjectUrl
+ value: originalRevokeObjectUrl,
});
});
@@ -37,7 +57,7 @@ describe("Workspace", () => {
...song.sections[0]!.roles[0]!,
id: "bass-guitar",
name: "Bass Guitar",
- practiceProgress: 50
+ practiceProgress: 50,
};
const onSongUpdate = vi.fn();
@@ -46,7 +66,9 @@ describe("Workspace", () => {
// Select the Bass Guitar role to render PracticeProgress
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const increaseBtn = screen.getByRole("button", { name: "Increase progress" });
+ const increaseBtn = screen.getByRole("button", {
+ name: "Increase progress",
+ });
fireEvent.click(increaseBtn);
expect(onSongUpdate).toHaveBeenCalledTimes(1);
@@ -77,7 +99,7 @@ describe("Workspace", () => {
const song = createDemoRehearsalSong();
song.sections[0].timeRange = {
start: Number.NaN,
- end: Number.POSITIVE_INFINITY
+ end: Number.POSITIVE_INFINITY,
};
render( );
@@ -91,27 +113,60 @@ describe("Workspace", () => {
render( );
- expect(screen.getByRole("region", { name: /Tonight's section loop/i })).toBeTruthy();
- expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch(
- /Loop verse from 0:10–0:30\. Choose a local song first/i
- );
+ expect(
+ screen.getByRole("region", { name: /Tonight's section loop/i }),
+ ).toBeTruthy();
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i);
});
- it("starts the section loop from the selected role instead of a coming-soon control", () => {
+ it("keeps the role loop action unavailable without local audio authority", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "bass-guitar",
- name: "Bass Guitar"
+ name: "Bass Guitar",
};
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const loopButton = screen.getByRole("button", {
+ name: "Loop this section",
+ });
+ expect(loopButton.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(loopButton);
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Choose a local song first/i);
+ });
+
+ it("starts the section loop from the selected role when local audio is available", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ };
+
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
fireEvent.click(screen.getByRole("button", { name: "Loop this section" }));
- expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch(/Count in 4 beats at 120 BPM/i);
- expect(screen.queryByRole("button", { name: /Loop section coming soon/i })).toBeNull();
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 4 beats at 120 BPM/i);
+ expect(
+ screen.queryByRole("button", { name: /Loop section coming soon/i }),
+ ).toBeNull();
});
it("enables bass transcription from selected role metadata rather than role id text", () => {
@@ -119,13 +174,15 @@ describe("Workspace", () => {
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "low-end",
- name: "Bass Guitar"
+ name: "Bass Guitar",
};
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement;
+ const transcribeButton = screen.getByRole("button", {
+ name: "Transcribe Bass",
+ }) as HTMLButtonElement;
expect(transcribeButton.disabled).toBe(false);
expect(transcribeButton.title).toBe("Transcribe part");
});
@@ -137,14 +194,16 @@ describe("Workspace", () => {
name: "Bass Guitar",
transcription: [
{ pitch: "E2", onset: 0, offset: 0.75, velocity: 0.74 },
- { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 }
- ]
+ { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 },
+ ],
};
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i });
+ const grooveMap = screen.getByRole("region", {
+ name: /bass transcription groove map/i,
+ });
expect(grooveMap.className).toContain("bg-slate-950");
expect(screen.getByText("E2")).toBeTruthy();
expect(screen.getByText("G2")).toBeTruthy();
@@ -165,7 +224,9 @@ describe("Workspace", () => {
expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
expect(screen.getByText(/whole step lower/i)).toBeTruthy();
- expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy();
+ expect(
+ screen.getByText(/Lock the bass entrance against the pickup/i),
+ ).toBeTruthy();
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});
@@ -175,11 +236,11 @@ describe("Workspace", () => {
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
harmonicExplanation: " ",
- transpositionPlan: ""
+ transpositionPlan: "",
};
song.collaboration = {
syncMode: "local_only",
- syncNote: "Local-only draft"
+ syncNote: "Local-only draft",
} as RehearsalSong["collaboration"];
render( );
@@ -191,34 +252,27 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
expect(screen.getByText("vi pedal anchor")).toBeTruthy();
- expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText("Stay on roots if the chorus entrance gets muddy.")
+ .length,
+ ).toBeGreaterThan(0);
});
it("exports a metadata-only handoff artifact from the workspace", async () => {
const song = createDemoRehearsalSong();
- const sourceBootstrap: ProjectBootstrapSummary = {
- projectId: "project-1",
- sourceMode: "reference",
- projectRoot: "/tmp/bandscope/projects/project-1",
- cacheRoot: "/tmp/bandscope/cache/project-1",
- tempRoot: "/tmp/bandscope/temp/project-1",
- source: {
- sourcePath: "/Users/test/Music/late-night-set.wav",
- fileName: "late-night-set.wav",
- extension: "wav",
- fileSizeBytes: 1_024_000
- }
- };
+ const sourceBootstrap = createLocalSourceBootstrap();
const createObjectUrl = vi.fn(() => "blob:handoff");
const revokeObjectUrl = vi.fn();
- const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const click = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: createObjectUrl
+ value: createObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: revokeObjectUrl
+ value: revokeObjectUrl,
});
render( );
@@ -236,18 +290,20 @@ describe("Workspace", () => {
it("exports metadata-only handoff when source bootstrap is invalid", async () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
- projectId: "project-1"
+ projectId: "project-1",
} as ProjectBootstrapSummary;
const createObjectUrl = vi.fn(() => "blob:handoff");
const revokeObjectUrl = vi.fn();
- const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const click = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
- value: createObjectUrl
+ value: createObjectUrl,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
- value: revokeObjectUrl
+ value: revokeObjectUrl,
});
render( );
@@ -264,11 +320,13 @@ describe("Workspace", () => {
it("validates source bootstrap before generating metadata handoff", () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
- projectId: "project-1"
+ projectId: "project-1",
} as ProjectBootstrapSummary;
expect(() => {
- generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap });
+ generateMetadataHandoffJson(song, {
+ sourceBootstrap: invalidSourceBootstrap,
+ });
}).toThrow("sourceMode");
});
@@ -277,7 +335,9 @@ describe("Workspace", () => {
render( );
render( );
- expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "분석 준비 완료" }),
+ ).toBeTruthy();
expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy();
});
@@ -286,7 +346,7 @@ describe("Workspace", () => {
const song = createDemoRehearsalSong();
song.exportSummary = {
...song.exportSummary,
- headline: ""
+ headline: "",
};
render( );
From de0081120af51c6d92eb767601b136f0374e5eb6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 17:38:10 -0700
Subject: [PATCH 05/70] fix(workspace): gate role loop by local audio authority
---
.../src/features/workspace/Workspace.tsx | 20 +++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 72108fe27..1eb1f1448 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -123,6 +123,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const [activeRole, setActiveRole] = useState(null);
const [loopStartNonce, setLoopStartNonce] = useState(0);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const hasLocalAudio = safeProjectBootstrapSummary(sourceBootstrap) !== null;
// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
@@ -337,7 +338,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -373,10 +374,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
setLoopStartNonce((current) => current + 1)}
+ onClick={(event) => {
+ if (!hasLocalAudio) {
+ preventUnavailableAction(event);
+ return;
+ }
+ setLoopStartNonce((current) => current + 1);
+ }}
variant="outline"
- className="min-h-11 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
+ className={
+ hasLocalAudio
+ ? "min-h-11 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
+ : "min-h-11 cursor-not-allowed border-white/10 bg-white/5 font-semibold text-slate-400 opacity-70"
+ }
>
{t("workspaceLoopThisSection")}
@@ -497,4 +509,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
);
-}
+}
\ No newline at end of file
From 80265c2f03da65a5ede7961e284f75bce309bb3f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 17:39:54 -0700
Subject: [PATCH 06/70] test(workspace): reject duplicate section id selection
aliasing
---
.../workspace/RehearsalPlayer.test.tsx | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 049accfee..0825e2d18 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -124,6 +124,44 @@ describe("RehearsalPlayer", () => {
).not.toMatch(/Count in 4 beats/i);
});
+ it("keeps duplicate analysis section ids selectable by renderer position", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections = [
+ {
+ ...song.sections[0]!,
+ id: "duplicate-section",
+ label: "verse",
+ timeRange: { start: 10, end: 20 },
+ },
+ {
+ ...song.sections[0]!,
+ id: "duplicate-section",
+ label: "chorus",
+ timeRange: { start: 30, end: 40 },
+ },
+ ];
+
+ render( );
+
+ const verseButton = screen.getByRole("button", {
+ name: /verse.*0:10.*0:20/i,
+ });
+ const chorusButton = screen.getByRole("button", {
+ name: /chorus.*0:30.*0:40/i,
+ });
+ expect(verseButton.getAttribute("aria-pressed")).toBe("true");
+ expect(chorusButton.getAttribute("aria-pressed")).toBe("false");
+
+ fireEvent.click(chorusButton);
+
+ expect(verseButton.getAttribute("aria-pressed")).toBe("false");
+ expect(chorusButton.getAttribute("aria-pressed")).toBe("true");
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Loop chorus from 0:30–0:40\. Start the count-in/i);
+ });
+
it("stays fail-closed when no section has a usable window", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
From 28e82d13062bac094683f12b8b98c1e9e72cc29f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 17:42:01 -0700
Subject: [PATCH 07/70] fix(workspace): select loop sections by renderer
position
---
.../features/workspace/RehearsalPlayer.tsx | 49 ++++++++++++-------
1 file changed, 31 insertions(+), 18 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 24bbc786e..7889fb4e0 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -9,13 +9,13 @@ import {
import {
beatDurationMs,
createIdleTransportState,
+ createLoopWindow,
fillRehearsalCopy,
formatRehearsalClock,
isPlayableLoopSection,
nextActionTemplateKey,
nextActionValues,
reduceRehearsalTransport,
- resolveLoopWindow,
type RehearsalTransportState,
} from "./rehearsalTransport";
@@ -59,23 +59,28 @@ export function RehearsalPlayer({
: [],
[song],
);
- const [selectedSectionId, setSelectedSectionId] = useState(
- playableSections[0]?.id ?? null,
- );
- const [transport, setTransport] = useState(() =>
- reduceRehearsalTransport(createIdleTransportState(), {
+ const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
+ const selectedRendererIndex = playableSections[selectedSectionIndex]
+ ? selectedSectionIndex
+ : 0;
+ const [transport, setTransport] = useState(() => {
+ const firstSection = playableSections[0];
+ return reduceRehearsalTransport(createIdleTransportState(), {
type: "arm",
- loop: resolveLoopWindow(song, playableSections[0]?.id),
- }),
- );
+ loop: firstSection ? createLoopWindow(firstSection, song.tempo) : null,
+ });
+ });
const lastHandledStartNonce = useRef(0);
useEffect(() => {
- const nextLoop = resolveLoopWindow(song, selectedSectionId);
+ const selectedSection = playableSections[selectedRendererIndex];
+ const nextLoop = selectedSection
+ ? createLoopWindow(selectedSection, song.tempo)
+ : null;
setTransport((current) =>
reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }),
);
- }, [song, selectedSectionId]);
+ }, [playableSections, selectedRendererIndex, song.tempo]);
useEffect(() => {
if (startNonce <= lastHandledStartNonce.current) {
@@ -86,15 +91,24 @@ export function RehearsalPlayer({
return;
}
setTransport((current) => {
+ const selectedSection = playableSections[selectedRendererIndex];
const armed = current.loop
? current
: reduceRehearsalTransport(current, {
type: "arm",
- loop: resolveLoopWindow(song, selectedSectionId),
+ loop: selectedSection
+ ? createLoopWindow(selectedSection, song.tempo)
+ : null,
});
return reduceRehearsalTransport(armed, { type: "start" });
});
- }, [startNonce, hasLocalAudio, song, selectedSectionId]);
+ }, [
+ startNonce,
+ hasLocalAudio,
+ playableSections,
+ selectedRendererIndex,
+ song.tempo,
+ ]);
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
@@ -157,12 +171,11 @@ export function RehearsalPlayer({
role="group"
aria-label={t("workspaceLoopSectionPickerLabel")}
>
- {playableSections.map((section) => {
- const selected =
- section.id === (transport.loop?.sectionId ?? selectedSectionId);
+ {playableSections.map((section, index) => {
+ const selected = index === selectedRendererIndex;
return (
setSelectedSectionId(section.id)}
+ onClick={() => setSelectedSectionIndex(index)}
>
{section.label} ·{" "}
{formatRehearsalClock(section.timeRange.start)}–
From 1fb2ad1a908de838f384443308c2523f2f7d57c6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 19:21:37 -0700
Subject: [PATCH 08/70] test(workspace): revoke loop authority fail closed
---
.../workspace/RehearsalPlayer.test.tsx | 52 +++++++++++++++++++
1 file changed, 52 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 0825e2d18..cc4889a07 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -55,6 +55,58 @@ describe("RehearsalPlayer", () => {
).not.toMatch(/Count in 4 beats/i);
});
+ it("stops active count-in and loop ticking when local-audio authority is revoked", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const song = createDemoRehearsalSong();
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 4 beats/i);
+
+ rerender( );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Choose a local song first/i);
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).not.toMatch(/looping/i);
+
+ rerender( );
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/looping/i);
+
+ const playheadBeforeRevocation = screen
+ .getByTestId("rehearsal-loop-playhead")
+ .getAttribute("style");
+ rerender( );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Choose a local song first/i);
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"),
+ ).toBe(playheadBeforeRevocation);
+ });
+
it("counts in then loops the selected section on the map clock", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
From 6675a837b6b0fc3ef1daca248da09019bb1ef152 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:12:58 -0700
Subject: [PATCH 09/70] fix(workspace): stop loop when local audio is revoked
---
.../src/features/workspace/RehearsalPlayer.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 7889fb4e0..4bc8b3a15 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -110,6 +110,18 @@ export function RehearsalPlayer({
song.tempo,
]);
+ useEffect(() => {
+ if (hasLocalAudio) {
+ return;
+ }
+ setTransport((current) => {
+ if (current.phase === "idle" || current.phase === "armed") {
+ return current;
+ }
+ return reduceRehearsalTransport(current, { type: "stop" });
+ });
+ }, [hasLocalAudio]);
+
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
return undefined;
@@ -257,4 +269,4 @@ export function RehearsalPlayer({
);
-}
\ No newline at end of file
+}
From 9e8f60550d21ece9fdb0ed274868996f4d545712 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:14:01 -0700
Subject: [PATCH 10/70] test(workspace): reject malformed loop section entries
---
.../features/workspace/rehearsalTransport.test.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index 523f58ecb..47812fcd6 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -40,6 +40,21 @@ describe("rehearsalTransport", () => {
expect(window?.endSeconds).toBe(64);
});
+ it("skips malformed section entries before requested-id lookup and fallback", () => {
+ const song = createDemoRehearsalSong();
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ song.sections = [null as never, chorus];
+
+ const window = resolveLoopWindow(song, "missing-section");
+
+ expect(window?.sectionId).toBe("chorus-1");
+ expect(window?.startSeconds).toBe(40);
+ expect(window?.endSeconds).toBe(64);
+ });
+
it("assumes 120 BPM when tempo is missing and keeps published tempo in range", () => {
expect(resolveRehearsalTempo(undefined)).toEqual({
tempoBpm: 120,
From 705b0f789321d61541214f025e3ba7424e82d0d3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:16:28 -0700
Subject: [PATCH 11/70] fix(workspace): validate loop sections before id lookup
---
apps/desktop/src/features/workspace/rehearsalTransport.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 9e255b28d..96e9d1b47 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -127,7 +127,9 @@ export function resolveLoopWindow(
): RehearsalLoopWindow | null {
const sections = Array.isArray(song?.sections) ? song.sections : [];
if (typeof sectionId === "string" && sectionId.trim()) {
- const requested = sections.find((section) => section.id === sectionId);
+ const requested = sections.find(
+ (section) => isPlayableLoopSection(section) && section.id === sectionId,
+ );
const requestedWindow = requested
? createLoopWindow(requested, song?.tempo)
: null;
From 65da84ce2c642e3f5e4931b7202c061ed3fcc977 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:17:27 -0700
Subject: [PATCH 12/70] test(workspace): preserve paused count-in progress
---
.../workspace/rehearsalTransport.test.ts | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index 47812fcd6..46b44bf94 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -99,6 +99,29 @@ describe("rehearsalTransport", () => {
expect(wrapPlayhead(loop!.endSeconds, loop!)).toBe(loop!.startSeconds);
});
+ it("resumes the remaining count-in beats after pausing during count-in", () => {
+ const song = createDemoRehearsalSong();
+ const loop = resolveLoopWindow(song);
+ let state = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ });
+ state = reduceRehearsalTransport(state, { type: "start" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ expect(state.countInRemainingBeats).toBe(3);
+ state = reduceRehearsalTransport(state, { type: "pause" });
+ expect(state.phase).toBe("paused");
+
+ state = reduceRehearsalTransport(state, { type: "start" });
+
+ expect(state.phase).toBe("counting-in");
+ expect(state.countInRemainingBeats).toBe(3);
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ state = reduceRehearsalTransport(state, { type: "beat" });
+ expect(state.phase).toBe("looping");
+ });
+
it("pauses a live loop and names the next play action", () => {
const song = createDemoRehearsalSong();
const loop = resolveLoopWindow(song, song.sections[0]!.id);
From 5a839dab4ecd5532eb115d69989eefe655f19e07 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:18:36 -0700
Subject: [PATCH 13/70] fix(workspace): resume paused count-in before looping
---
apps/desktop/src/features/workspace/rehearsalTransport.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 96e9d1b47..eb3b1eded 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -192,7 +192,11 @@ export function reduceRehearsalTransport(
return state;
}
if (state.phase === "paused") {
- return { ...state, phase: "looping" };
+ return {
+ ...state,
+ phase:
+ state.countInRemainingBeats > 0 ? "counting-in" : "looping",
+ };
}
return {
...state,
From eafc1217216cb5c5c559ede2f56686132d986a60 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:19:21 -0700
Subject: [PATCH 14/70] test(workspace): disable duplicate start during active
loop timing
---
.../workspace/RehearsalPlayer.test.tsx | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index cc4889a07..4fd706f9b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -135,6 +135,29 @@ describe("RehearsalPlayer", () => {
).toContain("%");
});
+ it("disables start while count-in or loop timing is already active", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const song = createDemoRehearsalSong();
+ render( );
+
+ const startButton = screen.getByRole("button", {
+ name: /Start the count-in/i,
+ }) as HTMLButtonElement;
+ expect(startButton.disabled).toBe(false);
+
+ fireEvent.click(startButton);
+ expect(startButton.disabled).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(startButton.disabled).toBe(true);
+
+ fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
+ expect(startButton.disabled).toBe(false);
+ });
+
it("does not restart the count-in when section selection changes under the same start nonce", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
From 65c73c872d031405baffbe72c60f7bfd1ef1e51d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:20:14 -0700
Subject: [PATCH 15/70] fix(workspace): disable start while loop timing is
active
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 4bc8b3a15..3b78d8097 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -154,7 +154,10 @@ export function RehearsalPlayer({
t(actionKey as TranslationKey),
nextActionValues(transport),
);
- const canStart = transport.loop !== null && hasLocalAudio;
+ const canStart =
+ transport.loop !== null &&
+ hasLocalAudio &&
+ (transport.phase === "armed" || transport.phase === "paused");
const canPause =
transport.phase === "counting-in" || transport.phase === "looping";
const canStop = transport.phase !== "idle" && transport.loop !== null;
From 0476b6d80e38681f34434bc5fb8705ee789563bc Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:21:58 -0700
Subject: [PATCH 16/70] docs: use analysis-engine working directory for Python
checks
---
AGENTS.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ce03853d8..638bbd097 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,8 +70,8 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Full harness check: `./scripts/harness/quickcheck.sh`
- Frontend tests: `npm run test --workspaces --if-present`
-- Python tests: `uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100`
-- Typecheck: `npm run typecheck --workspaces --if-present && uv run --project services/analysis-engine mypy src`
+- Python tests: `uv run --directory services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100`
+- Typecheck: `npm run typecheck --workspaces --if-present && uv run --directory services/analysis-engine mypy src`
## Architecture references
From 78527c753a28f885b583e3e8818b36088657c495 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 20:22:32 -0700
Subject: [PATCH 17/70] docs(workspace): describe finite non-negative transport
guard
---
apps/desktop/src/features/workspace/rehearsalTransport.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index eb3b1eded..b8ab5b02b 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -37,7 +37,7 @@ export type RehearsalTransportEvent =
| { type: "pause" }
| { type: "stop" };
-/** Documented. */
+/** Return true only for finite numeric values greater than or equal to zero. */
export function isFiniteNonNegativeNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
From ff205818717f895949111f3cbb898199e34e1a25 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 01:23:07 -0700
Subject: [PATCH 18/70] test(workspace): scope timeline range assertion
---
apps/desktop/src/App.test.tsx | 42 +++++------------------------------
1 file changed, 6 insertions(+), 36 deletions(-)
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..329e9c30a 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -313,10 +313,10 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy();
});
- expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy();
+ const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
+ expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy();
expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy();
expect(screen.queryByText(/Mock-board/i)).toBeNull();
- const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(timelineRegion.className).toContain("overflow-x-auto");
expect(timelineRegion.getAttribute("tabindex")).toBe("0");
expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull();
@@ -361,8 +361,6 @@ describe("App", () => {
it("short-circuits confidence evaluation when encountering a low confidence section", async () => {
const loadedProject = succeededResult().result; // medium is first
- // Add low and high sections. High shouldn't matter since low is lowest.
- // And low will trigger the early break in the loop.
loadedProject.sections.push(
{
...loadedProject.sections[0],
@@ -810,9 +808,6 @@ describe("App", () => {
render( );
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
- await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
-
- fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3));
act(() => {
@@ -1091,7 +1086,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/Section Roadmap/i)).toBeTruthy();
});
- expect(tauriInvoke).toHaveBeenCalledTimes(2); // select + start
+ expect(tauriInvoke).toHaveBeenCalledTimes(2);
});
it("imports a YouTube URL successfully", async () => {
@@ -1191,9 +1186,6 @@ describe("App", () => {
const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: " " } });
const button = screen.getByRole("button", { name: /Import YouTube/i });
- // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage
- // or we can test that the error is set when it somehow triggers, but actually it's disabled.
- // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled!
fireEvent.click(button);
await waitFor(() => {
@@ -1227,7 +1219,7 @@ describe("App", () => {
it("rejects non-allowlisted YouTube URL intake before invoking the bridge", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL.../i);
+ const input = screen.getByPlaceholderText(/YouTube URL/i);
fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1240,7 +1232,7 @@ describe("App", () => {
it("rejects downgraded YouTube URL intake before invoking the bridge", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL.../i);
+ const input = screen.getByPlaceholderText(/YouTube URL/i);
fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1253,7 +1245,7 @@ describe("App", () => {
it("rejects duplicate YouTube video parameters even when one is blank", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL.../i);
+ const input = screen.getByPlaceholderText(/YouTube URL/i);
fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1264,7 +1256,6 @@ describe("App", () => {
expect(tauriInvoke).not.toHaveBeenCalled();
});
-
it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
@@ -1294,7 +1285,6 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
- // Should not show error, should remain in empty state
await waitFor(() => {
expect(mockLoadProject).toHaveBeenCalledTimes(1);
});
@@ -1359,7 +1349,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1367,7 +1356,6 @@ describe("App", () => {
mockSaveProject.mockResolvedValueOnce(undefined);
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1379,7 +1367,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1387,7 +1374,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1399,7 +1385,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1407,7 +1392,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("User cancelled"));
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1422,7 +1406,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1430,7 +1413,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("Disk full");
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1468,7 +1450,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1476,7 +1457,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("User cancelled");
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1491,19 +1471,15 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});
- // Mock prompt to simulate user entering a new chord
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7");
- // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit)
fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]);
- // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated)
await waitFor(() => {
expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0);
});
@@ -1552,7 +1528,6 @@ describe("App", () => {
});
});
-
it("renders Settings and Help as focusable aria-disabled controls", () => {
render( );
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
@@ -1589,8 +1564,6 @@ describe("App", () => {
fireEvent.click(scoreButton);
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
- // Projects opened from a .bscope file have no live workspace, so score
- // storage is gated behind the active-project notice.
expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
@@ -1604,9 +1577,6 @@ describe("App", () => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
- // The compact nav is a separate rendered bar (shown on small viewports) with
- // its own set of buttons; exercise it directly so the mobile navigation path
- // is covered, not just the sidebar one.
const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i });
expect(compactScoreButton).toBeEnabled();
From affa8da7591f60a9004da17358c32956de05f907 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 01:26:05 -0700
Subject: [PATCH 19/70] test(workspace): restore focused App integration
coverage
---
apps/desktop/src/App.test.tsx | 42 ++++++++++++++++++++++++++++++-----
1 file changed, 36 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 329e9c30a..3eed386f8 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -313,10 +313,10 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy();
});
- const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
- expect(within(timelineRegion).getByText(/verse · 0:10–0:30/i)).toBeTruthy();
+ expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy();
expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy();
expect(screen.queryByText(/Mock-board/i)).toBeNull();
+ const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i });
expect(timelineRegion.className).toContain("overflow-x-auto");
expect(timelineRegion.getAttribute("tabindex")).toBe("0");
expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull();
@@ -361,6 +361,8 @@ describe("App", () => {
it("short-circuits confidence evaluation when encountering a low confidence section", async () => {
const loadedProject = succeededResult().result; // medium is first
+ // Add low and high sections. High shouldn't matter since low is lowest.
+ // And low will trigger the early break in the loop.
loadedProject.sections.push(
{
...loadedProject.sections[0],
@@ -808,6 +810,9 @@ describe("App", () => {
render( );
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3));
act(() => {
@@ -1086,7 +1091,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByText(/Section Roadmap/i)).toBeTruthy();
});
- expect(tauriInvoke).toHaveBeenCalledTimes(2);
+ expect(tauriInvoke).toHaveBeenCalledTimes(2); // select + start
});
it("imports a YouTube URL successfully", async () => {
@@ -1186,6 +1191,9 @@ describe("App", () => {
const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: " " } });
const button = screen.getByRole("button", { name: /Import YouTube/i });
+ // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage
+ // or we can test that the error is set when it somehow triggers, but actually it's disabled.
+ // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled!
fireEvent.click(button);
await waitFor(() => {
@@ -1219,7 +1227,7 @@ describe("App", () => {
it("rejects non-allowlisted YouTube URL intake before invoking the bridge", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL/i);
+ const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1232,7 +1240,7 @@ describe("App", () => {
it("rejects downgraded YouTube URL intake before invoking the bridge", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL/i);
+ const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: "http://youtube.com/watch?v=abc123DEF45" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1245,7 +1253,7 @@ describe("App", () => {
it("rejects duplicate YouTube video parameters even when one is blank", async () => {
render( );
- const input = screen.getByPlaceholderText(/YouTube URL/i);
+ const input = screen.getByPlaceholderText(/YouTube URL.../i);
fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45&v=" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));
@@ -1256,6 +1264,7 @@ describe("App", () => {
expect(tauriInvoke).not.toHaveBeenCalled();
});
+
it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
@@ -1285,6 +1294,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
+ // Should not show error, should remain in empty state
await waitFor(() => {
expect(mockLoadProject).toHaveBeenCalledTimes(1);
});
@@ -1349,6 +1359,7 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1356,6 +1367,7 @@ describe("App", () => {
mockSaveProject.mockResolvedValueOnce(undefined);
+ // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1367,6 +1379,7 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1374,6 +1387,7 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));
+ // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1385,6 +1399,7 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1392,6 +1407,7 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("User cancelled"));
+ // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1406,6 +1422,7 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1413,6 +1430,7 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("Disk full");
+ // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1450,6 +1468,7 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1457,6 +1476,7 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("User cancelled");
+ // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1471,15 +1491,19 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
+ // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});
+ // Mock prompt to simulate user entering a new chord
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7");
+ // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit)
fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]);
+ // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated)
await waitFor(() => {
expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0);
});
@@ -1528,6 +1552,7 @@ describe("App", () => {
});
});
+
it("renders Settings and Help as focusable aria-disabled controls", () => {
render( );
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
@@ -1564,6 +1589,8 @@ describe("App", () => {
fireEvent.click(scoreButton);
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
+ // Projects opened from a .bscope file have no live workspace, so score
+ // storage is gated behind the active-project notice.
expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
@@ -1577,6 +1604,9 @@ describe("App", () => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
+ // The compact nav is a separate rendered bar (shown on small viewports) with
+ // its own set of buttons; exercise it directly so the mobile navigation path
+ // is covered, not just the sidebar one.
const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i });
expect(compactScoreButton).toBeEnabled();
From 1fc631780d28e00f3727c308377771c85a699408 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 01:26:56 -0700
Subject: [PATCH 20/70] fix(workspace): structure loop selector label fields
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 3b78d8097..1bc97883c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -202,9 +202,12 @@ export function RehearsalPlayer({
}
onClick={() => setSelectedSectionIndex(index)}
>
- {section.label} ·{" "}
- {formatRehearsalClock(section.timeRange.start)}–
- {formatRehearsalClock(section.timeRange.end)}
+ {section.label}
+ ·
+
+ {formatRehearsalClock(section.timeRange.start)}–
+ {formatRehearsalClock(section.timeRange.end)}
+
);
})}
From 6a58bada1284e9fe31007645564e97bbb93b89bd Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 03:41:08 -0700
Subject: [PATCH 21/70] test(workspace): preserve live loop across metadata
updates
---
.../workspace/RehearsalPlayer.test.tsx | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 4fd706f9b..f307ebd72 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -135,6 +135,47 @@ describe("RehearsalPlayer", () => {
).toContain("%");
});
+ it("keeps a live loop running across unrelated song metadata updates", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const song = createDemoRehearsalSong();
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ act(() => {
+ vi.advanceTimersByTime(2500);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/looping/i);
+
+ const updatedSong = {
+ ...song,
+ sections: song.sections.map((section, sectionIndex) =>
+ sectionIndex === 0
+ ? {
+ ...section,
+ roles: section.roles.map((role, roleIndex) =>
+ roleIndex === 0
+ ? { ...role, practiceProgress: 50 }
+ : role,
+ ),
+ }
+ : section,
+ ),
+ };
+
+ rerender( );
+
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/looping/i);
+ });
+
it("disables start while count-in or loop timing is already active", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
From b100551671dab3c35e4a268ee4a0e012292b347a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 03:42:03 -0700
Subject: [PATCH 22/70] fix(workspace): preserve live loop on unrelated song
updates
---
.../features/workspace/RehearsalPlayer.tsx | 34 +++++++++++++++++--
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 1bc97883c..907fc67f5 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -16,6 +16,7 @@ import {
nextActionTemplateKey,
nextActionValues,
reduceRehearsalTransport,
+ type RehearsalLoopWindow,
type RehearsalTransportState,
} from "./rehearsalTransport";
@@ -45,6 +46,20 @@ function loopProgressPercent(state: RehearsalTransportState): number {
);
}
+/** Return whether two loop windows describe the same transport timing authority. */
+function hasSameLoopTiming(
+ current: RehearsalLoopWindow,
+ next: RehearsalLoopWindow,
+): boolean {
+ return (
+ current.sectionId === next.sectionId &&
+ current.startSeconds === next.startSeconds &&
+ current.endSeconds === next.endSeconds &&
+ current.tempoBpm === next.tempoBpm &&
+ current.countInBeats === next.countInBeats
+ );
+}
+
/** Render tonight's first section loop with a count-in and a named next action. */
export function RehearsalPlayer({
song,
@@ -77,9 +92,22 @@ export function RehearsalPlayer({
const nextLoop = selectedSection
? createLoopWindow(selectedSection, song.tempo)
: null;
- setTransport((current) =>
- reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }),
- );
+ setTransport((current) => {
+ if (
+ current.loop &&
+ nextLoop &&
+ hasSameLoopTiming(current.loop, nextLoop)
+ ) {
+ if (
+ current.loop.sectionLabel === nextLoop.sectionLabel &&
+ current.loop.tempoAssumed === nextLoop.tempoAssumed
+ ) {
+ return current;
+ }
+ return { ...current, loop: nextLoop };
+ }
+ return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop });
+ });
}, [playableSections, selectedRendererIndex, song.tempo]);
useEffect(() => {
From 646f470ea5680c278852dadaae8c26f006c88705 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 21 Aug 2026 06:51:59 -0700
Subject: [PATCH 23/70] docs: align Python verification working directory
---
CLAUDE.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 52399239a..3a11902ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,8 +41,8 @@ npm --workspace @bandscope/desktop exec vitest run src/lib/export.test.ts # on
npm run dev --workspace @bandscope/desktop # Vite dev server (browser fallback mode)
npm run storybook --workspace @bandscope/desktop # component workbench
-uv run --project services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate)
-uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100 # full Python gate
+uv run --directory services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate)
+uv run --directory services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100 # full Python gate
```
## Architecture
From 96a191a9cb784b3fdb20237de728ecb56f179b9d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:14:45 -0700
Subject: [PATCH 24/70] test(i18n): name selected-section loop action honestly
---
apps/desktop/src/i18n/rehearsalLoopCopy.test.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
new file mode 100644
index 000000000..681c06014
--- /dev/null
+++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+import { createTranslator } from "./index";
+
+describe("rehearsal loop action copy", () => {
+ it("names the role action as starting the selected section in both locales", () => {
+ expect(createTranslator("en")("workspaceLoopThisSection")).toBe(
+ "Start selected section loop",
+ );
+ expect(createTranslator("ko")("workspaceLoopThisSection")).toBe(
+ "선택한 구간 루프 시작",
+ );
+ });
+});
From b4a5a0dbb0a1d6bcba16f307f28b2eda4da2a91b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:15:53 -0700
Subject: [PATCH 25/70] fix(i18n): name selected-section loop action honestly
---
apps/desktop/src/locales/en/common.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 32467027c..746eaae50 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,7 +153,7 @@
"workspaceLoopTitle": "Tonight's loop",
"workspaceLoopSectionPickerLabel": "Playable sections",
"workspaceLoopStart": "Start the count-in",
- "workspaceLoopThisSection": "Loop this section",
+ "workspaceLoopThisSection": "Start selected section loop",
"workspaceLoopResume": "Continue this loop",
"workspaceLoopPause": "Pause the loop",
"workspaceLoopStop": "Stop and reset",
From 3de2b6253cab832591cf4e761b0984f903bd3627 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:17:07 -0700
Subject: [PATCH 26/70] fix(i18n): localize selected-section loop action
---
apps/desktop/src/locales/ko/common.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index ef99ede55..8447db2d7 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,7 +153,7 @@
"workspaceLoopTitle": "오늘 밤 루프",
"workspaceLoopSectionPickerLabel": "연습할 구간",
"workspaceLoopStart": "카운트인 시작",
- "workspaceLoopThisSection": "이 구간 루프",
+ "workspaceLoopThisSection": "선택한 구간 루프 시작",
"workspaceLoopResume": "이 루프 이어가기",
"workspaceLoopPause": "루프 일시정지",
"workspaceLoopStop": "멈추고 처음으로",
From 110ca679980faf57e5f516a16d98b405d1c340f6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:18:51 -0700
Subject: [PATCH 27/70] test(workspace): follow selected-section loop copy
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 8a2c62125..87bdd85b8 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -134,7 +134,7 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
const loopButton = screen.getByRole("button", {
- name: "Loop this section",
+ name: "Start selected section loop",
});
expect(loopButton.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(loopButton);
@@ -143,7 +143,7 @@ describe("Workspace", () => {
).toMatch(/Choose a local song first/i);
});
- it("starts the section loop from the selected role when local audio is available", () => {
+ it("starts the selected section loop from the role action when local audio is available", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
@@ -159,7 +159,9 @@ describe("Workspace", () => {
/>,
);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- fireEvent.click(screen.getByRole("button", { name: "Loop this section" }));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Start selected section loop" }),
+ );
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
From ab660b6e0a0f960aceab311a55292aaf15712ff8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:20:09 -0700
Subject: [PATCH 28/70] test(i18n): keep rehearsal clock copy honest
---
apps/desktop/src/i18n/rehearsalLoopCopy.test.ts | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
index 681c06014..609269eaa 100644
--- a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
+++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
@@ -10,4 +10,18 @@ describe("rehearsal loop action copy", () => {
"선택한 구간 루프 시작",
);
});
+
+ it("describes the timer-only transport as a rehearsal clock in both locales", () => {
+ const en = createTranslator("en");
+ expect(en("workspaceLoopArmedWithAudio")).toContain("rehearsal clock");
+ expect(en("workspaceLoopCountingIn")).toContain("rehearsal clock");
+ expect(en("workspaceLoopPlaying")).toContain("rehearsal clock");
+ expect(en("workspaceLoopArmedNoAudio")).not.toMatch(/\bhear\b|\blisten\b/i);
+ expect(en("workspaceLoopArmedWithAudio")).not.toMatch(/\bhear\b|\blisten\b/i);
+
+ const ko = createTranslator("ko");
+ expect(ko("workspaceLoopArmedWithAudio")).toContain("합주 시계");
+ expect(ko("workspaceLoopCountingIn")).toContain("합주 시계");
+ expect(ko("workspaceLoopPlaying")).toContain("합주 시계");
+ });
});
From 139e8202aa588ccbb2c916f88ba81641c8503d87 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:21:23 -0700
Subject: [PATCH 29/70] fix(i18n): describe timer-only loop honestly
---
apps/desktop/src/locales/en/common.json | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 746eaae50..16c826d96 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -154,13 +154,13 @@
"workspaceLoopSectionPickerLabel": "Playable sections",
"workspaceLoopStart": "Start the count-in",
"workspaceLoopThisSection": "Start selected section loop",
- "workspaceLoopResume": "Continue this loop",
- "workspaceLoopPause": "Pause the loop",
- "workspaceLoopStop": "Stop and reset",
+ "workspaceLoopResume": "Continue rehearsal clock",
+ "workspaceLoopPause": "Pause rehearsal clock",
+ "workspaceLoopStop": "Stop and reset rehearsal clock",
"workspaceLoopIdle": "Add a section with a start and end time, then loop it here.",
- "workspaceLoopArmedNoAudio": "Loop {section} from {start}–{end}. Choose a local song first so you can hear this loop.",
- "workspaceLoopArmedWithAudio": "Loop {section} from {start}–{end}. Start the count-in to hear it.",
- "workspaceLoopCountingIn": "Count in {beats} beats at {tempo} BPM, then {section} loops.",
- "workspaceLoopPlaying": "{section} is looping {start}–{end}. Pause when you have the entrance.",
- "workspaceLoopPaused": "{section} is paused on the loop. Press play to continue."
+ "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.",
+ "workspaceLoopCountingIn": "Count in {beats} beats at {tempo} BPM, then the rehearsal clock loops {section}.",
+ "workspaceLoopPlaying": "The rehearsal clock is looping {section} from {start}–{end}. Pause when you have the entrance.",
+ "workspaceLoopPaused": "The rehearsal clock is paused on {section}. Continue when you are ready."
}
From 7617f0dc7cea3e24e23a5e955400f1be99783533 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:22:45 -0700
Subject: [PATCH 30/70] fix(i18n): localize rehearsal clock honestly
---
apps/desktop/src/locales/ko/common.json | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 8447db2d7..1389e3271 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -154,13 +154,13 @@
"workspaceLoopSectionPickerLabel": "연습할 구간",
"workspaceLoopStart": "카운트인 시작",
"workspaceLoopThisSection": "선택한 구간 루프 시작",
- "workspaceLoopResume": "이 루프 이어가기",
- "workspaceLoopPause": "루프 일시정지",
- "workspaceLoopStop": "멈추고 처음으로",
+ "workspaceLoopResume": "합주 시계 계속",
+ "workspaceLoopPause": "합주 시계 일시정지",
+ "workspaceLoopStop": "합주 시계 멈추고 초기화",
"workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.",
- "workspaceLoopArmedNoAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 이 루프를 들으려면 먼저 로컬 곡을 고르세요.",
- "workspaceLoopArmedWithAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 카운트인을 시작해 들어 보세요.",
- "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 {section} 구간이 루프됩니다.",
- "workspaceLoopPlaying": "{section} 구간이 {start}–{end}에서 루프 중입니다. 입구가 잡히면 일시정지하세요.",
- "workspaceLoopPaused": "{section} 구간 루프가 멈춰 있습니다. 재생을 눌러 이어 가세요."
+ "workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.",
+ "workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.",
+ "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 합주 시계가 {section} 구간을 반복합니다.",
+ "workspaceLoopPlaying": "합주 시계가 {section} 구간 {start}–{end}를 반복 중입니다. 입구가 잡히면 일시정지하세요.",
+ "workspaceLoopPaused": "{section} 구간 합주 시계가 멈춰 있습니다. 준비되면 이어 가세요."
}
From c9fb0f663000e5b26564b337f0d3c8d3b09e734c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 16:24:34 -0700
Subject: [PATCH 31/70] test(workspace): follow rehearsal clock copy
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 87bdd85b8..679b6683d 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -118,7 +118,9 @@ describe("Workspace", () => {
).toBeTruthy();
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
- ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i);
+ ).toMatch(
+ /Map verse from 0:10–0:30\. Choose a local song first to start the rehearsal clock/i,
+ );
});
it("keeps the role loop action unavailable without local audio authority", () => {
From 0512f30909a038e594aa934810cb38a8a4cb7f39 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 18:13:35 -0700
Subject: [PATCH 32/70] test(player): align loop assertions with shipped
next-action copy
---
.../src/features/workspace/RehearsalPlayer.test.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index f307ebd72..a3e805b4c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -26,7 +26,7 @@ describe("RehearsalPlayer", () => {
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
- ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i);
+ ).toMatch(/Map verse from 0:10–0:30\. Choose a local song first/i);
expect(
(
screen.getByRole("button", {
@@ -125,7 +125,7 @@ describe("RehearsalPlayer", () => {
});
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
- ).toMatch(/verse is looping 0:10–0:30/i);
+ ).toMatch(/The rehearsal clock is looping verse from 0:10–0:30/i);
act(() => {
vi.advanceTimersByTime(1500);
@@ -234,7 +234,7 @@ describe("RehearsalPlayer", () => {
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
- ).toMatch(/Loop chorus from 0:20–0:30\. Start the count-in/i);
+ ).toMatch(/Map chorus from 0:20–0:30\. Start the count-in/i);
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).not.toMatch(/Count in 4 beats/i);
@@ -275,7 +275,7 @@ describe("RehearsalPlayer", () => {
expect(chorusButton.getAttribute("aria-pressed")).toBe("true");
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
- ).toMatch(/Loop chorus from 0:30–0:40\. Start the count-in/i);
+ ).toMatch(/Map chorus from 0:30–0:40\. Start the count-in/i);
});
it("stays fail-closed when no section has a usable window", () => {
@@ -295,4 +295,4 @@ describe("RehearsalPlayer", () => {
).disabled,
).toBe(true);
});
-});
\ No newline at end of file
+});
From f5f753d1bd418813cb811b94cff1125107da3b7a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 18:15:08 -0700
Subject: [PATCH 33/70] test(player): verify revocation after the loop advances
---
.../src/features/workspace/RehearsalPlayer.test.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index a3e805b4c..acbb30f5d 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -86,7 +86,7 @@ describe("RehearsalPlayer", () => {
screen.getByRole("button", { name: /Start the count-in/i }),
);
act(() => {
- vi.advanceTimersByTime(2000);
+ vi.advanceTimersByTime(2500);
});
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
@@ -95,16 +95,23 @@ describe("RehearsalPlayer", () => {
const playheadBeforeRevocation = screen
.getByTestId("rehearsal-loop-playhead")
.getAttribute("style");
+ expect(playheadBeforeRevocation).not.toContain("width: 0%");
+
rerender( );
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/Choose a local song first/i);
+ const playheadAfterRevocation = screen
+ .getByTestId("rehearsal-loop-playhead")
+ .getAttribute("style");
+ expect(playheadAfterRevocation).not.toBe(playheadBeforeRevocation);
+
act(() => {
vi.advanceTimersByTime(1000);
});
expect(
screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"),
- ).toBe(playheadBeforeRevocation);
+ ).toBe(playheadAfterRevocation);
});
it("counts in then loops the selected section on the map clock", () => {
From 7c1b4973f9d7f4ae7320814cfa340d42d29e2706 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 22 Aug 2026 18:18:17 -0700
Subject: [PATCH 34/70] test(player): advance the live-loop clock before
revocation
---
apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index acbb30f5d..82f9dea9b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -86,11 +86,14 @@ describe("RehearsalPlayer", () => {
screen.getByRole("button", { name: /Start the count-in/i }),
);
act(() => {
- vi.advanceTimersByTime(2500);
+ vi.advanceTimersByTime(2000);
});
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/looping/i);
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
const playheadBeforeRevocation = screen
.getByTestId("rehearsal-loop-playhead")
From ad2d219556bad6e5f48f4f3c94ef95d22d1a02b2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:04:27 -0700
Subject: [PATCH 35/70] test(workspace): pin loop section descriptor authority
---
...rsalTransport.descriptor-authority.test.ts | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts
new file mode 100644
index 000000000..ae892fc3f
--- /dev/null
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts
@@ -0,0 +1,37 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { createLoopWindow } from "./rehearsalTransport";
+
+describe("rehearsal transport descriptor authority", () => {
+ it("uses one owned section snapshot instead of Proxy get values", () => {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ const expectedId = section.id;
+ const expectedLabel = section.label;
+ const expectedRange = { ...section.timeRange };
+ const proxiedSection = new Proxy(section, {
+ get(target, property, receiver) {
+ if (property === "id") {
+ return "proxy-injected-section";
+ }
+ if (property === "label") {
+ return "outro";
+ }
+ if (property === "timeRange") {
+ return { start: 90, end: 100 };
+ }
+ return Reflect.get(target, property, receiver);
+ }
+ });
+
+ expect(createLoopWindow(proxiedSection, song.tempo)).toEqual({
+ sectionId: expectedId,
+ sectionLabel: expectedLabel,
+ startSeconds: expectedRange.start,
+ endSeconds: expectedRange.end,
+ tempoBpm: 120,
+ tempoAssumed: false,
+ countInBeats: 4
+ });
+ });
+});
From 6b8abe5ed9206d91484792cbd6c7cecc2193776c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:05:23 -0700
Subject: [PATCH 36/70] fix(workspace): snapshot loop section authority
---
.../features/workspace/rehearsalTransport.ts | 102 ++++++++++++------
1 file changed, 71 insertions(+), 31 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index b8ab5b02b..5bc1e603c 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -37,25 +37,71 @@ export type RehearsalTransportEvent =
| { type: "pause" }
| { type: "stop" };
+type PlayableSectionSnapshot = Readonly<{
+ id: string;
+ label: string;
+ startSeconds: number;
+ endSeconds: number;
+}>;
+
/** Return true only for finite numeric values greater than or equal to zero. */
export function isFiniteNonNegativeNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
+/** Read one own data-property value without activating accessors or Proxy get traps. */
+function ownDataValue(value: object, key: PropertyKey): unknown {
+ try {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ return descriptor !== undefined &&
+ Object.prototype.hasOwnProperty.call(descriptor, "value")
+ ? descriptor.value
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Snapshot one playable section before any value can become transport authority. */
+function playableSectionSnapshot(
+ section: RehearsalSection | undefined | null,
+): PlayableSectionSnapshot | null {
+ if (!section || typeof section !== "object") {
+ return null;
+ }
+ const id = ownDataValue(section, "id");
+ const label = ownDataValue(section, "label");
+ const timeRange = ownDataValue(section, "timeRange");
+ if (
+ typeof id !== "string" ||
+ id.trim() === "" ||
+ !timeRange ||
+ typeof timeRange !== "object"
+ ) {
+ return null;
+ }
+ const start = ownDataValue(timeRange, "start");
+ const end = ownDataValue(timeRange, "end");
+ if (
+ !isFiniteNonNegativeNumber(start) ||
+ !isFiniteNonNegativeNumber(end) ||
+ end <= start
+ ) {
+ return null;
+ }
+ return {
+ id,
+ label: typeof label === "string" && label.trim() ? label : id,
+ startSeconds: start,
+ endSeconds: end,
+ };
+}
+
/** Return whether a section exposes a usable closed loop window. */
export function isPlayableLoopSection(
section: RehearsalSection | undefined | null,
): boolean {
- if (!section || typeof section.id !== "string" || section.id.trim() === "") {
- return false;
- }
- const start = section.timeRange?.start;
- const end = section.timeRange?.end;
- return (
- isFiniteNonNegativeNumber(start) &&
- isFiniteNonNegativeNumber(end) &&
- end > start
- );
+ return playableSectionSnapshot(section) !== null;
}
/** Admit a published tempo or fall back to the labeled rehearsal default. */
@@ -97,23 +143,21 @@ export function formatRehearsalClock(totalSeconds: number): string {
return `${minutes}:${seconds}`;
}
-/** Build a loop window from one section plus the song tempo. */
+/** Build a loop window from one snapshotted section plus the song tempo. */
export function createLoopWindow(
section: RehearsalSection,
tempo: unknown,
): RehearsalLoopWindow | null {
- if (!isPlayableLoopSection(section)) {
+ const snapshot = playableSectionSnapshot(section);
+ if (!snapshot) {
return null;
}
const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo);
return {
- sectionId: section.id,
- sectionLabel:
- typeof section.label === "string" && section.label.trim()
- ? section.label
- : section.id,
- startSeconds: section.timeRange.start,
- endSeconds: section.timeRange.end,
+ sectionId: snapshot.id,
+ sectionLabel: snapshot.label,
+ startSeconds: snapshot.startSeconds,
+ endSeconds: snapshot.endSeconds,
tempoBpm,
tempoAssumed,
countInBeats: DEFAULT_COUNT_IN_BEATS,
@@ -126,24 +170,20 @@ export function resolveLoopWindow(
sectionId?: string | null,
): RehearsalLoopWindow | null {
const sections = Array.isArray(song?.sections) ? song.sections : [];
+ const tempo = song?.tempo;
+ const windows = sections.flatMap((section) => {
+ const window = createLoopWindow(section, tempo);
+ return window ? [window] : [];
+ });
if (typeof sectionId === "string" && sectionId.trim()) {
- const requested = sections.find(
- (section) => isPlayableLoopSection(section) && section.id === sectionId,
+ const requestedWindow = windows.find(
+ (window) => window.sectionId === sectionId,
);
- const requestedWindow = requested
- ? createLoopWindow(requested, song?.tempo)
- : null;
if (requestedWindow) {
return requestedWindow;
}
}
- for (const section of sections) {
- const window = createLoopWindow(section, song?.tempo);
- if (window) {
- return window;
- }
- }
- return null;
+ return windows[0] ?? null;
}
/** Return the idle transport snapshot. */
From 0c7b293c7f9051ce47ab0b61e5e1f431ff0b4b17 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:05:42 -0700
Subject: [PATCH 37/70] test(workspace): pin loop picker snapshot authority
---
...earsalPlayer.descriptor-authority.test.tsx | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx
new file mode 100644
index 000000000..59bc9c4b5
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+describe("RehearsalPlayer descriptor authority", () => {
+ it("renders the admitted section snapshot instead of Proxy get values", () => {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ const expectedLabel = section.label;
+ const expectedStart = section.timeRange.start;
+ const expectedEnd = section.timeRange.end;
+ song.sections = [
+ new Proxy(section, {
+ get(target, property, receiver) {
+ if (property === "id") {
+ return "proxy-injected-section";
+ }
+ if (property === "label") {
+ return "outro";
+ }
+ if (property === "timeRange") {
+ return { start: 90, end: 100 };
+ }
+ return Reflect.get(target, property, receiver);
+ }
+ })
+ ];
+
+ render( );
+
+ expect(
+ screen.getByRole("button", {
+ name: new RegExp(`${expectedLabel}.*0:${String(expectedStart).padStart(2, "0")}.*0:${String(expectedEnd).padStart(2, "0")}`, "i")
+ })
+ ).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /outro.*1:30.*1:40/i })).toBeNull();
+ });
+});
From 3d5ac83c4a06b9ec2fb7d321ce7041d9c4c0ce6c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:06:48 -0700
Subject: [PATCH 38/70] fix(workspace): snapshot song loop windows
---
.../features/workspace/rehearsalTransport.ts | 59 +++++++++++++++++--
1 file changed, 53 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 5bc1e603c..8a66af2a7 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -62,6 +62,37 @@ function ownDataValue(value: object, key: PropertyKey): unknown {
}
}
+/** Snapshot an ordinary array through owned numeric data properties only. */
+function ownedDenseArray(value: unknown): unknown[] | null {
+ try {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+ const length = ownDataValue(value, "length");
+ if (
+ typeof length !== "number" ||
+ !Number.isSafeInteger(length) ||
+ length < 0
+ ) {
+ return null;
+ }
+ const items: unknown[] = [];
+ for (let index = 0; index < length; index += 1) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, index);
+ if (
+ descriptor === undefined ||
+ !Object.prototype.hasOwnProperty.call(descriptor, "value")
+ ) {
+ return null;
+ }
+ items.push(descriptor.value);
+ }
+ return items;
+ } catch {
+ return null;
+ }
+}
+
/** Snapshot one playable section before any value can become transport authority. */
function playableSectionSnapshot(
section: RehearsalSection | undefined | null,
@@ -164,17 +195,33 @@ export function createLoopWindow(
};
}
+/** Snapshot every playable loop window from one untrusted song record. */
+export function resolveLoopWindows(
+ song: RehearsalSong | null | undefined,
+): RehearsalLoopWindow[] {
+ if (!song || typeof song !== "object") {
+ return [];
+ }
+ const sections = ownedDenseArray(ownDataValue(song, "sections"));
+ if (!sections) {
+ return [];
+ }
+ const tempo = ownDataValue(song, "tempo");
+ return sections.flatMap((section) => {
+ if (!section || typeof section !== "object") {
+ return [];
+ }
+ const window = createLoopWindow(section as RehearsalSection, tempo);
+ return window ? [window] : [];
+ });
+}
+
/** Resolve the requested section, or the first valid section, as a loop window. */
export function resolveLoopWindow(
song: RehearsalSong | null | undefined,
sectionId?: string | null,
): RehearsalLoopWindow | null {
- const sections = Array.isArray(song?.sections) ? song.sections : [];
- const tempo = song?.tempo;
- const windows = sections.flatMap((section) => {
- const window = createLoopWindow(section, tempo);
- return window ? [window] : [];
- });
+ const windows = resolveLoopWindows(song);
if (typeof sectionId === "string" && sectionId.trim()) {
const requestedWindow = windows.find(
(window) => window.sectionId === sectionId,
From f420ffe0c5d5d64e1ad0daa4fe77bfd8b6c033e3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:07:21 -0700
Subject: [PATCH 39/70] fix(workspace): render snapshotted loop windows
---
.../features/workspace/RehearsalPlayer.tsx | 57 +++++++------------
1 file changed, 19 insertions(+), 38 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 907fc67f5..523cd5c53 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -9,13 +9,12 @@ import {
import {
beatDurationMs,
createIdleTransportState,
- createLoopWindow,
fillRehearsalCopy,
formatRehearsalClock,
- isPlayableLoopSection,
nextActionTemplateKey,
nextActionValues,
reduceRehearsalTransport,
+ resolveLoopWindows,
type RehearsalLoopWindow,
type RehearsalTransportState,
} from "./rehearsalTransport";
@@ -28,7 +27,7 @@ interface RehearsalPlayerProps {
const PLAYHEAD_TICK_SECONDS = 0.1;
-/** Documented. */
+/** Return the displayed map-clock progress for the current loop. */
function loopProgressPercent(state: RehearsalTransportState): number {
if (!state.loop) {
return 0;
@@ -67,31 +66,21 @@ export function RehearsalPlayer({
startNonce = 0,
}: RehearsalPlayerProps): ReactElement {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const playableSections = useMemo(
- () =>
- Array.isArray(song.sections)
- ? song.sections.filter((section) => isPlayableLoopSection(section))
- : [],
- [song],
- );
+ const playableLoops = useMemo(() => resolveLoopWindows(song), [song]);
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
- const selectedRendererIndex = playableSections[selectedSectionIndex]
+ const selectedRendererIndex = playableLoops[selectedSectionIndex]
? selectedSectionIndex
: 0;
- const [transport, setTransport] = useState(() => {
- const firstSection = playableSections[0];
- return reduceRehearsalTransport(createIdleTransportState(), {
+ const [transport, setTransport] = useState(() =>
+ reduceRehearsalTransport(createIdleTransportState(), {
type: "arm",
- loop: firstSection ? createLoopWindow(firstSection, song.tempo) : null,
- });
- });
+ loop: playableLoops[0] ?? null,
+ }),
+ );
const lastHandledStartNonce = useRef(0);
useEffect(() => {
- const selectedSection = playableSections[selectedRendererIndex];
- const nextLoop = selectedSection
- ? createLoopWindow(selectedSection, song.tempo)
- : null;
+ const nextLoop = playableLoops[selectedRendererIndex] ?? null;
setTransport((current) => {
if (
current.loop &&
@@ -108,7 +97,7 @@ export function RehearsalPlayer({
}
return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop });
});
- }, [playableSections, selectedRendererIndex, song.tempo]);
+ }, [playableLoops, selectedRendererIndex]);
useEffect(() => {
if (startNonce <= lastHandledStartNonce.current) {
@@ -119,24 +108,16 @@ export function RehearsalPlayer({
return;
}
setTransport((current) => {
- const selectedSection = playableSections[selectedRendererIndex];
+ const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
const armed = current.loop
? current
: reduceRehearsalTransport(current, {
type: "arm",
- loop: selectedSection
- ? createLoopWindow(selectedSection, song.tempo)
- : null,
+ loop: selectedLoop,
});
return reduceRehearsalTransport(armed, { type: "start" });
});
- }, [
- startNonce,
- hasLocalAudio,
- playableSections,
- selectedRendererIndex,
- song.tempo,
- ]);
+ }, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]);
useEffect(() => {
if (hasLocalAudio) {
@@ -208,13 +189,13 @@ export function RehearsalPlayer({
>
{nextAction}
- {playableSections.length > 0 ? (
+ {playableLoops.length > 0 ? (
- {playableSections.map((section, index) => {
+ {playableLoops.map((loop, index) => {
const selected = index === selectedRendererIndex;
return (
setSelectedSectionIndex(index)}
>
- {section.label}
+ {loop.sectionLabel}
·
- {formatRehearsalClock(section.timeRange.start)}–
- {formatRehearsalClock(section.timeRange.end)}
+ {formatRehearsalClock(loop.startSeconds)}–
+ {formatRehearsalClock(loop.endSeconds)}
);
From 2a8a96b17f8ab2042129a9b3c07239b4f2448eef Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:08:54 -0700
Subject: [PATCH 40/70] docs(changelog): describe map-clock snapshot boundary
---
CHANGELOG.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 403727250..52954f61a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
### Added
-- Tonight's rehearsal map now arms the first valid section loop, runs a tempo count-in, and names the next play, pause, or choose-local-song action instead of a coming-soon control.
+- 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.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -56,9 +56,9 @@
- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics
- Issue #28: Delivered practical rehearsal workspace UI
- Issue #27: Supported manual overrides, provenance tracking, and local project persistence
-- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports
-- Issue #30: Added policy-constrained YouTube import with local fallback
-- Issue #26: Finalized roadmap and prepared application for initial release
+- Issue #36: Added rehearsal priority calculation and cue-sheet/chart summary exports
+- Issue #30: Added YouTube import with policy-constrained browser fallback and local project ingestion
+- Issue #26: Added release safety gates and cross-platform build verification
## [0.1.4] - 2026-05-15
From d8e0c4ff40285d5dc9f5b3cdb150e4a2b5f2c87c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 15:09:19 -0700
Subject: [PATCH 41/70] docs(changelog): preserve historical release text
---
CHANGELOG.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52954f61a..51bce1031 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,9 +56,9 @@
- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics
- Issue #28: Delivered practical rehearsal workspace UI
- Issue #27: Supported manual overrides, provenance tracking, and local project persistence
-- Issue #36: Added rehearsal priority calculation and cue-sheet/chart summary exports
-- Issue #30: Added YouTube import with policy-constrained browser fallback and local project ingestion
-- Issue #26: Added release safety gates and cross-platform build verification
+- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports
+- Issue #30: Added policy-constrained YouTube import with local fallback
+- Issue #26: Finalized roadmap and prepared application for initial release
## [0.1.4] - 2026-05-15
From cfcfa1ec37348ae6ab35fc309b8e8307a6edaa54 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sat, 29 Aug 2026 05:27:02 +0900
Subject: [PATCH 42/70] fix(desktop): bound rehearsal loop inputs
---
.../workspace/rehearsalTransport.test.ts | 9 +++++++++
.../features/workspace/rehearsalTransport.ts | 19 ++++++++++++++++---
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index 46b44bf94..b252978ee 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -24,6 +24,8 @@ describe("rehearsalTransport", () => {
expect(createLoopWindow(song.sections[0]!, song.tempo)).toBeNull();
song.sections[0]!.timeRange = { start: 40, end: 10 };
expect(isPlayableLoopSection(song.sections[0])).toBe(false);
+ song.sections[0]!.timeRange = { start: 4_294_967_295, end: 4_294_967_296 };
+ expect(isPlayableLoopSection(song.sections[0])).toBe(false);
});
it("arms the first valid section and skips a requested invalid id", () => {
@@ -55,6 +57,13 @@ describe("rehearsalTransport", () => {
expect(window?.endSeconds).toBe(64);
});
+ it("rejects a sparse hostile section array without scanning its declared length", () => {
+ const song = createDemoRehearsalSong();
+ song.sections = new Array(0xffffffff) as typeof song.sections;
+
+ expect(resolveLoopWindow(song)).toBeNull();
+ });
+
it("assumes 120 BPM when tempo is missing and keeps published tempo in range", () => {
expect(resolveRehearsalTempo(undefined)).toEqual({
tempoBpm: 120,
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 8a66af2a7..0540f9fcc 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -1,4 +1,8 @@
-import type { RehearsalSection, RehearsalSong } from "@bandscope/shared-types";
+import {
+ MAX_SECTION_TIME_SECONDS,
+ type RehearsalSection,
+ type RehearsalSong,
+} from "@bandscope/shared-types";
const DEFAULT_REHEARSAL_TEMPO_BPM = 120;
const DEFAULT_COUNT_IN_BEATS = 4;
@@ -76,9 +80,16 @@ function ownedDenseArray(value: unknown): unknown[] | null {
) {
return null;
}
+ const keys = Object.keys(value);
+ if (keys.length !== length) {
+ return null;
+ }
const items: unknown[] = [];
- for (let index = 0; index < length; index += 1) {
- const descriptor = Object.getOwnPropertyDescriptor(value, index);
+ for (const [index, key] of keys.entries()) {
+ if (key !== String(index)) {
+ return null;
+ }
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (
descriptor === undefined ||
!Object.prototype.hasOwnProperty.call(descriptor, "value")
@@ -116,6 +127,8 @@ function playableSectionSnapshot(
if (
!isFiniteNonNegativeNumber(start) ||
!isFiniteNonNegativeNumber(end) ||
+ start > MAX_SECTION_TIME_SECONDS ||
+ end > MAX_SECTION_TIME_SECONDS ||
end <= start
) {
return null;
From 6de3f55dd370c42e0fe70aa0e4142fc83eebedcd Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sat, 29 Aug 2026 09:20:54 +0900
Subject: [PATCH 43/70] fix(a11y): announce rehearsal transport status
---
.../desktop/src/features/workspace/RehearsalPlayer.test.tsx | 6 ++++++
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 2 ++
2 files changed, 8 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 82f9dea9b..a3d223168 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -24,6 +24,12 @@ describe("RehearsalPlayer", () => {
const song = createDemoRehearsalSong();
render( );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").getAttribute("role"),
+ ).toBe("status");
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").getAttribute("aria-live"),
+ ).toBe("polite");
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/Map verse from 0:10–0:30\. Choose a local song first/i);
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 523cd5c53..6b5d52ae5 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -185,6 +185,8 @@ export function RehearsalPlayer({
{nextAction}
From 7a59047b004faa7a0b584f3d6d68c94889a452d5 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sat, 29 Aug 2026 09:40:36 +0900
Subject: [PATCH 44/70] fix(player): restart externally requested loops
---
.../workspace/RehearsalPlayer.test.tsx | 27 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 10 +++----
2 files changed, 31 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index a3d223168..ea752fd83 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -215,6 +215,33 @@ describe("RehearsalPlayer", () => {
expect(startButton.disabled).toBe(false);
});
+ it("restarts a paused loop from an external section-start request", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const song = createDemoRehearsalSong();
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/paused/i);
+
+ rerender(
+ ,
+ );
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 4 beats/i);
+ });
+
it("does not restart the count-in when section selection changes under the same start nonce", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 6b5d52ae5..70264984c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -109,12 +109,10 @@ export function RehearsalPlayer({
}
setTransport((current) => {
const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
- const armed = current.loop
- ? current
- : reduceRehearsalTransport(current, {
- type: "arm",
- loop: selectedLoop,
- });
+ const armed = reduceRehearsalTransport(current, {
+ type: "arm",
+ loop: selectedLoop,
+ });
return reduceRehearsalTransport(armed, { type: "start" });
});
}, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]);
From 280310901f63e9e71a8c064b33d719426a9cf1c7 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 02:21:01 +0900
Subject: [PATCH 45/70] feat(player): play real audio section loops
---
apps/desktop/src-tauri/Cargo.lock | 7 +
apps/desktop/src-tauri/Cargo.toml | 2 +-
apps/desktop/src-tauri/src/main.rs | 16 ++
apps/desktop/src-tauri/tauri.conf.json | 4 +
.../workspace/RehearsalPlayer.test.tsx | 95 ++++++--
.../features/workspace/RehearsalPlayer.tsx | 210 +++++++++++++++++-
.../src/features/workspace/Workspace.tsx | 9 +-
.../workspace/rehearsalTransport.test.ts | 25 +++
.../features/workspace/rehearsalTransport.ts | 13 ++
apps/desktop/src/locales/en/common.json | 1 +
apps/desktop/src/locales/ko/common.json | 1 +
11 files changed, 359 insertions(+), 24 deletions(-)
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 0fed84b0c..80832589f 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -1219,6 +1219,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2951,6 +2957,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
+ "http-range",
"jni",
"libc",
"log",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index bcafbab44..f32cccfae 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -11,7 +11,7 @@ bandscope-desktop-core = { path = "../core" }
rfd = "0.17.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-tauri = { version = "2.11.1", default-features = false, features = ["wry"] }
+tauri = { version = "2.11.1", default-features = false, features = ["protocol-asset", "wry"] }
time = { version = "0.3", features = ["formatting", "macros"] }
tokio = { version = "1.50.0", features = ["time"] }
url = "2.5.8"
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index ed4f967bd..a8a3de6ce 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -317,6 +317,20 @@ fn lookup_bootstrap_source(
.ok_or_else(|| "Analysis job source was not found. Choose local audio again.".to_string())
}
+/// Allow only the already-normalized source file to be served by the asset protocol.
+///
+/// Security Notes: the path is produced by the native file dialog or by the
+/// validated, app-owned YouTube cache path. The protocol starts with an empty
+/// scope, so this does not expose a directory or accept a path from JavaScript.
+fn allow_audio_source_for_playback(
+ app: &tauri::AppHandle,
+ source: &LocalAudioSourcePayload,
+) -> Result<(), String> {
+ app.asset_protocol_scope()
+ .allow_file(&source.source_path)
+ .map_err(|_| "Could not prepare the selected audio for playback.".to_string())
+}
+
fn drain_analysis_status_updates(
state: &AppState,
app: &tauri::AppHandle,
@@ -643,6 +657,7 @@ fn select_local_audio_source(
.pick_file()
.ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?;
let source = normalize_local_audio_source(&path)?;
+ allow_audio_source_for_playback(&app, &source)?;
let project_id = next_project_id(&state);
let project_root = app_owned_root(&app, "projects", &project_id)?;
let cache_root = app_owned_root(&app, "cache", &project_id)?;
@@ -712,6 +727,7 @@ async fn import_youtube_url(
if parsed.get("ok").and_then(|v| v.as_bool()) == Some(true) {
if let Some(metadata) = parsed.get("metadata") {
let source = youtube_source_from_metadata(metadata, &cache_root)?;
+ allow_audio_source_for_playback(&app, &source)?;
let summary = ProjectBootstrapSummaryPayload {
project_id,
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 8efaf48c7..b43115283 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -17,6 +17,10 @@
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: data: blob:; font-src 'self' data:",
+ "assetProtocol": {
+ "enable": true,
+ "scope": []
+ },
"capabilities": ["main-capability"]
}
},
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index ea752fd83..591d83597 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { RehearsalPlayer } from "./RehearsalPlayer";
const originalLanguage = navigator.language;
+const originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
@@ -17,6 +21,16 @@ describe("RehearsalPlayer", () => {
setNavigatorLanguage(originalLanguage);
vi.useRealTimers();
vi.restoreAllMocks();
+ if (originalTauriInternals) {
+ Object.defineProperty(
+ window,
+ "__TAURI_INTERNALS__",
+ originalTauriInternals,
+ );
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
});
it("names the first playable loop and blocks starting before local audio exists", () => {
@@ -28,7 +42,9 @@ describe("RehearsalPlayer", () => {
screen.getByTestId("rehearsal-loop-next-action").getAttribute("role"),
).toBe("status");
expect(
- screen.getByTestId("rehearsal-loop-next-action").getAttribute("aria-live"),
+ screen
+ .getByTestId("rehearsal-loop-next-action")
+ .getAttribute("aria-live"),
).toBe("polite");
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
@@ -46,11 +62,7 @@ describe("RehearsalPlayer", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
render(
- ,
+ ,
);
expect(
@@ -151,6 +163,65 @@ describe("RehearsalPlayer", () => {
).toContain("%");
});
+ it("uses the scoped native asset as the media clock for a real loop", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`);
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: { convertFileSrc },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ const play = vi
+ .spyOn(HTMLMediaElement.prototype, "play")
+ .mockResolvedValue(undefined);
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId(
+ "rehearsal-loop-audio",
+ ) as HTMLAudioElement;
+ expect(convertFileSrc).toHaveBeenCalledWith(
+ "/Users/test/Music/rehearsal.wav",
+ "asset",
+ );
+ expect(audio.src).toContain("asset://localhost/");
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ expect(play).toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ Object.defineProperty(audio, "currentTime", {
+ configurable: true,
+ writable: true,
+ value: 17.5,
+ });
+ fireEvent(audio, new Event("timeupdate"));
+ expect(
+ screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"),
+ ).toContain("37.5%");
+
+ Object.defineProperty(audio, "currentTime", {
+ configurable: true,
+ writable: true,
+ value: 31,
+ });
+ fireEvent(audio, new Event("timeupdate"));
+ expect(audio.currentTime).toBe(10);
+ });
+
it("keeps a live loop running across unrelated song metadata updates", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
@@ -176,9 +247,7 @@ describe("RehearsalPlayer", () => {
? {
...section,
roles: section.roles.map((role, roleIndex) =>
- roleIndex === 0
- ? { ...role, practiceProgress: 50 }
- : role,
+ roleIndex === 0 ? { ...role, practiceProgress: 50 } : role,
),
}
: section,
@@ -260,13 +329,7 @@ describe("RehearsalPlayer", () => {
},
];
- render(
- ,
- );
+ render( );
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/Count in 4 beats/i);
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 70264984c..f271dd427 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -1,5 +1,13 @@
-import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactElement,
+} from "react";
import type { RehearsalSong } from "@bandscope/shared-types";
+import { convertFileSrc } from "@tauri-apps/api/core";
import { Button } from "@/components/ui/button";
import {
createTranslator,
@@ -22,11 +30,26 @@ import {
interface RehearsalPlayerProps {
song: RehearsalSong;
hasLocalAudio?: boolean;
+ audioSourcePath?: string | null;
startNonce?: number;
}
const PLAYHEAD_TICK_SECONDS = 0.1;
+/** Convert a validated native source path into a scoped Tauri asset URL. */
+function resolveAudioSourceUrl(
+ sourcePath: string | null | undefined,
+): string | null {
+ if (!sourcePath || sourcePath.startsWith("browser://")) {
+ return null;
+ }
+ try {
+ return convertFileSrc(sourcePath);
+ } catch {
+ return null;
+ }
+}
+
/** Return the displayed map-clock progress for the current loop. */
function loopProgressPercent(state: RehearsalTransportState): number {
if (!state.loop) {
@@ -63,6 +86,7 @@ function hasSameLoopTiming(
export function RehearsalPlayer({
song,
hasLocalAudio = false,
+ audioSourcePath = null,
startNonce = 0,
}: RehearsalPlayerProps): ReactElement {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
@@ -78,6 +102,71 @@ export function RehearsalPlayer({
}),
);
const lastHandledStartNonce = useRef(0);
+ const restartAudioOnLoopRef = useRef(false);
+ const audioRef = useRef(null);
+ const audioSourceUrl = useMemo(
+ () => resolveAudioSourceUrl(audioSourcePath),
+ [audioSourcePath],
+ );
+ const [playbackError, setPlaybackError] = useState(false);
+
+ const handlePlaybackError = useCallback(() => {
+ setPlaybackError(true);
+ setTransport((current) => {
+ if (current.phase === "idle" || current.phase === "armed") {
+ return current;
+ }
+ return reduceRehearsalTransport(current, { type: "stop" });
+ });
+ }, []);
+
+ const startAudio = useCallback(
+ (loop: RehearsalLoopWindow, resume: boolean) => {
+ const audio = audioRef.current;
+ if (!audio || !audioSourceUrl) {
+ return;
+ }
+ try {
+ restartAudioOnLoopRef.current = !resume;
+ if (!resume) {
+ audio.currentTime = loop.startSeconds;
+ audio.volume = 0;
+ } else {
+ audio.volume = 1;
+ }
+ const playPromise = audio.play();
+ if (playPromise) {
+ void playPromise.catch(handlePlaybackError);
+ }
+ } catch {
+ handlePlaybackError();
+ }
+ },
+ [audioSourceUrl, handlePlaybackError],
+ );
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) {
+ return undefined;
+ }
+ if (!audio.paused) {
+ audio.pause();
+ }
+ audio.volume = 1;
+ if (audioSourceUrl) {
+ audio.src = audioSourceUrl;
+ audio.load();
+ } else {
+ audio.removeAttribute("src");
+ }
+ setPlaybackError(false);
+ return () => {
+ if (!audio.paused) {
+ audio.pause();
+ }
+ };
+ }, [audioSourceUrl]);
useEffect(() => {
const nextLoop = playableLoops[selectedRendererIndex] ?? null;
@@ -107,15 +196,25 @@ export function RehearsalPlayer({
if (!hasLocalAudio) {
return;
}
+ const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
+ if (selectedLoop) {
+ setPlaybackError(false);
+ startAudio(selectedLoop, false);
+ }
setTransport((current) => {
- const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
const armed = reduceRehearsalTransport(current, {
type: "arm",
loop: selectedLoop,
});
return reduceRehearsalTransport(armed, { type: "start" });
});
- }, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]);
+ }, [
+ startAudio,
+ startNonce,
+ hasLocalAudio,
+ playableLoops,
+ selectedRendererIndex,
+ ]);
useEffect(() => {
if (hasLocalAudio) {
@@ -142,7 +241,84 @@ export function RehearsalPlayer({
}, [transport.phase, transport.loop]);
useEffect(() => {
- if (transport.phase !== "looping" || !transport.loop) {
+ if (!audioSourceUrl || !transport.loop) {
+ return undefined;
+ }
+ const audio = audioRef.current;
+ if (!audio) {
+ return undefined;
+ }
+ if (transport.phase === "looping") {
+ try {
+ if (restartAudioOnLoopRef.current) {
+ audio.currentTime = transport.loop.startSeconds;
+ restartAudioOnLoopRef.current = false;
+ }
+ audio.volume = 1;
+ const playPromise = audio.play();
+ if (playPromise) {
+ void playPromise.catch(handlePlaybackError);
+ }
+ } catch {
+ handlePlaybackError();
+ }
+ } else if (
+ transport.phase === "armed" ||
+ transport.phase === "paused" ||
+ transport.phase === "idle"
+ ) {
+ if (!audio.paused) {
+ audio.pause();
+ }
+ audio.volume = 1;
+ }
+ return undefined;
+ }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]);
+
+ useEffect(() => {
+ if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) {
+ return undefined;
+ }
+ const audio = audioRef.current;
+ if (!audio) {
+ return undefined;
+ }
+ const loop = transport.loop;
+ /** Keep the map playhead aligned with the scoped audio element. */
+ const syncPlayhead = () => {
+ if (audio.currentTime >= loop.endSeconds) {
+ try {
+ audio.currentTime = loop.startSeconds;
+ const playPromise = audio.play();
+ if (playPromise) {
+ void playPromise.catch(handlePlaybackError);
+ }
+ } catch {
+ handlePlaybackError();
+ return;
+ }
+ }
+ setTransport((current) =>
+ reduceRehearsalTransport(current, {
+ type: "sync",
+ playheadSeconds: audio.currentTime,
+ }),
+ );
+ };
+ /** Stop the transport when the media element can no longer play. */
+ const failPlayback = () => handlePlaybackError();
+ audio.addEventListener("timeupdate", syncPlayhead);
+ audio.addEventListener("error", failPlayback);
+ audio.addEventListener("ended", failPlayback);
+ return () => {
+ audio.removeEventListener("timeupdate", syncPlayhead);
+ audio.removeEventListener("error", failPlayback);
+ audio.removeEventListener("ended", failPlayback);
+ };
+ }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]);
+
+ useEffect(() => {
+ if (audioSourceUrl || transport.phase !== "looping" || !transport.loop) {
return undefined;
}
const timer = window.setInterval(() => {
@@ -154,7 +330,7 @@ export function RehearsalPlayer({
);
}, PLAYHEAD_TICK_SECONDS * 1000);
return () => window.clearInterval(timer);
- }, [transport.phase, transport.loop]);
+ }, [audioSourceUrl, transport.phase, transport.loop]);
const actionKey = nextActionTemplateKey(transport, hasLocalAudio);
const nextAction = fillRehearsalCopy(
@@ -246,6 +422,14 @@ export function RehearsalPlayer({
if (!canStart) {
return;
}
+ setPlaybackError(false);
+ if (transport.loop) {
+ startAudio(
+ transport.loop,
+ transport.phase === "paused" &&
+ transport.countInRemainingBeats === 0,
+ );
+ }
setTransport((current) =>
reduceRehearsalTransport(current, { type: "start" }),
);
@@ -282,6 +466,22 @@ export function RehearsalPlayer({
{t("workspaceLoopStop")}
+ {playbackError ? (
+
+ {t("workspaceLoopAudioError")}
+
+ ) : null}
+
);
}
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index a24a02338..94de28ffc 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -124,7 +124,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const [activeRole, setActiveRole] = useState(null);
const [loopStartNonce, setLoopStartNonce] = useState(0);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const hasLocalAudio = safeProjectBootstrapSummary(sourceBootstrap) !== null;
+ const parsedSourceBootstrap = useMemo(
+ () => safeProjectBootstrapSummary(sourceBootstrap),
+ [sourceBootstrap],
+ );
+ const hasLocalAudio = parsedSourceBootstrap !== null;
// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
@@ -361,6 +365,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -531,4 +536,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
);
-}
\ No newline at end of file
+}
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index b252978ee..76405d57b 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -108,6 +108,31 @@ describe("rehearsalTransport", () => {
expect(wrapPlayhead(loop!.endSeconds, loop!)).toBe(loop!.startSeconds);
});
+ it("syncs the map clock from an admitted media playhead", () => {
+ const loop = resolveLoopWindow(createDemoRehearsalSong());
+ const looping = {
+ ...reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ }),
+ phase: "looping" as const,
+ countInRemainingBeats: 0,
+ };
+
+ const synced = reduceRehearsalTransport(looping, {
+ type: "sync",
+ playheadSeconds: 17.25,
+ });
+
+ expect(synced.playheadSeconds).toBe(17.25);
+ expect(
+ reduceRehearsalTransport(looping, {
+ type: "sync",
+ playheadSeconds: Number.NaN,
+ }).playheadSeconds,
+ ).toBe(loop!.startSeconds);
+ });
+
it("resumes the remaining count-in beats after pausing during count-in", () => {
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 0540f9fcc..b1a205ed7 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -37,6 +37,7 @@ export type RehearsalTransportEvent =
| { type: "arm"; loop: RehearsalLoopWindow | null }
| { type: "start" }
| { type: "beat" }
+ | { type: "sync"; playheadSeconds: number }
| { type: "tick"; deltaSeconds: number }
| { type: "pause" }
| { type: "stop" };
@@ -320,6 +321,18 @@ export function reduceRehearsalTransport(
}
return { ...state, countInRemainingBeats: remaining };
}
+ case "sync": {
+ if (state.phase !== "looping" || !state.loop) {
+ return state;
+ }
+ const playhead = Number.isFinite(event.playheadSeconds)
+ ? Math.max(state.loop.startSeconds, event.playheadSeconds)
+ : state.loop.startSeconds;
+ return {
+ ...state,
+ playheadSeconds: wrapPlayhead(playhead, state.loop),
+ };
+ }
case "tick": {
if (state.phase !== "looping" || !state.loop) {
return state;
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 508aa8ff1..c520ef61a 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -157,6 +157,7 @@
"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.",
"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 d05610140..e0d52893c 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -157,6 +157,7 @@
"workspaceLoopResume": "합주 시계 계속",
"workspaceLoopPause": "합주 시계 일시정지",
"workspaceLoopStop": "합주 시계 멈추고 초기화",
+ "workspaceLoopAudioError": "이 로컬 오디오를 재생할 수 없습니다. 곡을 다시 선택한 뒤 재시도하세요.",
"workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.",
"workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.",
"workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.",
From c911b41523df96bf85bc732fd3e344c819e3a3fd Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 02:33:08 +0900
Subject: [PATCH 46/70] fix(player): harden cross-platform audio loop
boundaries
---
apps/desktop/src-tauri/tauri.conf.json | 2 +-
.../workspace/RehearsalPlayer.test.tsx | 30 +++++++++-
.../features/workspace/RehearsalPlayer.tsx | 57 +++++++++++++++----
3 files changed, 77 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index b43115283..2c80b1590 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -16,7 +16,7 @@
}
],
"security": {
- "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: data: blob:; font-src 'self' data:",
+ "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:",
"assetProtocol": {
"enable": true,
"scope": []
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 591d83597..5c4108d40 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -1,3 +1,5 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -8,6 +10,7 @@ const originalTauriInternals = Object.getOwnPropertyDescriptor(
window,
"__TAURI_INTERNALS__",
);
+const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json");
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
@@ -33,6 +36,23 @@ describe("RehearsalPlayer", () => {
}
});
+ it("allows both platform Tauri asset origins in the media CSP", () => {
+ const config = JSON.parse(readFileSync(tauriConfigPath, "utf8")) as {
+ app: { security: { csp: string } };
+ };
+ const mediaDirective = config.app.security.csp
+ .split(";")
+ .find((directive) => directive.trim().startsWith("media-src "));
+ const sources = mediaDirective?.trim().split(/\s+/).slice(1) ?? [];
+
+ expect(sources).toEqual(
+ expect.arrayContaining(["asset:", "http://asset.localhost"]),
+ );
+ expect(sources).not.toContain("*");
+ expect(sources).not.toContain("http:");
+ expect(sources).not.toContain("https:");
+ });
+
it("names the first playable loop and blocks starting before local audio exists", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -216,9 +236,17 @@ describe("RehearsalPlayer", () => {
Object.defineProperty(audio, "currentTime", {
configurable: true,
writable: true,
- value: 31,
+ value: 29.9,
});
fireEvent(audio, new Event("timeupdate"));
+ Object.defineProperty(audio, "currentTime", {
+ configurable: true,
+ writable: true,
+ value: 30,
+ });
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
expect(audio.currentTime).toBe(10);
});
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index f271dd427..da1b3cf2d 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -284,19 +284,54 @@ export function RehearsalPlayer({
return undefined;
}
const loop = transport.loop;
+ let boundaryTimer: number | undefined;
+ /** Cancel the pending media-clock boundary check. */
+ const clearBoundaryTimer = () => {
+ if (boundaryTimer !== undefined) {
+ window.clearTimeout(boundaryTimer);
+ boundaryTimer = undefined;
+ }
+ };
+ /** Restart media at the exact selected section boundary. */
+ const restartLoop = () => {
+ try {
+ audio.currentTime = loop.startSeconds;
+ const playPromise = audio.play();
+ if (playPromise) {
+ void playPromise.catch(handlePlaybackError);
+ }
+ } catch {
+ handlePlaybackError();
+ return;
+ }
+ scheduleLoopBoundary();
+ };
+ /** Schedule a media-clock boundary check and reschedule if timers fire early. */
+ const scheduleLoopBoundary = () => {
+ clearBoundaryTimer();
+ const remainingSeconds = loop.endSeconds - audio.currentTime;
+ if (!Number.isFinite(remainingSeconds)) {
+ return;
+ }
+ if (remainingSeconds <= 0) {
+ restartLoop();
+ return;
+ }
+ boundaryTimer = window.setTimeout(() => {
+ boundaryTimer = undefined;
+ if (audio.currentTime >= loop.endSeconds) {
+ restartLoop();
+ } else {
+ scheduleLoopBoundary();
+ }
+ }, remainingSeconds * 1000);
+ };
/** Keep the map playhead aligned with the scoped audio element. */
const syncPlayhead = () => {
if (audio.currentTime >= loop.endSeconds) {
- try {
- audio.currentTime = loop.startSeconds;
- const playPromise = audio.play();
- if (playPromise) {
- void playPromise.catch(handlePlaybackError);
- }
- } catch {
- handlePlaybackError();
- return;
- }
+ restartLoop();
+ } else {
+ scheduleLoopBoundary();
}
setTransport((current) =>
reduceRehearsalTransport(current, {
@@ -310,7 +345,9 @@ export function RehearsalPlayer({
audio.addEventListener("timeupdate", syncPlayhead);
audio.addEventListener("error", failPlayback);
audio.addEventListener("ended", failPlayback);
+ scheduleLoopBoundary();
return () => {
+ clearBoundaryTimer();
audio.removeEventListener("timeupdate", syncPlayhead);
audio.removeEventListener("error", failPlayback);
audio.removeEventListener("ended", failPlayback);
From 5187e038bb6ae0918d972a26fe4e3f3b594387eb Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 02:36:37 +0900
Subject: [PATCH 47/70] fix(player): cap long loop boundary timers
---
.../workspace/RehearsalPlayer.test.tsx | 39 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 2 +-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 5c4108d40..79b1a247c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -250,6 +250,45 @@ describe("RehearsalPlayer", () => {
expect(audio.currentTime).toBe(10);
});
+ it("caps long media boundary timers before the browser timeout limit", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`);
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: { convertFileSrc },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.timeRange = {
+ start: 10,
+ end: 10 + 2_147_483_648,
+ };
+ const setTimeoutSpy = vi.spyOn(window, "setTimeout");
+
+ render(
+ ,
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(
+ expect.any(Function),
+ 2_147_483_647,
+ );
+ });
+
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 da1b3cf2d..334bd8c87 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -324,7 +324,7 @@ export function RehearsalPlayer({
} else {
scheduleLoopBoundary();
}
- }, remainingSeconds * 1000);
+ }, Math.min(remainingSeconds * 1000, 2_147_483_647));
};
/** Keep the map playhead aligned with the scoped audio element. */
const syncPlayhead = () => {
From 6a9c576112de977e824f41eb168504e4139ddc0d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 29 Aug 2026 10:47:38 -0700
Subject: [PATCH 48/70] test(player): reproduce unusable audio authority start
---
.../RehearsalPlayer.audioAuthority.test.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
new file mode 100644
index 000000000..0ca048497
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+describe("RehearsalPlayer audio authority", () => {
+ it("refuses to start when local-audio metadata has no playable asset URL", () => {
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /start the count-in/i }),
+ ).toBeDisabled();
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).not.toMatch(/count in 4 beats/i);
+ });
+});
From 2ff261585b2e4cf78100be92785ff91fe373c0b2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 29 Aug 2026 10:49:01 -0700
Subject: [PATCH 49/70] fix(player): require playable media authority
---
.../features/workspace/RehearsalPlayer.tsx | 31 +++++--------------
1 file changed, 8 insertions(+), 23 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 334bd8c87..53bdc3be9 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -34,8 +34,6 @@ interface RehearsalPlayerProps {
startNonce?: number;
}
-const PLAYHEAD_TICK_SECONDS = 0.1;
-
/** Convert a validated native source path into a scoped Tauri asset URL. */
function resolveAudioSourceUrl(
sourcePath: string | null | undefined,
@@ -108,6 +106,7 @@ export function RehearsalPlayer({
() => resolveAudioSourceUrl(audioSourcePath),
[audioSourcePath],
);
+ const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null;
const [playbackError, setPlaybackError] = useState(false);
const handlePlaybackError = useCallback(() => {
@@ -124,6 +123,7 @@ export function RehearsalPlayer({
(loop: RehearsalLoopWindow, resume: boolean) => {
const audio = audioRef.current;
if (!audio || !audioSourceUrl) {
+ handlePlaybackError();
return;
}
try {
@@ -193,7 +193,7 @@ export function RehearsalPlayer({
return;
}
lastHandledStartNonce.current = startNonce;
- if (!hasLocalAudio) {
+ if (!hasPlayableAudio) {
return;
}
const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
@@ -211,13 +211,13 @@ export function RehearsalPlayer({
}, [
startAudio,
startNonce,
- hasLocalAudio,
+ hasPlayableAudio,
playableLoops,
selectedRendererIndex,
]);
useEffect(() => {
- if (hasLocalAudio) {
+ if (hasPlayableAudio) {
return;
}
setTransport((current) => {
@@ -226,7 +226,7 @@ export function RehearsalPlayer({
}
return reduceRehearsalTransport(current, { type: "stop" });
});
- }, [hasLocalAudio]);
+ }, [hasPlayableAudio]);
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
@@ -354,29 +354,14 @@ export function RehearsalPlayer({
};
}, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]);
- useEffect(() => {
- if (audioSourceUrl || transport.phase !== "looping" || !transport.loop) {
- return undefined;
- }
- const timer = window.setInterval(() => {
- setTransport((current) =>
- reduceRehearsalTransport(current, {
- type: "tick",
- deltaSeconds: PLAYHEAD_TICK_SECONDS,
- }),
- );
- }, PLAYHEAD_TICK_SECONDS * 1000);
- return () => window.clearInterval(timer);
- }, [audioSourceUrl, transport.phase, transport.loop]);
-
- const actionKey = nextActionTemplateKey(transport, hasLocalAudio);
+ const actionKey = nextActionTemplateKey(transport, hasPlayableAudio);
const nextAction = fillRehearsalCopy(
t(actionKey as TranslationKey),
nextActionValues(transport),
);
const canStart =
transport.loop !== null &&
- hasLocalAudio &&
+ hasPlayableAudio &&
(transport.phase === "armed" || transport.phase === "paused");
const canPause =
transport.phase === "counting-in" || transport.phase === "looping";
From d53758b61b15dfc5b130abb3011fe8797364eeed Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 02:53:34 +0900
Subject: [PATCH 50/70] test(player): use real media authority in loop tests
---
.../workspace/RehearsalPlayer.test.tsx | 122 ++++++++++++++----
.../src/features/workspace/Workspace.test.tsx | 27 ++++
2 files changed, 127 insertions(+), 22 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 79b1a247c..3ac05a7f6 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -11,6 +11,7 @@ const originalTauriInternals = Object.getOwnPropertyDescriptor(
"__TAURI_INTERNALS__",
);
const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json");
+const audioSourcePath = "/Users/test/Music/rehearsal.wav";
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
@@ -19,6 +20,20 @@ function setNavigatorLanguage(language: string) {
});
}
+function installPlayableAudioMocks() {
+ const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`);
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: { convertFileSrc },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ const play = vi
+ .spyOn(HTMLMediaElement.prototype, "play")
+ .mockResolvedValue(undefined);
+ return { convertFileSrc, play };
+}
+
describe("RehearsalPlayer", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
@@ -96,9 +111,14 @@ describe("RehearsalPlayer", () => {
it("stops active count-in and loop ticking when local-audio authority is revoked", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
const { rerender } = render(
- ,
+ ,
);
fireEvent.click(
@@ -119,7 +139,13 @@ describe("RehearsalPlayer", () => {
screen.getByTestId("rehearsal-loop-next-action").textContent,
).not.toMatch(/looping/i);
- rerender( );
+ rerender(
+ ,
+ );
fireEvent.click(
screen.getByRole("button", { name: /Start the count-in/i }),
);
@@ -133,6 +159,16 @@ describe("RehearsalPlayer", () => {
vi.advanceTimersByTime(500);
});
+ const audio = screen.getByTestId(
+ "rehearsal-loop-audio",
+ ) as HTMLAudioElement;
+ Object.defineProperty(audio, "currentTime", {
+ configurable: true,
+ writable: true,
+ value: 15,
+ });
+ fireEvent(audio, new Event("timeupdate"));
+
const playheadBeforeRevocation = screen
.getByTestId("rehearsal-loop-playhead")
.getAttribute("style");
@@ -158,8 +194,15 @@ describe("RehearsalPlayer", () => {
it("counts in then loops the selected section on the map clock", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
- render( );
+ render(
+ ,
+ );
fireEvent.click(
screen.getByRole("button", { name: /Start the count-in/i }),
@@ -186,23 +229,14 @@ describe("RehearsalPlayer", () => {
it("uses the scoped native asset as the media clock for a real loop", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
- const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`);
- Object.defineProperty(window, "__TAURI_INTERNALS__", {
- configurable: true,
- value: { convertFileSrc },
- });
- vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
- vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
- const play = vi
- .spyOn(HTMLMediaElement.prototype, "play")
- .mockResolvedValue(undefined);
+ const { convertFileSrc, play } = installPlayableAudioMocks();
const song = createDemoRehearsalSong();
render(
,
);
@@ -272,7 +306,7 @@ describe("RehearsalPlayer", () => {
,
);
fireEvent.click(
@@ -292,9 +326,14 @@ describe("RehearsalPlayer", () => {
it("keeps a live loop running across unrelated song metadata updates", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
const { rerender } = render(
- ,
+ ,
);
fireEvent.click(
@@ -321,7 +360,13 @@ describe("RehearsalPlayer", () => {
),
};
- rerender( );
+ rerender(
+ ,
+ );
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
@@ -331,8 +376,15 @@ describe("RehearsalPlayer", () => {
it("disables start while count-in or loop timing is already active", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
- render( );
+ render(
+ ,
+ );
const startButton = screen.getByRole("button", {
name: /Start the count-in/i,
@@ -354,9 +406,15 @@ describe("RehearsalPlayer", () => {
it("restarts a paused loop from an external section-start request", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
const { rerender } = render(
- ,
+ ,
);
fireEvent.click(
@@ -371,7 +429,12 @@ describe("RehearsalPlayer", () => {
).toMatch(/paused/i);
rerender(
- ,
+ ,
);
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
@@ -380,6 +443,7 @@ describe("RehearsalPlayer", () => {
it("does not restart the count-in when section selection changes under the same start nonce", () => {
setNavigatorLanguage("en-US");
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
song.sections = [
{
@@ -396,7 +460,14 @@ describe("RehearsalPlayer", () => {
},
];
- render( );
+ render(
+ ,
+ );
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
).toMatch(/Count in 4 beats/i);
@@ -415,6 +486,7 @@ describe("RehearsalPlayer", () => {
it("keeps duplicate analysis section ids selectable by renderer position", () => {
setNavigatorLanguage("en-US");
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
song.sections = [
{
@@ -431,7 +503,13 @@ describe("RehearsalPlayer", () => {
},
];
- render( );
+ render(
+ ,
+ );
const verseButton = screen.getByRole("button", {
name: /verse.*0:10.*0:20/i,
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index f65cd7ae2..1c689d90f 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -12,6 +12,10 @@ import { generateMetadataHandoffJson } from "../../lib/export";
const originalLanguage = navigator.language;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
+const originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
@@ -36,6 +40,18 @@ function createLocalSourceBootstrap(): ProjectBootstrapSummary {
};
}
+function installPlayableAudioMocks() {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: (path: string) => `asset://localhost/${path}`,
+ },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+}
+
describe("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
@@ -48,6 +64,16 @@ describe("Workspace", () => {
configurable: true,
value: originalRevokeObjectUrl,
});
+ if (originalTauriInternals) {
+ Object.defineProperty(
+ window,
+ "__TAURI_INTERNALS__",
+ originalTauriInternals,
+ );
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
});
it("updates practice progress immutably through onSongUpdate", () => {
@@ -147,6 +173,7 @@ describe("Workspace", () => {
it("starts the selected section loop from the role action when local audio is available", () => {
setNavigatorLanguage("en-US");
+ installPlayableAudioMocks();
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
From 8ab7e7db3e31393d736db7ea0a8dcd35431ef628 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 03:03:22 +0900
Subject: [PATCH 51/70] feat(player): add bounded rehearsal playback rates
---
.../workspace/RehearsalPlayer.test.tsx | 60 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 52 ++++++++++++++++
.../workspace/rehearsalTransport.test.ts | 26 ++++++++
.../features/workspace/rehearsalTransport.ts | 33 ++++++++++
apps/desktop/src/locales/en/common.json | 2 +
apps/desktop/src/locales/ko/common.json | 2 +
6 files changed, 175 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 3ac05a7f6..22bab447b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -10,6 +10,10 @@ const originalTauriInternals = Object.getOwnPropertyDescriptor(
window,
"__TAURI_INTERNALS__",
);
+const originalPreservesPitch = Object.getOwnPropertyDescriptor(
+ HTMLMediaElement.prototype,
+ "preservesPitch",
+);
const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json");
const audioSourcePath = "/Users/test/Music/rehearsal.wav";
@@ -28,6 +32,11 @@ function installPlayableAudioMocks() {
});
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ Object.defineProperty(HTMLMediaElement.prototype, "preservesPitch", {
+ configurable: true,
+ writable: true,
+ value: false,
+ });
const play = vi
.spyOn(HTMLMediaElement.prototype, "play")
.mockResolvedValue(undefined);
@@ -49,6 +58,17 @@ describe("RehearsalPlayer", () => {
delete (window as Window & { __TAURI_INTERNALS__?: unknown })
.__TAURI_INTERNALS__;
}
+ if (originalPreservesPitch) {
+ Object.defineProperty(
+ HTMLMediaElement.prototype,
+ "preservesPitch",
+ originalPreservesPitch,
+ );
+ } else {
+ delete (HTMLMediaElement.prototype as HTMLMediaElement & {
+ preservesPitch?: boolean;
+ }).preservesPitch;
+ }
});
it("allows both platform Tauri asset origins in the media CSP", () => {
@@ -323,6 +343,46 @@ describe("RehearsalPlayer", () => {
);
});
+ it("applies supported playback speed while preserving pitch when available", () => {
+ setNavigatorLanguage("en-US");
+ const { play } = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+
+ const { rerender } = render(
+ ,
+ );
+
+ const audio = screen.getByTestId(
+ "rehearsal-loop-audio",
+ ) as HTMLAudioElement;
+ const rateSelect = screen.getByRole("combobox", {
+ name: /Playback speed/i,
+ }) as HTMLSelectElement;
+ expect(rateSelect.value).toBe("1");
+
+ fireEvent.change(rateSelect, { target: { value: "0.75" } });
+
+ expect(audio.playbackRate).toBe(0.75);
+ expect(audio.preservesPitch).toBe(true);
+ expect(
+ screen.getByText(/Pitch stays natural when the audio engine supports it/i),
+ ).toBeTruthy();
+ expect(play).not.toHaveBeenCalled();
+
+ rerender(
+ ,
+ );
+ expect(audio.playbackRate).toBe(0.75);
+ });
+
it("keeps a live loop running across unrelated song metadata updates", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 53bdc3be9..2e597a140 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -21,6 +21,8 @@ import {
formatRehearsalClock,
nextActionTemplateKey,
nextActionValues,
+ isRehearsalPlaybackRate,
+ rehearsalPlaybackRates,
reduceRehearsalTransport,
resolveLoopWindows,
type RehearsalLoopWindow,
@@ -188,6 +190,21 @@ export function RehearsalPlayer({
});
}, [playableLoops, selectedRendererIndex]);
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) {
+ return;
+ }
+ try {
+ audio.playbackRate = transport.playbackRate;
+ if ("preservesPitch" in audio) {
+ audio.preservesPitch = true;
+ }
+ } catch {
+ handlePlaybackError();
+ }
+ }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]);
+
useEffect(() => {
if (startNonce <= lastHandledStartNonce.current) {
return;
@@ -420,6 +437,41 @@ export function RehearsalPlayer({
})}
) : null}
+
+
+ {t("workspaceLoopPlaybackRateLabel")}
+ {
+ const nextRate = Number(event.currentTarget.value);
+ if (isRehearsalPlaybackRate(nextRate)) {
+ setTransport((current) =>
+ reduceRehearsalTransport(current, {
+ type: "set-playback-rate",
+ rate: nextRate,
+ }),
+ );
+ }
+ }}
+ >
+ {rehearsalPlaybackRates().map((rate) => (
+
+ {rate}×
+
+ ))}
+
+
+
+ {t("workspaceLoopPlaybackRateHint")}
+
+
{
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..e8a465c98 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 =
@@ -30,6 +42,7 @@ export interface RehearsalTransportState {
loop: RehearsalLoopWindow | null;
countInRemainingBeats: number;
playheadSeconds: number;
+ playbackRate: RehearsalPlaybackRate;
}
/** Discrete transport commands that never inspect the filesystem. */
@@ -39,6 +52,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 +68,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 {
@@ -254,6 +278,7 @@ export function createIdleTransportState(): RehearsalTransportState {
loop: null,
countInRemainingBeats: 0,
playheadSeconds: 0,
+ playbackRate: DEFAULT_REHEARSAL_PLAYBACK_RATE,
};
}
@@ -286,6 +311,7 @@ export function reduceRehearsalTransport(
loop: event.loop,
countInRemainingBeats: event.loop.countInBeats,
playheadSeconds: event.loop.startSeconds,
+ playbackRate: state.playbackRate,
};
}
case "start": {
@@ -348,6 +374,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 +395,7 @@ export function reduceRehearsalTransport(
loop: state.loop,
countInRemainingBeats: state.loop.countInBeats,
playheadSeconds: state.loop.startSeconds,
+ playbackRate: state.playbackRate,
};
}
default:
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index c520ef61a..2c0308977 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -158,6 +158,8 @@
"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..6f78ff5b5 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -158,6 +158,8 @@
"workspaceLoopPause": "합주 시계 일시정지",
"workspaceLoopStop": "합주 시계 멈추고 초기화",
"workspaceLoopAudioError": "이 로컬 오디오를 재생할 수 없습니다. 곡을 다시 선택한 뒤 재시도하세요.",
+ "workspaceLoopPlaybackRateLabel": "재생 속도",
+ "workspaceLoopPlaybackRateHint": "오디오 엔진이 지원하면 음정은 자연스럽게 유지됩니다.",
"workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.",
"workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.",
"workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.",
From 949154375304ebef41e03d2e8bfde3ae86a40714 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 03:09:36 +0900
Subject: [PATCH 52/70] fix(player): share playable audio authority
---
.../RehearsalPlayer.audioAuthority.test.tsx | 44 ++++++++++++++++++-
.../features/workspace/RehearsalPlayer.tsx | 17 ++++++-
.../src/features/workspace/Workspace.test.tsx | 31 +++++++++++++
.../src/features/workspace/Workspace.tsx | 11 +++--
4 files changed, 96 insertions(+), 7 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
index 0ca048497..8ebd45a63 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
@@ -1,9 +1,24 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { RehearsalPlayer } from "./RehearsalPlayer";
+const originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
+
describe("RehearsalPlayer audio authority", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (originalTauriInternals) {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
+ });
+
it("refuses to start when local-audio metadata has no playable asset URL", () => {
const song = createDemoRehearsalSong();
@@ -23,4 +38,31 @@ describe("RehearsalPlayer audio authority", () => {
screen.getByTestId("rehearsal-loop-next-action").textContent,
).not.toMatch(/count in 4 beats/i);
});
+
+ it("reports native asset conversion failures instead of presenting missing-audio copy", () => {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: () => {
+ throw new Error("asset conversion failed");
+ },
+ },
+ });
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /start the count-in/i }),
+ ).toBeDisabled();
+ expect(screen.getByRole("alert").textContent).toMatch(
+ /could not play this local audio/i,
+ );
+ });
});
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 53bdc3be9..45edaa778 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -48,6 +48,13 @@ function resolveAudioSourceUrl(
}
}
+/** Return whether a source path can be converted into a playable native asset URL. */
+export function isPlayableAudioSource(
+ sourcePath: string | null | undefined,
+): boolean {
+ return resolveAudioSourceUrl(sourcePath) !== null;
+}
+
/** Return the displayed map-clock progress for the current loop. */
function loopProgressPercent(state: RehearsalTransportState): number {
if (!state.loop) {
@@ -107,6 +114,12 @@ export function RehearsalPlayer({
[audioSourcePath],
);
const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null;
+ const hasNativeAudioConversionError = Boolean(
+ hasLocalAudio &&
+ audioSourcePath &&
+ !audioSourcePath.startsWith("browser://") &&
+ audioSourceUrl === null,
+ );
const [playbackError, setPlaybackError] = useState(false);
const handlePlaybackError = useCallback(() => {
@@ -160,13 +173,13 @@ export function RehearsalPlayer({
} else {
audio.removeAttribute("src");
}
- setPlaybackError(false);
+ setPlaybackError(hasNativeAudioConversionError);
return () => {
if (!audio.paused) {
audio.pause();
}
};
- }, [audioSourceUrl]);
+ }, [audioSourceUrl, hasNativeAudioConversionError]);
useEffect(() => {
const nextLoop = playableLoops[selectedRendererIndex] ?? null;
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 1c689d90f..b36a13bc2 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -171,6 +171,37 @@ describe("Workspace", () => {
).toMatch(/Choose a local song first/i);
});
+ it("keeps the role loop action unavailable for browser-only audio authority", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ };
+ const browserSourceBootstrap = {
+ ...createLocalSourceBootstrap(),
+ source: {
+ ...createLocalSourceBootstrap().source,
+ sourcePath: "browser://selected-audio",
+ },
+ } satisfies ProjectBootstrapSummary;
+
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const loopButton = screen.getByRole("button", {
+ name: "Start selected section loop",
+ });
+ expect(loopButton.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(loopButton);
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).not.toMatch(/count in 4 beats/i);
+ });
+
it("starts the selected section loop from the role action when local audio is available", () => {
setNavigatorLanguage("en-US");
installPlayableAudioMocks();
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 94de28ffc..80612ad8c 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,7 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
-import { RehearsalPlayer } from "./RehearsalPlayer";
+import { isPlayableAudioSource, RehearsalPlayer } from "./RehearsalPlayer";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -129,6 +129,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
[sourceBootstrap],
);
const hasLocalAudio = parsedSourceBootstrap !== null;
+ const hasPlayableAudio =
+ hasLocalAudio &&
+ isPlayableAudioSource(parsedSourceBootstrap?.source.sourcePath ?? null);
// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
@@ -401,10 +404,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{
- if (!hasLocalAudio) {
+ if (!hasPlayableAudio) {
preventUnavailableAction(event);
return;
}
@@ -412,7 +415,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}}
variant="outline"
className={
- hasLocalAudio
+ hasPlayableAudio
? "min-h-11 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
: "min-h-11 cursor-not-allowed border-white/10 bg-white/5 font-semibold text-slate-400 opacity-70"
}
From e517a9875d9eae13113c56d7b2b433ed9a3a7888 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 03:16:11 +0900
Subject: [PATCH 53/70] fix(player): align loop timing with playback rate
---
.../workspace/RehearsalPlayer.test.tsx | 77 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 20 ++++-
2 files changed, 93 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 22bab447b..80ac13db2 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -343,6 +343,83 @@ 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 }),
+ );
+ 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("applies supported playback speed while preserving pitch when available", () => {
setNavigatorLanguage("en-US");
const { play } = installPlayableAudioMocks();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index b95de46c7..cb438d19c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -266,9 +266,9 @@ export function RehearsalPlayer({
setTransport((current) =>
reduceRehearsalTransport(current, { type: "beat" }),
);
- }, beatDurationMs(transport.loop.tempoBpm));
+ }, beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate);
return () => window.clearInterval(timer);
- }, [transport.phase, transport.loop]);
+ }, [transport.phase, transport.loop, transport.playbackRate]);
useEffect(() => {
if (!audioSourceUrl || !transport.loop) {
@@ -314,6 +314,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 = () => {
@@ -354,7 +355,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 = () => {
@@ -382,7 +388,13 @@ 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(
From 39927fbbaf0c4f7fb5d84d5ba6086f6c5f748064 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 04:52:43 +0900
Subject: [PATCH 54/70] feat(player): filter loops by rehearsal role
---
CHANGELOG.md | 4 +-
.../workspace/RehearsalPlayer.test.tsx | 47 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 39 ++++++++++++---
.../src/features/workspace/Workspace.test.tsx | 22 ++++++++-
.../src/features/workspace/Workspace.tsx | 2 +
.../workspace/rehearsalTransport.test.ts | 22 +++++++++
.../features/workspace/rehearsalTransport.ts | 20 ++++++++
apps/desktop/src/locales/en/common.json | 3 ++
apps/desktop/src/locales/ko/common.json | 3 ++
9 files changed, 154 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de75b99cf..64e4c894a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,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 +77,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 80ac13db2..202da786f 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -128,6 +128,53 @@ 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("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();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index cb438d19c..db8453ce5 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -33,6 +33,8 @@ interface RehearsalPlayerProps {
song: RehearsalSong;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
+ activeRole?: string | null;
+ activeRoleName?: string | null;
startNonce?: number;
}
@@ -94,10 +96,15 @@ export function RehearsalPlayer({
song,
hasLocalAudio = false,
audioSourcePath = null,
+ activeRole = null,
+ activeRoleName = null,
startNonce = 0,
}: RehearsalPlayerProps): ReactElement {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const playableLoops = useMemo(() => resolveLoopWindows(song), [song]);
+ const playableLoops = useMemo(
+ () => resolveLoopWindows(song, activeRole),
+ [activeRole, song],
+ );
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
const selectedRendererIndex = playableLoops[selectedSectionIndex]
? selectedSectionIndex
@@ -397,10 +404,20 @@ export function RehearsalPlayer({
]);
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 &&
@@ -429,11 +446,21 @@ export function RehearsalPlayer({
>
{nextAction}
+ {activeRoleName && playableLoops.length > 0 ? (
+
+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), {
+ roleName: activeRoleName,
+ })}
+
+ ) : null}
{playableLoops.length > 0 ? (
{playableLoops.map((loop, index) => {
const selected = index === selectedRendererIndex;
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index b36a13bc2..1aebd0116 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
import {
createDemoRehearsalSong,
type ProjectBootstrapSummary,
@@ -231,6 +231,26 @@ describe("Workspace", () => {
).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("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..b4f5b6617 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -369,6 +369,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
song={song}
hasLocalAudio={hasLocalAudio}
audioSourcePath={parsedSourceBootstrap?.source.sourcePath ?? null}
+ activeRole={activeRole}
+ activeRoleName={activeRole ? activeRoleDetails?.name ?? activeRole : null}
startNonce={loopStartNonce}
/>
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
index c07c41fc5..0310d4e0c 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts
@@ -13,6 +13,7 @@ import {
rehearsalPlaybackRates,
reduceRehearsalTransport,
resolveLoopWindow,
+ resolveLoopWindows,
resolveRehearsalTempo,
wrapPlayhead,
} from "./rehearsalTransport";
@@ -59,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;
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index e8a465c98..5cc0e9899 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -166,6 +166,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,
@@ -236,6 +250,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 [];
@@ -245,10 +260,15 @@ export function resolveLoopWindows(
return [];
}
const tempo = ownDataValue(song, "tempo");
+ const selectedRoleId =
+ typeof roleId === "string" && roleId.trim() ? roleId : null;
return sections.flatMap((section) => {
if (!section || typeof section !== "object") {
return [];
}
+ if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) {
+ return [];
+ }
const window = createLoopWindow(section as RehearsalSection, tempo);
return window ? [window] : [];
});
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 2c0308977..e51e2a962 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -152,6 +152,9 @@
"workspaceLoopRegionLabel": "Tonight's section loop",
"workspaceLoopTitle": "Tonight's loop",
"workspaceLoopSectionPickerLabel": "Playable sections",
+ "workspaceLoopSectionPickerForRole": "Playable sections for {roleName}",
+ "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",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 6f78ff5b5..b489229c3 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -152,6 +152,9 @@
"workspaceLoopRegionLabel": "오늘 밤 구간 루프",
"workspaceLoopTitle": "오늘 밤 루프",
"workspaceLoopSectionPickerLabel": "연습할 구간",
+ "workspaceLoopSectionPickerForRole": "{roleName} 역할의 연습 구간",
+ "workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.",
+ "workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.",
"workspaceLoopStart": "카운트인 시작",
"workspaceLoopThisSection": "선택한 구간 루프 시작",
"workspaceLoopResume": "합주 시계 계속",
From 43698a61377f9c2f7d01e65783089caceed8c1b5 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 05:06:57 +0900
Subject: [PATCH 55/70] fix(player): preserve role loop selection
---
.../workspace/RehearsalPlayer.test.tsx | 38 ++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 45 +++++++++++--------
.../src/features/workspace/Workspace.test.tsx | 26 +++++++++++
.../src/features/workspace/Workspace.tsx | 42 ++++++++++-------
4 files changed, 116 insertions(+), 35 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 202da786f..453cc3ecf 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -156,6 +156,44 @@ describe("RehearsalPlayer", () => {
);
});
+ 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("explains when the active role has no playable sections", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index db8453ce5..d344b002b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -91,6 +91,11 @@ function hasSameLoopTiming(
);
}
+/** Return a stable selection key when analysis emits duplicate section IDs. */
+function loopSelectionKey(loop: RehearsalLoopWindow): string {
+ return `${loop.sectionId}:${loop.startSeconds}:${loop.endSeconds}`;
+}
+
/** Render tonight's first section loop with a count-in and a named next action. */
export function RehearsalPlayer({
song,
@@ -105,10 +110,11 @@ export function RehearsalPlayer({
() => resolveLoopWindows(song, activeRole),
[activeRole, song],
);
- const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
- const selectedRendererIndex = playableLoops[selectedSectionIndex]
- ? selectedSectionIndex
- : 0;
+ const [selectedLoopKey, setSelectedLoopKey] = useState(null);
+ const selectedLoop =
+ playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
+ playableLoops[0] ??
+ null;
const [transport, setTransport] = useState(() =>
reduceRehearsalTransport(createIdleTransportState(), {
type: "arm",
@@ -191,24 +197,26 @@ 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
) {
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;
@@ -233,7 +241,6 @@ export function RehearsalPlayer({
if (!hasPlayableAudio) {
return;
}
- const selectedLoop = playableLoops[selectedRendererIndex] ?? null;
if (selectedLoop) {
setPlaybackError(false);
startAudio(selectedLoop, false);
@@ -249,8 +256,7 @@ export function RehearsalPlayer({
startAudio,
startNonce,
hasPlayableAudio,
- playableLoops,
- selectedRendererIndex,
+ selectedLoop,
]);
useEffect(() => {
@@ -463,10 +469,13 @@ export function RehearsalPlayer({
aria-label={sectionPickerLabel}
>
{playableLoops.map((loop, index) => {
- const selected = index === selectedRendererIndex;
+ const selectionKey = loopSelectionKey(loop);
+ const selected =
+ selectedLoop !== null &&
+ selectionKey === loopSelectionKey(selectedLoop);
return (
setSelectedSectionIndex(index)}
+ onClick={() => setSelectedLoopKey(selectionKey)}
>
{loop.sectionLabel}
·
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 1aebd0116..6bb8cb566 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -251,6 +251,32 @@ describe("Workspace", () => {
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 b4f5b6617..54842b46b 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) ??
@@ -369,8 +377,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
song={song}
hasLocalAudio={hasLocalAudio}
audioSourcePath={parsedSourceBootstrap?.source.sourcePath ?? null}
- activeRole={activeRole}
- activeRoleName={activeRole ? activeRoleDetails?.name ?? activeRole : null}
+ activeRole={resolvedActiveRole}
+ activeRoleName={resolvedActiveRole ? activeRoleDetails?.name ?? resolvedActiveRole : null}
startNonce={loopStartNonce}
/>
@@ -382,15 +390,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {activeRole && (
+ {resolvedActiveRole && (
Stem Player
-
{activeRoleDetails?.name ?? activeRole}
+
{activeRoleDetails?.name ?? resolvedActiveRole}
From 2490ce247011e4dc00646a4c9adab703fffc240c Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 05:35:32 +0900
Subject: [PATCH 56/70] feat(player): add keyboard section cue navigation
---
CHANGELOG.md | 1 +
.../workspace/RehearsalPlayer.test.tsx | 28 +++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 40 +++++++++++++++++++
apps/desktop/src/locales/en/common.json | 1 +
apps/desktop/src/locales/ko/common.json | 1 +
5 files changed, 71 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64e4c894a..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
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 453cc3ecf..a50d58ee1 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -156,6 +156,34 @@ describe("RehearsalPlayer", () => {
);
});
+ 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.");
+
+ 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("keeps the selected loop by section ID when an earlier section is filtered out", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index d344b002b..62d8a436a 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -4,6 +4,7 @@ import {
useMemo,
useRef,
useState,
+ type KeyboardEvent,
type ReactElement,
} from "react";
import type { RehearsalSong } from "@bandscope/shared-types";
@@ -115,6 +116,34 @@ export function RehearsalPlayer({
playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
playableLoops[0] ??
null;
+ const handleSectionKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
+ return;
+ }
+ const selectedIndex = selectedLoop
+ ? playableLoops.indexOf(selectedLoop)
+ : -1;
+ const nextIndex =
+ selectedIndex + (event.key === "ArrowRight" ? 1 : -1);
+ if (
+ selectedIndex < 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",
@@ -479,13 +508,16 @@ export function RehearsalPlayer({
type="button"
variant={selected ? "default" : "outline"}
size="sm"
+ id={`rehearsal-loop-section-${selectionKey}-${index}`}
aria-pressed={selected}
+ aria-keyshortcuts="ArrowLeft ArrowRight"
className={
selected
? "min-h-10 border-cyan-300/30 bg-cyan-300 font-semibold text-slate-950"
: "min-h-10 border-white/10 bg-white/5 font-semibold text-slate-100"
}
onClick={() => setSelectedLoopKey(selectionKey)}
+ onKeyDown={handleSectionKeyDown}
>
{loop.sectionLabel}
·
@@ -498,6 +530,14 @@ export function RehearsalPlayer({
})}
) : null}
+ {playableLoops.length > 1 ? (
+
+ {t("workspaceLoopSectionKeyboardHint")}
+
+ ) : null}
{t("workspaceLoopPlaybackRateLabel")}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index e51e2a962..704b4f00a 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,7 @@
"workspaceLoopTitle": "Tonight's loop",
"workspaceLoopSectionPickerLabel": "Playable sections",
"workspaceLoopSectionPickerForRole": "Playable sections for {roleName}",
+ "workspaceLoopSectionKeyboardHint": "Use Left and Right Arrow to move between section cues.",
"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",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index b489229c3..10ca9e4b4 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,7 @@
"workspaceLoopTitle": "오늘 밤 루프",
"workspaceLoopSectionPickerLabel": "연습할 구간",
"workspaceLoopSectionPickerForRole": "{roleName} 역할의 연습 구간",
+ "workspaceLoopSectionKeyboardHint": "왼쪽·오른쪽 화살표로 구간 큐를 이동하세요.",
"workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.",
"workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.",
"workspaceLoopStart": "카운트인 시작",
From 04396d9b4ebfd50ad598ffaf1edb33df3de70840 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 05:41:10 +0900
Subject: [PATCH 57/70] fix(player): follow focused cue during arrow navigation
---
.../src/features/workspace/RehearsalPlayer.test.tsx | 4 ++++
.../src/features/workspace/RehearsalPlayer.tsx | 12 ++++++++++--
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index a50d58ee1..7f823ef47 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -173,6 +173,10 @@ describe("RehearsalPlayer", () => {
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();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 62d8a436a..42398104f 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -121,13 +121,20 @@ export function RehearsalPlayer({
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 =
- selectedIndex + (event.key === "ArrowRight" ? 1 : -1);
+ currentIndex + (event.key === "ArrowRight" ? 1 : -1);
if (
- selectedIndex < 0 ||
+ currentIndex < 0 ||
nextIndex < 0 ||
nextIndex >= playableLoops.length
) {
@@ -509,6 +516,7 @@ export function RehearsalPlayer({
variant={selected ? "default" : "outline"}
size="sm"
id={`rehearsal-loop-section-${selectionKey}-${index}`}
+ data-loop-index={index}
aria-pressed={selected}
aria-keyshortcuts="ArrowLeft ArrowRight"
className={
From 9c61a803a7cc951d31015fb22ca275d64f1830f7 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 06:02:55 +0900
Subject: [PATCH 58/70] feat(player): allow manual cue boundary corrections
---
.../workspace/RehearsalPlayer.test.tsx | 43 ++++++
.../features/workspace/RehearsalPlayer.tsx | 134 +++++++++++++++++-
.../src/features/workspace/Workspace.tsx | 1 +
apps/desktop/src/locales/en/common.json | 6 +
apps/desktop/src/locales/ko/common.json | 6 +
5 files changed, 189 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 7f823ef47..12271ec7a 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -188,6 +188,49 @@ describe("RehearsalPlayer", () => {
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 the selected loop by section ID when an earlier section is filtered out", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 42398104f..2af0478c2 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -4,10 +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 {
@@ -32,6 +36,7 @@ import {
interface RehearsalPlayerProps {
song: RehearsalSong;
+ onSongUpdate?: (song: RehearsalSong) => void;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
activeRole?: string | null;
@@ -100,6 +105,7 @@ function loopSelectionKey(loop: RehearsalLoopWindow): string {
/** 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,
@@ -112,10 +118,14 @@ export function RehearsalPlayer({
[activeRole, song],
);
const [selectedLoopKey, setSelectedLoopKey] = useState(null);
+ const [boundaryError, setBoundaryError] = useState(false);
const selectedLoop =
playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
playableLoops[0] ??
null;
+ useEffect(() => {
+ setBoundaryError(false);
+ }, [selectedLoopKey]);
const handleSectionKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
@@ -471,6 +481,72 @@ 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) {
+ event.currentTarget.value = String(
+ boundary === "start"
+ ? selectedLoop.startSeconds
+ : selectedLoop.endSeconds,
+ );
+ setBoundaryError(true);
+ return;
+ }
+
+ setBoundaryError(false);
+ const currentValue =
+ boundary === "start"
+ ? selectedLoop.startSeconds
+ : selectedLoop.endSeconds;
+ if (value === currentValue) {
+ return;
+ }
+
+ const sectionIndex = song.sections.findIndex(
+ (section) =>
+ section.id === selectedLoop.sectionId &&
+ section.timeRange.start === selectedLoop.startSeconds &&
+ section.timeRange.end === selectedLoop.endSeconds,
+ );
+ if (sectionIndex < 0) {
+ return;
+ }
+ const nextSong = {
+ ...song,
+ sections: song.sections.map((section, index) =>
+ index === sectionIndex
+ ? {
+ ...section,
+ timeRange: {
+ ...section.timeRange,
+ [boundary]: value,
+ },
+ }
+ : section,
+ ),
+ };
+ const nextLoop =
+ boundary === "start"
+ ? { ...selectedLoop, startSeconds: value }
+ : { ...selectedLoop, endSeconds: value };
+ setSelectedLoopKey(loopSelectionKey(nextLoop));
+ onSongUpdate(nextSong);
+ },
+ [onSongUpdate, selectedLoop, song],
+ );
return (
) : null}
+ {selectedLoop && onSongUpdate ? (
+
+
+
+ {t("workspaceLoopBoundaryTitle")}
+
+
+ {t("workspaceLoopBoundaryCorrectionBadge")}
+
+
+
+ {boundaryError
+ ? t("workspaceLoopBoundaryError")
+ : t("workspaceLoopBoundaryHint")}
+
+
+
+ {t("workspaceLoopBoundaryStartLabel")}
+ handleBoundaryBlur("start", event)}
+ step={1}
+ type="number"
+ />
+
+
+ {t("workspaceLoopBoundaryEndLabel")}
+ handleBoundaryBlur("end", event)}
+ step={1}
+ type="number"
+ />
+
+
+
+ ) : null}
{t("workspaceLoopPlaybackRateLabel")}
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 54842b46b..ce269580a 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -375,6 +375,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
Date: Sun, 30 Aug 2026 06:11:35 +0900
Subject: [PATCH 59/70] feat(player): add bounded cue seeking
---
.../workspace/RehearsalPlayer.test.tsx | 26 +++++++++
.../features/workspace/RehearsalPlayer.tsx | 54 +++++++++++++++++++
.../workspace/rehearsalTransport.test.ts | 38 +++++++++++++
.../features/workspace/rehearsalTransport.ts | 24 +++++++++
apps/desktop/src/locales/en/common.json | 2 +
apps/desktop/src/locales/ko/common.json | 2 +
6 files changed, 146 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 12271ec7a..0198e06a8 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -464,6 +464,32 @@ describe("RehearsalPlayer", () => {
expect(audio.currentTime).toBe(10);
});
+ it("seeks the scoped media clock within a live loop", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement;
+ fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i }));
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ const seek = screen.getByRole("slider", { name: "Seek within this cue" });
+ fireEvent.change(seek, { target: { value: "18.5" } });
+
+ expect(audio.currentTime).toBe(18.5);
+ expect(seek).toHaveValue("18.5");
+ });
+
it("caps long media boundary timers before the browser timeout limit", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 2af0478c2..994fa59d2 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -4,6 +4,7 @@ import {
useMemo,
useRef,
useState,
+ type ChangeEvent,
type FocusEvent,
type KeyboardEvent,
type ReactElement,
@@ -547,6 +548,34 @@ export function RehearsalPlayer({
},
[onSongUpdate, selectedLoop, song],
);
+ const canSeek =
+ transport.loop !== null &&
+ hasPlayableAudio &&
+ (transport.phase === "looping" ||
+ (transport.phase === "paused" && transport.countInRemainingBeats === 0));
+ const handleSeek = useCallback(
+ (event: ChangeEvent) => {
+ if (!canSeek || !transport.loop || !audioSourceUrl) {
+ return;
+ }
+ const nextTransport = reduceRehearsalTransport(transport, {
+ type: "seek",
+ playheadSeconds: Number(event.currentTarget.value),
+ });
+ try {
+ const audio = audioRef.current;
+ if (!audio) {
+ return;
+ }
+ audio.currentTime = nextTransport.playheadSeconds;
+ setPlaybackError(false);
+ setTransport(nextTransport);
+ } catch {
+ handlePlaybackError();
+ }
+ },
+ [audioSourceUrl, canSeek, handlePlaybackError, transport],
+ );
return (
+
+
+ {t("workspaceLoopSeekLabel")}
+
+
+
+ {t("workspaceLoopSeekHint")}
+
+
{
).toBe(loop!.startSeconds);
});
+ it("clamps seek to the loop and rejects it during count-in", () => {
+ const loop = resolveLoopWindow(createDemoRehearsalSong());
+ const armed = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ });
+ expect(
+ reduceRehearsalTransport(armed, {
+ type: "seek",
+ playheadSeconds: 15,
+ }),
+ ).toBe(armed);
+
+ const looping = {
+ ...armed,
+ phase: "looping" as const,
+ countInRemainingBeats: 0,
+ };
+ expect(
+ reduceRehearsalTransport(looping, {
+ type: "seek",
+ playheadSeconds: -1,
+ }).playheadSeconds,
+ ).toBe(loop!.startSeconds);
+ expect(
+ reduceRehearsalTransport(looping, {
+ type: "seek",
+ playheadSeconds: loop!.endSeconds + 1,
+ }).playheadSeconds,
+ ).toBe(loop!.startSeconds);
+ expect(
+ reduceRehearsalTransport(looping, {
+ type: "seek",
+ playheadSeconds: 17.5,
+ }).playheadSeconds,
+ ).toBe(17.5);
+ });
+
it("resumes the remaining count-in beats after pausing during count-in", () => {
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 5cc0e9899..c5fcd33de 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -51,6 +51,7 @@ export type RehearsalTransportEvent =
| { type: "start" }
| { type: "beat" }
| { type: "sync"; playheadSeconds: number }
+ | { type: "seek"; playheadSeconds: number }
| { type: "tick"; deltaSeconds: number }
| { type: "set-playback-rate"; rate: RehearsalPlaybackRate }
| { type: "pause" }
@@ -379,6 +380,29 @@ export function reduceRehearsalTransport(
playheadSeconds: wrapPlayhead(playhead, state.loop),
};
}
+ case "seek": {
+ if (
+ !state.loop ||
+ (state.phase !== "looping" &&
+ !(state.phase === "paused" && state.countInRemainingBeats === 0))
+ ) {
+ return state;
+ }
+ const requested = Number.isFinite(event.playheadSeconds)
+ ? event.playheadSeconds
+ : state.loop.startSeconds;
+ const clamped = Math.min(
+ state.loop.endSeconds,
+ Math.max(state.loop.startSeconds, requested),
+ );
+ return {
+ ...state,
+ playheadSeconds:
+ clamped >= state.loop.endSeconds
+ ? state.loop.startSeconds
+ : clamped,
+ };
+ }
case "tick": {
if (state.phase !== "looping" || !state.loop) {
return state;
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 133a6ce7f..9363b2ced 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -160,6 +160,8 @@
"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.",
+ "workspaceLoopSeekLabel": "Seek within this cue",
+ "workspaceLoopSeekHint": "Seek is available while the local rehearsal clock is running or paused.",
"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",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index e4151ebbc..99d5c0e2d 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -160,6 +160,8 @@
"workspaceLoopBoundaryEndLabel": "끝 시각(초)",
"workspaceLoopBoundaryHint": "정수 초로 곡 지도를 보정하며 다음 프로젝트 저장에 포함됩니다.",
"workspaceLoopBoundaryError": "0부터 제한값까지의 정수 초를 입력하고 끝을 시작보다 뒤로 두세요.",
+ "workspaceLoopSeekLabel": "이 큐 안에서 이동",
+ "workspaceLoopSeekHint": "로컬 합주 시계가 실행 중이거나 일시정지된 동안 이동할 수 있습니다.",
"workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.",
"workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.",
"workspaceLoopStart": "카운트인 시작",
From 81efbe3716632acbbff72626258ef0b09b00fbde Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 06:15:51 +0900
Subject: [PATCH 60/70] fix(player): preserve cue editor focus
---
.../workspace/RehearsalPlayer.test.tsx | 25 +++++++++++
.../features/workspace/RehearsalPlayer.tsx | 41 +++++++++++++++----
2 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 12271ec7a..863945f7c 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";
@@ -231,6 +232,30 @@ describe("RehearsalPlayer", () => {
);
});
+ 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();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 2af0478c2..1440d990a 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -123,9 +123,18 @@ export function RehearsalPlayer({
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);
- }, [selectedLoopKey]);
+ 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") {
@@ -497,11 +506,14 @@ export function RehearsalPlayer({
? value < selectedLoop.endSeconds
: value > selectedLoop.startSeconds);
if (!valid) {
- event.currentTarget.value = String(
+ const currentValue =
boundary === "start"
? selectedLoop.startSeconds
- : selectedLoop.endSeconds,
- );
+ : selectedLoop.endSeconds;
+ setBoundaryDraft((current) => ({
+ ...current,
+ [boundary]: String(currentValue),
+ }));
setBoundaryError(true);
return;
}
@@ -512,6 +524,10 @@ export function RehearsalPlayer({
? selectedLoop.startSeconds
: selectedLoop.endSeconds;
if (value === currentValue) {
+ setBoundaryDraft((current) => ({
+ ...current,
+ [boundary]: String(currentValue),
+ }));
return;
}
@@ -542,6 +558,10 @@ export function RehearsalPlayer({
boundary === "start"
? { ...selectedLoop, startSeconds: value }
: { ...selectedLoop, endSeconds: value };
+ setBoundaryDraft((current) => ({
+ ...current,
+ [boundary]: String(value),
+ }));
setSelectedLoopKey(loopSelectionKey(nextLoop));
onSongUpdate(nextSong);
},
@@ -624,7 +644,6 @@ export function RehearsalPlayer({
) : null}
{selectedLoop && onSongUpdate ? (
@@ -651,13 +670,17 @@ export function RehearsalPlayer({
aria-describedby="rehearsal-loop-boundary-hint"
aria-invalid={boundaryError}
className="min-h-11 rounded-lg border border-white/10 bg-slate-950 px-3 text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
- defaultValue={selectedLoop.startSeconds}
id="rehearsal-loop-boundary-start"
max={MAX_SECTION_TIME_SECONDS}
min={0}
onBlur={(event) => handleBoundaryBlur("start", event)}
+ onChange={(event) => {
+ const value = event.currentTarget.value;
+ setBoundaryDraft((current) => ({ ...current, start: value }));
+ }}
step={1}
type="number"
+ value={boundaryDraft.start}
/>
@@ -666,13 +689,17 @@ export function RehearsalPlayer({
aria-describedby="rehearsal-loop-boundary-hint"
aria-invalid={boundaryError}
className="min-h-11 rounded-lg border border-white/10 bg-slate-950 px-3 text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
- defaultValue={selectedLoop.endSeconds}
id="rehearsal-loop-boundary-end"
max={MAX_SECTION_TIME_SECONDS}
min={0}
onBlur={(event) => handleBoundaryBlur("end", event)}
+ onChange={(event) => {
+ const value = event.currentTarget.value;
+ setBoundaryDraft((current) => ({ ...current, end: value }));
+ }}
step={1}
type="number"
+ value={boundaryDraft.end}
/>
From c37e589150a864cbb4472290c858c1e1ebda0d60 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 06:24:04 +0900
Subject: [PATCH 61/70] feat(player): add safe transport shortcuts
---
.../workspace/RehearsalPlayer.test.tsx | 40 +++++++++
.../features/workspace/RehearsalPlayer.tsx | 82 +++++++++++++------
apps/desktop/src/locales/en/common.json | 1 +
apps/desktop/src/locales/ko/common.json | 1 +
4 files changed, 101 insertions(+), 23 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 0198e06a8..44c6ac863 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -490,6 +490,46 @@ describe("RehearsalPlayer", () => {
expect(seek).toHaveValue("18.5");
});
+ it("supports transport shortcuts without capturing field editing", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ const { play } = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ const onSongUpdate = vi.fn();
+ render(
+ ,
+ );
+
+ const start = screen.getByRole("button", { name: /Start the count-in/i });
+ const boundaryStart = screen.getByRole("spinbutton", {
+ name: "Start time (seconds)",
+ });
+ boundaryStart.focus();
+ fireEvent.keyDown(boundaryStart, { key: " " });
+ expect(start).toBeEnabled();
+ expect(play).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(window, { key: " " });
+ expect(start).toBeDisabled();
+ expect(play).toHaveBeenCalled();
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ fireEvent.keyDown(window, { key: " " });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /paused/i,
+ );
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /Start the count-in/i,
+ );
+ });
+
it("caps long media boundary timers before the browser timeout limit", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 994fa59d2..6a99f1182 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -6,7 +6,7 @@ import {
useState,
type ChangeEvent,
type FocusEvent,
- type KeyboardEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
type ReactElement,
} from "react";
import {
@@ -128,7 +128,7 @@ export function RehearsalPlayer({
setBoundaryError(false);
}, [selectedLoopKey]);
const handleSectionKeyDown = useCallback(
- (event: KeyboardEvent) => {
+ (event: ReactKeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
@@ -576,6 +576,58 @@ export function RehearsalPlayer({
},
[audioSourceUrl, canSeek, handlePlaybackError, transport],
);
+ const startOrResume = useCallback(() => {
+ if (!canStart) {
+ return;
+ }
+ setPlaybackError(false);
+ if (transport.loop) {
+ startAudio(
+ transport.loop,
+ transport.phase === "paused" && transport.countInRemainingBeats === 0,
+ );
+ }
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "start" }),
+ );
+ }, [canStart, startAudio, transport]);
+ const stopTransport = useCallback(() => {
+ if (!canStop) {
+ return;
+ }
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "stop" }),
+ );
+ }, [canStop]);
+ useEffect(() => {
+ /** Keep transport shortcuts out of editable controls. */
+ const handleTransportShortcut = (event: KeyboardEvent) => {
+ const target = event.target;
+ if (
+ event.defaultPrevented ||
+ (target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.closest("button, a, input, select, textarea")))
+ ) {
+ return;
+ }
+ if (event.key === " " && (canPause || canStart)) {
+ event.preventDefault();
+ if (canPause) {
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "pause" }),
+ );
+ } else {
+ startOrResume();
+ }
+ } else if (event.key === "Escape" && canStop) {
+ event.preventDefault();
+ stopTransport();
+ }
+ };
+ window.addEventListener("keydown", handleTransportShortcut);
+ return () => window.removeEventListener("keydown", handleTransportShortcut);
+ }, [canPause, canStart, canStop, startOrResume, stopTransport]);
return (
+
+ {t("workspaceLoopTransportKeyboardHint")}
+
{
- if (!canStart) {
- return;
- }
- setPlaybackError(false);
- if (transport.loop) {
- startAudio(
- transport.loop,
- transport.phase === "paused" &&
- transport.countInRemainingBeats === 0,
- );
- }
- setTransport((current) =>
- reduceRehearsalTransport(current, { type: "start" }),
- );
- }}
+ onClick={startOrResume}
>
{startLabel}
@@ -826,11 +866,7 @@ export function RehearsalPlayer({
disabled={!canStop}
aria-disabled={!canStop}
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100 disabled:cursor-not-allowed disabled:opacity-70"
- onClick={() =>
- setTransport((current) =>
- reduceRehearsalTransport(current, { type: "stop" }),
- )
- }
+ onClick={stopTransport}
>
{t("workspaceLoopStop")}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 9363b2ced..9c4431fcc 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -162,6 +162,7 @@
"workspaceLoopBoundaryError": "Use whole seconds from 0 to the limit, with the end after the start.",
"workspaceLoopSeekLabel": "Seek within this cue",
"workspaceLoopSeekHint": "Seek is available while the local rehearsal clock is running or paused.",
+ "workspaceLoopTransportKeyboardHint": "Space plays or pauses the clock; Escape stops it. Shortcuts stay quiet while you edit a field.",
"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",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 99d5c0e2d..29df4d0a9 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -162,6 +162,7 @@
"workspaceLoopBoundaryError": "0부터 제한값까지의 정수 초를 입력하고 끝을 시작보다 뒤로 두세요.",
"workspaceLoopSeekLabel": "이 큐 안에서 이동",
"workspaceLoopSeekHint": "로컬 합주 시계가 실행 중이거나 일시정지된 동안 이동할 수 있습니다.",
+ "workspaceLoopTransportKeyboardHint": "스페이스로 시계를 재생·일시정지하고 Escape로 멈춥니다. 입력 필드를 편집할 때는 단축키가 동작하지 않습니다.",
"workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.",
"workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.",
"workspaceLoopStart": "카운트인 시작",
From dbd15a9f22bc94cd0676ac0c552a55a872f6c2b6 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 06:28:26 +0900
Subject: [PATCH 62/70] fix(player): ignore repeated transport shortcuts
---
apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx | 2 ++
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 1 +
2 files changed, 3 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 44c6ac863..756ebf05f 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -515,12 +515,14 @@ describe("RehearsalPlayer", () => {
expect(play).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: " " });
+ fireEvent.keyDown(window, { key: " ", repeat: true });
expect(start).toBeDisabled();
expect(play).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(2000);
});
fireEvent.keyDown(window, { key: " " });
+ fireEvent.keyDown(window, { key: " ", repeat: true });
expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
/paused/i,
);
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 6a99f1182..1bbd17d53 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -605,6 +605,7 @@ export function RehearsalPlayer({
const target = event.target;
if (
event.defaultPrevented ||
+ event.repeat ||
(target instanceof HTMLElement &&
(target.isContentEditable ||
target.closest("button, a, input, select, textarea")))
From b8c17fa68de8b24b495cbad0c4b2953f8b7ea8d6 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 10:47:47 +0900
Subject: [PATCH 63/70] fix(player): scope transport shortcuts safely
---
.../workspace/RehearsalPlayer.test.tsx | 11 ++++++++--
.../features/workspace/RehearsalPlayer.tsx | 20 +++++++++++++++----
2 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 756ebf05f..a6e0545ad 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -490,7 +490,7 @@ describe("RehearsalPlayer", () => {
expect(seek).toHaveValue("18.5");
});
- it("supports transport shortcuts without capturing field editing", () => {
+ it("supports transport shortcuts without capturing controls or modifiers", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
const { play } = installPlayableAudioMocks();
@@ -521,12 +521,19 @@ describe("RehearsalPlayer", () => {
act(() => {
vi.advanceTimersByTime(2000);
});
+ fireEvent.keyDown(window, { key: " ", ctrlKey: true });
+ fireEvent.keyDown(window, { key: " ", shiftKey: true });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /looping/i,
+ );
fireEvent.keyDown(window, { key: " " });
fireEvent.keyDown(window, { key: " ", repeat: true });
expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
/paused/i,
);
- fireEvent.keyDown(window, { key: "Escape" });
+ const stop = screen.getByRole("button", { name: /Stop/i });
+ stop.focus();
+ fireEvent.keyDown(stop, { key: "Escape" });
expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
/Start the count-in/i,
);
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 1bbd17d53..cc65059e5 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -603,16 +603,28 @@ export function RehearsalPlayer({
/** Keep transport shortcuts out of editable controls. */
const handleTransportShortcut = (event: KeyboardEvent) => {
const target = event.target;
+ const targetIsButtonOrLink =
+ target instanceof Element && target.closest("button, a") !== null;
+ const targetIsEditable =
+ target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.closest("input, select, textarea") !== null);
if (
event.defaultPrevented ||
event.repeat ||
- (target instanceof HTMLElement &&
- (target.isContentEditable ||
- target.closest("button, a, input, select, textarea")))
+ targetIsEditable
) {
return;
}
- if (event.key === " " && (canPause || canStart)) {
+ if (
+ event.key === " " &&
+ !targetIsButtonOrLink &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ (canPause || canStart)
+ ) {
event.preventDefault();
if (canPause) {
setTransport((current) =>
From aa1c36fd29d450d8ad55355a2c33e3dac6679e52 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:06:22 +0900
Subject: [PATCH 64/70] fix(player): ignore modified escape shortcuts
---
.../src/features/workspace/RehearsalPlayer.test.tsx | 4 ++++
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 9 ++++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index a6e0545ad..72e8d000f 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -533,6 +533,10 @@ describe("RehearsalPlayer", () => {
);
const stop = screen.getByRole("button", { name: /Stop/i });
stop.focus();
+ fireEvent.keyDown(stop, { key: "Escape", altKey: true });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /paused/i,
+ );
fireEvent.keyDown(stop, { key: "Escape" });
expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
/Start the count-in/i,
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index cc65059e5..e47d649b3 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -633,7 +633,14 @@ export function RehearsalPlayer({
} else {
startOrResume();
}
- } else if (event.key === "Escape" && canStop) {
+ } else if (
+ event.key === "Escape" &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.shiftKey &&
+ canStop
+ ) {
event.preventDefault();
stopTransport();
}
From b8a4e1c16663ae10203de5293ad5f8347e81a397 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:11:50 +0900
Subject: [PATCH 65/70] fix(player): preserve duplicate cue identity
---
.../workspace/RehearsalPlayer.test.tsx | 37 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 22 ++++++-----
...rsalTransport.descriptor-authority.test.ts | 1 +
.../features/workspace/rehearsalTransport.ts | 11 +++++-
4 files changed, 59 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 863945f7c..9347c693d 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -294,6 +294,43 @@ describe("RehearsalPlayer", () => {
);
});
+ 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("explains when the active role has no playable sections", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 1440d990a..470c02f2c 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -89,6 +89,7 @@ function hasSameLoopTiming(
next: RehearsalLoopWindow,
): boolean {
return (
+ current.sourceIndex === next.sourceIndex &&
current.sectionId === next.sectionId &&
current.startSeconds === next.startSeconds &&
current.endSeconds === next.endSeconds &&
@@ -99,7 +100,7 @@ function hasSameLoopTiming(
/** Return a stable selection key when analysis emits duplicate section IDs. */
function loopSelectionKey(loop: RehearsalLoopWindow): string {
- return `${loop.sectionId}:${loop.startSeconds}:${loop.endSeconds}`;
+ return String(loop.sourceIndex);
}
/** Render tonight's first section loop with a count-in and a named next action. */
@@ -531,18 +532,19 @@ export function RehearsalPlayer({
return;
}
- const sectionIndex = song.sections.findIndex(
- (section) =>
- section.id === selectedLoop.sectionId &&
- section.timeRange.start === selectedLoop.startSeconds &&
- section.timeRange.end === selectedLoop.endSeconds,
- );
- if (sectionIndex < 0) {
+ 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((section, index) =>
+ sections: song.sections.map((currentSection, index) =>
index === sectionIndex
? {
...section,
@@ -551,7 +553,7 @@ export function RehearsalPlayer({
[boundary]: value,
},
}
- : section,
+ : currentSection,
),
};
const nextLoop =
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..aeee51ac0 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,7 @@ describe("rehearsal transport descriptor authority", () => {
});
expect(createLoopWindow(proxiedSection, song.tempo)).toEqual({
+ sourceIndex: 0,
sectionId: expectedId,
sectionLabel: expectedLabel,
startSeconds: expectedRange.start,
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 5cc0e9899..2fed6ffa7 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -27,6 +27,7 @@ export type RehearsalTransportPhase =
/** Bounded loop window derived from one valid analyzed section. */
export interface RehearsalLoopWindow {
+ sourceIndex: number;
sectionId: string;
sectionLabel: string;
startSeconds: number;
@@ -230,6 +231,7 @@ export function formatRehearsalClock(totalSeconds: number): string {
export function createLoopWindow(
section: RehearsalSection,
tempo: unknown,
+ sourceIndex = 0,
): RehearsalLoopWindow | null {
const snapshot = playableSectionSnapshot(section);
if (!snapshot) {
@@ -237,6 +239,7 @@ export function createLoopWindow(
}
const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo);
return {
+ sourceIndex,
sectionId: snapshot.id,
sectionLabel: snapshot.label,
startSeconds: snapshot.startSeconds,
@@ -262,14 +265,18 @@ export function resolveLoopWindows(
const tempo = ownDataValue(song, "tempo");
const selectedRoleId =
typeof roleId === "string" && roleId.trim() ? roleId : null;
- return sections.flatMap((section) => {
+ return sections.flatMap((section, sourceIndex) => {
if (!section || typeof section !== "object") {
return [];
}
if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) {
return [];
}
- const window = createLoopWindow(section as RehearsalSection, tempo);
+ const window = createLoopWindow(
+ section as RehearsalSection,
+ tempo,
+ sourceIndex,
+ );
return window ? [window] : [];
});
}
From b96e4549ef87b899fa7b784a6458bb2860bff53d Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:21:57 +0900
Subject: [PATCH 66/70] fix(player): preserve cue selection across edits
---
.../workspace/RehearsalPlayer.test.tsx | 46 +++++++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 7 +--
...rsalTransport.descriptor-authority.test.ts | 1 +
.../features/workspace/rehearsalTransport.ts | 28 +++++++++--
4 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 9347c693d..1e638a334 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -331,6 +331,52 @@ describe("RehearsalPlayer", () => {
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();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 470c02f2c..880c215fb 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -89,7 +89,7 @@ function hasSameLoopTiming(
next: RehearsalLoopWindow,
): boolean {
return (
- current.sourceIndex === next.sourceIndex &&
+ current.selectionKey === next.selectionKey &&
current.sectionId === next.sectionId &&
current.startSeconds === next.startSeconds &&
current.endSeconds === next.endSeconds &&
@@ -100,7 +100,7 @@ function hasSameLoopTiming(
/** Return a stable selection key when analysis emits duplicate section IDs. */
function loopSelectionKey(loop: RehearsalLoopWindow): string {
- return String(loop.sourceIndex);
+ return loop.selectionKey;
}
/** Render tonight's first section loop with a count-in and a named next action. */
@@ -261,7 +261,8 @@ export function RehearsalPlayer({
) {
if (
current.loop.sectionLabel === selectedLoop.sectionLabel &&
- current.loop.tempoAssumed === selectedLoop.tempoAssumed
+ current.loop.tempoAssumed === selectedLoop.tempoAssumed &&
+ current.loop.sourceIndex === selectedLoop.sourceIndex
) {
return current;
}
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 aeee51ac0..219b32724 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts
@@ -26,6 +26,7 @@ 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.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 2fed6ffa7..ac689e10e 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -28,6 +28,7 @@ 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;
@@ -240,6 +241,10 @@ 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,
@@ -265,19 +270,36 @@ export function resolveLoopWindows(
const tempo = ownDataValue(song, "tempo");
const selectedRoleId =
typeof roleId === "string" && roleId.trim() ? roleId : null;
+ const selectionOrdinals = new Map();
return sections.flatMap((section, sourceIndex) => {
if (!section || typeof section !== "object") {
return [];
}
- if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) {
- return [];
+ 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,
);
- return window ? [window] : [];
+ if (!window) {
+ return [];
+ }
+ if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) {
+ return [];
+ }
+ return [
+ {
+ ...window,
+ selectionKey: JSON.stringify([window.sectionId, ordinal]),
+ },
+ ];
});
}
From b8b4241ae723d25c47dfa72f0261ad9fea58418f Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:41:50 +0900
Subject: [PATCH 67/70] fix(player): preserve count-in timing across rate
changes
---
.../workspace/RehearsalPlayer.test.tsx | 44 ++++++++++++++
.../features/workspace/RehearsalPlayer.tsx | 57 ++++++++++++++++---
.../features/workspace/rehearsalTransport.ts | 2 +-
3 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 1e638a334..caa3bdafc 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -672,6 +672,9 @@ describe("RehearsalPlayer", () => {
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);
});
@@ -688,6 +691,47 @@ describe("RehearsalPlayer", () => {
);
});
+ 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(133);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 4 beats at 90 BPM/i);
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/Count in 3 beats at 90 BPM/i);
+ });
+
it("applies supported playback speed while preserving pitch when available", () => {
setNavigatorLanguage("en-US");
const { play } = installPlayableAudioMocks();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 880c215fb..bf420cd91 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -179,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),
@@ -330,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) / transport.playbackRate);
- return () => window.clearInterval(timer);
- }, [transport.phase, transport.loop, transport.playbackRate]);
+ 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) {
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index ac689e10e..c7689ab0b 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -503,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),
};
}
From 826b1df416bf3da31b3715ea556a13b0f5111863 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:43:59 +0900
Subject: [PATCH 68/70] docs: align agent guidance with playback rate
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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.
From d99c9358b4c7bdcbf7d090c287b40d8104f97ade Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:49:04 +0900
Subject: [PATCH 69/70] test(player): cover repeated count-in rate changes
---
.../features/workspace/RehearsalPlayer.test.tsx | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index caa3bdafc..0cae5a010 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -719,17 +719,27 @@ describe("RehearsalPlayer", () => {
).toMatch(/Count in 4 beats at 90 BPM/i);
act(() => {
- vi.advanceTimersByTime(133);
+ 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 90 BPM/i);
+ ).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 90 BPM/i);
+ ).toMatch(/Count in 3 beats at 150 BPM/i);
});
it("applies supported playback speed while preserving pitch when available", () => {
From 7c24f593e0d5896a9bbda03bf493d1241cb92a11 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Sun, 30 Aug 2026 11:58:25 +0900
Subject: [PATCH 70/70] fix(player): preserve scroll-region Space behavior
---
.../workspace/RehearsalPlayer.test.tsx | 47 ++++++++++++++++++-
.../features/workspace/RehearsalPlayer.tsx | 4 ++
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 72e8d000f..6a7681555 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -1,6 +1,12 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
-import { act, fireEvent, render, screen } from "@testing-library/react";
+import {
+ act,
+ createEvent,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RehearsalPlayer } from "./RehearsalPlayer";
@@ -543,6 +549,45 @@ describe("RehearsalPlayer", () => {
);
});
+ it("preserves native Space behavior for focused scroll regions", () => {
+ setNavigatorLanguage("en-US");
+ vi.useFakeTimers();
+ installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+
+ render(
+ <>
+
+
+ >,
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ const region = screen.getByRole("region", {
+ name: "Scrollable rehearsal timeline",
+ });
+ const event = createEvent.keyDown(region, { key: " " });
+ region.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(
+ screen.getByTestId("rehearsal-loop-next-action").textContent,
+ ).toMatch(/looping/i);
+ });
+
it("caps long media boundary timers before the browser timeout limit", () => {
setNavigatorLanguage("en-US");
vi.useFakeTimers();
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index e47d649b3..75edf080b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -605,6 +605,9 @@ export function RehearsalPlayer({
const target = event.target;
const targetIsButtonOrLink =
target instanceof Element && target.closest("button, a") !== null;
+ const targetIsScrollableRegion =
+ target instanceof Element &&
+ target.closest('[role="region"][tabindex="0"]') !== null;
const targetIsEditable =
target instanceof HTMLElement &&
(target.isContentEditable ||
@@ -619,6 +622,7 @@ export function RehearsalPlayer({
if (
event.key === " " &&
!targetIsButtonOrLink &&
+ !targetIsScrollableRegion &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&