diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..dc60f2462 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 swell plan with the owning part when a staying named role is corroborated, the owned `swellPlan` 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, 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..2ac27d878 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 swell plan on the mounted map when section-level stem energy shows a corroborated intensity rise (same distinct source set stays, named vocals or bass RMS ≥1.8× after already-audible previous), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from 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..5e6500ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first swell plan in the mounted rehearsal workspace so the part that grows in place can lift into 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 grows by at least 1.8× after an already-audible previous section, 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..4e1410006 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 swell 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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..7e3eddcfd 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,13 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum SwellPlanSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -191,6 +198,10 @@ pub struct RehearsalRolePayload { setup_note: String, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + swell_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + swell_plan_source: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +538,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_swell_plan(value: &str) -> bool { + let mut has_non_whitespace = false; + for character in value.chars() { + if matches!( + character, + '\n' | '\r' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) { + return false; + } + if !is_plan_whitespace(character) { + has_non_whitespace = true; + } + } + has_non_whitespace +} + +fn validate_swell_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role + .swell_plan + .as_deref() + .is_some_and(|swell_plan| !is_valid_swell_plan(swell_plan)) + { + return Err("Invalid project file format".to_string()); + } + if role.swell_plan.is_none() && role.swell_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + if role.swell_plan.is_some() && role.swell_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_swell_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +616,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 swell." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Grow into the next downbeat." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal stays while the lift grows." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "swellPlan": "Swell this part; grow into the next downbeat.", + "swellPlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Grow the chorus swell together.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_swell_plan_provenance() { + let payload = song_with_swell_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 swell-plan fields"); + let serialized = + serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["swellPlan"], + payload["sections"][0]["roles"][0]["swellPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["swellPlanSource"], + json!("model") + ); +} + +#[test] +fn project_contract_rejects_swell_plan_source_without_swell_plan() { + let mut payload = song_with_swell_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("swellPlan"); + 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_swell_plan_without_source() { + let mut payload = song_with_swell_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("swellPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject swell-plan copy without provenance" + ); +} + +#[test] +fn project_contract_rejects_invalid_swell_plan_copy_with_source() { + for swell_plan in [ + "", + " ", + "\u{00A0}\u{2003}\u{3000}", + "swell here\nthen hold", + "swell here\rthen hold", + "swell here\u{0085}then hold", + "swell here\u{2028}then hold", + "swell here\u{2029}then hold", + ] { + let mut payload = song_with_swell_plan(); + payload["sections"][0]["roles"][0]["swellPlan"] = json!(swell_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 swell-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_swell_plan_source() { + let mut payload = song_with_swell_plan(); + payload["sections"][0]["roles"][0]["swellPlanSource"] = 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_swell_copy() { + let mut payload = song_with_swell_plan(); + payload["sections"][0]["roles"][0]["swellPlan"] = json!(" Grow together. \u{00A0}"); + payload["sections"][0]["roles"][0]["swellPlanSource"] = 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]["swellPlan"], + payload["sections"][0]["roles"][0]["swellPlan"] + ); +} diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..89a607a9a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +const appendedSongStructureTargets = new Set(); + +function songWithCustomSwellPlan(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.swellPlan = text; + if (source) { + vocal.swellPlanSource = 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("FirstSwellPlanCallout custom guidance", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + }); + + it("preserves user-authored swell 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 swell", () => { + appendSongStructureTarget(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" })); + + expect(screen.getByText("Lead Vocal swells the chorus at 0:30.")).toBeTruthy(); + expect(screen.getByText("Grow on the snare; don't rush the last eighth.")).toBeTruthy(); + expect(screen.queryByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeNull(); + }); + + it("fails closed for custom copy without provenance", () => { + render( + + ); + expect( + screen.getByText( + "No swell plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + expect(screen.queryByText("Stack the last bar and grow together.")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.identity.test.tsx new file mode 100644 index 000000000..08585377f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +it("gives co-mounted swell-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first swell 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/FirstSwellPlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..cbe3dd3a4 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +function songWithSwellPlan() { + 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.swellPlan = "Swell this part; grow into the next downbeat."; + vocal.swellPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstSwellPlanCallout navigation failure", () => { + it("names the next action when the rendered map target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" })); + expect( + screen.getByRole("status").textContent + ).toBe("Could not open this swell on the song map. Use the map below to find the section."); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.particle.test.tsx new file mode 100644 index 000000000..f5038524d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +function songWithKoreanSwell( + swellPlan: string, + swellPlanSource?: "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", + swellPlan, + ...(swellPlanSource ? { swellPlanSource } : {}) + } + ]; + 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("FirstSwellPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the swell action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanSwell( + "Swell this part; grow into the next downbeat.", + "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/FirstSwellPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.provenance.test.tsx new file mode 100644 index 000000000..c909614ad --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +function songWithKoreanSwell( + swellPlan: string, + swellPlanSource?: "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", + swellPlan, + ...(swellPlanSource ? { swellPlanSource } : {}) + } + ]; + 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("FirstSwellPlanCallout swell-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user swell guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Swell this part; grow into the next downbeat."; + const song = songWithKoreanSwell(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 파트를 스웰하세요. 다음 다운비트까지 키우세요.")).toBeNull(); + }); + + it("fails closed when persisted swell guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Swell this part; grow into the next downbeat."; + const song = songWithKoreanSwell(legacyPlan); + + render(); + + expect( + screen.getByText( + "사용 가능한 스웰 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + expect(screen.queryByText(legacyPlan)).toBeNull(); + 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 = songWithKoreanSwell( + "Swell this part with Keyboard 1 Right Hand; grow into the next downbeat.", + "model" + ); + + render(); + + expect( + screen.getByText( + "Keyboard 1 Right Hand 파트와 이 파트를 스웰하세요. 다음 다운비트까지 키우세요." + ) + ).toBeTruthy(); + expect( + screen.queryByText( + "Swell this part with Keyboard 1 Right Hand; grow into the next downbeat." + ) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..f571fe5ab --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +const DEMO_SWELL_PLAN = "Swell this part; grow into the next downbeat."; + +function songWithSwellPlan() { + 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.swellPlan = DEMO_SWELL_PLAN; + vocal.swellPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstSwellPlanCallout reduced motion", () => { + afterEach(() => { + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => { + node.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 swell at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.test.tsx new file mode 100644 index 000000000..197353218 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.test.tsx @@ -0,0 +1,177 @@ +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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +const DEMO_SWELL_PLAN = "Swell this part; grow into the next downbeat."; +const appendedSongStructureTargets = new Set(); + +function songWithSwellPlan() { + 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.swellPlan = DEMO_SWELL_PLAN; + vocal.swellPlanSource = "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("FirstSwellPlanCallout", () => { + 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 swell 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 = songWithSwellPlan(); + 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 swell at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithSwellPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No swell 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 swell signature", () => { + const firstSong = songWithSwellPlan(); + const nextSong = songWithSwellPlan(); + 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 swell at 0:30" })); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal swells the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithSwellPlan(); + 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 swell at 0:30" })); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Singer swells the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Swell Lead Singer together at 0:30 so the lift is audible./)).toBeNull(); + }); + + it("resets armed guidance when the section label changes in the same workspace", () => { + const firstSong = songWithSwellPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.label = "bridge"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" })); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal swells the bridge at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeNull(); + }); + + it("opens the named swell on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeTruthy(); + expect(screen.getByText(DEMO_SWELL_PLAN)).toBeTruthy(); + }); + + it("clears armed guidance when a later map navigation fails", () => { + const { grid } = appendSongStructureTarget(); + render(); + const openButton = screen.getByRole("button", { name: "Open Lead Vocal swell at 0:30" }); + + fireEvent.click(openButton); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeTruthy(); + + grid.remove(); + fireEvent.click(openButton); + + expect(screen.getByRole("status")).toBeTruthy(); + expect(screen.getByText("Lead Vocal swells the chorus at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Swell Lead Vocal together at 0:30 so the lift is audible./)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.tsx new file mode 100644 index 000000000..7f0f485e1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.tsx @@ -0,0 +1,222 @@ +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 { + formatSwellPlanTime, + resolveFirstSwellPlan, + type SwellPlanGuidance +} from "./firstSwellPlan"; + +/** Props for the first swell-plan rehearsal callout. */ +export interface FirstSwellPlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type SwellPlanCopyValues = Readonly>; +type SwellPlanSource = "model" | "user"; + +type OpenedSwellPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + swellPlan: string; + swellPlanSource: SwellPlanSource; + swellPlanGuidanceKind: SwellPlanGuidance["kind"] | null; + swellPlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableSwellPlanSongIdentity(song: RehearsalSong, workspaceInstanceKey: unknown): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate swell-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatSwellPlanCopy(template: string, values: SwellPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof SwellPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model swell guidance from structured landing topology, never from display-copy grammar. */ +function localizedSwellPlan( + swellPlan: string, + swellPlanSource: SwellPlanSource, + guidance: SwellPlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (swellPlanSource !== "model" || guidance === null) { + return swellPlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredSwellPlanScrollBehavior(): 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 resolveSwellPlanRenderer(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 swell plan and open the matching rendered map section. */ +export function FirstSwellPlanCallout({ song, workspaceInstanceKey }: FirstSwellPlanCalloutProps) { + const calloutId = `workspace-surface-swell-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableSwellPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstSwellPlan(song), [song]); + const [openedSwellPlan, setOpenedSwellPlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.swellPlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.swellPlanGuidance?.kind === "role" ? named.swellPlanGuidance.targetRoleName : null; + + useEffect(() => { + setOpenedSwellPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.swellPlan, + named?.swellPlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedSwellPlan !== null && + openedSwellPlan.songIdentity === songIdentity && + openedSwellPlan.sectionId === named.sectionId && + openedSwellPlan.sectionIndex === named.sectionIndex && + openedSwellPlan.sectionLabel === named.sectionLabel && + openedSwellPlan.landingRoleId === named.landingRoleId && + openedSwellPlan.landingRoleName === named.landingRoleName && + openedSwellPlan.swellPlan === named.swellPlan && + openedSwellPlan.swellPlanSource === named.swellPlanSource && + openedSwellPlan.swellPlanGuidanceKind === guidanceKind && + openedSwellPlan.swellPlanTargetRoleName === guidanceTargetRoleName && + openedSwellPlan.atSeconds === named.atSeconds; + const at = formatSwellPlanTime(named.atSeconds); + const copyValues: SwellPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatSwellPlanCopy(t("firstSwellPlanOpenAction"), copyValues); + const body = formatSwellPlanCopy(t("firstSwellPlanBody"), copyValues); + const armed = formatSwellPlanCopy(t("firstSwellPlanArmed"), copyValues); + const swellPlan = localizedSwellPlan( + named.swellPlan, + named.swellPlanSource, + named.swellPlanGuidance, + t("firstSwellPlanGeneratedGuidance"), + t("firstSwellPlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstSwellPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..8fd59a05d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSwellPlanCallout.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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; + +describe("FirstSwellPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English swell plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No swell plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean swell plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 스웰 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.swell-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.swell-state.test.tsx new file mode 100644 index 000000000..9fbf37cf9 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.swell-state.test.tsx @@ -0,0 +1,72 @@ +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 analyzedSongWithSwellPlan(): 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-swell-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.swellPlan = "Swell this part; grow into the next downbeat."; + vocal.swellPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace swell state authority", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + }); + + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + }); + + it("keeps an opened swell armed after an immutable practice-progress update", () => { + const song = analyzedSongWithSwellPlan(); + 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 swell at 0:30" })); + expect(screen.getByText(/Swell Lead Vocal together at 0:30 so the lift 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(/Swell Lead Vocal together at 0:30 so the lift is audible\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..3b162fb79 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, 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 { FirstSwellPlanCallout } from "./FirstSwellPlanCallout"; 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,18 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const localSongUpdateRef = useRef(null); + const workspaceInstanceRef = useRef(song); + const previousSongRef = useRef(song); + + if (song !== previousSongRef.current) { + const isLocalWorkspaceUpdate = song === localSongUpdateRef.current; + if (!isLocalWorkspaceUpdate) { + workspaceInstanceRef.current = song; + } + localSongUpdateRef.current = null; + previousSongRef.current = song; + } // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -164,6 +181,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 +212,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 +334,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ +

{t("workspaceSongTimelineLabel")}

@@ -505,7 +531,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.accompaniment-provenance.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.accompaniment-provenance.test.ts new file mode 100644 index 000000000..b67250048 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.accompaniment-provenance.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstSwellPlan } from "./firstSwellPlan"; + +const MODEL_SWELL_PLAN = "Swell this part; grow into the next downbeat."; + +describe("resolveFirstSwellPlan accompaniment provenance", () => { + it("does not name a shared accompaniment role from persisted swell 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 { swellPlan?: string }).swellPlan; + delete (role as { swellPlanSource?: string }).swellPlanSource; + } + keys.swellPlan = MODEL_SWELL_PLAN; + keys.swellPlanSource = "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 swell = structuredClone(template); + swell.id = "chorus-swell"; + swell.label = "chorus"; + swell.timeRange = { start: 10, end: 30 }; + swell.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + swell.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, swell]; + + expect(resolveFirstSwellPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.demo.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.demo.test.ts new file mode 100644 index 000000000..9c4acd110 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.demo.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstSwellPlan } from "./firstSwellPlan"; + +describe("resolveFirstSwellPlan demo topology", () => { + it("keeps heuristic demo topology unnamed until real stem energy corroborates a swell", () => { + expect(resolveFirstSwellPlan(createDemoRehearsalSong())).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.model-guidance.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.model-guidance.test.ts new file mode 100644 index 000000000..0f1dbda5b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.model-guidance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstSwellPlan } from "./firstSwellPlan"; + +it("rejects non-template model swell 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-swell"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + vocal.swellPlan = "Model says: grow the chorus hard."; + vocal.swellPlanSource = "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(resolveFirstSwellPlan(song)).toBeNull(); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.proxy-authority.test.ts new file mode 100644 index 000000000..37512202d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.proxy-authority.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstSwellPlan } from "./firstSwellPlan"; + +describe("resolveFirstSwellPlan proxy authority", () => { + it("does not read inherited or Proxy-substituted swellPlan 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 === "swellPlan") { + return "Swell this part; grow into the next downbeat."; + } + return Reflect.get(_target, property); + } + }); + chorus.roles = chorus.roles.map((role) => (role.id === "lead-vocal" ? hostile : role)); + song.sections = [verse, chorus]; + expect(resolveFirstSwellPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.source-continuity.test.ts new file mode 100644 index 000000000..c6a9a7456 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.source-continuity.test.ts @@ -0,0 +1,56 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSwellPlan } from "./firstSwellPlan"; + +const SWELL_PLAN = "Swell this part; grow into the next downbeat."; + +describe("resolveFirstSwellPlan 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 { swellPlan?: string }).swellPlan; + delete (role as { swellPlanSource?: string }).swellPlanSource; + } + vocal.swellPlan = SWELL_PLAN; + vocal.swellPlanSource = "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-swell"; + 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 = resolveFirstSwellPlan(song); + expect(resolved?.sectionId).toBe("chorus-swell"); + expect(resolved?.landingRoleId).toBe("lead-vocal"); + expect(resolved?.swellPlan).toBe(SWELL_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.test.ts b/apps/desktop/src/features/workspace/firstSwellPlan.test.ts new file mode 100644 index 000000000..db12f3714 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatSwellPlanTime, resolveFirstSwellPlan } from "./firstSwellPlan"; + +const DEMO_SWELL_PLAN = "Swell this part; grow into the next downbeat."; + +function withSwellSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + swellPlan?: string; + swellPlanSource?: "model" | "user"; + label?: + | "intro" + | "verse" + | "pre-chorus" + | "chorus" + | "bridge" + | "outro" + | "tag" + | "pickup" + | "stop" + | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + 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 { swellPlan?: string }).swellPlan; + delete (keys as { swellPlanSource?: string }).swellPlanSource; + delete (vocal as { swellPlan?: string }).swellPlan; + delete (vocal as { swellPlanSource?: string }).swellPlanSource; + delete (bass as { swellPlan?: string }).swellPlan; + delete (bass as { swellPlanSource?: string }).swellPlanSource; + + 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", + swellPlan: overrides.swellPlan ?? DEMO_SWELL_PLAN, + swellPlanSource: overrides.swellPlanSource ?? "user" + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-swell"; + 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 { swellPlan?: string }).swellPlan; + delete (role as { swellPlanSource?: string }).swellPlanSource; + }); + 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("resolveFirstSwellPlan", () => { + it("picks the earliest swell plan and the part that grows in place", () => { + const resolved = resolveFirstSwellPlan(withSwellSection()); + expect(resolved?.section.id).toBe("chorus-swell"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.swellPlan).toBe(DEMO_SWELL_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatSwellPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatSwellPlanTime(Number.NaN)).toBe("0:00"); + expect(formatSwellPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a swell 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, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withSwellSection(); + delete song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.swellPlan; + 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_SWELL_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_SWELL_PLAN + }; + expect(resolveFirstSwellPlan(song)).toBeNull(); + }); + + it("skips a blank swell plan", () => { + expect(resolveFirstSwellPlan(withSwellSection({ swellPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line swell plan", () => { + expect( + resolveFirstSwellPlan(withSwellSection({ swellPlan: "Grow together.\nLeave the stack." })) + ).toBeNull(); + }); + + it("preserves long user-authored swell copy verbatim", () => { + const swellPlan = `${"Grow together. ".repeat(20)}Keep the landing clear.`; + expect( + resolveFirstSwellPlan(withSwellSection({ swellPlan, swellPlanSource: "user" }))?.swellPlan + ).toBe(swellPlan); + }); + + it("prefers the earlier of two swell plans", () => { + const song = withSwellSection({ + id: "chorus-late-swell", + start: 40, + end: 56, + previousStart: 24, + roleId: "lead-vocal", + swellPlan: "Late swell." + }); + 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", + swellPlan: "Earlier swell.", + swellPlanSource: "user" + }, + ...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 = resolveFirstSwellPlan(song); + expect(resolved?.section.id).toBe("chorus-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.swellPlan).toBe("Earlier swell."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time swell-plan ties with locale-independent id ordering", () => { + const song = withSwellSection({ id: "ä-swell", start: 10, end: 26 }); + const umlautHold = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiHold = structuredClone(umlautHold); + asciiHold.id = "z-swell-hold"; + const ascii = structuredClone(umlaut); + ascii.id = "z-swell"; + song.sections = [umlautHold, umlaut, asciiHold, ascii]; + + expect(resolveFirstSwellPlan(song)?.section.id).toBe("z-swell"); + }); + + it("prefers a high-priority landing part over a low-priority part in the same section", () => { + const song = withSwellSection({ + roleId: "bass-guitar", + roleName: "Bass Guitar", + priority: "low", + swellPlan: "Low-priority swell." + }); + 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, + swellPlan: "High-priority swell.", + swellPlanSource: "user" + }; + 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(resolveFirstSwellPlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstSwellPlan(song)?.swellPlan).toBe("High-priority swell."); + }); + + it("skips a swell plan whose graph node is inactive", () => { + expect(resolveFirstSwellPlan(withSwellSection({ isActive: false }))).toBeNull(); + }); + + it("skips a swell plan whose previous graph node was inactive", () => { + expect(resolveFirstSwellPlan(withSwellSection({ wasActive: false }))).toBeNull(); + }); + + it("skips a swell whose previous graph did not already include the landing", () => { + expect(resolveFirstSwellPlan(withSwellSection({ previousVocalActive: false }))).toBeNull(); + }); + + it("skips a swell plan whose rest and landing windows do not abut", () => { + const song = withSwellSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstSwellPlan(song)).toBeNull(); + }); + + it("skips a swell plan whose rehearsal window is unbounded", () => { + expect(resolveFirstSwellPlan(withSwellSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a swell plan whose end precedes its start", () => { + expect(resolveFirstSwellPlan(withSwellSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length swell-plan window", () => { + expect(resolveFirstSwellPlan(withSwellSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a swell plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstSwellPlan( + withSwellSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstSwellPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withSwellSection(); + song.sections[1]!.roles = [null as never, ...song.sections[1]!.roles]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstSwellPlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withSwellSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstSwellPlan(song)).toBeNull(); + }); + + it("keeps the swell plan unnamed when role identities are duplicated", () => { + const song = withSwellSection(); + 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(resolveFirstSwellPlan(song)).toBeNull(); + }); + + it("does not name a density fill as a swell", () => { + const song = withSwellSection({ 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(resolveFirstSwellPlan(song)).toBeNull(); + }); + + it("does not name a density drop as a swell", () => { + const song = withSwellSection(); + 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(resolveFirstSwellPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSwellPlan.ts b/apps/desktop/src/features/workspace/firstSwellPlan.ts new file mode 100644 index 000000000..322850b98 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSwellPlan.ts @@ -0,0 +1,433 @@ +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_SWELL_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 SWELL_PLAN_SOLO = "Swell this part; grow into the next downbeat."; +const SWELL_PLAN_PREFIX = "Swell this part with "; +const SWELL_PLAN_SUFFIX = "; grow into the next downbeat."; + +type SwellPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated swell-plan copy. */ +export type SwellPlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedSwellPlan = Readonly<{ + text: string; + source: SwellPlanSource; + guidance: SwellPlanGuidance | null; +}>; + +/** Tonight's first swell plan: the earliest labeled intensity rise on staying sources. */ +export type FirstSwellPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + swellPlan: string; + swellPlanSource: SwellPlanSource; + swellPlanGuidance: SwellPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative swell-plan time as m:ss for rehearsal copy. */ +export function formatSwellPlanTime(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 swell template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedSwellPlan(value: string): OwnedSwellPlan | null { + if (value === SWELL_PLAN_SOLO) { + return { text: SWELL_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(SWELL_PLAN_PREFIX) || !value.endsWith(SWELL_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(SWELL_PLAN_PREFIX.length, -SWELL_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(SWELL_PLAN_PREFIX + SWELL_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_SWELL_PLAN_CHARACTERS - fixedLength); + return { + text: `${SWELL_PLAN_PREFIX}${boundedTarget}${SWELL_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own swell plan and its explicit provenance, or null when malformed. */ +function ownedSwellPlan(role: unknown): OwnedSwellPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const swellPlan = ownDataValue(role, "swellPlan"); + const swellPlanSource = ownDataValue(role, "swellPlanSource"); + if (typeof swellPlan !== "string") { + return null; + } + if (swellPlanSource !== "model" && swellPlanSource !== "user") { + return null; + } + if (!isNonEmptySingleLineText(swellPlan)) { + return null; + } + if (swellPlanSource === "model") { + const trimmed = swellPlan.trim(); + return boundedGeneratedSwellPlan(trimmed); + } + return { + text: swellPlan, + source: swellPlanSource, + 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 swellSourceId(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) => swellSourceId(roleId))); +} + +/** Resolve a swell plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstSwellPlan(song: RehearsalSong): FirstSwellPlan | 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 swellPlan = ownedSwellPlan(metadata.role); + return swellPlan === null + ? [] + : [ + { + ...metadata, + swellPlan: swellPlan.text, + swellPlanSource: swellPlan.source, + swellPlanGuidance: swellPlan.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, + swellPlan: landingRole.swellPlan, + swellPlanSource: landingRole.swellPlanSource, + swellPlanGuidance: landingRole.swellPlanGuidance, + 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 swell plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstSwellPlan(song: RehearsalSong): FirstSwellPlan | null { + try { + return resolveSafeFirstSwellPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..f432901c8 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,60 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + 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-swell-plan next-action copy particle-safe and tonally consistent", () => { + const t = createTranslator("ko"); + expect(t("firstSwellPlanOpenAction")).toBe("{at} {role} 스웰 열기"); + expect(t("firstSwellPlanBody")).toBe("{at} {section}에서 {role} 파트가 스웰합니다."); + expect(t("firstSwellPlanArmed")).toBe( + "{at}에서 {role} 파트로 함께 스웰하세요. 리프트가 들리도록 키우세요." + ); + expect(t("firstSwellPlanGeneratedGuidance")).toBe( + "{target} 파트와 이 파트를 스웰하세요. 다음 다운비트까지 키우세요." + ); + expect(t("firstSwellPlanGeneratedSoloGuidance")).toBe( + "이 파트를 스웰하세요. 다음 다운비트까지 키우세요." + ); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..4acf8fb2f 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")) { @@ -25,4 +59,4 @@ export function detectPreferredLocale(): Locale { } return "en"; -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..e731c5555 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}.", + "firstSwellPlanLabel": "Tonight's first swell plan", + "firstSwellPlanOpenAction": "Open {role} swell at {at}", + "firstSwellPlanBody": "{role} swells the {section} at {at}.", + "firstSwellPlanArmed": "Swell {role} together at {at} so the lift is audible.", + "firstSwellPlanGeneratedGuidance": "Swell this part with {target}; grow into the next downbeat.", + "firstSwellPlanGeneratedSoloGuidance": "Swell this part; grow into the next downbeat.", + "firstSwellPlanUnavailable": "No swell plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstSwellPlanNavigationFailed": "Could not open this swell 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..46e6d488a 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} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstSwellPlanLabel": "오늘 첫 스웰 계획", + "firstSwellPlanOpenAction": "{at} {role} 스웰 열기", + "firstSwellPlanBody": "{at} {section}에서 {role} 파트가 스웰합니다.", + "firstSwellPlanArmed": "{at}에서 {role} 파트로 함께 스웰하세요. 리프트가 들리도록 키우세요.", + "firstSwellPlanGeneratedGuidance": "{target} 파트와 이 파트를 스웰하세요. 다음 다운비트까지 키우세요.", + "firstSwellPlanGeneratedSoloGuidance": "이 파트를 스웰하세요. 다음 다운비트까지 키우세요.", + "firstSwellPlanUnavailable": "사용 가능한 스웰 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstSwellPlanNavigationFailed": "곡 맵에서 이 스웰을 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..b772c92a4 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; + swellPlan?: string; + swellPlanSource?: ProvenanceSource; }; /** Documented. */ @@ -407,6 +409,43 @@ function isOneOf(options: readonly T[], value: unknown): value return typeof value === "string" && options.includes(value as T); } +/** Return whether a plan is non-empty and contains no Unicode line separator. */ +export function isNonEmptySingleLineText(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + let hasNonWhitespace = false; + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint === 0x000a || + codePoint === 0x000d || + codePoint === 0x0085 || + codePoint === 0x2028 || + codePoint === 0x2029 + ) { + return false; + } + if (!( + (codePoint >= 0x0009 && codePoint <= 0x000d) || + codePoint === 0x0020 || + codePoint === 0x0085 || + codePoint === 0x00a0 || + codePoint === 0x1680 || + (codePoint >= 0x2000 && codePoint <= 0x200a) || + codePoint === 0x2028 || + codePoint === 0x2029 || + codePoint === 0x202f || + codePoint === 0x205f || + codePoint === 0x3000 || + codePoint === 0xfeff + )) { + hasNonWhitespace = true; + } + } + return hasNonWhitespace; +} + /** Documented. */ function invalidField(path: string): string { return `Invalid rehearsal song contract: invalid field '${path}'`; @@ -1500,7 +1539,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "swellPlan", + "swellPlanSource" ], path ); @@ -1588,6 +1629,27 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.swellPlan !== undefined && + ( + !isNonEmptySingleLineText(value.swellPlan) + ) + ) { + return invalidField(`${path}.swellPlan`); + } + if ( + value.swellPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.swellPlanSource) + ) { + return invalidField(`${path}.swellPlanSource`); + } + if (value.swellPlanSource !== undefined && value.swellPlan === undefined) { + return invalidField(`${path}.swellPlanSource`); + } + if (value.swellPlan !== undefined && value.swellPlanSource === undefined) { + return invalidField(`${path}.swellPlanSource`); + } + return null; } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..cc1230fda 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].swellPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.swellPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/packages/shared-types/test/swellPlanProvenance.test.ts b/packages/shared-types/test/swellPlanProvenance.test.ts new file mode 100644 index 000000000..f4f2b9bbd --- /dev/null +++ b/packages/shared-types/test/swellPlanProvenance.test.ts @@ -0,0 +1,65 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("swellPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s swell plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.swellPlan = "Swell this part; grow into the next downbeat."; + role.swellPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.swellPlanSource).toBe(source); + }); + + it("rejects an unknown swell plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.swellPlan = "Swell this part; grow into the next downbeat."; + role.swellPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/swellPlanSource/); + }); + + it("rejects a swell plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.swellPlan; + role.swellPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/swellPlanSource/); + }); + + it("rejects swell plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.swellPlan = "Swell this part; grow into the next downbeat."; + delete role.swellPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/swellPlanSource/); + }); + + it.each([ + "", + " ", + "\u00a0\u2003\u3000", + "swell here\nthen hold", + "swell here\rthen hold", + "swell here\u0085then hold", + "swell here\u2028then hold", + "swell here\u2029then hold" + ])( + "rejects a swell plan source with blank or multiline copy %j", + (swellPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.swellPlan = swellPlan; + role.swellPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/swellPlan/); + } + ); + + it("accepts padded single-line swell copy without normalizing it", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.swellPlan = " Grow together. \u00a0"; + role.swellPlanSource = "user"; + + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.swellPlan).toBe(role.swellPlan); + }); +}); diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..cae71c7ea 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 @@ -116,6 +116,8 @@ class RehearsalRolePayload(TypedDict): setupNote: str manualOverrides: list[ManualOverridePayload] overlapWarnings: list[str] + swellPlan: NotRequired[str] + swellPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNodePayload(TypedDict): @@ -613,15 +615,29 @@ 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: """Return metadata + array cache paths for intermediate local-audio features.""" - analysis_cache_path = _analysis_cache_path(request) - if analysis_cache_path is None: + if request["sourceKind"] != "local_audio" or "localSource" not in request: + return None + cache_root = request.get("cacheRoot") + if not cache_root: return None - stem_cache_base = analysis_cache_path.with_suffix("") + + local_source = request["localSource"] + key_payload = { + "schemaVersion": FEATURE_CACHE_SCHEMA_VERSION, + "projectId": request.get("projectId", ""), + "sourcePath": local_source["sourcePath"], + "fileName": local_source["fileName"], + "fileSizeBytes": local_source["fileSizeBytes"], + } + digest = hashlib.sha256( + json.dumps(key_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + stem_cache_base = Path(cache_root) / "analysis-cache-v2" / digest return ( stem_cache_base.with_suffix(".features.json"), stem_cache_base.with_suffix(".features.npz"), diff --git a/services/analysis-engine/src/bandscope_analysis/roles/activity.py b/services/analysis-engine/src/bandscope_analysis/roles/activity.py index 623e24e77..e48392d51 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 = min(start_sample, audio.size) + seg_end = 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 @@ -90,6 +95,38 @@ def detect_stem_activity( return activity_per_segment +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 swell 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_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: """Map stem activity to role activity. @@ -118,6 +155,28 @@ def map_stems_to_roles(stem_activity: dict[str, bool]) -> dict[str, bool]: } +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 swell. + """ + 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..0400ecd39 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -3,10 +3,18 @@ from __future__ import annotations import logging +from copy import deepcopy +from math import isfinite 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 +30,14 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_NAMED_SWELL_ROLE_IDS = frozenset({"lead-vocal", "bass-guitar"}) +_SWELL_PLAN_SOLO = "Swell this part; grow into the next downbeat." +_SWELL_PLAN_PREFIX = "Swell this part with " +_SWELL_PLAN_SUFFIX = "; grow into the next downbeat." +_SWELL_RATIO = 1.8 +_SWELL_PREVIOUS_FLOOR = 1e-4 + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -54,25 +70,54 @@ def extract( vocal_range, vocal_chord, bass_range, bass_chord = self._extract_features(stems, sr) roles = self._build_roles(bass_chord, bass_range, vocal_chord, vocal_range) - # Use real stem activity detection when we have stems and boundaries - activity_maps: list[dict[str, bool]] | None = None + # Keep raw stem source sets beside rendered role activity so swell + # continuity can see non-rendered sources such as drums. + activity_evidence: list[tuple[dict[str, bool], set[str]]] | 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) - activity_maps = [map_stems_to_roles(sa) for sa in stem_activity] + activity_evidence = [ + ( + map_stems_to_roles(segment_activity), + {stem_id for stem_id, is_active in segment_activity.items() if is_active}, + ) + for segment_activity in stem_activity + ] except Exception as e: logger.warning("Stem activity detection failed, using fallback: %s", e) - activity_maps = None + activity_evidence = 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 swell unnamed: %s", e) + energy_maps = None for i, section in enumerate(sections): section_id = validate_section(section, i, logger) - if activity_maps is not None: + if activity_evidence is not None: # Real activity-based topology - current_activity = activity_maps[i] - next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None + current_activity, current_source_ids = activity_evidence[i] + next_activity = ( + activity_evidence[i + 1][0] if i + 1 < len(activity_evidence) else None + ) + previous_activity = activity_evidence[i - 1][0] if i > 0 else None + stem_source_continuity = ( + current_source_ids == activity_evidence[i - 1][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, + stem_source_continuity, ) else: # Fallback to heuristic-based topology @@ -82,7 +127,7 @@ def extract( extraction_method = ( "Extracted roles from real stem activity detection." - if activity_maps is not None + if activity_evidence is not None else "Extracted roles and computed handoffs." ) @@ -330,12 +375,105 @@ 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} + + def _named_swell_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, + stem_source_continuity: bool | None = None, + ) -> set[str]: + """Return named staying roles whose RMS rose by the swell ratio.""" + if role_energy is None or previous_role_energy is None: + return set() + if stem_source_continuity is False: + return set() + previous_active = self._active_role_ids(previous_role_activity) + current_active = self._active_role_ids(role_activity) + if self._source_ids(previous_active) != self._source_ids(current_active): + return set() + swelled: set[str] = set() + for role_id in _NAMED_SWELL_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 not isfinite(previous_rms) or not isfinite(current_rms): + continue + if previous_rms < _SWELL_PREVIOUS_FLOOR: + continue + if current_rms < previous_rms * _SWELL_RATIO: + continue + swelled.add(role_id) + return swelled + + def _activity_swell_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, + stem_source_continuity: bool | None = None, + ) -> str | None: + """Return bounded swell guidance only for a corroborated intensity rise. + + A swell plan is emitted only when real stem activity shows this named + part staying while its RMS grows by at least 1.8× after an already + audible previous section, and the distinct source set does not change. + A density fill (drop), a thinning hold (breakdown), a leaving part + (dropout), 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 + swelled = self._named_swell_ids( + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + stem_source_continuity, + ) + if role_id not in swelled: + return None + partners = sorted(swelled - {role_id}) + if not partners: + return _SWELL_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"{_SWELL_PLAN_PREFIX}{other_name}{_SWELL_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, + stem_source_continuity: bool | None = None, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -357,7 +495,20 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = deepcopy(roles[role_key]) + swell_plan = self._activity_swell_plan( + role_id, + roles, + role_activity, + previous_role_activity, + role_energy, + previous_role_energy, + stem_source_continuity, + ) + if swell_plan is not None: + role["swellPlan"] = swell_plan + role["swellPlanSource"] = "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..4b1889303 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] + swellPlan: NotRequired[str] + swellPlanSource: 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..d47e28e6f 100644 --- a/services/analysis-engine/tests/test_activity.py +++ b/services/analysis-engine/tests/test_activity.py @@ -1,10 +1,13 @@ """Tests for stem activity detection and role mapping.""" import numpy as np +import pytest 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 +119,42 @@ 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: + """Ensure per-segment RMS is reported for usable slices.""" + sr = 8 + quiet = np.full(sr, 0.2, dtype=np.float32) + loud = np.full(sr, 0.8, dtype=np.float32) + vocals = np.concatenate([quiet, loud]) + energy = detect_stem_energy({"vocals": vocals}, [(0.0, 1.0), (1.0, 2.0)], sr) + assert len(energy) == 2 + assert energy[0]["vocals"] == pytest.approx(0.2, rel=1e-5) + assert energy[1]["vocals"] == pytest.approx(0.8, rel=1e-5) + + +def test_detect_stem_energy_empty_inputs() -> None: + """Ensure empty stems or boundaries return empty energy.""" + assert detect_stem_energy({}, [(0.0, 5.0)], 22050) == [] + assert detect_stem_energy({"bass": np.zeros(1000, dtype=np.float32)}, [], 22050) == [] + + +def test_detect_stem_energy_marks_empty_and_out_of_range_segments_zero() -> None: + """Ensure unusable slices fail closed as zero 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_map_stems_to_role_energy_maps_named_and_shared_stems() -> None: + """Ensure vocals and bass own energy while other is shared.""" + mapped = map_stems_to_role_energy({"vocals": 0.4, "bass": 0.2, "other": 0.1, "drums": 0.9}) + assert mapped["lead-vocal"] == 0.4 + assert mapped["bass-guitar"] == 0.2 + assert mapped["keys-left"] == 0.1 + assert mapped["keys-right"] == 0.1 + assert mapped["acoustic-guitar"] == 0.1 + 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_feature_cache_schema_independence.py b/services/analysis-engine/tests/test_feature_cache_schema_independence.py new file mode 100644 index 000000000..30a7ef4b9 --- /dev/null +++ b/services/analysis-engine/tests/test_feature_cache_schema_independence.py @@ -0,0 +1,37 @@ +"""Regression coverage for independent final-result and feature cache schemas.""" + +from unittest.mock import patch + +from bandscope_analysis.api import ( + _analysis_cache_path, + _feature_cache_paths, + validate_analysis_job_request, +) + + +def test_feature_cache_paths_survive_final_analysis_schema_bumps(tmp_path) -> None: + """Keep reusable separated stems when only the final result schema changes.""" + request = validate_analysis_job_request( + { + "sourceKind": "local_audio", + "projectId": "project-cache", + "sourceLabel": "late-night-set.wav", + "roleFocus": ["bass-guitar"], + "localSource": { + "sourcePath": "/Users/test/Music/late-night-set.wav", + "fileName": "late-night-set.wav", + "extension": "wav", + "fileSizeBytes": 1024000, + }, + "cacheRoot": str(tmp_path / "cache"), + } + ) + original_analysis_path = _analysis_cache_path(request) + original_feature_paths = _feature_cache_paths(request) + + with patch("bandscope_analysis.api.ANALYSIS_CACHE_SCHEMA_VERSION", 999): + bumped_analysis_path = _analysis_cache_path(request) + bumped_feature_paths = _feature_cache_paths(request) + + assert bumped_analysis_path != original_analysis_path + assert bumped_feature_paths == original_feature_paths diff --git a/services/analysis-engine/tests/test_role_extractor_section_isolation.py b/services/analysis-engine/tests/test_role_extractor_section_isolation.py new file mode 100644 index 000000000..b20276f71 --- /dev/null +++ b/services/analysis-engine/tests/test_role_extractor_section_isolation.py @@ -0,0 +1,28 @@ +"""Regression tests for section-local role ownership in RoleExtractor.""" + +from bandscope_analysis.roles.extractor import RoleExtractor + + +def _empty_range() -> dict[str, str]: + """Return the minimal empty range accepted by the role builder.""" + return {"lowestNote": "", "highestNote": ""} + + +def test_activity_topologies_do_not_share_mutable_role_objects() -> None: + """Mutating one section's active role cannot alter another section or the role template.""" + extractor = RoleExtractor() + roles = extractor._build_roles("", _empty_range(), "", _empty_range()) + activity = {"lead-vocal": True} + + first = extractor._build_activity_topology("verse-1", roles, activity, None) + second = extractor._build_activity_topology("verse-2", roles, activity, None) + + first_vocal = first["active_roles"][0] + second_vocal = second["active_roles"][0] + first_vocal["setupNote"] = "section-local edit" + + assert second_vocal["setupNote"] != "section-local edit" + assert roles["vocal"]["setupNote"] != "section-local edit" + assert first_vocal is not second_vocal + assert first_vocal is not roles["vocal"] + assert second_vocal is not roles["vocal"] diff --git a/services/analysis-engine/tests/test_swell_nonfinite_energy.py b/services/analysis-engine/tests/test_swell_nonfinite_energy.py new file mode 100644 index 000000000..8bb334918 --- /dev/null +++ b/services/analysis-engine/tests/test_swell_nonfinite_energy.py @@ -0,0 +1,73 @@ +"""Regression tests for fail-closed swell energy validation.""" + +from __future__ import annotations + +import math + +import pytest + +from bandscope_analysis.roles.extractor import RoleExtractor + + +def _activity() -> dict[str, bool]: + """Return a stable section where bass, accompaniment, and vocal all stay active.""" + return { + "bass-guitar": True, + "keys-left": False, + "keys-right": True, + "lead-vocal": True, + "acoustic-guitar": False, + } + + +def _energy(*, vocal: float) -> dict[str, float]: + """Return role energy with only vocal varied by the regression.""" + return { + "bass-guitar": 0.2, + "keys-left": 0.2, + "keys-right": 0.2, + "lead-vocal": vocal, + "acoustic-guitar": 0.2, + } + + +def _roles(extractor: RoleExtractor): + """Build canonical role fixtures used by the production topology path.""" + return extractor._build_roles( + "C#m7", + {"lowestNote": "C#2", "highestNote": "E3"}, + "C#m7", + {"lowestNote": "G#3", "highestNote": "C#5"}, + ) + + +@pytest.mark.parametrize("current_rms", [math.nan, math.inf]) +def test_activity_swell_rejects_nonfinite_current_rms(current_rms: float) -> None: + """NaN or infinity in current RMS must never manufacture a swell plan.""" + extractor = RoleExtractor() + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + _activity(), + None, + _activity(), + _energy(vocal=current_rms), + _energy(vocal=0.2), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +@pytest.mark.parametrize("previous_rms", [math.nan, math.inf]) +def test_activity_swell_rejects_nonfinite_previous_rms(previous_rms: float) -> None: + """NaN or infinity in previous RMS must fail closed symmetrically.""" + extractor = RoleExtractor() + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + _activity(), + None, + _activity(), + _energy(vocal=0.5), + _energy(vocal=previous_rms), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) diff --git a/services/analysis-engine/tests/test_swell_plan.py b/services/analysis-engine/tests/test_swell_plan.py new file mode 100644 index 000000000..f6c4986f4 --- /dev/null +++ b/services/analysis-engine/tests/test_swell_plan.py @@ -0,0 +1,363 @@ +"""Tests for corroborated swell-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 = "Swell this part; grow into the next downbeat." +_PREFIX = "Swell this part with " +_SUFFIX = "; grow into the next downbeat." + + +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_swell_emits_solo_plan_for_a_staying_vocal_rise() -> None: + """A staying vocal that grows 1.8× names the swell without inventing a drop.""" + 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.5), + _energy(bass=0.2, vocal=0.2), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["swellPlan"] == _SOLO_PLAN + assert vocal["swellPlanSource"] == "model" + assert all( + "swellPlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"] + ) + + +def test_activity_swell_names_two_named_rises_as_partners() -> None: + """Vocal and bass growing 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.5, vocal=0.5), + _energy(bass=0.2, vocal=0.2), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["swellPlan"] == f"{_PREFIX}Bass Guitar{_SUFFIX}" + assert roles_by_id["bass-guitar"]["swellPlan"] == f"{_PREFIX}Lead Vocal{_SUFFIX}" + assert "swellPlan" not in roles_by_id["keys-right"] + + +def test_activity_swell_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a swell.""" + 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.5, vocal=0.5), + None, + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a swell plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_for_a_density_fill() -> None: + """A new entrance is a drop, not a swell.""" + 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.5, vocal=0.5), + _energy(bass=0.2, vocal=0.0), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_for_a_density_drop() -> None: + """A thinning hold is a breakdown, not a swell.""" + 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.5, vocal=0.0, other=0.0), + _energy(bass=0.2, vocal=0.2, other=0.2), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_when_ratio_is_too_small() -> None: + """A small mix lift is not a corroborated swell.""" + 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.25), + _energy(bass=0.2, vocal=0.2), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_when_previous_energy_is_silent() -> None: + """Silence-to-loud is an entrance, not a swell.""" + 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.5), + _energy(bass=0.2, vocal=0.0), + ) + assert all("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_does_not_assign_an_other_stem_landing() -> None: + """The shared other stem may stay in the texture but never owns the swell.""" + 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.5, other=0.9), + _energy(bass=0.2, vocal=0.2, other=0.2), + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["swellPlan"] == _SOLO_PLAN + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "swellPlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_swell_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.5), + _energy(bass=0.2, vocal=0.2), + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["swellPlan"] == _SOLO_PLAN + + +def test_extract_emits_swell_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section energy into swell 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.2, dtype=np.float32), np.full(sr, 0.8, 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("swellPlan") == _SOLO_PLAN + assert all("swellPlan" not in role for role in result["topologies"][0]["active_roles"]) + + +@pytest.mark.parametrize(("previous_drums", "current_drums"), [(0.0, 0.6), (0.6, 0.0)]) +def test_extract_leaves_swell_unnamed_when_drum_source_set_changes( + previous_drums: float, + current_drums: float, +) -> None: + """A drum entrance or exit changes the real source set and is not a swell.""" + 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.2, dtype=np.float32), np.full(sr, 0.8, dtype=np.float32)]) + drums = np.concatenate( + [ + np.full(sr, previous_drums, dtype=np.float32), + np.full(sr, current_drums, dtype=np.float32), + ] + ) + result = extractor.extract( + [{"id": "verse-1"}, {"id": "chorus-1"}], + { + "stems": {"bass": bass, "drums": drums, "other": other, "vocals": vocal}, + "sr": sr, + "boundaries": [(0.0, 1.0), (1.0, 2.0)], + }, + ) + chorus: dict[str, Any] = result["topologies"][1] + assert all("swellPlan" not in role for role in chorus["active_roles"]) + + +def test_activity_swell_stays_unnamed_when_energy_maps_are_missing() -> None: + """Activity without RMS evidence cannot name a swell.""" + 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("swellPlan" not in role for role in topology["active_roles"]) + + +def test_activity_swell_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-named swell without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_swell_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=True, vocal=True), + _activity(bass=True, keys_right=True, vocal=True), + _energy(bass=0.5, vocal=0.5), + _energy(bass=0.2, vocal=0.2), + ) + assert plan is None + + +def test_activity_swell_stays_unnamed_when_role_is_inactive() -> None: + """An inactive role cannot own a swell even if RMS looks louder.""" + extractor = RoleExtractor() + plan = extractor._activity_swell_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.5), + _energy(bass=0.2, vocal=0.2), + ) + assert plan is None + + +def test_extract_leaves_swell_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( + "swellPlan" not in role + for topology in result["topologies"] + for role in topology["active_roles"] + )