From 0eeec7baa0e1c6ed26fd427e3484ac7b418691e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:15:23 +0000 Subject: [PATCH 01/70] feat(workspace): loop tonight's first section from the map Replace the coming-soon loop control with a fail-closed rehearsal transport that arms the first valid section, counts in at the admitted tempo, and names the next play, pause, or choose-local-song action. Advances #961. Does not decode local audio, copy #783, or open a parallel MIR lane. --- AGENTS.md | 16 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/RehearsalPlayer.test.tsx | 80 +++++ .../features/workspace/RehearsalPlayer.tsx | 239 ++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 29 ++ .../src/features/workspace/Workspace.tsx | 19 +- .../workspace/rehearsalTransport.test.ts | 127 ++++++++ .../features/workspace/rehearsalTransport.ts | 307 ++++++++++++++++++ apps/desktop/src/locales/en/common.json | 16 +- apps/desktop/src/locales/ko/common.json | 16 +- 12 files changed, 845 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.tsx create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.test.ts create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..ce03853d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ # AGENTS.md ## Project overview + - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- The ready workspace must name a next rehearsal action. Tonight's first playable section loop (count-in, pause, stop) is the #961 transport slice; stem playback and pitch-preserving rate remain later work. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. @@ -12,15 +14,19 @@ - Repository governance and Gitflow rules live in `docs/repository/governance.md`, `docs/repository/bootstrap-plan.md`, and `docs/repository/gitflow.md`. ## Security workflow + - Before writing PRDs, TRDs, UX copy, architecture changes, or implementation plans that touch risky boundaries, read `docs/security/app-security.md`. - If a task touches files, URLs, subprocesses, ffmpeg or native tools, WebView, local backend or IPC, updates, model downloads, project formats, logs, telemetry, or exports, the result must include `Security Notes`. - `Security Notes` should cover untrusted inputs, trust boundaries, allowlists or validation, safe failure, logging/privacy impact, and test points. + ## Agent guidance (CWL governance) + This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working in this repo. ### Security & review gate + - Every PR runs a central **Security Scan** required gate: `osv-scan` + `dependency-review` (diff-scoped) and `trivy-fs` (repo-wide, CRITICAL/HIGH, fixable). It runs on every PR base, **including stacked PRs**. Gating is by the Security Scan **job result**. - A failing `trivy-fs` is a **REAL finding, not a flake.** Read the job log (it prints each finding's rule id / severity / file) or the run's SARIF results, then **remediate**: - This repo ships **no Dockerfile and no k8s manifests**, so findings are almost always dependency vulns. Bump the offending package in the relevant lockfile — `apps/desktop/src-tauri/Cargo.lock` (Rust/Tauri), `package-lock.json` (Node), or `uv.lock` / `services/analysis-engine` (Python). @@ -30,10 +36,13 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - The org `code_scanning` ruleset is intentionally **CodeQL-only** (multiple code-scanning tools can't converge on one PR ref). Do **not** add tools to the `code_scanning` rule; enforcement stays on the Security Scan job. ### Code exploration + - This repo has **no `.codegraph/` index**, so use normal search (grep/find/ripgrep) to locate and understand code. If a `.codegraph/` directory is later added at the repo root, prefer CodeGraph (`codegraph explore ""`, or the code-review-graph MCP tools) **before** grep/find — it surfaces callers/callees/impact that text search misses. + ## Supply chain workflow + - Before adding or changing dependencies, GitHub Actions, bundled binaries, or model artifacts, read `docs/security/dependency-policy.md`. - New direct dependencies must include admission rationale covering purpose, dependency class, alternatives, maintainer trust, license fit, known security issues, transitive footprint, and BandScope release risk. - Lockfiles, dependency review, audit, SBOM generation, and supplemental component inventory are mandatory and must not be skipped or loosened. @@ -41,26 +50,31 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Use `FAILED` when repo-controlled supply-chain artifacts are missing; use `BLOCKED` only when GitHub permission, auth, network, or platform capability prevents enforcement. ## Cross-platform build workflow + - Before changing CI, packaging, release flows, or native desktop build settings, read `docs/security/cross-platform-build-policy.md`. - Windows and macOS builds are required security controls for `develop`, `main`, and release validation. - Protected-branch build checks for Windows and macOS must not be removed, downgraded, or treated as optional. ## GitHub bootstrap workflow + - Before declaring a GitHub task blocked, read `docs/workflow/github-bootstrap-execution-policy.md`. - Missing local git state, missing GitHub repo, missing `main`, missing `develop`, or missing initial workflows are bootstrap conditions, not default blockers. - For GitHub tasks, only use `BLOCKED` when the failure is caused by missing GitHub permissions, missing auth, missing network access, or platform-level feature limits. ## Setup commands + - Node: `npm install` - Python: `uv sync --project services/analysis-engine --group dev` ## Build / Test commands + - Full harness check: `./scripts/harness/quickcheck.sh` - Frontend tests: `npm run test --workspaces --if-present` - Python tests: `uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100` - Typecheck: `npm run typecheck --workspaces --if-present && uv run --project services/analysis-engine mypy src` ## Architecture references + - `ARCHITECTURE.md` - `docs/engineering/acceptance-criteria.md` - `docs/engineering/harness-engineering.md` @@ -80,6 +94,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - `docs/plans/2026-03-10-bandscope-harness.md` ## Code style + - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. @@ -87,6 +102,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. ## Safety + - Do not add network-dependent runtime paths for local analysis. - Treat YouTube import as policy-constrained and fallback-friendly. - Treat files, URLs, metadata, model artifacts, and project files as untrusted input. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..d27bb5686 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-21 ## Brand source @@ -86,6 +86,7 @@ Last updated: 2026-03-11 - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form + - a local rehearsal transport that arms the first valid section loop, counts in at the admitted tempo, and names the next play/pause/stop action without pretending disk audio is playing when no local song is loaded ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..403727250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Tonight's rehearsal map now arms the first valid section loop, runs a tempo count-in, and names the next play, pause, or choose-local-song action instead of a coming-soon control. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..52399239a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The workspace `RehearsalPlayer` owns tonight's first section loop and count-in clock; it does not decode local audio in this slice. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx new file mode 100644 index 000000000..d6318ccb9 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -0,0 +1,80 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language, + }); +} + +describe("RehearsalPlayer", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("names the first playable loop and asks for a local song before hearing it", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + render(); + + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i); + expect( + screen.getByRole("button", { name: /Start the count-in/i }), + ).toBeTruthy(); + }); + + it("counts in then loops the selected section on the map clock", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const song = createDemoRehearsalSong(); + render(); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 120 BPM/i); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/verse is looping 0:10–0:30/i); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect( + screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"), + ).toContain("%"); + }); + + it("stays fail-closed when no section has a usable window", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Add a section with a start and end time/i); + expect( + ( + screen.getByRole("button", { + name: /Start the count-in/i, + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx new file mode 100644 index 000000000..4eedeada3 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -0,0 +1,239 @@ +import { useEffect, useMemo, useState, type ReactElement } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + beatDurationMs, + createIdleTransportState, + fillRehearsalCopy, + formatRehearsalClock, + isPlayableLoopSection, + nextActionTemplateKey, + nextActionValues, + reduceRehearsalTransport, + resolveLoopWindow, + type RehearsalTransportState, +} from "./rehearsalTransport"; + +interface RehearsalPlayerProps { + song: RehearsalSong; + hasLocalAudio?: boolean; + startNonce?: number; +} + +const PLAYHEAD_TICK_SECONDS = 0.1; + +/** Documented. */ +function loopProgressPercent(state: RehearsalTransportState): number { + if (!state.loop) { + return 0; + } + const duration = state.loop.endSeconds - state.loop.startSeconds; + if (!(duration > 0)) { + return 0; + } + return Math.min( + 100, + Math.max( + 0, + ((state.playheadSeconds - state.loop.startSeconds) / duration) * 100, + ), + ); +} + +/** Render tonight's first section loop with a count-in and a named next action. */ +export function RehearsalPlayer({ + song, + hasLocalAudio = false, + startNonce = 0, +}: RehearsalPlayerProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const playableSections = useMemo( + () => + Array.isArray(song.sections) + ? song.sections.filter((section) => isPlayableLoopSection(section)) + : [], + [song], + ); + const [selectedSectionId, setSelectedSectionId] = useState( + playableSections[0]?.id ?? null, + ); + const [transport, setTransport] = useState(() => + reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop: resolveLoopWindow(song, playableSections[0]?.id), + }), + ); + + useEffect(() => { + const nextLoop = resolveLoopWindow(song, selectedSectionId); + setTransport((current) => + reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }), + ); + }, [song, selectedSectionId]); + + useEffect(() => { + if (startNonce <= 0) { + return; + } + setTransport((current) => { + const armed = current.loop + ? current + : reduceRehearsalTransport(current, { + type: "arm", + loop: resolveLoopWindow(song, selectedSectionId), + }); + return reduceRehearsalTransport(armed, { type: "start" }); + }); + }, [startNonce, song, selectedSectionId]); + + useEffect(() => { + if (transport.phase !== "counting-in" || !transport.loop) { + return undefined; + } + const timer = window.setInterval(() => { + setTransport((current) => + reduceRehearsalTransport(current, { type: "beat" }), + ); + }, beatDurationMs(transport.loop.tempoBpm)); + return () => window.clearInterval(timer); + }, [transport.phase, transport.loop]); + + useEffect(() => { + if (transport.phase !== "looping" || !transport.loop) { + return undefined; + } + const timer = window.setInterval(() => { + setTransport((current) => + reduceRehearsalTransport(current, { + type: "tick", + deltaSeconds: PLAYHEAD_TICK_SECONDS, + }), + ); + }, PLAYHEAD_TICK_SECONDS * 1000); + return () => window.clearInterval(timer); + }, [transport.phase, transport.loop]); + + const actionKey = nextActionTemplateKey(transport, hasLocalAudio); + const nextAction = fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const canStart = transport.loop !== null; + const canPause = + transport.phase === "counting-in" || transport.phase === "looping"; + const canStop = transport.phase !== "idle" && transport.loop !== null; + const startLabel = + transport.phase === "paused" + ? t("workspaceLoopResume") + : t("workspaceLoopStart"); + + return ( +
+

+ {t("workspaceLoopTitle")} +

+

+ {nextAction} +

+ {playableSections.length > 0 ? ( +
+ {playableSections.map((section) => { + const selected = + section.id === (transport.loop?.sectionId ?? selectedSectionId); + return ( + + ); + })} +
+ ) : null} +
+ ); +} 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 @@ -236,4 +244,4 @@ export function RehearsalPlayer({
); -} +} \ No newline at end of file From 58cf24947373184aaacca3928b366d70c3e9c9a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:25:10 -0700 Subject: [PATCH 04/70] test(workspace): bind role loop action to local audio --- .../src/features/workspace/Workspace.test.tsx | 156 ++++++++++++------ 1 file changed, 108 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 92272c013..8a2c62125 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,5 +1,9 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; +import { + createDemoRehearsalSong, + type ProjectBootstrapSummary, + type RehearsalSong, +} from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; import { EmptyState, LoadingState } from "./WorkspaceStates"; @@ -12,21 +16,37 @@ const originalRevokeObjectUrl = URL.revokeObjectURL; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { configurable: true, - value: language + value: language, }); } +function createLocalSourceBootstrap(): ProjectBootstrapSummary { + return { + projectId: "project-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-1", + cacheRoot: "/tmp/bandscope/cache/project-1", + tempRoot: "/tmp/bandscope/temp/project-1", + source: { + sourcePath: "/Users/test/Music/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 1_024_000, + }, + }; +} + describe("Workspace", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); Object.defineProperty(URL, "createObjectURL", { configurable: true, - value: originalCreateObjectUrl + value: originalCreateObjectUrl, }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, - value: originalRevokeObjectUrl + value: originalRevokeObjectUrl, }); }); @@ -37,7 +57,7 @@ describe("Workspace", () => { ...song.sections[0]!.roles[0]!, id: "bass-guitar", name: "Bass Guitar", - practiceProgress: 50 + practiceProgress: 50, }; const onSongUpdate = vi.fn(); @@ -46,7 +66,9 @@ describe("Workspace", () => { // Select the Bass Guitar role to render PracticeProgress fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const increaseBtn = screen.getByRole("button", { name: "Increase progress" }); + const increaseBtn = screen.getByRole("button", { + name: "Increase progress", + }); fireEvent.click(increaseBtn); expect(onSongUpdate).toHaveBeenCalledTimes(1); @@ -77,7 +99,7 @@ describe("Workspace", () => { const song = createDemoRehearsalSong(); song.sections[0].timeRange = { start: Number.NaN, - end: Number.POSITIVE_INFINITY + end: Number.POSITIVE_INFINITY, }; render(); @@ -91,27 +113,60 @@ describe("Workspace", () => { render(); - expect(screen.getByRole("region", { name: /Tonight's section loop/i })).toBeTruthy(); - expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch( - /Loop verse from 0:10–0:30\. Choose a local song first/i - ); + expect( + screen.getByRole("region", { name: /Tonight's section loop/i }), + ).toBeTruthy(); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i); }); - it("starts the section loop from the selected role instead of a coming-soon control", () => { + it("keeps the role loop action unavailable without local audio authority", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "bass-guitar", - name: "Bass Guitar" + name: "Bass Guitar", }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const loopButton = screen.getByRole("button", { + name: "Loop this section", + }); + expect(loopButton.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(loopButton); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Choose a local song first/i); + }); + + it("starts the section loop from the selected role when local audio is available", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + }; + + render( + , + ); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); fireEvent.click(screen.getByRole("button", { name: "Loop this section" })); - expect(screen.getByTestId("rehearsal-loop-next-action").textContent).toMatch(/Count in 4 beats at 120 BPM/i); - expect(screen.queryByRole("button", { name: /Loop section coming soon/i })).toBeNull(); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 120 BPM/i); + expect( + screen.queryByRole("button", { name: /Loop section coming soon/i }), + ).toBeNull(); }); it("enables bass transcription from selected role metadata rather than role id text", () => { @@ -119,13 +174,15 @@ describe("Workspace", () => { song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "low-end", - name: "Bass Guitar" + name: "Bass Guitar", }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement; + const transcribeButton = screen.getByRole("button", { + name: "Transcribe Bass", + }) as HTMLButtonElement; expect(transcribeButton.disabled).toBe(false); expect(transcribeButton.title).toBe("Transcribe part"); }); @@ -137,14 +194,16 @@ describe("Workspace", () => { name: "Bass Guitar", transcription: [ { pitch: "E2", onset: 0, offset: 0.75, velocity: 0.74 }, - { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 } - ] + { pitch: "G2", onset: 0.9, offset: 1.25, velocity: 0.68 }, + ], }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i }); + const grooveMap = screen.getByRole("region", { + name: /bass transcription groove map/i, + }); expect(grooveMap.className).toContain("bg-slate-950"); expect(screen.getByText("E2")).toBeTruthy(); expect(screen.getByText("G2")).toBeTruthy(); @@ -165,7 +224,9 @@ describe("Workspace", () => { expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); expect(screen.getByText(/whole step lower/i)).toBeTruthy(); - expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); + expect( + screen.getByText(/Lock the bass entrance against the pickup/i), + ).toBeTruthy(); expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); @@ -175,11 +236,11 @@ describe("Workspace", () => { song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, harmonicExplanation: " ", - transpositionPlan: "" + transpositionPlan: "", }; song.collaboration = { syncMode: "local_only", - syncNote: "Local-only draft" + syncNote: "Local-only draft", } as RehearsalSong["collaboration"]; render(); @@ -191,34 +252,27 @@ describe("Workspace", () => { fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); expect(screen.getByText("vi pedal anchor")).toBeTruthy(); - expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0); + expect( + screen.getAllByText("Stay on roots if the chorus entrance gets muddy.") + .length, + ).toBeGreaterThan(0); }); it("exports a metadata-only handoff artifact from the workspace", async () => { const song = createDemoRehearsalSong(); - const sourceBootstrap: ProjectBootstrapSummary = { - projectId: "project-1", - sourceMode: "reference", - projectRoot: "/tmp/bandscope/projects/project-1", - cacheRoot: "/tmp/bandscope/cache/project-1", - tempRoot: "/tmp/bandscope/temp/project-1", - source: { - sourcePath: "/Users/test/Music/late-night-set.wav", - fileName: "late-night-set.wav", - extension: "wav", - fileSizeBytes: 1_024_000 - } - }; + const sourceBootstrap = createLocalSourceBootstrap(); const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); - const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); Object.defineProperty(URL, "createObjectURL", { configurable: true, - value: createObjectUrl + value: createObjectUrl, }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, - value: revokeObjectUrl + value: revokeObjectUrl, }); render(); @@ -236,18 +290,20 @@ describe("Workspace", () => { it("exports metadata-only handoff when source bootstrap is invalid", async () => { const song = createDemoRehearsalSong(); const invalidSourceBootstrap = { - projectId: "project-1" + projectId: "project-1", } as ProjectBootstrapSummary; const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); - const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); Object.defineProperty(URL, "createObjectURL", { configurable: true, - value: createObjectUrl + value: createObjectUrl, }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, - value: revokeObjectUrl + value: revokeObjectUrl, }); render(); @@ -264,11 +320,13 @@ describe("Workspace", () => { it("validates source bootstrap before generating metadata handoff", () => { const song = createDemoRehearsalSong(); const invalidSourceBootstrap = { - projectId: "project-1" + projectId: "project-1", } as ProjectBootstrapSummary; expect(() => { - generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap }); + generateMetadataHandoffJson(song, { + sourceBootstrap: invalidSourceBootstrap, + }); }).toThrow("sourceMode"); }); @@ -277,7 +335,9 @@ describe("Workspace", () => { render(); render(); - expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect( + screen.getByRole("heading", { name: "분석 준비 완료" }), + ).toBeTruthy(); expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); }); @@ -286,7 +346,7 @@ describe("Workspace", () => { const song = createDemoRehearsalSong(); song.exportSummary = { ...song.exportSummary, - headline: "" + headline: "", }; render(); From de0081120af51c6d92eb767601b136f0374e5eb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:38:10 -0700 Subject: [PATCH 05/70] fix(workspace): gate role loop by local audio authority --- .../src/features/workspace/Workspace.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 72108fe27..1eb1f1448 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -123,6 +123,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const [activeRole, setActiveRole] = useState(null); const [loopStartNonce, setLoopStartNonce] = useState(0); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const hasLocalAudio = safeProjectBootstrapSummary(sourceBootstrap) !== null; // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -337,7 +338,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp @@ -373,10 +374,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp @@ -497,4 +509,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ); -} +} \ No newline at end of file From 80265c2f03da65a5ede7961e284f75bce309bb3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:39:54 -0700 Subject: [PATCH 06/70] test(workspace): reject duplicate section id selection aliasing --- .../workspace/RehearsalPlayer.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 049accfee..0825e2d18 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -124,6 +124,44 @@ describe("RehearsalPlayer", () => { ).not.toMatch(/Count in 4 beats/i); }); + it("keeps duplicate analysis section ids selectable by renderer position", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0]!, + id: "duplicate-section", + label: "verse", + timeRange: { start: 10, end: 20 }, + }, + { + ...song.sections[0]!, + id: "duplicate-section", + label: "chorus", + timeRange: { start: 30, end: 40 }, + }, + ]; + + render(); + + const verseButton = screen.getByRole("button", { + name: /verse.*0:10.*0:20/i, + }); + const chorusButton = screen.getByRole("button", { + name: /chorus.*0:30.*0:40/i, + }); + expect(verseButton.getAttribute("aria-pressed")).toBe("true"); + expect(chorusButton.getAttribute("aria-pressed")).toBe("false"); + + fireEvent.click(chorusButton); + + expect(verseButton.getAttribute("aria-pressed")).toBe("false"); + expect(chorusButton.getAttribute("aria-pressed")).toBe("true"); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Loop chorus from 0:30–0:40\. Start the count-in/i); + }); + it("stays fail-closed when no section has a usable window", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); From 28e82d13062bac094683f12b8b98c1e9e72cc29f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:42:01 -0700 Subject: [PATCH 07/70] fix(workspace): select loop sections by renderer position --- .../features/workspace/RehearsalPlayer.tsx | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 24bbc786e..7889fb4e0 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -9,13 +9,13 @@ import { import { beatDurationMs, createIdleTransportState, + createLoopWindow, fillRehearsalCopy, formatRehearsalClock, isPlayableLoopSection, nextActionTemplateKey, nextActionValues, reduceRehearsalTransport, - resolveLoopWindow, type RehearsalTransportState, } from "./rehearsalTransport"; @@ -59,23 +59,28 @@ export function RehearsalPlayer({ : [], [song], ); - const [selectedSectionId, setSelectedSectionId] = useState( - playableSections[0]?.id ?? null, - ); - const [transport, setTransport] = useState(() => - reduceRehearsalTransport(createIdleTransportState(), { + const [selectedSectionIndex, setSelectedSectionIndex] = useState(0); + const selectedRendererIndex = playableSections[selectedSectionIndex] + ? selectedSectionIndex + : 0; + const [transport, setTransport] = useState(() => { + const firstSection = playableSections[0]; + return reduceRehearsalTransport(createIdleTransportState(), { type: "arm", - loop: resolveLoopWindow(song, playableSections[0]?.id), - }), - ); + loop: firstSection ? createLoopWindow(firstSection, song.tempo) : null, + }); + }); const lastHandledStartNonce = useRef(0); useEffect(() => { - const nextLoop = resolveLoopWindow(song, selectedSectionId); + const selectedSection = playableSections[selectedRendererIndex]; + const nextLoop = selectedSection + ? createLoopWindow(selectedSection, song.tempo) + : null; setTransport((current) => reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }), ); - }, [song, selectedSectionId]); + }, [playableSections, selectedRendererIndex, song.tempo]); useEffect(() => { if (startNonce <= lastHandledStartNonce.current) { @@ -86,15 +91,24 @@ export function RehearsalPlayer({ return; } setTransport((current) => { + const selectedSection = playableSections[selectedRendererIndex]; const armed = current.loop ? current : reduceRehearsalTransport(current, { type: "arm", - loop: resolveLoopWindow(song, selectedSectionId), + loop: selectedSection + ? createLoopWindow(selectedSection, song.tempo) + : null, }); return reduceRehearsalTransport(armed, { type: "start" }); }); - }, [startNonce, hasLocalAudio, song, selectedSectionId]); + }, [ + startNonce, + hasLocalAudio, + playableSections, + selectedRendererIndex, + song.tempo, + ]); useEffect(() => { if (transport.phase !== "counting-in" || !transport.loop) { @@ -157,12 +171,11 @@ export function RehearsalPlayer({ role="group" aria-label={t("workspaceLoopSectionPickerLabel")} > - {playableSections.map((section) => { - const selected = - section.id === (transport.loop?.sectionId ?? selectedSectionId); + {playableSections.map((section, index) => { + const selected = index === selectedRendererIndex; return ( ); })} From 6a58bada1284e9fe31007645564e97bbb93b89bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:41:08 -0700 Subject: [PATCH 21/70] test(workspace): preserve live loop across metadata updates --- .../workspace/RehearsalPlayer.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 4fd706f9b..f307ebd72 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -135,6 +135,47 @@ describe("RehearsalPlayer", () => { ).toContain("%"); }); + it("keeps a live loop running across unrelated song metadata updates", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(2500); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + + const updatedSong = { + ...song, + sections: song.sections.map((section, sectionIndex) => + sectionIndex === 0 + ? { + ...section, + roles: section.roles.map((role, roleIndex) => + roleIndex === 0 + ? { ...role, practiceProgress: 50 } + : role, + ), + } + : section, + ), + }; + + rerender(); + + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + }); + it("disables start while count-in or loop timing is already active", () => { setNavigatorLanguage("en-US"); vi.useFakeTimers(); From b100551671dab3c35e4a268ee4a0e012292b347a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:42:03 -0700 Subject: [PATCH 22/70] fix(workspace): preserve live loop on unrelated song updates --- .../features/workspace/RehearsalPlayer.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 1bc97883c..907fc67f5 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -16,6 +16,7 @@ import { nextActionTemplateKey, nextActionValues, reduceRehearsalTransport, + type RehearsalLoopWindow, type RehearsalTransportState, } from "./rehearsalTransport"; @@ -45,6 +46,20 @@ function loopProgressPercent(state: RehearsalTransportState): number { ); } +/** Return whether two loop windows describe the same transport timing authority. */ +function hasSameLoopTiming( + current: RehearsalLoopWindow, + next: RehearsalLoopWindow, +): boolean { + return ( + current.sectionId === next.sectionId && + current.startSeconds === next.startSeconds && + current.endSeconds === next.endSeconds && + current.tempoBpm === next.tempoBpm && + current.countInBeats === next.countInBeats + ); +} + /** Render tonight's first section loop with a count-in and a named next action. */ export function RehearsalPlayer({ song, @@ -77,9 +92,22 @@ export function RehearsalPlayer({ const nextLoop = selectedSection ? createLoopWindow(selectedSection, song.tempo) : null; - setTransport((current) => - reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }), - ); + setTransport((current) => { + if ( + current.loop && + nextLoop && + hasSameLoopTiming(current.loop, nextLoop) + ) { + if ( + current.loop.sectionLabel === nextLoop.sectionLabel && + current.loop.tempoAssumed === nextLoop.tempoAssumed + ) { + return current; + } + return { ...current, loop: nextLoop }; + } + return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }); + }); }, [playableSections, selectedRendererIndex, song.tempo]); useEffect(() => { From 646f470ea5680c278852dadaae8c26f006c88705 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:51:59 -0700 Subject: [PATCH 23/70] docs: align Python verification working directory --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 52399239a..3a11902ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,8 @@ npm --workspace @bandscope/desktop exec vitest run src/lib/export.test.ts # on npm run dev --workspace @bandscope/desktop # Vite dev server (browser fallback mode) npm run storybook --workspace @bandscope/desktop # component workbench -uv run --project services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate) -uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100 # full Python gate +uv run --directory services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate) +uv run --directory services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100 # full Python gate ``` ## Architecture From 96a191a9cb784b3fdb20237de728ecb56f179b9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:14:45 -0700 Subject: [PATCH 24/70] test(i18n): name selected-section loop action honestly --- apps/desktop/src/i18n/rehearsalLoopCopy.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/src/i18n/rehearsalLoopCopy.test.ts diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts new file mode 100644 index 000000000..681c06014 --- /dev/null +++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { createTranslator } from "./index"; + +describe("rehearsal loop action copy", () => { + it("names the role action as starting the selected section in both locales", () => { + expect(createTranslator("en")("workspaceLoopThisSection")).toBe( + "Start selected section loop", + ); + expect(createTranslator("ko")("workspaceLoopThisSection")).toBe( + "선택한 구간 루프 시작", + ); + }); +}); From b4a5a0dbb0a1d6bcba16f307f28b2eda4da2a91b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:15:53 -0700 Subject: [PATCH 25/70] fix(i18n): name selected-section loop action honestly --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 32467027c..746eaae50 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,7 +153,7 @@ "workspaceLoopTitle": "Tonight's loop", "workspaceLoopSectionPickerLabel": "Playable sections", "workspaceLoopStart": "Start the count-in", - "workspaceLoopThisSection": "Loop this section", + "workspaceLoopThisSection": "Start selected section loop", "workspaceLoopResume": "Continue this loop", "workspaceLoopPause": "Pause the loop", "workspaceLoopStop": "Stop and reset", From 3de2b6253cab832591cf4e761b0984f903bd3627 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:17:07 -0700 Subject: [PATCH 26/70] fix(i18n): localize selected-section loop action --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index ef99ede55..8447db2d7 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,7 +153,7 @@ "workspaceLoopTitle": "오늘 밤 루프", "workspaceLoopSectionPickerLabel": "연습할 구간", "workspaceLoopStart": "카운트인 시작", - "workspaceLoopThisSection": "이 구간 루프", + "workspaceLoopThisSection": "선택한 구간 루프 시작", "workspaceLoopResume": "이 루프 이어가기", "workspaceLoopPause": "루프 일시정지", "workspaceLoopStop": "멈추고 처음으로", From 110ca679980faf57e5f516a16d98b405d1c340f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:18:51 -0700 Subject: [PATCH 27/70] test(workspace): follow selected-section loop copy --- apps/desktop/src/features/workspace/Workspace.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 8a2c62125..87bdd85b8 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -134,7 +134,7 @@ describe("Workspace", () => { fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); const loopButton = screen.getByRole("button", { - name: "Loop this section", + name: "Start selected section loop", }); expect(loopButton.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(loopButton); @@ -143,7 +143,7 @@ describe("Workspace", () => { ).toMatch(/Choose a local song first/i); }); - it("starts the section loop from the selected role when local audio is available", () => { + it("starts the selected section loop from the role action when local audio is available", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { @@ -159,7 +159,9 @@ describe("Workspace", () => { />, ); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - fireEvent.click(screen.getByRole("button", { name: "Loop this section" })); + fireEvent.click( + screen.getByRole("button", { name: "Start selected section loop" }), + ); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, From ab660b6e0a0f960aceab311a55292aaf15712ff8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:20:09 -0700 Subject: [PATCH 28/70] test(i18n): keep rehearsal clock copy honest --- apps/desktop/src/i18n/rehearsalLoopCopy.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts index 681c06014..609269eaa 100644 --- a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts +++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts @@ -10,4 +10,18 @@ describe("rehearsal loop action copy", () => { "선택한 구간 루프 시작", ); }); + + it("describes the timer-only transport as a rehearsal clock in both locales", () => { + const en = createTranslator("en"); + expect(en("workspaceLoopArmedWithAudio")).toContain("rehearsal clock"); + expect(en("workspaceLoopCountingIn")).toContain("rehearsal clock"); + expect(en("workspaceLoopPlaying")).toContain("rehearsal clock"); + expect(en("workspaceLoopArmedNoAudio")).not.toMatch(/\bhear\b|\blisten\b/i); + expect(en("workspaceLoopArmedWithAudio")).not.toMatch(/\bhear\b|\blisten\b/i); + + const ko = createTranslator("ko"); + expect(ko("workspaceLoopArmedWithAudio")).toContain("합주 시계"); + expect(ko("workspaceLoopCountingIn")).toContain("합주 시계"); + expect(ko("workspaceLoopPlaying")).toContain("합주 시계"); + }); }); From 139e8202aa588ccbb2c916f88ba81641c8503d87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:21:23 -0700 Subject: [PATCH 29/70] fix(i18n): describe timer-only loop honestly --- apps/desktop/src/locales/en/common.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 746eaae50..16c826d96 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,13 +154,13 @@ "workspaceLoopSectionPickerLabel": "Playable sections", "workspaceLoopStart": "Start the count-in", "workspaceLoopThisSection": "Start selected section loop", - "workspaceLoopResume": "Continue this loop", - "workspaceLoopPause": "Pause the loop", - "workspaceLoopStop": "Stop and reset", + "workspaceLoopResume": "Continue rehearsal clock", + "workspaceLoopPause": "Pause rehearsal clock", + "workspaceLoopStop": "Stop and reset rehearsal clock", "workspaceLoopIdle": "Add a section with a start and end time, then loop it here.", - "workspaceLoopArmedNoAudio": "Loop {section} from {start}–{end}. Choose a local song first so you can hear this loop.", - "workspaceLoopArmedWithAudio": "Loop {section} from {start}–{end}. Start the count-in to hear it.", - "workspaceLoopCountingIn": "Count in {beats} beats at {tempo} BPM, then {section} loops.", - "workspaceLoopPlaying": "{section} is looping {start}–{end}. Pause when you have the entrance.", - "workspaceLoopPaused": "{section} is paused on the loop. Press play to continue." + "workspaceLoopArmedNoAudio": "Map {section} from {start}–{end}. Choose a local song first to start the rehearsal clock.", + "workspaceLoopArmedWithAudio": "Map {section} from {start}–{end}. Start the count-in to run the rehearsal clock.", + "workspaceLoopCountingIn": "Count in {beats} beats at {tempo} BPM, then the rehearsal clock loops {section}.", + "workspaceLoopPlaying": "The rehearsal clock is looping {section} from {start}–{end}. Pause when you have the entrance.", + "workspaceLoopPaused": "The rehearsal clock is paused on {section}. Continue when you are ready." } From 7617f0dc7cea3e24e23a5e955400f1be99783533 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:22:45 -0700 Subject: [PATCH 30/70] fix(i18n): localize rehearsal clock honestly --- apps/desktop/src/locales/ko/common.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 8447db2d7..1389e3271 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,13 +154,13 @@ "workspaceLoopSectionPickerLabel": "연습할 구간", "workspaceLoopStart": "카운트인 시작", "workspaceLoopThisSection": "선택한 구간 루프 시작", - "workspaceLoopResume": "이 루프 이어가기", - "workspaceLoopPause": "루프 일시정지", - "workspaceLoopStop": "멈추고 처음으로", + "workspaceLoopResume": "합주 시계 계속", + "workspaceLoopPause": "합주 시계 일시정지", + "workspaceLoopStop": "합주 시계 멈추고 초기화", "workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.", - "workspaceLoopArmedNoAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 이 루프를 들으려면 먼저 로컬 곡을 고르세요.", - "workspaceLoopArmedWithAudio": "{section} 구간을 {start}–{end}에서 루프합니다. 카운트인을 시작해 들어 보세요.", - "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 {section} 구간이 루프됩니다.", - "workspaceLoopPlaying": "{section} 구간이 {start}–{end}에서 루프 중입니다. 입구가 잡히면 일시정지하세요.", - "workspaceLoopPaused": "{section} 구간 루프가 멈춰 있습니다. 재생을 눌러 이어 가세요." + "workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.", + "workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.", + "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 합주 시계가 {section} 구간을 반복합니다.", + "workspaceLoopPlaying": "합주 시계가 {section} 구간 {start}–{end}를 반복 중입니다. 입구가 잡히면 일시정지하세요.", + "workspaceLoopPaused": "{section} 구간 합주 시계가 멈춰 있습니다. 준비되면 이어 가세요." } From c9fb0f663000e5b26564b337f0d3c8d3b09e734c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:24:34 -0700 Subject: [PATCH 31/70] test(workspace): follow rehearsal clock copy --- apps/desktop/src/features/workspace/Workspace.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 87bdd85b8..679b6683d 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -118,7 +118,9 @@ describe("Workspace", () => { ).toBeTruthy(); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, - ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i); + ).toMatch( + /Map verse from 0:10–0:30\. Choose a local song first to start the rehearsal clock/i, + ); }); it("keeps the role loop action unavailable without local audio authority", () => { From 0512f30909a038e594aa934810cb38a8a4cb7f39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:13:35 -0700 Subject: [PATCH 32/70] test(player): align loop assertions with shipped next-action copy --- .../src/features/workspace/RehearsalPlayer.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index f307ebd72..a3e805b4c 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -26,7 +26,7 @@ describe("RehearsalPlayer", () => { expect( screen.getByTestId("rehearsal-loop-next-action").textContent, - ).toMatch(/Loop verse from 0:10–0:30\. Choose a local song first/i); + ).toMatch(/Map verse from 0:10–0:30\. Choose a local song first/i); expect( ( screen.getByRole("button", { @@ -125,7 +125,7 @@ describe("RehearsalPlayer", () => { }); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, - ).toMatch(/verse is looping 0:10–0:30/i); + ).toMatch(/The rehearsal clock is looping verse from 0:10–0:30/i); act(() => { vi.advanceTimersByTime(1500); @@ -234,7 +234,7 @@ describe("RehearsalPlayer", () => { expect( screen.getByTestId("rehearsal-loop-next-action").textContent, - ).toMatch(/Loop chorus from 0:20–0:30\. Start the count-in/i); + ).toMatch(/Map chorus from 0:20–0:30\. Start the count-in/i); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, ).not.toMatch(/Count in 4 beats/i); @@ -275,7 +275,7 @@ describe("RehearsalPlayer", () => { expect(chorusButton.getAttribute("aria-pressed")).toBe("true"); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, - ).toMatch(/Loop chorus from 0:30–0:40\. Start the count-in/i); + ).toMatch(/Map chorus from 0:30–0:40\. Start the count-in/i); }); it("stays fail-closed when no section has a usable window", () => { @@ -295,4 +295,4 @@ describe("RehearsalPlayer", () => { ).disabled, ).toBe(true); }); -}); \ No newline at end of file +}); From f5f753d1bd418813cb811b94cff1125107da3b7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:15:08 -0700 Subject: [PATCH 33/70] test(player): verify revocation after the loop advances --- .../src/features/workspace/RehearsalPlayer.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index a3e805b4c..acbb30f5d 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -86,7 +86,7 @@ describe("RehearsalPlayer", () => { screen.getByRole("button", { name: /Start the count-in/i }), ); act(() => { - vi.advanceTimersByTime(2000); + vi.advanceTimersByTime(2500); }); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, @@ -95,16 +95,23 @@ describe("RehearsalPlayer", () => { const playheadBeforeRevocation = screen .getByTestId("rehearsal-loop-playhead") .getAttribute("style"); + expect(playheadBeforeRevocation).not.toContain("width: 0%"); + rerender(); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, ).toMatch(/Choose a local song first/i); + const playheadAfterRevocation = screen + .getByTestId("rehearsal-loop-playhead") + .getAttribute("style"); + expect(playheadAfterRevocation).not.toBe(playheadBeforeRevocation); + act(() => { vi.advanceTimersByTime(1000); }); expect( screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"), - ).toBe(playheadBeforeRevocation); + ).toBe(playheadAfterRevocation); }); it("counts in then loops the selected section on the map clock", () => { From 7c1b4973f9d7f4ae7320814cfa340d42d29e2706 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:18:17 -0700 Subject: [PATCH 34/70] test(player): advance the live-loop clock before revocation --- apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index acbb30f5d..82f9dea9b 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -86,11 +86,14 @@ describe("RehearsalPlayer", () => { screen.getByRole("button", { name: /Start the count-in/i }), ); act(() => { - vi.advanceTimersByTime(2500); + vi.advanceTimersByTime(2000); }); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, ).toMatch(/looping/i); + act(() => { + vi.advanceTimersByTime(500); + }); const playheadBeforeRevocation = screen .getByTestId("rehearsal-loop-playhead") From ad2d219556bad6e5f48f4f3c94ef95d22d1a02b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:04:27 -0700 Subject: [PATCH 35/70] test(workspace): pin loop section descriptor authority --- ...rsalTransport.descriptor-authority.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts new file mode 100644 index 000000000..ae892fc3f --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts @@ -0,0 +1,37 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { createLoopWindow } from "./rehearsalTransport"; + +describe("rehearsal transport descriptor authority", () => { + it("uses one owned section snapshot instead of Proxy get values", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const expectedId = section.id; + const expectedLabel = section.label; + const expectedRange = { ...section.timeRange }; + const proxiedSection = new Proxy(section, { + get(target, property, receiver) { + if (property === "id") { + return "proxy-injected-section"; + } + if (property === "label") { + return "outro"; + } + if (property === "timeRange") { + return { start: 90, end: 100 }; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(createLoopWindow(proxiedSection, song.tempo)).toEqual({ + sectionId: expectedId, + sectionLabel: expectedLabel, + startSeconds: expectedRange.start, + endSeconds: expectedRange.end, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4 + }); + }); +}); From 6b8abe5ed9206d91484792cbd6c7cecc2193776c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:05:23 -0700 Subject: [PATCH 36/70] fix(workspace): snapshot loop section authority --- .../features/workspace/rehearsalTransport.ts | 102 ++++++++++++------ 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts index b8ab5b02b..5bc1e603c 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -37,25 +37,71 @@ export type RehearsalTransportEvent = | { type: "pause" } | { type: "stop" }; +type PlayableSectionSnapshot = Readonly<{ + id: string; + label: string; + startSeconds: number; + endSeconds: number; +}>; + /** Return true only for finite numeric values greater than or equal to zero. */ export function isFiniteNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } +/** Read one own data-property value without activating accessors or Proxy get traps. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +/** Snapshot one playable section before any value can become transport authority. */ +function playableSectionSnapshot( + section: RehearsalSection | undefined | null, +): PlayableSectionSnapshot | null { + if (!section || typeof section !== "object") { + return null; + } + const id = ownDataValue(section, "id"); + const label = ownDataValue(section, "label"); + const timeRange = ownDataValue(section, "timeRange"); + if ( + typeof id !== "string" || + id.trim() === "" || + !timeRange || + typeof timeRange !== "object" + ) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + !isFiniteNonNegativeNumber(start) || + !isFiniteNonNegativeNumber(end) || + end <= start + ) { + return null; + } + return { + id, + label: typeof label === "string" && label.trim() ? label : id, + startSeconds: start, + endSeconds: end, + }; +} + /** Return whether a section exposes a usable closed loop window. */ export function isPlayableLoopSection( section: RehearsalSection | undefined | null, ): boolean { - if (!section || typeof section.id !== "string" || section.id.trim() === "") { - return false; - } - const start = section.timeRange?.start; - const end = section.timeRange?.end; - return ( - isFiniteNonNegativeNumber(start) && - isFiniteNonNegativeNumber(end) && - end > start - ); + return playableSectionSnapshot(section) !== null; } /** Admit a published tempo or fall back to the labeled rehearsal default. */ @@ -97,23 +143,21 @@ export function formatRehearsalClock(totalSeconds: number): string { return `${minutes}:${seconds}`; } -/** Build a loop window from one section plus the song tempo. */ +/** Build a loop window from one snapshotted section plus the song tempo. */ export function createLoopWindow( section: RehearsalSection, tempo: unknown, ): RehearsalLoopWindow | null { - if (!isPlayableLoopSection(section)) { + const snapshot = playableSectionSnapshot(section); + if (!snapshot) { return null; } const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo); return { - sectionId: section.id, - sectionLabel: - typeof section.label === "string" && section.label.trim() - ? section.label - : section.id, - startSeconds: section.timeRange.start, - endSeconds: section.timeRange.end, + sectionId: snapshot.id, + sectionLabel: snapshot.label, + startSeconds: snapshot.startSeconds, + endSeconds: snapshot.endSeconds, tempoBpm, tempoAssumed, countInBeats: DEFAULT_COUNT_IN_BEATS, @@ -126,24 +170,20 @@ export function resolveLoopWindow( sectionId?: string | null, ): RehearsalLoopWindow | null { const sections = Array.isArray(song?.sections) ? song.sections : []; + const tempo = song?.tempo; + const windows = sections.flatMap((section) => { + const window = createLoopWindow(section, tempo); + return window ? [window] : []; + }); if (typeof sectionId === "string" && sectionId.trim()) { - const requested = sections.find( - (section) => isPlayableLoopSection(section) && section.id === sectionId, + const requestedWindow = windows.find( + (window) => window.sectionId === sectionId, ); - const requestedWindow = requested - ? createLoopWindow(requested, song?.tempo) - : null; if (requestedWindow) { return requestedWindow; } } - for (const section of sections) { - const window = createLoopWindow(section, song?.tempo); - if (window) { - return window; - } - } - return null; + return windows[0] ?? null; } /** Return the idle transport snapshot. */ From 0c7b293c7f9051ce47ab0b61e5e1f431ff0b4b17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:05:42 -0700 Subject: [PATCH 37/70] test(workspace): pin loop picker snapshot authority --- ...earsalPlayer.descriptor-authority.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx new file mode 100644 index 000000000..59bc9c4b5 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.descriptor-authority.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +describe("RehearsalPlayer descriptor authority", () => { + it("renders the admitted section snapshot instead of Proxy get values", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const expectedLabel = section.label; + const expectedStart = section.timeRange.start; + const expectedEnd = section.timeRange.end; + song.sections = [ + new Proxy(section, { + get(target, property, receiver) { + if (property === "id") { + return "proxy-injected-section"; + } + if (property === "label") { + return "outro"; + } + if (property === "timeRange") { + return { start: 90, end: 100 }; + } + return Reflect.get(target, property, receiver); + } + }) + ]; + + render(); + + expect( + screen.getByRole("button", { + name: new RegExp(`${expectedLabel}.*0:${String(expectedStart).padStart(2, "0")}.*0:${String(expectedEnd).padStart(2, "0")}`, "i") + }) + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /outro.*1:30.*1:40/i })).toBeNull(); + }); +}); From 3d5ac83c4a06b9ec2fb7d321ce7041d9c4c0ce6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:06:48 -0700 Subject: [PATCH 38/70] fix(workspace): snapshot song loop windows --- .../features/workspace/rehearsalTransport.ts | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts index 5bc1e603c..8a66af2a7 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -62,6 +62,37 @@ function ownDataValue(value: object, key: PropertyKey): unknown { } } +/** Snapshot an ordinary array through owned numeric data properties only. */ +function ownedDenseArray(value: unknown): unknown[] | null { + try { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, index); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, "value") + ) { + return null; + } + items.push(descriptor.value); + } + return items; + } catch { + return null; + } +} + /** Snapshot one playable section before any value can become transport authority. */ function playableSectionSnapshot( section: RehearsalSection | undefined | null, @@ -164,17 +195,33 @@ export function createLoopWindow( }; } +/** Snapshot every playable loop window from one untrusted song record. */ +export function resolveLoopWindows( + song: RehearsalSong | null | undefined, +): RehearsalLoopWindow[] { + if (!song || typeof song !== "object") { + return []; + } + const sections = ownedDenseArray(ownDataValue(song, "sections")); + if (!sections) { + return []; + } + const tempo = ownDataValue(song, "tempo"); + return sections.flatMap((section) => { + if (!section || typeof section !== "object") { + return []; + } + const window = createLoopWindow(section as RehearsalSection, tempo); + return window ? [window] : []; + }); +} + /** Resolve the requested section, or the first valid section, as a loop window. */ export function resolveLoopWindow( song: RehearsalSong | null | undefined, sectionId?: string | null, ): RehearsalLoopWindow | null { - const sections = Array.isArray(song?.sections) ? song.sections : []; - const tempo = song?.tempo; - const windows = sections.flatMap((section) => { - const window = createLoopWindow(section, tempo); - return window ? [window] : []; - }); + const windows = resolveLoopWindows(song); if (typeof sectionId === "string" && sectionId.trim()) { const requestedWindow = windows.find( (window) => window.sectionId === sectionId, From f420ffe0c5d5d64e1ad0daa4fe77bfd8b6c033e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:07:21 -0700 Subject: [PATCH 39/70] fix(workspace): render snapshotted loop windows --- .../features/workspace/RehearsalPlayer.tsx | 57 +++++++------------ 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 907fc67f5..523cd5c53 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -9,13 +9,12 @@ import { import { beatDurationMs, createIdleTransportState, - createLoopWindow, fillRehearsalCopy, formatRehearsalClock, - isPlayableLoopSection, nextActionTemplateKey, nextActionValues, reduceRehearsalTransport, + resolveLoopWindows, type RehearsalLoopWindow, type RehearsalTransportState, } from "./rehearsalTransport"; @@ -28,7 +27,7 @@ interface RehearsalPlayerProps { const PLAYHEAD_TICK_SECONDS = 0.1; -/** Documented. */ +/** Return the displayed map-clock progress for the current loop. */ function loopProgressPercent(state: RehearsalTransportState): number { if (!state.loop) { return 0; @@ -67,31 +66,21 @@ export function RehearsalPlayer({ startNonce = 0, }: RehearsalPlayerProps): ReactElement { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const playableSections = useMemo( - () => - Array.isArray(song.sections) - ? song.sections.filter((section) => isPlayableLoopSection(section)) - : [], - [song], - ); + const playableLoops = useMemo(() => resolveLoopWindows(song), [song]); const [selectedSectionIndex, setSelectedSectionIndex] = useState(0); - const selectedRendererIndex = playableSections[selectedSectionIndex] + const selectedRendererIndex = playableLoops[selectedSectionIndex] ? selectedSectionIndex : 0; - const [transport, setTransport] = useState(() => { - const firstSection = playableSections[0]; - return reduceRehearsalTransport(createIdleTransportState(), { + const [transport, setTransport] = useState(() => + reduceRehearsalTransport(createIdleTransportState(), { type: "arm", - loop: firstSection ? createLoopWindow(firstSection, song.tempo) : null, - }); - }); + loop: playableLoops[0] ?? null, + }), + ); const lastHandledStartNonce = useRef(0); useEffect(() => { - const selectedSection = playableSections[selectedRendererIndex]; - const nextLoop = selectedSection - ? createLoopWindow(selectedSection, song.tempo) - : null; + const nextLoop = playableLoops[selectedRendererIndex] ?? null; setTransport((current) => { if ( current.loop && @@ -108,7 +97,7 @@ export function RehearsalPlayer({ } return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }); }); - }, [playableSections, selectedRendererIndex, song.tempo]); + }, [playableLoops, selectedRendererIndex]); useEffect(() => { if (startNonce <= lastHandledStartNonce.current) { @@ -119,24 +108,16 @@ export function RehearsalPlayer({ return; } setTransport((current) => { - const selectedSection = playableSections[selectedRendererIndex]; + const selectedLoop = playableLoops[selectedRendererIndex] ?? null; const armed = current.loop ? current : reduceRehearsalTransport(current, { type: "arm", - loop: selectedSection - ? createLoopWindow(selectedSection, song.tempo) - : null, + loop: selectedLoop, }); return reduceRehearsalTransport(armed, { type: "start" }); }); - }, [ - startNonce, - hasLocalAudio, - playableSections, - selectedRendererIndex, - song.tempo, - ]); + }, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]); useEffect(() => { if (hasLocalAudio) { @@ -208,13 +189,13 @@ export function RehearsalPlayer({ > {nextAction}

- {playableSections.length > 0 ? ( + {playableLoops.length > 0 ? (
- {playableSections.map((section, index) => { + {playableLoops.map((loop, index) => { const selected = index === selectedRendererIndex; return ( ); From 2a8a96b17f8ab2042129a9b3c07239b4f2448eef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:08:54 -0700 Subject: [PATCH 40/70] docs(changelog): describe map-clock snapshot boundary --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 403727250..52954f61a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Tonight's rehearsal map now arms the first valid section loop, runs a tempo count-in, and names the next play, pause, or choose-local-song action instead of a coming-soon control. +- Tonight's rehearsal map now arms the first valid section map-clock loop, runs a tempo count-in, and names the next start, pause, stop, or choose-local-song action without claiming decoded audio playback; admitted section timing and picker copy use the same descriptor-snapshotted transport window. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -56,9 +56,9 @@ - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI - Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release +- Issue #36: Added rehearsal priority calculation and cue-sheet/chart summary exports +- Issue #30: Added YouTube import with policy-constrained browser fallback and local project ingestion +- Issue #26: Added release safety gates and cross-platform build verification ## [0.1.4] - 2026-05-15 From d8e0c4ff40285d5dc9f5b3cdb150e4a2b5f2c87c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 15:09:19 -0700 Subject: [PATCH 41/70] docs(changelog): preserve historical release text --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52954f61a..51bce1031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,9 +56,9 @@ - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI - Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Added rehearsal priority calculation and cue-sheet/chart summary exports -- Issue #30: Added YouTube import with policy-constrained browser fallback and local project ingestion -- Issue #26: Added release safety gates and cross-platform build verification +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release ## [0.1.4] - 2026-05-15 From cfcfa1ec37348ae6ab35fc309b8e8307a6edaa54 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 05:27:02 +0900 Subject: [PATCH 42/70] fix(desktop): bound rehearsal loop inputs --- .../workspace/rehearsalTransport.test.ts | 9 +++++++++ .../features/workspace/rehearsalTransport.ts | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts index 46b44bf94..b252978ee 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts @@ -24,6 +24,8 @@ describe("rehearsalTransport", () => { expect(createLoopWindow(song.sections[0]!, song.tempo)).toBeNull(); song.sections[0]!.timeRange = { start: 40, end: 10 }; expect(isPlayableLoopSection(song.sections[0])).toBe(false); + song.sections[0]!.timeRange = { start: 4_294_967_295, end: 4_294_967_296 }; + expect(isPlayableLoopSection(song.sections[0])).toBe(false); }); it("arms the first valid section and skips a requested invalid id", () => { @@ -55,6 +57,13 @@ describe("rehearsalTransport", () => { expect(window?.endSeconds).toBe(64); }); + it("rejects a sparse hostile section array without scanning its declared length", () => { + const song = createDemoRehearsalSong(); + song.sections = new Array(0xffffffff) as typeof song.sections; + + expect(resolveLoopWindow(song)).toBeNull(); + }); + it("assumes 120 BPM when tempo is missing and keeps published tempo in range", () => { expect(resolveRehearsalTempo(undefined)).toEqual({ tempoBpm: 120, diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts index 8a66af2a7..0540f9fcc 100644 --- a/apps/desktop/src/features/workspace/rehearsalTransport.ts +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -1,4 +1,8 @@ -import type { RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalSection, + type RehearsalSong, +} from "@bandscope/shared-types"; const DEFAULT_REHEARSAL_TEMPO_BPM = 120; const DEFAULT_COUNT_IN_BEATS = 4; @@ -76,9 +80,16 @@ function ownedDenseArray(value: unknown): unknown[] | null { ) { return null; } + const keys = Object.keys(value); + if (keys.length !== length) { + return null; + } const items: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, index); + for (const [index, key] of keys.entries()) { + if (key !== String(index)) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); if ( descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value") @@ -116,6 +127,8 @@ function playableSectionSnapshot( if ( !isFiniteNonNegativeNumber(start) || !isFiniteNonNegativeNumber(end) || + start > MAX_SECTION_TIME_SECONDS || + end > MAX_SECTION_TIME_SECONDS || end <= start ) { return null; From 6de3f55dd370c42e0fe70aa0e4142fc83eebedcd Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 09:20:54 +0900 Subject: [PATCH 43/70] fix(a11y): announce rehearsal transport status --- .../desktop/src/features/workspace/RehearsalPlayer.test.tsx | 6 ++++++ apps/desktop/src/features/workspace/RehearsalPlayer.tsx | 2 ++ 2 files changed, 8 insertions(+) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 82f9dea9b..a3d223168 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -24,6 +24,12 @@ describe("RehearsalPlayer", () => { const song = createDemoRehearsalSong(); render(); + expect( + screen.getByTestId("rehearsal-loop-next-action").getAttribute("role"), + ).toBe("status"); + expect( + screen.getByTestId("rehearsal-loop-next-action").getAttribute("aria-live"), + ).toBe("polite"); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, ).toMatch(/Map verse from 0:10–0:30\. Choose a local song first/i); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 523cd5c53..6b5d52ae5 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -185,6 +185,8 @@ export function RehearsalPlayer({

{nextAction} From 7a59047b004faa7a0b584f3d6d68c94889a452d5 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 09:40:36 +0900 Subject: [PATCH 44/70] fix(player): restart externally requested loops --- .../workspace/RehearsalPlayer.test.tsx | 27 +++++++++++++++++++ .../features/workspace/RehearsalPlayer.tsx | 10 +++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index a3d223168..ea752fd83 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -215,6 +215,33 @@ describe("RehearsalPlayer", () => { expect(startButton.disabled).toBe(false); }); + it("restarts a paused loop from an external section-start request", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(500); + }); + fireEvent.click(screen.getByRole("button", { name: /Pause/i })); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/paused/i); + + rerender( + , + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats/i); + }); + it("does not restart the count-in when section selection changes under the same start nonce", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 6b5d52ae5..70264984c 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -109,12 +109,10 @@ export function RehearsalPlayer({ } setTransport((current) => { const selectedLoop = playableLoops[selectedRendererIndex] ?? null; - const armed = current.loop - ? current - : reduceRehearsalTransport(current, { - type: "arm", - loop: selectedLoop, - }); + const armed = reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); return reduceRehearsalTransport(armed, { type: "start" }); }); }, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]); From 280310901f63e9e71a8c064b33d719426a9cf1c7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 30 Aug 2026 02:21:01 +0900 Subject: [PATCH 45/70] feat(player): play real audio section loops --- apps/desktop/src-tauri/Cargo.lock | 7 + apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/main.rs | 16 ++ apps/desktop/src-tauri/tauri.conf.json | 4 + .../workspace/RehearsalPlayer.test.tsx | 95 ++++++-- .../features/workspace/RehearsalPlayer.tsx | 210 +++++++++++++++++- .../src/features/workspace/Workspace.tsx | 9 +- .../workspace/rehearsalTransport.test.ts | 25 +++ .../features/workspace/rehearsalTransport.ts | 13 ++ apps/desktop/src/locales/en/common.json | 1 + apps/desktop/src/locales/ko/common.json | 1 + 11 files changed, 359 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0fed84b0c..80832589f 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1219,6 +1219,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2951,6 +2957,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bcafbab44..f32cccfae 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ bandscope-desktop-core = { path = "../core" } rfd = "0.17.2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri = { version = "2.11.1", default-features = false, features = ["wry"] } +tauri = { version = "2.11.1", default-features = false, features = ["protocol-asset", "wry"] } time = { version = "0.3", features = ["formatting", "macros"] } tokio = { version = "1.50.0", features = ["time"] } url = "2.5.8" diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..a8a3de6ce 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -317,6 +317,20 @@ fn lookup_bootstrap_source( .ok_or_else(|| "Analysis job source was not found. Choose local audio again.".to_string()) } +/// Allow only the already-normalized source file to be served by the asset protocol. +/// +/// Security Notes: the path is produced by the native file dialog or by the +/// validated, app-owned YouTube cache path. The protocol starts with an empty +/// scope, so this does not expose a directory or accept a path from JavaScript. +fn allow_audio_source_for_playback( + app: &tauri::AppHandle, + source: &LocalAudioSourcePayload, +) -> Result<(), String> { + app.asset_protocol_scope() + .allow_file(&source.source_path) + .map_err(|_| "Could not prepare the selected audio for playback.".to_string()) +} + fn drain_analysis_status_updates( state: &AppState, app: &tauri::AppHandle, @@ -643,6 +657,7 @@ fn select_local_audio_source( .pick_file() .ok_or_else(|| "Choose a WAV, MP3, FLAC, or M4A file to start analysis.".to_string())?; let source = normalize_local_audio_source(&path)?; + allow_audio_source_for_playback(&app, &source)?; let project_id = next_project_id(&state); let project_root = app_owned_root(&app, "projects", &project_id)?; let cache_root = app_owned_root(&app, "cache", &project_id)?; @@ -712,6 +727,7 @@ async fn import_youtube_url( if parsed.get("ok").and_then(|v| v.as_bool()) == Some(true) { if let Some(metadata) = parsed.get("metadata") { let source = youtube_source_from_metadata(metadata, &cache_root)?; + allow_audio_source_for_playback(&app, &source)?; let summary = ProjectBootstrapSummaryPayload { project_id, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8efaf48c7..b43115283 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -17,6 +17,10 @@ ], "security": { "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: data: blob:; font-src 'self' data:", + "assetProtocol": { + "enable": true, + "scope": [] + }, "capabilities": ["main-capability"] } }, diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index ea752fd83..591d83597 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { RehearsalPlayer } from "./RehearsalPlayer"; const originalLanguage = navigator.language; +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -17,6 +21,16 @@ describe("RehearsalPlayer", () => { setNavigatorLanguage(originalLanguage); vi.useRealTimers(); vi.restoreAllMocks(); + if (originalTauriInternals) { + Object.defineProperty( + window, + "__TAURI_INTERNALS__", + originalTauriInternals, + ); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } }); it("names the first playable loop and blocks starting before local audio exists", () => { @@ -28,7 +42,9 @@ describe("RehearsalPlayer", () => { screen.getByTestId("rehearsal-loop-next-action").getAttribute("role"), ).toBe("status"); expect( - screen.getByTestId("rehearsal-loop-next-action").getAttribute("aria-live"), + screen + .getByTestId("rehearsal-loop-next-action") + .getAttribute("aria-live"), ).toBe("polite"); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, @@ -46,11 +62,7 @@ describe("RehearsalPlayer", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); render( - , + , ); expect( @@ -151,6 +163,65 @@ describe("RehearsalPlayer", () => { ).toContain("%"); }); + it("uses the scoped native asset as the media clock for a real loop", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + const song = createDemoRehearsalSong(); + + render( + , + ); + + const audio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + expect(convertFileSrc).toHaveBeenCalledWith( + "/Users/test/Music/rehearsal.wav", + "asset", + ); + expect(audio.src).toContain("asset://localhost/"); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + expect(play).toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: 17.5, + }); + fireEvent(audio, new Event("timeupdate")); + expect( + screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"), + ).toContain("37.5%"); + + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: 31, + }); + fireEvent(audio, new Event("timeupdate")); + expect(audio.currentTime).toBe(10); + }); + it("keeps a live loop running across unrelated song metadata updates", () => { setNavigatorLanguage("en-US"); vi.useFakeTimers(); @@ -176,9 +247,7 @@ describe("RehearsalPlayer", () => { ? { ...section, roles: section.roles.map((role, roleIndex) => - roleIndex === 0 - ? { ...role, practiceProgress: 50 } - : role, + roleIndex === 0 ? { ...role, practiceProgress: 50 } : role, ), } : section, @@ -260,13 +329,7 @@ describe("RehearsalPlayer", () => { }, ]; - render( - , - ); + render(); expect( screen.getByTestId("rehearsal-loop-next-action").textContent, ).toMatch(/Count in 4 beats/i); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index 70264984c..f271dd427 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -1,5 +1,13 @@ -import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { Button } from "@/components/ui/button"; import { createTranslator, @@ -22,11 +30,26 @@ import { interface RehearsalPlayerProps { song: RehearsalSong; hasLocalAudio?: boolean; + audioSourcePath?: string | null; startNonce?: number; } const PLAYHEAD_TICK_SECONDS = 0.1; +/** Convert a validated native source path into a scoped Tauri asset URL. */ +function resolveAudioSourceUrl( + sourcePath: string | null | undefined, +): string | null { + if (!sourcePath || sourcePath.startsWith("browser://")) { + return null; + } + try { + return convertFileSrc(sourcePath); + } catch { + return null; + } +} + /** Return the displayed map-clock progress for the current loop. */ function loopProgressPercent(state: RehearsalTransportState): number { if (!state.loop) { @@ -63,6 +86,7 @@ function hasSameLoopTiming( export function RehearsalPlayer({ song, hasLocalAudio = false, + audioSourcePath = null, startNonce = 0, }: RehearsalPlayerProps): ReactElement { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); @@ -78,6 +102,71 @@ export function RehearsalPlayer({ }), ); const lastHandledStartNonce = useRef(0); + const restartAudioOnLoopRef = useRef(false); + const audioRef = useRef(null); + const audioSourceUrl = useMemo( + () => resolveAudioSourceUrl(audioSourcePath), + [audioSourcePath], + ); + const [playbackError, setPlaybackError] = useState(false); + + const handlePlaybackError = useCallback(() => { + setPlaybackError(true); + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, []); + + const startAudio = useCallback( + (loop: RehearsalLoopWindow, resume: boolean) => { + const audio = audioRef.current; + if (!audio || !audioSourceUrl) { + return; + } + try { + restartAudioOnLoopRef.current = !resume; + if (!resume) { + audio.currentTime = loop.startSeconds; + audio.volume = 0; + } else { + audio.volume = 1; + } + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch(handlePlaybackError); + } + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, handlePlaybackError], + ); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return undefined; + } + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + if (audioSourceUrl) { + audio.src = audioSourceUrl; + audio.load(); + } else { + audio.removeAttribute("src"); + } + setPlaybackError(false); + return () => { + if (!audio.paused) { + audio.pause(); + } + }; + }, [audioSourceUrl]); useEffect(() => { const nextLoop = playableLoops[selectedRendererIndex] ?? null; @@ -107,15 +196,25 @@ export function RehearsalPlayer({ if (!hasLocalAudio) { return; } + const selectedLoop = playableLoops[selectedRendererIndex] ?? null; + if (selectedLoop) { + setPlaybackError(false); + startAudio(selectedLoop, false); + } setTransport((current) => { - const selectedLoop = playableLoops[selectedRendererIndex] ?? null; const armed = reduceRehearsalTransport(current, { type: "arm", loop: selectedLoop, }); return reduceRehearsalTransport(armed, { type: "start" }); }); - }, [startNonce, hasLocalAudio, playableLoops, selectedRendererIndex]); + }, [ + startAudio, + startNonce, + hasLocalAudio, + playableLoops, + selectedRendererIndex, + ]); useEffect(() => { if (hasLocalAudio) { @@ -142,7 +241,84 @@ export function RehearsalPlayer({ }, [transport.phase, transport.loop]); useEffect(() => { - if (transport.phase !== "looping" || !transport.loop) { + if (!audioSourceUrl || !transport.loop) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + if (transport.phase === "looping") { + try { + if (restartAudioOnLoopRef.current) { + audio.currentTime = transport.loop.startSeconds; + restartAudioOnLoopRef.current = false; + } + audio.volume = 1; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch(handlePlaybackError); + } + } catch { + handlePlaybackError(); + } + } else if ( + transport.phase === "armed" || + transport.phase === "paused" || + transport.phase === "idle" + ) { + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + } + return undefined; + }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]); + + useEffect(() => { + if (!audioSourceUrl || transport.phase !== "looping" || !transport.loop) { + return undefined; + } + const audio = audioRef.current; + if (!audio) { + return undefined; + } + const loop = transport.loop; + /** Keep the map playhead aligned with the scoped audio element. */ + const syncPlayhead = () => { + if (audio.currentTime >= loop.endSeconds) { + try { + audio.currentTime = loop.startSeconds; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch(handlePlaybackError); + } + } catch { + handlePlaybackError(); + return; + } + } + setTransport((current) => + reduceRehearsalTransport(current, { + type: "sync", + playheadSeconds: audio.currentTime, + }), + ); + }; + /** Stop the transport when the media element can no longer play. */ + const failPlayback = () => handlePlaybackError(); + audio.addEventListener("timeupdate", syncPlayhead); + audio.addEventListener("error", failPlayback); + audio.addEventListener("ended", failPlayback); + return () => { + audio.removeEventListener("timeupdate", syncPlayhead); + audio.removeEventListener("error", failPlayback); + audio.removeEventListener("ended", failPlayback); + }; + }, [audioSourceUrl, handlePlaybackError, transport.phase, transport.loop]); + + useEffect(() => { + if (audioSourceUrl || transport.phase !== "looping" || !transport.loop) { return undefined; } const timer = window.setInterval(() => { @@ -154,7 +330,7 @@ export function RehearsalPlayer({ ); }, PLAYHEAD_TICK_SECONDS * 1000); return () => window.clearInterval(timer); - }, [transport.phase, transport.loop]); + }, [audioSourceUrl, transport.phase, transport.loop]); const actionKey = nextActionTemplateKey(transport, hasLocalAudio); const nextAction = fillRehearsalCopy( @@ -246,6 +422,14 @@ export function RehearsalPlayer({ if (!canStart) { return; } + setPlaybackError(false); + if (transport.loop) { + startAudio( + transport.loop, + transport.phase === "paused" && + transport.countInRemainingBeats === 0, + ); + } setTransport((current) => reduceRehearsalTransport(current, { type: "start" }), ); @@ -282,6 +466,22 @@ export function RehearsalPlayer({ {t("workspaceLoopStop")}

+ {playbackError ? ( +

+ {t("workspaceLoopAudioError")} +

+ ) : null} +