diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..431188c83 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; #1063 adds bounded pitch-preserving playback rate, while stem playback remains later work. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. @@ -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` +- Python tests: `uv run --directory services/analysis-engine pytest --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100` +- Typecheck: `npm run typecheck --workspaces --if-present && uv run --directory services/analysis-engine mypy src` ## Architecture references + - `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 ca0df5ac4..07498e364 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 0b6f7e784..d890e25de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- Tonight's rehearsal map now arms the first valid section map-clock loop, runs a tempo count-in, and names the next start, pause, stop, or choose-local-song action without claiming decoded audio playback; admitted section timing and picker copy use the same descriptor-snapshotted transport window. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Move between playable section cues with Left and Right Arrow and keep the selected cue focused. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed @@ -14,6 +16,8 @@ ### Fixed +- Kept the rehearsal player section picker aligned with the selected player or + vocal role while preserving the full song-form roadmap. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ## [0.1.3] - 2026-04-29 @@ -74,4 +78,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..9a12fd557 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 @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The workspace `RehearsalPlayer` owns tonight's first section loop and count-in clock; it does not decode local audio in this slice. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/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-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..2c80b1590 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -16,7 +16,11 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: data: blob:; font-src 'self' data:", + "csp": "default-src 'self'; img-src 'self' asset: data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:", + "assetProtocol": { + "enable": true, + "scope": [] + }, "capabilities": ["main-capability"] } }, diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx new file mode 100644 index 000000000..8ebd45a63 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); + +describe("RehearsalPlayer audio authority", () => { + afterEach(() => { + vi.restoreAllMocks(); + if (originalTauriInternals) { + Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + }); + + it("refuses to start when local-audio metadata has no playable asset URL", () => { + const song = createDemoRehearsalSong(); + + render( + , + ); + + expect( + screen.getByRole("button", { name: /start the count-in/i }), + ).toBeDisabled(); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/count in 4 beats/i); + }); + + it("reports native asset conversion failures instead of presenting missing-audio copy", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: () => { + throw new Error("asset conversion failed"); + }, + }, + }); + const song = createDemoRehearsalSong(); + + render( + , + ); + + expect( + screen.getByRole("button", { name: /start the count-in/i }), + ).toBeDisabled(); + expect(screen.getByRole("alert").textContent).toMatch( + /could not play this local audio/i, + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.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(); + }); +}); 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..224de28bd --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -0,0 +1,1132 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + act, + createEvent, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalLanguage = navigator.language; +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); +const originalPreservesPitch = Object.getOwnPropertyDescriptor( + HTMLMediaElement.prototype, + "preservesPitch", +); +const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const audioSourcePath = "/Users/test/Music/rehearsal.wav"; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language, + }); +} + +function installPlayableAudioMocks() { + const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + Object.defineProperty(HTMLMediaElement.prototype, "preservesPitch", { + configurable: true, + writable: true, + value: false, + }); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + return { convertFileSrc, play }; +} + +describe("RehearsalPlayer", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.useRealTimers(); + vi.restoreAllMocks(); + if (originalTauriInternals) { + Object.defineProperty( + window, + "__TAURI_INTERNALS__", + originalTauriInternals, + ); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + if (originalPreservesPitch) { + Object.defineProperty( + HTMLMediaElement.prototype, + "preservesPitch", + originalPreservesPitch, + ); + } else { + delete (HTMLMediaElement.prototype as HTMLMediaElement & { + preservesPitch?: boolean; + }).preservesPitch; + } + }); + + it("allows both platform Tauri asset origins in the media CSP", () => { + const config = JSON.parse(readFileSync(tauriConfigPath, "utf8")) as { + app: { security: { csp: string } }; + }; + const mediaDirective = config.app.security.csp + .split(";") + .find((directive) => directive.trim().startsWith("media-src ")); + const sources = mediaDirective?.trim().split(/\s+/).slice(1) ?? []; + + expect(sources).toEqual( + expect.arrayContaining(["asset:", "http://asset.localhost"]), + ); + expect(sources).not.toContain("*"); + expect(sources).not.toContain("http:"); + expect(sources).not.toContain("https:"); + }); + + it("names the first playable loop and blocks starting before local audio exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + 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); + expect( + ( + screen.getByRole("button", { + name: /Start the count-in/i, + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it("does not let an external start request bypass missing local-audio authority", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + render( + , + ); + + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Choose a local song first/i); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/Count in 4 beats/i); + }); + + it("limits the section picker to sections containing the active role", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + render( + , + ); + + expect( + screen.getByRole("group", { name: "Playable sections for Lead Vocal" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /verse/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /chorus/i })).toBeNull(); + expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent( + "Showing sections that include Lead Vocal.", + ); + }); + + it("moves section cues with the arrow keys and keeps the selected cue focused", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [song.sections[0]!, chorus]; + + render(); + + const verse = screen.getByRole("button", { name: /verse/i }); + const chorusButton = screen.getByRole("button", { name: /chorus/i }); + expect( + screen.getByTestId("rehearsal-loop-keyboard-hint"), + ).toHaveTextContent("Use Left and Right Arrow to move between section cues."); + + chorusButton.focus(); + fireEvent.keyDown(chorusButton, { key: "ArrowLeft" }); + expect(verse).toHaveFocus(); + + fireEvent.keyDown(verse, { key: "ArrowRight" }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + expect(chorusButton).toHaveFocus(); + + fireEvent.keyDown(chorusButton, { key: "ArrowRight" }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + fireEvent.keyDown(chorusButton, { key: "ArrowLeft" }); + expect(verse).toHaveAttribute("aria-pressed", "true"); + expect(verse).toHaveFocus(); + }); + + it("lets the selected cue keep a manual range correction in the song map", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + expect(screen.getByTestId("rehearsal-loop-boundary-editor")).toHaveTextContent( + "Manual cue correction", + ); + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "12" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange).toEqual({ + start: 12, + end: 30, + }); + expect(song.sections[0]!.timeRange.start).toBe(10); + }); + + it("rejects a boundary correction that would invert the selected cue", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + const end = screen.getByRole("spinbutton", { + name: "End time (seconds)", + }); + fireEvent.change(end, { target: { value: "5" } }); + fireEvent.blur(end); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(end).toHaveValue(30); + expect(end).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByTestId("rehearsal-loop-boundary-editor")).toHaveTextContent( + "with the end after the start", + ); + }); + + it("keeps focus on the next boundary field after a Tab correction", async () => { + setNavigatorLanguage("en-US"); + const user = userEvent.setup(); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render(); + + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + const end = screen.getByRole("spinbutton", { + name: "End time (seconds)", + }); + await user.click(start); + await user.clear(start); + await user.type(start, "12"); + await user.tab(); + + expect(end).toHaveFocus(); + expect(start).toHaveValue(12); + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange.start).toBe(12); + }); + + it("keeps the selected loop by section ID when an earlier section is filtered out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.id = "verse-no-lead-vocal"; + verse.roles = verse.roles.filter((role) => role.id !== "lead-vocal"); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /chorus/i })); + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + + rerender( + , + ); + + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Map chorus from 0:40–1:04/i, + ); + }); + + it("keeps duplicate cue identities distinct for navigation and correction", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const duplicate = { + ...song.sections[0]!, + label: "verse copy", + timeRange: { ...song.sections[0]!.timeRange }, + }; + song.sections = [song.sections[0]!, duplicate]; + const onSongUpdate = vi.fn(); + render(); + + const sectionButtons = screen + .getAllByRole("button") + .filter((button) => button.id.startsWith("rehearsal-loop-section-")); + expect(sectionButtons).toHaveLength(2); + expect(sectionButtons[0]).toHaveAttribute("aria-pressed", "true"); + expect(sectionButtons[1]).toHaveAttribute("aria-pressed", "false"); + + sectionButtons[0]!.focus(); + fireEvent.keyDown(sectionButtons[0]!, { key: "ArrowRight" }); + + expect(sectionButtons[1]).toHaveFocus(); + expect(sectionButtons[0]).toHaveAttribute("aria-pressed", "false"); + expect(sectionButtons[1]).toHaveAttribute("aria-pressed", "true"); + + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "12" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[0]!.timeRange.start).toBe(10); + expect(onSongUpdate.mock.calls[0]![0].sections[1]!.timeRange.start).toBe(12); + }); + + it("preserves the selected cue when an earlier section is inserted", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [song.sections[0]!, chorus]; + const onSongUpdate = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /chorus/i })); + const inserted = structuredClone(song.sections[0]!); + inserted.id = "intro-1"; + inserted.label = "intro"; + inserted.timeRange = { start: 0, end: 5 }; + const updatedSong = { ...song, sections: [inserted, ...song.sections] }; + rerender( + , + ); + + const chorusButton = screen.getByRole("button", { name: /chorus/i }); + expect(chorusButton).toHaveAttribute("aria-pressed", "true"); + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + fireEvent.change(start, { target: { value: "42" } }); + fireEvent.blur(start); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + expect(onSongUpdate.mock.calls[0]![0].sections[1]!.timeRange.start).toBe(10); + expect(onSongUpdate.mock.calls[0]![0].sections[2]!.timeRange.start).toBe(42); + rerender( + , + ); + expect(screen.getByRole("button", { name: /chorus/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("explains when the active role has no playable sections", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = []; + + render( + , + ); + + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + "No playable sections include Lead Vocal yet.", + ); + expect(screen.queryByRole("group")).toBeNull(); + }); + + it("stops active count-in and loop ticking when local-audio authority is revoked", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const { rerender } = render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats/i); + + rerender(); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Choose a local song first/i); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/looping/i); + + rerender( + , + ); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + act(() => { + vi.advanceTimersByTime(500); + }); + + const audio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: 15, + }); + fireEvent(audio, new Event("timeupdate")); + + const playheadBeforeRevocation = screen + .getByTestId("rehearsal-loop-playhead") + .getAttribute("style"); + 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(playheadAfterRevocation); + }); + + it("counts in then loops the selected section on the map clock", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + 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(/The rehearsal clock is looping verse from 0:10–0:30/i); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect( + screen.getByTestId("rehearsal-loop-playhead").getAttribute("style"), + ).toContain("%"); + }); + + it("uses the scoped native asset as the media clock for a real loop", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const { convertFileSrc, play } = installPlayableAudioMocks(); + 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: 29.9, + }); + fireEvent(audio, new Event("timeupdate")); + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: 30, + }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(audio.currentTime).toBe(10); + }); + + it("seeks the scoped media clock within a live loop", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i })); + act(() => { + vi.advanceTimersByTime(2000); + }); + + const seek = screen.getByRole("slider", { name: "Seek within this cue" }); + fireEvent.change(seek, { target: { value: "18.5" } }); + + expect(audio.currentTime).toBe(18.5); + expect(seek).toHaveValue("18.5"); + }); + + it("supports transport shortcuts without capturing controls or modifiers", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const { play } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + render( + , + ); + + const start = screen.getByRole("button", { name: /Start the count-in/i }); + const boundaryStart = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + boundaryStart.focus(); + fireEvent.keyDown(boundaryStart, { key: " " }); + expect(start).toBeEnabled(); + expect(play).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: " " }); + fireEvent.keyDown(window, { key: " ", repeat: true }); + expect(start).toBeDisabled(); + expect(play).toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(2000); + }); + fireEvent.keyDown(window, { key: " ", ctrlKey: true }); + fireEvent.keyDown(window, { key: " ", shiftKey: true }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /looping/i, + ); + fireEvent.keyDown(window, { key: " " }); + fireEvent.keyDown(window, { key: " ", repeat: true }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /paused/i, + ); + const stop = screen.getByRole("button", { name: /Stop/i }); + stop.focus(); + fireEvent.keyDown(stop, { key: "Escape", altKey: true }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /paused/i, + ); + fireEvent.keyDown(stop, { key: "Escape" }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Start the count-in/i, + ); + }); + + it("preserves native Space behavior for focused scroll regions", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + <> + +
+ , + ); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(2000); + }); + + const region = screen.getByRole("region", { + name: "Scrollable rehearsal timeline", + }); + const event = createEvent.keyDown(region, { key: " " }); + region.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + }); + + it("caps long media boundary timers before the browser timeout limit", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + const convertFileSrc = vi.fn((path: string) => `asset://localhost/${path}`); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { + start: 10, + end: 10 + 2_147_483_648, + }; + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(setTimeoutSpy).toHaveBeenLastCalledWith( + expect.any(Function), + 2_147_483_647, + ); + }); + + it.each([ + { rate: "0.75", beforeLoopMs: 2000, remainingMs: 700 }, + { rate: "1.25", beforeLoopMs: 1500, remainingMs: 200 }, + ])( + "keeps the count-in aligned with playback rate $rate", + ({ rate, beforeLoopMs, remainingMs }) => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: rate }, + }); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + + act(() => { + vi.advanceTimersByTime(beforeLoopMs); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/looping/i); + + act(() => { + vi.advanceTimersByTime(remainingMs); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/looping/i); + }, + ); + + it("scales and reschedules section boundaries when playback rate changes", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + render( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + act(() => { + vi.advanceTimersByTime(1600); + }); + + expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBeCloseTo(16_000, 5); + + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "0.75" }, + }); + + expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBeCloseTo( + 20_000 / 0.75, + 5, + ); + }); + + it("keeps the remaining count-in beat when playback rate changes", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(400); + }); + + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "0.75" }, + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 90 BPM/i); + + act(() => { + vi.advanceTimersByTime(50); + }); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + + act(() => { + vi.advanceTimersByTime(369); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats at 150 BPM/i); + act(() => { + vi.advanceTimersByTime(1); + }); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 3 beats at 150 BPM/i); + }); + + it("applies supported playback speed while preserving pitch when available", () => { + setNavigatorLanguage("en-US"); + const { play } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + const { rerender } = render( + , + ); + + const audio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + const rateSelect = screen.getByRole("combobox", { + name: /Playback speed/i, + }) as HTMLSelectElement; + expect(rateSelect.value).toBe("1"); + + fireEvent.change(rateSelect, { target: { value: "0.75" } }); + + expect(audio.playbackRate).toBe(0.75); + expect(audio.preservesPitch).toBe(true); + expect( + screen.getByText(/Pitch stays natural when the audio engine supports it/i), + ).toBeTruthy(); + expect(play).not.toHaveBeenCalled(); + + rerender( + , + ); + expect(audio.playbackRate).toBe(0.75); + }); + + it("keeps a live loop running across unrelated song metadata updates", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + 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(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + render( + , + ); + + const startButton = screen.getByRole("button", { + name: /Start the count-in/i, + }) as HTMLButtonElement; + expect(startButton.disabled).toBe(false); + + fireEvent.click(startButton); + expect(startButton.disabled).toBe(true); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(startButton.disabled).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: /Pause/i })); + expect(startButton.disabled).toBe(false); + }); + + it("restarts a paused loop from an external section-start request", () => { + setNavigatorLanguage("en-US"); + vi.useFakeTimers(); + installPlayableAudioMocks(); + 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"); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + song.sections = [ + { + ...song.sections[0]!, + id: "verse-a", + label: "verse", + timeRange: { start: 10, end: 20 }, + }, + { + ...song.sections[0]!, + id: "chorus-b", + label: "chorus", + timeRange: { start: 20, end: 30 }, + }, + ]; + + render( + , + ); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Count in 4 beats/i); + + fireEvent.click( + screen.getByRole("button", { name: /chorus.*0:20.*0:30/i }), + ); + + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/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); + }); + + it("keeps duplicate analysis section ids selectable by renderer position", () => { + setNavigatorLanguage("en-US"); + installPlayableAudioMocks(); + 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(/Map 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(); + 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..85f5b8b31 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -0,0 +1,989 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent as ReactKeyboardEvent, + type ReactElement, +} from "react"; +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalSong, +} from "@bandscope/shared-types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + type TranslationKey, +} from "../../i18n"; +import { + beatDurationMs, + createIdleTransportState, + fillRehearsalCopy, + formatRehearsalClock, + nextActionTemplateKey, + nextActionValues, + isRehearsalPlaybackRate, + rehearsalPlaybackRates, + reduceRehearsalTransport, + resolveLoopWindows, + type RehearsalLoopWindow, + type RehearsalTransportState, +} from "./rehearsalTransport"; + +interface RehearsalPlayerProps { + song: RehearsalSong; + onSongUpdate?: (song: RehearsalSong) => void; + hasLocalAudio?: boolean; + audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; + startNonce?: number; +} + +/** 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 whether a source path can be converted into a playable native asset URL. */ +export function isPlayableAudioSource( + sourcePath: string | null | undefined, +): boolean { + return resolveAudioSourceUrl(sourcePath) !== null; +} + +/** Return the displayed map-clock progress for the current loop. */ +function loopProgressPercent(state: RehearsalTransportState): number { + if (!state.loop) { + 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, + ), + ); +} + +/** Return whether two loop windows describe the same transport timing authority. */ +function hasSameLoopTiming( + current: RehearsalLoopWindow, + next: RehearsalLoopWindow, +): boolean { + return ( + current.selectionKey === next.selectionKey && + current.sectionId === next.sectionId && + current.startSeconds === next.startSeconds && + current.endSeconds === next.endSeconds && + current.tempoBpm === next.tempoBpm && + current.countInBeats === next.countInBeats + ); +} + +/** Return a stable selection key when analysis emits duplicate section IDs. */ +function loopSelectionKey(loop: RehearsalLoopWindow): string { + return loop.selectionKey; +} + +/** Render tonight's first section loop with a count-in and a named next action. */ +export function RehearsalPlayer({ + song, + onSongUpdate, + hasLocalAudio = false, + audioSourcePath = null, + activeRole = null, + activeRoleName = null, + startNonce = 0, +}: RehearsalPlayerProps): ReactElement { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const playableLoops = useMemo( + () => resolveLoopWindows(song, activeRole), + [activeRole, song], + ); + const [selectedLoopKey, setSelectedLoopKey] = useState(null); + const [boundaryError, setBoundaryError] = useState(false); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; + const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; + const [boundaryDraft, setBoundaryDraft] = useState(() => ({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + })); + useEffect(() => { + setBoundaryError(false); + setBoundaryDraft({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + start: selectedLoop ? String(selectedLoop.startSeconds) : "", + }); + }, [selectedBoundaryKey, selectedLoop?.endSeconds, selectedLoop?.startSeconds]); + const handleSectionKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + const focusedIndex = Number(event.currentTarget.dataset.loopIndex); + const selectedIndex = selectedLoop + ? playableLoops.indexOf(selectedLoop) + : -1; + const currentIndex = + Number.isSafeInteger(focusedIndex) && + focusedIndex >= 0 && + focusedIndex < playableLoops.length + ? focusedIndex + : selectedIndex; + const nextIndex = + currentIndex + (event.key === "ArrowRight" ? 1 : -1); + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= playableLoops.length + ) { + return; + } + event.preventDefault(); + const nextLoop = playableLoops[nextIndex]; + setSelectedLoopKey(loopSelectionKey(nextLoop)); + document + .getElementById( + `rehearsal-loop-section-${loopSelectionKey(nextLoop)}-${nextIndex}`, + ) + ?.focus(); + }, + [playableLoops, selectedLoop], + ); + const [transport, setTransport] = useState(() => + reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop: playableLoops[0] ?? null, + }), + ); + const lastHandledStartNonce = useRef(0); + const restartAudioOnLoopRef = useRef(false); + const countInBeatRef = useRef<{ + durationMs: number; + startedAt: number; + remainingBeats: number; + } | null>(null); + const audioRef = useRef(null); + const audioSourceUrl = useMemo( + () => resolveAudioSourceUrl(audioSourcePath), + [audioSourcePath], + ); + const hasPlayableAudio = hasLocalAudio && audioSourceUrl !== null; + const hasNativeAudioConversionError = Boolean( + hasLocalAudio && + audioSourcePath && + !audioSourcePath.startsWith("browser://") && + audioSourceUrl === null, + ); + 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) { + handlePlaybackError(); + 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(hasNativeAudioConversionError); + return () => { + if (!audio.paused) { + audio.pause(); + } + }; + }, [audioSourceUrl, hasNativeAudioConversionError]); + + useEffect(() => { + setTransport((current) => { + if ( + current.loop && + selectedLoop && + hasSameLoopTiming(current.loop, selectedLoop) + ) { + if ( + current.loop.sectionLabel === selectedLoop.sectionLabel && + current.loop.tempoAssumed === selectedLoop.tempoAssumed && + current.loop.sourceIndex === selectedLoop.sourceIndex + ) { + return current; + } + return { ...current, loop: selectedLoop }; + } + return reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + }); + }, [selectedLoop]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + try { + audio.playbackRate = transport.playbackRate; + if ("preservesPitch" in audio) { + audio.preservesPitch = true; + } + } catch { + handlePlaybackError(); + } + }, [audioSourceUrl, handlePlaybackError, transport.playbackRate]); + + useEffect(() => { + if (startNonce <= lastHandledStartNonce.current) { + return; + } + lastHandledStartNonce.current = startNonce; + if (!hasPlayableAudio) { + return; + } + if (selectedLoop) { + setPlaybackError(false); + startAudio(selectedLoop, false); + } + setTransport((current) => { + const armed = reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + return reduceRehearsalTransport(armed, { type: "start" }); + }); + }, [ + startAudio, + startNonce, + hasPlayableAudio, + selectedLoop, + ]); + + useEffect(() => { + if (hasPlayableAudio) { + return; + } + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, [hasPlayableAudio]); + + useEffect(() => { + if (transport.phase !== "counting-in" || !transport.loop) { + countInBeatRef.current = null; + return undefined; + } + const durationMs = + beatDurationMs(transport.loop.tempoBpm) / transport.playbackRate; + const now = performance.now(); + const previous = countInBeatRef.current; + const sameBeat = + previous?.remainingBeats === transport.countInRemainingBeats; + const elapsedMs = sameBeat + ? Math.max(0, now - previous.startedAt) + : 0; + const progress = sameBeat + ? Math.min(1, elapsedMs / previous.durationMs) + : 0; + countInBeatRef.current = { + durationMs, + startedAt: now, + remainingBeats: transport.countInRemainingBeats, + }; + let timer: number | undefined; + /** Schedule the next count-in beat without coupling it to React commits. */ + const scheduleBeat = (delayMs: number) => { + timer = window.setTimeout(() => { + const current = countInBeatRef.current; + if (!current || current.remainingBeats <= 0) { + return; + } + current.remainingBeats -= 1; + setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); + if (current.remainingBeats > 0) { + current.startedAt = performance.now(); + scheduleBeat(current.durationMs); + } + }, delayMs); + }; + scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [ + transport.loop, + transport.phase, + transport.playbackRate, + ]); + + useEffect(() => { + if (!audioSourceUrl || !transport.loop) { + 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; + const playbackRate = transport.playbackRate; + let boundaryTimer: number | undefined; + /** Cancel the pending media-clock boundary check. */ + const clearBoundaryTimer = () => { + if (boundaryTimer !== undefined) { + window.clearTimeout(boundaryTimer); + boundaryTimer = undefined; + } + }; + /** Restart media at the exact selected section boundary. */ + const restartLoop = () => { + try { + audio.currentTime = loop.startSeconds; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch(handlePlaybackError); + } + } catch { + handlePlaybackError(); + return; + } + scheduleLoopBoundary(); + }; + /** Schedule a media-clock boundary check and reschedule if timers fire early. */ + const scheduleLoopBoundary = () => { + clearBoundaryTimer(); + const remainingSeconds = loop.endSeconds - audio.currentTime; + if (!Number.isFinite(remainingSeconds)) { + return; + } + if (remainingSeconds <= 0) { + restartLoop(); + return; + } + boundaryTimer = window.setTimeout(() => { + boundaryTimer = undefined; + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + }, + Math.min( + (remainingSeconds / playbackRate) * 1000, + 2_147_483_647, + ), + ); + }; + /** Keep the map playhead aligned with the scoped audio element. */ + const syncPlayhead = () => { + if (audio.currentTime >= loop.endSeconds) { + restartLoop(); + } else { + scheduleLoopBoundary(); + } + 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); + scheduleLoopBoundary(); + return () => { + clearBoundaryTimer(); + audio.removeEventListener("timeupdate", syncPlayhead); + audio.removeEventListener("error", failPlayback); + audio.removeEventListener("ended", failPlayback); + }; + }, [ + audioSourceUrl, + handlePlaybackError, + transport.phase, + transport.loop, + transport.playbackRate, + ]); + + const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); + const nextAction = + activeRoleName && playableLoops.length === 0 + ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { + roleName: activeRoleName, + }) + : fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const sectionPickerLabel = activeRoleName + ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { + roleName: activeRoleName, + }) + : t("workspaceLoopSectionPickerLabel"); + const canStart = + transport.loop !== null && + hasPlayableAudio && + (transport.phase === "armed" || transport.phase === "paused"); + 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"); + const handleBoundaryBlur = useCallback( + (boundary: "start" | "end", event: FocusEvent) => { + if (!selectedLoop || !onSongUpdate) { + return; + } + const rawValue = event.currentTarget.value.trim(); + const value = Number(rawValue); + const valid = + rawValue !== "" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_SECTION_TIME_SECONDS && + (boundary === "start" + ? value < selectedLoop.endSeconds + : value > selectedLoop.startSeconds); + if (!valid) { + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + setBoundaryError(true); + return; + } + + setBoundaryError(false); + const currentValue = + boundary === "start" + ? selectedLoop.startSeconds + : selectedLoop.endSeconds; + if (value === currentValue) { + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(currentValue), + })); + return; + } + + const sectionIndex = selectedLoop.sourceIndex; + const section = song.sections[sectionIndex]; + if ( + !section || + section.id !== selectedLoop.sectionId || + section.timeRange.start !== selectedLoop.startSeconds || + section.timeRange.end !== selectedLoop.endSeconds + ) { + return; + } + const nextSong = { + ...song, + sections: song.sections.map((currentSection, index) => + index === sectionIndex + ? { + ...section, + timeRange: { + ...section.timeRange, + [boundary]: value, + }, + } + : currentSection, + ), + }; + const nextLoop = + boundary === "start" + ? { ...selectedLoop, startSeconds: value } + : { ...selectedLoop, endSeconds: value }; + setBoundaryDraft((current) => ({ + ...current, + [boundary]: String(value), + })); + setSelectedLoopKey(loopSelectionKey(nextLoop)); + onSongUpdate(nextSong); + }, + [onSongUpdate, selectedLoop, song], + ); + const canSeek = + transport.loop !== null && + hasPlayableAudio && + (transport.phase === "looping" || + (transport.phase === "paused" && transport.countInRemainingBeats === 0)); + const handleSeek = useCallback( + (event: ChangeEvent) => { + if (!canSeek || !transport.loop || !audioSourceUrl) { + return; + } + const nextTransport = reduceRehearsalTransport(transport, { + type: "seek", + playheadSeconds: Number(event.currentTarget.value), + }); + try { + const audio = audioRef.current; + if (!audio) { + return; + } + audio.currentTime = nextTransport.playheadSeconds; + setPlaybackError(false); + setTransport(nextTransport); + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, canSeek, handlePlaybackError, transport], + ); + const startOrResume = useCallback(() => { + if (!canStart) { + return; + } + setPlaybackError(false); + if (transport.loop) { + startAudio( + transport.loop, + transport.phase === "paused" && transport.countInRemainingBeats === 0, + ); + } + setTransport((current) => + reduceRehearsalTransport(current, { type: "start" }), + ); + }, [canStart, startAudio, transport]); + const stopTransport = useCallback(() => { + if (!canStop) { + return; + } + setTransport((current) => + reduceRehearsalTransport(current, { type: "stop" }), + ); + }, [canStop]); + useEffect(() => { + /** Keep transport shortcuts out of editable controls. */ + const handleTransportShortcut = (event: KeyboardEvent) => { + const target = event.target; + const targetIsButtonOrLink = + target instanceof Element && target.closest("button, a") !== null; + const targetIsScrollableRegion = + target instanceof Element && + target.closest('[role="region"][tabindex="0"]') !== null; + const targetIsEditable = + target instanceof HTMLElement && + (target.isContentEditable || + target.closest("input, select, textarea") !== null); + if ( + event.defaultPrevented || + event.repeat || + targetIsEditable + ) { + return; + } + if ( + event.key === " " && + !targetIsButtonOrLink && + !targetIsScrollableRegion && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (canPause || canStart) + ) { + event.preventDefault(); + if (canPause) { + setTransport((current) => + reduceRehearsalTransport(current, { type: "pause" }), + ); + } else { + startOrResume(); + } + } else if ( + event.key === "Escape" && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + canStop + ) { + event.preventDefault(); + stopTransport(); + } + }; + window.addEventListener("keydown", handleTransportShortcut); + return () => window.removeEventListener("keydown", handleTransportShortcut); + }, [canPause, canStart, canStop, startOrResume, stopTransport]); + + return ( +
+

+ {t("workspaceLoopTitle")} +

+

+ {nextAction} +

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

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

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

+ {t("workspaceLoopSectionKeyboardHint")} +

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

+ {t("workspaceLoopBoundaryTitle")} +

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

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

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

+ {t("workspaceLoopPlaybackRateHint")} +

+
+
+ ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..6bb8cb566 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 { fireEvent, render, screen, within } from "@testing-library/react"; +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"; @@ -8,26 +12,68 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { 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, + }, + }; +} + +function installPlayableAudioMocks() { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: (path: string) => `asset://localhost/${path}`, + }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); +} + describe("Workspace", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); Object.defineProperty(URL, "createObjectURL", { configurable: true, - value: originalCreateObjectUrl + value: originalCreateObjectUrl, }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, - value: originalRevokeObjectUrl + value: originalRevokeObjectUrl, }); + if (originalTauriInternals) { + Object.defineProperty( + window, + "__TAURI_INTERNALS__", + originalTauriInternals, + ); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } }); it("updates practice progress immutably through onSongUpdate", () => { @@ -37,7 +83,7 @@ describe("Workspace", () => { ...song.sections[0]!.roles[0]!, id: "bass-guitar", name: "Bass Guitar", - practiceProgress: 50 + practiceProgress: 50, }; const onSongUpdate = vi.fn(); @@ -46,7 +92,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 +125,7 @@ describe("Workspace", () => { const song = createDemoRehearsalSong(); song.sections[0].timeRange = { start: Number.NaN, - end: Number.POSITIVE_INFINITY + end: Number.POSITIVE_INFINITY, }; render(); @@ -85,18 +133,164 @@ 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( + /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", () => { + 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" })); + + const loopButton = screen.getByRole("button", { + name: "Start selected section loop", + }); + expect(loopButton.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(loopButton); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).toMatch(/Choose a local song first/i); + }); + + it("keeps the role loop action unavailable for browser-only audio authority", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + }; + const browserSourceBootstrap = { + ...createLocalSourceBootstrap(), + source: { + ...createLocalSourceBootstrap().source, + sourcePath: "browser://selected-audio", + }, + } satisfies ProjectBootstrapSummary; + + render( + , + ); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const loopButton = screen.getByRole("button", { + name: "Start selected section loop", + }); + expect(loopButton.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(loopButton); + expect( + screen.getByTestId("rehearsal-loop-next-action").textContent, + ).not.toMatch(/count in 4 beats/i); + }); + + it("starts the selected section loop from the role action when local audio is available", () => { + setNavigatorLanguage("en-US"); + installPlayableAudioMocks(); + 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: "Start selected section loop" }), + ); + + 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("passes the selected role into the player section filter", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const playerSections = screen.getByRole("group", { + name: "Playable sections for Lead Vocal", + }); + expect(within(playerSections).getByRole("button", { name: /verse/i })).toBeTruthy(); + expect(within(playerSections).queryByRole("button", { name: /chorus/i })).toBeNull(); + }); + + it("clears a role that is absent after replacing the song", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const replacement = createDemoRehearsalSong(); + replacement.sections = replacement.sections.map((section) => ({ + ...section, + roles: section.roles.filter((role) => role.id !== "lead-vocal"), + })); + + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent( + "Showing sections that include Lead Vocal.", + ); + + rerender(); + + expect(screen.queryByTestId("rehearsal-loop-role-filter")).toBeNull(); + expect( + screen.getByRole("tab", { name: "All Roles", selected: true }), + ).toBeTruthy(); + expect(screen.getByTestId("rehearsal-loop-next-action")).not.toHaveTextContent( + /No playable sections include Lead Vocal/i, + ); + }); + it("enables bass transcription from selected role metadata rather than role id text", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...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"); }); @@ -108,14 +302,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(); @@ -136,7 +332,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(); }); @@ -202,11 +400,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(); @@ -218,34 +416,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(); @@ -263,18 +454,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(); @@ -291,11 +484,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"); }); @@ -304,7 +499,9 @@ describe("Workspace", () => { render(); render(); - expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect( + screen.getByRole("heading", { name: "분석 준비 완료" }), + ).toBeTruthy(); expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); }); @@ -313,7 +510,7 @@ describe("Workspace", () => { const song = createDemoRehearsalSong(); song.exportSummary = { ...song.exportSummary, - headline: "" + headline: "", }; render(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..ce269580a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,9 +1,10 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, memo, useEffect, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { isPlayableAudioSource, RehearsalPlayer } from "./RehearsalPlayer"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -121,7 +122,16 @@ 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()), []); + const parsedSourceBootstrap = useMemo( + () => safeProjectBootstrapSummary(sourceBootstrap), + [sourceBootstrap], + ); + const hasLocalAudio = parsedSourceBootstrap !== null; + const hasPlayableAudio = + hasLocalAudio && + isPlayableAudioSource(parsedSourceBootstrap?.source.sourcePath ?? null); // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -136,6 +146,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp return map; }, [song]); + const resolvedActiveRole = activeRole && roleMap.has(activeRole) ? activeRole : null; + + useEffect(() => { + if (activeRole && !roleMap.has(activeRole)) { + setActiveRole(null); + } + }, [activeRole, roleMap]); + const allRoles = useMemo(() => { // Performance: Avoid O(N) allocation of intermediate array from Array.from() before mapping const roles: { id: string; name: string }[] = []; @@ -147,11 +165,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp // Performance: use the cached roleMap so activeRoleDetails does not rescan sections and roles on every render. const activeRoleDetails = useMemo(() => { - if (!activeRole) return undefined; - return roleMap.get(activeRole); - }, [activeRole, roleMap]); + if (!resolvedActiveRole) return undefined; + return roleMap.get(resolvedActiveRole); + }, [resolvedActiveRole, roleMap]); const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; - const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); + const firstRange = useMemo(() => firstRangeSqueeze(song, resolvedActiveRole), [resolvedActiveRole, song]); const firstRangeCopy = firstRange ? fillRangeCopy( t(firstRange.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), @@ -166,13 +184,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { - if (!activeRole || !onSongUpdate) return; + if (!resolvedActiveRole || !onSongUpdate) return; // Performance: Use shallow copying to avoid expensive structuredClone const nextSong = { ...song, sections: song.sections.map(section => { - const roleIndex = section.roles.findIndex(r => r.id === activeRole); + const roleIndex = section.roles.findIndex(r => r.id === resolvedActiveRole); if (roleIndex === -1) return section; const nextRoles = [...section.roles]; @@ -211,12 +229,12 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp [collaborationApprovals.length, collaborationAssignments.length, collaborationComments.length] ); const activeRoleAssignments = useMemo( - () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === activeRole), - [activeRole, collaborationAssignments] + () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === resolvedActiveRole), + [resolvedActiveRole, collaborationAssignments] ); const activeRoleComments = useMemo( - () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === activeRole), - [activeRole, collaborationComments] + () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === resolvedActiveRole), + [resolvedActiveRole, collaborationComments] ); const roleHarmonicExplanation = nonBlankText(activeRoleDetails?.harmonicExplanation) ?? @@ -355,6 +373,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp + +
@@ -363,15 +391,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {activeRole && ( + {resolvedActiveRole && (

Stem Player

-

{activeRoleDetails?.name ?? activeRole}

+

{activeRoleDetails?.name ?? resolvedActiveRole}

diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts new file mode 100644 index 000000000..219b32724 --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.descriptor-authority.test.ts @@ -0,0 +1,39 @@ +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({ + sourceIndex: 0, + selectionKey: JSON.stringify([expectedId, 0]), + sectionId: expectedId, + sectionLabel: expectedLabel, + startSeconds: expectedRange.start, + endSeconds: expectedRange.end, + tempoBpm: 120, + tempoAssumed: false, + countInBeats: 4 + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts new file mode 100644 index 000000000..308990c4e --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.test.ts @@ -0,0 +1,285 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + beatDurationMs, + createIdleTransportState, + createLoopWindow, + fillRehearsalCopy, + formatRehearsalClock, + isRehearsalPlaybackRate, + isPlayableLoopSection, + nextActionTemplateKey, + nextActionValues, + rehearsalPlaybackRates, + reduceRehearsalTransport, + resolveLoopWindow, + resolveLoopWindows, + resolveRehearsalTempo, + wrapPlayhead, +} from "./rehearsalTransport"; + +describe("rehearsalTransport", () => { + it("rejects blank, inverted, and non-finite section windows before arming a loop", () => { + const song = createDemoRehearsalSong(); + expect(isPlayableLoopSection(song.sections[0])).toBe(true); + expect(isPlayableLoopSection(undefined)).toBe(false); + song.sections[0]!.timeRange = { start: Number.NaN, end: 30 }; + expect(createLoopWindow(song.sections[0]!, song.tempo)).toBeNull(); + song.sections[0]!.timeRange = { start: 40, end: 10 }; + expect(isPlayableLoopSection(song.sections[0])).toBe(false); + 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", () => { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections.push(chorus); + song.sections[0]!.timeRange = { start: Number.POSITIVE_INFINITY, end: 30 }; + const window = resolveLoopWindow(song, "missing-section"); + expect(window?.sectionId).toBe("chorus-1"); + expect(window?.startSeconds).toBe(40); + expect(window?.endSeconds).toBe(64); + }); + + it("skips malformed section entries before requested-id lookup and fallback", () => { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [null as never, chorus]; + + const window = resolveLoopWindow(song, "missing-section"); + + expect(window?.sectionId).toBe("chorus-1"); + expect(window?.startSeconds).toBe(40); + expect(window?.endSeconds).toBe(64); + }); + + it("filters loop windows to sections containing the selected role", () => { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + expect( + resolveLoopWindows(song, "lead-vocal").map((window) => window.sectionId), + ).toEqual(["verse-1"]); + expect( + resolveLoopWindows(song, "bass-guitar").map((window) => window.sectionId), + ).toEqual(["verse-1", "chorus-1"]); + expect(resolveLoopWindows(song).map((window) => window.sectionId)).toEqual([ + "verse-1", + "chorus-1", + ]); + }); + + it("rejects a sparse hostile section array without scanning its declared length", () => { + const song = createDemoRehearsalSong(); + song.sections = new Array(0xffffffff) as typeof song.sections; + + expect(resolveLoopWindow(song)).toBeNull(); + }); + + it("assumes 120 BPM when tempo is missing and keeps published tempo in range", () => { + expect(resolveRehearsalTempo(undefined)).toEqual({ + tempoBpm: 120, + tempoAssumed: true, + }); + expect(resolveRehearsalTempo(0)).toEqual({ + tempoBpm: 120, + tempoAssumed: true, + }); + expect(resolveRehearsalTempo(96)).toEqual({ + tempoBpm: 96, + tempoAssumed: false, + }); + expect(beatDurationMs(120)).toBe(500); + }); + + it("keeps playback speed inside the supported media contract", () => { + const loop = resolveLoopWindow(createDemoRehearsalSong()); + const armed = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + + expect(rehearsalPlaybackRates()).toEqual([0.75, 1, 1.25]); + expect(isRehearsalPlaybackRate(0.75)).toBe(true); + expect(isRehearsalPlaybackRate(2)).toBe(false); + expect( + reduceRehearsalTransport(armed, { + type: "set-playback-rate", + rate: 0.75, + }).playbackRate, + ).toBe(0.75); + expect( + reduceRehearsalTransport(armed, { + type: "set-playback-rate", + rate: 2 as never, + }), + ).toBe(armed); + }); + + it("counts in four beats then wraps the playhead inside the section", () => { + const song = createDemoRehearsalSong(); + const loop = resolveLoopWindow(song); + expect(loop).not.toBeNull(); + let state = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + expect(nextActionTemplateKey(state, false)).toBe( + "workspaceLoopArmedNoAudio", + ); + state = reduceRehearsalTransport(state, { type: "start" }); + expect(state.phase).toBe("counting-in"); + expect(state.countInRemainingBeats).toBe(4); + state = reduceRehearsalTransport(state, { type: "beat" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + expect(state.phase).toBe("looping"); + expect(state.playheadSeconds).toBe(loop!.startSeconds); + state = reduceRehearsalTransport(state, { + type: "tick", + deltaSeconds: loop!.endSeconds - loop!.startSeconds + 1.5, + }); + expect(state.playheadSeconds).toBeCloseTo(loop!.startSeconds + 1.5); + expect(wrapPlayhead(loop!.endSeconds, loop!)).toBe(loop!.startSeconds); + }); + + it("syncs the map clock from an admitted media playhead", () => { + const loop = resolveLoopWindow(createDemoRehearsalSong()); + const looping = { + ...reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }), + phase: "looping" as const, + countInRemainingBeats: 0, + }; + + const synced = reduceRehearsalTransport(looping, { + type: "sync", + playheadSeconds: 17.25, + }); + + expect(synced.playheadSeconds).toBe(17.25); + expect( + reduceRehearsalTransport(looping, { + type: "sync", + playheadSeconds: Number.NaN, + }).playheadSeconds, + ).toBe(loop!.startSeconds); + }); + + it("clamps seek to the loop and rejects it during count-in", () => { + const loop = resolveLoopWindow(createDemoRehearsalSong()); + const armed = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + expect( + reduceRehearsalTransport(armed, { + type: "seek", + playheadSeconds: 15, + }), + ).toBe(armed); + + const looping = { + ...armed, + phase: "looping" as const, + countInRemainingBeats: 0, + }; + expect( + reduceRehearsalTransport(looping, { + type: "seek", + playheadSeconds: -1, + }).playheadSeconds, + ).toBe(loop!.startSeconds); + expect( + reduceRehearsalTransport(looping, { + type: "seek", + playheadSeconds: loop!.endSeconds + 1, + }).playheadSeconds, + ).toBe(loop!.startSeconds); + expect( + reduceRehearsalTransport(looping, { + type: "seek", + playheadSeconds: 17.5, + }).playheadSeconds, + ).toBe(17.5); + }); + + it("resumes the remaining count-in beats after pausing during count-in", () => { + const song = createDemoRehearsalSong(); + const loop = resolveLoopWindow(song); + let state = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + state = reduceRehearsalTransport(state, { type: "start" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + expect(state.countInRemainingBeats).toBe(3); + state = reduceRehearsalTransport(state, { type: "pause" }); + expect(state.phase).toBe("paused"); + + state = reduceRehearsalTransport(state, { type: "start" }); + + expect(state.phase).toBe("counting-in"); + expect(state.countInRemainingBeats).toBe(3); + state = reduceRehearsalTransport(state, { type: "beat" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + state = reduceRehearsalTransport(state, { type: "beat" }); + expect(state.phase).toBe("looping"); + }); + + it("pauses a live loop and names the next play action", () => { + const song = createDemoRehearsalSong(); + const loop = resolveLoopWindow(song, song.sections[0]!.id); + let state = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + state = reduceRehearsalTransport(state, { type: "start" }); + state = reduceRehearsalTransport( + { ...state, phase: "looping", countInRemainingBeats: 0 }, + { type: "pause" }, + ); + expect(state.phase).toBe("paused"); + expect(nextActionTemplateKey(state, true)).toBe("workspaceLoopPaused"); + expect( + fillRehearsalCopy( + "Loop {section} from {start}–{end}.", + nextActionValues(state), + ), + ).toContain(song.sections[0]!.label); + state = reduceRehearsalTransport(state, { type: "stop" }); + expect(state.phase).toBe("armed"); + expect(state.playheadSeconds).toBe(loop!.startSeconds); + }); + + it("formats a safe clock and stays idle when no playable section exists", () => { + expect(formatRehearsalClock(Number.NaN)).toBe("0:00"); + expect(formatRehearsalClock(125)).toBe("2:05"); + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveLoopWindow(song)).toBeNull(); + const idle = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop: null, + }); + expect(nextActionTemplateKey(idle, true)).toBe("workspaceLoopIdle"); + expect(reduceRehearsalTransport(idle, { type: "start" }).phase).toBe( + "idle", + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/rehearsalTransport.ts b/apps/desktop/src/features/workspace/rehearsalTransport.ts new file mode 100644 index 000000000..39cd05412 --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -0,0 +1,532 @@ +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; +const MIN_REHEARSAL_TEMPO_BPM = 30; +const MAX_REHEARSAL_TEMPO_BPM = 300; +const DEFAULT_REHEARSAL_PLAYBACK_RATE = 1; + +/** Documented playback-rate choices supported by the rehearsal media contract. */ +const REHEARSAL_PLAYBACK_RATES = [0.75, 1, 1.25] as const; + +/** Playback-rate value accepted by the rehearsal media contract. */ +export type RehearsalPlaybackRate = (typeof REHEARSAL_PLAYBACK_RATES)[number]; + +/** Return the supported playback-rate choices for the rehearsal control. */ +export function rehearsalPlaybackRates(): readonly RehearsalPlaybackRate[] { + return REHEARSAL_PLAYBACK_RATES; +} + +/** Documented rehearsal transport phases for the first section loop. */ +export type RehearsalTransportPhase = + "idle" | "armed" | "counting-in" | "looping" | "paused"; + +/** Bounded loop window derived from one valid analyzed section. */ +export interface RehearsalLoopWindow { + sourceIndex: number; + selectionKey: string; + sectionId: string; + sectionLabel: string; + startSeconds: number; + endSeconds: number; + tempoBpm: number; + tempoAssumed: boolean; + countInBeats: number; +} + +/** Deterministic transport snapshot used by the rehearsal player. */ +export interface RehearsalTransportState { + phase: RehearsalTransportPhase; + loop: RehearsalLoopWindow | null; + countInRemainingBeats: number; + playheadSeconds: number; + playbackRate: RehearsalPlaybackRate; +} + +/** Discrete transport commands that never inspect the filesystem. */ +export type RehearsalTransportEvent = + | { type: "arm"; loop: RehearsalLoopWindow | null } + | { type: "start" } + | { type: "beat" } + | { type: "sync"; playheadSeconds: number } + | { type: "seek"; playheadSeconds: number } + | { type: "tick"; deltaSeconds: number } + | { type: "set-playback-rate"; rate: RehearsalPlaybackRate } + | { 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; +} + +/** Return true only for playback rates supported by the rehearsal contract. */ +export function isRehearsalPlaybackRate( + value: unknown, +): value is RehearsalPlaybackRate { + return ( + typeof value === "number" && + REHEARSAL_PLAYBACK_RATES.includes(value as RehearsalPlaybackRate) + ); +} + +/** Read one own data-property value without activating accessors or Proxy get traps. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +/** 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 keys = Object.keys(value); + if (keys.length !== length) { + return null; + } + const items: unknown[] = []; + 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") + ) { + 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, +): 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) || + start > MAX_SECTION_TIME_SECONDS || + end > MAX_SECTION_TIME_SECONDS || + end <= start + ) { + return null; + } + return { + id, + label: typeof label === "string" && label.trim() ? label : id, + startSeconds: start, + endSeconds: end, + }; +} + +/** Return whether a snapshotted section contains the selected rehearsal role. */ +function sectionContainsRole(section: object, roleId: string): boolean { + const roles = ownedDenseArray(ownDataValue(section, "roles")); + if (!roles) { + return false; + } + return roles.some( + (role) => + role !== null && + typeof role === "object" && + ownDataValue(role, "id") === roleId, + ); +} + +/** Return whether a section exposes a usable closed loop window. */ +export function isPlayableLoopSection( + section: RehearsalSection | undefined | null, +): boolean { + return playableSectionSnapshot(section) !== null; +} + +/** Admit a published tempo or fall back to the labeled rehearsal default. */ +export function resolveRehearsalTempo(tempo: unknown): { + tempoBpm: number; + tempoAssumed: boolean; +} { + if ( + typeof tempo === "number" && + Number.isFinite(tempo) && + tempo >= MIN_REHEARSAL_TEMPO_BPM && + tempo <= MAX_REHEARSAL_TEMPO_BPM + ) { + return { tempoBpm: tempo, tempoAssumed: false }; + } + return { tempoBpm: DEFAULT_REHEARSAL_TEMPO_BPM, tempoAssumed: true }; +} + +/** Convert one beat at the admitted tempo into milliseconds. */ +export function beatDurationMs(tempoBpm: number): number { + const admitted = resolveRehearsalTempo(tempoBpm).tempoBpm; + return 60_000 / admitted; +} + +/** Convert one beat at the admitted tempo into seconds. */ +export function beatDurationSeconds(tempoBpm: number): number { + return beatDurationMs(tempoBpm) / 1000; +} + +/** Format a bounded clock as m:ss for the rehearsal map. */ +export function formatRehearsalClock(totalSeconds: number): string { + const safeSeconds = isFiniteNonNegativeNumber(totalSeconds) + ? totalSeconds + : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Build a loop window from one snapshotted section plus the song tempo. */ +export function createLoopWindow( + section: RehearsalSection, + tempo: unknown, + sourceIndex = 0, +): RehearsalLoopWindow | null { + const snapshot = playableSectionSnapshot(section); + if (!snapshot) { + return null; + } + const { tempoBpm, tempoAssumed } = resolveRehearsalTempo(tempo); + return { + sourceIndex, + selectionKey: JSON.stringify([ + snapshot.id, + 0, + ]), + sectionId: snapshot.id, + sectionLabel: snapshot.label, + startSeconds: snapshot.startSeconds, + endSeconds: snapshot.endSeconds, + tempoBpm, + tempoAssumed, + countInBeats: DEFAULT_COUNT_IN_BEATS, + }; +} + +/** Snapshot every playable loop window from one untrusted song record. */ +export function resolveLoopWindows( + song: RehearsalSong | null | undefined, + roleId: string | null | undefined = null, +): RehearsalLoopWindow[] { + if (!song || typeof song !== "object") { + return []; + } + const sections = ownedDenseArray(ownDataValue(song, "sections")); + if (!sections) { + return []; + } + const tempo = ownDataValue(song, "tempo"); + const selectedRoleId = + typeof roleId === "string" && roleId.trim() ? roleId : null; + const selectionOrdinals = new Map(); + return sections.flatMap((section, sourceIndex) => { + if (!section || typeof section !== "object") { + return []; + } + const sectionId = ownDataValue(section, "id"); + const ordinal = + typeof sectionId === "string" + ? (selectionOrdinals.get(sectionId) ?? 0) + : 0; + if (typeof sectionId === "string") { + selectionOrdinals.set(sectionId, ordinal + 1); + } + const window = createLoopWindow( + section as RehearsalSection, + tempo, + sourceIndex, + ); + if (!window) { + return []; + } + if (selectedRoleId && !sectionContainsRole(section, selectedRoleId)) { + return []; + } + return [ + { + ...window, + selectionKey: JSON.stringify([window.sectionId, ordinal]), + }, + ]; + }); +} + +/** 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 windows = resolveLoopWindows(song); + if (typeof sectionId === "string" && sectionId.trim()) { + const requestedWindow = windows.find( + (window) => window.sectionId === sectionId, + ); + if (requestedWindow) { + return requestedWindow; + } + } + return windows[0] ?? null; +} + +/** Return the idle transport snapshot. */ +export function createIdleTransportState(): RehearsalTransportState { + return { + phase: "idle", + loop: null, + countInRemainingBeats: 0, + playheadSeconds: 0, + playbackRate: DEFAULT_REHEARSAL_PLAYBACK_RATE, + }; +} + +/** Wrap a looping playhead back to the loop start without overshooting the end. */ +export function wrapPlayhead( + playheadSeconds: number, + loop: RehearsalLoopWindow, +): number { + const duration = loop.endSeconds - loop.startSeconds; + if (!(duration > 0) || !Number.isFinite(playheadSeconds)) { + return loop.startSeconds; + } + const elapsed = playheadSeconds - loop.startSeconds; + const wrapped = ((elapsed % duration) + duration) % duration; + return loop.startSeconds + wrapped; +} + +/** Advance the transport without touching audio files or native paths. */ +export function reduceRehearsalTransport( + state: RehearsalTransportState, + event: RehearsalTransportEvent, +): RehearsalTransportState { + switch (event.type) { + case "arm": { + if (!event.loop) { + return createIdleTransportState(); + } + return { + phase: "armed", + loop: event.loop, + countInRemainingBeats: event.loop.countInBeats, + playheadSeconds: event.loop.startSeconds, + playbackRate: state.playbackRate, + }; + } + case "start": { + if (!state.loop) { + return state; + } + if (state.phase === "paused") { + return { + ...state, + phase: + state.countInRemainingBeats > 0 ? "counting-in" : "looping", + }; + } + return { + ...state, + phase: "counting-in", + countInRemainingBeats: state.loop.countInBeats, + playheadSeconds: state.loop.startSeconds, + }; + } + case "beat": { + if (state.phase !== "counting-in" || !state.loop) { + return state; + } + const remaining = state.countInRemainingBeats - 1; + if (remaining <= 0) { + return { + ...state, + phase: "looping", + countInRemainingBeats: 0, + playheadSeconds: state.loop.startSeconds, + }; + } + return { ...state, countInRemainingBeats: remaining }; + } + case "sync": { + if (state.phase !== "looping" || !state.loop) { + return state; + } + const playhead = Number.isFinite(event.playheadSeconds) + ? Math.max(state.loop.startSeconds, event.playheadSeconds) + : state.loop.startSeconds; + return { + ...state, + playheadSeconds: wrapPlayhead(playhead, state.loop), + }; + } + case "seek": { + if ( + !state.loop || + (state.phase !== "looping" && + !(state.phase === "paused" && state.countInRemainingBeats === 0)) + ) { + return state; + } + const requested = Number.isFinite(event.playheadSeconds) + ? event.playheadSeconds + : state.loop.startSeconds; + const clamped = Math.min( + state.loop.endSeconds, + Math.max(state.loop.startSeconds, requested), + ); + return { + ...state, + playheadSeconds: + clamped >= state.loop.endSeconds + ? state.loop.startSeconds + : clamped, + }; + } + case "tick": { + if (state.phase !== "looping" || !state.loop) { + return state; + } + const delta = Number.isFinite(event.deltaSeconds) + ? Math.max(0, event.deltaSeconds) + : 0; + return { + ...state, + playheadSeconds: wrapPlayhead( + state.playheadSeconds + delta, + state.loop, + ), + }; + } + case "set-playback-rate": { + if (!isRehearsalPlaybackRate(event.rate)) { + return state; + } + return { ...state, playbackRate: event.rate }; + } + case "pause": { + if (state.phase !== "looping" && state.phase !== "counting-in") { + return state; + } + return { ...state, phase: "paused" }; + } + case "stop": { + if (!state.loop) { + return createIdleTransportState(); + } + return { + phase: "armed", + loop: state.loop, + countInRemainingBeats: state.loop.countInBeats, + playheadSeconds: state.loop.startSeconds, + playbackRate: state.playbackRate, + }; + } + default: + return state; + } +} + +/** Fill `{name}` placeholders in a rehearsal next-action template. */ +export function fillRehearsalCopy( + template: string, + values: Record, +): string { + return template.replace( + /\{([a-zA-Z]+)\}/g, + (_match, name: string) => values[name] ?? "", + ); +} + +/** Choose the next-action template key for the current transport snapshot. */ +export function nextActionTemplateKey( + state: RehearsalTransportState, + hasLocalAudio: boolean, +): + | "workspaceLoopIdle" + | "workspaceLoopArmedNoAudio" + | "workspaceLoopArmedWithAudio" + | "workspaceLoopCountingIn" + | "workspaceLoopPlaying" + | "workspaceLoopPaused" { + if (!state.loop || state.phase === "idle") { + return "workspaceLoopIdle"; + } + if (state.phase === "counting-in") { + return "workspaceLoopCountingIn"; + } + if (state.phase === "looping") { + return "workspaceLoopPlaying"; + } + if (state.phase === "paused") { + return "workspaceLoopPaused"; + } + return hasLocalAudio + ? "workspaceLoopArmedWithAudio" + : "workspaceLoopArmedNoAudio"; +} + +/** Build the placeholder map for the current loop window. */ +export function nextActionValues( + state: RehearsalTransportState, +): Record { + if (!state.loop) { + return {}; + } + return { + section: state.loop.sectionLabel, + start: formatRehearsalClock(state.loop.startSeconds), + end: formatRehearsalClock(state.loop.endSeconds), + beats: String(state.countInRemainingBeats || state.loop.countInBeats), + tempo: String(state.loop.tempoBpm * state.playbackRate), + }; +} diff --git a/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts new file mode 100644 index 000000000..609269eaa --- /dev/null +++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts @@ -0,0 +1,27 @@ +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( + "선택한 구간 루프 시작", + ); + }); + + 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("합주 시계"); + }); +}); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..9c4431fcc 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,36 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "workspaceLoopRegionLabel": "Tonight's section loop", + "workspaceLoopTitle": "Tonight's loop", + "workspaceLoopSectionPickerLabel": "Playable sections", + "workspaceLoopSectionPickerForRole": "Playable sections for {roleName}", + "workspaceLoopSectionKeyboardHint": "Use Left and Right Arrow to move between section cues.", + "workspaceLoopBoundaryTitle": "Correct this cue's range", + "workspaceLoopBoundaryCorrectionBadge": "Manual cue correction", + "workspaceLoopBoundaryStartLabel": "Start time (seconds)", + "workspaceLoopBoundaryEndLabel": "End time (seconds)", + "workspaceLoopBoundaryHint": "Whole seconds update the song map and are included in the next project save.", + "workspaceLoopBoundaryError": "Use whole seconds from 0 to the limit, with the end after the start.", + "workspaceLoopSeekLabel": "Seek within this cue", + "workspaceLoopSeekHint": "Seek is available while the local rehearsal clock is running or paused.", + "workspaceLoopTransportKeyboardHint": "Space plays or pauses the clock; Escape stops it. Shortcuts stay quiet while you edit a field.", + "workspaceLoopRoleFilterHint": "Showing sections that include {roleName}.", + "workspaceLoopNoRoleSections": "No playable sections include {roleName} yet. Choose All Roles or map this role first.", + "workspaceLoopStart": "Start the count-in", + "workspaceLoopThisSection": "Start selected section loop", + "workspaceLoopResume": "Continue rehearsal clock", + "workspaceLoopPause": "Pause rehearsal clock", + "workspaceLoopStop": "Stop and reset rehearsal clock", + "workspaceLoopAudioError": "Could not play this local audio. Choose the song again and retry.", + "workspaceLoopPlaybackRateLabel": "Playback speed", + "workspaceLoopPlaybackRateHint": "Pitch stays natural when the audio engine supports it.", + "workspaceLoopIdle": "Add a section with a start and end time, then loop it here.", + "workspaceLoopArmedNoAudio": "Map {section} from {start}–{end}. Choose a local song first to start the rehearsal clock.", + "workspaceLoopArmedWithAudio": "Map {section} from {start}–{end}. Start the count-in to run the rehearsal clock.", + "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.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..29df4d0a9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,36 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "workspaceLoopRegionLabel": "오늘 밤 구간 루프", + "workspaceLoopTitle": "오늘 밤 루프", + "workspaceLoopSectionPickerLabel": "연습할 구간", + "workspaceLoopSectionPickerForRole": "{roleName} 역할의 연습 구간", + "workspaceLoopSectionKeyboardHint": "왼쪽·오른쪽 화살표로 구간 큐를 이동하세요.", + "workspaceLoopBoundaryTitle": "이 큐의 구간 보정", + "workspaceLoopBoundaryCorrectionBadge": "수동 큐 보정", + "workspaceLoopBoundaryStartLabel": "시작 시각(초)", + "workspaceLoopBoundaryEndLabel": "끝 시각(초)", + "workspaceLoopBoundaryHint": "정수 초로 곡 지도를 보정하며 다음 프로젝트 저장에 포함됩니다.", + "workspaceLoopBoundaryError": "0부터 제한값까지의 정수 초를 입력하고 끝을 시작보다 뒤로 두세요.", + "workspaceLoopSeekLabel": "이 큐 안에서 이동", + "workspaceLoopSeekHint": "로컬 합주 시계가 실행 중이거나 일시정지된 동안 이동할 수 있습니다.", + "workspaceLoopTransportKeyboardHint": "스페이스로 시계를 재생·일시정지하고 Escape로 멈춥니다. 입력 필드를 편집할 때는 단축키가 동작하지 않습니다.", + "workspaceLoopRoleFilterHint": "{roleName} 역할이 포함된 구간만 표시합니다.", + "workspaceLoopNoRoleSections": "{roleName} 역할이 포함된 연습 구간이 아직 없습니다. 전체 보기로 바꾸거나 먼저 역할을 배치하세요.", + "workspaceLoopStart": "카운트인 시작", + "workspaceLoopThisSection": "선택한 구간 루프 시작", + "workspaceLoopResume": "합주 시계 계속", + "workspaceLoopPause": "합주 시계 일시정지", + "workspaceLoopStop": "합주 시계 멈추고 초기화", + "workspaceLoopAudioError": "이 로컬 오디오를 재생할 수 없습니다. 곡을 다시 선택한 뒤 재시도하세요.", + "workspaceLoopPlaybackRateLabel": "재생 속도", + "workspaceLoopPlaybackRateHint": "오디오 엔진이 지원하면 음정은 자연스럽게 유지됩니다.", + "workspaceLoopIdle": "시작·끝 시각이 있는 구간을 만든 다음, 여기서 루프하세요.", + "workspaceLoopArmedNoAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 시작하려면 먼저 로컬 곡을 고르세요.", + "workspaceLoopArmedWithAudio": "{section} 구간 {start}–{end}의 합주 시계를 준비했습니다. 카운트인을 시작하세요.", + "workspaceLoopCountingIn": "{tempo} BPM으로 {beats}박 카운트인한 다음 합주 시계가 {section} 구간을 반복합니다.", + "workspaceLoopPlaying": "합주 시계가 {section} 구간 {start}–{end}를 반복 중입니다. 입구가 잡히면 일시정지하세요.", + "workspaceLoopPaused": "{section} 구간 합주 시계가 멈춰 있습니다. 준비되면 이어 가세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",