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 34331fb86..70d2ef1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- Tonight's rehearsal player now arms the first valid section loop, runs a tempo count-in, and plays validated local audio through a scoped Tauri asset URL in the browser media element; the React layer does not decode audio bytes itself, and admitted section timing and picker copy use the same descriptor-snapshotted transport window. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Move between playable section cues with Left and Right Arrow and keep the selected cue focused. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed @@ -15,6 +17,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 @@ -49,7 +53,6 @@ - Resolve npm audit vulnerabilities - Fix ruff import sorting and formatting errors - Add missing docstrings to tests -- Fix test configuration and typing issues ## [0.1.0] - 2026-03-27 diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..a347f12d9 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 bytes in React; after native admission, its browser media element plays the authorized source through a scoped Tauri asset URL. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..6e142bd1f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,13 +1,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod playback_protocol; + use bandscope_desktop_core::*; +use playback_protocol::{playback_authority_uri, PlaybackAuthority, PLAYBACK_SCHEME}; use rfd::FileDialog; use serde_json::{json, Value}; use std::{ io::{BufRead, BufReader, Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - sync::{atomic::Ordering, mpsc}, + sync::{atomic::Ordering, mpsc, Arc}, thread, time::Instant, }; @@ -317,6 +320,15 @@ fn lookup_bootstrap_source( .ok_or_else(|| "Analysis job source was not found. Choose local audio again.".to_string()) } +/// Return a renderer-safe bootstrap while retaining the native source only in +/// BandScope's process-local analysis and playback authority stores. +fn renderer_bootstrap_summary( + mut summary: ProjectBootstrapSummaryPayload, +) -> Result { + summary.source.source_path = playback_authority_uri(&summary.project_id)?; + Ok(summary) +} + fn drain_analysis_status_updates( state: &AppState, app: &tauri::AppHandle, @@ -637,6 +649,7 @@ fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) -> fn select_local_audio_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, + playback_authority: tauri::State<'_, Arc>, ) -> Result { let path = FileDialog::new() .add_filter("Audio", &AUDIO_EXTENSIONS) @@ -656,9 +669,10 @@ fn select_local_audio_source( temp_root: temp_root.to_string_lossy().into_owned(), source, }; + playback_authority.activate(&summary.project_id, &summary.source)?; store_bootstrap_source(&state, summary.clone()); - Ok(summary) + renderer_bootstrap_summary(summary) } #[tauri::command] @@ -666,6 +680,7 @@ async fn import_youtube_url( url: String, app: tauri::AppHandle, state: tauri::State<'_, AppState>, + playback_authority: tauri::State<'_, Arc>, ) -> Result { if !is_supported_youtube_url(&url) { return Err("Only standard YouTube URLs are supported.".to_string()); @@ -721,8 +736,9 @@ async fn import_youtube_url( temp_root: temp_root.to_string_lossy().into_owned(), source, }; + playback_authority.activate(&summary.project_id, &summary.source)?; store_bootstrap_source(&state, summary.clone()); - return Ok(summary); + return renderer_bootstrap_summary(summary); } return Err(youtube_missing_metadata_error(&parsed)); } @@ -866,8 +882,15 @@ fn remove_score_pdf( } fn main() { + let playback_authority = Arc::new(PlaybackAuthority::default()); + let protocol_authority = Arc::clone(&playback_authority); + tauri::Builder::default() .manage(AppState::default()) + .manage(playback_authority) + .register_uri_scheme_protocol(PLAYBACK_SCHEME, move |_context, request| { + protocol_authority.respond(request) + }) .invoke_handler(tauri::generate_handler![ select_local_audio_source, import_youtube_url, diff --git a/apps/desktop/src-tauri/src/playback_protocol.rs b/apps/desktop/src-tauri/src/playback_protocol.rs new file mode 100644 index 000000000..136e47e2d --- /dev/null +++ b/apps/desktop/src-tauri/src/playback_protocol.rs @@ -0,0 +1,638 @@ +//! Revocable native media authority for the mounted rehearsal player. +//! +//! The WebView receives only an app-minted project id. Native source paths stay +//! behind this protocol boundary and every request is checked against the one +//! currently active project before BandScope opens any file. + +use bandscope_desktop_core::{is_valid_project_id, LocalAudioSourcePayload}; +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + sync::Mutex, +}; +use tauri::http::{ + header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}, + Method, Request, Response, StatusCode, +}; + +/// Custom scheme used only for current-project audio playback. +pub const PLAYBACK_SCHEME: &str = "bandscope-playback"; + +/// Opaque renderer-side handle prefix. The suffix is an app-minted project id, +/// never a native path or user-controlled URL. +pub const PLAYBACK_AUTHORITY_PREFIX: &str = "bandscope-project://"; + +/// Match Tauri's bounded single-range chunk size so media seeks do not allocate +/// an arbitrarily large buffer from an untrusted Range header. +const MAX_RANGE_BYTES: u64 = 1_000 * 1024; + +#[cfg(unix)] +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlaybackFileIdentity { + device: u64, + inode: u64, + change_time_seconds: i64, + change_time_nanoseconds: i64, +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(C)] +struct WindowsFileTime { + low_date_time: u32, + high_date_time: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsByHandleFileInformation { + file_attributes: u32, + creation_time: WindowsFileTime, + last_access_time: WindowsFileTime, + last_write_time: WindowsFileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +#[cfg(windows)] +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlaybackFileIdentity { + volume_serial_number: u32, + file_index: u64, + last_write_time: WindowsFileTime, +} + +#[cfg(not(any(unix, windows)))] +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlaybackFileIdentity; + +#[cfg(unix)] +fn playback_file_identity(file: &File) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(PlaybackFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + change_time_seconds: metadata.ctime(), + change_time_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +fn playback_file_identity(file: &File) -> std::io::Result { + use std::{mem::MaybeUninit, os::windows::io::AsRawHandle}; + + #[link(name = "kernel32")] + extern "system" { + #[link_name = "GetFileInformationByHandle"] + fn get_file_information_by_handle( + file: std::os::windows::io::RawHandle, + information: *mut WindowsByHandleFileInformation, + ) -> i32; + } + + let mut information = MaybeUninit::::uninit(); + let result = unsafe { + get_file_information_by_handle(file.as_raw_handle(), information.as_mut_ptr()) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(PlaybackFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + last_write_time: information.last_write_time, + }) +} + +#[cfg(not(any(unix, windows)))] +fn playback_file_identity(_file: &File) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native playback file identity is unsupported on this platform", + )) +} + +#[derive(Clone, Debug)] +struct PlaybackSourceAuthority { + project_id: String, + source_path: PathBuf, + extension: String, + expected_size: u64, + source_identity: PlaybackFileIdentity, +} + +/// Process-local authority for the one audio source currently admitted to the +/// rehearsal player. Replacing it revokes every previously selected source. +#[derive(Default)] +pub struct PlaybackAuthority { + current: Mutex>, +} + +/// Return the only renderer-visible handle for an app-minted playback project. +pub fn playback_authority_uri(project_id: &str) -> Result { + if !is_valid_project_id(project_id) { + return Err("Could not prepare the selected audio for playback.".to_string()); + } + Ok(format!("{PLAYBACK_AUTHORITY_PREFIX}{project_id}")) +} + +impl PlaybackAuthority { + /// Replace the current playback source with an already validated native + /// source. The project id is app-minted and never derived from a path. + pub fn activate(&self, project_id: &str, source: &LocalAudioSourcePayload) -> Result<(), String> { + if !is_valid_project_id(project_id) { + return Err("Could not prepare the selected audio for playback.".to_string()); + } + let source_path = PathBuf::from(&source.source_path); + let (file, _) = open_validated_source(&source_path, source.file_size_bytes) + .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; + let source_identity = playback_file_identity(&file) + .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; + let mut current = self + .current + .lock() + .map_err(|_| "Could not prepare the selected audio for playback.".to_string())?; + *current = Some(PlaybackSourceAuthority { + project_id: project_id.to_string(), + source_path, + extension: source.extension.clone(), + expected_size: source.file_size_bytes, + source_identity, + }); + Ok(()) + } + + /// Serve GET/HEAD media requests only when their opaque project id still + /// names the current authority. Stale project ids fail closed immediately. + pub fn respond(&self, request: Request>) -> Response> { + let Some(project_id) = project_id_from_path(request.uri().path()) else { + return empty_response(StatusCode::NOT_FOUND); + }; + self.with_current_authority(project_id, |authority| { + if request.method() != Method::GET && request.method() != Method::HEAD { + return empty_response(StatusCode::METHOD_NOT_ALLOWED); + } + serve_authorized_source(authority, &request) + }) + .unwrap_or_else(|| empty_response(StatusCode::NOT_FOUND)) + } + + fn with_current_authority( + &self, + project_id: &str, + use_authority: impl FnOnce(&PlaybackSourceAuthority) -> R, + ) -> Option { + if !is_valid_project_id(project_id) { + return None; + } + let current = self.current.lock().ok()?; + let authority = current + .as_ref() + .filter(|entry| entry.project_id == project_id)?; + Some(use_authority(authority)) + } +} + +fn project_id_from_path(path: &str) -> Option<&str> { + let project_id = path.strip_prefix('/')?; + if project_id.is_empty() || project_id.contains('/') || project_id.contains('%') { + return None; + } + is_valid_project_id(project_id).then_some(project_id) +} + +fn content_type(extension: &str) -> Option<&'static str> { + match extension { + "wav" => Some("audio/wav"), + "mp3" => Some("audio/mpeg"), + "flac" => Some("audio/flac"), + "m4a" => Some("audio/mp4"), + _ => None, + } +} + +fn open_validated_source(source_path: &Path, expected_size: u64) -> Result<(File, u64), StatusCode> { + let link_metadata = std::fs::symlink_metadata(source_path).map_err(|_| StatusCode::GONE)?; + if link_metadata.file_type().is_symlink() + || !link_metadata.is_file() + || link_metadata.len() != expected_size + { + return Err(StatusCode::GONE); + } + let canonical = source_path.canonicalize().map_err(|_| StatusCode::GONE)?; + if canonical != source_path { + return Err(StatusCode::GONE); + } + let file = File::open(&canonical).map_err(|_| StatusCode::GONE)?; + let metadata = file.metadata().map_err(|_| StatusCode::GONE)?; + if !metadata.is_file() || metadata.len() != expected_size || metadata.len() == 0 { + return Err(StatusCode::GONE); + } + Ok((file, metadata.len())) +} + +fn validated_file(authority: &PlaybackSourceAuthority) -> Result<(File, u64), StatusCode> { + let (file, len) = open_validated_source(&authority.source_path, authority.expected_size)?; + let current_identity = playback_file_identity(&file).map_err(|_| StatusCode::GONE)?; + if current_identity != authority.source_identity { + return Err(StatusCode::GONE); + } + Ok((file, len)) +} + +fn serve_authorized_source( + authority: &PlaybackSourceAuthority, + request: &Request>, +) -> Response> { + let Some(media_type) = content_type(&authority.extension) else { + return empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE); + }; + let (mut file, len) = match validated_file(authority) { + Ok(value) => value, + Err(status) => return empty_response(status), + }; + + if request.method() == Method::HEAD { + return Response::builder() + .status(StatusCode::OK) + .header(ACCEPT_RANGES, "bytes") + .header(CONTENT_TYPE, media_type) + .header(CONTENT_LENGTH, len) + .body(Vec::new()) + .expect("static playback HEAD response should build"); + } + + let range_header = request.headers().get(RANGE).and_then(|value| value.to_str().ok()); + if let Some(range_header) = range_header { + let (start, end) = match parse_single_range(range_header, len) { + Ok(range) => range, + Err(()) => return range_not_satisfiable(len), + }; + let byte_count = end + 1 - start; + let Ok(capacity) = usize::try_from(byte_count) else { + return range_not_satisfiable(len); + }; + if file.seek(SeekFrom::Start(start)).is_err() { + return empty_response(StatusCode::GONE); + } + let mut body = Vec::with_capacity(capacity); + if file.take(byte_count).read_to_end(&mut body).is_err() || body.len() != capacity { + return empty_response(StatusCode::GONE); + } + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(ACCEPT_RANGES, "bytes") + .header(CONTENT_TYPE, media_type) + .header(CONTENT_RANGE, format!("bytes {start}-{end}/{len}")) + .header(CONTENT_LENGTH, byte_count) + .body(body) + .expect("static playback range response should build"); + } + + // Tauri custom-protocol bodies are buffered. Keep tiny compatibility GETs, + // but require large media clients to use the bounded Range path above. + if len > MAX_RANGE_BYTES { + return range_not_satisfiable(len); + } + + let mut body = Vec::new(); + if file.read_to_end(&mut body).is_err() || body.len() as u64 != len { + return empty_response(StatusCode::GONE); + } + Response::builder() + .status(StatusCode::OK) + .header(ACCEPT_RANGES, "bytes") + .header(CONTENT_TYPE, media_type) + .header(CONTENT_LENGTH, len) + .body(body) + .expect("static playback response should build") +} + +fn parse_single_range(header: &str, len: u64) -> Result<(u64, u64), ()> { + if len == 0 { + return Err(()); + } + let value = header.strip_prefix("bytes=").ok_or(())?.trim(); + if value.is_empty() || value.contains(',') { + return Err(()); + } + + if let Some(suffix) = value.strip_prefix('-') { + let suffix_len = suffix.parse::().map_err(|_| ())?; + if suffix_len == 0 { + return Err(()); + } + let bounded_suffix = suffix_len.min(len).min(MAX_RANGE_BYTES); + return Ok((len - bounded_suffix, len - 1)); + } + + let (start, end) = value.split_once('-').ok_or(())?; + let start = start.parse::().map_err(|_| ())?; + if start >= len { + return Err(()); + } + let requested_end = if end.is_empty() { + len - 1 + } else { + end.parse::().map_err(|_| ())?.min(len - 1) + }; + if requested_end < start { + return Err(()); + } + let end = requested_end.min(start.saturating_add(MAX_RANGE_BYTES - 1)); + Ok((start, end)) +} + +fn range_not_satisfiable(len: u64) -> Response> { + Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(ACCEPT_RANGES, "bytes") + .header(CONTENT_RANGE, format!("bytes */{len}")) + .body(Vec::new()) + .expect("static playback range error should build") +} + +fn empty_response(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .body(Vec::new()) + .expect("static playback error response should build") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_source(label: &str, bytes: &[u8]) -> (PathBuf, LocalAudioSourcePayload) { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bandscope-playback-{}-{label}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("test playback root should be created"); + let path = root.join("source.wav"); + std::fs::write(&path, bytes).expect("test playback source should be written"); + let canonical = path.canonicalize().expect("test source should canonicalize"); + let source = LocalAudioSourcePayload { + source_path: canonical.to_string_lossy().into_owned(), + file_name: "source.wav".to_string(), + extension: "wav".to_string(), + file_size_bytes: bytes.len() as u64, + }; + (root, source) + } + + fn request(project_id: &str) -> Request> { + Request::builder() + .uri(format!("{PLAYBACK_SCHEME}://localhost/{project_id}")) + .body(Vec::new()) + .expect("test request should build") + } + + #[test] + fn renderer_handle_contains_only_the_app_minted_project_id() { + assert_eq!( + playback_authority_uri("project-100-1").as_deref(), + Ok("bandscope-project://project-100-1") + ); + assert!(playback_authority_uri("../../private.wav").is_err()); + } + + #[test] + fn rotating_authority_revokes_the_previous_project_immediately() { + let (first_root, first_source) = test_source("first", b"first-audio"); + let (second_root, second_source) = test_source("second", b"second-audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-100-1", &first_source) + .expect("first source should activate"); + assert_eq!( + authority.respond(request("project-100-1")).status(), + StatusCode::OK + ); + + authority + .activate("project-101-2", &second_source) + .expect("second source should activate"); + + assert_eq!( + authority.respond(request("project-100-1")).status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + authority.respond(request("project-101-2")).body(), + b"second-audio" + ); + let _ = std::fs::remove_dir_all(first_root); + let _ = std::fs::remove_dir_all(second_root); + } + + #[test] + fn authorized_response_keeps_revocation_authority_until_use_finishes() { + let (root, source) = test_source("linearizable-revocation", b"audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-150-2", &source) + .expect("source should activate"); + + let lock_was_held = authority.with_current_authority("project-150-2", |_| { + authority.current.try_lock().is_err() + }); + + assert_eq!( + lock_was_held, + Some(true), + "an authorized response must retain revocation authority until serving finishes" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn current_authority_rejects_path_shaped_or_unknown_tokens() { + let (root, source) = test_source("token", b"audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-200-3", &source) + .expect("source should activate"); + + assert_eq!( + authority.respond(request("project-999-9")).status(), + StatusCode::NOT_FOUND + ); + let traversal = Request::builder() + .uri(format!("{PLAYBACK_SCHEME}://localhost/project-200-3/../../private.wav")) + .body(Vec::new()) + .expect("test traversal request should build"); + assert_eq!(authority.respond(traversal).status(), StatusCode::NOT_FOUND); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn single_range_is_bounded_and_reports_partial_content() { + let bytes = vec![b'x'; (MAX_RANGE_BYTES + 32) as usize]; + let (root, source) = test_source("range", &bytes); + let authority = PlaybackAuthority::default(); + authority + .activate("project-300-4", &source) + .expect("source should activate"); + let request = Request::builder() + .uri(format!("{PLAYBACK_SCHEME}://localhost/project-300-4")) + .header(RANGE, "bytes=0-") + .body(Vec::new()) + .expect("range request should build"); + + let response = authority.respond(request); + let expected_content_range = format!("bytes 0-{}/{}", MAX_RANGE_BYTES - 1, bytes.len()); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body().len() as u64, MAX_RANGE_BYTES); + assert_eq!( + response.headers().get(CONTENT_RANGE).and_then(|value| value.to_str().ok()), + Some(expected_content_range.as_str()) + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn large_get_without_range_fails_closed_before_buffering_the_recording() { + let bytes = vec![b'x'; (MAX_RANGE_BYTES + 32) as usize]; + let (root, source) = test_source("unranged", &bytes); + let authority = PlaybackAuthority::default(); + authority + .activate("project-350-4", &source) + .expect("source should activate"); + + let response = authority.respond(request("project-350-4")); + let expected_content_range = format!("bytes */{}", bytes.len()); + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert!(response.body().is_empty()); + assert_eq!( + response.headers().get(CONTENT_RANGE).and_then(|value| value.to_str().ok()), + Some(expected_content_range.as_str()) + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn multiple_or_unsatisfiable_ranges_fail_closed() { + let (root, source) = test_source("invalid-range", b"0123456789"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-400-5", &source) + .expect("source should activate"); + for value in ["bytes=0-1,3-4", "bytes=99-", "items=0-1", "bytes=-0"] { + let request = Request::builder() + .uri(format!("{PLAYBACK_SCHEME}://localhost/project-400-5")) + .header(RANGE, value) + .body(Vec::new()) + .expect("invalid range request should build"); + assert_eq!( + authority.respond(request).status(), + StatusCode::RANGE_NOT_SATISFIABLE, + "range {value} must fail closed" + ); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn source_replacement_after_admission_is_not_served() { + let (root, source) = test_source("mutation", b"original-audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-500-6", &source) + .expect("source should activate"); + std::fs::write(&source.source_path, b"changed-size") + .expect("test should replace source contents"); + + assert_eq!( + authority.respond(request("project-500-6")).status(), + StatusCode::GONE + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn same_size_path_replacement_after_admission_is_not_served() { + let (root, source) = test_source("same-size-replacement", b"trusted-audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-550-6", &source) + .expect("source should activate"); + + let replacement = root.join("replacement.wav"); + std::fs::write(&replacement, b"hostile-audio") + .expect("test replacement should be written"); + std::fs::remove_file(&source.source_path) + .expect("admitted source should be removable for replacement"); + std::fs::rename(&replacement, &source.source_path) + .expect("same-size replacement should occupy admitted path"); + + assert_eq!( + authority.respond(request("project-550-6")).status(), + StatusCode::GONE + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn same_size_in_place_mutation_after_admission_is_not_served() { + let (root, source) = test_source("same-size-mutation", b"trusted-audio"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-575-6", &source) + .expect("source should activate"); + + std::fs::write(&source.source_path, b"hostile-audio") + .expect("test should mutate the admitted source in place"); + + assert_eq!( + authority.respond(request("project-575-6")).status(), + StatusCode::GONE + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn head_is_metadata_only_and_other_methods_fail_closed() { + let (root, source) = test_source("head", b"audio-bytes"); + let authority = PlaybackAuthority::default(); + authority + .activate("project-600-7", &source) + .expect("source should activate"); + let head = Request::builder() + .method(Method::HEAD) + .uri(format!("{PLAYBACK_SCHEME}://localhost/project-600-7")) + .body(Vec::new()) + .expect("HEAD request should build"); + let post = Request::builder() + .method(Method::POST) + .uri(format!("{PLAYBACK_SCHEME}://localhost/project-600-7")) + .body(Vec::new()) + .expect("POST request should build"); + + let head_response = authority.respond(head); + assert_eq!(head_response.status(), StatusCode::OK); + assert!(head_response.body().is_empty()); + assert_eq!( + head_response.headers().get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()), + Some("11") + ); + assert_eq!(authority.respond(post).status(), StatusCode::METHOD_NOT_ALLOWED); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8efaf48c7..1a5d32c75 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' data: blob:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; media-src 'self' bandscope-playback: http://bandscope-playback.localhost data: blob:; font-src 'self' data:", + "assetProtocol": { + "enable": false, + "scope": [] + }, "capabilities": ["main-capability"] } }, @@ -24,4 +28,4 @@ "active": true, "targets": "all" } -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx new file mode 100644 index 000000000..c1f72c569 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audible-count-in.test.tsx @@ -0,0 +1,193 @@ +import { StrictMode } from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalLanguage = navigator.language; +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); +const originalAudioContext = Object.getOwnPropertyDescriptor( + window, + "AudioContext", +); +const audioSourcePath = "/Users/test/Music/rehearsal.wav"; + +type FakeOscillator = { + connect: ReturnType; + disconnect: ReturnType; + frequency: { value: number }; + onended: (() => void) | null; + start: ReturnType; + stop: ReturnType; + type: OscillatorType; +}; + +type PlayableAudioMocks = { + closeAudioContext: ReturnType; + oscillators: FakeOscillator[]; +}; + +function installPlayableAudioMocks(): PlayableAudioMocks { + const oscillators: FakeOscillator[] = []; + const closeAudioContext = vi.fn(async () => undefined); + Object.defineProperty(navigator, "language", { + configurable: true, + value: "en-US", + }); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`), + }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); + + class FakeAudioContext { + currentTime = 1; + destination = {}; + state: AudioContextState = "running"; + + close = closeAudioContext; + resume = vi.fn(async () => undefined); + + createGain() { + const gain = { + connect: vi.fn(() => gain), + disconnect: vi.fn(), + gain: { + exponentialRampToValueAtTime: vi.fn(), + setValueAtTime: vi.fn(), + }, + }; + return gain; + } + + createOscillator() { + const oscillator: FakeOscillator = { + connect: vi.fn(), + disconnect: vi.fn(), + frequency: { value: 0 }, + onended: null, + start: vi.fn(), + stop: vi.fn(), + type: "sine", + }; + oscillators.push(oscillator); + return oscillator; + } + } + + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: FakeAudioContext, + }); + return { closeAudioContext, oscillators }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + Object.defineProperty(navigator, "language", { + configurable: true, + value: originalLanguage, + }); + if (originalTauriInternals) { + Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + if (originalAudioContext) { + Object.defineProperty(window, "AudioContext", originalAudioContext); + } else { + Reflect.deleteProperty(window, "AudioContext"); + } +}); + +it("sounds the transport count-in without replaying a beat when playback rate changes", () => { + vi.useFakeTimers(); + const { oscillators } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i })); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0]?.frequency.value).toBe(1200); + expect(oscillators[0]?.type).toBe("square"); + expect(oscillators[0]?.start).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(400); + }); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + expect(oscillators).toHaveLength(1); + + act(() => { + vi.advanceTimersByTime(79); + }); + expect(oscillators).toHaveLength(1); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(oscillators).toHaveLength(2); + expect(oscillators[1]?.frequency.value).toBe(800); + + fireEvent.click(screen.getByRole("button", { name: /Pause/i })); + expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2); +}); + +it("keeps audible count-in available across Strict Mode effect replay", () => { + const { closeAudioContext, oscillators } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const { unmount } = render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i })); + + expect(oscillators).toHaveLength(1); + unmount(); + expect(closeAudioContext).toHaveBeenCalledTimes(1); +}); + +it("closes the count-in audio context when the mounted player unmounts", () => { + const { closeAudioContext, oscillators } = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const { unmount } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i })); + expect(oscillators).toHaveLength(1); + + unmount(); + + expect(closeAudioContext).toHaveBeenCalledTimes(1); +}); \ No newline at end of file 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..5b8e6bb2b --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.audioAuthority.test.tsx @@ -0,0 +1,117 @@ +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 authority handle", () => { + 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 playback conversion failures instead of presenting missing-audio copy", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: () => { + throw new Error("playback 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, + ); + }); + + it("mints the media URL from the app-owned project authority, never a native source path", () => { + const convertFileSrc = vi.fn( + () => "bandscope-playback://localhost/project-100-1", + ); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + const song = createDemoRehearsalSong(); + + render( + , + ); + + expect(convertFileSrc).toHaveBeenCalledWith( + "project-100-1", + "bandscope-playback", + ); + expect(convertFileSrc).not.toHaveBeenCalledWith( + "/Users/test/Music/private-rehearsal.wav", + ); + }); + + it("does not expose a native path when no current playback project authority exists", () => { + const convertFileSrc = vi.fn(() => "asset://localhost/private-rehearsal.wav"); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { convertFileSrc }, + }); + const song = createDemoRehearsalSong(); + + render( + , + ); + + expect(convertFileSrc).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: /start the count-in/i }), + ).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx new file mode 100644 index 000000000..f4d841bb0 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.boundary-snapshot.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +describe("RehearsalPlayer boundary snapshot", () => { + it("rejects a stale boundary edit and restores the admitted loop value", () => { + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + + render(); + + const start = screen.getByRole("spinbutton", { + name: "Start time (seconds)", + }); + expect(start).toHaveValue(10); + + fireEvent.change(start, { target: { value: "12" } }); + song.sections[0]!.timeRange.start = 11; + fireEvent.blur(start); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(start).toHaveValue(10); + expect(start).toHaveAttribute("aria-invalid", "true"); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx new file mode 100644 index 000000000..07c1d8c7d --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.count-in-progress.test.tsx @@ -0,0 +1,105 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalLanguage = navigator.language; +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); +const originalPreservesPitch = Object.getOwnPropertyDescriptor( + HTMLMediaElement.prototype, + "preservesPitch", +); +const audioSourcePath = "/Users/test/Music/rehearsal.wav"; + +function installPlayableAudioMocks() { + Object.defineProperty(navigator, "language", { + configurable: true, + value: "en-US", + }); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`), + }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); + Object.defineProperty(HTMLMediaElement.prototype, "preservesPitch", { + configurable: true, + writable: true, + value: false, + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + Object.defineProperty(navigator, "language", { + configurable: true, + value: originalLanguage, + }); + if (originalTauriInternals) { + Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + if (originalPreservesPitch) { + Object.defineProperty( + HTMLMediaElement.prototype, + "preservesPitch", + originalPreservesPitch, + ); + } else { + delete (HTMLMediaElement.prototype as HTMLMediaElement & { + preservesPitch?: boolean; + }).preservesPitch; + } +}); + +it("preserves cumulative progress through a count-in beat across repeated speed changes", () => { + vi.useFakeTimers(); + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Start the count-in/i })); + act(() => { + vi.advanceTimersByTime(400); + }); + + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "0.75" }, + }); + act(() => { + vi.advanceTimersByTime(50); + }); + fireEvent.change(screen.getByRole("combobox", { name: /Playback speed/i }), { + target: { value: "1.25" }, + }); + + act(() => { + vi.advanceTimersByTime(49); + }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Count in 4 beats at 150 BPM/i, + ); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Count in 3 beats at 150 BPM/i, + ); +}); 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.media-duration.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx new file mode 100644 index 000000000..14c01d7d0 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-duration.test.tsx @@ -0,0 +1,144 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); +const audioSourcePath = "/Users/test/Music/rehearsal.wav"; + +function installPlayableAudioMocks() { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`), + }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + return vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); +} + +function admitDuration(audio: HTMLAudioElement, duration: number) { + Object.defineProperty(audio, "duration", { + configurable: true, + value: duration, + }); + fireEvent.loadedMetadata(audio); +} + +describe("RehearsalPlayer admitted media duration", () => { + afterEach(() => { + vi.restoreAllMocks(); + if (originalTauriInternals) { + Object.defineProperty( + window, + "__TAURI_INTERNALS__", + originalTauriInternals, + ); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + }); + + it("disables transport start when the selected loop extends beyond loaded audio", () => { + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + + render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 20); + + expect( + screen.getByRole("button", { name: /Start the count-in/i }), + ).toBeDisabled(); + }); + + it("rejects a corrected end boundary beyond admitted EOF", () => { + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange.end = 20; + const onSongUpdate = vi.fn(); + + render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 25); + const endInput = screen.getByLabelText(/end/i) as HTMLInputElement; + fireEvent.change(endInput, { target: { value: "26" } }); + fireEvent.blur(endInput); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(endInput.value).toBe("20"); + expect(endInput.getAttribute("aria-invalid")).toBe("true"); + }); + + it("rejects a corrected start boundary at admitted EOF", () => { + installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + + render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 25); + const startInput = screen.getByLabelText(/start/i) as HTMLInputElement; + fireEvent.change(startInput, { target: { value: "25" } }); + fireEvent.blur(startInput); + + expect(onSongUpdate).not.toHaveBeenCalled(); + expect(startInput.value).toBe("10"); + expect(startInput.getAttribute("aria-invalid")).toBe("true"); + }); + + it("does not replay a rejected start request after selecting a covered loop", () => { + const play = installPlayableAudioMocks(); + const song = createDemoRehearsalSong(); + const coveredSection = structuredClone(song.sections[0]!); + coveredSection.id = "intro-covered"; + coveredSection.label = "intro"; + coveredSection.timeRange = { start: 0, end: 20 }; + song.sections.push(coveredSection); + + render( + , + ); + + const audio = screen.getByTestId("rehearsal-loop-audio") as HTMLAudioElement; + admitDuration(audio, 25); + expect(play).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: /intro/i })); + + expect(play).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx new file mode 100644 index 000000000..099f009ef --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.media-lifecycle.test.tsx @@ -0,0 +1,154 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RehearsalPlayer } from "./RehearsalPlayer"; + +const originalTauriInternals = Object.getOwnPropertyDescriptor( + window, + "__TAURI_INTERNALS__", +); +const audioSourcePath = "/Users/test/Music/rehearsal.wav"; + +function installAudioBoundary() { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { + convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`), + }, + }); + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); +} + +function renderPlayableSong() { + render( + , + ); +} + +describe("RehearsalPlayer media lifecycle", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + if (originalTauriInternals) { + Object.defineProperty( + window, + "__TAURI_INTERNALS__", + originalTauriInternals, + ); + } else { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + } + }); + + it("keeps a quick user pause when the pending play request aborts", async () => { + installAudioBoundary(); + let rejectPlay: ((reason?: unknown) => void) | undefined; + vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectPlay = reject; + }), + ); + renderPlayableSong(); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + fireEvent.click(screen.getByRole("button", { name: /Pause/i })); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /paused/i, + ); + + await act(async () => { + const interruption = new Error("The play request was interrupted"); + interruption.name = "AbortError"; + rejectPlay?.(interruption); + await Promise.resolve(); + }); + + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /paused/i, + ); + expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull(); + }); + + it("ignores a stale play rejection after playback has resumed", async () => { + installAudioBoundary(); + let rejectFirstPlay: ((reason?: unknown) => void) | undefined; + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirstPlay = reject; + }), + ) + .mockResolvedValue(undefined); + renderPlayableSong(); + + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + fireEvent.click(screen.getByRole("button", { name: /Pause/i })); + fireEvent.click(screen.getByRole("button", { name: /Resume/i })); + expect(play).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Count in/i, + ); + + await act(async () => { + const staleInterruption = new Error("The earlier play request was interrupted"); + staleInterruption.name = "AbortError"; + rejectFirstPlay?.(staleInterruption); + await Promise.resolve(); + }); + + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Count in/i, + ); + expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull(); + }); + + it("restarts a selected loop when the media ends at the section boundary", () => { + vi.useFakeTimers(); + installAudioBoundary(); + const play = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + renderPlayableSong(); + + const audio = screen.getByTestId( + "rehearsal-loop-audio", + ) as HTMLAudioElement; + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: 10, + }); + fireEvent.click( + screen.getByRole("button", { name: /Start the count-in/i }), + ); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /looping/i, + ); + + audio.currentTime = 30; + fireEvent(audio, new Event("ended")); + + expect(audio.currentTime).toBe(10); + expect(play.mock.calls.length).toBeGreaterThan(1); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /looping/i, + ); + expect(screen.queryByTestId("rehearsal-loop-audio-error")).toBeNull(); + }); +}); 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..bd79fe007 --- /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(49); + }); + 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..14d582265 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -0,0 +1,1188 @@ +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 { + createRehearsalCountInClickEngine, + type RehearsalCountInClickEngine, +} from "./rehearsalCountInClick"; +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; + onSelectedSectionIndexChange?: (sectionIndex: number | null) => void; + sectionSelectionRequest?: { + sectionIndex: number; + requestId: number; + } | null; + hasLocalAudio?: boolean; + audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; + startNonce?: number; +} + +const PLAYBACK_AUTHORITY_PREFIX = "bandscope-project://"; +const PLAYBACK_PROJECT_ID = /^project-[0-9]+-[0-9]+$/; + +/** Convert an opaque current-project authority into BandScope's native media URL. */ +function resolveAudioSourceUrl( + sourcePath: string | null | undefined, +): string | null { + if (!sourcePath?.startsWith(PLAYBACK_AUTHORITY_PREFIX)) { + return null; + } + const projectId = sourcePath.slice(PLAYBACK_AUTHORITY_PREFIX.length); + if (!PLAYBACK_PROJECT_ID.test(projectId)) { + return null; + } + try { + return convertFileSrc(projectId, "bandscope-playback"); + } catch { + return null; + } +} + +/** Return whether a source authority can be converted into a playable native 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; +} + +/** Return whether a selected loop is fully covered by admitted local media. */ +function loopFitsAdmittedMedia( + loop: RehearsalLoopWindow, + mediaDurationSeconds: number | null, +): boolean { + return ( + mediaDurationSeconds !== null && + Number.isFinite(mediaDurationSeconds) && + mediaDurationSeconds > 0 && + loop.startSeconds < mediaDurationSeconds && + loop.endSeconds <= mediaDurationSeconds + ); +} + +/** Render tonight's first section loop with a count-in and a named next action. */ +export function RehearsalPlayer({ + song, + onSongUpdate, + onSelectedSectionIndexChange, + sectionSelectionRequest = null, + 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 lastHandledSectionSelectionRequestId = useRef(0); + const [boundaryError, setBoundaryError] = useState(false); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; + useEffect(() => { + onSelectedSectionIndexChange?.(selectedLoop?.sourceIndex ?? null); + }, [onSelectedSectionIndexChange, selectedLoop?.sourceIndex]); + useEffect(() => { + if (!sectionSelectionRequest) { + return; + } + const { requestId, sectionIndex } = sectionSelectionRequest; + if ( + !Number.isSafeInteger(requestId) || + requestId <= lastHandledSectionSelectionRequestId.current + ) { + return; + } + lastHandledSectionSelectionRequestId.current = requestId; + if ( + !Number.isSafeInteger(sectionIndex) || + sectionIndex < 0 || + sectionIndex >= song.sections.length + ) { + return; + } + const requestedLoop = playableLoops.find( + (loop) => loop.sourceIndex === sectionIndex, + ); + if (!requestedLoop) { + return; + } + setSelectedLoopKey(loopSelectionKey(requestedLoop)); + }, [playableLoops, sectionSelectionRequest, song.sections.length]); + const selectedBoundaryKey = selectedLoop ? loopSelectionKey(selectedLoop) : null; + const [boundaryDraft, setBoundaryDraft] = useState(() => ({ + end: selectedLoop ? String(selectedLoop.endSeconds) : "", + 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 countInClickEngine = useMemo( + () => createRehearsalCountInClickEngine(), + [], + ); + const lastHandledStartNonce = useRef(0); + const restartAudioOnLoopRef = useRef(false); + const lastCountInClickKeyRef = useRef(null); + const countInBeatRef = useRef<{ + durationMs: number; + startedAt: number; + remainingBeats: number; + progress: number; + } | null>(null); + const audioRef = useRef(null); + const playbackIntentRef = useRef<"active" | "inactive">("inactive"); + const playRequestSequenceRef = useRef(0); + 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 [mediaDurationSeconds, setMediaDurationSeconds] = useState( + null, + ); + + useEffect( + () => () => { + void countInClickEngine.dispose(); + }, + [countInClickEngine], + ); + + const handlePlaybackError = useCallback(() => { + playbackIntentRef.current = "inactive"; + setPlaybackError(true); + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, []); + + const handlePlayRejection = useCallback( + (error: unknown, requestSequence: number) => { + if (requestSequence !== playRequestSequenceRef.current) { + return; + } + const expectedInterruption = + playbackIntentRef.current === "inactive" && + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; + if (!expectedInterruption) { + handlePlaybackError(); + } + }, + [handlePlaybackError], + ); + + const startAudio = useCallback( + (loop: RehearsalLoopWindow, resume: boolean) => { + const audio = audioRef.current; + if (!audio || !audioSourceUrl) { + handlePlaybackError(); + return; + } + try { + restartAudioOnLoopRef.current = !resume; + if (!resume) { + audio.currentTime = loop.startSeconds; + audio.volume = 0; + } else { + audio.volume = 1; + } + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + }, + [audioSourceUrl, handlePlaybackError, handlePlayRejection], + ); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return undefined; + } + playbackIntentRef.current = "inactive"; + setMediaDurationSeconds(null); + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + if (audioSourceUrl) { + audio.src = audioSourceUrl; + audio.load(); + } else { + audio.removeAttribute("src"); + } + setPlaybackError(hasNativeAudioConversionError); + return () => { + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + }; + }, [audioSourceUrl, hasNativeAudioConversionError]); + + /** Admit only a finite positive duration from the currently loaded local source. */ + const handleLoadedMetadata = useCallback(() => { + const duration = audioRef.current?.duration ?? Number.NaN; + setMediaDurationSeconds( + Number.isFinite(duration) && duration > 0 ? duration : null, + ); + }, []); + + useEffect(() => { + setTransport((current) => { + if ( + 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; + } + if (!hasPlayableAudio || !selectedLoop || mediaDurationSeconds === null) { + return; + } + if (!loopFitsAdmittedMedia(selectedLoop, mediaDurationSeconds)) { + lastHandledStartNonce.current = startNonce; + return; + } + lastHandledStartNonce.current = startNonce; + setPlaybackError(false); + startAudio(selectedLoop, false); + setTransport((current) => { + const armed = reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); + return reduceRehearsalTransport(armed, { type: "start" }); + }); + }, [ + startAudio, + startNonce, + hasPlayableAudio, + mediaDurationSeconds, + selectedLoop, + ]); + + useEffect(() => { + const transportLoopCovered = + transport.loop === null || + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds); + if (hasPlayableAudio && transportLoopCovered) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => { + if (current.phase === "idle" || current.phase === "armed") { + return current; + } + return reduceRehearsalTransport(current, { type: "stop" }); + }); + }, [hasPlayableAudio, mediaDurationSeconds, transport.loop]); + + useEffect(() => { + if (transport.phase !== "counting-in" || !transport.loop) { + countInBeatRef.current = null; + lastCountInClickKeyRef.current = null; + countInClickEngine.stop(); + 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 elapsedProgress = sameBeat + ? Math.max(0, now - previous.startedAt) / previous.durationMs + : 0; + const progress = sameBeat + ? Math.min(1, previous.progress + elapsedProgress) + : 0; + const currentClickKey = `${transport.loop.selectionKey}:${transport.countInRemainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== currentClickKey + ) { + lastCountInClickKeyRef.current = currentClickKey; + void countInClickEngine + .click(transport.countInRemainingBeats === transport.loop.countInBeats) + .catch(() => { + // Count-in click failure must not gain authority over admitted song playback. + }); + } + countInBeatRef.current = { + durationMs, + startedAt: now, + remainingBeats: transport.countInRemainingBeats, + progress, + }; + 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; + current.progress = 0; + setTransport((state) => reduceRehearsalTransport(state, { type: "beat" })); + if (current.remainingBeats > 0) { + const nextClickKey = `${transport.loop?.selectionKey ?? ""}:${current.remainingBeats}`; + if ( + countInClickEngine.available && + lastCountInClickKeyRef.current !== nextClickKey + ) { + lastCountInClickKeyRef.current = nextClickKey; + void countInClickEngine.click(false).catch(() => { + // The transport remains authoritative when Web Audio is unavailable. + }); + } + current.startedAt = performance.now(); + scheduleBeat(current.durationMs); + } + }, delayMs); + }; + scheduleBeat(Math.ceil(Math.max(0, durationMs * (1 - progress)))); + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [ + countInClickEngine, + 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; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } catch { + handlePlaybackError(); + } + } else if ( + transport.phase === "armed" || + transport.phase === "paused" || + transport.phase === "idle" + ) { + playbackIntentRef.current = "inactive"; + if (!audio.paused) { + audio.pause(); + } + audio.volume = 1; + } + return undefined; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + 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; + playbackIntentRef.current = "active"; + const requestSequence = ++playRequestSequenceRef.current; + const playPromise = audio.play(); + if (playPromise) { + void playPromise.catch((error: unknown) => + handlePlayRejection(error, requestSequence), + ); + } + } 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 reports a real playback error. */ + const failPlayback = () => handlePlaybackError(); + audio.addEventListener("timeupdate", syncPlayhead); + audio.addEventListener("error", failPlayback); + audio.addEventListener("ended", restartLoop); + scheduleLoopBoundary(); + return () => { + clearBoundaryTimer(); + audio.removeEventListener("timeupdate", syncPlayhead); + audio.removeEventListener("error", failPlayback); + audio.removeEventListener("ended", restartLoop); + }; + }, [ + audioSourceUrl, + handlePlaybackError, + handlePlayRejection, + 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 && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (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 withinLoadedMedia = + !hasPlayableAudio || + (mediaDurationSeconds !== null && + (boundary === "start" + ? value < mediaDurationSeconds + : value <= mediaDurationSeconds)); + const valid = + rawValue !== "" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_SECTION_TIME_SECONDS && + withinLoadedMedia && + (boundary === "start" + ? value < selectedLoop.endSeconds + : value > selectedLoop.startSeconds); + 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 + ) { + setBoundaryDraft({ + end: String(selectedLoop.endSeconds), + start: String(selectedLoop.startSeconds), + }); + setBoundaryError(true); + 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); + }, + [ + hasPlayableAudio, + mediaDurationSeconds, + onSongUpdate, + selectedLoop, + song, + ], + ); + const canSeek = + transport.loop !== null && + hasPlayableAudio && + loopFitsAdmittedMedia(transport.loop, mediaDurationSeconds) && + (transport.phase === "looping" || + (transport.phase === "paused" && transport.countInRemainingBeats === 0)); + const handleSeek = useCallback( + (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 pauseTransport = useCallback(() => { + if (!canPause) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "pause" }), + ); + }, [canPause]); + const stopTransport = useCallback(() => { + if (!canStop) { + return; + } + playbackIntentRef.current = "inactive"; + setTransport((current) => + reduceRehearsalTransport(current, { type: "stop" }), + ); + }, [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) { + pauseTransport(); + } 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, pauseTransport, 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")} +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..1d87042f7 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -106,4 +106,60 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("keeps focus target ids renderer-owned for arbitrary analysis section ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0].id = " verse 1 "; + + render(); + + const card = document.getElementById("workspace-section-card-0"); + expect(card).toBeTruthy(); + expect(card?.getAttribute("tabindex")).toBe("-1"); + expect(card?.id).not.toContain(song.sections[0].id); + }); + + it("keeps duplicate analysis section ids as distinct renderer focus targets", () => { + const song = createDemoRehearsalSong(); + const duplicate = { ...song.sections[0], roles: [...song.sections[0].roles] }; + song.sections = [song.sections[0], duplicate]; + + render(); + + expect(document.getElementById("workspace-section-card-0")).toBeTruthy(); + const second = document.getElementById("workspace-section-card-1"); + expect(second).toBeTruthy(); + expect(second?.className).toContain("ring-cyan-300/70"); + }); + + it("edits only the renderer-selected occurrence when analysis section and role ids collide", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const firstSection = song.sections[0]!; + const duplicate = { + ...firstSection, + roles: firstSection.roles.map((role) => ({ + ...role, + harmony: { ...role.harmony }, + manualOverrides: [...role.manualOverrides] + })) + }; + song.sections = [firstSection, duplicate]; + const onSongUpdate = vi.fn(); + vi.spyOn(window, "prompt").mockReturnValue("Dm7"); + + render(); + + const editButtons = screen.getAllByRole("button", { + name: "Edit chord for Bass Guitar in verse, current C#m7" + }); + expect(editButtons).toHaveLength(2); + fireEvent.click(editButtons[1]!); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + const updatedSong = onSongUpdate.mock.calls[0]![0]; + expect(updatedSong.sections[0].roles[0].harmony.chord).toBe("C#m7"); + expect(updatedSong.sections[1].roles[0].harmony.chord).toBe("Dm7"); + expect(updatedSong.sections[1].roles[0].harmony.source).toBe("user"); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..b508ed284 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,5 +1,5 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useRef } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; @@ -12,13 +12,60 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + loopedSectionIndex?: number | null; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const lastLoopedSectionIndex = useRef(loopedSectionIndex); + const observedInitialSelection = useRef(loopedSectionIndex !== null); + + useEffect(() => { + if (loopedSectionIndex === null) { + lastLoopedSectionIndex.current = null; + return; + } + + if (!observedInitialSelection.current) { + observedInitialSelection.current = true; + lastLoopedSectionIndex.current = loopedSectionIndex; + return; + } + + if (lastLoopedSectionIndex.current === loopedSectionIndex) { + return; + } + lastLoopedSectionIndex.current = loopedSectionIndex; + + const selectedCard = document.getElementById( + `workspace-section-card-${loopedSectionIndex}`, + ); + if (!(selectedCard instanceof HTMLElement)) { + return; + } + + const prefersReducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (typeof selectedCard.scrollIntoView === "function") { + selectedCard.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest", + inline: "center", + }); + } + + const activeElement = document.activeElement; + const sectionPickerOwnsFocus = + activeElement instanceof HTMLButtonElement && + activeElement.id.startsWith("rehearsal-loop-section-"); + if (!sectionPickerOwnsFocus) { + selectedCard.focus(); + } + }, [loopedSectionIndex]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -28,26 +75,34 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma .replace("{chord}", role.harmony.chord); }; - /** Documented. */ - const handleChordEdit = (sectionId: string, role: RehearsalRole) => { + /** Edit exactly one renderer-selected occurrence without treating analysis IDs as unique UI authority. */ + const handleChordEdit = ( + sectionIndex: number, + roleIndex: number, + sectionSnapshot: RehearsalSong["sections"][number], + roleSnapshot: RehearsalRole + ) => { if (!onSongUpdate) return; - const newChord = window.prompt(t("chordEditPrompt"), role.harmony.chord); + + const currentSection = song.sections[sectionIndex]; + const currentRole = currentSection?.roles[roleIndex]; + if (currentSection !== sectionSnapshot || currentRole !== roleSnapshot) return; + + const newChord = window.prompt(t("chordEditPrompt"), roleSnapshot.harmony.chord); if (newChord === null) return; const trimmedChord = newChord.trim(); - if (trimmedChord === "" || trimmedChord === role.harmony.chord) return; + if (trimmedChord === "" || trimmedChord === roleSnapshot.harmony.chord) return; - let changed = false; const updatedSong = { ...song, - sections: song.sections.map((section) => { - if (section.id !== sectionId) return section; + sections: song.sections.map((section, candidateSectionIndex) => { + if (candidateSectionIndex !== sectionIndex) return section; return { ...section, - roles: section.roles.map((targetRole) => { - if (targetRole.id !== role.id) return targetRole; - changed = true; + roles: section.roles.map((targetRole, candidateRoleIndex) => { + if (candidateRoleIndex !== roleIndex) return targetRole; const harmony = { ...targetRole.harmony, @@ -72,7 +127,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma }) }; - if (changed) onSongUpdate(updatedSong); + onSongUpdate(updatedSong); }; /** Documented. */ const getPriorityColor = (priority: string) => { @@ -104,11 +159,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( @@ -123,13 +184,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma - {section.roles - .filter(role => !activeRole || role.id === activeRole) - .map(role => { - const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote); - return ( + {section.roles.map((role, roleIndex) => { + if (activeRole && role.id !== activeRole) return null; + + const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote); + return (
@@ -164,7 +225,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma ? "bg-indigo-300/15 text-indigo-200" : "text-cyan-100" }`} - onClick={() => handleChordEdit(section.id, role)} + onClick={() => handleChordEdit(sectionIndex, roleIndex, section, role)} title={onSongUpdate ? t("chordEditTitle") : undefined} disabled={!onSongUpdate} > @@ -224,8 +285,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
- ); - })} + ); + })} ))} diff --git a/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx new file mode 100644 index 000000000..a88ce3a19 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.loop-roadmap-sync.test.tsx @@ -0,0 +1,198 @@ +import { fireEvent, render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguageDescriptor = Object.getOwnPropertyDescriptor( + window.navigator, + "language", +); +const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia"); +const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", +); + +afterEach(() => { + if (originalLanguageDescriptor) { + Object.defineProperty(window.navigator, "language", originalLanguageDescriptor); + } + + if (originalMatchMediaDescriptor) { + Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor); + } else { + Reflect.deleteProperty(window, "matchMedia"); + } + + if (originalScrollIntoViewDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + "scrollIntoView", + originalScrollIntoViewDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + + vi.restoreAllMocks(); +}); + +describe("Workspace loop roadmap synchronization", () => { + it("projects the player-selected section onto the production roadmap", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + render(); + + const sectionButtons = Array.from( + document.querySelectorAll( + 'button[id^="rehearsal-loop-section-"]', + ), + ); + const verseCard = document.getElementById("workspace-section-card-0"); + const chorusCard = document.getElementById("workspace-section-card-1"); + + expect(sectionButtons).toHaveLength(2); + expect(verseCard?.className).toContain("ring-cyan-300/70"); + expect(chorusCard?.className).not.toContain("ring-cyan-300/70"); + + fireEvent.click(sectionButtons[1]!); + + expect(verseCard?.className).not.toContain("ring-cyan-300/70"); + expect(chorusCard?.className).toContain("ring-cyan-300/70"); + }); + + it("selects the same player and roadmap occurrence from the song-structure timeline", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + render(); + + const timelineChorus = document.querySelector( + 'button[data-song-structure-section-index="1"]', + ); + const playerButtons = Array.from( + document.querySelectorAll( + 'button[id^="rehearsal-loop-section-"]', + ), + ); + const chorusCard = document.getElementById("workspace-section-card-1"); + + expect(timelineChorus).not.toBeNull(); + expect(playerButtons).toHaveLength(2); + fireEvent.click(timelineChorus!); + + expect(timelineChorus?.getAttribute("aria-pressed")).toBe("true"); + expect(playerButtons[1]?.getAttribute("aria-pressed")).toBe("true"); + expect(chorusCard?.className).toContain("ring-cyan-300/70"); + }); + + it("localizes the song-structure timeline region for Korean assistive technology", () => { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: "ko-KR", + }); + + const view = render(); + + expect( + view.getByRole("region", { name: "스크롤 가능한 곡 구조 타임라인" }), + ).toBeTruthy(); + }); + + it("focuses the selected roadmap occurrence without motion when reduced motion is requested", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(song.sections[0]!); + chorus.id = verse.id; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + render(); + + const sectionButtons = Array.from( + document.querySelectorAll( + 'button[id^="rehearsal-loop-section-"]', + ), + ); + const chorusCard = document.getElementById("workspace-section-card-1"); + + expect(sectionButtons).toHaveLength(2); + fireEvent.click(sectionButtons[1]!); + + expect(document.activeElement).toBe(chorusCard); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest", + inline: "center", + }); + }); + + it("keeps focus in the section picker across consecutive arrow-key selections", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + const bridge = structuredClone(song.sections[0]!); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 64, end: 88 }; + song.sections = [verse, chorus, bridge]; + + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + + render(); + + const sectionButtons = Array.from( + document.querySelectorAll( + 'button[id^="rehearsal-loop-section-"]', + ), + ); + + expect(sectionButtons).toHaveLength(3); + sectionButtons[0]!.focus(); + + fireEvent.keyDown(sectionButtons[0]!, { key: "ArrowRight" }); + expect(document.activeElement).toBe(sectionButtons[1]); + + fireEvent.keyDown(sectionButtons[1]!, { key: "ArrowRight" }); + expect(document.activeElement).toBe(sectionButtons[2]); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..3945694b8 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: "bandscope-project://project-1-1", + 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,20 +133,168 @@ describe("Workspace", () => { expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); - it("enables bass transcription from selected role metadata rather than role id text", () => { + 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("keeps bass transcription unavailable when role metadata names bass but no action exists", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, 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; - expect(transcribeButton.disabled).toBe(false); - expect(transcribeButton.title).toBe("Transcribe part"); + const transcribeButton = screen.getByRole("button", { + name: "Transcribe Bass", + }); + expect(transcribeButton.getAttribute("aria-disabled")).toBe("true"); + expect(transcribeButton.getAttribute("title")).toBe( + "Bass Guitar transcription is not available yet.", + ); }); it("renders bass transcription in the dark rehearsal cockpit system", () => { @@ -108,14 +304,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 +334,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 +402,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 +418,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 +456,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 +486,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 +501,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 +512,7 @@ describe("Workspace", () => { const song = createDemoRehearsalSong(); song.exportSummary = { ...song.exportSummary, - headline: "" + headline: "", }; render(); diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx new file mode 100644 index 000000000..867a065f7 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-role-selection.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalMatchMedia = window.matchMedia; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +afterEach(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia, + }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView, + }); + vi.restoreAllMocks(); +}); + +describe("Workspace timeline role selection", () => { + it("clears the role filter before selecting a timeline section outside that role", async () => { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + const { container } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const roleFilteredSections = screen.getByRole("group", { + name: "Playable sections for Lead Vocal", + }); + expect( + within(roleFilteredSections).queryByRole("button", { name: /chorus/i }), + ).toBeNull(); + + const chorusTimelineButton = container.querySelector( + '[data-song-structure-section-index="1"]', + ); + expect(chorusTimelineButton).not.toBeNull(); + fireEvent.click(chorusTimelineButton!); + + await waitFor(() => { + expect( + screen.getByRole("tab", { name: "All Roles", selected: true }), + ).toBeTruthy(); + const allSections = screen.getByRole("group", { name: "Playable sections" }); + expect( + within(allSections).getByRole("button", { name: /chorus/i }), + ).toBeTruthy(); + }); + }); + + it("retains the role and selected loop when a timeline section is not admissible", async () => { + const song = createDemoRehearsalSong(); + const malformed = structuredClone(song.sections[0]!); + malformed.id = "malformed-chorus"; + malformed.label = "malformed chorus"; + malformed.timeRange = { + start: Number.NaN, + end: Number.POSITIVE_INFINITY, + }; + malformed.roles = malformed.roles.filter( + (role) => role.id !== "lead-vocal", + ); + song.sections.push(malformed); + + const { container } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const roleFilteredSections = screen.getByRole("group", { + name: "Playable sections for Lead Vocal", + }); + const verseButton = within(roleFilteredSections).getByRole("button", { + name: /verse/i, + }); + expect(verseButton.getAttribute("aria-pressed")).toBe("true"); + + const malformedTimelineButton = container.querySelector( + '[data-song-structure-section-index="1"]', + ); + expect(malformedTimelineButton).not.toBeNull(); + fireEvent.click(malformedTimelineButton!); + + await waitFor(() => { + expect( + screen.getByRole("tab", { name: "Lead Vocal", selected: true }), + ).toBeTruthy(); + expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent( + "Showing sections that include Lead Vocal.", + ); + expect(verseButton.getAttribute("aria-pressed")).toBe("true"); + }); + }); + + it("focuses an explicitly reselected first occurrence with reduced-motion scrolling", async () => { + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + const { container } = render(); + const firstTimelineButton = container.querySelector( + '[data-song-structure-section-index="0"]', + ); + expect(firstTimelineButton).not.toBeNull(); + fireEvent.click(firstTimelineButton!); + + await waitFor(() => { + expect(document.activeElement?.id).toBe("workspace-section-card-0"); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest", + inline: "center", + }); + }); + }); + + it("focuses the clicked renderer occurrence when analysis section ids collide", async () => { + const song = createDemoRehearsalSong(); + const duplicate = structuredClone(song.sections[0]!); + duplicate.timeRange = { start: 40, end: 64 }; + song.sections.push(duplicate); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + + const { container } = render(); + const secondTimelineButton = container.querySelector( + '[data-song-structure-section-index="1"]', + ); + expect(secondTimelineButton).not.toBeNull(); + fireEvent.click(secondTimelineButton!); + + await waitFor(() => { + expect(document.activeElement?.id).toBe("workspace-section-card-1"); + expect(scrollIntoView).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..60660615f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,9 +1,11 @@ -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 { resolveLoopWindows } from "./rehearsalTransport"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -71,8 +73,37 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro } } -/** Documented. */ -const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { +/** Focus one renderer-owned roadmap occurrence after an explicit timeline action. */ +function focusWorkspaceSection(sectionIndex: number): void { + const sectionCard = document.getElementById(`workspace-section-card-${sectionIndex}`); + if (!(sectionCard instanceof HTMLElement)) { + return; + } + const prefersReducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (typeof sectionCard.scrollIntoView === "function") { + sectionCard.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest", + inline: "center", + }); + } + sectionCard.focus(); +} + +/** Render renderer-position section actions without creating a second transport store. */ +const SongStructure = memo(function SongStructure({ + sections, + t, + selectedSectionIndex, + onSelectSection, +}: { + sections: RehearsalSong["sections"]; + t: Translator; + selectedSectionIndex: number | null; + onSelectSection: (sectionIndex: number) => void; +}) { return (
@@ -84,21 +115,37 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R role="region" tabIndex={0} className="overflow-x-auto rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(8,18,35,0.96),rgba(2,6,23,0.98))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" - aria-label="Scrollable song structure timeline" + aria-label={t("workspaceSongStructureTimelineRegionAria")} >
- {sections.map((section) => ( -
-

- {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)} -

-

{section.groove}

-
- ))} + {sections.map((section, sectionIndex) => { + const selected = selectedSectionIndex === sectionIndex; + return ( +
+ +
+ ); + })}
- + + +
@@ -363,15 +479,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {activeRole && ( + {resolvedActiveRole && (

Stem Player

-

{activeRoleDetails?.name ?? activeRole}

+

{activeRoleDetails?.name ?? resolvedActiveRole}

- {canTranscribeBass ? ( - - ) : ( - - )} +
@@ -504,7 +619,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts new file mode 100644 index 000000000..afcfe1bfa --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createRehearsalCountInClickEngine, + defaultRehearsalCountInContextFactory, + type RehearsalCountInAudioContext, + type RehearsalCountInGain, + type RehearsalCountInOscillator, +} from "./rehearsalCountInClick"; + +function createFakeContext(): { + context: RehearsalCountInAudioContext; + gains: RehearsalCountInGain[]; + oscillators: RehearsalCountInOscillator[]; +} { + const gains: RehearsalCountInGain[] = []; + const oscillators: RehearsalCountInOscillator[] = []; + const context: RehearsalCountInAudioContext = { + close: vi.fn(async () => undefined), + createGain: () => { + const gain: RehearsalCountInGain = { + connect: () => gain, + disconnect: vi.fn(), + gain: { + exponentialRampToValueAtTime: vi.fn(), + setValueAtTime: vi.fn(), + }, + }; + gains.push(gain); + return gain; + }, + createOscillator: () => { + const oscillator: RehearsalCountInOscillator = { + connect: vi.fn(), + disconnect: vi.fn(), + frequency: { value: 0 }, + onended: null, + start: vi.fn(), + stop: vi.fn(), + type: "sine", + }; + oscillators.push(oscillator); + return oscillator; + }, + currentTime: 1, + destination: {}, + resume: vi.fn(async () => undefined), + state: "running", + }; + return { context, gains, oscillators }; +} + +describe("defaultRehearsalCountInContextFactory", () => { + const originalAudioContext = Object.getOwnPropertyDescriptor( + window, + "AudioContext", + ); + + afterEach(() => { + if (originalAudioContext) { + Object.defineProperty(window, "AudioContext", originalAudioContext); + } else { + Reflect.deleteProperty(window, "AudioContext"); + } + Reflect.deleteProperty(window, "webkitAudioContext"); + }); + + it("is unavailable when the host exposes no Web Audio constructor", () => { + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: undefined, + }); + expect(defaultRehearsalCountInContextFactory()).toBeNull(); + }); + + it("constructs the standard AudioContext when present", () => { + class FakeAudioContext { + currentTime = 0; + } + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: FakeAudioContext, + }); + const factory = defaultRehearsalCountInContextFactory(); + expect(factory).not.toBeNull(); + expect(factory?.()).toBeInstanceOf(FakeAudioContext); + }); + + it("falls back to webkitAudioContext when required by the host", () => { + class FakeWebkitAudioContext { + currentTime = 0; + } + Object.defineProperty(window, "AudioContext", { + configurable: true, + writable: true, + value: undefined, + }); + Object.defineProperty(window, "webkitAudioContext", { + configurable: true, + writable: true, + value: FakeWebkitAudioContext, + }); + const factory = defaultRehearsalCountInContextFactory(); + expect(factory).not.toBeNull(); + expect(factory?.()).toBeInstanceOf(FakeWebkitAudioContext); + }); +}); + +describe("createRehearsalCountInClickEngine", () => { + it("rejects a click when the host has no Web Audio boundary", async () => { + const engine = createRehearsalCountInClickEngine(null); + expect(engine.available).toBe(false); + await expect(engine.click(true)).rejects.toThrow(/unavailable/i); + }); + + it("sounds accent and tap frequencies through one retained context", async () => { + const { context, gains, oscillators } = createFakeContext(); + const factory = vi.fn(() => context); + const engine = createRehearsalCountInClickEngine(factory); + + await engine.click(true); + await engine.click(false); + + expect(factory).toHaveBeenCalledTimes(1); + expect(oscillators).toHaveLength(2); + expect(oscillators[0]?.frequency.value).toBe(1200); + expect(oscillators[1]?.frequency.value).toBe(800); + expect(oscillators[0]?.type).toBe("square"); + expect(oscillators[0]?.start).toHaveBeenCalledTimes(1); + expect(oscillators[0]?.stop).toHaveBeenCalledTimes(1); + expect(gains[0]?.gain.setValueAtTime).toHaveBeenCalled(); + expect(gains[0]?.gain.exponentialRampToValueAtTime).toHaveBeenCalledTimes(2); + + oscillators[0]?.onended?.(); + expect(oscillators[0]?.disconnect).toHaveBeenCalledTimes(1); + expect(gains[0]?.disconnect).toHaveBeenCalledTimes(1); + + engine.stop(); + expect(oscillators[1]?.stop).toHaveBeenCalledTimes(2); + expect(oscillators[1]?.disconnect).toHaveBeenCalledTimes(1); + expect(gains[1]?.disconnect).toHaveBeenCalledTimes(1); + }); + + it("does not create a click after stop invalidates a pending resume", async () => { + const { context, oscillators } = createFakeContext(); + context.state = "suspended"; + let finishResume: (() => void) | undefined; + context.resume = vi.fn( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const engine = createRehearsalCountInClickEngine(() => context); + + const clickPromise = engine.click(true); + await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(1); + engine.stop(); + finishResume?.(); + await clickPromise; + + expect(oscillators).toHaveLength(0); + }); + + it("closes the context and invalidates pending resume work during disposal", async () => { + const { context, oscillators } = createFakeContext(); + context.state = "suspended"; + let finishResume: (() => void) | undefined; + context.resume = vi.fn( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const engine = createRehearsalCountInClickEngine(() => context); + + const clickPromise = engine.click(true); + await Promise.resolve(); + expect(context.resume).toHaveBeenCalledTimes(1); + + await engine.dispose(); + expect(context.close).toHaveBeenCalledTimes(1); + expect(engine.available).toBe(false); + + finishResume?.(); + await clickPromise; + expect(oscillators).toHaveLength(0); + + await engine.dispose(); + expect(context.close).toHaveBeenCalledTimes(1); + await expect(engine.click(false)).rejects.toThrow(/unavailable/i); + }); + + it("contains graph-construction failure and releases acquired nodes", async () => { + const { context, oscillators } = createFakeContext(); + context.createGain = () => { + throw new Error("gain failed"); + }; + const engine = createRehearsalCountInClickEngine(() => context); + + await expect(engine.click(true)).rejects.toThrow("gain failed"); + expect(oscillators[0]?.stop).toHaveBeenCalledTimes(1); + expect(oscillators[0]?.disconnect).toHaveBeenCalledTimes(1); + }); + + it("contains node configuration failure and tolerates teardown errors", async () => { + const { context, gains, oscillators } = createFakeContext(); + const baseCreateGain = context.createGain; + context.createGain = () => { + const gain = baseCreateGain(); + gain.gain.setValueAtTime = () => { + throw new Error("envelope failed"); + }; + gain.disconnect = () => { + throw new Error("gain already released"); + }; + return gain; + }; + const baseCreateOscillator = context.createOscillator; + context.createOscillator = () => { + const oscillator = baseCreateOscillator(); + oscillator.stop = () => { + throw new Error("oscillator already stopped"); + }; + oscillator.disconnect = () => { + throw new Error("oscillator already released"); + }; + return oscillator; + }; + const engine = createRehearsalCountInClickEngine(() => context); + + await expect(engine.click(false)).rejects.toThrow("envelope failed"); + expect(oscillators).toHaveLength(1); + expect(gains).toHaveLength(1); + expect(() => engine.stop()).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/features/workspace/rehearsalCountInClick.ts b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts new file mode 100644 index 000000000..ee01d746e --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalCountInClick.ts @@ -0,0 +1,198 @@ +/** Oscillator operations the count-in engine owns across browser and test contexts. */ +export type RehearsalCountInOscillator = { + connect: (destination: unknown) => void; + disconnect: () => void; + frequency: { value: number }; + /** Release the retained graph node when the browser reports oscillator completion. */ + onended: (() => void) | null; + start: (when?: number) => void; + stop: (when?: number) => void; + type: string; +}; + +/** Gain-envelope operations needed to shape one bounded count-in click. */ +export type RehearsalCountInGain = { + connect: (destination: unknown) => RehearsalCountInGain; + disconnect: () => void; + gain: { + exponentialRampToValueAtTime: (value: number, when: number) => void; + setValueAtTime: (value: number, when: number) => void; + }; +}; + +/** Minimal Web Audio context authority retained by one mounted rehearsal player. */ +export type RehearsalCountInAudioContext = { + close: () => Promise; + createGain: () => RehearsalCountInGain; + createOscillator: () => RehearsalCountInOscillator; + currentTime: number; + destination: unknown; + resume: () => Promise; + state: string; +}; + +/** Lazily construct the browser audio context only when a count-in first sounds. */ +export type RehearsalCountInContextFactory = () => RehearsalCountInAudioContext; + +/** Count-in lifecycle port: stop is reusable while dispose is terminal after acquisition. */ +export type RehearsalCountInClickEngine = { + available: boolean; + click: (accent: boolean) => Promise; + dispose: () => Promise; + stop: () => void; +}; + +const ACCENT_FREQUENCY_HZ = 1200; +const TAP_FREQUENCY_HZ = 800; +const CLICK_SECONDS = 0.05; +const CLICK_LEAD_SECONDS = 0.005; + +type LiveClickNode = { + gain: RehearsalCountInGain; + oscillator: RehearsalCountInOscillator; +}; + +/** Return the host Web Audio factory used only for the local count-in click. */ +export function defaultRehearsalCountInContextFactory(): RehearsalCountInContextFactory | null { + const AudioContextCtor = + typeof window === "undefined" + ? undefined + : window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!AudioContextCtor) { + return null; + } + return () => new AudioContextCtor() as unknown as RehearsalCountInAudioContext; +} + +/** Create a bounded local click engine owned by the active-player transport. */ +export function createRehearsalCountInClickEngine( + contextFactory: RehearsalCountInContextFactory | null = + defaultRehearsalCountInContextFactory(), +): RehearsalCountInClickEngine { + let context: RehearsalCountInAudioContext | null = null; + let disposed = false; + let playbackGeneration = 0; + const liveNodes = new Set(); + + /** Release one tracked click node without retaining audio-graph authority. */ + const releaseNode = (node: LiveClickNode, stopOscillator: boolean): void => { + liveNodes.delete(node); + if (stopOscillator) { + try { + node.oscillator.stop(); + } catch { + // An already-ended oscillator may reject a second stop during teardown. + } + } + try { + node.oscillator.disconnect(); + } catch { + // Disconnect remains best-effort after the browser releases a node. + } + try { + node.gain.disconnect(); + } catch { + // Gain cleanup must not keep a stale transport alive. + } + }; + + /** Release an oscillator acquired before gain-node construction completed. */ + const releasePartialOscillator = ( + oscillator: RehearsalCountInOscillator, + ): void => { + try { + oscillator.stop(); + } catch { + // Partial construction still releases every acquired node. + } + try { + oscillator.disconnect(); + } catch { + // Partial construction cleanup is best-effort. + } + }; + + /** Invalidate pending resume work and release every active click node. */ + const stop = (): void => { + playbackGeneration += 1; + for (const node of [...liveNodes]) { + releaseNode(node, true); + } + }; + + /** Permanently release Web Audio authority once this engine has acquired it. */ + const dispose = async (): Promise => { + if (disposed) { + return; + } + const closingContext = context; + if (!closingContext) { + // React Strict Mode replays effect cleanup before lazy audio acquisition. + return; + } + disposed = true; + stop(); + context = null; + try { + await closingContext.close(); + } catch { + // Browser teardown failures must not keep an unmounted player alive. + } + }; + + return { + /** Report whether this effect-lifetime engine can still sound a local click. */ + get available(): boolean { + return contextFactory !== null && !disposed; + }, + /** Sound exactly one admitted transport beat, accented only at count-in start. */ + async click(accent: boolean): Promise { + if (!contextFactory || disposed) { + throw new Error("Rehearsal count-in click is unavailable."); + } + const generation = playbackGeneration; + const activeContext = context ?? (context = contextFactory()); + if (activeContext.state === "suspended") { + await activeContext.resume(); + } + if (generation !== playbackGeneration || disposed) { + return; + } + + const oscillator = activeContext.createOscillator(); + let gain: RehearsalCountInGain; + try { + gain = activeContext.createGain(); + } catch (error) { + releasePartialOscillator(oscillator); + throw error; + } + + const node = { gain, oscillator }; + liveNodes.add(node); + try { + const when = activeContext.currentTime + CLICK_LEAD_SECONDS; + oscillator.type = "square"; + oscillator.frequency.value = accent + ? ACCENT_FREQUENCY_HZ + : TAP_FREQUENCY_HZ; + gain.gain.setValueAtTime(0.0001, when); + gain.gain.exponentialRampToValueAtTime(0.12, when + 0.002); + gain.gain.exponentialRampToValueAtTime(0.0001, when + CLICK_SECONDS); + oscillator.connect(gain); + gain.connect(activeContext.destination); + /** Release this node after the bounded oscillator has naturally ended. */ + oscillator.onended = () => releaseNode(node, false); + oscillator.start(when); + oscillator.stop(when + CLICK_SECONDS + 0.01); + } catch (error) { + releaseNode(node, true); + throw error; + } + }, + dispose, + stop, + }; +} 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.tempo-display.test.ts b/apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts new file mode 100644 index 000000000..672ea9763 --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.tempo-display.test.ts @@ -0,0 +1,26 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + createIdleTransportState, + nextActionValues, + reduceRehearsalTransport, + resolveLoopWindow, +} from "./rehearsalTransport"; + +describe("rehearsal transport tempo display", () => { + it("rounds the playback-adjusted tempo for rehearsal copy", () => { + const song = createDemoRehearsalSong(); + song.tempo = 90; + const loop = resolveLoopWindow(song); + let state = reduceRehearsalTransport(createIdleTransportState(), { + type: "arm", + loop, + }); + state = reduceRehearsalTransport(state, { + type: "set-playback-rate", + rate: 1.25, + }); + + expect(nextActionValues(state).tempo).toBe("113"); + }); +}); 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..18fe11c98 --- /dev/null +++ b/apps/desktop/src/features/workspace/rehearsalTransport.ts @@ -0,0 +1,533 @@ +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(Math.round(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..4d7035bfe --- /dev/null +++ b/apps/desktop/src/i18n/rehearsalLoopCopy.test.ts @@ -0,0 +1,26 @@ +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); + + const ko = createTranslator("ko"); + expect(ko("workspaceLoopArmedWithAudio")).toContain("합주 시계"); + expect(ko("workspaceLoopCountingIn")).toContain("합주 시계"); + expect(ko("workspaceLoopPlaying")).toContain("합주 시계"); + }); +}); diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..bdef50f73 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -4,6 +4,7 @@ import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + selectLocalAudioSource, startAnalysisJob } from "./analysis"; @@ -20,6 +21,52 @@ describe("analysis bridge", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("preserves safe playback preparation errors from native local audio selection", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue(new Error("Could not prepare the selected audio for playback.")); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Could not prepare the selected audio for playback." + } + }); + }); + + it("preserves allowlisted string rejections from the production Tauri boundary", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue("Could not prepare the selected audio for playback."); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Could not prepare the selected audio for playback." + } + }); + }); + + it("does not expose unallowlisted native string rejections", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockRejectedValue("/Users/test/private/song.wav failed"); + + const selection = await selectLocalAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "Choose a WAV, MP3, FLAC, or M4A file to start analysis." + } + }); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); @@ -237,4 +284,4 @@ describe("analysis bridge", () => { } }); }); -}); +}); \ No newline at end of file diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..0da2295d7 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -38,6 +38,7 @@ const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", + "Could not prepare the selected audio for playback.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", "Could not prepare the local temp workspace." @@ -231,13 +232,15 @@ export async function selectLocalAudioSource(): Promise { export async function loadProject(): Promise { const response = await invokeAnalysis("load_project"); return parseRehearsalSong(response); -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..1d56978b7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -37,6 +37,7 @@ "workspaceTempoLabel": "Tempo", "workspaceSongStructureLabel": "Song Structure", "workspaceRehearsalTimelineLabel": "Rehearsal timeline", + "workspaceSongStructureTimelineRegionAria": "Scrollable song structure timeline", "workspaceSongTimelineLabel": "Song Timeline", "workspaceCollaborationLabel": "Collaboration", "workspaceCollaborationEmpty": "Assignments, comments, and approvals will show up here as the room aligns.", @@ -149,6 +150,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..2200b34b9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -37,6 +37,7 @@ "workspaceTempoLabel": "템포", "workspaceSongStructureLabel": "곡 구조", "workspaceRehearsalTimelineLabel": "합주 타임라인", + "workspaceSongStructureTimelineRegionAria": "스크롤 가능한 곡 구조 타임라인", "workspaceSongTimelineLabel": "곡 타임라인", "workspaceCollaborationLabel": "협업", "workspaceCollaborationEmpty": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.", @@ -149,6 +150,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} 들어가기 전에 그 충돌을 악기로 들어 보세요.",