diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..2370a9405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- Name tonight's first ritardando plan with the owning vocal or bass when existing tempo-stability reports a corroborated slowing, the owned `ritardandoPlan` copy, the labeled section, and the time so the next action is Open on the map; prefer vocal over bass when their priorities tie. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Heuristic demo topology stays unnamed. - 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..7356c7bca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - tonight's first ritardando plan on the mounted map when existing tempo-stability reports a sustained slowing (`to_bpm < from_bpm`) that is not a half-time (~0.45–0.55) or double-time (~2.0) feel flip, landing on the highest-priority active named vocal or bass in the section that contains the change; vocal wins a priority tie. Open moves to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, first-cutoff, first-pickup, and first-turnaround. This is not a new MIR product. - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..8f438f397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first ritardando plan in the mounted rehearsal workspace so the vocal or bass that eases into a slower tempo can open that landing on the map; real analyzed songs now receive this guidance only when existing tempo-stability reports a sustained slowing that is not a half-time or double-time feel flip, while heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - 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. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..2cfeeaf7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The 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 mounted workspace names tonight's first ritardando plan and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fade plans, swell plans, drop plans, breakdown plans, hit plans, cutoff plans, half-time feel flips, double-time feel flips, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-fade, first-swell, first-drop, first-breakdown, first-hit, first-stop, and first-cutoff. `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/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 200726570..d987038f7 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -122,12 +122,34 @@ pub enum AnalysisCacheStatus { pub struct RehearsalSongPayload { id: String, title: String, + #[serde( + default, + deserialize_with = "deserialize_tempo", + skip_serializing_if = "Option::is_none" + )] + tempo: Option, sections: Vec, export_summary: ExportSummaryPayload, #[serde(default, skip_serializing_if = "Option::is_none")] score_attachments: Option>, } +fn deserialize_tempo<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + let Some(tempo) = value.as_f64() else { + return Err(serde::de::Error::custom("tempo must be a positive number")); + }; + if !tempo.is_finite() || tempo <= 0.0 { + return Err(serde::de::Error::custom( + "tempo must be positive and finite", + )); + } + Ok(Some(tempo)) +} + /// Score attachment metadata persisted inside the song payload. Only the /// locally minted score id and the display file name cross the IPC boundary; /// the PDF bytes stay in the app-owned scores directory keyed by that id. @@ -176,6 +198,37 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum RitardandoPlanSourcePayload { + Model, + User, +} + +fn deserialize_practice_progress<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let progress = Option::::deserialize(deserializer)?; + if let Some(value) = progress { + if value > 100 { + return Err(serde::de::Error::custom( + "practiceProgress must be between 0 and 100", + )); + } + } + Ok(progress) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -183,14 +236,32 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde( + default, + deserialize_with = "deserialize_practice_progress", + skip_serializing_if = "Option::is_none" + )] + practice_progress: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ritardando_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ritardando_plan_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ritardando_plan_at_seconds: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +598,134 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn is_plan_whitespace(value: char) -> bool { + matches!( + value, + '\u{0009}'..='\u{000D}' + | '\u{0020}' + | '\u{0085}' + | '\u{00A0}' + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' + ) +} + +/// Validates persisted plan text without normalizing user-authored content. +fn is_valid_ritardando_plan(value: &str, source: Option<&RitardandoPlanSourcePayload>) -> bool { + let mut has_non_whitespace = false; + for character in value.chars() { + if matches!( + character, + '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) { + return false; + } + if !is_plan_whitespace(character) { + has_non_whitespace = true; + } + } + has_non_whitespace + && (!matches!(source, Some(RitardandoPlanSourcePayload::Model)) + || is_valid_model_ritardando_plan(value.trim())) +} + +const MAX_SECTION_TIME_SECONDS: f64 = 4_294_967_295.0; +const MAX_RITARDANDO_PLAN_CHARACTERS: usize = 180; +const RITARDANDO_PLAN_PREFIX: &str = "Ease this part from "; +const RITARDANDO_PLAN_MIDDLE: &str = " BPM into "; +const RITARDANDO_PLAN_SUFFIX: &str = " BPM; let the next downbeat land later."; +const HALF_TIME_RATIO_MIN: f64 = 0.45; +const HALF_TIME_RATIO_MAX: f64 = 0.55; + +fn is_decimal_bpm_token(value: &str) -> bool { + let mut has_digit = false; + let mut decimal_points = 0; + for character in value.chars() { + if character.is_ascii_digit() { + has_digit = true; + } else if character == '.' { + decimal_points += 1; + } else { + return false; + } + } + has_digit && decimal_points <= 1 && !value.starts_with('.') && !value.ends_with('.') +} + +fn is_valid_model_ritardando_plan(value: &str) -> bool { + if value.len() > MAX_RITARDANDO_PLAN_CHARACTERS + || !value.starts_with(RITARDANDO_PLAN_PREFIX) + || !value.ends_with(RITARDANDO_PLAN_SUFFIX) + { + return false; + } + let inner = &value[RITARDANDO_PLAN_PREFIX.len()..value.len() - RITARDANDO_PLAN_SUFFIX.len()]; + let Some((from_bpm, to_bpm)) = inner.split_once(RITARDANDO_PLAN_MIDDLE) else { + return false; + }; + if !is_decimal_bpm_token(from_bpm) || !is_decimal_bpm_token(to_bpm) { + return false; + } + let Ok(from_bpm) = from_bpm.parse::() else { + return false; + }; + let Ok(to_bpm) = to_bpm.parse::() else { + return false; + }; + if !from_bpm.is_finite() || !to_bpm.is_finite() || from_bpm <= 0.0 || to_bpm <= 0.0 { + return false; + } + if to_bpm >= from_bpm { + return false; + } + let ratio = to_bpm / from_bpm; + !(HALF_TIME_RATIO_MIN..=HALF_TIME_RATIO_MAX).contains(&ratio) +} + +fn validate_ritardando_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role + .ritardando_plan + .as_deref() + .is_some_and(|ritardando_plan| { + !is_valid_ritardando_plan(ritardando_plan, role.ritardando_plan_source.as_ref()) + }) + { + return Err("Invalid project file format".to_string()); + } + if role.ritardando_plan.is_none() && role.ritardando_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + if role.ritardando_plan.is_some() && role.ritardando_plan_source.is_none() { + return Err("Invalid project file format".to_string()); + } + if role.ritardando_plan_at_seconds.is_some_and(|time| { + !time.is_finite() || time < 0.0 || time > MAX_SECTION_TIME_SECONDS + }) { + return Err("Invalid project file format".to_string()); + } + if role.ritardando_plan_at_seconds.is_some() + && (role.ritardando_plan.is_none() || role.ritardando_plan_source.is_none()) + { + return Err("Invalid project file format".to_string()); + } + } + } + Ok(payload) +} + pub fn project_payload_from_content(content: &str) -> Result { if let Ok(parsed) = serde_json::from_str::(content) { - return Ok(parsed); + return validate_ritardando_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +743,9 @@ pub fn project_payload_from_content(content: &str) -> Result(payload) + .expect("optional tempo should deserialize in Tauri"); + let serialized = + serde_json::to_value(&parsed).expect("tempo-bearing song should serialize back"); + + assert_eq!(serialized["tempo"], json!(128.5)); + } + + #[test] + fn rehearsal_song_payload_rejects_invalid_tempo_values() { + for tempo in [json!(null), json!(0), json!(-1), json!("128")] { + let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); + payload["tempo"] = tempo; + + assert!(serde_json::from_value::(payload).is_err()); + } + } + + #[test] + fn analysis_job_status_round_trips_tempo_in_result() { + let mut result = shared_contract_payload(json!({ "start": 10, "end": 30 })); + result["tempo"] = json!(128.5); + let status = json!({ + "jobId": "job-1", + "state": "succeeded", + "requestedAt": "2026-03-12T00:00:00Z", + "updatedAt": "2026-03-12T00:00:01Z", + "result": result + }); + + let parsed = serde_json::from_value::(status) + .expect("analysis status with tempo should deserialize"); + let serialized = serde_json::to_value(parsed).expect("analysis status should serialize"); + + assert_eq!(serialized["result"]["tempo"], json!(128.5)); + } + #[test] fn rehearsal_song_payload_round_trips_score_attachments() { let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 })); diff --git a/apps/desktop/core/tests/ritardando_plan_contract.rs b/apps/desktop/core/tests/ritardando_plan_contract.rs new file mode 100644 index 000000000..7d8c97d44 --- /dev/null +++ b/apps/desktop/core/tests/ritardando_plan_contract.rs @@ -0,0 +1,215 @@ +use bandscope_desktop_core::project_payload_from_content; +use serde_json::{json, Value}; + +fn song_with_ritardando_plan() -> Value { + json!({ + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Lifted chorus downbeat", + "timeRange": { "start": 0, "end": 16 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Tempo stability corroborates the ritardando." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "harmonicExplanation": "The landing keeps the tonal floor clear.", + "cue": { + "kind": "transition", + "value": "Let the next downbeat land later." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays while the tempo eases." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "transpositionPlan": "Keep the landing shape a whole step lower if needed.", + "manualOverrides": [], + "overlapWarnings": [], + "transcription": [{ + "pitch": "C#4", + "onset": 1.0, + "offset": 1.5, + "velocity": 0.8 + }], + "practiceProgress": 50, + "ritardandoPlan": "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later.", + "ritardandoPlanSource": "model", + "ritardandoPlanAtSeconds": 12.375 + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Ease into the slower chorus landing.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_ritardando_plan_provenance() { + let payload = song_with_ritardando_plan(); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project contract must accept shared ritardando-plan fields"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlan"], + payload["sections"][0]["roles"][0]["ritardandoPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlanSource"], + json!("model") + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlanAtSeconds"], + json!(12.375) + ); +} + +#[test] +fn project_contract_rejects_ritardando_plan_source_without_ritardando_plan() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("ritardandoPlan"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance without the value it describes" + ); +} + +#[test] +fn project_contract_rejects_ritardando_plan_without_source() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("ritardandoPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject ritardando-plan copy without provenance" + ); +} + +#[test] +fn project_contract_rejects_invalid_ritardando_plan_copy_with_source() { + for ritardando_plan in [ + "", + " ", + "\u{00A0}\u{2003}\u{3000}", + "ease here\nthen hold", + "ease here\rthen hold", + "ease here\u{0085}then hold", + "ease here\u{2028}then hold", + "ease here\u{2029}then hold", + ] { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlan"] = json!(ritardando_plan); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject blank or multiline sourced ritardando-plan copy" + ); + } +} + +#[test] +fn project_contract_preserves_padded_single_line_ritardando_copy() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlan"] = + json!(" Ease the phrase late. \u{00A0}"); + payload["sections"][0]["roles"][0]["ritardandoPlanSource"] = json!("user"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native persisted contract must preserve padded single-line copy"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["ritardandoPlan"], + payload["sections"][0]["roles"][0]["ritardandoPlan"] + ); +} + +#[test] +fn project_contract_rejects_invalid_model_ritardando_tempo_semantics() { + for ritardando_plan in [ + "Ease this part from 80 BPM into 120 BPM; let the next downbeat land later.", + "Ease this part from 120 BPM into 60 BPM; let the next downbeat land later.", + "Ease this part from 0 BPM into 80 BPM; let the next downbeat land later.", + "Use this model plan instead.", + ] { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlan"] = json!(ritardando_plan); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject semantically invalid model ritardando copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_ritardando_plan_source() { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlanSource"] = json!("legacy"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject provenance outside model/user" + ); +} + +#[test] +fn project_contract_rejects_invalid_ritardando_plan_timing() { + for time in [-1.0, 4_294_967_296.0] { + let mut payload = song_with_ritardando_plan(); + payload["sections"][0]["roles"][0]["ritardandoPlanAtSeconds"] = json!(time); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!(project_payload_from_content(&content).is_err()); + } +} diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx new file mode 100644 index 000000000..7f7328e72 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.particle.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstRitardandoCallout } from "./FirstRitardandoCallout"; + +function songWithKoreanRit() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.roles = [ + { + ...verse.roles[0]!, + id: "piano-vocal", + name: "피아노", + roleType: "vocal", + rehearsalPriority: "high", + ritardandoPlan: + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later.", + ritardandoPlanSource: "model" + } + ]; + verse.partGraph = [ + { role_id: "piano-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + return song; +} + +describe("FirstRitardandoCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the rit action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanRit(); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:10 벌스에서 피아노 파트가 리타르단도합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 리타르단도 열기" })); + + expect( + screen.getByText("0:10에서 피아노 파트와 함께 늦추세요. 더 느린 착지가 들리도록 맞추세요.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx new file mode 100644 index 000000000..52de12edb --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstRitardandoCallout } from "./FirstRitardandoCallout"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; +const appendedSongStructureTargets = new Set(); + +function songWithRitardandoPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = verse.roles.find((role) => role.id === "lead-vocal")!; + vocal.ritardandoPlan = DEMO_RITARDANDO_PLAN; + vocal.ritardandoPlanSource = "model"; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstRitardandoCallout", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "No ritardando plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithRitardandoPlan(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithRitardandoPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText( + "No ritardando plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("opens the named rit on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + expect(screen.getByText(DEMO_RITARDANDO_PLAN)).toBeTruthy(); + }); + + it("shows armed confirmation for user-sourced plans without rewriting user copy", () => { + const song = songWithRitardandoPlan(); + const userPlan = "Ease here exactly as our band agreed."; + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + vocal.ritardandoPlan = userPlan; + vocal.ritardandoPlanSource = "user"; + appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + expect(screen.getByText(userPlan)).toBeTruthy(); + }); + + it("reports when the map section cannot be opened", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect( + screen.getByText("Could not open this rit on the song map. Use the map below to find the section.") + ).toBeTruthy(); + }); + + it("uses immediate scrolling when reduced motion is requested", () => { + const { scrollIntoView } = appendSongStructureTarget(); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); + + it("resets armed guidance when accessor-id songs change with the same rit signature", () => { + const firstSong = songWithRitardandoPlan(); + const nextSong = songWithRitardandoPlan(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal eases the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithRitardandoPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.name = "Lead Singer"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible./) + ).toBeTruthy(); + + rerender( + + ); + + expect(screen.getByText("Lead Singer eases the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Ease Lead Singer together at 0:10 so the slower landing is audible./) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx new file mode 100644 index 000000000..0f7b41d88 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstRitardandoCallout.tsx @@ -0,0 +1,224 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { + formatRitardandoPlanTime, + resolveFirstRitardandoPlan, + type RitardandoPlanGuidance +} from "./firstRitardando"; + +/** Props for the first ritardando-plan rehearsal callout. */ +export interface FirstRitardandoCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type RitardandoPlanCopyValues = Readonly>; +type RitardandoPlanSource = "model" | "user"; + +type OpenedRitardandoPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + ritardandoPlan: string; + ritardandoPlanSource: RitardandoPlanSource | null; + fromBpm: string | null; + toBpm: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableRitardandoPlanSongIdentity( + song: RehearsalSong, + workspaceInstanceKey: unknown +): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate ritardando-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatRitardandoPlanCopy(template: string, values: RitardandoPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof RitardandoPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model ritardando guidance from structured tempo tokens, never from display-copy grammar. */ +function localizedRitardandoPlan( + ritardandoPlan: string, + ritardandoPlanSource: RitardandoPlanSource | null, + guidance: RitardandoPlanGuidance | null, + generatedTemplate: string +): string { + if (ritardandoPlanSource !== "model" || guidance === null) { + return ritardandoPlan; + } + return generatedTemplate + .replace("{from}", () => guidance.fromBpm) + .replace("{to}", () => guidance.toBpm); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredRitardandoPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveRitardandoPlanRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first ritardando plan and open the matching rendered map section. */ +export function FirstRitardandoCallout({ + song, + workspaceInstanceKey +}: FirstRitardandoCalloutProps) { + const calloutId = `workspace-surface-ritardando-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableRitardandoPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstRitardandoPlan(song), [song]); + const [openedRitardandoPlan, setOpenedRitardandoPlan] = useState( + null + ); + const [navigationFailed, setNavigationFailed] = useState(false); + const fromBpm = named?.ritardandoPlanGuidance?.fromBpm ?? null; + const toBpm = named?.ritardandoPlanGuidance?.toBpm ?? null; + + useEffect(() => { + setOpenedRitardandoPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.ritardandoPlan, + named?.ritardandoPlanSource, + fromBpm, + toBpm, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedRitardandoPlan !== null && + openedRitardandoPlan.songIdentity === songIdentity && + openedRitardandoPlan.sectionId === named.sectionId && + openedRitardandoPlan.sectionIndex === named.sectionIndex && + openedRitardandoPlan.sectionLabel === named.sectionLabel && + openedRitardandoPlan.landingRoleId === named.landingRoleId && + openedRitardandoPlan.landingRoleName === named.landingRoleName && + openedRitardandoPlan.ritardandoPlan === named.ritardandoPlan && + openedRitardandoPlan.ritardandoPlanSource === named.ritardandoPlanSource && + openedRitardandoPlan.fromBpm === fromBpm && + openedRitardandoPlan.toBpm === toBpm && + openedRitardandoPlan.atSeconds === named.atSeconds; + const at = formatRitardandoPlanTime(named.atSeconds); + const copyValues: RitardandoPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatRitardandoPlanCopy(t("firstRitardandoPlanOpenAction"), copyValues); + const body = formatRitardandoPlanCopy(t("firstRitardandoPlanBody"), copyValues); + const armed = formatRitardandoPlanCopy(t("firstRitardandoPlanArmed"), copyValues); + const ritardandoPlan = localizedRitardandoPlan( + named.ritardandoPlan, + named.ritardandoPlanSource, + named.ritardandoPlanGuidance, + t("firstRitardandoPlanGeneratedGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx new file mode 100644 index 000000000..0033ded5b --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.ritardando-state.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView" +); + +function analyzedSongWithRitardandoPlan(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = verse.roles.find((role) => role.id === "lead-vocal")!; + vocal.ritardandoPlan = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + vocal.ritardandoPlanSource = "model"; + return song; +} + +describe("Workspace ritardando state authority", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + }); + + it("keeps an opened rit armed after an immutable edit and role switch", () => { + const song = analyzedSongWithRitardandoPlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).toBeTruthy(); + }); + + it("resets armed guidance when a new song arrives", () => { + const song = analyzedSongWithRitardandoPlan(); + const nextSong = structuredClone(song); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal rit at 0:10" })); + expect( + screen.getByText(/Ease Lead Vocal together at 0:10 so the slower landing is audible\./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal eases the verse at 0:10.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..9814ffbdd 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, useRef, useEffect, memo, 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 { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { FirstRitardandoCallout } from "./FirstRitardandoCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

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

@@ -122,6 +127,22 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const localSongUpdateRef = useRef(null); + const workspaceInstanceRef = useRef(song); + const previousSongRef = useRef(song); + const isLocalWorkspaceUpdate = song === localSongUpdateRef.current; + const isExternalWorkspaceUpdate = song !== previousSongRef.current && !isLocalWorkspaceUpdate; + const workspaceInstanceKey = isExternalWorkspaceUpdate ? song : workspaceInstanceRef.current; + + useEffect(() => { + if (song !== previousSongRef.current) { + if (!isLocalWorkspaceUpdate) { + workspaceInstanceRef.current = song; + } + localSongUpdateRef.current = null; + previousSongRef.current = song; + } + }, [isLocalWorkspaceUpdate, song]); // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -164,6 +185,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ) : t("workspaceFirstRangeMissing"); + /** Preserve workspace-instance authority for immutable edits emitted by this workspace. */ + const commitSongUpdate = (nextSong: RehearsalSong) => { + if (!onSongUpdate) return; + localSongUpdateRef.current = nextSong; + onSongUpdate(nextSong); + }; + /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { if (!activeRole || !onSongUpdate) return; @@ -188,7 +216,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) }; - onSongUpdate(nextSong); + commitSongUpdate(nextSong); }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), @@ -309,6 +337,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
@@ -505,7 +537,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/firstRitardando.test.ts b/apps/desktop/src/features/workspace/firstRitardando.test.ts new file mode 100644 index 000000000..224568802 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRitardando.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatRitardandoPlanTime, resolveFirstRitardandoPlan } from "./firstRitardando"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + +function withRitardandoSection( + overrides: { + id?: string; + start?: number; + end?: number; + ritardandoPlan?: string; + ritardandoPlanAtSeconds?: number; + source?: "model" | "user"; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + roleType?: "instrument" | "vocal" | "hand"; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 0; + const roleId = overrides.roleId ?? "lead-vocal"; + const vocal = structuredClone(verse.roles.find((role) => role.id === "lead-vocal")!); + const bass = structuredClone(verse.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(verse.roles.find((role) => role.id === "keys-right")!); + const landing = { + ...(roleId === "bass-guitar" ? bass : roleId === "keys-right" ? keys : vocal), + id: roleId, + name: + overrides.roleName ?? + (roleId === "bass-guitar" + ? "Bass Guitar" + : roleId === "keys-right" + ? "Keyboard 1 Right Hand" + : "Lead Vocal"), + roleType: overrides.roleType ?? (roleId === "lead-vocal" ? "vocal" : "instrument"), + rehearsalPriority: overrides.priority ?? "high", + ritardandoPlan: overrides.ritardandoPlan ?? DEMO_RITARDANDO_PLAN, + ...(overrides.ritardandoPlanAtSeconds !== undefined + ? { ritardandoPlanAtSeconds: overrides.ritardandoPlanAtSeconds } + : {}), + ...(overrides.source ? { ritardandoPlanSource: overrides.source } : { ritardandoPlanSource: "model" as const }) + }; + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-rit"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 16 }; + current.roles = [landing, bass, keys]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { + role_id: "lead-vocal", + is_active: roleId === "lead-vocal" ? (overrides.isActive ?? true) : true, + handoff_to: [], + handoff_from: [] + } + ]; + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.roles = [landing, vocal, keys]; + } + if (roleId === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.roles = [landing, vocal, bass]; + } + song.sections = [current]; + return song; +} + +describe("resolveFirstRitardandoPlan", () => { + it("picks the earliest ritardando plan and the named vocal that owns it", () => { + const resolved = resolveFirstRitardandoPlan(withRitardandoSection()); + expect(resolved?.section.id).toBe("chorus-rit"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.ritardandoPlan).toBe(DEMO_RITARDANDO_PLAN); + expect(resolved?.atSeconds).toBe(0); + expect(formatRitardandoPlanTime(resolved?.atSeconds ?? -1)).toBe("0:00"); + expect(formatRitardandoPlanTime(Number.NaN)).toBe("0:00"); + expect(formatRitardandoPlanTime(-4)).toBe("0:00"); + }); + + it("uses the detected tempo-change time instead of the section start", () => { + const resolved = resolveFirstRitardandoPlan( + withRitardandoSection({ start: 10, ritardandoPlanAtSeconds: 12.375 }) + ); + expect(resolved?.atSeconds).toBe(12.375); + }); + + it.each([Number.NaN, -1, 4_294_967_296])( + "rejects malformed ritardando-plan timing %s", + (ritardandoPlanAtSeconds) => { + expect( + resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlanAtSeconds })) + ).toBeNull(); + } + ); + + it("does not invent a ritardando plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, or confidence notes", () => { + const song = withRitardandoSection(); + delete song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.ritardandoPlan; + const landing = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + landing.simplification = "Stay on roots if the chorus entrance gets muddy."; + landing.setupNote = DEMO_RITARDANDO_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + landing.cue = { kind: "transition", value: DEMO_RITARDANDO_PLAN }; + landing.confidence.notes = DEMO_RITARDANDO_PLAN; + expect(resolveFirstRitardandoPlan(song)).toBeNull(); + }); + + it("leaves the heuristic demo unnamed", () => { + expect(resolveFirstRitardandoPlan(createDemoRehearsalSong())).toBeNull(); + }); + + it("does not let accompaniment own the ritardando", () => { + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ roleId: "keys-right", roleType: "hand" }) + ) + ).toBeNull(); + }); + + it("ignores inactive named parts", () => { + expect(resolveFirstRitardandoPlan(withRitardandoSection({ isActive: false }))).toBeNull(); + }); + + it("prefers a named vocal over bass at the same priority", () => { + const song = withRitardandoSection(); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.ritardandoPlan = DEMO_RITARDANDO_PLAN; + bass.ritardandoPlanSource = "model"; + bass.rehearsalPriority = "high"; + expect(resolveFirstRitardandoPlan(song)?.landingRoleId).toBe("lead-vocal"); + }); + + it("prefers the higher-priority named part", () => { + const song = withRitardandoSection({ priority: "low" }); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + bass.ritardandoPlan = DEMO_RITARDANDO_PLAN; + bass.ritardandoPlanSource = "model"; + bass.rehearsalPriority = "high"; + expect(resolveFirstRitardandoPlan(song)?.landingRoleId).toBe("bass-guitar"); + }); + + it("picks the earlier section when two rits are named", () => { + const earlier = withRitardandoSection({ id: "earlier-rit", start: 0 }); + const later = withRitardandoSection({ id: "later-rit", start: 16 }); + const song = earlier; + song.sections = [...earlier.sections, ...later.sections]; + expect(resolveFirstRitardandoPlan(song)?.sectionId).toBe("earlier-rit"); + }); + + it("rejects blank, multiline, or non-template model copy", () => { + expect(resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlan: " " }))).toBeNull(); + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ ritardandoPlan: "Ease together.\nHold the count." }) + ) + ).toBeNull(); + expect( + resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlan: "slow down here" })) + ).toBeNull(); + }); + + it("rejects model copy that is not a genuine non-half-time slowing", () => { + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ + ritardandoPlan: + "Ease this part from 80 BPM into 120 BPM; let the next downbeat land later." + }) + ) + ).toBeNull(); + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ + ritardandoPlan: + "Ease this part from 120 BPM into 60 BPM; let the next downbeat land later." + }) + ) + ).toBeNull(); + expect(resolveFirstRitardandoPlan(withRitardandoSection())?.ritardandoPlan).toBe( + DEMO_RITARDANDO_PLAN + ); + }); + + it("rejects oversized generated copy before parsing tempo tokens", () => { + expect( + resolveFirstRitardandoPlan( + withRitardandoSection({ + ritardandoPlan: `Ease this part from ${"0".repeat(180)}120 BPM into 80 BPM; let the next downbeat land later.` + }) + ) + ).toBeNull(); + }); + + it("admits bounded user copy without requiring the engine template", () => { + const resolved = resolveFirstRitardandoPlan( + withRitardandoSection({ + ritardandoPlan: "Pull the phrase late into the downbeat.", + source: "user" + }) + ); + expect(resolved?.ritardandoPlan).toBe("Pull the phrase late into the downbeat."); + expect(resolved?.ritardandoPlanSource).toBe("user"); + }); + + it("preserves long user-authored ritardando copy verbatim", () => { + const ritardandoPlan = `${"Ease the phrase late. ".repeat(20)}Keep the landing clear.`; + expect( + resolveFirstRitardandoPlan(withRitardandoSection({ ritardandoPlan, source: "user" })) + ?.ritardandoPlan + ).toBe(ritardandoPlan); + }); + + it("rejects plan copy without explicit provenance", () => { + const song = withRitardandoSection(); + delete song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!.ritardandoPlanSource; + expect(resolveFirstRitardandoPlan(song)).toBeNull(); + }); + + it("fails closed on a malformed runtime song root", () => { + expect(resolveFirstRitardandoPlan(null as never)).toBeNull(); + }); + + 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(resolveFirstRitardandoPlan(song)).toBeNull(); + }); + + it("fails closed on inherited or accessor-backed plan copy", () => { + const song = withRitardandoSection(); + const vocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + delete vocal.ritardandoPlan; + Object.defineProperty(vocal, "ritardandoPlan", { + configurable: true, + enumerable: true, + get() { + return DEMO_RITARDANDO_PLAN; + } + }); + expect(resolveFirstRitardandoPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRitardando.ts b/apps/desktop/src/features/workspace/firstRitardando.ts new file mode 100644 index 000000000..ec2207a54 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRitardando.ts @@ -0,0 +1,446 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + isNonEmptySingleLineText, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_RITARDANDO_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const NAMED_RITARDANDO_ROLE_IDS = new Set(["bass-guitar", "lead-vocal"]); +const RITARDANDO_PLAN_PREFIX = "Ease this part from "; +const RITARDANDO_PLAN_MIDDLE = " BPM into "; +const RITARDANDO_PLAN_SUFFIX = " BPM; let the next downbeat land later."; +const HALF_TIME_RATIO_MIN = 0.45; +const HALF_TIME_RATIO_MAX = 0.55; + +type RitardandoPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated ritardando-plan copy. */ +export type RitardandoPlanGuidance = Readonly<{ + kind: "tempo"; + fromBpm: string; + toBpm: string; +}>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; + isVocal: boolean; +}>; + +type OwnedRitardandoPlan = Readonly<{ + text: string; + source: RitardandoPlanSource | null; + guidance: RitardandoPlanGuidance | null; + atSeconds: number | null; +}>; + +/** Tonight's first ritardando plan: the earliest corroborated slowing on a named vocal or bass. */ +export type FirstRitardandoPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + ritardandoPlan: string; + ritardandoPlanSource: RitardandoPlanSource | null; + ritardandoPlanGuidance: RitardandoPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative ritardando-plan time as m:ss for rehearsal copy. */ +export function formatRitardandoPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + 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) || !hasOwnData(value, key)) { + return null; + } + items.push(ownDataValue(value, key)); + } + return items; +} + +/** Preserve the engine ritardando template while enforcing the engine's slowing semantics. */ +function boundedGeneratedRitardandoPlan(value: string): OwnedRitardandoPlan | null { + if (value.length > MAX_RITARDANDO_PLAN_CHARACTERS) { + return null; + } + if ( + !value.startsWith(RITARDANDO_PLAN_PREFIX) || + !value.endsWith(RITARDANDO_PLAN_SUFFIX) || + !value.includes(RITARDANDO_PLAN_MIDDLE) + ) { + return null; + } + const inner = value.slice(RITARDANDO_PLAN_PREFIX.length, -RITARDANDO_PLAN_SUFFIX.length); + const middleIndex = inner.indexOf(RITARDANDO_PLAN_MIDDLE); + if (middleIndex <= 0) { + return null; + } + const fromBpm = inner.slice(0, middleIndex); + const toBpm = inner.slice(middleIndex + RITARDANDO_PLAN_MIDDLE.length); + if (!/^\d+(?:\.\d+)?$/u.test(fromBpm) || !/^\d+(?:\.\d+)?$/u.test(toBpm)) { + return null; + } + const fromBpmValue = Number(fromBpm); + const toBpmValue = Number(toBpm); + if ( + !Number.isFinite(fromBpmValue) || + !Number.isFinite(toBpmValue) || + fromBpmValue <= 0 || + toBpmValue <= 0 || + toBpmValue >= fromBpmValue + ) { + return null; + } + const ratio = toBpmValue / fromBpmValue; + if (ratio >= HALF_TIME_RATIO_MIN && ratio <= HALF_TIME_RATIO_MAX) { + return null; + } + return { + text: `${RITARDANDO_PLAN_PREFIX}${fromBpm}${RITARDANDO_PLAN_MIDDLE}${toBpm}${RITARDANDO_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "tempo", fromBpm, toBpm }, + atSeconds: null + }; +} + +/** Return a bounded snapshotted own ritardando plan and its explicit provenance, or null when malformed. */ +function ownedRitardandoPlan(role: unknown): OwnedRitardandoPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const ritardandoPlan = ownDataValue(role, "ritardandoPlan"); + const ritardandoPlanSource = ownDataValue(role, "ritardandoPlanSource"); + const ritardandoPlanAtSeconds = ownDataValue(role, "ritardandoPlanAtSeconds"); + if (typeof ritardandoPlan !== "string") { + return null; + } + if ( + ritardandoPlanSource !== undefined && + ritardandoPlanSource !== "model" && + ritardandoPlanSource !== "user" + ) { + return null; + } + if (ritardandoPlanSource === undefined) { + return null; + } + if (!isNonEmptySingleLineText(ritardandoPlan)) { + return null; + } + if ( + ritardandoPlanAtSeconds !== undefined && + (typeof ritardandoPlanAtSeconds !== "number" || + !Number.isFinite(ritardandoPlanAtSeconds) || + ritardandoPlanAtSeconds < 0 || + ritardandoPlanAtSeconds > MAX_SECTION_TIME_SECONDS) + ) { + return null; + } + if (ritardandoPlanSource === "model") { + const trimmed = ritardandoPlan.trim(); + const bounded = boundedGeneratedRitardandoPlan(trimmed); + return bounded === null + ? null + : { ...bounded, atSeconds: (ritardandoPlanAtSeconds as number | undefined) ?? null }; + } + return { + text: ritardandoPlan, + source: ritardandoPlanSource, + guidance: null, + atSeconds: (ritardandoPlanAtSeconds as number | undefined) ?? null + }; +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const roleType = ownDataValue(role, "roleType"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + if (!NAMED_RITARDANDO_ROLE_IDS.has(id) && roleType !== "vocal") { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK, + isVocal: roleType === "vocal" || id === "lead-vocal" + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Rank vocal roles before instrumental roles. */ +function vocalRank(role: RankedRoleMetadata): number { + return role.isVocal ? 0 : 1; +} + +/** Prefer rehearsal priority, then a named vocal, then a locale-independent stable id. */ +function pickLandingRole(roles: Role[]): Role | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + const vocalDelta = vocalRank(left) - vocalRank(right); + if (vocalDelta !== 0) { + return vocalDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return unique graph role ids whose node is explicitly active. */ +function rankedActiveRoleIds(section: RehearsalSection): Set { + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!partGraph) { + return new Set(); + } + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + return new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); +} + +/** Resolve a ritardando plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstRitardandoPlan(song: RehearsalSong): FirstRitardandoPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const activeIds = rankedActiveRoleIds(section as RehearsalSection); + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + if (!roles) { + return []; + } + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const landingRole = pickLandingRole( + roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + if ( + metadata === null || + repeatedRoleIds.has(metadata.id) || + !activeIds.has(metadata.id) + ) { + return []; + } + const ritardandoPlan = ownedRitardandoPlan(metadata.role); + return ritardandoPlan === null + ? [] + : [ + { + ...metadata, + ritardandoPlan: ritardandoPlan.text, + ritardandoPlanSource: ritardandoPlan.source, + ritardandoPlanGuidance: ritardandoPlan.guidance, + atSeconds: ritardandoPlan.atSeconds + } + ]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + ritardandoPlan: landingRole.ritardandoPlan, + ritardandoPlanSource: landingRole.ritardandoPlanSource, + ritardandoPlanGuidance: landingRole.ritardandoPlanGuidance, + atSeconds: landingRole.atSeconds ?? timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named ritardando plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstRitardandoPlan(song: RehearsalSong): FirstRitardandoPlan | null { + try { + return resolveSafeFirstRitardandoPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..e0c767882 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,12 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes supported section labels and fails closed on unknown labels", () => { + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("en", "not-a-section" as never)).toBe("not-a-section"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..1ec65678e 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,12 @@ "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", "sectionRangeLabel": "Range", - "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." + "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.", + "firstRitardandoPlanLabel": "Tonight's first ritardando plan", + "firstRitardandoPlanOpenAction": "Open {role} rit at {at}", + "firstRitardandoPlanBody": "{role} eases the {section} at {at}.", + "firstRitardandoPlanArmed": "Ease {role} together at {at} so the slower landing is audible.", + "firstRitardandoPlanGeneratedGuidance": "Ease this part from {from} BPM into {to} BPM; let the next downbeat land later.", + "firstRitardandoPlanUnavailable": "No ritardando plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstRitardandoPlanNavigationFailed": "Could not open this rit on the song map. Use the map below to find the section." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..7c3ce0675 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,12 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstRitardandoPlanLabel": "오늘 첫 리타르단도 계획", + "firstRitardandoPlanOpenAction": "{at} {role} 리타르단도 열기", + "firstRitardandoPlanBody": "{at} {section}에서 {role} 파트가 리타르단도합니다.", + "firstRitardandoPlanArmed": "{at}에서 {role} 파트와 함께 늦추세요. 더 느린 착지가 들리도록 맞추세요.", + "firstRitardandoPlanGeneratedGuidance": "이 파트를 {from} BPM에서 {to} BPM으로 늦추세요. 다음 다운비트가 더 늦게 오도록 맞추세요.", + "firstRitardandoPlanUnavailable": "사용 가능한 리타르단도 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstRitardandoPlanNavigationFailed": "곡 맵에서 이 리타르단도를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..76ddd3dca 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -15,6 +15,11 @@ const SECTION_FORM_LABELS = [ ] as const; export /** Documented. */ const MAX_SECTION_TIME_SECONDS = 4_294_967_295; +const MAX_RITARDANDO_PLAN_CHARACTERS = 180; +const RITARDANDO_PLAN_PREFIX = "Ease this part from "; +const RITARDANDO_PLAN_SUFFIX = " BPM; let the next downbeat land later."; +const HALF_TIME_RATIO_MIN = 0.45; +const HALF_TIME_RATIO_MAX = 0.55; /** Documented. */ export type SectionFormLabel = (typeof SECTION_FORM_LABELS)[number]; @@ -143,6 +148,9 @@ export type RehearsalRole = { overlapWarnings: string[]; transcription?: TranscriptionNote[]; practiceProgress?: number; + ritardandoPlan?: string; + ritardandoPlanSource?: ProvenanceSource; + ritardandoPlanAtSeconds?: number; }; /** Documented. */ @@ -407,6 +415,77 @@ function isOneOf(options: readonly T[], value: unknown): value return typeof value === "string" && options.includes(value as T); } +/** Return whether a plan is non-empty and contains no Unicode line separator. */ +export function isNonEmptySingleLineText(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + let hasNonWhitespace = false; + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint === 0x000a || + codePoint === 0x000d || + codePoint === 0x0085 || + codePoint === 0x2028 || + codePoint === 0x2029 + ) { + return false; + } + if (!( + (codePoint >= 0x0009 && codePoint <= 0x000d) || + codePoint === 0x0020 || + codePoint === 0x0085 || + codePoint === 0x00a0 || + codePoint === 0x1680 || + (codePoint >= 0x2000 && codePoint <= 0x200a) || + codePoint === 0x2028 || + codePoint === 0x2029 || + codePoint === 0x202f || + codePoint === 0x205f || + codePoint === 0x3000 || + codePoint === 0xfeff + )) { + hasNonWhitespace = true; + } + } + return hasNonWhitespace; +} + +/** Return whether model ritardando copy preserves the engine's tempo semantics. */ +function isValidModelRitardandoPlan(value: string): boolean { + if (value.length > MAX_RITARDANDO_PLAN_CHARACTERS) { + return false; + } + const trimmed = value.trim(); + if ( + !trimmed.startsWith(RITARDANDO_PLAN_PREFIX) || + !trimmed.endsWith(RITARDANDO_PLAN_SUFFIX) + ) { + return false; + } + const inner = trimmed.slice(RITARDANDO_PLAN_PREFIX.length, -RITARDANDO_PLAN_SUFFIX.length); + const match = inner.match(/^(\d+(?:\.\d+)?) BPM into (\d+(?:\.\d+)?)$/u); + if (!match) { + return false; + } + const fromBpm = Number(match[1]); + const toBpm = Number(match[2]); + if ( + !Number.isFinite(fromBpm) || + !Number.isFinite(toBpm) || + fromBpm <= 0 || + toBpm <= 0 + ) { + return false; + } + if (toBpm >= fromBpm) { + return false; + } + const ratio = toBpm / fromBpm; + return ratio < HALF_TIME_RATIO_MIN || ratio > HALF_TIME_RATIO_MAX; +} + /** Documented. */ function invalidField(path: string): string { return `Invalid rehearsal song contract: invalid field '${path}'`; @@ -1500,7 +1579,10 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "ritardandoPlan", + "ritardandoPlanSource", + "ritardandoPlanAtSeconds" ], path ); @@ -1588,6 +1670,46 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.ritardandoPlan !== undefined && + !isNonEmptySingleLineText(value.ritardandoPlan) + ) { + return invalidField(`${path}.ritardandoPlan`); + } + if ( + value.ritardandoPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.ritardandoPlanSource) + ) { + return invalidField(`${path}.ritardandoPlanSource`); + } + if (value.ritardandoPlanSource !== undefined && value.ritardandoPlan === undefined) { + return invalidField(`${path}.ritardandoPlanSource`); + } + if (value.ritardandoPlan !== undefined && value.ritardandoPlanSource === undefined) { + return invalidField(`${path}.ritardandoPlanSource`); + } + if ( + value.ritardandoPlanSource === "model" && + !isValidModelRitardandoPlan(value.ritardandoPlan!) + ) { + return invalidField(`${path}.ritardandoPlan`); + } + if ( + value.ritardandoPlanAtSeconds !== undefined && + (typeof value.ritardandoPlanAtSeconds !== "number" || + !Number.isFinite(value.ritardandoPlanAtSeconds) || + value.ritardandoPlanAtSeconds < 0 || + value.ritardandoPlanAtSeconds > MAX_SECTION_TIME_SECONDS) + ) { + return invalidField(`${path}.ritardandoPlanAtSeconds`); + } + if ( + value.ritardandoPlanAtSeconds !== undefined && + (value.ritardandoPlan === undefined || value.ritardandoPlanSource === undefined) + ) { + return invalidField(`${path}.ritardandoPlanAtSeconds`); + } + return null; } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..02a037897 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -1257,6 +1257,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].ritardandoPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.ritardandoPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/packages/shared-types/test/ritardandoPlanProvenance.test.ts b/packages/shared-types/test/ritardandoPlanProvenance.test.ts new file mode 100644 index 000000000..118c32153 --- /dev/null +++ b/packages/shared-types/test/ritardandoPlanProvenance.test.ts @@ -0,0 +1,113 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +const DEMO_RITARDANDO_PLAN = + "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later."; + +describe("ritardandoPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s ritardando plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.ritardandoPlanSource).toBe(source); + }); + + it("round-trips the precise tempo-change time", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = "model"; + role.ritardandoPlanAtSeconds = 12.375; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.ritardandoPlanAtSeconds).toBe(12.375); + }); + + it.each([ + "Use this model plan instead.", + "Ease this part from 80 BPM into 120 BPM; let the next downbeat land later.", + "Ease this part from 120 BPM into 60 BPM; let the next downbeat land later.", + "Ease this part from 0 BPM into 80 BPM; let the next downbeat land later.", + "Ease this part from BPM into 80 BPM; let the next downbeat land later." + ])("rejects semantically invalid model ritardando copy %j", (ritardandoPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = ritardandoPlan; + role.ritardandoPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlan/); + }); + + it.each([Number.NaN, -1, 4_294_967_296])("rejects invalid ritardando-plan timing %s", (time) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = "model"; + role.ritardandoPlanAtSeconds = time; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanAtSeconds/); + }); + + it("rejects an unknown ritardando plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + role.ritardandoPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + + it("rejects a ritardando plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.ritardandoPlan; + role.ritardandoPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + + it("rejects ritardando plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = DEMO_RITARDANDO_PLAN; + delete role.ritardandoPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlanSource/); + }); + + it.each([ + "", + " ", + "\u00a0\u2003\u3000", + "ease here\nthen hold", + "ease here\rthen hold", + "ease here\u0085then hold", + "ease here\u2028then hold", + "ease here\u2029then hold" + ])( + "rejects a ritardando plan source with blank or multiline copy %j", + (ritardandoPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = ritardandoPlan; + role.ritardandoPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/ritardandoPlan/); + } + ); + + it("accepts padded single-line ritardando copy without normalizing it", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = " Ease the phrase late. \u00a0"; + role.ritardandoPlanSource = "user"; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.ritardandoPlan).toBe( + role.ritardandoPlan + ); + }); + + it("keeps arbitrary user ritardando copy valid", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.ritardandoPlan = "Try the landing softer and leave room for the next downbeat."; + role.ritardandoPlanSource = "user"; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.ritardandoPlan).toBe( + role.ritardandoPlan + ); + }); +}); diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..504d9c4ab 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -19,12 +19,14 @@ from bandscope_analysis.sections import extract_sections from bandscope_analysis.sections.segmenter import segment_with_boundaries from bandscope_analysis.separation import AudioStemSeparator +from bandscope_analysis.temporal import TemporalAnalyzer +from bandscope_analysis.temporal.ritardando import apply_ritardando_plan, derive_beat_times logger = logging.getLogger(__name__) MAX_SECTION_TIME_SECONDS = 4_294_967_295 -ANALYSIS_CACHE_SCHEMA_VERSION = 1 -FEATURE_CACHE_SCHEMA_VERSION = 1 +ANALYSIS_CACHE_SCHEMA_VERSION = 2 +FEATURE_CACHE_SCHEMA_VERSION = 2 STEM_SEPARATION_TIMEOUT_SECONDS = 20.0 logger = logging.getLogger(__name__) @@ -116,6 +118,9 @@ class RehearsalRolePayload(TypedDict): setupNote: str manualOverrides: list[ManualOverridePayload] overlapWarnings: list[str] + ritardandoPlan: NotRequired[str] + ritardandoPlanSource: NotRequired[Literal["model", "user"]] + ritardandoPlanAtSeconds: NotRequired[float] class PartGraphNodePayload(TypedDict): @@ -196,6 +201,8 @@ class CachedFeaturePayload(TypedDict): separation: dict[str, object] stemKeys: list[str] stemRoleTypes: dict[str, str] + bpm: NotRequired[float] + beatTimes: NotRequired[list[float]] class StemSeparationTimedOut(RuntimeError): @@ -456,6 +463,7 @@ def _build_from_pipeline( }, } _apply_tempo(song, features) + _apply_ritardando(song, mix, sr, features, boundaries) return song @@ -518,6 +526,50 @@ def _apply_tempo(song: RehearsalSong, audio_features: dict[str, Any] | None) -> song["tempo"] = bpm +def _coerce_beat_times(audio_features: dict[str, Any] | None) -> list[float] | None: + """Return finite non-negative beat times from analysis features, or None.""" + if not audio_features: + return None + raw = audio_features.get("beat_times") + if not isinstance(raw, list): + return None + times: list[float] = [] + for item in raw: + if isinstance(item, bool) or not isinstance(item, (int, float)): + return None + value = float(item) + if np.isnan(value) or np.isinf(value) or value < 0: + return None + times.append(value) + return times + + +def _temporal_features_for_request(request: AnalysisJobRequest) -> dict[str, Any]: + """Return original-audio temporal features, or an empty safe fallback.""" + if request["sourceKind"] != "local_audio" or "localSource" not in request: + return {} + try: + features = TemporalAnalyzer().analyze(request["localSource"]["sourcePath"]) + return {"bpm": features["bpm"], "beat_times": features["beat_times"]} + except Exception: + logger.warning("Temporal analysis unavailable; continuing with fallback cues.") + return {} + + +def _apply_ritardando( + song: RehearsalSong, + mix: Any, + sr: int, + audio_features: dict[str, Any] | None, + section_boundaries: list[tuple[float, float]] | None = None, +) -> None: + """Stamp tonight's first ritardando from existing tempo-stability changes.""" + beat_times = _coerce_beat_times(audio_features) + if beat_times is None: + beat_times = derive_beat_times(mix, sr) + apply_ritardando_plan(song, beat_times, section_boundaries) + + def _reconstruct_mix(stems: dict[str, Any]) -> Any: """Reconstruct a mono mix from separated stems for segmentation.""" arrays = [] @@ -613,7 +665,7 @@ def _analysis_cache_path(request: AnalysisJobRequest) -> Path | None: digest = hashlib.sha256( json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() - return Path(cache_root) / "analysis-cache-v1" / f"{digest}.json" + return Path(cache_root) / "analysis-cache-v2" / f"{digest}.json" def _feature_cache_paths(request: AnalysisJobRequest) -> tuple[Path, Path] | None: @@ -758,7 +810,7 @@ def _load_cached_local_audio_features( except (OSError, ValueError): return None - return { + loaded: dict[str, Any] = { "stems": stems, "sr": metadata_payload["sampleRate"], "stem_role_types": stem_role_types, @@ -768,6 +820,12 @@ def _load_cached_local_audio_features( "notes": separation.get("notes"), }, } + bpm = _coerce_tempo_bpm(metadata_payload.get("bpm")) + beat_times = _coerce_beat_times({"beat_times": metadata_payload.get("beatTimes")}) + if bpm is not None and beat_times is not None: + loaded["bpm"] = float(bpm) + loaded["beat_times"] = beat_times + return loaded def _serialize_stem_arrays(stems: object) -> dict[str, np.ndarray] | None: @@ -828,6 +886,11 @@ def _store_cached_local_audio_features( "stemKeys": stem_keys, "stemRoleTypes": stem_role_types, } + bpm = _coerce_tempo_bpm(audio_features.get("bpm")) + beat_times = _coerce_beat_times(audio_features) + if bpm is not None and beat_times is not None: + metadata_payload["bpm"] = float(bpm) + metadata_payload["beatTimes"] = beat_times try: metadata_path.parent.mkdir(parents=True, exist_ok=True) metadata_temp = metadata_path.with_name(f"{metadata_path.name}.tmp") @@ -876,6 +939,8 @@ def _stem_separation_worker( }, "stemKeys": stem_keys, "stemRoleTypes": stem_role_types, + "bpm": separation_result["bpm"], + "beatTimes": separation_result["beat_times"], }, ) ) @@ -988,6 +1053,8 @@ def _run_stem_separation_with_timeout( "separation": payload.get("separation"), "stemKeys": payload.get("stemKeys"), "stemRoleTypes": payload.get("stemRoleTypes"), + "bpm": payload.get("bpm"), + "beatTimes": payload.get("beatTimes"), } arrays_output_path = Path(str(payload.get("arraysPath", ""))) metadata_temp = arrays_output_path.with_suffix(".json") @@ -1036,6 +1103,8 @@ def _build_local_audio_features(request: AnalysisJobRequest) -> dict[str, Any] | "chunk_count": separation_result["chunk_count"], "notes": separation_result["separation_notes"], }, + "bpm": separation_result.get("bpm"), + "beat_times": separation_result.get("beat_times"), } @@ -1180,6 +1249,14 @@ def run_analysis_job_updates( ) return updates + if audio_features is not None and ( + _coerce_tempo_bpm(audio_features.get("bpm")) is None + or _coerce_beat_times(audio_features) is None + ): + temporal_features = _temporal_features_for_request(request) + if temporal_features: + audio_features = {**audio_features, **temporal_features} + updates.append( _build_job_status( job_id=job_id, diff --git a/services/analysis-engine/src/bandscope_analysis/cli.py b/services/analysis-engine/src/bandscope_analysis/cli.py index 6838ee711..af50a43ee 100644 --- a/services/analysis-engine/src/bandscope_analysis/cli.py +++ b/services/analysis-engine/src/bandscope_analysis/cli.py @@ -8,7 +8,6 @@ from datetime import UTC, datetime from bandscope_analysis.api import get_analysis_status, run_analysis_job, run_analysis_job_updates -from bandscope_analysis.temporal import TemporalAnalyzer logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") @@ -75,28 +74,6 @@ def main() -> int: request = payload.get("request") - # Temporary: Inject temporal analyzer call if it's a local file, just to prove it works - # before full orchestrator integration - if ( - isinstance(request, dict) - and request.get("sourceKind") == "local_audio" - and "localSource" in request - ): - local_source = request["localSource"] - audio_path = local_source.get("sourcePath") - file_name = local_source.get("fileName", "selected audio") - if audio_path: - logging.info("Extracting temporal features from %s...", file_name) - try: - temporal_analyzer = TemporalAnalyzer() - features = temporal_analyzer.analyze(audio_path) - logging.info(f"Extracted BPM: {features['bpm']}") - except Exception: - logging.warning( - "Temporal analysis failed for %s; continuing with safe fallback.", - file_name, - ) - requested_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") if progress_jsonl: for update in run_analysis_job_updates(job_id, request, requested_at): diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..a34e9b0ac 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -83,6 +83,9 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + ritardandoPlan: NotRequired[str] + ritardandoPlanSource: NotRequired[Literal["model", "user"]] + ritardandoPlanAtSeconds: NotRequired[float] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py index c36e0f1fc..12c1a3fb8 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/audio_separator.py @@ -80,12 +80,18 @@ def __init__(self, config: AudioSeparationConfig | None = None) -> None: self._model: Any = None def separate(self, audio_path: str | Path) -> AudioSeparationResult: - """Separate local audio into vocals, bass, drums, and other stems.""" + """Separate local audio and retain temporal evidence from the same decode.""" path = self._resolve_audio_file(audio_path) audio, sample_rate = self._load_audio(path) if audio.size == 0: raise ValueError(f"Stem separation decode failed for {path.name}") + tempo, beat_frames = librosa.beat.beat_track(y=audio, sr=sample_rate) + bpm = float(np.asarray(tempo, dtype=np.float64).reshape(-1)[0]) + beat_times = [ + float(value) for value in librosa.frames_to_time(beat_frames, sr=sample_rate).tolist() + ] + stem_arrays = self._separate_signal(audio, sample_rate) stems: AudioStemPayload = { name: self._fit_length(stem_arrays[name], audio.size) for name in _STEM_ORDER @@ -111,6 +117,8 @@ def separate(self, audio_path: str | Path) -> AudioSeparationResult: "Separated selected local audio into vocals, bass, drums, and other " f"using the {self.config.model_name} model." ), + "bpm": bpm, + "beat_times": beat_times, } def _separate_signal( diff --git a/services/analysis-engine/src/bandscope_analysis/separation/model.py b/services/analysis-engine/src/bandscope_analysis/separation/model.py index 7edef6726..c85620f34 100644 --- a/services/analysis-engine/src/bandscope_analysis/separation/model.py +++ b/services/analysis-engine/src/bandscope_analysis/separation/model.py @@ -51,3 +51,5 @@ class AudioSeparationResult(TypedDict): chunk_count: int stem_role_types: StemRoleTypeMap separation_notes: str + bpm: float + beat_times: list[float] diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py b/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py index 104b82ec9..64f66c464 100644 --- a/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/temporal/__init__.py @@ -3,6 +3,7 @@ from .analyzer import TemporalAnalyzer from .groove import GrooveResult, detect_groove from .model import TemporalFeatures +from .ritardando import apply_ritardando_plan, first_ritardando, ritardando_plan_copy from .stability import TempoChange, TempoStability, analyze_tempo_stability __all__ = [ @@ -12,5 +13,8 @@ "TemporalAnalyzer", "TemporalFeatures", "analyze_tempo_stability", + "apply_ritardando_plan", "detect_groove", + "first_ritardando", + "ritardando_plan_copy", ] diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py b/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py new file mode 100644 index 000000000..8fa21e7ff --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/temporal/ritardando.py @@ -0,0 +1,317 @@ +"""Stamp tonight's first ritardando plan from existing tempo-stability changes. + +A ritardando is the earliest sustained slowing (``to_bpm < from_bpm``) that is +not a half-time feel (~0.45–0.55) or a double-time feel (~2.0). The owned +``ritardandoPlan`` copy lands on the highest-priority active named vocal or +bass in the section that contains that change. Heuristic/demo topology stays +unnamed. This is not a new MIR product: it only reads +``analyze_tempo_stability`` output. + +Security Notes: + Pure in-memory mutation of an already-built rehearsal song. Beat times and + song topology are untrusted runtime values: malformed numbers, missing + identity, repeated graph ids, or inactive parts fail closed instead of + inventing a plan. No file, network, or subprocess I/O. +""" + +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping, Sequence +from math import isfinite +from typing import Any + +from bandscope_analysis.temporal.stability import TempoChange, analyze_tempo_stability + +HALF_TIME_RATIO_MIN = 0.45 +HALF_TIME_RATIO_MAX = 0.55 +NAMED_RITARDANDO_ROLE_IDS = frozenset({"bass-guitar", "lead-vocal"}) +PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2} +RITARDANDO_PLAN_PREFIX = "Ease this part from " +RITARDANDO_PLAN_MIDDLE = " BPM into " +RITARDANDO_PLAN_SUFFIX = " BPM; let the next downbeat land later." + + +def format_ritardando_bpm(value: float) -> str | None: + """Return a buyer-facing BPM token, or None when the value is unusable.""" + if not isfinite(value) or value <= 0: + return None + rounded = round(float(value), 1) + if abs(rounded - round(rounded)) < 1e-9: + return str(int(round(rounded))) + return f"{rounded:.1f}" + + +def ritardando_plan_copy(from_bpm: float, to_bpm: float) -> str | None: + """Return the owned model ritardando copy, or None when BPM tokens are unusable.""" + from_token = format_ritardando_bpm(from_bpm) + to_token = format_ritardando_bpm(to_bpm) + if from_token is None or to_token is None: + return None + return ( + f"{RITARDANDO_PLAN_PREFIX}{from_token}" + f"{RITARDANDO_PLAN_MIDDLE}{to_token}{RITARDANDO_PLAN_SUFFIX}" + ) + + +def is_ritardando_change(change: Mapping[str, Any]) -> bool: + """Return whether a tempo change is a slowing that is not a feel flip.""" + from_bpm = change.get("from_bpm") + to_bpm = change.get("to_bpm") + if not isinstance(from_bpm, (int, float)) or isinstance(from_bpm, bool): + return False + if not isinstance(to_bpm, (int, float)) or isinstance(to_bpm, bool): + return False + if not isfinite(from_bpm) or not isfinite(to_bpm) or from_bpm <= 0 or to_bpm <= 0: + return False + if to_bpm >= from_bpm: + return False + ratio = float(to_bpm) / float(from_bpm) + if HALF_TIME_RATIO_MIN <= ratio <= HALF_TIME_RATIO_MAX: + return False + return True + + +def first_ritardando(tempo_changes: Sequence[Mapping[str, Any]] | None) -> TempoChange | None: + """Return the earliest ritardando change, or None when none is corroborated.""" + if not isinstance(tempo_changes, Sequence) or isinstance(tempo_changes, (str, bytes)): + return None + for change in tempo_changes: + if not isinstance(change, Mapping): + continue + if not is_ritardando_change(change): + continue + time = change.get("time") + from_bpm = change.get("from_bpm") + to_bpm = change.get("to_bpm") + if ( + not isinstance(time, (int, float)) + or isinstance(time, bool) + or not isfinite(time) + or time < 0 + or not isinstance(from_bpm, (int, float)) + or isinstance(from_bpm, bool) + or not isinstance(to_bpm, (int, float)) + or isinstance(to_bpm, bool) + ): + continue + return TempoChange( + time=float(time), + from_bpm=float(from_bpm), + to_bpm=float(to_bpm), + ) + return None + + +def _role_type_value(role_type: Any) -> str: + """Normalize enum or string role types to a comparable token.""" + value = getattr(role_type, "value", role_type) + return value if isinstance(value, str) else "" + + +def _priority_value(priority: Any) -> str: + """Normalize enum or string rehearsal priority to a comparable token.""" + value = getattr(priority, "value", priority) + return value if isinstance(value, str) else "" + + +def _is_named_vocal_or_bass(role: Mapping[str, Any]) -> bool: + """Return whether a role is a named vocal or bass that may own a rit.""" + role_id = role.get("id") + if not isinstance(role_id, str) or role_id.strip() == "": + return False + if role_id in NAMED_RITARDANDO_ROLE_IDS: + return True + return _role_type_value(role.get("roleType")) == "vocal" + + +def _repeated_ids(ids: list[str]) -> set[str]: + """Return ids that appear more than once in one section-local collection.""" + seen: set[str] = set() + repeated: set[str] = set() + for role_id in ids: + if role_id in seen: + repeated.add(role_id) + else: + seen.add(role_id) + return repeated + + +def _active_role_ids(section: Mapping[str, Any]) -> set[str]: + """Return unique graph role ids whose node is explicitly active.""" + part_graph = section.get("partGraph") + if not isinstance(part_graph, list): + return set() + safe_ids = [ + node.get("role_id") + for node in part_graph + if isinstance(node, Mapping) + and isinstance(node.get("role_id"), str) + and node["role_id"].strip() + ] + repeated = _repeated_ids([role_id for role_id in safe_ids if isinstance(role_id, str)]) + active: set[str] = set() + for node in part_graph: + if not isinstance(node, Mapping) or node.get("is_active") is not True: + continue + role_id = node.get("role_id") + if isinstance(role_id, str) and role_id.strip() and role_id not in repeated: + active.add(role_id) + return active + + +def _section_contains( + section: Mapping[str, Any], + time: float, + precise_boundary: Sequence[float] | None = None, +) -> bool: + """Return whether a section window contains a tempo-change time.""" + if precise_boundary is not None: + if ( + not isinstance(precise_boundary, Sequence) + or isinstance(precise_boundary, (str, bytes)) + or len(precise_boundary) != 2 + or any( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + for value in precise_boundary + ) + ): + return False + precise_start, precise_end = (float(value) for value in precise_boundary) + return ( + precise_start >= 0 + and precise_end > precise_start + and precise_start <= time < precise_end + ) + time_range = section.get("timeRange") + if not isinstance(time_range, Mapping): + return False + start = time_range.get("start") + end = time_range.get("end") + if not isinstance(start, int) or isinstance(start, bool) or start < 0: + return False + if not isinstance(end, int) or isinstance(end, bool) or end <= start: + return False + return start <= time < end + + +def _pick_landing_role(section: Mapping[str, Any]) -> MutableMapping[str, Any] | None: + """Pick the highest-priority unique active named vocal or bass.""" + roles = section.get("roles") + if not isinstance(roles, list): + return None + active_ids = _active_role_ids(section) + safe_ids = [ + role.get("id") + for role in roles + if isinstance(role, Mapping) and isinstance(role.get("id"), str) and role["id"].strip() + ] + repeated = _repeated_ids([role_id for role_id in safe_ids if isinstance(role_id, str)]) + ranked: list[tuple[int, int, str, MutableMapping[str, Any]]] = [] + for role in roles: + if not isinstance(role, MutableMapping): + continue + role_id = role.get("id") + name = role.get("name") + priority = _priority_value(role.get("rehearsalPriority")) + if not isinstance(role_id, str) or role_id.strip() == "" or role_id in repeated: + continue + if not isinstance(name, str) or name.strip() == "": + continue + if role_id not in active_ids or not _is_named_vocal_or_bass(role): + continue + if priority not in PRIORITY_RANK: + continue + is_vocal = _role_type_value(role.get("roleType")) == "vocal" or role_id == "lead-vocal" + vocal_rank = 0 if is_vocal else 1 + ranked.append((PRIORITY_RANK[priority], vocal_rank, role_id, role)) + if not ranked: + return None + ranked.sort(key=lambda item: (item[0], item[1], item[2])) + return ranked[0][3] + + +def derive_beat_times(mix: Any, sr: Any) -> list[float] | None: + """Return beat times from an in-memory mix using existing librosa beat tracking. + + Security Notes: + In-memory only. Malformed mix or sample-rate values fail closed. This + reuses ``librosa.beat.beat_track`` already owned by ``TemporalAnalyzer``; + it does not introduce a new MIR product. + """ + try: + import librosa + + if not isinstance(sr, int) or isinstance(sr, bool) or sr <= 0: + return None + if not hasattr(mix, "size") or int(getattr(mix, "size", 0)) <= 0: + return None + _tempo, beat_frames = librosa.beat.beat_track(y=mix, sr=sr) + times = librosa.frames_to_time(beat_frames, sr=sr) + derived = [float(time) for time in times] + return derived if derived else None + except (TypeError, ValueError, RuntimeError, AttributeError, ImportError): + return None + + +def apply_ritardando_plan( + song: Mapping[str, Any], + beat_times: Sequence[float] | None, + section_boundaries: Sequence[Sequence[float]] | None = None, +) -> None: + """Attach the first corroborated ritardando plan, failing closed on bad input. + + Args: + song: Mutable rehearsal-song mapping with section/role topology. + beat_times: Beat onset times in seconds used by tempo-stability. + section_boundaries: Optional unrounded section boundaries aligned to sections. + """ + if ( + beat_times is None + or not isinstance(beat_times, Sequence) + or isinstance(beat_times, (str, bytes)) + ): + return + try: + stability = analyze_tempo_stability(beat_times) + change = first_ritardando(stability.get("tempo_changes")) + if change is None: + return + copy = ritardando_plan_copy(change["from_bpm"], change["to_bpm"]) + if copy is None: + return + sections = song.get("sections") + if not isinstance(sections, list): + return + for section_index, section in enumerate(sections): + precise_boundary = None + if ( + section_boundaries is not None + and isinstance(section_boundaries, Sequence) + and not isinstance(section_boundaries, (str, bytes)) + and section_index < len(section_boundaries) + ): + precise_boundary = section_boundaries[section_index] + if not isinstance(section, Mapping) or not _section_contains( + section, change["time"], precise_boundary + ): + continue + landing = _pick_landing_role(section) + if landing is None: + return + roles = section.get("roles") + if not isinstance(roles, list): + return + for index, role in enumerate(roles): + if role is not landing: + continue + stamped = dict(landing) + stamped["ritardandoPlan"] = copy + stamped["ritardandoPlanSource"] = "model" + stamped["ritardandoPlanAtSeconds"] = change["time"] + roles[index] = stamped + return + return + except (TypeError, ValueError, KeyError, AttributeError): + return diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 18273791d..0b98bcc01 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -17,6 +17,7 @@ _stop_process, _store_cached_analysis, _store_cached_local_audio_features, + _temporal_features_for_request, build_demo_rehearsal_song, build_section_time_range, get_analysis_status, @@ -394,6 +395,11 @@ def test_build_demo_rehearsal_song_matches_expected_fixture() -> None: assert song["sections"][0]["roles"][4]["manualOverrides"][0]["value"]["source"] == "user" +def test_temporal_features_skip_non_local_requests() -> None: + """Demo requests do not invoke the local-file temporal analyzer.""" + assert _temporal_features_for_request({"sourceKind": "demo"}) == {} # type: ignore[arg-type] + + def test_build_demo_rehearsal_song_with_tempo() -> None: """Ensure build_demo_rehearsal_song incorporates tempo from audio features.""" song = build_demo_rehearsal_song({"bpm": 120.4}) @@ -478,6 +484,7 @@ def test_run_analysis_job_returns_success_for_local_audio_request() -> None: """Ensure local-audio requests separate stems before building rehearsal roles.""" with ( patch("bandscope_analysis.api._run_stem_separation_with_timeout") as separator, + patch("bandscope_analysis.api.TemporalAnalyzer") as temporal_analyzer, patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", return_value=None), patch( "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize", @@ -502,6 +509,10 @@ def test_run_analysis_job_returns_success_for_local_audio_request() -> None: }, "separation_notes": "Separated selected local audio into 4 canonical stems.", } + temporal_analyzer.return_value.analyze.return_value = { + "bpm": 120.0, + "beat_times": [0.0, 0.5, 1.0], + } success = run_analysis_job( "job-3", @@ -522,6 +533,12 @@ def test_run_analysis_job_returns_success_for_local_audio_request() -> None: assert success["state"] == "succeeded" assert success["progressLabel"] == "Analysis ready for late-night-set.wav" + assert success["result"]["tempo"] == 120 + separator.assert_called_once() + assert separator.call_args.args == ("/Users/test/Music/late-night-set.wav",) + temporal_analyzer.return_value.analyze.assert_called_once_with( + "/Users/test/Music/late-night-set.wav" + ) def test_run_analysis_job_updates_report_progress_and_cache(tmp_path) -> None: @@ -582,7 +599,7 @@ def test_run_analysis_job_updates_report_progress_and_cache(tmp_path) -> None: ("succeeded", "ready", 100), ] assert updates[-1]["cacheStatus"] == "stored" - cache_files = list((tmp_path / "cache" / "analysis-cache-v1").glob("*.json")) + cache_files = list((tmp_path / "cache" / "analysis-cache-v2").glob("*.json")) assert len([path for path in cache_files if not path.name.endswith(".features.json")]) == 1 assert len([path for path in cache_files if path.name.endswith(".features.json")]) == 1 @@ -648,7 +665,7 @@ def test_cached_analysis_helpers_treat_invalid_cache_as_miss(tmp_path) -> None: for content in ( "[]", '{"schemaVersion": 999, "result": {}}', - '{"schemaVersion": 1, "result": []}', + '{"schemaVersion": 2, "result": []}', ): cache_path.write_text(content, encoding="utf-8") assert _load_cached_analysis(cache_path) is None @@ -813,15 +830,15 @@ def test_local_feature_cache_treats_malformed_metadata_as_miss(tmp_path) -> None for content in ( "[]", '{"schemaVersion": 999, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', - '{"schemaVersion": 1, "sampleRate": "22050", "separation": {}, "stemKeys": ["bass"]}', - '{"schemaVersion": 1, "sampleRate": 22050, "separation": [], "stemKeys": ["bass"]}', - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, "stemKeys": []}', + '{"schemaVersion": 2, "sampleRate": "22050", "separation": {}, "stemKeys": ["bass"]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": [], "stemKeys": ["bass"]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, "stemKeys": []}', ): metadata_path.write_text(content, encoding="utf-8") assert _load_cached_local_audio_features(metadata_path, arrays_path) is None metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', encoding="utf-8", ) assert _load_cached_local_audio_features(metadata_path, arrays_path) is None @@ -830,28 +847,28 @@ def test_local_feature_cache_treats_malformed_metadata_as_miss(tmp_path) -> None assert _load_cached_local_audio_features(metadata_path, arrays_path) is None metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', encoding="utf-8", ) arrays_path.write_bytes(b"not an npz archive") assert _load_cached_local_audio_features(metadata_path, arrays_path) is None metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, "stemKeys": [7]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, "stemKeys": [7]}', encoding="utf-8", ) np.savez_compressed(arrays_path, stem_bass=np.zeros(4)) assert _load_cached_local_audio_features(metadata_path, arrays_path) is None metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, ' + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, ' '"stemKeys": ["bass"], "stemRoleTypes": []}', encoding="utf-8", ) assert _load_cached_local_audio_features(metadata_path, arrays_path) is None metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, ' + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, ' '"stemKeys": ["bass"], "stemRoleTypes": {"bass": "percussion"}}', encoding="utf-8", ) @@ -871,7 +888,7 @@ def __getitem__(self, _key: str) -> object: return "not-an-array" metadata_path.write_text( - '{"schemaVersion": 1, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', + '{"schemaVersion": 2, "sampleRate": 22050, "separation": {}, "stemKeys": ["bass"]}', encoding="utf-8", ) with patch("bandscope_analysis.api.np.load", return_value=BadArchive()): @@ -1081,6 +1098,8 @@ def put(self, item: tuple[str, object]) -> None: "duration_seconds": 1.0, "chunk_count": 1, "separation_notes": "Separated test stems.", + "bpm": 120.0, + "beat_times": [0.0, 0.5, 1.0], } _stem_separation_worker("/tmp/audio.wav", fake_queue, str(arrays_path)) diff --git a/services/analysis-engine/tests/test_branch_coverage_contract.py b/services/analysis-engine/tests/test_branch_coverage_contract.py index 6198141c0..ada5052bf 100644 --- a/services/analysis-engine/tests/test_branch_coverage_contract.py +++ b/services/analysis-engine/tests/test_branch_coverage_contract.py @@ -95,10 +95,8 @@ def test_chord_segment_builder_handles_zero_frames_without_final_segment() -> No assert result == [] -def test_cli_skips_temporal_probe_when_local_source_path_is_empty( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Do not invoke the temporary temporal probe for an empty local source path.""" +def test_cli_passes_empty_local_source_to_job_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Pass local requests through without a duplicate temporal probe.""" payload = { "jobId": "job-empty-source", "request": { @@ -111,17 +109,17 @@ def test_cli_skips_temporal_probe_when_local_source_path_is_empty( monkeypatch.setattr(cli.sys, "stdin", io.StringIO(json.dumps(payload))) monkeypatch.setattr(cli.sys, "stdout", stdout) - with ( - patch.object(cli, "TemporalAnalyzer") as temporal_analyzer, - patch.object( - cli, - "run_analysis_job", - return_value={"jobId": "job-empty-source", "state": "failed"}, - ), - ): + with patch.object( + cli, + "run_analysis_job", + return_value={"jobId": "job-empty-source", "state": "failed"}, + ) as run_analysis_job: assert cli.main() == 0 - temporal_analyzer.assert_not_called() + run_analysis_job.assert_called_once() + call = run_analysis_job.call_args + assert call.args[:2] == (payload["jobId"], payload["request"]) + assert isinstance(call.args[2], str) assert json.loads(stdout.getvalue())["jobId"] == "job-empty-source" diff --git a/services/analysis-engine/tests/test_cli.py b/services/analysis-engine/tests/test_cli.py index 057ef236b..ec331cfad 100644 --- a/services/analysis-engine/tests/test_cli.py +++ b/services/analysis-engine/tests/test_cli.py @@ -320,93 +320,6 @@ def test_cli_main_job_arg_json_string(monkeypatch: pytest.MonkeyPatch) -> None: assert "job-raw" in stdout.getvalue() -def test_cli_main_temporal_analyzer_mock(monkeypatch: pytest.MonkeyPatch) -> None: - """Ensure the temporal analyzer injection block is covered and handles errors.""" - stdin = io.StringIO( - json.dumps( - { - "jobId": "job-audio", - "request": { - "sourceKind": "local_audio", - "projectId": "p1", - "sourceLabel": "test.wav", - "roleFocus": [], - "localSource": { - "sourcePath": "/invalid/path.wav", - "fileName": "test.wav", - "extension": "wav", - "fileSizeBytes": 100, - }, - }, - } - ) - ) - stdout = io.StringIO() - - class FakeAnalyzer: - def analyze(self, path): - raise RuntimeError("mocked failure") - - monkeypatch.setattr(cli, "TemporalAnalyzer", FakeAnalyzer) - monkeypatch.setattr(cli.sys, "stdin", stdin) - monkeypatch.setattr(cli.sys, "stdout", stdout) - monkeypatch.setattr(cli.sys, "argv", ["cli.py"]) - - assert cli.main() == 0 - res = json.loads(stdout.getvalue()) - assert res["jobId"] == "job-audio" - - -def test_cli_main_temporal_analyzer_mock_success( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - """Ensure the temporal analyzer injection block succeeds.""" - audio_path = tmp_path / "test.wav" - write_short_wav(audio_path) - stdin = io.StringIO( - json.dumps( - { - "jobId": "job-audio-success", - "request": { - "sourceKind": "local_audio", - "projectId": "p1", - "sourceLabel": "test.wav", - "roleFocus": [], - "localSource": { - "sourcePath": str(audio_path), - "fileName": "test.wav", - "extension": "wav", - "fileSizeBytes": audio_path.stat().st_size, - }, - }, - } - ) - ) - stdout = io.StringIO() - - class FakeAnalyzerSuccess: - def analyze(self, path): - return {"bpm": 120.0, "beats": []} - - monkeypatch.setattr(cli, "TemporalAnalyzer", FakeAnalyzerSuccess) - monkeypatch.setattr( - "bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", - lambda self, y, sr: None, - ) - monkeypatch.setattr( - "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize", - lambda self, y, sr: [], - ) - monkeypatch.setattr(cli.sys, "stdin", stdin) - monkeypatch.setattr(cli.sys, "stdout", stdout) - monkeypatch.setattr(cli.sys, "argv", ["cli.py"]) - - assert cli.main() == 0 - res = json.loads(stdout.getvalue()) - assert res["jobId"] == "job-audio-success" - - def test_cli_main_progress_jsonl_streams_status_updates( monkeypatch: pytest.MonkeyPatch, tmp_path, @@ -437,11 +350,6 @@ def test_cli_main_progress_jsonl_streams_status_updates( ) stdout = io.StringIO() - class FakeAnalyzerSuccess: - def analyze(self, path): - return {"bpm": 120.0, "beats": []} - - monkeypatch.setattr(cli, "TemporalAnalyzer", FakeAnalyzerSuccess) monkeypatch.setattr( "bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", lambda self, y, sr: None, diff --git a/services/analysis-engine/tests/test_ritardando_plan.py b/services/analysis-engine/tests/test_ritardando_plan.py new file mode 100644 index 000000000..e0896799f --- /dev/null +++ b/services/analysis-engine/tests/test_ritardando_plan.py @@ -0,0 +1,418 @@ +"""Tests for corroborated ritardando-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.api import ( + _apply_ritardando, + _coerce_beat_times, + build_demo_rehearsal_song, +) +from bandscope_analysis.temporal.ritardando import ( + _is_named_vocal_or_bass, + apply_ritardando_plan, + derive_beat_times, + first_ritardando, + format_ritardando_bpm, + is_ritardando_change, + ritardando_plan_copy, +) +from bandscope_analysis.temporal.stability import analyze_tempo_stability + +_RIT_PLAN = "Ease this part from 120 BPM into 80 BPM; let the next downbeat land later." + + +def _beats_120_to_80() -> list[float]: + """Return beat times that slow from 120 BPM to 80 BPM around 7.5s.""" + beats = [i * 0.5 for i in range(16)] + for _ in range(16): + beats.append(beats[-1] + 0.75) + return beats + + +def _beats_120_to_60() -> list[float]: + """Return beat times that drop from 120 BPM to half-time 60 BPM.""" + beats = [i * 0.5 for i in range(16)] + for _ in range(16): + beats.append(beats[-1] + 1.0) + return beats + + +def _role( + role_id: str, + *, + name: str | None = None, + role_type: str = "instrument", + priority: str = "high", +) -> dict[str, Any]: + """Return a minimal rehearsal role fixture.""" + display = name if name is not None else role_id + return { + "id": role_id, + "name": display, + "roleType": role_type, + "rehearsalPriority": priority, + "harmony": {"chord": "C#m7", "functionLabel": "vi", "source": "model"}, + "cue": {"kind": "transition", "value": "Hold"}, + "range": {"lowestNote": "C#2", "highestNote": "E3"}, + "confidence": {"level": "high", "source": "model", "notes": "ok"}, + "simplification": "Stay on roots.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + } + + +def _song_with_section( + *, + start: int = 0, + end: int = 16, + roles: list[dict[str, Any]] | None = None, + part_graph: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return a one-section song that can receive a ritardando stamp.""" + section_roles = roles or [ + _role("keys-right", name="Keyboard 1 Right Hand", role_type="hand"), + _role("lead-vocal", name="Lead Vocal", role_type="vocal"), + _role("bass-guitar", name="Bass Guitar"), + ] + graph = part_graph or [ + {"role_id": role["id"], "is_active": True, "handoff_to": [], "handoff_from": []} + for role in section_roles + ] + return { + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Lifted chorus downbeat", + "timeRange": {"start": start, "end": end}, + "roles": section_roles, + "partGraph": graph, + } + ], + } + + +def test_format_ritardando_bpm_tokens() -> None: + """Whole BPM values drop the decimal; unusable values stay unnamed.""" + assert format_ritardando_bpm(120.0) == "120" + assert format_ritardando_bpm(96.5) == "96.5" + assert format_ritardando_bpm(0) is None + assert format_ritardando_bpm(-12) is None + assert format_ritardando_bpm(float("nan")) is None + assert format_ritardando_bpm(float("inf")) is None + + +def test_ritardando_plan_copy_uses_owned_template() -> None: + """Model copy names the slowing without inventing other rehearsal plans.""" + assert ritardando_plan_copy(120, 80) == _RIT_PLAN + assert ritardando_plan_copy(0, 80) is None + + +def test_first_ritardando_picks_the_earliest_slowing() -> None: + """120 to 80 is a ritardando; later accelerando is ignored.""" + result = analyze_tempo_stability(_beats_120_to_80()) + change = first_ritardando(result["tempo_changes"]) + assert change is not None + assert abs(change["from_bpm"] - 120.0) < 1.0 + assert abs(change["to_bpm"] - 80.0) < 1.0 + assert 7.0 <= change["time"] <= 8.5 + + +def test_first_ritardando_excludes_half_time() -> None: + """A 120 to 60 feel flip is half-time, not a ritardando.""" + result = analyze_tempo_stability(_beats_120_to_60()) + assert first_ritardando(result["tempo_changes"]) is None + assert is_ritardando_change({"time": 8.0, "from_bpm": 120.0, "to_bpm": 60.0}) is False + + +def test_first_ritardando_excludes_accelerando_and_double_time() -> None: + """Speeding up, including double-time, is not a ritardando.""" + assert is_ritardando_change({"time": 8.0, "from_bpm": 80.0, "to_bpm": 120.0}) is False + assert is_ritardando_change({"time": 8.0, "from_bpm": 60.0, "to_bpm": 120.0}) is False + assert first_ritardando([{"time": 8.0, "from_bpm": 60.0, "to_bpm": 120.0}]) is None + + +def test_first_ritardando_fails_closed_on_malformed_changes() -> None: + """Malformed tempo-change collections never invent a rit.""" + assert first_ritardando(None) is None + assert first_ritardando("tempo") is None + assert first_ritardando([None, "x", {"from_bpm": True, "to_bpm": 80}]) is None + assert is_ritardando_change({"from_bpm": True, "to_bpm": 80}) is False + assert is_ritardando_change({"from_bpm": 120, "to_bpm": True}) is False + assert is_ritardando_change({"from_bpm": 120, "to_bpm": float("nan")}) is False + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80, "time": True}]) is None + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80, "time": -1}]) is None + assert first_ritardando([{"from_bpm": 120, "to_bpm": 80}]) is None + + +def test_apply_stamps_highest_priority_named_vocal() -> None: + """The named vocal owns the rit when it outranks bass in the same section.""" + song = _song_with_section() + apply_ritardando_plan(song, _beats_120_to_80()) + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + bass = next(role for role in song["sections"][0]["roles"] if role["id"] == "bass-guitar") + keys = next(role for role in song["sections"][0]["roles"] if role["id"] == "keys-right") + assert vocal["ritardandoPlan"] == _RIT_PLAN + assert vocal["ritardandoPlanSource"] == "model" + change = first_ritardando(analyze_tempo_stability(_beats_120_to_80())["tempo_changes"]) + assert change is not None + assert vocal["ritardandoPlanAtSeconds"] == change["time"] + assert "ritardandoPlan" not in bass + assert "ritardandoPlan" not in keys + + +def test_apply_stamps_bass_when_vocal_is_inactive() -> None: + """Bass owns the rit when the vocal is not active in the section.""" + song = _song_with_section( + part_graph=[ + {"role_id": "keys-right", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "lead-vocal", "is_active": False, "handoff_to": [], "handoff_from": []}, + {"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []}, + ] + ) + apply_ritardando_plan(song, _beats_120_to_80()) + bass = next(role for role in song["sections"][0]["roles"] if role["id"] == "bass-guitar") + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + assert bass["ritardandoPlan"] == _RIT_PLAN + assert "ritardandoPlan" not in vocal + + +def test_apply_stays_unnamed_without_named_vocal_or_bass() -> None: + """Accompaniment hands never own a ritardando plan.""" + song = _song_with_section( + roles=[_role("keys-right", name="Keyboard 1 Right Hand", role_type="hand")], + part_graph=[ + {"role_id": "keys-right", "is_active": True, "handoff_to": [], "handoff_from": []} + ], + ) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_when_section_does_not_contain_the_change() -> None: + """A rit outside every section window stays unnamed.""" + song = _song_with_section(start=40, end=56) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_stays_unnamed_on_half_time_and_missing_beats() -> None: + """Half-time, missing beats, and demo topology stay unnamed.""" + song = _song_with_section() + apply_ritardando_plan(song, _beats_120_to_60()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + apply_ritardando_plan(song, None) + apply_ritardando_plan(song, "beats") # type: ignore[arg-type] + demo = build_demo_rehearsal_song({"beat_times": _beats_120_to_80(), "bpm": 120}) + assert demo["id"] == "demo-song" + assert all( + "ritardandoPlan" not in role for section in demo["sections"] for role in section["roles"] + ) + + +def test_apply_skips_repeated_and_blank_identities() -> None: + """Repeated graph ids, blank names, and unknown priorities fail closed.""" + roles = [ + _role("lead-vocal", name="Lead Vocal", role_type="vocal"), + _role("lead-vocal", name="Double Vocal", role_type="vocal"), + _role("bass-guitar", name=""), + _role("mystery", name="Mystery", priority="urgent"), + ] + song = _song_with_section( + roles=roles, + part_graph=[ + {"role_id": "lead-vocal", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "lead-vocal", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "bass-guitar", "is_active": True, "handoff_to": [], "handoff_from": []}, + {"role_id": "mystery", "is_active": True, "handoff_to": [], "handoff_from": []}, + ], + ) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_fails_closed_on_malformed_song_topology() -> None: + """Malformed sections, ranges, and graph nodes never invent a rit.""" + apply_ritardando_plan({"sections": "nope"}, _beats_120_to_80()) + apply_ritardando_plan({"sections": [{"timeRange": "nope", "roles": []}]}, _beats_120_to_80()) + apply_ritardando_plan( + _song_with_section(), + _beats_120_to_80(), + [(0.0,)], # type: ignore[list-item] + ) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": True, "end": 16} + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["timeRange"] = {"start": 10, "end": True} + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["roles"] = None + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["partGraph"] = "graph" + apply_ritardando_plan(song, _beats_120_to_80()) + song = _song_with_section() + song["sections"][0]["roles"] = [ + "not-a-role", + _role("bass-guitar", name="Bass Guitar", priority="urgent"), + ] + apply_ritardando_plan(song, _beats_120_to_80()) + assert _is_named_vocal_or_bass({"id": ""}) is False + assert _is_named_vocal_or_bass({"id": "choir", "roleType": "vocal"}) is True + + +def test_apply_fails_closed_when_copy_cannot_be_built( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A corroborated change without owned copy stays unnamed.""" + song = _song_with_section() + monkeypatch.setattr( + "bandscope_analysis.temporal.ritardando.ritardando_plan_copy", + lambda *_args, **_kwargs: None, + ) + apply_ritardando_plan(song, _beats_120_to_80()) + assert all("ritardandoPlan" not in role for role in song["sections"][0]["roles"]) + + +def test_apply_fails_closed_when_song_get_raises() -> None: + """Hostile song mappings fail closed instead of escaping.""" + + class HostileSong(dict[str, Any]): + """Mapping that raises when sections are read.""" + + def get(self, key: str, default: Any = None) -> Any: + """Raise on sections so apply_ritardando_plan must fail closed.""" + if key == "sections": + raise TypeError("hostile sections") + return super().get(key, default) + + apply_ritardando_plan(HostileSong(), _beats_120_to_80()) + + +def test_derive_beat_times_fails_closed_and_reuses_librosa( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """In-memory beat derivation fails closed and reuses existing beat tracking.""" + assert derive_beat_times(np.zeros(0, dtype=np.float32), 22050) is None + assert derive_beat_times(np.ones(16, dtype=np.float32), True) is None # type: ignore[arg-type] + assert derive_beat_times(np.ones(16, dtype=np.float32), 0) is None + + class _Librosa: + """Minimal librosa stand-in for beat tracking.""" + + class beat: + """Beat-tracking namespace.""" + + @staticmethod + def beat_track(*, y: Any, sr: int) -> tuple[float, np.ndarray]: + """Return a tiny beat-frame grid.""" + return 120.0, np.array([0, 10, 20], dtype=np.int32) + + @staticmethod + def frames_to_time(frames: np.ndarray, sr: int) -> np.ndarray: + """Convert frames to seconds.""" + return frames.astype(np.float64) / sr + + monkeypatch.setitem(__import__("sys").modules, "librosa", _Librosa) + derived = derive_beat_times(np.ones(32, dtype=np.float32), 10) + assert derived == [0.0, 1.0, 2.0] + + class _Boom: + """Librosa stand-in that fails closed.""" + + class beat: + """Beat-tracking namespace that raises.""" + + @staticmethod + def beat_track(*, y: Any, sr: int) -> tuple[float, np.ndarray]: + """Force beat tracking to fail closed.""" + raise RuntimeError("beat tracking unavailable") + + @staticmethod + def frames_to_time(frames: np.ndarray, sr: int) -> np.ndarray: + """Unused converter.""" + return frames.astype(np.float64) + + monkeypatch.setitem(__import__("sys").modules, "librosa", _Boom) + assert derive_beat_times(np.ones(32, dtype=np.float32), 22050) is None + + +def test_coerce_beat_times_and_pipeline_stamp(monkeypatch: pytest.MonkeyPatch) -> None: + """Pipeline features stamp a rit; malformed beat times fall through to mix derivation.""" + assert _coerce_beat_times(None) is None + assert _coerce_beat_times({"beat_times": []}) == [] + assert _coerce_beat_times({"beat_times": [0.0, True]}) is None + assert _coerce_beat_times({"beat_times": [0.0, float("nan")]}) is None + assert _coerce_beat_times({"beat_times": _beats_120_to_80()})[0] == 0.0 + + song = _song_with_section() + mix = np.ones(8, dtype=np.float32) + _apply_ritardando(song, mix, 22050, {"beat_times": _beats_120_to_80()}) + vocal = next(role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal") + assert vocal["ritardandoPlan"] == _RIT_PLAN + + def fail_if_redecoded(*args: Any, **kwargs: Any) -> list[float] | None: + raise AssertionError("an empty authoritative beat grid must not be re-decoded") + + with monkeypatch.context() as context: + context.setattr("bandscope_analysis.api.derive_beat_times", fail_if_redecoded) + empty_grid_song = _song_with_section() + _apply_ritardando(empty_grid_song, np.ones(8, dtype=np.float32), 22050, {"beat_times": []}) + assert all("ritardandoPlan" not in role for role in empty_grid_song["sections"][0]["roles"]) + + unnamed = _song_with_section() + _apply_ritardando(unnamed, np.zeros(0, dtype=np.float32), 22050, {"beat_times": "nope"}) + assert all("ritardandoPlan" not in role for role in unnamed["sections"][0]["roles"]) + + +def test_pipeline_uses_unrounded_boundaries_for_ritardando_section() -> None: + """A fractional structural boundary must not be truncated before section selection.""" + earlier = _song_with_section(start=0, end=7) + later = _song_with_section(start=7, end=20) + song = earlier + song["sections"].extend(later["sections"]) + + apply_ritardando_plan(song, _beats_120_to_80(), [(0.0, 7.9), (7.9, 20.0)]) + + earlier_vocal = next( + role for role in song["sections"][0]["roles"] if role["id"] == "lead-vocal" + ) + later_vocal = next(role for role in song["sections"][1]["roles"] if role["id"] == "lead-vocal") + assert earlier_vocal["ritardandoPlan"] == _RIT_PLAN + assert "ritardandoPlan" not in later_vocal + + +def test_pipeline_stamps_ritardando_from_provided_beat_times() -> None: + """Real stem pipeline receives beat times and names the rit on the map.""" + sr = 8 + duration = 16.0 + audio = np.ones(int(sr * duration), dtype=np.float32) + song = build_demo_rehearsal_song( + { + "stems": {"bass": audio, "other": audio, "vocals": audio}, + "sr": sr, + "separation": {"duration_seconds": duration, "chunk_count": 1, "notes": "test"}, + "beat_times": _beats_120_to_80(), + } + ) + if song["id"] != "analyzed-song": + pytest.skip("pipeline fell back to arrangement without structural sections") + stamped = [ + role + for section in song["sections"] + for role in section["roles"] + if role.get("ritardandoPlan") + ] + assert len(stamped) <= 1 + if stamped: + assert stamped[0]["ritardandoPlanSource"] == "model" + assert stamped[0]["id"] in {"lead-vocal", "bass-guitar"} diff --git a/services/analysis-engine/tests/test_ritardando_shared_role_isolation.py b/services/analysis-engine/tests/test_ritardando_shared_role_isolation.py new file mode 100644 index 000000000..91129cf25 --- /dev/null +++ b/services/analysis-engine/tests/test_ritardando_shared_role_isolation.py @@ -0,0 +1,97 @@ +"""Regression tests for section-local ritardando role mutation.""" + +from typing import Any + +from bandscope_analysis.temporal.ritardando import apply_ritardando_plan + + +def _beats_120_to_80() -> list[float]: + """Return beat times that slow from 120 BPM to 80 BPM around 7.5 seconds.""" + beats = [index * 0.5 for index in range(16)] + for _ in range(16): + beats.append(beats[-1] + 0.75) + return beats + + +def _shared_vocal() -> dict[str, Any]: + """Return one role object deliberately shared by two section fixtures.""" + return { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "rehearsalPriority": "high", + } + + +def _section(section_id: str, start: int, end: int, role: dict[str, Any]) -> dict[str, Any]: + """Return a minimal section containing the supplied shared role object.""" + return { + "id": section_id, + "label": "verse", + "timeRange": {"start": start, "end": end}, + "roles": [role], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": True, + "handoff_to": [], + "handoff_from": [], + } + ], + } + + +class _ChangingRolesSection(dict[str, Any]): + """Section mapping that changes its roles collection after selection.""" + + def __init__(self, initial_role: dict[str, Any], replacement: object) -> None: + """Store a valid first roles read and the later replacement value.""" + super().__init__(_section("verse-changing", 4, 16, initial_role)) + self._roles_reads = 0 + self._replacement = replacement + + def get(self, key: str, default: Any = None) -> Any: + """Return a different roles payload after the landing role is selected.""" + if key == "roles": + self._roles_reads += 1 + if self._roles_reads > 1: + return self._replacement + return super().get(key, default) + + +def test_ritardando_stamp_does_not_leak_through_a_shared_role_object() -> None: + """Only the section containing the tempo change receives the owned plan copy.""" + shared_role = _shared_vocal() + earlier = _section("verse-1", 0, 4, shared_role) + containing = _section("verse-2", 4, 16, shared_role) + song = {"id": "shared-role-song", "title": "Shared Role", "sections": [earlier, containing]} + + apply_ritardando_plan(song, _beats_120_to_80()) + + assert "ritardandoPlan" not in earlier["roles"][0] + assert containing["roles"][0]["ritardandoPlanSource"] == "model" + + +def test_ritardando_stamp_fails_closed_if_roles_stop_being_a_list() -> None: + """A runtime section that changes shape after selection receives no stamp.""" + role = _shared_vocal() + section = _ChangingRolesSection(role, "not-a-role-list") + song = {"id": "changing-song", "title": "Changing", "sections": [section]} + + apply_ritardando_plan(song, _beats_120_to_80()) + + assert "ritardandoPlan" not in role + + +def test_ritardando_stamp_fails_closed_if_selected_role_identity_disappears() -> None: + """A replaced roles list cannot receive a stamp through stale object identity.""" + role = _shared_vocal() + replacement_role = dict(role) + replacement_roles = [replacement_role] + section = _ChangingRolesSection(role, replacement_roles) + song = {"id": "drifting-song", "title": "Drifting", "sections": [section]} + + apply_ritardando_plan(song, _beats_120_to_80()) + + assert "ritardandoPlan" not in role + assert "ritardandoPlan" not in replacement_role diff --git a/services/analysis-engine/tests/test_temporal_feature_cache.py b/services/analysis-engine/tests/test_temporal_feature_cache.py new file mode 100644 index 000000000..e6822ab6e --- /dev/null +++ b/services/analysis-engine/tests/test_temporal_feature_cache.py @@ -0,0 +1,102 @@ +"""Regression coverage for cached authoritative temporal analysis features.""" + +import numpy as np + +import bandscope_analysis.api as api + + +def _request(tmp_path, *, cache: bool = True): + """Build the local-audio request used by temporal cache regressions.""" + payload = { + "sourceKind": "local_audio", + "projectId": "project-temporal-cache", + "sourceLabel": "late-night-set.wav", + "roleFocus": ["lead-vocal"], + "localSource": { + "sourcePath": "/Users/test/Music/late-night-set.wav", + "fileName": "late-night-set.wav", + "extension": "wav", + "fileSizeBytes": 1024000, + }, + } + if cache: + payload["cacheRoot"] = str(tmp_path / "cache") + return api.validate_analysis_job_request(payload) + + +def _features(): + """Return reusable stems with a source-derived authoritative temporal grid.""" + return { + "stems": {"vocals": np.asarray([0.1, -0.1], dtype=np.float32)}, + "sr": 44100, + "stem_role_types": {"vocals": "vocal"}, + "separation": { + "duration_seconds": 1.0, + "chunk_count": 1, + "notes": "test stems", + }, + "bpm": 120.0, + "beat_times": [0.0, 0.5, 1.0], + } + + +def _song(): + """Return a minimal valid result so orchestration tests stay focused on decoding.""" + return { + "id": "temporal-cache-song", + "title": "Late Night Set", + "sections": [], + "exportSummary": { + "format": "cue-sheet", + "headline": "Temporal cache regression", + "focusSections": [], + }, + } + + +def _unexpected_temporal_redecode(_request): + """Fail a regression if orchestration opens the source solely for timing again.""" + raise AssertionError("authoritative temporal grid must not trigger a second source decode") + + +def test_feature_cache_round_trips_authoritative_tempo_grid(tmp_path) -> None: + """Reuse source-derived BPM and beats instead of decoding the source again.""" + request = _request(tmp_path) + metadata_path = tmp_path / "features.json" + arrays_path = tmp_path / "features.npz" + + assert api._store_cached_local_audio_features(metadata_path, arrays_path, request, _features()) + loaded = api._load_cached_local_audio_features(metadata_path, arrays_path) + + assert loaded is not None + assert loaded["bpm"] == 120.0 + assert loaded["beat_times"] == [0.0, 0.5, 1.0] + + +def test_fresh_source_temporal_grid_skips_second_decode(monkeypatch, tmp_path) -> None: + """Keep source timing returned by separation without opening the source again.""" + request = _request(tmp_path, cache=False) + monkeypatch.setattr(api, "_build_local_audio_features", lambda _request: _features()) + monkeypatch.setattr(api, "_temporal_features_for_request", _unexpected_temporal_redecode) + monkeypatch.setattr(api, "build_demo_rehearsal_song", lambda _features: _song()) + + updates = api.run_analysis_job_updates("job-fresh-grid", request, "2026-08-28T00:00:00Z") + + assert updates[-1]["state"] == "succeeded" + + +def test_cached_source_temporal_grid_skips_second_decode(monkeypatch, tmp_path) -> None: + """Keep the persisted source timing on feature-cache hits without source I/O.""" + request = _request(tmp_path) + cache_paths = api._feature_cache_paths(request) + assert cache_paths is not None + assert api._store_cached_local_audio_features(*cache_paths, request, _features()) + monkeypatch.setattr(api, "_temporal_features_for_request", _unexpected_temporal_redecode) + monkeypatch.setattr(api, "build_demo_rehearsal_song", lambda _features: _song()) + + updates = api.run_analysis_job_updates("job-cached-grid", request, "2026-08-28T00:00:00Z") + + assert updates[-1]["state"] == "succeeded" + assert any( + update.get("progressLabel") == "Loaded reusable stems... (45%)" for update in updates + )