diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..64fedf6ec 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 fade plan with the owning part when a staying named role is corroborated, the owned `fadePlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes. - 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..83c0fb9b2 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 fade plan on the mounted map when section-level stem energy shows a corroborated intensity fall (same distinct source set stays, named vocals or bass previous RMS ≥1.8× current after already-audible previous, current still audible), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-swell, first-drop, first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. Accompaniment other never owns. - 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..6d9a60c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first fade plan in the mounted rehearsal workspace so the part that quiets in place can land the next downbeat on the map; real analyzed songs now receive this guidance only when section-level stem energy shows the same distinct source set staying while named vocals or bass RMS falls by at least 1.8× after an already-audible previous section and the current section stays audible, 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..90a5fb2cc 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 fade 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, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-swell, first-drop, first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. `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..ec4a952c4 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,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 FadePlanSourcePayload { + 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 +214,30 @@ 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")] + fade_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fade_plan_source: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +574,67 @@ 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}' + ) +} + +/// Mirrors shared-types plan validation without normalizing persisted text. +fn is_valid_fade_plan(value: &str) -> bool { + let mut has_non_whitespace = false; + for character in value.chars() { + if matches!( + character, + '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) { + return false; + } + if !is_plan_whitespace(character) { + has_non_whitespace = true; + } + } + has_non_whitespace +} + +pub fn validate_fade_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role + .fade_plan + .as_deref() + .is_some_and(|fade_plan| !is_valid_fade_plan(fade_plan)) + { + return Err("Invalid project file format".to_string()); + } + if role.fade_plan.is_none() && role.fade_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + if role.fade_plan.is_some() && role.fade_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_fade_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +652,9 @@ pub fn project_payload_from_content(content: &str) -> Result Value { + json!({ + "id": "analyzed-song", + "title": "Late Night Set", + "sections": [ + { + "id": "chorus-1", + "label": "chorus", + "groove": "Lifted chorus downbeat", + "timeRange": { "start": 30, "end": 46 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Stem energy corroborates the fade." + }, + "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 quieter." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays while the level comes down." + }, + "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, + "fadePlan": "Fade this part; let the next downbeat land quieter.", + "fadePlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Let the chorus fade together.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_fade_plan_provenance() { + let payload = song_with_fade_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 fade-plan fields"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["fadePlan"], + payload["sections"][0]["roles"][0]["fadePlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["fadePlanSource"], + json!("model") + ); +} + +#[test] +fn project_contract_rejects_fade_plan_source_without_fade_plan() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fadePlan"); + 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_fade_plan_without_source() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("fadePlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject fade-plan copy without provenance" + ); +} + +#[test] +fn project_contract_rejects_invalid_fade_plan_copy_with_source() { + for fade_plan in [ + "", + " ", + "\u{00A0}\u{2003}\u{3000}", + "fade here\nthen hold", + "fade here\rthen hold", + "fade here\u{000B}then hold", + "fade here\u{000C}then hold", + "fade here\u{0085}then hold", + "fade here\u{2028}then hold", + "fade here\u{2029}then hold", + ] { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlan"] = json!(fade_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 fade-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_fade_plan_source() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlanSource"] = 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_preserves_padded_single_line_fade_copy() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["fadePlan"] = json!(" Fade together. \u{00A0}"); + payload["sections"][0]["roles"][0]["fadePlanSource"] = 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]["fadePlan"], + payload["sections"][0]["roles"][0]["fadePlan"] + ); +} + +#[test] +fn project_contract_rejects_practice_progress_above_shared_bound() { + let mut payload = song_with_fade_plan(); + payload["sections"][0]["roles"][0]["practiceProgress"] = json!(101); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject practiceProgress above the shared 0..=100 bound" + ); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index ed4f967bd..ea3312080 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -740,8 +740,11 @@ async fn import_youtube_url( #[tauri::command] fn save_project(payload: Value) -> Result<(), String> { - let parsed = serde_json::from_value::(payload) - .map_err(|_| "Invalid project payload".to_string())?; + let parsed = validate_fade_plan_provenance( + serde_json::from_value::(payload) + .map_err(|_| "Invalid project payload".to_string())?, + ) + .map_err(|_| "Invalid project payload".to_string())?; let path = FileDialog::new() .add_filter("BandScope Project", &["bscope", "json"]) diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..b32eec45f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.custom-guidance.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const appendedSongStructureTargets = new Set(); + +function songWithCustomFadePlan(source: "model" | "user" | undefined, text: string) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = text; + if (source) { + vocal.fadePlanSource = source; + } + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + appendedSongStructureTargets.add(timeline); +} + +describe("FirstFadePlanCallout custom guidance", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + }); + + it("preserves user-authored fade guidance verbatim", () => { + render( + + ); + expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy(); + }); + + it("keeps user-authored guidance in the plain body after opening the fade", () => { + appendSongStructureTarget(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("does not render custom copy without provenance", () => { + render( + + ); + expect(screen.queryByText("Stack the last bar and grow together.")).toBeNull(); + expect( + screen.getByText( + "No fade plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx new file mode 100644 index 000000000..f494f0eca --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +it("gives co-mounted fade-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first fade plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..d9f3efeec --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.navigation-failure.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = "Fade this part; let the next downbeat land quieter."; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout navigation failure", () => { + it("names the next action when the rendered map target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect( + screen.getByRole("status").textContent + ).toBe("Could not open this fade on the song map. Use the map below to find the section."); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx new file mode 100644 index 000000000..a50e6316a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.particle.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithKoreanFade( + fadePlan: string, + fadePlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + fadePlan, + ...(fadePlanSource ? { fadePlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the fade action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanFade( + "Fade this part; let the next downbeat land quieter.", + "model" + ); + + 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 = "1"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:30 코러스에서 피아노 파트가 페이드합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:30 피아노 페이드 열기" })); + + expect( + screen.getByText("0:30에서 피아노 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요.") + ).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + expect(screen.queryByText(/피아노을/)).toBeNull(); + expect(screen.queryByText(/피아노를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx new file mode 100644 index 000000000..b50906449 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.provenance.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +function songWithKoreanFade( + fadePlan: string, + fadePlanSource?: "model" | "user" +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + fadePlan, + ...(fadePlanSource ? { fadePlanSource } : {}) + } + ]; + chorus.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout fade-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user fade guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Fade this part; let the next downbeat land quieter."; + const song = songWithKoreanFade(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); + }); + + it("does not render persisted fade guidance when it has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Fade this part; let the next downbeat land quieter."; + const song = songWithKoreanFade(legacyPlan); + + render(); + + expect(screen.queryByText(legacyPlan)).toBeNull(); + expect( + screen.getByText( + "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + expect(screen.queryByText("이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.")).toBeNull(); + }); + + it("localizes model guidance from structured landing topology instead of display sentence wording", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanFade( + "Fade this part with Keyboard 1 Right Hand; let the next downbeat land quieter.", + "model" + ); + + render(); + + expect( + screen.getByText( + "Keyboard 1 Right Hand 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ) + ).toBeTruthy(); + expect( + screen.queryByText( + "Fade this part with Keyboard 1 Right Hand; let the next downbeat land quieter." + ) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..9e9080d6d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.reduced-motion.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = DEMO_FADE_PLAN; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstFadePlanCallout reduced motion", () => { + afterEach(() => { + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => { + node.parentElement?.remove(); + }); + vi.unstubAllGlobals(); + }); + + it("uses immediate scrolling when the operating system requests reduced motion", () => { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { configurable: true, value: scrollIntoView }); + grid.appendChild(target); + document.body.appendChild(grid); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion: reduce"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx new file mode 100644 index 000000000..448f43b09 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.test.tsx @@ -0,0 +1,161 @@ +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 { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; +const appendedSongStructureTargets = new Set(); + +function songWithFadePlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = DEMO_FADE_PLAN; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + 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("FirstFadePlanCallout", () => { + 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 fade 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 = songWithFadePlan(); + 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 fade at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithFadePlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No fade plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same fade signature", () => { + const firstSong = songWithFadePlan(); + const nextSong = songWithFadePlan(); + 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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithFadePlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Singer fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Singer together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("resets armed guidance when the section label changes in the same workspace", () => { + const firstSong = songWithFadePlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.label = "bridge"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the bridge at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeNull(); + }); + + it("opens the named fade on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible./)).toBeTruthy(); + expect(screen.getByText(DEMO_FADE_PLAN)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx new file mode 100644 index 000000000..031e7d00d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.tsx @@ -0,0 +1,221 @@ +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 { + formatFadePlanTime, + resolveFirstFadePlan, + type FadePlanGuidance +} from "./firstFadePlan"; + +/** Props for the first fade-plan rehearsal callout. */ +export interface FirstFadePlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type FadePlanCopyValues = Readonly>; +type FadePlanSource = "model" | "user"; + +type OpenedFadePlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + fadePlan: string; + fadePlanSource: FadePlanSource | null; + fadePlanGuidanceKind: FadePlanGuidance["kind"] | null; + fadePlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableFadePlanSongIdentity(song: RehearsalSong, workspaceInstanceKey: unknown): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate fade-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatFadePlanCopy(template: string, values: FadePlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof FadePlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model fade guidance from structured landing topology, never from display-copy grammar. */ +function localizedFadePlan( + fadePlan: string, + fadePlanSource: FadePlanSource | null, + guidance: FadePlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (fadePlanSource !== "model" || guidance === null) { + return fadePlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredFadePlanScrollBehavior(): 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 resolveFadePlanRenderer(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 fade plan and open the matching rendered map section. */ +export function FirstFadePlanCallout({ song, workspaceInstanceKey }: FirstFadePlanCalloutProps) { + const calloutId = `workspace-surface-fade-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableFadePlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstFadePlan(song), [song]); + const [openedFadePlan, setOpenedFadePlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.fadePlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.fadePlanGuidance?.kind === "role" ? named.fadePlanGuidance.targetRoleName : null; + + useEffect(() => { + setOpenedFadePlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.fadePlan, + named?.fadePlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedFadePlan !== null && + openedFadePlan.songIdentity === songIdentity && + openedFadePlan.sectionId === named.sectionId && + openedFadePlan.sectionIndex === named.sectionIndex && + openedFadePlan.sectionLabel === named.sectionLabel && + openedFadePlan.landingRoleId === named.landingRoleId && + openedFadePlan.landingRoleName === named.landingRoleName && + openedFadePlan.fadePlan === named.fadePlan && + openedFadePlan.fadePlanSource === named.fadePlanSource && + openedFadePlan.fadePlanGuidanceKind === guidanceKind && + openedFadePlan.fadePlanTargetRoleName === guidanceTargetRoleName && + openedFadePlan.atSeconds === named.atSeconds; + const at = formatFadePlanTime(named.atSeconds); + const copyValues: FadePlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatFadePlanCopy(t("firstFadePlanOpenAction"), copyValues); + const body = formatFadePlanCopy(t("firstFadePlanBody"), copyValues); + const armed = formatFadePlanCopy(t("firstFadePlanArmed"), copyValues); + const fadePlan = localizedFadePlan( + named.fadePlan, + named.fadePlanSource, + named.fadePlanGuidance, + t("firstFadePlanGeneratedGuidance"), + t("firstFadePlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..baacf754c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstFadePlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstFadePlanCallout } from "./FirstFadePlanCallout"; + +describe("FirstFadePlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English fade plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No fade plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean fade plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx new file mode 100644 index 000000000..022e5f0b6 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.fade-state.test.tsx @@ -0,0 +1,117 @@ +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 analyzedSongWithFadePlan(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.id = "analyzed-song"; + const verse = song.sections[0]!; + verse.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: true, handoff_to: [], handoff_from: [] } + ]; + + const chorus = structuredClone(verse); + chorus.id = "chorus-fade-state"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.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: true, handoff_to: [], handoff_from: [] } + ]; + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + vocal.fadePlan = "Fade this part; let the next downbeat land quieter."; + vocal.fadePlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace fade 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 fade armed after an immutable practice-progress update", () => { + const song = analyzedSongWithFadePlan(); + 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 fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter 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(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + }); + + it("keeps an opened fade armed after an immutable chord edit", () => { + const song = analyzedSongWithFadePlan(); + let updatedSong: RehearsalSong | null = null; + const onSongUpdate = vi.fn((nextSong: RehearsalSong) => { + updatedSong = nextSong; + }); + vi.spyOn(window, "prompt").mockReturnValue("Dm7"); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) + ); + expect(updatedSong).not.toBeNull(); + + rerender(); + + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + }); + + it("resets opened fade guidance when an external song reuses the same fade metadata", () => { + const firstSong = analyzedSongWithFadePlan(); + const replacementSong = structuredClone(firstSong); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal fade at 0:30" })); + expect(screen.getByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal fades the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Fade Lead Vocal together at 0:30 so the quieter landing is audible\./)).toBeNull(); + }); + + it("keeps chord editing unavailable when no song update handler exists", () => { + render(); + + expect( + screen.getByRole("button", { name: /Edit chord for Lead Vocal in chorus, current/ }) + ).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..8b8d4af8f 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 { FirstFadePlanCallout } from "./FirstFadePlanCallout"; 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,17 @@ 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); + + useEffect(() => { + if (song !== localSongUpdateRef.current) { + workspaceInstanceRef.current = song; + } + localSongUpdateRef.current = null; + }, [song]); + const workspaceInstanceKey = + song === localSongUpdateRef.current ? workspaceInstanceRef.current : song; // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -164,6 +180,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 +211,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) }; - onSongUpdate(nextSong); + commitSongUpdate(nextSong); }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), @@ -310,6 +333,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ +

{t("workspaceSongTimelineLabel")}

@@ -505,7 +530,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts new file mode 100644 index 000000000..b7e915e02 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.accompaniment-provenance.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +const MODEL_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +describe("resolveFirstFadePlan accompaniment provenance", () => { + it("does not name a shared accompaniment role from persisted fade metadata", () => { + const song = createDemoRehearsalSong(); + const template = structuredClone(song.sections[0]!); + const bass = structuredClone(template.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(template.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(template.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, vocal]) { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + } + keys.fadePlan = MODEL_FADE_PLAN; + keys.fadePlanSource = "model"; + + const previous = structuredClone(template); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + previous.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: true, handoff_to: [], handoff_from: [] } + ]; + + const fade = structuredClone(template); + fade.id = "chorus-fade"; + fade.label = "chorus"; + fade.timeRange = { start: 10, end: 30 }; + fade.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + fade.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: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, fade]; + + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts new file mode 100644 index 000000000..053078812 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.demo.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +describe("resolveFirstFadePlan demo topology", () => { + it("keeps heuristic demo topology unnamed until real stem energy corroborates a fade", () => { + expect(resolveFirstFadePlan(createDemoRehearsalSong())).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts new file mode 100644 index 000000000..c62a091c1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.model-guidance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +it("rejects non-template model fade guidance instead of rendering untranslated copy", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + const previous = structuredClone(seed); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys, vocal]; + previous.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: true, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-fade"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + vocal.fadePlan = "Model says: grow the chorus hard."; + vocal.fadePlanSource = "model"; + current.roles = [vocal, 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: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, current]; + + expect(resolveFirstFadePlan(song)).toBeNull(); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts new file mode 100644 index 000000000..626b29d38 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.proxy-authority.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +describe("resolveFirstFadePlan proxy authority", () => { + it("does not read inherited or Proxy-substituted fadePlan as rehearsal copy", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ ...node, is_active: true })); + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: verse.timeRange.end, end: verse.timeRange.end + 16 }; + chorus.partGraph = chorus.partGraph.map((node) => ({ ...node, is_active: true })); + const vocal = chorus.roles.find((role) => role.id === "lead-vocal")!; + const hostile = new Proxy(vocal, { + get(_target, property) { + if (property === "fadePlan") { + return "Fade this part; let the next downbeat land quieter."; + } + return Reflect.get(_target, property); + } + }); + chorus.roles = chorus.roles.map((role) => (role.id === "lead-vocal" ? hostile : role)); + song.sections = [verse, chorus]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts new file mode 100644 index 000000000..d0427f248 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.source-continuity.test.ts @@ -0,0 +1,56 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstFadePlan } from "./firstFadePlan"; + +const FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +describe("resolveFirstFadePlan source continuity", () => { + it("keeps the shared accompaniment source across a keys-to-guitar role swap", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const bass = structuredClone(seed.roles.find((role) => role.id === "bass-guitar")!); + const keys = structuredClone(seed.roles.find((role) => role.id === "keys-right")!); + const guitar = structuredClone(keys); + guitar.id = "acoustic-guitar"; + guitar.name = "Acoustic Guitar"; + const vocal = structuredClone(seed.roles.find((role) => role.id === "lead-vocal")!); + + for (const role of [bass, keys, guitar, vocal]) { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + } + vocal.fadePlan = FADE_PLAN; + vocal.fadePlanSource = "model"; + + const previous = structuredClone(seed); + previous.id = "verse-hold"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys, vocal]; + previous.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: "acoustic-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-fade"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + current.roles = [bass, guitar, vocal]; + current.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "acoustic-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + song.sections = [previous, current]; + + const resolved = resolveFirstFadePlan(song); + expect(resolved?.sectionId).toBe("chorus-fade"); + expect(resolved?.landingRoleId).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe(FADE_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.test.ts b/apps/desktop/src/features/workspace/firstFadePlan.test.ts new file mode 100644 index 000000000..0ca8852da --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatFadePlanTime, resolveFirstFadePlan } from "./firstFadePlan"; + +const DEMO_FADE_PLAN = "Fade this part; let the next downbeat land quieter."; + +function withFadeSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + fadePlan?: string; + label?: + | "intro" + | "verse" + | "pre-chorus" + | "chorus" + | "bridge" + | "outro" + | "tag" + | "pickup" + | "stop" + | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + source?: "model" | "user"; + isActive?: boolean; + wasActive?: boolean; + previousVocalActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const landingStart = overrides.start ?? 10; + const previousStart = overrides.previousStart ?? 0; + const roleId = overrides.roleId ?? "lead-vocal"; + const keys = structuredClone(verse.roles.find((role) => role.id === "keys-right")!); + const vocal = structuredClone(verse.roles.find((role) => role.id === "lead-vocal")!); + const bass = structuredClone(verse.roles.find((role) => role.id === "bass-guitar")!); + delete (keys as { fadePlan?: string }).fadePlan; + delete (keys as { fadePlanSource?: string }).fadePlanSource; + delete (vocal as { fadePlan?: string }).fadePlan; + delete (vocal as { fadePlanSource?: string }).fadePlanSource; + delete (bass as { fadePlan?: string }).fadePlan; + delete (bass as { fadePlanSource?: string }).fadePlanSource; + + const landing = { + ...(roleId === "keys-right" ? keys : roleId === "bass-guitar" ? bass : vocal), + id: roleId, + name: + overrides.roleName ?? + (roleId === "keys-right" + ? "Keyboard 1 Right Hand" + : roleId === "bass-guitar" + ? "Bass Guitar" + : "Lead Vocal"), + rehearsalPriority: overrides.priority ?? "high", + fadePlan: overrides.fadePlan ?? DEMO_FADE_PLAN, + fadePlanSource: overrides.source ?? "model" + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-fade"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 20 }; + 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 === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.roles = [landing, bass, vocal]; + } + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.roles = [landing, keys, vocal]; + } + + const previous = structuredClone(current); + previous.id = `${current.id}-hold`; + previous.label = "verse"; + previous.timeRange = { start: previousStart, end: landingStart }; + previous.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + previous.roles.forEach((role) => { + delete (role as { fadePlan?: string }).fadePlan; + delete (role as { fadePlanSource?: string }).fadePlanSource; + }); + const previousVocalActive = overrides.previousVocalActive ?? true; + previous.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: previousVocalActive, + handoff_to: [], + handoff_from: [] + } + ]; + if (overrides.wasActive === false) { + previous.partGraph = previous.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId ? false : node.is_active + })); + } + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstFadePlan", () => { + it("picks the earliest fade plan and the part that quiets in place", () => { + const resolved = resolveFirstFadePlan(withFadeSection()); + expect(resolved?.section.id).toBe("chorus-fade"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe(DEMO_FADE_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatFadePlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatFadePlanTime(Number.NaN)).toBe("0:00"); + expect(formatFadePlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a fade plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, vamp plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, hit plans, cutoff plans, turnaround plans, pickup plans, breakdown plans, drop plans, swell plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withFadeSection(); + delete song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.fadePlan; + const landing = song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!; + song.sections[1]!.groove = "Straight eighths with a late snare feel"; + landing.simplification = "Stay on roots if the chorus entrance gets muddy."; + landing.setupNote = DEMO_FADE_PLAN; + landing.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + (landing as { vampPlan?: string }).vampPlan = + "Keep this part going until Lead Vocal enters in the next section."; + (landing as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (landing as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (landing as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (landing as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the pickup."; + (landing as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (landing as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (landing as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + (landing as { hitPlan?: string }).hitPlan = + "Land this hit with Lead Vocal on the verse downbeat; don't drift past the pickup."; + (landing as { cutoffPlan?: string }).cutoffPlan = + "Cut this off with Lead Vocal on the verse last beat; don't linger past the pickup."; + (landing as { turnaroundPlan?: string }).turnaroundPlan = + "Turn these last bars with Lead Vocal; land the downbeat together."; + (landing as { pickupPlan?: string }).pickupPlan = + "Play this pickup with Lead Vocal; land the downbeat together."; + (landing as { breakdownPlan?: string }).breakdownPlan = + "Hold this breakdown; keep it sparse until the drop."; + (landing as { dropPlan?: string }).dropPlan = + "Hit this drop; come in together when the texture fills."; + landing.cue = { kind: "lyric", value: "city lights" }; + landing.range = { lowestNote: "G#3", highestNote: "C#5" }; + landing.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + landing.harmony = { + chord: "C#m7", + functionLabel: "vi pedal anchor", + source: "user" + }; + landing.harmonicExplanation = "The vocal lands the chorus center."; + landing.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + landing.confidence = { + level: "high", + source: "user", + notes: DEMO_FADE_PLAN + }; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("skips a blank fade plan", () => { + expect(resolveFirstFadePlan(withFadeSection({ fadePlan: " " }))).toBeNull(); + }); + + it("skips a multi-line fade plan", () => { + expect( + resolveFirstFadePlan(withFadeSection({ fadePlan: "Grow together.\nLeave the stack." })) + ).toBeNull(); + }); + + it("preserves long user-authored fade copy verbatim", () => { + const fadePlan = `${"Fade together. ".repeat(20)}Keep the landing clear.`; + expect( + resolveFirstFadePlan(withFadeSection({ fadePlan, source: "user" }))?.fadePlan + ).toBe(fadePlan); + }); + + it("prefers the earlier of two fade plans", () => { + const song = withFadeSection({ + id: "chorus-late-fade", + start: 40, + end: 56, + previousStart: 24, + roleId: "lead-vocal", + fadePlan: "Late fade.", + source: "user" + }); + const earlier = structuredClone(song.sections[1]!); + earlier.id = "chorus-early"; + earlier.roles = [ + { + ...earlier.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + fadePlan: "Earlier fade." + }, + ...earlier.roles.filter((role) => role.id !== "lead-vocal") + ]; + earlier.timeRange = { start: 8, end: 24 }; + const earlierHold = structuredClone(song.sections[0]!); + earlierHold.id = "verse-before-early"; + earlierHold.timeRange = { start: 0, end: 8 }; + song.sections[0]!.timeRange = { start: 24, end: 40 }; + song.sections = [earlierHold, earlier, song.sections[0]!, song.sections[1]!]; + + const resolved = resolveFirstFadePlan(song); + expect(resolved?.section.id).toBe("chorus-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.fadePlan).toBe("Earlier fade."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time fade-plan ties with locale-independent id ordering", () => { + const song = withFadeSection({ id: "ä-fade", start: 10, end: 26 }); + const umlautHold = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiHold = structuredClone(umlautHold); + asciiHold.id = "z-fade-hold"; + const ascii = structuredClone(umlaut); + ascii.id = "z-fade"; + song.sections = [umlautHold, umlaut, asciiHold, ascii]; + + expect(resolveFirstFadePlan(song)?.section.id).toBe("z-fade"); + }); + + it("prefers a high-priority landing part over a low-priority part in the same section", () => { + const song = withFadeSection({ + roleId: "bass-guitar", + roleName: "Bass Guitar", + priority: "low", + fadePlan: "Low-priority fade.", + source: "user" + }); + const section = song.sections[1]!; + const highRole = { + ...section.roles.find((role) => role.id === "lead-vocal")!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + fadePlan: "High-priority fade.", + fadePlanSource: "user" as const + }; + section.roles = [...section.roles.filter((role) => role.id !== "lead-vocal"), highRole]; + section.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: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstFadePlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstFadePlan(song)?.fadePlan).toBe("High-priority fade."); + }); + + it("skips a fade plan whose graph node is inactive", () => { + expect(resolveFirstFadePlan(withFadeSection({ isActive: false }))).toBeNull(); + }); + + it("skips a fade plan whose previous graph node was inactive", () => { + expect(resolveFirstFadePlan(withFadeSection({ wasActive: false }))).toBeNull(); + }); + + it("skips a fade whose previous graph did not already include the landing", () => { + expect(resolveFirstFadePlan(withFadeSection({ previousVocalActive: false }))).toBeNull(); + }); + + it("skips a fade plan whose rest and landing windows do not abut", () => { + const song = withFadeSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("skips a fade plan whose rehearsal window is unbounded", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a fade plan whose end precedes its start", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length fade-plan window", () => { + expect(resolveFirstFadePlan(withFadeSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a fade plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstFadePlan( + withFadeSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstFadePlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withFadeSection(); + song.sections[1]!.roles = [null as never, ...song.sections[1]!.roles]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstFadePlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withFadeSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("keeps the fade plan unnamed when role identities are duplicated", () => { + const song = withFadeSection(); + const role = song.sections[1]!.roles.find((item) => item.id === "lead-vocal")!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("does not name a density fill as a fade", () => { + const song = withFadeSection({ previousVocalActive: false }); + song.sections[1]!.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: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); + + it("does not name a density drop as a fade", () => { + const song = withFadeSection(); + song.sections[1]!.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstFadePlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstFadePlan.ts b/apps/desktop/src/features/workspace/firstFadePlan.ts new file mode 100644 index 000000000..a2310f48c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstFadePlan.ts @@ -0,0 +1,436 @@ +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_FADE_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +const ACCOMPANIMENT_SOURCE_ROLE_IDS = new Set([ + "keys-left", + "keys-right", + "acoustic-guitar" +]); +const ACCOMPANIMENT_SOURCE_ID = "other"; +const FADE_PLAN_SOLO = "Fade this part; let the next downbeat land quieter."; +const FADE_PLAN_PREFIX = "Fade this part with "; +const FADE_PLAN_SUFFIX = "; let the next downbeat land quieter."; + +type FadePlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated fade-plan copy. */ +export type FadePlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedFadePlan = Readonly<{ + text: string; + source: FadePlanSource; + guidance: FadePlanGuidance | null; +}>; + +/** Tonight's first fade plan: the earliest labeled intensity fall on staying sources. */ +export type FirstFadePlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + fadePlan: string; + fadePlanSource: FadePlanSource; + fadePlanGuidance: FadePlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative fade-plan time as m:ss for rehearsal copy. */ +export function formatFadePlanTime(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 items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Preserve the engine fade template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedFadePlan(value: string): OwnedFadePlan | null { + if (value === FADE_PLAN_SOLO) { + return { text: FADE_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(FADE_PLAN_PREFIX) || !value.endsWith(FADE_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(FADE_PLAN_PREFIX.length, -FADE_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(FADE_PLAN_PREFIX + FADE_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_FADE_PLAN_CHARACTERS - fixedLength); + return { + text: `${FADE_PLAN_PREFIX}${boundedTarget}${FADE_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own fade plan and its explicit provenance, or null when malformed. */ +function ownedFadePlan(role: unknown): OwnedFadePlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const fadePlan = ownDataValue(role, "fadePlan"); + const fadePlanSource = ownDataValue(role, "fadePlanSource"); + if (typeof fadePlan !== "string") { + return null; + } + if (fadePlanSource !== undefined && fadePlanSource !== "model" && fadePlanSource !== "user") { + return null; + } + if (fadePlanSource === undefined) { + return null; + } + if (!isNonEmptySingleLineText(fadePlan)) { + return null; + } + if (fadePlanSource === "model") { + const trimmed = fadePlan.trim(); + return boundedGeneratedFadePlan(trimmed); + } + return { + text: fadePlan, + source: fadePlanSource, + guidance: 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 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; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** 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; +} + +/** Map canonical accompaniment roles back to their shared source-separation stem. */ +function fadeSourceId(roleId: string): string { + return ACCOMPANIMENT_SOURCE_ROLE_IDS.has(roleId) ? ACCOMPANIMENT_SOURCE_ID : roleId; +} + +/** Prefer rehearsal priority, 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; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return unique graph role ids whose node is explicitly active or inactive. */ +function rankedGraphRoleIds(section: RehearsalSection, isActive: boolean): 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") !== isActive) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + if (!roles) { + return []; + } + const activeIds = rankedGraphRoleIds(section, true); + 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); + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && !repeatedRoleIds.has(metadata.id) && activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Return distinct source-separation stems that are explicitly active. */ +function activeSourceIds(activeIds: Set): Set { + return new Set([...activeIds].map((roleId) => fadeSourceId(roleId))); +} + +/** Resolve a fade plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstFadePlan(song: RehearsalSong): FirstFadePlan | 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) || sectionIndex === 0) { + return []; + } + const previousSection = sections[sectionIndex - 1]; + if (!isRuntimeObject(previousSection)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + const previousTimeRange = ownedBoundedTimeRange(previousSection as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null || + previousTimeRange === null || + previousTimeRange.end !== timeRange.start + ) { + return []; + } + + const previousActiveIds = rankedGraphRoleIds(previousSection as RehearsalSection, true); + const currentActiveIds = rankedGraphRoleIds(section as RehearsalSection, true); + const previousSourceIds = activeSourceIds(previousActiveIds); + const currentSourceIds = activeSourceIds(currentActiveIds); + if (previousSourceIds.size < 1 || currentSourceIds.size !== previousSourceIds.size) { + return []; + } + for (const sourceId of previousSourceIds) { + if (!currentSourceIds.has(sourceId)) { + return []; + } + } + + const landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if ( + !previousActiveIds.has(metadata.id) || + ACCOMPANIMENT_SOURCE_ROLE_IDS.has(metadata.id) + ) { + return []; + } + const fadePlan = ownedFadePlan(metadata.role); + return fadePlan === null + ? [] + : [ + { + ...metadata, + fadePlan: fadePlan.text, + fadePlanSource: fadePlan.source, + fadePlanGuidance: fadePlan.guidance + } + ]; + }) + ); + if (!landingRole) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + landingRole: landingRole.role, + landingRoleId: landingRole.id, + landingRoleName: landingRole.name, + fadePlan: landingRole.fadePlan, + fadePlanSource: landingRole.fadePlanSource, + fadePlanGuidance: landingRole.fadePlanGuidance, + 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 fade plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstFadePlan(song: RehearsalSong): FirstFadePlan | null { + try { + return resolveSafeFirstFadePlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..13671dee7 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,33 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes supported section form labels", () => { + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-fade-plan next-action copy particle-safe and tonally consistent", () => { + const t = createTranslator("ko"); + expect(t("firstFadePlanOpenAction")).toBe("{at} {role} 페이드 열기"); + expect(t("firstFadePlanBody")).toBe("{at} {section}에서 {role} 파트가 페이드합니다."); + expect(t("firstFadePlanArmed")).toBe( + "{at}에서 {role} 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요." + ); + expect(t("firstFadePlanGeneratedGuidance")).toBe( + "{target} 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ); + expect(t("firstFadePlanGeneratedSoloGuidance")).toBe( + "이 파트를 페이드하세요. 다음 다운비트까지 줄이세요." + ); + }); + }); + }); 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..36aa1d594 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,13 @@ "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}.", + "firstFadePlanLabel": "Tonight's first fade plan", + "firstFadePlanOpenAction": "Open {role} fade at {at}", + "firstFadePlanBody": "{role} fades the {section} at {at}.", + "firstFadePlanArmed": "Fade {role} together at {at} so the quieter landing is audible.", + "firstFadePlanGeneratedGuidance": "Fade this part with {target}; let the next downbeat land quieter.", + "firstFadePlanGeneratedSoloGuidance": "Fade this part; let the next downbeat land quieter.", + "firstFadePlanUnavailable": "No fade plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstFadePlanNavigationFailed": "Could not open this fade 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..8d3981361 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,13 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstFadePlanLabel": "오늘 첫 페이드 계획", + "firstFadePlanOpenAction": "{at} {role} 페이드 열기", + "firstFadePlanBody": "{at} {section}에서 {role} 파트가 페이드합니다.", + "firstFadePlanArmed": "{at}에서 {role} 파트로 함께 페이드하세요. 더 조용하게 내려앉는 게 들리도록 줄이세요.", + "firstFadePlanGeneratedGuidance": "{target} 파트와 이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.", + "firstFadePlanGeneratedSoloGuidance": "이 파트를 페이드하세요. 다음 다운비트까지 줄이세요.", + "firstFadePlanUnavailable": "사용 가능한 페이드 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstFadePlanNavigationFailed": "곡 맵에서 이 페이드를 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..48ef2d83d 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -143,6 +143,8 @@ export type RehearsalRole = { overlapWarnings: string[]; transcription?: TranscriptionNote[]; practiceProgress?: number; + fadePlan?: string; + fadePlanSource?: ProvenanceSource; }; /** Documented. */ @@ -407,6 +409,45 @@ 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 === 0x000b || + codePoint === 0x000c || + 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; +} + /** Documented. */ function invalidField(path: string): string { return `Invalid rehearsal song contract: invalid field '${path}'`; @@ -1500,7 +1541,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "fadePlan", + "fadePlanSource" ], path ); @@ -1588,6 +1631,27 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.fadePlan !== undefined && + ( + !isNonEmptySingleLineText(value.fadePlan) + ) + ) { + return invalidField(`${path}.fadePlan`); + } + if ( + value.fadePlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.fadePlanSource) + ) { + return invalidField(`${path}.fadePlanSource`); + } + if (value.fadePlanSource !== undefined && value.fadePlan === undefined) { + return invalidField(`${path}.fadePlanSource`); + } + if (value.fadePlan !== undefined && value.fadePlanSource === undefined) { + return invalidField(`${path}.fadePlanSource`); + } + return null; } diff --git a/packages/shared-types/test/fadePlanProvenance.test.ts b/packages/shared-types/test/fadePlanProvenance.test.ts new file mode 100644 index 000000000..b72af8cca --- /dev/null +++ b/packages/shared-types/test/fadePlanProvenance.test.ts @@ -0,0 +1,67 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("fadePlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s fade plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + role.fadePlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.fadePlanSource).toBe(source); + }); + + it("rejects an unknown fade plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + role.fadePlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + + it("rejects a fade plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.fadePlan; + role.fadePlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + + it("rejects fade plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = "Fade this part; let the next downbeat land quieter."; + delete role.fadePlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlanSource/); + }); + + it.each([ + "", + " ", + "\u00a0\u2003\u3000", + "fade here\nthen hold", + "fade here\rthen hold", + "fade here\u000bthen hold", + "fade here\u000cthen hold", + "fade here\u0085then hold", + "fade here\u2028then hold", + "fade here\u2029then hold" + ])( + "rejects a fade plan source with blank or multiline copy %j", + (fadePlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = fadePlan; + role.fadePlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/fadePlan/); + } + ); + + it("accepts padded single-line fade copy without normalizing it", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.fadePlan = " Fade together. \u00a0"; + role.fadePlanSource = "user"; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.fadePlan).toBe(role.fadePlan); + }); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..befdcfef2 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].fadePlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.fadePlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..0773e9edc 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) MAX_SECTION_TIME_SECONDS = 4_294_967_295 -ANALYSIS_CACHE_SCHEMA_VERSION = 1 +ANALYSIS_CACHE_SCHEMA_VERSION = 2 FEATURE_CACHE_SCHEMA_VERSION = 1 STEM_SEPARATION_TIMEOUT_SECONDS = 20.0 @@ -613,7 +613,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: diff --git a/services/analysis-engine/src/bandscope_analysis/roles/activity.py b/services/analysis-engine/src/bandscope_analysis/roles/activity.py index 623e24e77..399aa5af7 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/activity.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/activity.py @@ -26,6 +26,22 @@ STEM_NAMES = ("vocals", "bass", "drums", "other") +def _segment_rms( + audio: NDArray[np.floating[Any]] | object, + start_sample: int, + end_sample: int, +) -> float | None: + """Return RMS for one bounded stem slice, or None when the slice is unusable.""" + if not isinstance(audio, np.ndarray) or audio.size == 0: + return None + seg_start = max(0, min(start_sample, audio.size)) + seg_end = max(0, min(end_sample, audio.size)) + if seg_end <= seg_start: + return None + segment = audio[seg_start:seg_end].astype(np.float64) + return float(np.sqrt(np.mean(segment**2))) + + def detect_stem_activity( stems: dict[str, NDArray[np.floating[Any]]], boundaries: list[tuple[float, float]], @@ -61,22 +77,11 @@ def detect_stem_activity( segment_activity: dict[str, bool] = {} for stem_name, audio in stems.items(): - if not isinstance(audio, np.ndarray) or audio.size == 0: + segment_rms = _segment_rms(audio, start_sample, end_sample) + if segment_rms is None: segment_activity[stem_name] = False continue - # Extract the segment region - seg_start = min(start_sample, audio.size) - seg_end = min(end_sample, audio.size) - - if seg_end <= seg_start: - segment_activity[stem_name] = False - continue - - segment = audio[seg_start:seg_end].astype(np.float64) - segment_rms = float(np.sqrt(np.mean(segment**2))) - - # A stem is active if its segment energy exceeds threshold relative to global g_rms = global_rms.get(stem_name, 0.0) if g_rms > 0: is_active = (segment_rms / g_rms) > ACTIVITY_THRESHOLD @@ -118,6 +123,60 @@ def map_stems_to_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: } +def detect_stem_energy( + stems: dict[str, NDArray[np.floating[Any]]], + boundaries: list[tuple[float, float]], + sr: int, +) -> list[dict[str, float]]: + """Return per-segment RMS energy for each stem. + + Args: + stems: Dict mapping stem names to audio arrays. + boundaries: List of (start_seconds, end_seconds) tuples. + sr: Sample rate. + + Returns: + List of dicts mapping stem name -> RMS, one per boundary. Missing or + unusable slices fail closed as 0.0 so a fade cannot be invented from + empty audio. + """ + if not boundaries or not stems: + return [] + + energy_per_segment: list[dict[str, float]] = [] + for start_sec, end_sec in boundaries: + start_sample = int(start_sec * sr) + end_sample = int(end_sec * sr) + segment_energy: dict[str, float] = {} + for stem_name, audio in stems.items(): + rms = _segment_rms(audio, start_sample, end_sample) + segment_energy[stem_name] = 0.0 if rms is None else rms + energy_per_segment.append(segment_energy) + return energy_per_segment + + +def map_stems_to_role_energy(stem_energy: dict[str, float]) -> dict[str, float]: + """Map stem RMS onto rehearsal roles without inventing a drums landing. + + Args: + stem_energy: Dict mapping stem names to RMS energy. + + Returns: + Dict mapping role IDs to RMS. Shared accompaniment roles receive the + ``other`` stem energy but never own a fade. + """ + vocals = float(stem_energy.get("vocals", 0.0) or 0.0) + bass = float(stem_energy.get("bass", 0.0) or 0.0) + other = float(stem_energy.get("other", 0.0) or 0.0) + return { + "bass-guitar": bass, + "keys-left": other, + "keys-right": other, + "lead-vocal": vocals, + "acoustic-guitar": other, + } + + def compute_handoffs( current_roles: dict[str, bool], next_roles: dict[str, bool] | None, diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..ede5c6931 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -6,7 +6,13 @@ from typing import Any from ..sections.utils import validate_section -from .activity import compute_handoffs, detect_stem_activity, map_stems_to_roles +from .activity import ( + compute_handoffs, + detect_stem_activity, + detect_stem_energy, + map_stems_to_role_energy, + map_stems_to_roles, +) from .model import ( CueAnchorKind, PartGraphNode, @@ -22,6 +28,15 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_NAMED_FADE_ROLE_IDS = frozenset({"lead-vocal", "bass-guitar"}) +_FADE_PLAN_SOLO = "Fade this part; let the next downbeat land quieter." +_FADE_PLAN_PREFIX = "Fade this part with " +_FADE_PLAN_SUFFIX = "; let the next downbeat land quieter." +_FADE_RATIO = 1.8 +_FADE_PREVIOUS_FLOOR = 1e-4 +_FADE_CURRENT_FLOOR = 1e-4 + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -56,13 +71,23 @@ def extract( # Use real stem activity detection when we have stems and boundaries activity_maps: list[dict[str, bool]] | None = None + source_activity_maps: list[dict[str, bool]] | None = None + energy_maps: list[dict[str, float]] | None = None if stems and boundaries and len(boundaries) == len(sections): try: stem_activity = detect_stem_activity(stems, boundaries, sr) + source_activity_maps = stem_activity activity_maps = [map_stems_to_roles(sa) for sa in stem_activity] except Exception as e: logger.warning("Stem activity detection failed, using fallback: %s", e) activity_maps = None + source_activity_maps = None + try: + stem_energy = detect_stem_energy(stems, boundaries, sr) + energy_maps = [map_stems_to_role_energy(se) for se in stem_energy] + except Exception as e: + logger.warning("Stem energy detection failed, leaving fade unnamed: %s", e) + energy_maps = None for i, section in enumerate(sections): section_id = validate_section(section, i, logger) @@ -71,8 +96,27 @@ def extract( # Real activity-based topology current_activity = activity_maps[i] next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None + previous_activity = activity_maps[i - 1] if i > 0 else None + current_energy = energy_maps[i] if energy_maps is not None else None + previous_energy = energy_maps[i - 1] if energy_maps is not None and i > 0 else None topology = self._build_activity_topology( - section_id, roles, current_activity, next_activity + section_id, + roles, + current_activity, + next_activity, + previous_activity, + current_energy, + previous_energy, + current_source_activity=( + source_activity_maps[i] + if source_activity_maps is not None and i < len(source_activity_maps) + else None + ), + previous_source_activity=( + source_activity_maps[i - 1] + if source_activity_maps is not None and i > 0 + else None + ), ) else: # Fallback to heuristic-based topology @@ -330,12 +374,123 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _source_id(role_id: str) -> str: + """Collapse accompaniment stems onto one rehearsal source.""" + return "other" if role_id in _OTHER_STEM_ROLE_IDS else role_id + + @staticmethod + def _active_role_ids(role_activity: dict[str, bool]) -> set[str]: + """Return role ids whose activity flag is explicitly true.""" + return {role_id for role_id, is_active in role_activity.items() if is_active} + + @classmethod + def _source_ids(cls, role_ids: set[str]) -> set[str]: + """Return distinct source-separation stems among the given roles.""" + return {cls._source_id(role_id) for role_id in role_ids} + + @staticmethod + def _active_source_ids(source_activity: dict[str, bool]) -> set[str]: + """Return raw active source names before role mapping can discard a stem.""" + return {source_id for source_id, is_active in source_activity.items() if is_active} + + def _named_fade_ids( + self, + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool], + role_energy: dict[str, float] | None, + previous_role_energy: dict[str, float] | None, + source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, + ) -> set[str]: + """Return named staying roles whose RMS fell by the fade ratio.""" + if role_energy is None or previous_role_energy is None: + return set() + previous_active = self._active_role_ids(previous_role_activity) + current_active = self._active_role_ids(role_activity) + previous_source_ids = ( + self._active_source_ids(previous_source_activity) + if previous_source_activity is not None + else self._source_ids(previous_active) + ) + current_source_ids = ( + self._active_source_ids(source_activity) + if source_activity is not None + else self._source_ids(current_active) + ) + if previous_source_ids != current_source_ids: + return set() + faded: set[str] = set() + for role_id in _NAMED_FADE_ROLE_IDS & current_active & previous_active: + previous_rms = float(previous_role_energy.get(role_id, 0.0) or 0.0) + current_rms = float(role_energy.get(role_id, 0.0) or 0.0) + if previous_rms < _FADE_PREVIOUS_FLOOR: + continue + if current_rms < _FADE_CURRENT_FLOOR: + continue + if previous_rms < current_rms * _FADE_RATIO: + continue + faded.add(role_id) + return faded + + def _activity_fade_plan( + self, + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool] | None, + role_energy: dict[str, float] | None, + previous_role_energy: dict[str, float] | None, + source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, + ) -> str | None: + """Return bounded fade guidance only for a corroborated intensity fall. + + A fade plan is emitted only when real stem activity shows this named + part staying while its RMS falls by at least 1.8× after an already + audible previous section, the current section stays audible, and the + distinct source set does not change. A density fill (drop), a thinning + hold (breakdown), a leaving part (dropout), a cutoff to silence, a + first-section, heuristic topology, or an accompaniment ``other`` + landing stay unnamed. + """ + if previous_role_activity is None: + return None + if role_id in _OTHER_STEM_ROLE_IDS: + return None + faded = self._named_fade_ids( + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + source_activity, + previous_source_activity, + ) + if role_id not in faded: + return None + partners = sorted(faded - {role_id}) + if not partners: + return _FADE_PLAN_SOLO + partner_id = partners[0] + other_name = next( + (role["name"] for role in roles.values() if role["id"] == partner_id), + None, + ) + if other_name is None: + return None + return f"{_FADE_PLAN_PREFIX}{other_name}{_FADE_PLAN_SUFFIX}" + def _build_activity_topology( self, section_id: str, roles: dict[str, RehearsalRole], role_activity: dict[str, bool], next_role_activity: dict[str, bool] | None, + previous_role_activity: dict[str, bool] | None = None, + role_energy: dict[str, float] | None = None, + previous_role_energy: dict[str, float] | None = None, + current_source_activity: dict[str, bool] | None = None, + previous_source_activity: dict[str, bool] | None = None, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -357,7 +512,22 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + fade_plan = self._activity_fade_plan( + role_id, + roles, + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + current_source_activity, + previous_source_activity, + ) + if fade_plan is not None: + role = role.copy() + role["fadePlan"] = fade_plan + role["fadePlanSource"] = "model" + active_roles.append(role) part_graph.append( { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..5d0ac16b8 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,8 @@ class RehearsalRole(TypedDict): setupNote: str manualOverrides: list[ManualOverride] overlapWarnings: list[str] + fadePlan: NotRequired[str] + fadePlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_activity.py b/services/analysis-engine/tests/test_activity.py index 9c3024c28..42a2cccfd 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -5,6 +5,8 @@ from bandscope_analysis.roles.activity import ( compute_handoffs, detect_stem_activity, + detect_stem_energy, + map_stems_to_role_energy, map_stems_to_roles, ) @@ -116,3 +118,56 @@ def test_compute_handoffs_no_changes_means_no_handoffs() -> None: for role_id in current: assert handoffs[role_id] == ([], []) + + +def test_detect_stem_energy_returns_segment_rms() -> None: + """Energy maps expose per-section RMS so a fade can be corroborated.""" + sr = 8 + vocals = np.concatenate( + [np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)] + ) + bass = np.full(sr * 2, 0.4, dtype=np.float32) + energy = detect_stem_energy( + {"vocals": vocals, "bass": bass}, + [(0.0, 1.0), (1.0, 2.0)], + sr, + ) + assert energy[0]["vocals"] > energy[1]["vocals"] * 1.8 + assert abs(energy[0]["bass"] - energy[1]["bass"]) < 1e-6 + + +def test_detect_stem_energy_fails_closed_for_empty_slices() -> None: + """Unusable slices must not invent fade energy.""" + stems = { + "vocals": np.array([], dtype=np.float32), + "bass": np.ones(10, dtype=np.float32), + } + energy = detect_stem_energy(stems, [(1.0, 2.0)], 10) + assert energy == [{"vocals": 0.0, "bass": 0.0}] + + +def test_detect_stem_energy_fails_closed_for_negative_boundaries() -> None: + """Negative section metadata must not read wrapped samples from the stem.""" + audio = np.zeros(20, dtype=np.float32) + audio[5:15] = 1.0 + + energy = detect_stem_energy({"vocals": audio}, [(-1.5, -0.5)], 10) + + assert energy == [{"vocals": 0.0}] + + +def test_detect_stem_energy_returns_empty_without_boundaries_or_stems() -> None: + """No input segments must produce no energy maps.""" + assert detect_stem_energy({}, [(0.0, 1.0)], 10) == [] + assert detect_stem_energy({"vocals": np.ones(10, dtype=np.float32)}, [], 10) == [] + + +def test_map_stems_to_role_energy_shares_other_without_drums() -> None: + """Accompaniment roles share other energy; drums never land a fade.""" + mapped = map_stems_to_role_energy({"vocals": 0.2, "bass": 0.4, "other": 0.9, "drums": 1.0}) + assert mapped["lead-vocal"] == 0.2 + assert mapped["bass-guitar"] == 0.4 + assert mapped["keys-left"] == 0.9 + assert mapped["keys-right"] == 0.9 + assert mapped["acoustic-guitar"] == 0.9 + assert "drums" not in mapped diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 18273791d..93f3ee962 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -582,7 +582,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 +648,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 diff --git a/services/analysis-engine/tests/test_fade_plan.py b/services/analysis-engine/tests/test_fade_plan.py new file mode 100644 index 000000000..6ea60c77e --- /dev/null +++ b/services/analysis-engine/tests/test_fade_plan.py @@ -0,0 +1,408 @@ +"""Tests for corroborated fade-plan emission.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from bandscope_analysis.roles.extractor import RoleExtractor +from bandscope_analysis.roles.model import RehearsalRole + +_SOLO_PLAN = "Fade this part; let the next downbeat land quieter." +_PREFIX = "Fade this part with " +_SUFFIX = "; let the next downbeat land quieter." + + +def _activity( + *, + bass: bool, + keys_right: bool, + vocal: bool, + keys_left: bool = False, + guitar: bool = False, + extra: dict[str, bool] | None = None, +) -> dict[str, bool]: + """Return a complete role-activity map for one section.""" + activity = { + "bass-guitar": bass, + "keys-left": keys_left, + "keys-right": keys_right, + "lead-vocal": vocal, + "acoustic-guitar": guitar, + } + if extra: + activity.update(extra) + return activity + + +def _energy( + *, + bass: float, + vocal: float, + other: float = 0.2, +) -> dict[str, float]: + """Return RMS energy mapped onto rehearsal roles.""" + return { + "bass-guitar": bass, + "keys-left": other, + "keys-right": other, + "lead-vocal": vocal, + "acoustic-guitar": other, + } + + +def _roles(extractor: RoleExtractor) -> dict[str, RehearsalRole]: + """Return canonical bass and vocal role fixtures for topology tests.""" + return extractor._build_roles( + "C#m7", + {"lowestNote": "C#2", "highestNote": "E3"}, + "C#m7", + {"lowestNote": "G#3", "highestNote": "C#5"}, + ) + + +def test_activity_fade_emits_solo_plan_for_a_staying_vocal_fall() -> None: + """A staying vocal that falls 1.8× names the fade without inventing a cutoff.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["fadePlan"] == _SOLO_PLAN + assert vocal["fadePlanSource"] == "model" + assert all( + "fadePlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"] + ) + + +def test_activity_fade_names_two_named_falls_as_partners() -> None: + """Vocal and bass falling together point at each other.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["fadePlan"] == f"{_PREFIX}Bass Guitar{_SUFFIX}" + assert roles_by_id["bass-guitar"]["fadePlan"] == f"{_PREFIX}Lead Vocal{_SUFFIX}" + assert "fadePlan" not in roles_by_id["keys-right"] + + +def test_activity_fade_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a fade.""" + extractor = RoleExtractor() + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "verse-1", + _roles(extractor), + current, + None, + None, + _energy(bass=0.2, vocal=0.2), + None, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a fade plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_density_fill() -> None: + """A new entrance is a drop, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "drop-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.0), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_density_drop() -> None: + """A thinning hold is a breakdown, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=False) + topology = extractor._build_activity_topology( + "breakdown-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.0, other=0.0), + _energy(bass=0.5, vocal=0.5, other=0.5), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_ratio_is_too_small() -> None: + """A small mix dip is not a corroborated fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "mix-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.25), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_previous_energy_is_silent() -> None: + """Silence-to-quiet is not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "from-silence-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.0), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_current_energy_is_silent() -> None: + """Loud-to-silence is a cutoff, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "cutoff-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.0), + _energy(bass=0.2, vocal=0.5), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_for_a_swell() -> None: + """An intensity rise is a swell, not a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "swell-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.5), + _energy(bass=0.2, vocal=0.2), + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_does_not_assign_an_other_stem_landing() -> None: + """The shared other stem may stay in the texture but never owns the fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True, keys_left=True, guitar=True) + current = _activity(bass=True, keys_right=True, vocal=True, keys_left=True, guitar=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2, other=0.2), + _energy(bass=0.2, vocal=0.5, other=0.9), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["fadePlan"] == _SOLO_PLAN + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "fadePlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_fade_keeps_shared_accompaniment_source_across_role_swap() -> None: + """A role swap inside the shared other stem does not invent a source change.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=False, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["fadePlan"] == _SOLO_PLAN + + +def test_activity_fade_stays_unnamed_when_a_drum_source_enters() -> None: + """A drum entry changes the raw source set even when rendered roles stay active.""" + extractor = RoleExtractor() + role_activity = _activity(bass=True, keys_right=True, vocal=True) + previous_sources = {"bass": True, "other": True, "vocals": True, "drums": False} + current_sources = {"bass": True, "other": True, "vocals": True, "drums": True} + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + role_activity, + None, + role_activity, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + current_source_activity=current_sources, + previous_source_activity=previous_sources, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_a_drum_source_exits() -> None: + """A drum exit changes the raw source set even when rendered roles stay active.""" + extractor = RoleExtractor() + role_activity = _activity(bass=True, keys_right=True, vocal=True) + previous_sources = {"bass": True, "other": True, "vocals": True, "drums": True} + current_sources = {"bass": True, "other": True, "vocals": True, "drums": False} + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + role_activity, + None, + role_activity, + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + current_source_activity=current_sources, + previous_source_activity=previous_sources, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_extract_emits_fade_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section energy into fade emission.""" + extractor = RoleExtractor() + sr = 8 + bass = np.full(sr * 2, 0.4, dtype=np.float32) + other = np.full(sr * 2, 0.3, dtype=np.float32) + vocal = np.concatenate([np.full(sr, 0.8, dtype=np.float32), np.full(sr, 0.2, dtype=np.float32)]) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": bass, "other": other, "vocals": vocal}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + chorus: dict[str, Any] = result["topologies"][1] + vocal_role = next(role for role in chorus["active_roles"] if role["id"] == "lead-vocal") + assert vocal_role.get("fadePlan") == _SOLO_PLAN + assert all("fadePlan" not in role for role in result["topologies"][0]["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_energy_maps_are_missing() -> None: + """Activity without RMS evidence cannot name a fade.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + None, + None, + ) + assert all("fadePlan" not in role for role in topology["active_roles"]) + + +def test_activity_fade_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-named fade without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_fade_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=True, vocal=True), + _activity(bass=True, keys_right=True, vocal=True), + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.5, vocal=0.5), + ) + assert plan is None + + +def test_activity_fade_stays_unnamed_when_role_is_inactive() -> None: + """An inactive role cannot own a fade even if RMS looks quieter.""" + extractor = RoleExtractor() + plan = extractor._activity_fade_plan( + "lead-vocal", + _roles(extractor), + _activity(bass=True, keys_right=True, vocal=False), + _activity(bass=True, keys_right=True, vocal=True), + _energy(bass=0.2, vocal=0.2), + _energy(bass=0.2, vocal=0.5), + ) + assert plan is None + + +def test_extract_leaves_fade_unnamed_when_energy_detection_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Energy failures fail closed without dropping activity topology.""" + extractor = RoleExtractor() + + def _boom(*_args: object, **_kwargs: object) -> list[dict[str, float]]: + """Force energy detection to fail closed.""" + raise RuntimeError("energy unavailable") + + monkeypatch.setattr( + "bandscope_analysis.roles.extractor.detect_stem_energy", + _boom, + ) + sr = 8 + audio = np.ones(sr * 2, dtype=np.float32) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": audio, "other": audio, "vocals": audio}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + assert result["topologies"] + assert all( + "fadePlan" not in role + for topology in result["topologies"] + for role in topology["active_roles"] + )