From 0eeec7baa0e1c6ed26fd427e3484ac7b418691e5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 20 Aug 2026 23:15:23 +0000
Subject: [PATCH 001/153] 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 002/153] 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 003/153] 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 004/153] 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 005/153] 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 006/153] 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 007/153] 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 008/153] 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 009/153] 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 010/153] 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 011/153] 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 012/153] 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 013/153] 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 014/153] 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 015/153] 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 016/153] 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 017/153] 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 018/153] 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 019/153] 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 020/153] 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 021/153] 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 022/153] 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 023/153] 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 024/153] 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 025/153] 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 026/153] 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 027/153] 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 028/153] 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 029/153] 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 030/153] 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 031/153] 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 032/153] 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 033/153] 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 034/153] 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 035/153] 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 036/153] 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 037/153] 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 038/153] 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 039/153] 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 040/153] 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 041/153] 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 042/153] 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 043/153] 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 044/153] 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 045/153] 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 046/153] 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 047/153] 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 048/153] 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 049/153] 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 050/153] 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 051/153] 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 052/153] 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 053/153] 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 054/153] 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 055/153] 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 056/153] 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 057/153] 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 058/153] 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 059/153] 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 060/153] 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 061/153] 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 062/153] 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 063/153] 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 064/153] 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 065/153] 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 066/153] 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 067/153] 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 068/153] 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 069/153] 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 070/153] 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 &&
From a802aceab82e12751328be0260579783d6ab2fed Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 17:38:09 +0900
Subject: [PATCH 071/153] test(player): preserve cumulative count-in progress
---
...RehearsalPlayer.count-in-progress.test.tsx | 105 ++++++++++++++++++
1 file changed, 105 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx
new file mode 100644
index 000000000..07c1d8c7d
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx
@@ -0,0 +1,105 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, expect, it, vi } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+const originalLanguage = navigator.language;
+const originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
+const originalPreservesPitch = Object.getOwnPropertyDescriptor(
+ HTMLMediaElement.prototype,
+ "preservesPitch",
+);
+const audioSourcePath = "/Users/test/Music/rehearsal.wav";
+
+function installPlayableAudioMocks() {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: "en-US",
+ });
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
+ },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+ Object.defineProperty(HTMLMediaElement.prototype, "preservesPitch", {
+ configurable: true,
+ writable: true,
+ value: false,
+ });
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: originalLanguage,
+ });
+ if (originalTauriInternals) {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
+ } else {
+ 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("preserves cumulative progress through a count-in beat across repeated speed changes", () => {
+ 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" },
+ });
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+ fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), {
+ target: { value: "1.25" },
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(49);
+ });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /Count in 4 beats at 150 BPM/i,
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /Count in 3 beats at 150 BPM/i,
+ );
+});
From c207737de762f66be40196f1919baec8c267ea8b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 20:37:16 +0900
Subject: [PATCH 072/153] fix(player): preserve cumulative count-in progress
---
.../src/features/workspace/RehearsalPlayer.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 85f5b8b31..b7ecc3947 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -184,6 +184,7 @@ export function RehearsalPlayer({
durationMs: number;
startedAt: number;
remainingBeats: number;
+ progress: number;
} | null>(null);
const audioRef = useRef(null);
const audioSourceUrl = useMemo(
@@ -257,7 +258,6 @@ export function RehearsalPlayer({
}
};
}, [audioSourceUrl, hasNativeAudioConversionError]);
-
useEffect(() => {
setTransport((current) => {
if (
@@ -345,16 +345,17 @@ export function RehearsalPlayer({
const previous = countInBeatRef.current;
const sameBeat =
previous?.remainingBeats === transport.countInRemainingBeats;
- const elapsedMs = sameBeat
- ? Math.max(0, now - previous.startedAt)
+ const elapsedProgress = sameBeat
+ ? Math.max(0, now - previous.startedAt) / previous.durationMs
: 0;
const progress = sameBeat
- ? Math.min(1, elapsedMs / previous.durationMs)
+ ? Math.min(1, previous.progress + elapsedProgress)
: 0;
countInBeatRef.current = {
durationMs,
startedAt: now,
remainingBeats: transport.countInRemainingBeats,
+ progress,
};
let timer: number | undefined;
/** Schedule the next count-in beat without coupling it to React commits. */
@@ -365,6 +366,7 @@ export function RehearsalPlayer({
return;
}
current.remainingBeats -= 1;
+ current.progress = 0;
setTransport((state) => reduceRehearsalTransport(state, { type: "beat" }));
if (current.remainingBeats > 0) {
current.startedAt = performance.now();
@@ -986,4 +988,4 @@ export function RehearsalPlayer({
/>
);
-}
+}
\ No newline at end of file
From 8ae5cfff4d3467d0d3650ad4bb10bc0d95ffa38f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 20:39:04 +0900
Subject: [PATCH 073/153] style(player): preserve canonical source formatting
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index b7ecc3947..c12a23cb8 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -258,6 +258,7 @@ export function RehearsalPlayer({
}
};
}, [audioSourceUrl, hasNativeAudioConversionError]);
+
useEffect(() => {
setTransport((current) => {
if (
@@ -988,4 +989,4 @@ export function RehearsalPlayer({
/>
);
-}
\ No newline at end of file
+}
From a8b7170bfc31f2c3c6bc4301c4f32655a0eca9b2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 20:44:06 +0900
Subject: [PATCH 074/153] test(player): reproduce media lifecycle interruptions
---
.../RehearsalPlayer.media-lifecycle.test.tsx | 117 ++++++++++++++++++
1 file changed, 117 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
new file mode 100644
index 000000000..50ed91742
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
@@ -0,0 +1,117 @@
+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 originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
+const audioSourcePath = "/Users/test/Music/rehearsal.wav";
+
+function installAudioBoundary() {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
+ },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+}
+
+function renderPlayableSong() {
+ render(
+ ,
+ );
+}
+
+describe("RehearsalPlayer media lifecycle", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ if (originalTauriInternals) {
+ Object.defineProperty(
+ window,
+ "__TAURI_INTERNALS__",
+ originalTauriInternals,
+ );
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
+ });
+
+ it("keeps a quick user pause when the pending play request aborts", async () => {
+ installAudioBoundary();
+ let rejectPlay: ((reason?: unknown) => void) | undefined;
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPlay = reject;
+ }),
+ );
+ renderPlayableSong();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /paused/i,
+ );
+
+ await act(async () => {
+ const interruption = new Error("The play request was interrupted");
+ interruption.name = "AbortError";
+ rejectPlay?.(interruption);
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /paused/i,
+ );
+ expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull();
+ });
+
+ it("restarts a selected loop when the media ends at the section boundary", () => {
+ vi.useFakeTimers();
+ installAudioBoundary();
+ const play = vi
+ .spyOn(HTMLMediaElement.prototype, "play")
+ .mockResolvedValue(undefined);
+ renderPlayableSong();
+
+ const audio = screen.getByTestId(
+ "rehearsal-loop-audio",
+ ) as HTMLAudioElement;
+ Object.defineProperty(audio, "currentTime", {
+ configurable: true,
+ writable: true,
+ value: 10,
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /looping/i,
+ );
+
+ audio.currentTime = 30;
+ fireEvent(audio, new Event("ended"));
+
+ expect(audio.currentTime).toBe(10);
+ expect(play.mock.calls.length).toBeGreaterThan(1);
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /looping/i,
+ );
+ expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull();
+ });
+});
From 7bba2de91639f5c04c41b62cecd161f9f5348641 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 20:46:24 +0900
Subject: [PATCH 075/153] fix(player): distinguish media interruption from
failure
---
.../features/workspace/RehearsalPlayer.tsx | 69 ++++++++++++++-----
1 file changed, 52 insertions(+), 17 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index c12a23cb8..ff07edc86 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -187,6 +187,7 @@ export function RehearsalPlayer({
progress: number;
} | null>(null);
const audioRef = useRef(null);
+ const playbackIntentRef = useRef<"active" | "inactive">("inactive");
const audioSourceUrl = useMemo(
() => resolveAudioSourceUrl(audioSourcePath),
[audioSourcePath],
@@ -201,6 +202,7 @@ export function RehearsalPlayer({
const [playbackError, setPlaybackError] = useState(false);
const handlePlaybackError = useCallback(() => {
+ playbackIntentRef.current = "inactive";
setPlaybackError(true);
setTransport((current) => {
if (current.phase === "idle" || current.phase === "armed") {
@@ -210,6 +212,21 @@ export function RehearsalPlayer({
});
}, []);
+ const handlePlayRejection = useCallback(
+ (error: unknown) => {
+ const expectedInterruption =
+ playbackIntentRef.current === "inactive" &&
+ typeof error === "object" &&
+ error !== null &&
+ "name" in error &&
+ error.name === "AbortError";
+ if (!expectedInterruption) {
+ handlePlaybackError();
+ }
+ },
+ [handlePlaybackError],
+ );
+
const startAudio = useCallback(
(loop: RehearsalLoopWindow, resume: boolean) => {
const audio = audioRef.current;
@@ -225,15 +242,16 @@ export function RehearsalPlayer({
} else {
audio.volume = 1;
}
+ playbackIntentRef.current = "active";
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlaybackError);
+ void playPromise.catch(handlePlayRejection);
}
} catch {
handlePlaybackError();
}
},
- [audioSourceUrl, handlePlaybackError],
+ [audioSourceUrl, handlePlaybackError, handlePlayRejection],
);
useEffect(() => {
@@ -241,6 +259,7 @@ export function RehearsalPlayer({
if (!audio) {
return undefined;
}
+ playbackIntentRef.current = "inactive";
if (!audio.paused) {
audio.pause();
}
@@ -253,6 +272,7 @@ export function RehearsalPlayer({
}
setPlaybackError(hasNativeAudioConversionError);
return () => {
+ playbackIntentRef.current = "inactive";
if (!audio.paused) {
audio.pause();
}
@@ -327,6 +347,7 @@ export function RehearsalPlayer({
if (hasPlayableAudio) {
return;
}
+ playbackIntentRef.current = "inactive";
setTransport((current) => {
if (current.phase === "idle" || current.phase === "armed") {
return current;
@@ -402,9 +423,10 @@ export function RehearsalPlayer({
restartAudioOnLoopRef.current = false;
}
audio.volume = 1;
+ playbackIntentRef.current = "active";
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlaybackError);
+ void playPromise.catch(handlePlayRejection);
}
} catch {
handlePlaybackError();
@@ -414,13 +436,20 @@ export function RehearsalPlayer({
transport.phase === "paused" ||
transport.phase === "idle"
) {
+ playbackIntentRef.current = "inactive";
if (!audio.paused) {
audio.pause();
}
audio.volume = 1;
}
return undefined;
- }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]);
+ }, [
+ audioSourceUrl,
+ handlePlaybackError,
+ handlePlayRejection,
+ transport.phase,
+ transport.loop,
+ ]);
useEffect(() => {
if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) {
@@ -444,9 +473,10 @@ export function RehearsalPlayer({
const restartLoop = () => {
try {
audio.currentTime = loop.startSeconds;
+ playbackIntentRef.current = "active";
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlaybackError);
+ void playPromise.catch(handlePlayRejection);
}
} catch {
handlePlaybackError();
@@ -493,21 +523,22 @@ export function RehearsalPlayer({
}),
);
};
- /** Stop the transport when the media element can no longer play. */
+ /** Stop the transport when the media element reports a real playback error. */
const failPlayback = () => handlePlaybackError();
audio.addEventListener("timeupdate", syncPlayhead);
audio.addEventListener("error", failPlayback);
- audio.addEventListener("ended", failPlayback);
+ audio.addEventListener("ended", restartLoop);
scheduleLoopBoundary();
return () => {
clearBoundaryTimer();
audio.removeEventListener("timeupdate", syncPlayhead);
audio.removeEventListener("error", failPlayback);
- audio.removeEventListener("ended", failPlayback);
+ audio.removeEventListener("ended", restartLoop);
};
}, [
audioSourceUrl,
handlePlaybackError,
+ handlePlayRejection,
transport.phase,
transport.loop,
transport.playbackRate,
@@ -660,10 +691,20 @@ export function RehearsalPlayer({
reduceRehearsalTransport(current, { type: "start" }),
);
}, [canStart, startAudio, transport]);
+ const pauseTransport = useCallback(() => {
+ if (!canPause) {
+ return;
+ }
+ playbackIntentRef.current = "inactive";
+ setTransport((current) =>
+ reduceRehearsalTransport(current, { type: "pause" }),
+ );
+ }, [canPause]);
const stopTransport = useCallback(() => {
if (!canStop) {
return;
}
+ playbackIntentRef.current = "inactive";
setTransport((current) =>
reduceRehearsalTransport(current, { type: "stop" }),
);
@@ -700,9 +741,7 @@ export function RehearsalPlayer({
) {
event.preventDefault();
if (canPause) {
- setTransport((current) =>
- reduceRehearsalTransport(current, { type: "pause" }),
- );
+ pauseTransport();
} else {
startOrResume();
}
@@ -720,7 +759,7 @@ export function RehearsalPlayer({
};
window.addEventListener("keydown", handleTransportShortcut);
return () => window.removeEventListener("keydown", handleTransportShortcut);
- }, [canPause, canStart, canStop, startOrResume, stopTransport]);
+ }, [canPause, canStart, canStop, pauseTransport, startOrResume, stopTransport]);
return (
- setTransport((current) =>
- reduceRehearsalTransport(current, { type: "pause" }),
- )
- }
+ onClick={pauseTransport}
>
{t("workspaceLoopPause")}
From 3f4e1f460fac7441219ec6d72f71e15839f9a02d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 20:52:22 +0900
Subject: [PATCH 076/153] test(player): reproduce stale play rejection race
---
.../RehearsalPlayer.media-lifecycle.test.tsx | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
index 50ed91742..099f009ef 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx
@@ -78,6 +78,43 @@ describe("RehearsalPlayer media lifecycle", () => {
expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull();
});
+ it("ignores a stale play rejection after playback has resumed", async () => {
+ installAudioBoundary();
+ let rejectFirstPlay: ((reason?: unknown) => void) | undefined;
+ const play = vi
+ .spyOn(HTMLMediaElement.prototype, "play")
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFirstPlay = reject;
+ }),
+ )
+ .mockResolvedValue(undefined);
+ renderPlayableSong();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
+ fireEvent.click(screen.getByRole("button", { name: /Resume/i }));
+ expect(play).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /Count in/i,
+ );
+
+ await act(async () => {
+ const staleInterruption = new Error("The earlier play request was interrupted");
+ staleInterruption.name = "AbortError";
+ rejectFirstPlay?.(staleInterruption);
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent(
+ /Count in/i,
+ );
+ expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull();
+ });
+
it("restarts a selected loop when the media ends at the section boundary", () => {
vi.useFakeTimers();
installAudioBoundary();
From 6467f576645cf81a481fa6f8bd65ac1f47791896 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 22:10:17 +0900
Subject: [PATCH 077/153] fix(player): ignore superseded play rejections
---
.../features/workspace/RehearsalPlayer.tsx | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index ff07edc86..fbe1edf8d 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -188,6 +188,7 @@ export function RehearsalPlayer({
} | null>(null);
const audioRef = useRef(null);
const playbackIntentRef = useRef<"active" | "inactive">("inactive");
+ const playRequestSequenceRef = useRef(0);
const audioSourceUrl = useMemo(
() => resolveAudioSourceUrl(audioSourcePath),
[audioSourcePath],
@@ -213,7 +214,10 @@ export function RehearsalPlayer({
}, []);
const handlePlayRejection = useCallback(
- (error: unknown) => {
+ (error: unknown, requestSequence: number) => {
+ if (requestSequence !== playRequestSequenceRef.current) {
+ return;
+ }
const expectedInterruption =
playbackIntentRef.current === "inactive" &&
typeof error === "object" &&
@@ -243,9 +247,12 @@ export function RehearsalPlayer({
audio.volume = 1;
}
playbackIntentRef.current = "active";
+ const requestSequence = ++playRequestSequenceRef.current;
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlayRejection);
+ void playPromise.catch((error: unknown) =>
+ handlePlayRejection(error, requestSequence),
+ );
}
} catch {
handlePlaybackError();
@@ -424,9 +431,12 @@ export function RehearsalPlayer({
}
audio.volume = 1;
playbackIntentRef.current = "active";
+ const requestSequence = ++playRequestSequenceRef.current;
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlayRejection);
+ void playPromise.catch((error: unknown) =>
+ handlePlayRejection(error, requestSequence),
+ );
}
} catch {
handlePlaybackError();
@@ -474,9 +484,12 @@ export function RehearsalPlayer({
try {
audio.currentTime = loop.startSeconds;
playbackIntentRef.current = "active";
+ const requestSequence = ++playRequestSequenceRef.current;
const playPromise = audio.play();
if (playPromise) {
- void playPromise.catch(handlePlayRejection);
+ void playPromise.catch((error: unknown) =>
+ handlePlayRejection(error, requestSequence),
+ );
}
} catch {
handlePlaybackError();
@@ -1024,4 +1037,4 @@ export function RehearsalPlayer({
/>
);
-}
+}
\ No newline at end of file
From a34f090a5c2f0799884672bc163f4e21afd1d716 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 1 Sep 2026 22:12:15 +0900
Subject: [PATCH 078/153] style(player): restore canonical trailing newline
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index fbe1edf8d..1ac4c958d 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -1037,4 +1037,4 @@ export function RehearsalPlayer({
/>
);
-}
\ No newline at end of file
+}
From 08a9900ae210b599207982c8d13e333c24bb00b9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 00:43:00 +0900
Subject: [PATCH 079/153] test(player): reproduce hidden playback-scope
preparation error
---
apps/desktop/src/lib/analysis.test.ts | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts
index e3347d1f5..a7f120683 100644
--- a/apps/desktop/src/lib/analysis.test.ts
+++ b/apps/desktop/src/lib/analysis.test.ts
@@ -4,6 +4,7 @@ import {
MAX_YOUTUBE_URL_LENGTH,
getAnalysisJobStatus,
importYoutubeUrl,
+ selectLocalAudioSource,
startAnalysisJob
} from "./analysis";
@@ -20,6 +21,22 @@ describe("analysis bridge", () => {
delete tauriWindow.__TAURI_INVOKE__;
});
+ it("preserves safe playback preparation errors from native local audio selection", async () => {
+ tauriWindow.__TAURI_INVOKE__ = vi
+ .fn()
+ .mockRejectedValue(new Error("Could not prepare the selected audio for playback."));
+
+ const selection = await selectLocalAudioSource();
+
+ expect(selection).toEqual({
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message: "Could not prepare the selected audio for playback."
+ }
+ });
+ });
+
it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => {
const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34");
@@ -237,4 +254,4 @@ describe("analysis bridge", () => {
}
});
});
-});
+});
\ No newline at end of file
From c5e1acda2892cb6579075aa0062675ae3180dc1e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 00:43:57 +0900
Subject: [PATCH 080/153] fix(player): preserve safe playback preparation
failure
---
apps/desktop/src/lib/analysis.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts
index bb750b34b..0cc4e9042 100644
--- a/apps/desktop/src/lib/analysis.ts
+++ b/apps/desktop/src/lib/analysis.ts
@@ -38,6 +38,7 @@ const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to
const SAFE_LOCAL_AUDIO_MESSAGES = new Set([
UNSUPPORTED_LOCAL_AUDIO_MESSAGE,
"Could not read the selected audio file.",
+ "Could not prepare the selected audio for playback.",
"Could not prepare the local project workspace.",
"Could not prepare the local cache workspace.",
"Could not prepare the local temp workspace."
From 598b8c22d95efe66841e58ea7b1d5683dcef26a3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 00:45:17 +0900
Subject: [PATCH 081/153] test(player): scope timer-only copy guard to no-audio
state
---
apps/desktop/src/i18n/rehearsalLoopCopy.test.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
index 609269eaa..4d7035bfe 100644
--- a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
+++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts
@@ -17,7 +17,6 @@ describe("rehearsal loop action copy", () => {
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("합주 시계");
From f1f31fc7e4fcef007df2d6b9d91d53c6db7753fd Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 00:46:18 +0900
Subject: [PATCH 082/153] docs(player): distinguish media playback from decode
ownership
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d890e25de..8b04a2b59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
### Added
-- 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.
+- Tonight's rehearsal player now arms the first valid section loop, runs a tempo count-in, and plays validated local audio through a scoped Tauri asset URL in the browser media element; the React layer does not decode audio bytes itself, and 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.
From fe4d7ee56c23fef154f88bc264b67c54c29dd106 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 00:46:55 +0900
Subject: [PATCH 083/153] docs(player): document scoped Tauri media playback
boundary
---
CLAUDE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9a12fd557..a347f12d9 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). The workspace `RehearsalPlayer` owns tonight's first section loop and count-in clock; it does not decode local audio in this slice. The ready workspace also names tonight's first playable range and the next instrument check. `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 bytes in React; after native admission, its browser media element plays the authorized source through a scoped Tauri asset URL. The ready workspace also names tonight's first playable range and the next instrument check. `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.
From 7f3df1aeca42304d5264ebca81c57948f8860eb8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 01:47:37 +0900
Subject: [PATCH 084/153] test(player): reproduce Tauri string rejection
boundary
---
apps/desktop/src/lib/analysis.test.ts | 30 +++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts
index a7f120683..bdef50f73 100644
--- a/apps/desktop/src/lib/analysis.test.ts
+++ b/apps/desktop/src/lib/analysis.test.ts
@@ -37,6 +37,36 @@ describe("analysis bridge", () => {
});
});
+ it("preserves allowlisted string rejections from the production Tauri boundary", async () => {
+ tauriWindow.__TAURI_INVOKE__ = vi
+ .fn()
+ .mockRejectedValue("Could not prepare the selected audio for playback.");
+
+ const selection = await selectLocalAudioSource();
+
+ expect(selection).toEqual({
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message: "Could not prepare the selected audio for playback."
+ }
+ });
+ });
+
+ it("does not expose unallowlisted native string rejections", async () => {
+ tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("/Users/test/private/song.wav failed");
+
+ const selection = await selectLocalAudioSource();
+
+ expect(selection).toEqual({
+ ok: false,
+ error: {
+ code: "invalid_request",
+ message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis."
+ }
+ });
+ });
+
it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => {
const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34");
From 3ee7989309117f17b44c7ba76badd53d847aba5d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 01:48:31 +0900
Subject: [PATCH 085/153] fix(player): surface allowlisted Tauri string errors
---
apps/desktop/src/lib/analysis.ts | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts
index 0cc4e9042..0da2295d7 100644
--- a/apps/desktop/src/lib/analysis.ts
+++ b/apps/desktop/src/lib/analysis.ts
@@ -232,13 +232,15 @@ export async function selectLocalAudioSource(): Promise {
export async function loadProject(): Promise {
const response = await invokeAnalysis("load_project");
return parseRehearsalSong(response);
-}
+}
\ No newline at end of file
From 1f1c7ac839b92a4e748f0c651b7b2c80324b174e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 01:52:06 +0900
Subject: [PATCH 086/153] test(player): reproduce stale boundary snapshot edit
---
...RehearsalPlayer.boundary-snapshot.test.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
new file mode 100644
index 000000000..f4d841bb0
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it, vi } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+describe("RehearsalPlayer boundary snapshot", () => {
+ it("rejects a stale boundary edit and restores the admitted loop value", () => {
+ const song = createDemoRehearsalSong();
+ const onSongUpdate = vi.fn();
+
+ render( );
+
+ const start = screen.getByRole("spinbutton", {
+ name: "Start time (seconds)",
+ });
+ expect(start).toHaveValue(10);
+
+ fireEvent.change(start, { target: { value: "12" } });
+ song.sections[0]!.timeRange.start = 11;
+ fireEvent.blur(start);
+
+ expect(onSongUpdate).not.toHaveBeenCalled();
+ expect(start).toHaveValue(10);
+ expect(start).toHaveAttribute("aria-invalid", "true");
+ });
+});
From 8cdb2d09034ff8b89c01261985c65dc4f818dfd4 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 01:53:43 +0900
Subject: [PATCH 087/153] chore(player): keep snapshot regression out until
causal fix lands
---
...RehearsalPlayer.boundary-snapshot.test.tsx | 26 -------------------
1 file changed, 26 deletions(-)
delete mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
deleted file mode 100644
index f4d841bb0..000000000
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong } from "@bandscope/shared-types";
-import { describe, expect, it, vi } from "vitest";
-import { RehearsalPlayer } from "./RehearsalPlayer";
-
-describe("RehearsalPlayer boundary snapshot", () => {
- it("rejects a stale boundary edit and restores the admitted loop value", () => {
- const song = createDemoRehearsalSong();
- const onSongUpdate = vi.fn();
-
- render( );
-
- const start = screen.getByRole("spinbutton", {
- name: "Start time (seconds)",
- });
- expect(start).toHaveValue(10);
-
- fireEvent.change(start, { target: { value: "12" } });
- song.sections[0]!.timeRange.start = 11;
- fireEvent.blur(start);
-
- expect(onSongUpdate).not.toHaveBeenCalled();
- expect(start).toHaveValue(10);
- expect(start).toHaveAttribute("aria-invalid", "true");
- });
-});
From 0a6e6c13b258e945efa81ec4f5daeb54b34bb525 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 03:08:15 +0900
Subject: [PATCH 088/153] test(player): reject stale boundary edits
---
...RehearsalPlayer.boundary-snapshot.test.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
new file mode 100644
index 000000000..f4d841bb0
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it, vi } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+describe("RehearsalPlayer boundary snapshot", () => {
+ it("rejects a stale boundary edit and restores the admitted loop value", () => {
+ const song = createDemoRehearsalSong();
+ const onSongUpdate = vi.fn();
+
+ render( );
+
+ const start = screen.getByRole("spinbutton", {
+ name: "Start time (seconds)",
+ });
+ expect(start).toHaveValue(10);
+
+ fireEvent.change(start, { target: { value: "12" } });
+ song.sections[0]!.timeRange.start = 11;
+ fireEvent.blur(start);
+
+ expect(onSongUpdate).not.toHaveBeenCalled();
+ expect(start).toHaveValue(10);
+ expect(start).toHaveAttribute("aria-invalid", "true");
+ });
+});
From a2fa4cc7a0cf22f186f5bc4bb2709ef379cb4020 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 03:11:46 +0900
Subject: [PATCH 089/153] fix(player): fail closed on stale boundary snapshots
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 1ac4c958d..f7f68a9d1 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -632,6 +632,11 @@ export function RehearsalPlayer({
section.timeRange.start !== selectedLoop.startSeconds ||
section.timeRange.end !== selectedLoop.endSeconds
) {
+ setBoundaryDraft({
+ end: String(selectedLoop.endSeconds),
+ start: String(selectedLoop.startSeconds),
+ });
+ setBoundaryError(true);
return;
}
const nextSong = {
From a21533087ce03ccf763e37c814e1081f8d95a67e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 03:13:38 +0900
Subject: [PATCH 090/153] test(player): round adjusted tempo in rehearsal copy
---
.../rehearsalTransport.tempo-display.test.ts | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts
new file mode 100644
index 000000000..672ea9763
--- /dev/null
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts
@@ -0,0 +1,26 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import {
+ createIdleTransportState,
+ nextActionValues,
+ reduceRehearsalTransport,
+ resolveLoopWindow,
+} from "./rehearsalTransport";
+
+describe("rehearsal transport tempo display", () => {
+ it("rounds the playback-adjusted tempo for rehearsal copy", () => {
+ const song = createDemoRehearsalSong();
+ song.tempo = 90;
+ const loop = resolveLoopWindow(song);
+ let state = reduceRehearsalTransport(createIdleTransportState(), {
+ type: "arm",
+ loop,
+ });
+ state = reduceRehearsalTransport(state, {
+ type: "set-playback-rate",
+ rate: 1.25,
+ });
+
+ expect(nextActionValues(state).tempo).toBe("113");
+ });
+});
From 797bf392f09b2857e3651a35b79564e8c6900394 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 03:16:19 +0900
Subject: [PATCH 091/153] fix(player): round adjusted rehearsal tempo
---
.../src/features/workspace/rehearsalTransport.ts | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts
index 39cd05412..18fe11c98 100644
--- a/apps/desktop/src/features/workspace/rehearsalTransport.ts
+++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts
@@ -23,7 +23,11 @@ export function rehearsalPlaybackRates(): readonly RehearsalPlaybackRate[] {
/** Documented rehearsal transport phases for the first section loop. */
export type RehearsalTransportPhase =
- "idle" | "armed" | "counting-in" | "looping" | "paused";
+ | "idle"
+ | "armed"
+ | "counting-in"
+ | "looping"
+ | "paused";
/** Bounded loop window derived from one valid analyzed section. */
export interface RehearsalLoopWindow {
@@ -242,10 +246,7 @@ export function createLoopWindow(
const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo);
return {
sourceIndex,
- selectionKey: JSON.stringify([
- snapshot.id,
- 0,
- ]),
+ selectionKey: JSON.stringify([snapshot.id, 0]),
sectionId: snapshot.id,
sectionLabel: snapshot.label,
startSeconds: snapshot.startSeconds,
@@ -527,6 +528,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 * state.playbackRate),
+ tempo: String(Math.round(state.loop.tempoBpm * state.playbackRate)),
};
}
From 86fbda601bd206d41221c593a0d572bbb06e1cf0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 03:27:00 +0900
Subject: [PATCH 092/153] test(player): align cumulative count-in timing
---
apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
index 224de28bd..bd79fe007 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx
@@ -853,7 +853,7 @@ describe("RehearsalPlayer", () => {
).toMatch(/Count in 4 beats at 150 BPM/i);
act(() => {
- vi.advanceTimersByTime(369);
+ vi.advanceTimersByTime(49);
});
expect(
screen.getByTestId("rehearsal-loop-next-action").textContent,
From 302b53c739ff2d5f14b0196572366dceff2a1792 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 07:48:25 +0900
Subject: [PATCH 093/153] test(player): preserve renderer-owned roadmap focus
contract
---
.../workspace/SectionRoadmap.test.tsx | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
index 5b32019d2..b59387fa9 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
@@ -106,4 +106,29 @@ describe("SectionRoadmap", () => {
expect(onSongUpdate).not.toHaveBeenCalled();
});
+
+ it("keeps focus target ids renderer-owned for arbitrary analysis section ids", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0].id = " verse 1 ";
+
+ render( );
+
+ const card = document.getElementById("workspace-section-card-0");
+ expect(card).toBeTruthy();
+ expect(card?.getAttribute("tabindex")).toBe("-1");
+ expect(card?.id).not.toContain(song.sections[0].id);
+ });
+
+ it("keeps duplicate analysis section ids as distinct renderer focus targets", () => {
+ const song = createDemoRehearsalSong();
+ const duplicate = { ...song.sections[0], roles: [...song.sections[0].roles] };
+ song.sections = [song.sections[0], duplicate];
+
+ render( );
+
+ expect(document.getElementById("workspace-section-card-0")).toBeTruthy();
+ const second = document.getElementById("workspace-section-card-1");
+ expect(second).toBeTruthy();
+ expect(second?.className).toContain("ring-cyan-300/70");
+ });
});
From b6d01a55d60cf5ec44082f8dfa336ee77ac6f3bb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 07:49:04 +0900
Subject: [PATCH 094/153] fix(player): preserve renderer-owned roadmap focus
targets
---
.../src/features/workspace/SectionRoadmap.tsx | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 834d1e8f0..61ec249c8 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -12,10 +12,11 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
+ loopedSectionIndex?: number | null;
}
/** Documented. */
-export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
+export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
@@ -104,11 +105,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
- {song.sections.map((section) => (
+ {song.sections.map((section, sectionIndex) => (
From 4534e842da9e9c0167c62cd6fb1d9e833fb09965 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 16:41:55 +0900
Subject: [PATCH 095/153] test(player): require audible transport count-in
---
.../RehearsalPlayer.audible-count-in.test.tsx | 146 ++++++++++++++++++
1 file changed, 146 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
new file mode 100644
index 000000000..d802a6ac4
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
@@ -0,0 +1,146 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, expect, it, vi } from "vitest";
+import { RehearsalPlayer } from "./RehearsalPlayer";
+
+const originalLanguage = navigator.language;
+const originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
+const originalAudioContext = Object.getOwnPropertyDescriptor(
+ window,
+ "AudioContext",
+);
+const audioSourcePath = "/Users/test/Music/rehearsal.wav";
+
+type FakeOscillator = {
+ connect: ReturnType;
+ disconnect: ReturnType;
+ frequency: { value: number };
+ onended: (() => void) | null;
+ start: ReturnType;
+ stop: ReturnType;
+ type: OscillatorType;
+};
+
+function installPlayableAudioMocks(): FakeOscillator[] {
+ const oscillators: FakeOscillator[] = [];
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: "en-US",
+ });
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
+ },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+
+ class FakeAudioContext {
+ currentTime = 1;
+ destination = {};
+ state: AudioContextState = "running";
+
+ resume = vi.fn(async () => undefined);
+
+ createGain() {
+ const gain = {
+ connect: vi.fn(() => gain),
+ disconnect: vi.fn(),
+ gain: {
+ exponentialRampToValueAtTime: vi.fn(),
+ setValueAtTime: vi.fn(),
+ },
+ };
+ return gain;
+ }
+
+ createOscillator() {
+ const oscillator: FakeOscillator = {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ frequency: { value: 0 },
+ onended: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ type: "sine",
+ };
+ oscillators.push(oscillator);
+ return oscillator;
+ }
+ }
+
+ Object.defineProperty(window, "AudioContext", {
+ configurable: true,
+ writable: true,
+ value: FakeAudioContext,
+ });
+ return oscillators;
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: originalLanguage,
+ });
+ if (originalTauriInternals) {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
+ if (originalAudioContext) {
+ Object.defineProperty(window, "AudioContext", originalAudioContext);
+ } else {
+ Reflect.deleteProperty(window, "AudioContext");
+ }
+});
+
+it("sounds the transport count-in without replaying a beat when playback rate changes", () => {
+ vi.useFakeTimers();
+ const oscillators = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i }));
+
+ expect(oscillators).toHaveLength(1);
+ expect(oscillators[0]?.frequency.value).toBe(1200);
+ expect(oscillators[0]?.type).toBe("square");
+ expect(oscillators[0]?.start).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), {
+ target: { value: "1.25" },
+ });
+ expect(oscillators).toHaveLength(1);
+
+ act(() => {
+ vi.advanceTimersByTime(79);
+ });
+ expect(oscillators).toHaveLength(1);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(oscillators).toHaveLength(2);
+ expect(oscillators[1]?.frequency.value).toBe(800);
+
+ fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
+ expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2);
+});
From e21a6cc9bc52d126490d5c6201a189c334bd0fb8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 16:42:31 +0900
Subject: [PATCH 096/153] feat(player): sound transport count-in beats
---
.../workspace/rehearsalCountInClick.ts | 172 ++++++++++++++++++
1 file changed, 172 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/rehearsalCountInClick.ts
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
new file mode 100644
index 000000000..6e25d4661
--- /dev/null
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
@@ -0,0 +1,172 @@
+export type RehearsalCountInOscillator = {
+ connect: (destination: unknown) => void;
+ disconnect: () => void;
+ frequency: { value: number };
+ onended: (() => void) | null;
+ start: (when?: number) => void;
+ stop: (when?: number) => void;
+ type: string;
+};
+
+export type RehearsalCountInGain = {
+ connect: (destination: unknown) => RehearsalCountInGain;
+ disconnect: () => void;
+ gain: {
+ exponentialRampToValueAtTime: (value: number, when: number) => void;
+ setValueAtTime: (value: number, when: number) => void;
+ };
+};
+
+export type RehearsalCountInAudioContext = {
+ createGain: () => RehearsalCountInGain;
+ createOscillator: () => RehearsalCountInOscillator;
+ currentTime: number;
+ destination: unknown;
+ resume: () => Promise;
+ state: string;
+};
+
+export type RehearsalCountInContextFactory = () => RehearsalCountInAudioContext;
+
+export type RehearsalCountInClickEngine = {
+ available: boolean;
+ click: (accent: boolean) => Promise;
+ stop: () => void;
+};
+
+const ACCENT_FREQUENCY_HZ = 1200;
+const TAP_FREQUENCY_HZ = 800;
+const CLICK_SECONDS = 0.05;
+const CLICK_LEAD_SECONDS = 0.005;
+
+type LiveClickNode = {
+ gain: RehearsalCountInGain;
+ oscillator: RehearsalCountInOscillator;
+};
+
+/** Return the host Web Audio factory used only for the local count-in click. */
+export function defaultRehearsalCountInContextFactory(): RehearsalCountInContextFactory | null {
+ const AudioContextCtor =
+ typeof window === "undefined"
+ ? undefined
+ : window.AudioContext ??
+ (window as Window & { webkitAudioContext?: typeof AudioContext })
+ .webkitAudioContext;
+ if (!AudioContextCtor) {
+ return null;
+ }
+ return () => new AudioContextCtor() as unknown as RehearsalCountInAudioContext;
+}
+
+/** Create a bounded local click engine owned by the active-player transport. */
+export function createRehearsalCountInClickEngine(
+ contextFactory: RehearsalCountInContextFactory | null =
+ defaultRehearsalCountInContextFactory(),
+): RehearsalCountInClickEngine {
+ let context: RehearsalCountInAudioContext | null = null;
+ let playbackGeneration = 0;
+ const liveNodes = new Set();
+
+ /** Release one tracked click node without retaining audio-graph authority. */
+ const releaseNode = (node: LiveClickNode, stopOscillator: boolean): void => {
+ liveNodes.delete(node);
+ if (stopOscillator) {
+ try {
+ node.oscillator.stop();
+ } catch {
+ // An already-ended oscillator may reject a second stop during teardown.
+ }
+ }
+ try {
+ node.oscillator.disconnect();
+ } catch {
+ // Disconnect remains best-effort after the browser releases a node.
+ }
+ try {
+ node.gain.disconnect();
+ } catch {
+ // Gain cleanup must not keep a stale transport alive.
+ }
+ };
+
+ /** Invalidate pending resume work and release every active click node. */
+ const stop = (): void => {
+ playbackGeneration += 1;
+ for (const node of [...liveNodes]) {
+ releaseNode(node, true);
+ }
+ };
+
+ return {
+ available: contextFactory !== null,
+ /** Sound exactly one admitted transport beat, accented only at count-in start. */
+ async click(accent: boolean): Promise {
+ if (!contextFactory) {
+ throw new Error("Rehearsal count-in click is unavailable.");
+ }
+ const generation = playbackGeneration;
+ if (!context) {
+ context = contextFactory();
+ }
+ if (context.state === "suspended") {
+ await context.resume();
+ }
+ if (generation !== playbackGeneration) {
+ return;
+ }
+
+ let oscillator: RehearsalCountInOscillator | null = null;
+ let gain: RehearsalCountInGain | null = null;
+ let node: LiveClickNode | null = null;
+ try {
+ oscillator = context.createOscillator();
+ gain = context.createGain();
+ node = { gain, oscillator };
+ liveNodes.add(node);
+ const when = context.currentTime + CLICK_LEAD_SECONDS;
+ oscillator.type = "square";
+ oscillator.frequency.value = accent
+ ? ACCENT_FREQUENCY_HZ
+ : TAP_FREQUENCY_HZ;
+ gain.gain.setValueAtTime(0.0001, when);
+ gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002);
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS);
+ oscillator.connect(gain);
+ gain.connect(context.destination);
+ oscillator.onended = () => {
+ if (node) {
+ releaseNode(node, false);
+ }
+ };
+ oscillator.start(when);
+ oscillator.stop(when + CLICK_SECONDS + 0.01);
+ } catch (error) {
+ if (node) {
+ releaseNode(node, true);
+ } else {
+ if (oscillator) {
+ try {
+ oscillator.stop();
+ } catch {
+ // Partial construction still releases every acquired node.
+ }
+ try {
+ oscillator.disconnect();
+ } catch {
+ // Partial construction cleanup is best-effort.
+ }
+ }
+ if (gain) {
+ try {
+ gain.disconnect();
+ } catch {
+ // Partial construction cleanup is best-effort.
+ }
+ }
+ }
+ throw error;
+ }
+ },
+ stop,
+ };
+}
From 07c001c728a69ba1807d21a549b184694e9b150b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 16:43:11 +0900
Subject: [PATCH 097/153] test(player): cover audible count-in engine
---
.../workspace/rehearsalCountInClick.test.ts | 209 ++++++++++++++++++
1 file changed, 209 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
new file mode 100644
index 000000000..466ae340a
--- /dev/null
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
@@ -0,0 +1,209 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ createRehearsalCountInClickEngine,
+ defaultRehearsalCountInContextFactory,
+ type RehearsalCountInAudioContext,
+ type RehearsalCountInGain,
+ type RehearsalCountInOscillator,
+} from "./rehearsalCountInClick";
+
+function createFakeContext(): {
+ context: RehearsalCountInAudioContext;
+ gains: RehearsalCountInGain[];
+ oscillators: RehearsalCountInOscillator[];
+} {
+ const gains: RehearsalCountInGain[] = [];
+ const oscillators: RehearsalCountInOscillator[] = [];
+ const context: RehearsalCountInAudioContext = {
+ createGain: () => {
+ const gain: RehearsalCountInGain = {
+ connect: () => gain,
+ disconnect: vi.fn(),
+ gain: {
+ exponentialRampToValueAtTime: vi.fn(),
+ setValueAtTime: vi.fn(),
+ },
+ };
+ gains.push(gain);
+ return gain;
+ },
+ createOscillator: () => {
+ const oscillator: RehearsalCountInOscillator = {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ frequency: { value: 0 },
+ onended: null,
+ start: vi.fn(),
+ stop: vi.fn(),
+ type: "sine",
+ };
+ oscillators.push(oscillator);
+ return oscillator;
+ },
+ currentTime: 1,
+ destination: {},
+ resume: vi.fn(async () => undefined),
+ state: "running",
+ };
+ return { context, gains, oscillators };
+}
+
+describe("defaultRehearsalCountInContextFactory", () => {
+ const originalAudioContext = Object.getOwnPropertyDescriptor(
+ window,
+ "AudioContext",
+ );
+
+ afterEach(() => {
+ if (originalAudioContext) {
+ Object.defineProperty(window, "AudioContext", originalAudioContext);
+ } else {
+ Reflect.deleteProperty(window, "AudioContext");
+ }
+ Reflect.deleteProperty(window, "webkitAudioContext");
+ });
+
+ it("is unavailable when the host exposes no Web Audio constructor", () => {
+ Object.defineProperty(window, "AudioContext", {
+ configurable: true,
+ writable: true,
+ value: undefined,
+ });
+ expect(defaultRehearsalCountInContextFactory()).toBeNull();
+ });
+
+ it("constructs the standard AudioContext when present", () => {
+ class FakeAudioContext {
+ currentTime = 0;
+ }
+ Object.defineProperty(window, "AudioContext", {
+ configurable: true,
+ writable: true,
+ value: FakeAudioContext,
+ });
+ const factory = defaultRehearsalCountInContextFactory();
+ expect(factory).not.toBeNull();
+ expect(factory?.()).toBeInstanceOf(FakeAudioContext);
+ });
+
+ it("falls back to webkitAudioContext when required by the host", () => {
+ class FakeWebkitAudioContext {
+ currentTime = 0;
+ }
+ Object.defineProperty(window, "AudioContext", {
+ configurable: true,
+ writable: true,
+ value: undefined,
+ });
+ Object.defineProperty(window, "webkitAudioContext", {
+ configurable: true,
+ writable: true,
+ value: FakeWebkitAudioContext,
+ });
+ const factory = defaultRehearsalCountInContextFactory();
+ expect(factory).not.toBeNull();
+ expect(factory?.()).toBeInstanceOf(FakeWebkitAudioContext);
+ });
+});
+
+describe("createRehearsalCountInClickEngine", () => {
+ it("rejects a click when the host has no Web Audio boundary", async () => {
+ const engine = createRehearsalCountInClickEngine(null);
+ expect(engine.available).toBe(false);
+ await expect(engine.click(true)).rejects.toThrow(/unavailable/i);
+ });
+
+ it("sounds accent and tap frequencies through one retained context", async () => {
+ const { context, gains, oscillators } = createFakeContext();
+ const factory = vi.fn(() => context);
+ const engine = createRehearsalCountInClickEngine(factory);
+
+ await engine.click(true);
+ await engine.click(false);
+
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(oscillators).toHaveLength(2);
+ expect(oscillators[0]?.frequency.value).toBe(1200);
+ expect(oscillators[1]?.frequency.value).toBe(800);
+ expect(oscillators[0]?.type).toBe("square");
+ expect(oscillators[0]?.start).toHaveBeenCalledTimes(1);
+ expect(oscillators[0]?.stop).toHaveBeenCalledTimes(1);
+ expect(gains[0]?.gain.setValueAtTime).toHaveBeenCalled();
+ expect(gains[0]?.gain.exponentialRampToValueAtTime).toHaveBeenCalledTimes(2);
+
+ oscillators[0]?.onended?.();
+ expect(oscillators[0]?.disconnect).toHaveBeenCalledTimes(1);
+ expect(gains[0]?.disconnect).toHaveBeenCalledTimes(1);
+
+ engine.stop();
+ expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2);
+ expect(oscillators[1]?.disconnect).toHaveBeenCalledTimes(1);
+ expect(gains[1]?.disconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not create a click after stop invalidates a pending resume", async () => {
+ const { context, oscillators } = createFakeContext();
+ context.state = "suspended";
+ let finishResume: (() => void) | undefined;
+ context.resume = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finishResume = resolve;
+ }),
+ );
+ const engine = createRehearsalCountInClickEngine(() => context);
+
+ const clickPromise = engine.click(true);
+ await Promise.resolve();
+ expect(context.resume).toHaveBeenCalledTimes(1);
+ engine.stop();
+ finishResume?.();
+ await clickPromise;
+
+ expect(oscillators).toHaveLength(0);
+ });
+
+ it("contains graph-construction failure and releases acquired nodes", async () => {
+ const { context, oscillators } = createFakeContext();
+ context.createGain = () => {
+ throw new Error("gain failed");
+ };
+ const engine = createRehearsalCountInClickEngine(() => context);
+
+ await expect(engine.click(true)).rejects.toThrow("gain failed");
+ expect(oscillators[0]?.stop).toHaveBeenCalledTimes(1);
+ expect(oscillators[0]?.disconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it("contains node configuration failure and tolerates teardown errors", async () => {
+ const { context, gains, oscillators } = createFakeContext();
+ const baseCreateGain = context.createGain;
+ context.createGain = () => {
+ const gain = baseCreateGain();
+ gain.gain.setValueAtTime = () => {
+ throw new Error("envelope failed");
+ };
+ gain.disconnect = () => {
+ throw new Error("gain already released");
+ };
+ return gain;
+ };
+ const baseCreateOscillator = context.createOscillator;
+ context.createOscillator = () => {
+ const oscillator = baseCreateOscillator();
+ oscillator.stop = () => {
+ throw new Error("oscillator already stopped");
+ };
+ oscillator.disconnect = () => {
+ throw new Error("oscillator already released");
+ };
+ return oscillator;
+ };
+ const engine = createRehearsalCountInClickEngine(() => context);
+
+ await expect(engine.click(false)).rejects.toThrow("envelope failed");
+ expect(oscillators).toHaveLength(1);
+ expect(gains).toHaveLength(1);
+ expect(() => engine.stop()).not.toThrow();
+ });
+});
From a3bc430bf310a89052c291d9748f46ef1951d5cb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 16:43:44 +0900
Subject: [PATCH 098/153] refactor(player): bound count-in node cleanup
---
.../workspace/rehearsalCountInClick.ts | 62 +++++++++----------
1 file changed, 28 insertions(+), 34 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
index 6e25d4661..791ae766b 100644
--- a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
@@ -89,6 +89,22 @@ export function createRehearsalCountInClickEngine(
}
};
+ /** Release an oscillator acquired before gain-node construction completed. */
+ const releasePartialOscillator = (
+ oscillator: RehearsalCountInOscillator,
+ ): void => {
+ try {
+ oscillator.stop();
+ } catch {
+ // Partial construction still releases every acquired node.
+ }
+ try {
+ oscillator.disconnect();
+ } catch {
+ // Partial construction cleanup is best-effort.
+ }
+ };
+
/** Invalidate pending resume work and release every active click node. */
const stop = (): void => {
playbackGeneration += 1;
@@ -115,14 +131,18 @@ export function createRehearsalCountInClickEngine(
return;
}
- let oscillator: RehearsalCountInOscillator | null = null;
- let gain: RehearsalCountInGain | null = null;
- let node: LiveClickNode | null = null;
+ const oscillator = context.createOscillator();
+ let gain: RehearsalCountInGain;
try {
- oscillator = context.createOscillator();
gain = context.createGain();
- node = { gain, oscillator };
- liveNodes.add(node);
+ } catch (error) {
+ releasePartialOscillator(oscillator);
+ throw error;
+ }
+
+ const node = { gain, oscillator };
+ liveNodes.add(node);
+ try {
const when = context.currentTime + CLICK_LEAD_SECONDS;
oscillator.type = "square";
oscillator.frequency.value = accent
@@ -133,37 +153,11 @@ export function createRehearsalCountInClickEngine(
gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS);
oscillator.connect(gain);
gain.connect(context.destination);
- oscillator.onended = () => {
- if (node) {
- releaseNode(node, false);
- }
- };
+ oscillator.onended = () => releaseNode(node, false);
oscillator.start(when);
oscillator.stop(when + CLICK_SECONDS + 0.01);
} catch (error) {
- if (node) {
- releaseNode(node, true);
- } else {
- if (oscillator) {
- try {
- oscillator.stop();
- } catch {
- // Partial construction still releases every acquired node.
- }
- try {
- oscillator.disconnect();
- } catch {
- // Partial construction cleanup is best-effort.
- }
- }
- if (gain) {
- try {
- gain.disconnect();
- } catch {
- // Partial construction cleanup is best-effort.
- }
- }
- }
+ releaseNode(node, true);
throw error;
}
},
From effb2376cf952ffbfc10f92b5612eed75d4c5056 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 16:47:53 +0900
Subject: [PATCH 099/153] feat(player): integrate audible count-in
---
.../features/workspace/RehearsalPlayer.tsx | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index f7f68a9d1..b9572a391 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -20,6 +20,10 @@ import {
detectPreferredLocale,
type TranslationKey,
} from "../../i18n";
+import {
+ createRehearsalCountInClickEngine,
+ type RehearsalCountInClickEngine,
+} from "./rehearsalCountInClick";
import {
beatDurationMs,
createIdleTransportState,
@@ -178,8 +182,13 @@ export function RehearsalPlayer({
loop: playableLoops[0] ?? null,
}),
);
+ const countInClickEngine = useMemo(
+ () => createRehearsalCountInClickEngine(),
+ [],
+ );
const lastHandledStartNonce = useRef(0);
const restartAudioOnLoopRef = useRef(false);
+ const lastCountInClickKeyRef = useRef(null);
const countInBeatRef = useRef<{
durationMs: number;
startedAt: number;
@@ -202,6 +211,13 @@ export function RehearsalPlayer({
);
const [playbackError, setPlaybackError] = useState(false);
+ useEffect(
+ () => () => {
+ countInClickEngine.stop();
+ },
+ [countInClickEngine],
+ );
+
const handlePlaybackError = useCallback(() => {
playbackIntentRef.current = "inactive";
setPlaybackError(true);
@@ -366,6 +382,8 @@ export function RehearsalPlayer({
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
countInBeatRef.current = null;
+ lastCountInClickKeyRef.current = null;
+ countInClickEngine.stop();
return undefined;
}
const durationMs =
@@ -380,6 +398,18 @@ export function RehearsalPlayer({
const progress = sameBeat
? Math.min(1, previous.progress + elapsedProgress)
: 0;
+ const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`;
+ if (
+ countInClickEngine.available &&
+ lastCountInClickKeyRef.current !== currentClickKey
+ ) {
+ lastCountInClickKeyRef.current = currentClickKey;
+ void countInClickEngine
+ .click(transport.countInRemainingBeats === transport.loop.countInBeats)
+ .catch(() => {
+ // Count-in click failure must not gain authority over admitted song playback.
+ });
+ }
countInBeatRef.current = {
durationMs,
startedAt: now,
@@ -398,6 +428,16 @@ export function RehearsalPlayer({
current.progress = 0;
setTransport((state) => reduceRehearsalTransport(state, { type: "beat" }));
if (current.remainingBeats > 0) {
+ const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`;
+ if (
+ countInClickEngine.available &&
+ lastCountInClickKeyRef.current !== nextClickKey
+ ) {
+ lastCountInClickKeyRef.current = nextClickKey;
+ void countInClickEngine.click(false).catch(() => {
+ // The transport remains authoritative when Web Audio is unavailable.
+ });
+ }
current.startedAt = performance.now();
scheduleBeat(current.durationMs);
}
@@ -410,6 +450,7 @@ export function RehearsalPlayer({
}
};
}, [
+ countInClickEngine,
transport.loop,
transport.phase,
transport.playbackRate,
From dca1cfaab1a907eda4bcba755b3018061e1df0a7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 18:47:47 +0900
Subject: [PATCH 100/153] test(player): require count-in context disposal
---
.../RehearsalPlayer.audible-count-in.test.tsx | 32 +++++++++++++++++--
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
index d802a6ac4..645dff1fb 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
@@ -24,8 +24,14 @@ type FakeOscillator = {
type: OscillatorType;
};
-function installPlayableAudioMocks(): FakeOscillator[] {
+type PlayableAudioMocks = {
+ closeAudioContext: ReturnType;
+ oscillators: FakeOscillator[];
+};
+
+function installPlayableAudioMocks(): PlayableAudioMocks {
const oscillators: FakeOscillator[] = [];
+ const closeAudioContext = vi.fn(async () => undefined);
Object.defineProperty(navigator, "language", {
configurable: true,
value: "en-US",
@@ -45,6 +51,7 @@ function installPlayableAudioMocks(): FakeOscillator[] {
destination = {};
state: AudioContextState = "running";
+ close = closeAudioContext;
resume = vi.fn(async () => undefined);
createGain() {
@@ -79,7 +86,7 @@ function installPlayableAudioMocks(): FakeOscillator[] {
writable: true,
value: FakeAudioContext,
});
- return oscillators;
+ return { closeAudioContext, oscillators };
}
afterEach(() => {
@@ -104,7 +111,7 @@ afterEach(() => {
it("sounds the transport count-in without replaying a beat when playback rate changes", () => {
vi.useFakeTimers();
- const oscillators = installPlayableAudioMocks();
+ const { oscillators } = installPlayableAudioMocks();
const song = createDemoRehearsalSong();
render(
@@ -144,3 +151,22 @@ it("sounds the transport count-in without replaying a beat when playback rate ch
fireEvent.click(screen.getByRole("button", { name: /Pause/i }));
expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2);
});
+
+it("closes the count-in audio context when the mounted player unmounts", () => {
+ const { closeAudioContext, oscillators } = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ const { unmount } = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i }));
+ expect(oscillators).toHaveLength(1);
+
+ unmount();
+
+ expect(closeAudioContext).toHaveBeenCalledTimes(1);
+});
From 2aa27c664e8839d7efd834bfcf465550ff4db251 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 18:48:31 +0900
Subject: [PATCH 101/153] fix(player): dispose count-in audio context
---
.../workspace/rehearsalCountInClick.ts | 47 ++++++++++++++-----
1 file changed, 35 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
index 791ae766b..776e4ec14 100644
--- a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
@@ -18,6 +18,7 @@ export type RehearsalCountInGain = {
};
export type RehearsalCountInAudioContext = {
+ close: () => Promise;
createGain: () => RehearsalCountInGain;
createOscillator: () => RehearsalCountInOscillator;
currentTime: number;
@@ -31,6 +32,7 @@ export type RehearsalCountInContextFactory = () => RehearsalCountInAudioContext;
export type RehearsalCountInClickEngine = {
available: boolean;
click: (accent: boolean) => Promise;
+ dispose: () => Promise;
stop: () => void;
};
@@ -64,6 +66,7 @@ export function createRehearsalCountInClickEngine(
defaultRehearsalCountInContextFactory(),
): RehearsalCountInClickEngine {
let context: RehearsalCountInAudioContext | null = null;
+ let disposed = false;
let playbackGeneration = 0;
const liveNodes = new Set();
@@ -113,28 +116,47 @@ export function createRehearsalCountInClickEngine(
}
};
+ /** Permanently release the mounted player's Web Audio authority. */
+ const dispose = async (): Promise => {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ stop();
+ const closingContext = context;
+ context = null;
+ if (!closingContext) {
+ return;
+ }
+ try {
+ await closingContext.close();
+ } catch {
+ // Browser teardown failures must not keep an unmounted player alive.
+ }
+ };
+
return {
- available: contextFactory !== null,
+ get available(): boolean {
+ return contextFactory !== null && !disposed;
+ },
/** Sound exactly one admitted transport beat, accented only at count-in start. */
async click(accent: boolean): Promise {
- if (!contextFactory) {
+ if (!contextFactory || disposed) {
throw new Error("Rehearsal count-in click is unavailable.");
}
const generation = playbackGeneration;
- if (!context) {
- context = contextFactory();
- }
- if (context.state === "suspended") {
- await context.resume();
+ const activeContext = context ?? (context = contextFactory());
+ if (activeContext.state === "suspended") {
+ await activeContext.resume();
}
- if (generation !== playbackGeneration) {
+ if (generation !== playbackGeneration || disposed) {
return;
}
- const oscillator = context.createOscillator();
+ const oscillator = activeContext.createOscillator();
let gain: RehearsalCountInGain;
try {
- gain = context.createGain();
+ gain = activeContext.createGain();
} catch (error) {
releasePartialOscillator(oscillator);
throw error;
@@ -143,7 +165,7 @@ export function createRehearsalCountInClickEngine(
const node = { gain, oscillator };
liveNodes.add(node);
try {
- const when = context.currentTime + CLICK_LEAD_SECONDS;
+ const when = activeContext.currentTime + CLICK_LEAD_SECONDS;
oscillator.type = "square";
oscillator.frequency.value = accent
? ACCENT_FREQUENCY_HZ
@@ -152,7 +174,7 @@ export function createRehearsalCountInClickEngine(
gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002);
gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS);
oscillator.connect(gain);
- gain.connect(context.destination);
+ gain.connect(activeContext.destination);
oscillator.onended = () => releaseNode(node, false);
oscillator.start(when);
oscillator.stop(when + CLICK_SECONDS + 0.01);
@@ -161,6 +183,7 @@ export function createRehearsalCountInClickEngine(
throw error;
}
},
+ dispose,
stop,
};
}
From dfb8519412f99fdabe5815068766702e006f70ee Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 21:22:19 +0900
Subject: [PATCH 102/153] test(player): cover count-in disposal during pending
resume
---
.../workspace/rehearsalCountInClick.test.ts | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
index 466ae340a..afcfe1bfa 100644
--- a/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts
@@ -15,6 +15,7 @@ function createFakeContext(): {
const gains: RehearsalCountInGain[] = [];
const oscillators: RehearsalCountInOscillator[] = [];
const context: RehearsalCountInAudioContext = {
+ close: vi.fn(async () => undefined),
createGain: () => {
const gain: RehearsalCountInGain = {
connect: () => gain,
@@ -163,6 +164,35 @@ describe("createRehearsalCountInClickEngine", () => {
expect(oscillators).toHaveLength(0);
});
+ it("closes the context and invalidates pending resume work during disposal", async () => {
+ const { context, oscillators } = createFakeContext();
+ context.state = "suspended";
+ let finishResume: (() => void) | undefined;
+ context.resume = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finishResume = resolve;
+ }),
+ );
+ const engine = createRehearsalCountInClickEngine(() => context);
+
+ const clickPromise = engine.click(true);
+ await Promise.resolve();
+ expect(context.resume).toHaveBeenCalledTimes(1);
+
+ await engine.dispose();
+ expect(context.close).toHaveBeenCalledTimes(1);
+ expect(engine.available).toBe(false);
+
+ finishResume?.();
+ await clickPromise;
+ expect(oscillators).toHaveLength(0);
+
+ await engine.dispose();
+ expect(context.close).toHaveBeenCalledTimes(1);
+ await expect(engine.click(false)).rejects.toThrow(/unavailable/i);
+ });
+
it("contains graph-construction failure and releases acquired nodes", async () => {
const { context, oscillators } = createFakeContext();
context.createGain = () => {
From 5d5b102f34cc2ccd05b431fd6cac7972f31d9902 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 21:24:23 +0900
Subject: [PATCH 103/153] fix(player): dispose count-in context on unmount
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index b9572a391..e7133f0d2 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -213,7 +213,7 @@ export function RehearsalPlayer({
useEffect(
() => () => {
- countInClickEngine.stop();
+ void countInClickEngine.dispose();
},
[countInClickEngine],
);
From cb9c347209b04acceb0f220992f9b7529278bd86 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 22:15:45 +0900
Subject: [PATCH 104/153] test(player): reproduce Strict Mode count-in disposal
regression
---
.../RehearsalPlayer.audible-count-in.test.tsx | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
index 645dff1fb..c1f72c569 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx
@@ -1,3 +1,4 @@
+import { StrictMode } from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, expect, it, vi } from "vitest";
@@ -152,6 +153,26 @@ it("sounds the transport count-in without replaying a beat when playback rate ch
expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2);
});
+it("keeps audible count-in available across Strict Mode effect replay", () => {
+ const { closeAudioContext, oscillators } = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i }));
+
+ expect(oscillators).toHaveLength(1);
+ unmount();
+ expect(closeAudioContext).toHaveBeenCalledTimes(1);
+});
+
it("closes the count-in audio context when the mounted player unmounts", () => {
const { closeAudioContext, oscillators } = installPlayableAudioMocks();
const song = createDemoRehearsalSong();
@@ -169,4 +190,4 @@ it("closes the count-in audio context when the mounted player unmounts", () => {
unmount();
expect(closeAudioContext).toHaveBeenCalledTimes(1);
-});
+});
\ No newline at end of file
From f2605475fdf06f6e5c37419267202c6028de6dfb Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 22:21:03 +0900
Subject: [PATCH 105/153] fix(player): preserve count-in through Strict Mode
effect replay
---
.../features/workspace/rehearsalCountInClick.ts | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
index 776e4ec14..ee01d746e 100644
--- a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
+++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts
@@ -1,13 +1,16 @@
+/** Oscillator operations the count-in engine owns across browser and test contexts. */
export type RehearsalCountInOscillator = {
connect: (destination: unknown) => void;
disconnect: () => void;
frequency: { value: number };
+ /** Release the retained graph node when the browser reports oscillator completion. */
onended: (() => void) | null;
start: (when?: number) => void;
stop: (when?: number) => void;
type: string;
};
+/** Gain-envelope operations needed to shape one bounded count-in click. */
export type RehearsalCountInGain = {
connect: (destination: unknown) => RehearsalCountInGain;
disconnect: () => void;
@@ -17,6 +20,7 @@ export type RehearsalCountInGain = {
};
};
+/** Minimal Web Audio context authority retained by one mounted rehearsal player. */
export type RehearsalCountInAudioContext = {
close: () => Promise;
createGain: () => RehearsalCountInGain;
@@ -27,8 +31,10 @@ export type RehearsalCountInAudioContext = {
state: string;
};
+/** Lazily construct the browser audio context only when a count-in first sounds. */
export type RehearsalCountInContextFactory = () => RehearsalCountInAudioContext;
+/** Count-in lifecycle port: stop is reusable while dispose is terminal after acquisition. */
export type RehearsalCountInClickEngine = {
available: boolean;
click: (accent: boolean) => Promise;
@@ -116,18 +122,19 @@ export function createRehearsalCountInClickEngine(
}
};
- /** Permanently release the mounted player's Web Audio authority. */
+ /** Permanently release Web Audio authority once this engine has acquired it. */
const dispose = async (): Promise => {
if (disposed) {
return;
}
- disposed = true;
- stop();
const closingContext = context;
- context = null;
if (!closingContext) {
+ // React Strict Mode replays effect cleanup before lazy audio acquisition.
return;
}
+ disposed = true;
+ stop();
+ context = null;
try {
await closingContext.close();
} catch {
@@ -136,6 +143,7 @@ export function createRehearsalCountInClickEngine(
};
return {
+ /** Report whether this effect-lifetime engine can still sound a local click. */
get available(): boolean {
return contextFactory !== null && !disposed;
},
@@ -175,6 +183,7 @@ export function createRehearsalCountInClickEngine(
gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS);
oscillator.connect(gain);
gain.connect(activeContext.destination);
+ /** Release this node after the bounded oscillator has naturally ended. */
oscillator.onended = () => releaseNode(node, false);
oscillator.start(when);
oscillator.stop(when + CLICK_SECONDS + 0.01);
From c7e53f761445d9acb947d62a1af63a074d23088f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 22:51:32 +0900
Subject: [PATCH 106/153] test(workspace): expose missing loop roadmap
selection wiring
---
.../Workspace.loop-roadmap-sync.test.tsx | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
diff --git a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
new file mode 100644
index 000000000..5d31ccd5d
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
@@ -0,0 +1,35 @@
+import { fireEvent, render } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+describe("Workspace loop roadmap synchronization", () => {
+ it("projects the player-selected section onto the production roadmap", () => {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ song.sections = [verse, chorus];
+
+ render( );
+
+ const sectionButtons = Array.from(
+ document.querySelectorAll(
+ 'button[id^="rehearsal-loop-section-"]',
+ ),
+ );
+ const verseCard = document.getElementById("workspace-section-card-0");
+ const chorusCard = document.getElementById("workspace-section-card-1");
+
+ expect(sectionButtons).toHaveLength(2);
+ expect(verseCard?.className).toContain("ring-cyan-300/70");
+ expect(chorusCard?.className).not.toContain("ring-cyan-300/70");
+
+ fireEvent.click(sectionButtons[1]!);
+
+ expect(verseCard?.className).not.toContain("ring-cyan-300/70");
+ expect(chorusCard?.className).toContain("ring-cyan-300/70");
+ });
+});
From 27a4a589c7a65f8699aab7a5e291e2c2143ec1c8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 22:56:39 +0900
Subject: [PATCH 107/153] chore(ci): stage bounded PR 971 roadmap wiring repair
---
.../checks/_temp_pr971_wire_loop_roadmap.py | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 scripts/checks/_temp_pr971_wire_loop_roadmap.py
diff --git a/scripts/checks/_temp_pr971_wire_loop_roadmap.py b/scripts/checks/_temp_pr971_wire_loop_roadmap.py
new file mode 100644
index 000000000..3564919ba
--- /dev/null
+++ b/scripts/checks/_temp_pr971_wire_loop_roadmap.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Apply the bounded PR #971 player-selection projection repair.
+
+The Active Player remains the single selection authority. This transition
+helper only projects its admitted source index through Workspace to the sibling
+SectionRoadmap, then is removed by its one-shot workflow after executable
+verification succeeds.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+PLAYER_PATH = Path("apps/desktop/src/features/workspace/RehearsalPlayer.tsx")
+WORKSPACE_PATH = Path("apps/desktop/src/features/workspace/Workspace.tsx")
+
+
+def replace_once(source: str, old: str, new: str, label: str) -> str:
+ """Replace exactly one expected source fragment and fail closed on drift."""
+ count = source.count(old)
+ if count != 1:
+ raise SystemExit(f"{label} drifted: expected one match, found {count}")
+ return source.replace(old, new, 1)
+
+
+def repair_player(source: str) -> str:
+ """Expose the selected admitted section index without duplicating transport state."""
+ source = replace_once(
+ source,
+ " onSongUpdate?: (song: RehearsalSong) => void;\n hasLocalAudio?: boolean;",
+ " onSongUpdate?: (song: RehearsalSong) => void;\n onSelectedSectionIndexChange?: (sectionIndex: number | null) => void;\n hasLocalAudio?: boolean;",
+ "player props",
+ )
+ source = replace_once(
+ source,
+ " song,\n onSongUpdate,\n hasLocalAudio = false,",
+ " song,\n onSongUpdate,\n onSelectedSectionIndexChange,\n hasLocalAudio = false,",
+ "player destructuring",
+ )
+ source = replace_once(
+ source,
+ " const selectedLoop =\n playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??\n playableLoops[0] ??\n null;\n const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;",
+ " const selectedLoop =\n playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??\n playableLoops[0] ??\n null;\n useEffect(() => {\n onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null);\n }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]);\n const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;",
+ "selected-loop projection",
+ )
+ return source
+
+
+def repair_workspace(source: str) -> str:
+ """Forward the player-owned selected source index to the roadmap projection."""
+ source = replace_once(
+ source,
+ " const [activeRole, setActiveRole] = useState(null);\n const [loopStartNonce, setLoopStartNonce] = useState(0);",
+ " const [activeRole, setActiveRole] = useState(null);\n const [loopStartNonce, setLoopStartNonce] = useState(0);\n const [loopedSectionIndex, setLoopedSectionIndex] = useState(null);",
+ "workspace projection state",
+ )
+ source = replace_once(
+ source,
+ " song={song}\n onSongUpdate={onSongUpdate}\n hasLocalAudio={hasLocalAudio}",
+ " song={song}\n onSongUpdate={onSongUpdate}\n onSelectedSectionIndexChange={setLoopedSectionIndex}\n hasLocalAudio={hasLocalAudio}",
+ "player callback wiring",
+ )
+ source = replace_once(
+ source,
+ " ",
+ " ",
+ "roadmap projection wiring",
+ )
+ return source
+
+
+def main() -> None:
+ """Patch only the two production files owning the missing sibling projection."""
+ player = PLAYER_PATH.read_text(encoding="utf-8")
+ workspace = WORKSPACE_PATH.read_text(encoding="utf-8")
+ PLAYER_PATH.write_text(repair_player(player), encoding="utf-8")
+ WORKSPACE_PATH.write_text(repair_workspace(workspace), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
From 762506bca0ee9c7cb1d3dde685cb2ad105d1549a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 2 Sep 2026 22:57:39 +0900
Subject: [PATCH 108/153] chore(ci): run bounded PR 971 roadmap wiring repair
---
.../_temp_pr971_wire_loop_roadmap.yml | 83 +++++++++++++++++++
1 file changed, 83 insertions(+)
create mode 100644 .github/workflows/_temp_pr971_wire_loop_roadmap.yml
diff --git a/.github/workflows/_temp_pr971_wire_loop_roadmap.yml b/.github/workflows/_temp_pr971_wire_loop_roadmap.yml
new file mode 100644
index 000000000..146622477
--- /dev/null
+++ b/.github/workflows/_temp_pr971_wire_loop_roadmap.yml
@@ -0,0 +1,83 @@
+name: Temporary PR 971 loop roadmap wiring repair
+
+on:
+ push:
+ branches:
+ - feat/rehearsal-player-first-section-loop
+ paths:
+ - .github/workflows/_temp_pr971_wire_loop_roadmap.yml
+ - scripts/checks/_temp_pr971_wire_loop_roadmap.py
+
+permissions:
+ contents: write
+
+concurrency:
+ group: temp-pr971-loop-roadmap-wiring
+ cancel-in-progress: true
+
+jobs:
+ repair:
+ if: github.actor_id == '8172694'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout exact writer head without persisted write credential
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Set up repository Node runtime
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "22.22.3"
+ package-manager-cache: false
+ - name: Activate repository-pinned npm
+ shell: bash
+ run: |
+ set -euo pipefail
+ corepack enable npm
+ test "$(npm --version)" = '10.9.9'
+ npm run check:npm-runtime
+ - name: Verify live writer head
+ shell: bash
+ run: |
+ set -euo pipefail
+ git fetch origin feat/rehearsal-player-first-section-loop
+ test "$(git rev-parse origin/feat/rehearsal-player-first-section-loop)" = "$GITHUB_SHA"
+ - name: Apply bounded production repair
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/checks/_temp_pr971_wire_loop_roadmap.py
+ git diff --check
+ test "$(git diff --name-only | sort)" = $'apps/desktop/src/features/workspace/RehearsalPlayer.tsx\napps/desktop/src/features/workspace/Workspace.tsx'
+ - name: Install frozen JavaScript dependencies
+ shell: bash
+ run: npm ci --ignore-scripts --no-audit --no-fund
+ - name: Verify production sibling wiring and focused regression
+ shell: bash
+ run: |
+ set -euo pipefail
+ npm exec --workspace @bandscope/desktop -- vitest run src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
+ npm run typecheck --workspace @bandscope/desktop
+ npm run lint --workspace @bandscope/desktop -- --quiet
+ - name: Publish repair and remove transition machinery
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ git add \
+ apps/desktop/src/features/workspace/RehearsalPlayer.tsx \
+ apps/desktop/src/features/workspace/Workspace.tsx
+ git rm \
+ .github/workflows/_temp_pr971_wire_loop_roadmap.yml \
+ scripts/checks/_temp_pr971_wire_loop_roadmap.py
+ git diff --cached --check
+ test "$(git diff --cached --name-only | sort)" = $'.github/workflows/_temp_pr971_wire_loop_roadmap.yml\napps/desktop/src/features/workspace/RehearsalPlayer.tsx\napps/desktop/src/features/workspace/Workspace.tsx\nscripts/checks/_temp_pr971_wire_loop_roadmap.py'
+ git config user.name 'ContextualWisdomLab repair bot'
+ git config user.email 'actions@users.noreply.github.com'
+ git commit -m 'fix(workspace): project player loop selection to roadmap'
+ git fetch origin feat/rehearsal-player-first-section-loop
+ test "$(git rev-parse origin/feat/rehearsal-player-first-section-loop)" = "$GITHUB_SHA"
+ auth_header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
+ git -c http.https://github.com/.extraheader="$auth_header" push https://github.com/ContextualWisdomLab/bandscope.git HEAD:feat/rehearsal-player-first-section-loop
From 75328c311964ca9d44fd6e12c9554ce7181d3006 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 00:16:32 +0900
Subject: [PATCH 109/153] refactor(ci): remove self-modifying player repair
workflow
---
.../_temp_pr971_wire_loop_roadmap.yml | 83 -------------------
1 file changed, 83 deletions(-)
delete mode 100644 .github/workflows/_temp_pr971_wire_loop_roadmap.yml
diff --git a/.github/workflows/_temp_pr971_wire_loop_roadmap.yml b/.github/workflows/_temp_pr971_wire_loop_roadmap.yml
deleted file mode 100644
index 146622477..000000000
--- a/.github/workflows/_temp_pr971_wire_loop_roadmap.yml
+++ /dev/null
@@ -1,83 +0,0 @@
-name: Temporary PR 971 loop roadmap wiring repair
-
-on:
- push:
- branches:
- - feat/rehearsal-player-first-section-loop
- paths:
- - .github/workflows/_temp_pr971_wire_loop_roadmap.yml
- - scripts/checks/_temp_pr971_wire_loop_roadmap.py
-
-permissions:
- contents: write
-
-concurrency:
- group: temp-pr971-loop-roadmap-wiring
- cancel-in-progress: true
-
-jobs:
- repair:
- if: github.actor_id == '8172694'
- runs-on: ubuntu-latest
- steps:
- - name: Checkout exact writer head without persisted write credential
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- fetch-depth: 0
- persist-credentials: false
- - name: Set up repository Node runtime
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- with:
- node-version: "22.22.3"
- package-manager-cache: false
- - name: Activate repository-pinned npm
- shell: bash
- run: |
- set -euo pipefail
- corepack enable npm
- test "$(npm --version)" = '10.9.9'
- npm run check:npm-runtime
- - name: Verify live writer head
- shell: bash
- run: |
- set -euo pipefail
- git fetch origin feat/rehearsal-player-first-section-loop
- test "$(git rev-parse origin/feat/rehearsal-player-first-section-loop)" = "$GITHUB_SHA"
- - name: Apply bounded production repair
- shell: bash
- run: |
- set -euo pipefail
- python3 scripts/checks/_temp_pr971_wire_loop_roadmap.py
- git diff --check
- test "$(git diff --name-only | sort)" = $'apps/desktop/src/features/workspace/RehearsalPlayer.tsx\napps/desktop/src/features/workspace/Workspace.tsx'
- - name: Install frozen JavaScript dependencies
- shell: bash
- run: npm ci --ignore-scripts --no-audit --no-fund
- - name: Verify production sibling wiring and focused regression
- shell: bash
- run: |
- set -euo pipefail
- npm exec --workspace @bandscope/desktop -- vitest run src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
- npm run typecheck --workspace @bandscope/desktop
- npm run lint --workspace @bandscope/desktop -- --quiet
- - name: Publish repair and remove transition machinery
- shell: bash
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- git add \
- apps/desktop/src/features/workspace/RehearsalPlayer.tsx \
- apps/desktop/src/features/workspace/Workspace.tsx
- git rm \
- .github/workflows/_temp_pr971_wire_loop_roadmap.yml \
- scripts/checks/_temp_pr971_wire_loop_roadmap.py
- git diff --cached --check
- test "$(git diff --cached --name-only | sort)" = $'.github/workflows/_temp_pr971_wire_loop_roadmap.yml\napps/desktop/src/features/workspace/RehearsalPlayer.tsx\napps/desktop/src/features/workspace/Workspace.tsx\nscripts/checks/_temp_pr971_wire_loop_roadmap.py'
- git config user.name 'ContextualWisdomLab repair bot'
- git config user.email 'actions@users.noreply.github.com'
- git commit -m 'fix(workspace): project player loop selection to roadmap'
- git fetch origin feat/rehearsal-player-first-section-loop
- test "$(git rev-parse origin/feat/rehearsal-player-first-section-loop)" = "$GITHUB_SHA"
- auth_header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
- git -c http.https://github.com/.extraheader="$auth_header" push https://github.com/ContextualWisdomLab/bandscope.git HEAD:feat/rehearsal-player-first-section-loop
From 6a537f3ded31893a121fe20cb618ae83d103069b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 00:16:49 +0900
Subject: [PATCH 110/153] refactor(ci): remove temporary source repair helper
---
.../checks/_temp_pr971_wire_loop_roadmap.py | 82 -------------------
1 file changed, 82 deletions(-)
delete mode 100644 scripts/checks/_temp_pr971_wire_loop_roadmap.py
diff --git a/scripts/checks/_temp_pr971_wire_loop_roadmap.py b/scripts/checks/_temp_pr971_wire_loop_roadmap.py
deleted file mode 100644
index 3564919ba..000000000
--- a/scripts/checks/_temp_pr971_wire_loop_roadmap.py
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/usr/bin/env python3
-"""Apply the bounded PR #971 player-selection projection repair.
-
-The Active Player remains the single selection authority. This transition
-helper only projects its admitted source index through Workspace to the sibling
-SectionRoadmap, then is removed by its one-shot workflow after executable
-verification succeeds.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-
-PLAYER_PATH = Path("apps/desktop/src/features/workspace/RehearsalPlayer.tsx")
-WORKSPACE_PATH = Path("apps/desktop/src/features/workspace/Workspace.tsx")
-
-
-def replace_once(source: str, old: str, new: str, label: str) -> str:
- """Replace exactly one expected source fragment and fail closed on drift."""
- count = source.count(old)
- if count != 1:
- raise SystemExit(f"{label} drifted: expected one match, found {count}")
- return source.replace(old, new, 1)
-
-
-def repair_player(source: str) -> str:
- """Expose the selected admitted section index without duplicating transport state."""
- source = replace_once(
- source,
- " onSongUpdate?: (song: RehearsalSong) => void;\n hasLocalAudio?: boolean;",
- " onSongUpdate?: (song: RehearsalSong) => void;\n onSelectedSectionIndexChange?: (sectionIndex: number | null) => void;\n hasLocalAudio?: boolean;",
- "player props",
- )
- source = replace_once(
- source,
- " song,\n onSongUpdate,\n hasLocalAudio = false,",
- " song,\n onSongUpdate,\n onSelectedSectionIndexChange,\n hasLocalAudio = false,",
- "player destructuring",
- )
- source = replace_once(
- source,
- " const selectedLoop =\n playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??\n playableLoops[0] ??\n null;\n const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;",
- " const selectedLoop =\n playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??\n playableLoops[0] ??\n null;\n useEffect(() => {\n onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null);\n }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]);\n const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;",
- "selected-loop projection",
- )
- return source
-
-
-def repair_workspace(source: str) -> str:
- """Forward the player-owned selected source index to the roadmap projection."""
- source = replace_once(
- source,
- " const [activeRole, setActiveRole] = useState(null);\n const [loopStartNonce, setLoopStartNonce] = useState(0);",
- " const [activeRole, setActiveRole] = useState(null);\n const [loopStartNonce, setLoopStartNonce] = useState(0);\n const [loopedSectionIndex, setLoopedSectionIndex] = useState(null);",
- "workspace projection state",
- )
- source = replace_once(
- source,
- " song={song}\n onSongUpdate={onSongUpdate}\n hasLocalAudio={hasLocalAudio}",
- " song={song}\n onSongUpdate={onSongUpdate}\n onSelectedSectionIndexChange={setLoopedSectionIndex}\n hasLocalAudio={hasLocalAudio}",
- "player callback wiring",
- )
- source = replace_once(
- source,
- " ",
- " ",
- "roadmap projection wiring",
- )
- return source
-
-
-def main() -> None:
- """Patch only the two production files owning the missing sibling projection."""
- player = PLAYER_PATH.read_text(encoding="utf-8")
- workspace = WORKSPACE_PATH.read_text(encoding="utf-8")
- PLAYER_PATH.write_text(repair_player(player), encoding="utf-8")
- WORKSPACE_PATH.write_text(repair_workspace(workspace), encoding="utf-8")
-
-
-if __name__ == "__main__":
- main()
From cafe635f3f5cac9f4f37b231d64861d16cea1c9d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 00:19:16 +0900
Subject: [PATCH 111/153] fix(workspace): project player loop selection to
roadmap
---
apps/desktop/src/features/workspace/Workspace.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index ce269580a..8585b1590 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -123,6 +123,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState(null);
const [loopStartNonce, setLoopStartNonce] = useState(0);
+ const [loopedSectionIndex, setLoopedSectionIndex] = useState(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const parsedSourceBootstrap = useMemo(
() => safeProjectBootstrapSummary(sourceBootstrap),
@@ -376,6 +377,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
From dd8e136883e15f117cad5af3e4c78fb31f2fa288 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 00:21:41 +0900
Subject: [PATCH 112/153] fix(player): publish admitted loop selection to
workspace
---
apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index e7133f0d2..ab4fe68f1 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -42,6 +42,7 @@ import {
interface RehearsalPlayerProps {
song: RehearsalSong;
onSongUpdate?: (song: RehearsalSong) => void;
+ onSelectedSectionIndexChange?: (sectionIndex: number | null) => void;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
activeRole?: string | null;
@@ -112,6 +113,7 @@ function loopSelectionKey(loop: RehearsalLoopWindow): string {
export function RehearsalPlayer({
song,
onSongUpdate,
+ onSelectedSectionIndexChange,
hasLocalAudio = false,
audioSourcePath = null,
activeRole = null,
@@ -129,6 +131,9 @@ export function RehearsalPlayer({
playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
playableLoops[0] ??
null;
+ useEffect(() => {
+ onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null);
+ }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]);
const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;
const [boundaryDraft, setBoundaryDraft] = useState(() => ({
end: selectedLoop ? String(selectedLoop.endSeconds) : "",
From b449fa99cd37a7ff8e5e4feb40fbc34236b9379e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 04:06:36 +0900
Subject: [PATCH 113/153] test(workspace): isolate duplicate-section chord
edits
---
.../workspace/SectionRoadmap.test.tsx | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
index b59387fa9..1d87042f7 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
@@ -131,4 +131,35 @@ describe("SectionRoadmap", () => {
expect(second).toBeTruthy();
expect(second?.className).toContain("ring-cyan-300/70");
});
+
+ it("edits only the renderer-selected occurrence when analysis section and role ids collide", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const firstSection = song.sections[0]!;
+ const duplicate = {
+ ...firstSection,
+ roles: firstSection.roles.map((role) => ({
+ ...role,
+ harmony: { ...role.harmony },
+ manualOverrides: [...role.manualOverrides]
+ }))
+ };
+ song.sections = [firstSection, duplicate];
+ const onSongUpdate = vi.fn();
+ vi.spyOn(window, "prompt").mockReturnValue("Dm7");
+
+ render( );
+
+ const editButtons = screen.getAllByRole("button", {
+ name: "Edit chord for Bass Guitar in verse, current C#m7"
+ });
+ expect(editButtons).toHaveLength(2);
+ fireEvent.click(editButtons[1]!);
+
+ expect(onSongUpdate).toHaveBeenCalledTimes(1);
+ const updatedSong = onSongUpdate.mock.calls[0]![0];
+ expect(updatedSong.sections[0].roles[0].harmony.chord).toBe("C#m7");
+ expect(updatedSong.sections[1].roles[0].harmony.chord).toBe("Dm7");
+ expect(updatedSong.sections[1].roles[0].harmony.source).toBe("user");
+ });
});
From 375ede4481185768af8c88115e4ec807b1f2ad8a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 04:08:32 +0900
Subject: [PATCH 114/153] fix(workspace): scope chord edits to renderer
occurrence
---
.../src/features/workspace/SectionRoadmap.tsx | 48 +++++++++++--------
1 file changed, 28 insertions(+), 20 deletions(-)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 61ec249c8..c4d276cf2 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -29,26 +29,34 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
.replace("{chord}", role.harmony.chord);
};
- /** Documented. */
- const handleChordEdit = (sectionId: string, role: RehearsalRole) => {
+ /** Edit exactly one renderer-selected occurrence without treating analysis IDs as unique UI authority. */
+ const handleChordEdit = (
+ sectionIndex: number,
+ roleIndex: number,
+ sectionSnapshot: RehearsalSong["sections"][number],
+ roleSnapshot: RehearsalRole
+ ) => {
if (!onSongUpdate) return;
- const newChord = window.prompt(t("chordEditPrompt"), role.harmony.chord);
+
+ const currentSection = song.sections[sectionIndex];
+ const currentRole = currentSection?.roles[roleIndex];
+ if (currentSection !== sectionSnapshot || currentRole !== roleSnapshot) return;
+
+ const newChord = window.prompt(t("chordEditPrompt"), roleSnapshot.harmony.chord);
if (newChord === null) return;
const trimmedChord = newChord.trim();
- if (trimmedChord === "" || trimmedChord === role.harmony.chord) return;
+ if (trimmedChord === "" || trimmedChord === roleSnapshot.harmony.chord) return;
- let changed = false;
const updatedSong = {
...song,
- sections: song.sections.map((section) => {
- if (section.id !== sectionId) return section;
+ sections: song.sections.map((section, candidateSectionIndex) => {
+ if (candidateSectionIndex !== sectionIndex) return section;
return {
...section,
- roles: section.roles.map((targetRole) => {
- if (targetRole.id !== role.id) return targetRole;
- changed = true;
+ roles: section.roles.map((targetRole, candidateRoleIndex) => {
+ if (candidateRoleIndex !== roleIndex) return targetRole;
const harmony = {
...targetRole.harmony,
@@ -73,7 +81,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
})
};
- if (changed) onSongUpdate(updatedSong);
+ onSongUpdate(updatedSong);
};
/** Documented. */
const getPriorityColor = (priority: string) => {
@@ -130,13 +138,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
- {section.roles
- .filter(role => !activeRole || role.id === activeRole)
- .map(role => {
- const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote);
- return (
+ {section.roles.map((role, roleIndex) => {
+ if (activeRole && role.id !== activeRole) return null;
+
+ const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote);
+ return (
@@ -171,7 +179,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
? "bg-indigo-300/15 text-indigo-200"
: "text-cyan-100"
}`}
- onClick={() => handleChordEdit(section.id, role)}
+ onClick={() => handleChordEdit(sectionIndex, roleIndex, section, role)}
title={onSongUpdate ? t("chordEditTitle") : undefined}
disabled={!onSongUpdate}
>
@@ -231,8 +239,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
- );
- })}
+ );
+ })}
))}
From 78a4de9307b8039aa5136cfc65ff4a7f9e2a31c5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 04:21:09 +0900
Subject: [PATCH 115/153] test(player): reject loop boundaries beyond loaded
audio
---
.../RehearsalPlayer.media-duration.test.tsx | 117 ++++++++++++++++++
1 file changed, 117 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
new file mode 100644
index 000000000..ec98d0f11
--- /dev/null
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
@@ -0,0 +1,117 @@
+import { 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 originalTauriInternals = Object.getOwnPropertyDescriptor(
+ window,
+ "__TAURI_INTERNALS__",
+);
+const audioSourcePath = "/Users/test/Music/rehearsal.wav";
+
+function installPlayableAudioMocks() {
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: {
+ convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
+ },
+ });
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+}
+
+function admitDuration(audio: HTMLAudioElement, duration: number) {
+ Object.defineProperty(audio, "duration", {
+ configurable: true,
+ value: duration,
+ });
+ fireEvent.loadedMetadata(audio);
+}
+
+describe("RehearsalPlayer admitted media duration", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ if (originalTauriInternals) {
+ Object.defineProperty(
+ window,
+ "__TAURI_INTERNALS__",
+ originalTauriInternals,
+ );
+ } else {
+ delete (window as Window & { __TAURI_INTERNALS__?: unknown })
+ .__TAURI_INTERNALS__;
+ }
+ });
+
+ it("disables transport start when the selected loop extends beyond loaded audio", () => {
+ installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement;
+ admitDuration(audio, 20);
+
+ expect(
+ screen.getByRole("button", { name: /Start the count-in/i }),
+ ).toBeDisabled();
+ });
+
+ it("rejects a corrected end boundary beyond admitted EOF", () => {
+ installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.timeRange.end = 20;
+ const onSongUpdate = vi.fn();
+
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement;
+ admitDuration(audio, 25);
+ const endInput = screen.getByLabelText(/end/i) as HTMLInputElement;
+ fireEvent.change(endInput, { target: { value: "26" } });
+ fireEvent.blur(endInput);
+
+ expect(onSongUpdate).not.toHaveBeenCalled();
+ expect(endInput.value).toBe("20");
+ expect(endInput.getAttribute("aria-invalid")).toBe("true");
+ });
+
+ it("rejects a corrected start boundary at admitted EOF", () => {
+ installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ const onSongUpdate = vi.fn();
+
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement;
+ admitDuration(audio, 25);
+ const startInput = screen.getByLabelText(/start/i) as HTMLInputElement;
+ fireEvent.change(startInput, { target: { value: "25" } });
+ fireEvent.blur(startInput);
+
+ expect(onSongUpdate).not.toHaveBeenCalled();
+ expect(startInput.value).toBe("10");
+ expect(startInput.getAttribute("aria-invalid")).toBe("true");
+ });
+});
From d3abeef7d0712b49957714bb2cff75d377206453 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 04:45:13 +0900
Subject: [PATCH 116/153] fix(player): bind loop authority to loaded media
duration
---
.../features/workspace/RehearsalPlayer.tsx | 81 ++++++++++++++++---
1 file changed, 69 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index ab4fe68f1..06fc0d9d0 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -109,6 +109,20 @@ function loopSelectionKey(loop: RehearsalLoopWindow): string {
return loop.selectionKey;
}
+/** Return whether a selected loop is fully covered by admitted local media. */
+function loopFitsAdmittedMedia(
+ loop: RehearsalLoopWindow,
+ mediaDurationSeconds: number | null,
+): boolean {
+ return (
+ mediaDurationSeconds !== null &&
+ Number.isFinite(mediaDurationSeconds) &&
+ mediaDurationSeconds > 0 &&
+ loop.startSeconds < mediaDurationSeconds &&
+ loop.endSeconds <= mediaDurationSeconds
+ );
+}
+
/** Render tonight's first section loop with a count-in and a named next action. */
export function RehearsalPlayer({
song,
@@ -215,6 +229,9 @@ export function RehearsalPlayer({
audioSourceUrl === null,
);
const [playbackError, setPlaybackError] = useState(false);
+ const [mediaDurationSeconds, setMediaDurationSeconds] = useState(
+ null,
+ );
useEffect(
() => () => {
@@ -288,6 +305,7 @@ export function RehearsalPlayer({
return undefined;
}
playbackIntentRef.current = "inactive";
+ setMediaDurationSeconds(null);
if (!audio.paused) {
audio.pause();
}
@@ -307,6 +325,14 @@ export function RehearsalPlayer({
};
}, [audioSourceUrl, hasNativeAudioConversionError]);
+ /** Admit only a finite positive duration from the currently loaded local source. */
+ const handleLoadedMetadata = useCallback(() => {
+ const duration = audioRef.current?.duration ?? Number.NaN;
+ setMediaDurationSeconds(
+ Number.isFinite(duration) && duration > 0 ? duration : null,
+ );
+ }, []);
+
useEffect(() => {
setTransport((current) => {
if (
@@ -349,14 +375,16 @@ export function RehearsalPlayer({
if (startNonce <= lastHandledStartNonce.current) {
return;
}
- lastHandledStartNonce.current = startNonce;
- if (!hasPlayableAudio) {
+ if (
+ !hasPlayableAudio ||
+ !selectedLoop ||
+ !loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)
+ ) {
return;
}
- if (selectedLoop) {
- setPlaybackError(false);
- startAudio(selectedLoop, false);
- }
+ lastHandledStartNonce.current = startNonce;
+ setPlaybackError(false);
+ startAudio(selectedLoop, false);
setTransport((current) => {
const armed = reduceRehearsalTransport(current, {
type: "arm",
@@ -368,11 +396,15 @@ export function RehearsalPlayer({
startAudio,
startNonce,
hasPlayableAudio,
+ mediaDurationSeconds,
selectedLoop,
]);
useEffect(() => {
- if (hasPlayableAudio) {
+ const transportLoopCovered =
+ transport.loop === null ||
+ loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds);
+ if (hasPlayableAudio && transportLoopCovered) {
return;
}
playbackIntentRef.current = "inactive";
@@ -382,7 +414,7 @@ export function RehearsalPlayer({
}
return reduceRehearsalTransport(current, { type: "stop" });
});
- }, [hasPlayableAudio]);
+ }, [hasPlayableAudio, mediaDurationSeconds, transport.loop]);
useEffect(() => {
if (transport.phase !== "counting-in" || !transport.loop) {
@@ -621,6 +653,7 @@ export function RehearsalPlayer({
const canStart =
transport.loop !== null &&
hasPlayableAudio &&
+ loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) &&
(transport.phase === "armed" || transport.phase === "paused");
const canPause =
transport.phase === "counting-in" || transport.phase === "looping";
@@ -636,11 +669,18 @@ export function RehearsalPlayer({
}
const rawValue = event.currentTarget.value.trim();
const value = Number(rawValue);
+ const withinLoadedMedia =
+ !hasPlayableAudio ||
+ (mediaDurationSeconds !== null &&
+ (boundary === "start"
+ ? value < mediaDurationSeconds
+ : value <= mediaDurationSeconds));
const valid =
rawValue !== "" &&
Number.isSafeInteger(value) &&
value >= 0 &&
value <= MAX_SECTION_TIME_SECONDS &&
+ withinLoadedMedia &&
(boundary === "start"
? value < selectedLoop.endSeconds
: value > selectedLoop.startSeconds);
@@ -710,11 +750,18 @@ export function RehearsalPlayer({
setSelectedLoopKey(loopSelectionKey(nextLoop));
onSongUpdate(nextSong);
},
- [onSongUpdate, selectedLoop, song],
+ [
+ hasPlayableAudio,
+ mediaDurationSeconds,
+ onSongUpdate,
+ selectedLoop,
+ song,
+ ],
);
const canSeek =
transport.loop !== null &&
hasPlayableAudio &&
+ loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) &&
(transport.phase === "looping" ||
(transport.phase === "paused" && transport.countInRemainingBeats === 0));
const handleSeek = useCallback(
@@ -928,7 +975,11 @@ export function RehearsalPlayer({
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"
id="rehearsal-loop-boundary-start"
- max={MAX_SECTION_TIME_SECONDS}
+ max={
+ hasPlayableAudio && mediaDurationSeconds !== null
+ ? mediaDurationSeconds
+ : MAX_SECTION_TIME_SECONDS
+ }
min={0}
onBlur={(event) => handleBoundaryBlur("start", event)}
onChange={(event) => {
@@ -947,7 +998,11 @@ export function RehearsalPlayer({
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"
id="rehearsal-loop-boundary-end"
- max={MAX_SECTION_TIME_SECONDS}
+ max={
+ hasPlayableAudio && mediaDurationSeconds !== null
+ ? mediaDurationSeconds
+ : MAX_SECTION_TIME_SECONDS
+ }
min={0}
onBlur={(event) => handleBoundaryBlur("end", event)}
onChange={(event) => {
@@ -1084,8 +1139,10 @@ export function RehearsalPlayer({
data-testid="rehearsal-loop-audio"
preload="metadata"
aria-hidden="true"
+ onDurationChange={handleLoadedMetadata}
+ onLoadedMetadata={handleLoadedMetadata}
tabIndex={-1}
/>
);
-}
+}
\ No newline at end of file
From e10675766dcb1c941c82dc0e94fae420db4d6826 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 05:18:38 +0900
Subject: [PATCH 117/153] test(player): reject replay of uncovered start
request
---
.../RehearsalPlayer.media-duration.test.tsx | 29 ++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
index ec98d0f11..14c01d7d0 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx
@@ -18,7 +18,7 @@ function installPlayableAudioMocks() {
});
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
- vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
+ return vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
}
function admitDuration(audio: HTMLAudioElement, duration: number) {
@@ -114,4 +114,31 @@ describe("RehearsalPlayer admitted media duration", () => {
expect(startInput.value).toBe("10");
expect(startInput.getAttribute("aria-invalid")).toBe("true");
});
+
+ it("does not replay a rejected start request after selecting a covered loop", () => {
+ const play = installPlayableAudioMocks();
+ const song = createDemoRehearsalSong();
+ const coveredSection = structuredClone(song.sections[0]!);
+ coveredSection.id = "intro-covered";
+ coveredSection.label = "intro";
+ coveredSection.timeRange = { start: 0, end: 20 };
+ song.sections.push(coveredSection);
+
+ render(
+ ,
+ );
+
+ const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement;
+ admitDuration(audio, 25);
+ expect(play).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: /intro/i }));
+
+ expect(play).not.toHaveBeenCalled();
+ });
});
From c9830a005fa81610dbd0f72fe9de6e0dd8312a79 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 05:21:35 +0900
Subject: [PATCH 118/153] fix(player): consume rejected uncovered start request
---
.../desktop/src/features/workspace/RehearsalPlayer.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 06fc0d9d0..0c8db8875 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -375,11 +375,11 @@ export function RehearsalPlayer({
if (startNonce <= lastHandledStartNonce.current) {
return;
}
- if (
- !hasPlayableAudio ||
- !selectedLoop ||
- !loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)
- ) {
+ if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) {
+ return;
+ }
+ if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) {
+ lastHandledStartNonce.current = startNonce;
return;
}
lastHandledStartNonce.current = startNonce;
From c90d855ad2f10e50572ded98bcf45f6b1f2a05aa Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:18:54 +0900
Subject: [PATCH 119/153] test(player): require revocable native playback
authority
---
.../RehearsalPlayer.audioAuthority.test.tsx | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
index 8ebd45a63..1371599ec 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
@@ -55,6 +55,7 @@ describe("RehearsalPlayer audio authority", () => {
song={song}
hasLocalAudio={true}
audioSourcePath="/Users/test/Music/late-night-set.wav"
+ audioPlaybackProjectId="project-1"
/>,
);
@@ -65,4 +66,54 @@ describe("RehearsalPlayer audio authority", () => {
/could not play this local audio/i,
);
});
+
+ it("mints the media URL from the app-owned project authority, never the native source path", () => {
+ const convertFileSrc = vi.fn(
+ () => "bandscope-playback://localhost/project-1",
+ );
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: { convertFileSrc },
+ });
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ expect(convertFileSrc).toHaveBeenCalledWith(
+ "project-1",
+ "bandscope-playback",
+ );
+ expect(convertFileSrc).not.toHaveBeenCalledWith(
+ "/Users/test/Music/private-rehearsal.wav",
+ );
+ });
+
+ it("does not expose a native path when no current playback project authority exists", () => {
+ const convertFileSrc = vi.fn(() => "asset://localhost/private-rehearsal.wav");
+ Object.defineProperty(window, "__TAURI_INTERNALS__", {
+ configurable: true,
+ value: { convertFileSrc },
+ });
+ const song = createDemoRehearsalSong();
+
+ render(
+ ,
+ );
+
+ expect(convertFileSrc).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole("button", { name: /start the count-in/i }),
+ ).toBeDisabled();
+ });
});
From f6fc519782b1e724c363b1532b7fdffbb594b851 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:20:24 +0900
Subject: [PATCH 120/153] feat(player): add revocable native playback protocol
---
.../src-tauri/src/playback_protocol.rs | 388 ++++++++++++++++++
1 file changed, 388 insertions(+)
create mode 100644 apps/desktop/src-tauri/src/playback_protocol.rs
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
new file mode 100644
index 000000000..c3935410a
--- /dev/null
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -0,0 +1,388 @@
+//! Revocable native media authority for the mounted rehearsal player.
+//!
+//! The WebView receives only an app-minted project id. Native source paths stay
+//! behind this protocol boundary and every request is checked against the one
+//! currently active project before BandScope opens any file.
+
+use bandscope_desktop_core::{is_valid_project_id, LocalAudioSourcePayload};
+use std::{
+ fs::File,
+ io::{Read, Seek, SeekFrom},
+ path::PathBuf,
+ sync::Mutex,
+};
+use tauri::http::{
+ header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE},
+ Method, Request, Response, StatusCode,
+};
+
+/// Custom scheme used only for current-project audio playback.
+pub const PLAYBACK_SCHEME: &str = "bandscope-playback";
+
+/// Match Tauri's bounded single-range chunk size so media seeks do not allocate
+/// an arbitrarily large buffer from an untrusted Range header.
+const MAX_RANGE_BYTES: u64 = 1_000 * 1024;
+
+#[derive(Clone, Debug)]
+struct PlaybackSourceAuthority {
+ project_id: String,
+ source_path: PathBuf,
+ extension: String,
+ expected_size: u64,
+}
+
+/// Process-local authority for the one audio source currently admitted to the
+/// rehearsal player. Replacing it revokes every previously selected source.
+#[derive(Default)]
+pub struct PlaybackAuthority {
+ current: Mutex>,
+}
+
+impl PlaybackAuthority {
+ /// Replace the current playback source with an already validated native
+ /// source. The project id is app-minted and never derived from a path.
+ pub fn activate(&self, project_id: &str, source: &LocalAudioSourcePayload) -> Result<(), String> {
+ if !is_valid_project_id(project_id) {
+ return Err("Could not prepare the selected audio for playback.".to_string());
+ }
+ let mut current = self
+ .current
+ .lock()
+ .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?;
+ *current = Some(PlaybackSourceAuthority {
+ project_id: project_id.to_string(),
+ source_path: PathBuf::from(&source.source_path),
+ extension: source.extension.clone(),
+ expected_size: source.file_size_bytes,
+ });
+ Ok(())
+ }
+
+ /// Serve GET/HEAD media requests only when their opaque project id still
+ /// names the current authority. Stale project ids fail closed immediately.
+ pub fn respond(&self, request: Request>) -> Response> {
+ let Some(project_id) = project_id_from_path(request.uri().path()) else {
+ return empty_response(StatusCode::NOT_FOUND);
+ };
+ let Some(authority) = self.current_authority(project_id) else {
+ return empty_response(StatusCode::NOT_FOUND);
+ };
+ if request.method() != Method::GET && request.method() != Method::HEAD {
+ return empty_response(StatusCode::METHOD_NOT_ALLOWED);
+ }
+ serve_authorized_source(&authority, &request)
+ }
+
+ fn current_authority(&self, project_id: &str) -> Option {
+ if !is_valid_project_id(project_id) {
+ return None;
+ }
+ self.current
+ .lock()
+ .ok()
+ .and_then(|current| current.as_ref().filter(|entry| entry.project_id == project_id).cloned())
+ }
+}
+
+fn project_id_from_path(path: &str) -> Option<&str> {
+ let project_id = path.strip_prefix('/')?;
+ if project_id.is_empty() || project_id.contains('/') || project_id.contains('%') {
+ return None;
+ }
+ is_valid_project_id(project_id).then_some(project_id)
+}
+
+fn content_type(extension: &str) -> Option<&'static str> {
+ match extension {
+ "wav" => Some("audio/wav"),
+ "mp3" => Some("audio/mpeg"),
+ "flac" => Some("audio/flac"),
+ "m4a" => Some("audio/mp4"),
+ _ => None,
+ }
+}
+
+fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> {
+ let link_metadata = std::fs::symlink_metadata(&authority.source_path)
+ .map_err(|_| StatusCode::GONE)?;
+ if link_metadata.file_type().is_symlink()
+ || !link_metadata.is_file()
+ || link_metadata.len() != authority.expected_size
+ {
+ return Err(StatusCode::GONE);
+ }
+ let canonical = authority
+ .source_path
+ .canonicalize()
+ .map_err(|_| StatusCode::GONE)?;
+ if canonical != authority.source_path {
+ return Err(StatusCode::GONE);
+ }
+ let file = File::open(&canonical).map_err(|_| StatusCode::GONE)?;
+ let metadata = file.metadata().map_err(|_| StatusCode::GONE)?;
+ if !metadata.is_file() || metadata.len() != authority.expected_size || metadata.len() == 0 {
+ return Err(StatusCode::GONE);
+ }
+ Ok((file, metadata.len()))
+}
+
+fn serve_authorized_source(
+ authority: &PlaybackSourceAuthority,
+ request: &Request>,
+) -> Response> {
+ let Some(media_type) = content_type(&authority.extension) else {
+ return empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE);
+ };
+ let (mut file, len) = match validated_file(authority) {
+ Ok(value) => value,
+ Err(status) => return empty_response(status),
+ };
+
+ if request.method() == Method::HEAD {
+ return Response::builder()
+ .status(StatusCode::OK)
+ .header(ACCEPT_RANGES, "bytes")
+ .header(CONTENT_TYPE, media_type)
+ .header(CONTENT_LENGTH, len)
+ .body(Vec::new())
+ .expect("static playback HEAD response should build");
+ }
+
+ let range_header = request.headers().get(RANGE).and_then(|value| value.to_str().ok());
+ if let Some(range_header) = range_header {
+ let (start, end) = match parse_single_range(range_header, len) {
+ Ok(range) => range,
+ Err(()) => return range_not_satisfiable(len),
+ };
+ let byte_count = end + 1 - start;
+ let Ok(capacity) = usize::try_from(byte_count) else {
+ return range_not_satisfiable(len);
+ };
+ if file.seek(SeekFrom::Start(start)).is_err() {
+ return empty_response(StatusCode::GONE);
+ }
+ let mut body = Vec::with_capacity(capacity);
+ if file.take(byte_count).read_to_end(&mut body).is_err() || body.len() != capacity {
+ return empty_response(StatusCode::GONE);
+ }
+ return Response::builder()
+ .status(StatusCode::PARTIAL_CONTENT)
+ .header(ACCEPT_RANGES, "bytes")
+ .header(CONTENT_TYPE, media_type)
+ .header(CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
+ .header(CONTENT_LENGTH, byte_count)
+ .body(body)
+ .expect("static playback range response should build");
+ }
+
+ let mut body = Vec::new();
+ if file.read_to_end(&mut body).is_err() || body.len() as u64 != len {
+ return empty_response(StatusCode::GONE);
+ }
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(ACCEPT_RANGES, "bytes")
+ .header(CONTENT_TYPE, media_type)
+ .header(CONTENT_LENGTH, len)
+ .body(body)
+ .expect("static playback response should build")
+}
+
+fn parse_single_range(header: &str, len: u64) -> Result<(u64, u64), ()> {
+ if len == 0 {
+ return Err(());
+ }
+ let value = header.strip_prefix("bytes=").ok_or(())?.trim();
+ if value.is_empty() || value.contains(',') {
+ return Err(());
+ }
+
+ if let Some(suffix) = value.strip_prefix('-') {
+ let suffix_len = suffix.parse::().map_err(|_| ())?;
+ if suffix_len == 0 {
+ return Err(());
+ }
+ let bounded_suffix = suffix_len.min(len).min(MAX_RANGE_BYTES);
+ return Ok((len - bounded_suffix, len - 1));
+ }
+
+ let (start, end) = value.split_once('-').ok_or(())?;
+ let start = start.parse::().map_err(|_| ())?;
+ if start >= len {
+ return Err(());
+ }
+ let requested_end = if end.is_empty() {
+ len - 1
+ } else {
+ end.parse::().map_err(|_| ())?.min(len - 1)
+ };
+ if requested_end < start {
+ return Err(());
+ }
+ let end = requested_end.min(start.saturating_add(MAX_RANGE_BYTES - 1));
+ Ok((start, end))
+}
+
+fn range_not_satisfiable(len: u64) -> Response> {
+ Response::builder()
+ .status(StatusCode::RANGE_NOT_SATISFIABLE)
+ .header(ACCEPT_RANGES, "bytes")
+ .header(CONTENT_RANGE, format!("bytes */{len}"))
+ .body(Vec::new())
+ .expect("static playback range error should build")
+}
+
+fn empty_response(status: StatusCode) -> Response> {
+ Response::builder()
+ .status(status)
+ .body(Vec::new())
+ .expect("static playback error response should build")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ fn test_source(label: &str, bytes: &[u8]) -> (PathBuf, LocalAudioSourcePayload) {
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("test clock should be after the Unix epoch")
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!(
+ "bandscope-playback-{}-{label}-{unique}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&root).expect("test playback root should be created");
+ let path = root.join("source.wav");
+ std::fs::write(&path, bytes).expect("test playback source should be written");
+ let canonical = path.canonicalize().expect("test source should canonicalize");
+ let source = LocalAudioSourcePayload {
+ source_path: canonical.to_string_lossy().into_owned(),
+ file_name: "source.wav".to_string(),
+ extension: "wav".to_string(),
+ file_size_bytes: bytes.len() as u64,
+ };
+ (root, source)
+ }
+
+ fn request(project_id: &str) -> Request> {
+ Request::builder()
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/{project_id}"))
+ .body(Vec::new())
+ .expect("test request should build")
+ }
+
+ #[test]
+ fn rotating_authority_revokes_the_previous_project_immediately() {
+ let (first_root, first_source) = test_source("first", b"first-audio");
+ let (second_root, second_source) = test_source("second", b"second-audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-100-1", &first_source)
+ .expect("first source should activate");
+ assert_eq!(
+ authority.respond(request("project-100-1")).status(),
+ StatusCode::OK
+ );
+
+ authority
+ .activate("project-101-2", &second_source)
+ .expect("second source should activate");
+
+ assert_eq!(
+ authority.respond(request("project-100-1")).status(),
+ StatusCode::NOT_FOUND
+ );
+ assert_eq!(
+ authority.respond(request("project-101-2")).body(),
+ b"second-audio"
+ );
+ let _ = std::fs::remove_dir_all(first_root);
+ let _ = std::fs::remove_dir_all(second_root);
+ }
+
+ #[test]
+ fn current_authority_rejects_path_shaped_or_unknown_tokens() {
+ let (root, source) = test_source("token", b"audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-200-3", &source)
+ .expect("source should activate");
+
+ assert_eq!(
+ authority.respond(request("project-999-9")).status(),
+ StatusCode::NOT_FOUND
+ );
+ let traversal = Request::builder()
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/project-200-3/../../private.wav"))
+ .body(Vec::new())
+ .expect("test traversal request should build");
+ assert_eq!(authority.respond(traversal).status(), StatusCode::NOT_FOUND);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn single_range_is_bounded_and_reports_partial_content() {
+ let bytes = vec![b'x'; (MAX_RANGE_BYTES + 32) as usize];
+ let (root, source) = test_source("range", &bytes);
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-300-4", &source)
+ .expect("source should activate");
+ let request = Request::builder()
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/project-300-4"))
+ .header(RANGE, "bytes=0-")
+ .body(Vec::new())
+ .expect("range request should build");
+
+ let response = authority.respond(request);
+
+ assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
+ assert_eq!(response.body().len() as u64, MAX_RANGE_BYTES);
+ assert_eq!(
+ response.headers().get(CONTENT_RANGE).and_then(|value| value.to_str().ok()),
+ Some(&*format!("bytes 0-{}/{ }", MAX_RANGE_BYTES - 1, bytes.len()).replace("/ ", "/"))
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn multiple_or_unsatisfiable_ranges_fail_closed() {
+ let (root, source) = test_source("invalid-range", b"0123456789");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-400-5", &source)
+ .expect("source should activate");
+ for value in ["bytes=0-1,3-4", "bytes=99-", "items=0-1", "bytes=-0"] {
+ let request = Request::builder()
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/project-400-5"))
+ .header(RANGE, value)
+ .body(Vec::new())
+ .expect("invalid range request should build");
+ assert_eq!(
+ authority.respond(request).status(),
+ StatusCode::RANGE_NOT_SATISFIABLE,
+ "range {value} must fail closed"
+ );
+ }
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn source_replacement_after_admission_is_not_served() {
+ let (root, source) = test_source("mutation", b"original-audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-500-6", &source)
+ .expect("source should activate");
+ std::fs::write(&source.source_path, b"changed-size")
+ .expect("test should replace source contents");
+
+ assert_eq!(
+ authority.respond(request("project-500-6")).status(),
+ StatusCode::GONE
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+}
From ae8aa46d87a545f7cde006019ef775d157aa87f9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:24:33 +0900
Subject: [PATCH 121/153] test(player): pin opaque playback authority handle
---
.../RehearsalPlayer.audioAuthority.test.tsx | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
index 1371599ec..5b8e6bb2b 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx
@@ -19,7 +19,7 @@ describe("RehearsalPlayer audio authority", () => {
}
});
- it("refuses to start when local-audio metadata has no playable asset URL", () => {
+ it("refuses to start when local-audio metadata has no playable authority handle", () => {
const song = createDemoRehearsalSong();
render(
@@ -39,12 +39,12 @@ describe("RehearsalPlayer audio authority", () => {
).not.toMatch(/count in 4 beats/i);
});
- it("reports native asset conversion failures instead of presenting missing-audio copy", () => {
+ it("reports native playback conversion failures instead of presenting missing-audio copy", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {
convertFileSrc: () => {
- throw new Error("asset conversion failed");
+ throw new Error("playback conversion failed");
},
},
});
@@ -54,8 +54,7 @@ describe("RehearsalPlayer audio authority", () => {
,
);
@@ -67,9 +66,9 @@ describe("RehearsalPlayer audio authority", () => {
);
});
- it("mints the media URL from the app-owned project authority, never the native source path", () => {
+ it("mints the media URL from the app-owned project authority, never a native source path", () => {
const convertFileSrc = vi.fn(
- () => "bandscope-playback://localhost/project-1",
+ () => "bandscope-playback://localhost/project-100-1",
);
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
@@ -81,13 +80,12 @@ describe("RehearsalPlayer audio authority", () => {
,
);
expect(convertFileSrc).toHaveBeenCalledWith(
- "project-1",
+ "project-100-1",
"bandscope-playback",
);
expect(convertFileSrc).not.toHaveBeenCalledWith(
From d0112f3866c39c17caa14f7fafd90c653acba50d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:26:45 +0900
Subject: [PATCH 122/153] fix(player): consume opaque native playback authority
---
.../src/features/workspace/RehearsalPlayer.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index 0c8db8875..feecc5292 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -50,21 +50,28 @@ interface RehearsalPlayerProps {
startNonce?: number;
}
-/** Convert a validated native source path into a scoped Tauri asset URL. */
+const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://";
+const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/;
+
+/** Convert an opaque current-project authority into BandScope's native media URL. */
function resolveAudioSourceUrl(
sourcePath: string | null | undefined,
): string | null {
- if (!sourcePath || sourcePath.startsWith("browser://")) {
+ if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) {
+ return null;
+ }
+ const projectId = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length);
+ if (!PLAYBACK_PROJECT_ID.test(projectId)) {
return null;
}
try {
- return convertFileSrc(sourcePath);
+ return convertFileSrc(projectId, "bandscope-playback");
} catch {
return null;
}
}
-/** Return whether a source path can be converted into a playable native asset URL. */
+/** Return whether a source authority can be converted into a playable native URL. */
export function isPlayableAudioSource(
sourcePath: string | null | undefined,
): boolean {
From 784ff4004fd258d86924e3ed510a40dc52203658 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:38:10 +0900
Subject: [PATCH 123/153] fix(player): expose revocable playback authority
helper
---
.../src-tauri/src/playback_protocol.rs | 53 ++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index c3935410a..2c726d89b 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -19,6 +19,10 @@ use tauri::http::{
/// Custom scheme used only for current-project audio playback.
pub const PLAYBACK_SCHEME: &str = "bandscope-playback";
+/// Opaque renderer-side handle prefix. The suffix is an app-minted project id,
+/// never a native path or user-controlled URL.
+pub const PLAYBACK_AUTHORITY_PREFIX: &str = "bandscope-project://";
+
/// Match Tauri's bounded single-range chunk size so media seeks do not allocate
/// an arbitrarily large buffer from an untrusted Range header.
const MAX_RANGE_BYTES: u64 = 1_000 * 1024;
@@ -38,6 +42,14 @@ pub struct PlaybackAuthority {
current: Mutex>,
}
+/// Return the only renderer-visible handle for an app-minted playback project.
+pub fn playback_authority_uri(project_id: &str) -> Result {
+ if !is_valid_project_id(project_id) {
+ return Err("Could not prepare the selected audio for playback.".to_string());
+ }
+ Ok(format!("{PLAYBACK_AUTHORITY_PREFIX}{project_id}"))
+}
+
impl PlaybackAuthority {
/// Replace the current playback source with an already validated native
/// source. The project id is app-minted and never derived from a path.
@@ -273,6 +285,15 @@ mod tests {
.expect("test request should build")
}
+ #[test]
+ fn renderer_handle_contains_only_the_app_minted_project_id() {
+ assert_eq!(
+ playback_authority_uri("project-100-1").as_deref(),
+ Ok("bandscope-project://project-100-1")
+ );
+ assert!(playback_authority_uri("../../private.wav").is_err());
+ }
+
#[test]
fn rotating_authority_revokes_the_previous_project_immediately() {
let (first_root, first_source) = test_source("first", b"first-audio");
@@ -337,12 +358,13 @@ mod tests {
.expect("range request should build");
let response = authority.respond(request);
+ let expected_content_range = format!("bytes 0-{}/{}", MAX_RANGE_BYTES - 1, bytes.len());
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.body().len() as u64, MAX_RANGE_BYTES);
assert_eq!(
response.headers().get(CONTENT_RANGE).and_then(|value| value.to_str().ok()),
- Some(&*format!("bytes 0-{}/{ }", MAX_RANGE_BYTES - 1, bytes.len()).replace("/ ", "/"))
+ Some(expected_content_range.as_str())
);
let _ = std::fs::remove_dir_all(root);
}
@@ -385,4 +407,33 @@ mod tests {
);
let _ = std::fs::remove_dir_all(root);
}
+
+ #[test]
+ fn head_is_metadata_only_and_other_methods_fail_closed() {
+ let (root, source) = test_source("head", b"audio-bytes");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-600-7", &source)
+ .expect("source should activate");
+ let head = Request::builder()
+ .method(Method::HEAD)
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/project-600-7"))
+ .body(Vec::new())
+ .expect("HEAD request should build");
+ let post = Request::builder()
+ .method(Method::POST)
+ .uri(format!("{PLAYBACK_SCHEME}://localhost/project-600-7"))
+ .body(Vec::new())
+ .expect("POST request should build");
+
+ let head_response = authority.respond(head);
+ assert_eq!(head_response.status(), StatusCode::OK);
+ assert!(head_response.body().is_empty());
+ assert_eq!(
+ head_response.headers().get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()),
+ Some("11")
+ );
+ assert_eq!(authority.respond(post).status(), StatusCode::METHOD_NOT_ALLOWED);
+ let _ = std::fs::remove_dir_all(root);
+ }
}
From c2b0802c4a08641dcd3f288f5e0ec31d757f75a6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:38:22 +0900
Subject: [PATCH 124/153] fix(player): disable additive asset protocol scope
---
apps/desktop/src-tauri/Cargo.toml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index f32cccfae..c11a5946f 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 = ["protocol-asset", "wry"] }
+tauri = { version = "2.11.1", default-features = false, features = ["wry"] }
time = { version = "0.3", features = ["formatting", "macros"] }
tokio = { version = "1.50.0", features = ["time"] }
url = "2.5.8"
@@ -22,4 +22,4 @@ default = []
[package.metadata.opencode.coverage]
# Baseline measured by the central Rust coverage evidence gate on PR #527.
-minimum_lines = 42
+minimum_lines = 42
\ No newline at end of file
From e9c4c8f828001d265f3b468cc4dc94a22e5c0702 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:38:32 +0900
Subject: [PATCH 125/153] fix(player): scope CSP to revocable playback protocol
---
apps/desktop/src-tauri/tauri.conf.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index 2c80b1590..1a5d32c75 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -16,9 +16,9 @@
}
],
"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: http://asset.localhost data: blob:; font-src 'self' data:",
+ "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' bandscope-playback: http://bandscope-playback.localhost data: blob:; font-src 'self' data:",
"assetProtocol": {
- "enable": true,
+ "enable": false,
"scope": []
},
"capabilities": ["main-capability"]
@@ -28,4 +28,4 @@
"active": true,
"targets": "all"
}
-}
+}
\ No newline at end of file
From 47115421cae656d782d51e684734fe155bb22613 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:40:13 +0900
Subject: [PATCH 126/153] fix(player): wire revocable native playback authority
---
apps/desktop/src-tauri/src/main.rs | 45 +++++++++++++++++-------------
1 file changed, 26 insertions(+), 19 deletions(-)
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index a8a3de6ce..e4547b38e 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -1,13 +1,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+mod playback_protocol;
+
use bandscope_desktop_core::*;
+use playback_protocol::{playback_authority_uri, PlaybackAuthority, PLAYBACK_SCHEME};
use rfd::FileDialog;
use serde_json::{json, Value};
use std::{
io::{BufRead, BufReader, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
- sync::{atomic::Ordering, mpsc},
+ sync::{atomic::Ordering, mpsc, Arc},
thread,
time::Instant,
};
@@ -317,18 +320,13 @@ 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())
+/// Return a renderer-safe bootstrap while retaining the native source only in
+/// BandScope's process-local analysis and playback authority stores.
+fn renderer_bootstrap_summary(
+ mut summary: ProjectBootstrapSummaryPayload,
+) -> Result {
+ summary.source.source_path = playback_authority_uri(&summary.project_id)?;
+ Ok(summary)
}
fn drain_analysis_status_updates(
@@ -651,13 +649,13 @@ fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) ->
fn select_local_audio_source(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
+ playback_authority: tauri::State<'_, Arc>,
) -> Result {
let path = FileDialog::new()
.add_filter("Audio", &AUDIO_EXTENSIONS)
.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)?;
@@ -671,9 +669,10 @@ fn select_local_audio_source(
temp_root: temp_root.to_string_lossy().into_owned(),
source,
};
+ playback_authority.activate(&summary.project_id, &summary.source)?;
store_bootstrap_source(&state, summary.clone());
- Ok(summary)
+ renderer_bootstrap_summary(summary)
}
#[tauri::command]
@@ -681,6 +680,7 @@ async fn import_youtube_url(
url: String,
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
+ playback_authority: tauri::State<'_, Arc>,
) -> Result {
if !is_supported_youtube_url(&url) {
return Err("Only standard YouTube URLs are supported.".to_string());
@@ -727,7 +727,6 @@ 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,
@@ -737,8 +736,9 @@ async fn import_youtube_url(
temp_root: temp_root.to_string_lossy().into_owned(),
source,
};
+ playback_authority.activate(&summary.project_id, &summary.source)?;
store_bootstrap_source(&state, summary.clone());
- return Ok(summary);
+ return renderer_bootstrap_summary(summary);
}
return Err(youtube_missing_metadata_error(&parsed));
}
@@ -852,7 +852,7 @@ fn read_score_pdf(
if !is_valid_project_id(&project_id) {
return Err("Invalid project id.".to_string());
}
- let scores_root = scores_root_for_project(&app, &project_id)?;
+ let scores_root = scores_root_for_project(&app, "projects", &project_id)?;
let path = resolve_existing_score_pdf(&scores_root, &score_id)?;
std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string())
}
@@ -882,8 +882,15 @@ fn remove_score_pdf(
}
fn main() {
+ let playback_authority = Arc::new(PlaybackAuthority::default());
+ let protocol_authority = Arc::clone(&playback_authority);
+
tauri::Builder::default()
.manage(AppState::default())
+ .manage(playback_authority)
+ .register_uri_scheme_protocol(PLAYBACK_SCHEME, move |_context, request| {
+ protocol_authority.respond(request)
+ })
.invoke_handler(tauri::generate_handler![
select_local_audio_source,
import_youtube_url,
@@ -897,4 +904,4 @@ fn main() {
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
-}
+}
\ No newline at end of file
From 3e0b6d7c68d3dedc2ea0ceda47ffd33f4d6947c9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:42:01 +0900
Subject: [PATCH 127/153] fix(player): preserve score root while wiring
playback
---
apps/desktop/src-tauri/src/main.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs
index e4547b38e..6e142bd1f 100644
--- a/apps/desktop/src-tauri/src/main.rs
+++ b/apps/desktop/src-tauri/src/main.rs
@@ -852,7 +852,7 @@ fn read_score_pdf(
if !is_valid_project_id(&project_id) {
return Err("Invalid project id.".to_string());
}
- let scores_root = scores_root_for_project(&app, "projects", &project_id)?;
+ let scores_root = scores_root_for_project(&app, &project_id)?;
let path = resolve_existing_score_pdf(&scores_root, &score_id)?;
std::fs::read(path).map_err(|_| "Could not read the score PDF.".to_string())
}
@@ -904,4 +904,4 @@ fn main() {
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
-}
\ No newline at end of file
+}
From d057566298083cd19076f05b659455a0fdc2b68d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:52:54 +0900
Subject: [PATCH 128/153] test(player): reject unbounded full-recording
playback reads
---
.../src-tauri/src/playback_protocol.rs | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 2c726d89b..7368834ef 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -369,6 +369,27 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
+ #[test]
+ fn large_get_without_range_fails_closed_before_buffering_the_recording() {
+ let bytes = vec![b'x'; (MAX_RANGE_BYTES + 32) as usize];
+ let (root, source) = test_source("unranged", &bytes);
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-350-4", &source)
+ .expect("source should activate");
+
+ let response = authority.respond(request("project-350-4"));
+ let expected_content_range = format!("bytes */{}", bytes.len());
+
+ assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
+ assert!(response.body().is_empty());
+ assert_eq!(
+ response.headers().get(CONTENT_RANGE).and_then(|value| value.to_str().ok()),
+ Some(expected_content_range.as_str())
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn multiple_or_unsatisfiable_ranges_fail_closed() {
let (root, source) = test_source("invalid-range", b"0123456789");
From 8716377275badac92b776ae3b5c890879442f1e5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 08:54:46 +0900
Subject: [PATCH 129/153] fix(player): bound unranged playback responses
---
apps/desktop/src-tauri/src/playback_protocol.rs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 7368834ef..68c5579b2 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -187,6 +187,12 @@ fn serve_authorized_source(
.expect("static playback range response should build");
}
+ // Tauri custom-protocol bodies are buffered. Keep tiny compatibility GETs,
+ // but require large media clients to use the bounded Range path above.
+ if len > MAX_RANGE_BYTES {
+ return range_not_satisfiable(len);
+ }
+
let mut body = Vec::new();
if file.read_to_end(&mut body).is_err() || body.len() as u64 != len {
return empty_response(StatusCode::GONE);
From 788df6800789cbdc721dd486e2331a835362ae5a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 17:12:04 +0900
Subject: [PATCH 130/153] test(playback): reject same-size source replacement
---
.../src-tauri/src/playback_protocol.rs | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 68c5579b2..66f966a83 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -435,6 +435,29 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
+ #[test]
+ fn same_size_path_replacement_after_admission_is_not_served() {
+ let (root, source) = test_source("same-size-replacement", b"trusted-audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-550-6", &source)
+ .expect("source should activate");
+
+ let replacement = root.join("replacement.wav");
+ std::fs::write(&replacement, b"hostile-audio")
+ .expect("test replacement should be written");
+ std::fs::remove_file(&source.source_path)
+ .expect("admitted source should be removable for replacement");
+ std::fs::rename(&replacement, &source.source_path)
+ .expect("same-size replacement should occupy admitted path");
+
+ assert_eq!(
+ authority.respond(request("project-550-6")).status(),
+ StatusCode::GONE
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn head_is_metadata_only_and_other_methods_fail_closed() {
let (root, source) = test_source("head", b"audio-bytes");
From 2ed840b904fdcd30e4519b4852586aa4f78b59f7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 17:14:08 +0900
Subject: [PATCH 131/153] fix(playback): bind authority to native source
identity
---
.../src-tauri/src/playback_protocol.rs | 123 ++++++++++++++++--
1 file changed, 111 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 66f966a83..b1ec78489 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -8,7 +8,7 @@ use bandscope_desktop_core::{is_valid_project_id, LocalAudioSourcePayload};
use std::{
fs::File,
io::{Read, Seek, SeekFrom},
- path::PathBuf,
+ path::{Path, PathBuf},
sync::Mutex,
};
use tauri::http::{
@@ -27,12 +27,100 @@ pub const PLAYBACK_AUTHORITY_PREFIX: &str = "bandscope-project://";
/// an arbitrarily large buffer from an untrusted Range header.
const MAX_RANGE_BYTES: u64 = 1_000 * 1024;
+#[cfg(unix)]
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PlaybackFileIdentity {
+ device: u64,
+ inode: u64,
+}
+
+#[cfg(windows)]
+#[repr(C)]
+struct WindowsFileTime {
+ low_date_time: u32,
+ high_date_time: u32,
+}
+
+#[cfg(windows)]
+#[repr(C)]
+struct WindowsByHandleFileInformation {
+ file_attributes: u32,
+ creation_time: WindowsFileTime,
+ last_access_time: WindowsFileTime,
+ last_write_time: WindowsFileTime,
+ volume_serial_number: u32,
+ file_size_high: u32,
+ file_size_low: u32,
+ number_of_links: u32,
+ file_index_high: u32,
+ file_index_low: u32,
+}
+
+#[cfg(windows)]
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PlaybackFileIdentity {
+ volume_serial_number: u32,
+ file_index: u64,
+}
+
+#[cfg(not(any(unix, windows)))]
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PlaybackFileIdentity;
+
+#[cfg(unix)]
+fn playback_file_identity(file: &File) -> std::io::Result {
+ use std::os::unix::fs::MetadataExt;
+
+ let metadata = file.metadata()?;
+ Ok(PlaybackFileIdentity {
+ device: metadata.dev(),
+ inode: metadata.ino(),
+ })
+}
+
+#[cfg(windows)]
+fn playback_file_identity(file: &File) -> std::io::Result {
+ use std::{mem::MaybeUninit, os::windows::io::AsRawHandle};
+
+ #[link(name = "kernel32")]
+ extern "system" {
+ #[link_name = "GetFileInformationByHandle"]
+ fn get_file_information_by_handle(
+ file: std::os::windows::io::RawHandle,
+ information: *mut WindowsByHandleFileInformation,
+ ) -> i32;
+ }
+
+ let mut information = MaybeUninit::::uninit();
+ let result = unsafe {
+ get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr())
+ };
+ if result == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let information = unsafe { information.assume_init() };
+ Ok(PlaybackFileIdentity {
+ volume_serial_number: information.volume_serial_number,
+ file_index: ((information.file_index_high as u64) << 32)
+ | information.file_index_low as u64,
+ })
+}
+
+#[cfg(not(any(unix, windows)))]
+fn playback_file_identity(_file: &File) -> std::io::Result {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::Unsupported,
+ "native playback file identity is unsupported on this platform",
+ ))
+}
+
#[derive(Clone, Debug)]
struct PlaybackSourceAuthority {
project_id: String,
source_path: PathBuf,
extension: String,
expected_size: u64,
+ source_identity: PlaybackFileIdentity,
}
/// Process-local authority for the one audio source currently admitted to the
@@ -57,15 +145,21 @@ impl PlaybackAuthority {
if !is_valid_project_id(project_id) {
return Err("Could not prepare the selected audio for playback.".to_string());
}
+ let source_path = PathBuf::from(&source.source_path);
+ let (file, _) = open_validated_source(&source_path, source.file_size_bytes)
+ .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?;
+ let source_identity = playback_file_identity(&file)
+ .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?;
let mut current = self
.current
.lock()
.map_err(|_| "Could not prepare the selected audio for playback.".to_string())?;
*current = Some(PlaybackSourceAuthority {
project_id: project_id.to_string(),
- source_path: PathBuf::from(&source.source_path),
+ source_path,
extension: source.extension.clone(),
expected_size: source.file_size_bytes,
+ source_identity,
});
Ok(())
}
@@ -114,30 +208,35 @@ fn content_type(extension: &str) -> Option<&'static str> {
}
}
-fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> {
- let link_metadata = std::fs::symlink_metadata(&authority.source_path)
- .map_err(|_| StatusCode::GONE)?;
+fn open_validated_source(source_path: &Path, expected_size: u64) -> Result<(File, u64), StatusCode> {
+ let link_metadata = std::fs::symlink_metadata(source_path).map_err(|_| StatusCode::GONE)?;
if link_metadata.file_type().is_symlink()
|| !link_metadata.is_file()
- || link_metadata.len() != authority.expected_size
+ || link_metadata.len() != expected_size
{
return Err(StatusCode::GONE);
}
- let canonical = authority
- .source_path
- .canonicalize()
- .map_err(|_| StatusCode::GONE)?;
- if canonical != authority.source_path {
+ let canonical = source_path.canonicalize().map_err(|_| StatusCode::GONE)?;
+ if canonical != source_path {
return Err(StatusCode::GONE);
}
let file = File::open(&canonical).map_err(|_| StatusCode::GONE)?;
let metadata = file.metadata().map_err(|_| StatusCode::GONE)?;
- if !metadata.is_file() || metadata.len() != authority.expected_size || metadata.len() == 0 {
+ if !metadata.is_file() || metadata.len() != expected_size || metadata.len() == 0 {
return Err(StatusCode::GONE);
}
Ok((file, metadata.len()))
}
+fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> {
+ let (file, len) = open_validated_source(&authority.source_path, authority.expected_size)?;
+ let current_identity = playback_file_identity(&file).map_err(|_| StatusCode::GONE)?;
+ if current_identity != authority.source_identity {
+ return Err(StatusCode::GONE);
+ }
+ Ok((file, len))
+}
+
fn serve_authorized_source(
authority: &PlaybackSourceAuthority,
request: &Request>,
From 9fb7863e268d56eec9f51f70db865c2b6b5c2899 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 18:13:07 +0900
Subject: [PATCH 132/153] test(playback): reject same-inode source mutation
---
.../desktop/src-tauri/src/playback_protocol.rs | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index b1ec78489..ef5bf775e 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -557,6 +557,24 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
+ #[test]
+ fn same_size_in_place_mutation_after_admission_is_not_served() {
+ let (root, source) = test_source("same-size-mutation", b"trusted-audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-575-6", &source)
+ .expect("source should activate");
+
+ std::fs::write(&source.source_path, b"hostile-audio")
+ .expect("test should mutate the admitted source in place");
+
+ assert_eq!(
+ authority.respond(request("project-575-6")).status(),
+ StatusCode::GONE
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn head_is_metadata_only_and_other_methods_fail_closed() {
let (root, source) = test_source("head", b"audio-bytes");
From 7c5adfd63be0834cf6a57205e541db96663b3fb6 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 18:15:01 +0900
Subject: [PATCH 133/153] fix(playback): revoke mutated native source
---
apps/desktop/src-tauri/src/playback_protocol.rs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index ef5bf775e..9772c75ec 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -32,9 +32,12 @@ const MAX_RANGE_BYTES: u64 = 1_000 * 1024;
struct PlaybackFileIdentity {
device: u64,
inode: u64,
+ change_time_seconds: i64,
+ change_time_nanoseconds: i64,
}
#[cfg(windows)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(C)]
struct WindowsFileTime {
low_date_time: u32,
@@ -61,6 +64,7 @@ struct WindowsByHandleFileInformation {
struct PlaybackFileIdentity {
volume_serial_number: u32,
file_index: u64,
+ last_write_time: WindowsFileTime,
}
#[cfg(not(any(unix, windows)))]
@@ -75,6 +79,8 @@ fn playback_file_identity(file: &File) -> std::io::Result
Ok(PlaybackFileIdentity {
device: metadata.dev(),
inode: metadata.ino(),
+ change_time_seconds: metadata.ctime(),
+ change_time_nanoseconds: metadata.ctime_nsec(),
})
}
@@ -103,6 +109,7 @@ fn playback_file_identity(file: &File) -> std::io::Result
volume_serial_number: information.volume_serial_number,
file_index: ((information.file_index_high as u64) << 32)
| information.file_index_low as u64,
+ last_write_time: information.last_write_time,
})
}
From 3fda1a77c4961820e860ab18bf4f6402ab90705b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 20:36:48 +0900
Subject: [PATCH 134/153] test(player): prove playback revocation is
linearizable
---
.../src-tauri/src/playback_protocol.rs | 43 ++++++++++++++++---
1 file changed, 36 insertions(+), 7 deletions(-)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 9772c75ec..685f7e39d 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -177,13 +177,22 @@ impl PlaybackAuthority {
let Some(project_id) = project_id_from_path(request.uri().path()) else {
return empty_response(StatusCode::NOT_FOUND);
};
- let Some(authority) = self.current_authority(project_id) else {
- return empty_response(StatusCode::NOT_FOUND);
- };
- if request.method() != Method::GET && request.method() != Method::HEAD {
- return empty_response(StatusCode::METHOD_NOT_ALLOWED);
- }
- serve_authorized_source(&authority, &request)
+ self.with_current_authority(project_id, |authority| {
+ if request.method() != Method::GET && request.method() != Method::HEAD {
+ return empty_response(StatusCode::METHOD_NOT_ALLOWED);
+ }
+ serve_authorized_source(authority, &request)
+ })
+ .unwrap_or_else(|| empty_response(StatusCode::NOT_FOUND))
+ }
+
+ fn with_current_authority(
+ &self,
+ project_id: &str,
+ use_authority: impl FnOnce(&PlaybackSourceAuthority) -> R,
+ ) -> Option {
+ let authority = self.current_authority(project_id)?;
+ Some(use_authority(&authority))
}
fn current_authority(&self, project_id: &str) -> Option {
@@ -435,6 +444,26 @@ mod tests {
let _ = std::fs::remove_dir_all(second_root);
}
+ #[test]
+ fn authorized_response_keeps_revocation_authority_until_use_finishes() {
+ let (root, source) = test_source("linearizable-revocation", b"audio");
+ let authority = PlaybackAuthority::default();
+ authority
+ .activate("project-150-2", &source)
+ .expect("source should activate");
+
+ let lock_was_held = authority.with_current_authority("project-150-2", |_| {
+ authority.current.try_lock().is_err()
+ });
+
+ assert_eq!(
+ lock_was_held,
+ Some(true),
+ "an authorized response must retain revocation authority until serving finishes"
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn current_authority_rejects_path_shaped_or_unknown_tokens() {
let (root, source) = test_source("token", b"audio");
From d5dc2fc4933023e3af0e6e3455ec2b9abf58ef19 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 20:38:06 +0900
Subject: [PATCH 135/153] fix(player): serialize playback authority revocation
---
apps/desktop/src-tauri/src/playback_protocol.rs | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs
index 685f7e39d..136e47e2d 100644
--- a/apps/desktop/src-tauri/src/playback_protocol.rs
+++ b/apps/desktop/src-tauri/src/playback_protocol.rs
@@ -191,18 +191,14 @@ impl PlaybackAuthority {
project_id: &str,
use_authority: impl FnOnce(&PlaybackSourceAuthority) -> R,
) -> Option {
- let authority = self.current_authority(project_id)?;
- Some(use_authority(&authority))
- }
-
- fn current_authority(&self, project_id: &str) -> Option {
if !is_valid_project_id(project_id) {
return None;
}
- self.current
- .lock()
- .ok()
- .and_then(|current| current.as_ref().filter(|entry| entry.project_id == project_id).cloned())
+ let current = self.current.lock().ok()?;
+ let authority = current
+ .as_ref()
+ .filter(|entry| entry.project_id == project_id)?;
+ Some(use_authority(authority))
}
}
From 22d41c1662a61080fe0eab95f333edc7edd31038 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 21:13:56 +0900
Subject: [PATCH 136/153] test(player): use renderer-safe playback authority
fixture
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 6bb8cb566..ce55f0ac9 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -32,7 +32,7 @@ function createLocalSourceBootstrap(): ProjectBootstrapSummary {
cacheRoot: "/tmp/bandscope/cache/project-1",
tempRoot: "/tmp/bandscope/temp/project-1",
source: {
- sourcePath: "/Users/test/Music/late-night-set.wav",
+ sourcePath: "bandscope-project://project-1-1",
fileName: "late-night-set.wav",
extension: "wav",
fileSizeBytes: 1_024_000,
From 00daa490ec11927cd58d6ce09b940b058e938792 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:09:02 +0900
Subject: [PATCH 137/153] test(player): require roadmap focus for selected loop
---
.../Workspace.loop-roadmap-sync.test.tsx | 76 ++++++++++++++++++-
1 file changed, 75 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
index 5d31ccd5d..090be7483 100644
--- a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
@@ -1,8 +1,34 @@
import { fireEvent, render } 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 { Workspace } from "./Workspace";
+const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
+const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "scrollIntoView",
+);
+
+afterEach(() => {
+ if (originalMatchMediaDescriptor) {
+ Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor);
+ } else {
+ Reflect.deleteProperty(window, "matchMedia");
+ }
+
+ if (originalScrollIntoViewDescriptor) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "scrollIntoView",
+ originalScrollIntoViewDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
+ }
+
+ vi.restoreAllMocks();
+});
+
describe("Workspace loop roadmap synchronization", () => {
it("projects the player-selected section onto the production roadmap", () => {
const song = createDemoRehearsalSong();
@@ -32,4 +58,52 @@ describe("Workspace loop roadmap synchronization", () => {
expect(verseCard?.className).not.toContain("ring-cyan-300/70");
expect(chorusCard?.className).toContain("ring-cyan-300/70");
});
+
+ it("focuses the selected roadmap occurrence without motion when reduced motion is requested", () => {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = verse.id;
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ song.sections = [verse, chorus];
+
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: vi.fn((query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ render( );
+
+ const sectionButtons = Array.from(
+ document.querySelectorAll(
+ 'button[id^="rehearsal-loop-section-"]',
+ ),
+ );
+ const chorusCard = document.getElementById("workspace-section-card-1");
+
+ expect(sectionButtons).toHaveLength(2);
+ fireEvent.click(sectionButtons[1]!);
+
+ expect(document.activeElement).toBe(chorusCard);
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "auto",
+ block: "nearest",
+ inline: "center",
+ });
+ });
});
From 8c49b47d3bd5d208ae666ddd93f086c90276838f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:12:09 +0900
Subject: [PATCH 138/153] fix(player): focus selected roadmap occurrence
---
.../src/features/workspace/SectionRoadmap.tsx | 41 ++++++++++++++++++-
1 file changed, 40 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index c4d276cf2..d894d3ad3 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -1,5 +1,5 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
-import { useId, useMemo } from "react";
+import { useEffect, useId, useMemo, useRef } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
@@ -20,6 +20,45 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
+ const lastLoopedSectionIndex = useRef(loopedSectionIndex);
+ const observedInitialSelection = useRef(loopedSectionIndex !== null);
+
+ useEffect(() => {
+ if (loopedSectionIndex === null) {
+ lastLoopedSectionIndex.current = null;
+ return;
+ }
+
+ if (!observedInitialSelection.current) {
+ observedInitialSelection.current = true;
+ lastLoopedSectionIndex.current = loopedSectionIndex;
+ return;
+ }
+
+ if (lastLoopedSectionIndex.current === loopedSectionIndex) {
+ return;
+ }
+ lastLoopedSectionIndex.current = loopedSectionIndex;
+
+ const selectedCard = document.getElementById(
+ `workspace-section-card-${loopedSectionIndex}`,
+ );
+ if (!(selectedCard instanceof HTMLElement)) {
+ return;
+ }
+
+ const prefersReducedMotion =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (typeof selectedCard.scrollIntoView === "function") {
+ selectedCard.scrollIntoView({
+ behavior: prefersReducedMotion ? "auto" : "smooth",
+ block: "nearest",
+ inline: "center",
+ });
+ }
+ selectedCard.focus();
+ }, [loopedSectionIndex]);
/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
From 161c954efd0b9856cdaf4549487023442bc5e977 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:16:36 +0900
Subject: [PATCH 139/153] test(player): preserve arrow-key focus across roadmap
sync
---
.../Workspace.loop-roadmap-sync.test.tsx | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
index 090be7483..c5660ee0d 100644
--- a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
@@ -106,4 +106,42 @@ describe("Workspace loop roadmap synchronization", () => {
inline: "center",
});
});
+
+ it("keeps focus in the section picker across consecutive arrow-key selections", () => {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ const bridge = structuredClone(song.sections[0]!);
+ bridge.id = "bridge-1";
+ bridge.label = "bridge";
+ bridge.timeRange = { start: 64, end: 88 };
+ song.sections = [verse, chorus, bridge];
+
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+
+ render( );
+
+ const sectionButtons = Array.from(
+ document.querySelectorAll(
+ 'button[id^="rehearsal-loop-section-"]',
+ ),
+ );
+
+ expect(sectionButtons).toHaveLength(3);
+ sectionButtons[0]!.focus();
+
+ fireEvent.keyDown(sectionButtons[0]!, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(sectionButtons[1]);
+
+ fireEvent.keyDown(sectionButtons[1]!, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(sectionButtons[2]);
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
+ });
});
From 90e74b4eee4e4654b2b950b37a74b549a02eaac8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:18:12 +0900
Subject: [PATCH 140/153] fix(player): preserve keyboard focus during roadmap
sync
---
apps/desktop/src/features/workspace/SectionRoadmap.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index d894d3ad3..b508ed284 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -57,7 +57,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIn
inline: "center",
});
}
- selectedCard.focus();
+
+ const activeElement = document.activeElement;
+ const sectionPickerOwnsFocus =
+ activeElement instanceof HTMLButtonElement &&
+ activeElement.id.startsWith("rehearsal-loop-section-");
+ if (!sectionPickerOwnsFocus) {
+ selectedCard.focus();
+ }
}, [loopedSectionIndex]);
/** Documented. */
From a7831a62ee7e3d8cbc19d192e73da9b12b4ebb62 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:24:00 +0900
Subject: [PATCH 141/153] test(player): require timeline-to-transport selection
---
.../Workspace.loop-roadmap-sync.test.tsx | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
index c5660ee0d..a88ce3a19 100644
--- a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx
@@ -3,6 +3,10 @@ import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
+const originalLanguageDescriptor = Object.getOwnPropertyDescriptor(
+ window.navigator,
+ "language",
+);
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
@@ -10,6 +14,10 @@ const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
);
afterEach(() => {
+ if (originalLanguageDescriptor) {
+ Object.defineProperty(window.navigator, "language", originalLanguageDescriptor);
+ }
+
if (originalMatchMediaDescriptor) {
Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor);
} else {
@@ -59,6 +67,49 @@ describe("Workspace loop roadmap synchronization", () => {
expect(chorusCard?.className).toContain("ring-cyan-300/70");
});
+ it("selects the same player and roadmap occurrence from the song-structure timeline", () => {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 64 };
+ song.sections = [verse, chorus];
+
+ render( );
+
+ const timelineChorus = document.querySelector(
+ 'button[data-song-structure-section-index="1"]',
+ );
+ const playerButtons = Array.from(
+ document.querySelectorAll(
+ 'button[id^="rehearsal-loop-section-"]',
+ ),
+ );
+ const chorusCard = document.getElementById("workspace-section-card-1");
+
+ expect(timelineChorus).not.toBeNull();
+ expect(playerButtons).toHaveLength(2);
+ fireEvent.click(timelineChorus!);
+
+ expect(timelineChorus?.getAttribute("aria-pressed")).toBe("true");
+ expect(playerButtons[1]?.getAttribute("aria-pressed")).toBe("true");
+ expect(chorusCard?.className).toContain("ring-cyan-300/70");
+ });
+
+ it("localizes the song-structure timeline region for Korean assistive technology", () => {
+ Object.defineProperty(window.navigator, "language", {
+ configurable: true,
+ value: "ko-KR",
+ });
+
+ const view = render( );
+
+ expect(
+ view.getByRole("region", { name: "스크롤 가능한 곡 구조 타임라인" }),
+ ).toBeTruthy();
+ });
+
it("focuses the selected roadmap occurrence without motion when reduced motion is requested", () => {
const song = createDemoRehearsalSong();
const verse = structuredClone(song.sections[0]!);
From 069fa5a4747cba2952ccaf1bd8c2650d645d556c Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 22:47:31 +0900
Subject: [PATCH 142/153] fix(player): route timeline selection through
transport
---
.../features/workspace/RehearsalPlayer.tsx | 33 ++++++++
.../src/features/workspace/Workspace.tsx | 75 ++++++++++++++++---
apps/desktop/src/locales/en/common.json | 1 +
apps/desktop/src/locales/ko/common.json | 1 +
4 files changed, 98 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
index feecc5292..14d582265 100644
--- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
+++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx
@@ -43,6 +43,10 @@ interface RehearsalPlayerProps {
song: RehearsalSong;
onSongUpdate?: (song: RehearsalSong) => void;
onSelectedSectionIndexChange?: (sectionIndex: number | null) => void;
+ sectionSelectionRequest?: {
+ sectionIndex: number;
+ requestId: number;
+ } | null;
hasLocalAudio?: boolean;
audioSourcePath?: string | null;
activeRole?: string | null;
@@ -135,6 +139,7 @@ export function RehearsalPlayer({
song,
onSongUpdate,
onSelectedSectionIndexChange,
+ sectionSelectionRequest = null,
hasLocalAudio = false,
audioSourcePath = null,
activeRole = null,
@@ -147,6 +152,7 @@ export function RehearsalPlayer({
[activeRole, song],
);
const [selectedLoopKey, setSelectedLoopKey] = useState(null);
+ const lastHandledSectionSelectionRequestId = useRef(0);
const [boundaryError, setBoundaryError] = useState(false);
const selectedLoop =
playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ??
@@ -155,6 +161,33 @@ export function RehearsalPlayer({
useEffect(() => {
onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null);
}, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]);
+ useEffect(() => {
+ if (!sectionSelectionRequest) {
+ return;
+ }
+ const { requestId, sectionIndex } = sectionSelectionRequest;
+ if (
+ !Number.isSafeInteger(requestId) ||
+ requestId <= lastHandledSectionSelectionRequestId.current
+ ) {
+ return;
+ }
+ lastHandledSectionSelectionRequestId.current = requestId;
+ if (
+ !Number.isSafeInteger(sectionIndex) ||
+ sectionIndex < 0 ||
+ sectionIndex >= song.sections.length
+ ) {
+ return;
+ }
+ const requestedLoop = playableLoops.find(
+ (loop) => loop.sourceIndex === sectionIndex,
+ );
+ if (!requestedLoop) {
+ return;
+ }
+ setSelectedLoopKey(loopSelectionKey(requestedLoop));
+ }, [playableLoops, sectionSelectionRequest, song.sections.length]);
const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null;
const [boundaryDraft, setBoundaryDraft] = useState(() => ({
end: selectedLoop ? String(selectedLoop.endSeconds) : "",
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 8585b1590..6fc205e17 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -72,8 +72,18 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro
}
}
-/** Documented. */
-const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) {
+/** Render renderer-position section actions without creating a second transport store. */
+const SongStructure = memo(function SongStructure({
+ sections,
+ t,
+ selectedSectionIndex,
+ onSelectSection,
+}: {
+ sections: RehearsalSong["sections"];
+ t: Translator;
+ selectedSectionIndex: number | null;
+ onSelectSection: (sectionIndex: number) => void;
+}) {
return (
@@ -85,21 +95,37 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
role="region"
tabIndex={0}
className="overflow-x-auto rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(8,18,35,0.96),rgba(2,6,23,0.98))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
- aria-label="Scrollable song structure timeline"
+ aria-label={t("workspaceSongStructureTimelineRegionAria")}
>
- {sections.map((section) => (
-
-
- {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}
-
-
{section.groove}
-
- ))}
+ {sections.map((section, sectionIndex) => {
+ const selected = selectedSectionIndex === sectionIndex;
+ return (
+
+
onSelectSection(sectionIndex)}
+ className="min-h-11 w-full rounded-lg px-1 py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
+ >
+
+ {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}
+
+ {section.groove}
+
+
+ );
+ })}
@@ -124,6 +150,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const [activeRole, setActiveRole] = useState(null);
const [loopStartNonce, setLoopStartNonce] = useState(0);
const [loopedSectionIndex, setLoopedSectionIndex] = useState(null);
+ const [sectionSelectionRequest, setSectionSelectionRequest] = useState<{
+ sectionIndex: number;
+ requestId: number;
+ } | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const parsedSourceBootstrap = useMemo(
() => safeProjectBootstrapSummary(sourceBootstrap),
@@ -245,6 +275,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
+ /** Route one timeline occurrence through the canonical player selection state. */
+ const requestSectionSelection = (sectionIndex: number): void => {
+ if (
+ !Number.isSafeInteger(sectionIndex) ||
+ sectionIndex < 0 ||
+ sectionIndex >= song.sections.length
+ ) {
+ return;
+ }
+ setSectionSelectionRequest((current) => ({
+ sectionIndex,
+ requestId: (current?.requestId ?? 0) + 1,
+ }));
+ };
+
/** Documented. */
const handleExportCueSheet = () => {
const csv = generateCueSheetCsv(song);
@@ -372,12 +417,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
-
+
Date: Thu, 3 Sep 2026 23:07:51 +0900
Subject: [PATCH 143/153] test(workspace): cover timeline selection outside
active role
---
...Workspace.timeline-role-selection.test.tsx | 42 +++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
new file mode 100644
index 000000000..4047fe428
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
@@ -0,0 +1,42 @@
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+describe("Workspace timeline role selection", () => {
+ it("clears the role filter before selecting a timeline section outside that role", async () => {
+ 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);
+
+ const { container } = render( );
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ const roleFilteredSections = screen.getByRole("group", {
+ name: "Playable sections for Lead Vocal",
+ });
+ expect(
+ within(roleFilteredSections).queryByRole("button", { name: /chorus/i }),
+ ).toBeNull();
+
+ const chorusTimelineButton = container.querySelector(
+ '[data-song-structure-section-index="1"]',
+ );
+ expect(chorusTimelineButton).not.toBeNull();
+ fireEvent.click(chorusTimelineButton!);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("tab", { name: "All Roles", selected: true }),
+ ).toBeTruthy();
+ const allSections = screen.getByRole("group", { name: "Playable sections" });
+ expect(
+ within(allSections).getByRole("button", { name: /chorus/i }),
+ ).toBeTruthy();
+ });
+ });
+});
From 73c3b9c95836074f95aa6cd588bad5a34354d9be Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 23:09:57 +0900
Subject: [PATCH 144/153] fix(workspace): honor full-form timeline selection
across role filters
---
apps/desktop/src/features/workspace/Workspace.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 6fc205e17..39a3736c5 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -284,6 +284,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
) {
return;
}
+ const requestedSection = song.sections[sectionIndex];
+ if (
+ resolvedActiveRole &&
+ requestedSection &&
+ !requestedSection.roles.some((role) => role.id === resolvedActiveRole)
+ ) {
+ setActiveRole(null);
+ }
setSectionSelectionRequest((current) => ({
sectionIndex,
requestId: (current?.requestId ?? 0) + 1,
From 449300fabb82e0902bb2e022313c4f643dca9df2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 23:15:18 +0900
Subject: [PATCH 145/153] test(workspace): preserve timeline roadmap focus
succession
---
...Workspace.timeline-role-selection.test.tsx | 78 ++++++++++++++++++-
1 file changed, 77 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
index 4047fe428..342de9e1e 100644
--- a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
@@ -1,8 +1,23 @@
import { fireEvent, render, screen, waitFor, within } 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 { Workspace } from "./Workspace";
+const originalMatchMedia = window.matchMedia;
+const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+
+afterEach(() => {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: originalMatchMedia,
+ });
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: originalScrollIntoView,
+ });
+ vi.restoreAllMocks();
+});
+
describe("Workspace timeline role selection", () => {
it("clears the role filter before selecting a timeline section outside that role", async () => {
const song = createDemoRehearsalSong();
@@ -39,4 +54,65 @@ describe("Workspace timeline role selection", () => {
).toBeTruthy();
});
});
+
+ it("focuses an explicitly reselected first occurrence with reduced-motion scrolling", async () => {
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: vi.fn((query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ const { container } = render( );
+ const firstTimelineButton = container.querySelector(
+ '[data-song-structure-section-index="0"]',
+ );
+ expect(firstTimelineButton).not.toBeNull();
+ fireEvent.click(firstTimelineButton!);
+
+ await waitFor(() => {
+ expect(document.activeElement?.id).toBe("workspace-section-card-0");
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "auto",
+ block: "nearest",
+ inline: "center",
+ });
+ });
+ });
+
+ it("focuses the clicked renderer occurrence when analysis section ids collide", async () => {
+ const song = createDemoRehearsalSong();
+ const duplicate = structuredClone(song.sections[0]!);
+ duplicate.timeRange = { start: 40, end: 64 };
+ song.sections.push(duplicate);
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView,
+ });
+
+ const { container } = render( );
+ const secondTimelineButton = container.querySelector(
+ '[data-song-structure-section-index="1"]',
+ );
+ expect(secondTimelineButton).not.toBeNull();
+ fireEvent.click(secondTimelineButton!);
+
+ await waitFor(() => {
+ expect(document.activeElement?.id).toBe("workspace-section-card-1");
+ expect(scrollIntoView).toHaveBeenCalled();
+ });
+ });
});
From 2535f713467b45d27f02e32530e5953dd41c2713 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 3 Sep 2026 23:19:53 +0900
Subject: [PATCH 146/153] fix(workspace): preserve roadmap focus on timeline
actions
---
.../src/features/workspace/Workspace.tsx | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 39a3736c5..0b8e750ca 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -72,6 +72,25 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro
}
}
+/** Focus one renderer-owned roadmap occurrence after an explicit timeline action. */
+function focusWorkspaceSection(sectionIndex: number): void {
+ const sectionCard = document.getElementById(`workspace-section-card-${sectionIndex}`);
+ if (!(sectionCard instanceof HTMLElement)) {
+ return;
+ }
+ const prefersReducedMotion =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (typeof sectionCard.scrollIntoView === "function") {
+ sectionCard.scrollIntoView({
+ behavior: prefersReducedMotion ? "auto" : "smooth",
+ block: "nearest",
+ inline: "center",
+ });
+ }
+ sectionCard.focus();
+}
+
/** Render renderer-position section actions without creating a second transport store. */
const SongStructure = memo(function SongStructure({
sections,
@@ -292,6 +311,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
) {
setActiveRole(null);
}
+ focusWorkspaceSection(sectionIndex);
setSectionSelectionRequest((current) => ({
sectionIndex,
requestId: (current?.requestId ?? 0) + 1,
From c58eb3b48f1d453bcfd2642bf41b2c997179f946 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:08:17 +0900
Subject: [PATCH 147/153] test(player): retain role on rejected timeline
section
---
...Workspace.timeline-role-selection.test.tsx | 42 +++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
index 342de9e1e..867a065f7 100644
--- a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx
@@ -55,6 +55,48 @@ describe("Workspace timeline role selection", () => {
});
});
+ it("retains the role and selected loop when a timeline section is not admissible", async () => {
+ const song = createDemoRehearsalSong();
+ const malformed = structuredClone(song.sections[0]!);
+ malformed.id = "malformed-chorus";
+ malformed.label = "malformed chorus";
+ malformed.timeRange = {
+ start: Number.NaN,
+ end: Number.POSITIVE_INFINITY,
+ };
+ malformed.roles = malformed.roles.filter(
+ (role) => role.id !== "lead-vocal",
+ );
+ song.sections.push(malformed);
+
+ const { container } = render( );
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ const roleFilteredSections = screen.getByRole("group", {
+ name: "Playable sections for Lead Vocal",
+ });
+ const verseButton = within(roleFilteredSections).getByRole("button", {
+ name: /verse/i,
+ });
+ expect(verseButton.getAttribute("aria-pressed")).toBe("true");
+
+ const malformedTimelineButton = container.querySelector(
+ '[data-song-structure-section-index="1"]',
+ );
+ expect(malformedTimelineButton).not.toBeNull();
+ fireEvent.click(malformedTimelineButton!);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("tab", { name: "Lead Vocal", selected: true }),
+ ).toBeTruthy();
+ expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent(
+ "Showing sections that include Lead Vocal.",
+ );
+ expect(verseButton.getAttribute("aria-pressed")).toBe("true");
+ });
+ });
+
it("focuses an explicitly reselected first occurrence with reduced-motion scrolling", async () => {
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
From f395479d4ff5baf5751b326a5d346685c9a569d2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:11:22 +0900
Subject: [PATCH 148/153] fix(player): preserve role on inadmissible timeline
selection
---
apps/desktop/src/features/workspace/Workspace.tsx | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 0b8e750ca..18bc9cd10 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { isPlayableAudioSource, RehearsalPlayer } from "./RehearsalPlayer";
+import { resolveLoopWindows } from "./rehearsalTransport";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -303,11 +304,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
) {
return;
}
- const requestedSection = song.sections[sectionIndex];
+ const requestedLoopIsAdmitted = resolveLoopWindows(song).some(
+ (loop) => loop.sourceIndex === sectionIndex,
+ );
+ const requestedLoopIsInActiveRole = resolvedActiveRole
+ ? resolveLoopWindows(song, resolvedActiveRole).some(
+ (loop) => loop.sourceIndex === sectionIndex,
+ )
+ : true;
if (
resolvedActiveRole &&
- requestedSection &&
- !requestedSection.roles.some((role) => role.id === resolvedActiveRole)
+ requestedLoopIsAdmitted &&
+ !requestedLoopIsInActiveRole
) {
setActiveRole(null);
}
From eb4fe64298753325020b3b942a394d625d06a1e0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:18:20 +0900
Subject: [PATCH 149/153] test(ui): reject false-active bass transcription
control
---
...kspace.transcription-availability.test.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
diff --git a/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx b/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
new file mode 100644
index 000000000..61b70e200
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { Workspace } from "./Workspace";
+
+describe("Workspace transcription availability", () => {
+ it("does not advertise bass transcription as actionable without a production transcription path", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "low-end",
+ name: "Bass Guitar",
+ };
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const transcribeButton = screen.getByRole("button", {
+ name: "Transcribe Bass",
+ });
+ expect(transcribeButton.getAttribute("aria-disabled")).toBe("true");
+ expect(transcribeButton.getAttribute("title")).toBe(
+ "Bass Guitar transcription is not available yet.",
+ );
+ });
+});
From e1d850eac820338f8f37e4f0aa909bbef3d757e7 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:20:01 +0900
Subject: [PATCH 150/153] fix(ui): stop advertising unavailable transcription
as active
---
.../src/features/workspace/Workspace.tsx | 32 ++++++-------------
1 file changed, 10 insertions(+), 22 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 18bc9cd10..60660615f 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -219,7 +219,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
if (!resolvedActiveRole) return undefined;
return roleMap.get(resolvedActiveRole);
}, [resolvedActiveRole, roleMap]);
- const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
const firstRange = useMemo(() => firstRangeSqueeze(song, resolvedActiveRole), [resolvedActiveRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(
@@ -533,27 +532,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
>
Solo / mute others
- {canTranscribeBass ? (
-
- Transcribe Bass
-
- ) : (
-
- Transcribe Bass
-
- )}
+
+ Transcribe Bass
+
From 175e5f7b9be3176b3c69f79e6b5bd9e53660da21 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:21:10 +0900
Subject: [PATCH 151/153] test(ui): align bass transcription contract with
actual capability
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index ce55f0ac9..3945694b8 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -277,7 +277,7 @@ describe("Workspace", () => {
);
});
- it("enables bass transcription from selected role metadata rather than role id text", () => {
+ it("keeps bass transcription unavailable when role metadata names bass but no action exists", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
@@ -290,9 +290,11 @@ describe("Workspace", () => {
const transcribeButton = screen.getByRole("button", {
name: "Transcribe Bass",
- }) as HTMLButtonElement;
- expect(transcribeButton.disabled).toBe(false);
- expect(transcribeButton.title).toBe("Transcribe part");
+ });
+ expect(transcribeButton.getAttribute("aria-disabled")).toBe("true");
+ expect(transcribeButton.getAttribute("title")).toBe(
+ "Bass Guitar transcription is not available yet.",
+ );
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
From 803019e092376383500c0a4f8c2f52c68eb7232f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 4 Sep 2026 04:21:28 +0900
Subject: [PATCH 152/153] test(ui): consolidate transcription regression
coverage
---
...kspace.transcription-availability.test.tsx | 26 -------------------
1 file changed, 26 deletions(-)
delete mode 100644 apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
diff --git a/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx b/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
deleted file mode 100644
index 61b70e200..000000000
--- a/apps/desktop/src/features/workspace/Workspace.transcription-availability.test.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong } from "@bandscope/shared-types";
-import { describe, expect, it } from "vitest";
-import { Workspace } from "./Workspace";
-
-describe("Workspace transcription availability", () => {
- it("does not advertise bass transcription as actionable without a production transcription path", () => {
- const song = createDemoRehearsalSong();
- song.sections[0]!.roles[0] = {
- ...song.sections[0]!.roles[0]!,
- id: "low-end",
- name: "Bass Guitar",
- };
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
-
- const transcribeButton = screen.getByRole("button", {
- name: "Transcribe Bass",
- });
- expect(transcribeButton.getAttribute("aria-disabled")).toBe("true");
- expect(transcribeButton.getAttribute("title")).toBe(
- "Bass Guitar transcription is not available yet.",
- );
- });
-});
From 09bedd835475015379716292e63e6be376fceec9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 5 Sep 2026 01:51:34 +0900
Subject: [PATCH 153/153] fix(player): remove stale Tauri lock orphan
---
apps/desktop/src-tauri/Cargo.lock | 7 -------
apps/desktop/src-tauri/Cargo.toml | 2 +-
2 files changed, 1 insertion(+), 8 deletions(-)
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 284725de2..67b39844c 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -1219,12 +1219,6 @@ 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"
@@ -2957,7 +2951,6 @@ 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 c11a5946f..bcafbab44 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -22,4 +22,4 @@ default = []
[package.metadata.opencode.coverage]
# Baseline measured by the central Rust coverage evidence gate on PR #527.
-minimum_lines = 42
\ No newline at end of file
+minimum_lines = 42