diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..7b3d4af3b 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 drop plan with the owning part when an entering role is corroborated, the owned `dropPlan` 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, 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..1fb06abef 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 drop plan on the mounted map when section-level stem activity shows a corroborated density fill (previous graph 1–2 distinct sources, current graph ≥3 sources, previous sources stay, new entrance), with Open moving to the matching rendered map section. Heuristic-only topology stays unnamed. Distinct from first-breakdown, first-dropout, first-cutoff, first-stop, first-pickup, and first-turnaround. - 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..00205c26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first drop plan in the mounted rehearsal workspace so the part that enters after a thin texture can land the full-band arrival on the map; real analyzed songs now receive this guidance only when section-level stem activity shows the previous graph with one or two distinct sources and the current graph holding at least three sources with those previous sources staying and a new entrance, 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..86623d6aa 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 drop 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from 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..523a5fa6e 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -176,6 +176,22 @@ pub struct ManualOverridePayload { source: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TranscriptionNotePayload { + pitch: String, + onset: f64, + offset: f64, + velocity: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum DropPlanSourcePayload { + Model, + User, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RehearsalRolePayload { @@ -183,14 +199,26 @@ pub struct RehearsalRolePayload { name: String, role_type: String, harmony: HarmonyPayload, + #[serde(default, skip_serializing_if = "Option::is_none")] + harmonic_explanation: Option, cue: CuePayload, range: RangePayload, confidence: ConfidencePayload, rehearsal_priority: String, simplification: String, setup_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + transposition_plan: Option, manual_overrides: Vec, overlap_warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + transcription: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + practice_progress: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + drop_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + drop_plan_source: Option, } #[derive(Clone, Debug, Serialize)] @@ -527,9 +555,35 @@ pub fn is_youtube_video_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') } +fn validate_drop_plan_provenance( + payload: RehearsalSongPayload, +) -> Result { + for section in &payload.sections { + for role in §ion.roles { + if role.practice_progress.is_some_and(|progress| progress > 100) { + return Err("Invalid project file format".to_string()); + } + if role.drop_plan.as_ref().is_some_and(|drop_plan| { + drop_plan.trim().is_empty() + || drop_plan.contains('\n') + || drop_plan.contains('\r') + }) { + return Err("Invalid project file format".to_string()); + } + if role.drop_plan.is_none() && role.drop_plan_source.is_some() { + return Err("Invalid project file format".to_string()); + } + if role.drop_plan.is_some() && role.drop_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_drop_plan_provenance(parsed); } let payload = serde_json::from_str::(content) @@ -547,7 +601,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": "Filled chorus downbeat", + "timeRange": { "start": 30, "end": 46 }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Stem activity corroborates the drop." + }, + "roles": [ + { + "id": "lead-vocal", + "name": "Lead Vocal", + "roleType": "vocal", + "harmony": { + "chord": "C#m7", + "functionLabel": "vi landing", + "source": "model" + }, + "cue": { + "kind": "transition", + "value": "Come in on the filled chorus." + }, + "range": { + "lowestNote": "G#3", + "highestNote": "C#5" + }, + "confidence": { + "level": "high", + "source": "model", + "notes": "Vocal enters when the texture fills." + }, + "rehearsalPriority": "high", + "simplification": "Hold the landing syllable.", + "setupNote": "Keep the attack short.", + "manualOverrides": [], + "overlapWarnings": [], + "dropPlan": "Hit this drop; come in together when the texture fills.", + "dropPlanSource": "model" + } + ], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": true, + "handoff_to": [], + "handoff_from": [] + } + ] + } + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Land the chorus drop together.", + "focusSections": ["chorus-1"] + } + }) +} + +#[test] +fn project_contract_round_trips_drop_plan_provenance() { + let payload = song_with_drop_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 drop-plan fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + + assert_eq!( + serialized["sections"][0]["roles"][0]["dropPlan"], + payload["sections"][0]["roles"][0]["dropPlan"] + ); + assert_eq!( + serialized["sections"][0]["roles"][0]["dropPlanSource"], + json!("model") + ); +} + +#[test] +fn project_contract_round_trips_optional_shared_role_fields() { + let mut payload = song_with_drop_plan(); + let role = payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object"); + role.insert( + "harmonicExplanation".into(), + json!("The leading tone resolves into the chorus tonic."), + ); + role.insert( + "transpositionPlan".into(), + json!("Move the line down a whole step if the vocal sits high."), + ); + role.insert( + "transcription".into(), + json!([{ + "pitch": "C#4", + "onset": 30.0, + "offset": 30.5, + "velocity": 96.0 + }]), + ); + role.insert("practiceProgress".into(), json!(75)); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + let parsed = project_payload_from_content(&content) + .expect("native project contract must accept optional shared role fields"); + let serialized = serde_json::to_value(parsed).expect("native project contract should serialize"); + let serialized_role = &serialized["sections"][0]["roles"][0]; + + for field in [ + "harmonicExplanation", + "transpositionPlan", + "transcription", + "practiceProgress", + ] { + assert_eq!(serialized_role[field], payload["sections"][0]["roles"][0][field]); + } +} + +#[test] +fn project_contract_rejects_practice_progress_outside_shared_range() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["practiceProgress"] = json!(101); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must enforce the shared 0..=100 practice-progress range" + ); +} + +#[test] +fn project_contract_rejects_drop_plan_source_without_drop_plan() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("dropPlan"); + 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_drop_plan_without_source() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0] + .as_object_mut() + .expect("role fixture should be an object") + .remove("dropPlanSource"); + let content = serde_json::to_string(&payload).expect("fixture should serialize"); + + assert!( + project_payload_from_content(&content).is_err(), + "native persisted contract must reject drop-plan copy without provenance" + ); +} + +#[test] +fn project_contract_rejects_invalid_drop_plan_copy_with_source() { + for drop_plan in ["", " ", "land here\nthen hold", "land here\rthen hold"] { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["dropPlan"] = json!(drop_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 drop-plan copy" + ); + } +} + +#[test] +fn project_contract_rejects_unknown_drop_plan_source() { + let mut payload = song_with_drop_plan(); + payload["sections"][0]["roles"][0]["dropPlanSource"] = 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" + ); +} diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx new file mode 100644 index 000000000..8dd19c270 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.custom-guidance.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +const appendedSongStructureTargets = new Set(); + +function songWithCustomDropPlan(source: "model" | "user" | undefined, text: string) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + 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.dropPlan = text; + if (source) { + vocal.dropPlanSource = 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("FirstDropPlanCallout custom guidance", () => { + afterEach(() => { + for (const timeline of appendedSongStructureTargets) { + timeline.remove(); + } + appendedSongStructureTargets.clear(); + }); + + it("preserves user-authored drop guidance verbatim", () => { + render( + + ); + expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy(); + }); + + it("keeps user-authored guidance in the plain body after opening the drop", () => { + appendSongStructureTarget(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + + expect(screen.getByText("Lead Vocal lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.getByText("Come in on the snare; don't rush the last eighth.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("preserves custom copy without model provenance instead of rewriting it", () => { + render( + + ); + expect(screen.getByText("Stack the last bar and land together.")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.identity.test.tsx new file mode 100644 index 000000000..9fe14b8a6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +it("gives co-mounted drop-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first drop 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/FirstDropPlanCallout.navigation-failure.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.navigation-failure.test.tsx new file mode 100644 index 000000000..64f52f02b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.navigation-failure.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + 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.dropPlan = "Hit this drop; come in together when the texture fills."; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstDropPlanCallout navigation failure", () => { + it("names the next action when the rendered map target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect( + screen.getByRole("status").textContent + ).toBe("Could not open this drop on the song map. Use the map below to find the section."); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.particle.test.tsx new file mode 100644 index 000000000..23f10fc98 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithKoreanDrop( + dropPlan: string, + dropPlanSource?: "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", + dropPlan, + ...(dropPlanSource ? { dropPlanSource } : {}) + } + ]; + 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: false, 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("FirstDropPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the drop action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanDrop( + "Hit this drop; come in together when the texture fills.", + "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/FirstDropPlanCallout.provenance.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx new file mode 100644 index 000000000..db43cb438 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.provenance.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +function songWithKoreanDrop( + dropPlan: string, + dropPlanSource?: "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", + dropPlan, + ...(dropPlanSource ? { dropPlanSource } : {}) + } + ]; + 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: false, 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("FirstDropPlanCallout drop-plan provenance", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves user drop guidance that happens to match the engine sentence shape", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const customPlan = "Hit this drop; come in together when the texture fills."; + const song = songWithKoreanDrop(customPlan, "user"); + + render(); + + expect(screen.getByText(customPlan)).toBeTruthy(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.")).toBeNull(); + }); + + it("does not infer model authority when persisted drop guidance has no source", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const legacyPlan = "Hit this drop; come in together when the texture fills."; + const song = songWithKoreanDrop(legacyPlan); + + render(); + + expect(screen.getByText(legacyPlan)).toBeTruthy(); + expect(screen.queryByText("이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.")).toBeNull(); + }); + + it("localizes model guidance from structured landing topology instead of display sentence wording", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithKoreanDrop( + "Hit this drop with Keyboard 1 Right Hand; come in together when the texture fills.", + "model" + ); + + render(); + + expect( + screen.getByText( + "Keyboard 1 Right Hand 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." + ) + ).toBeTruthy(); + expect( + screen.queryByText( + "Hit this drop with Keyboard 1 Right Hand; come in together when the texture fills." + ) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..74bdaf69b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.reduced-motion.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + 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.dropPlan = DEMO_DROP_PLAN; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("FirstDropPlanCallout reduced motion", () => { + afterEach(() => { + document.querySelectorAll('[data-testid="song-structure-grid"]').forEach((node) => { + node.parentElement?.remove(); + }); + vi.unstubAllGlobals(); + }); + + it("uses immediate scrolling when the operating system requests reduced motion", () => { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { configurable: true, value: scrollIntoView }); + grid.appendChild(target); + document.body.appendChild(grid); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-reduced-motion: reduce"), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx new file mode 100644 index 000000000..1b1f9e973 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.test.tsx @@ -0,0 +1,167 @@ +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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; +const appendedSongStructureTargets = new Set(); + +function songWithDropPlan() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + 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.dropPlan = DEMO_DROP_PLAN; + vocal.dropPlanSource = "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("FirstDropPlanCallout", () => { + 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 drop 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 = songWithDropPlan(); + 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 drop at 0:30" })).toBeTruthy(); + }); + + it("contains a hostile song identity descriptor lookup instead of crashing the callout", () => { + const song = new Proxy(songWithDropPlan(), { + getOwnPropertyDescriptor() { + throw new Error("hostile song id descriptor"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No drop 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 drop signature", () => { + const firstSong = songWithDropPlan(); + const nextSong = songWithDropPlan(); + 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 drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("resets armed guidance when the landing role name changes in the same workspace", () => { + const firstSong = songWithDropPlan(); + 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 drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Singer lands the chorus drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Singer together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("resets armed guidance when the section label changes in the same workspace", () => { + const firstSong = songWithDropPlan(); + const nextSong = structuredClone(firstSong); + nextSong.sections[1]!.label = "bridge"; + const workspaceInstanceKey = {}; + appendSongStructureTarget(); + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal lands the bridge drop at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeNull(); + }); + + it("opens the named drop on the rendered map", () => { + const { scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal drop at 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills./)).toBeTruthy(); + expect(screen.getByText(DEMO_DROP_PLAN)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx new file mode 100644 index 000000000..c68aac543 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.tsx @@ -0,0 +1,221 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { + formatDropPlanTime, + resolveFirstDropPlan, + type DropPlanGuidance +} from "./firstDropPlan"; + +/** Props for the first drop-plan rehearsal callout. */ +export interface FirstDropPlanCalloutProps { + song: RehearsalSong; + workspaceInstanceKey?: unknown; +} + +type DropPlanCopyValues = Readonly>; +type DropPlanSource = "model" | "user"; + +type OpenedDropPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + sectionLabel: string; + landingRoleId: string; + landingRoleName: string; + dropPlan: string; + dropPlanSource: DropPlanSource | null; + dropPlanGuidanceKind: DropPlanGuidance["kind"] | null; + dropPlanTargetRoleName: string | null; + atSeconds: number; +}>; + +/** Prefer the owning workspace instance while preserving direct-call compatibility. */ +function stableDropPlanSongIdentity(song: RehearsalSong, workspaceInstanceKey: unknown): unknown { + return workspaceInstanceKey ?? song; +} + +/** Interpolate drop-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatDropPlanCopy(template: string, values: DropPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof DropPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Localize model drop guidance from structured landing topology, never from display-copy grammar. */ +function localizedDropPlan( + dropPlan: string, + dropPlanSource: DropPlanSource | null, + guidance: DropPlanGuidance | null, + generatedTemplate: string, + generatedSoloTemplate: string +): string { + if (dropPlanSource !== "model" || guidance === null) { + return dropPlan; + } + return guidance.kind === "solo" + ? generatedSoloTemplate + : generatedTemplate.replace("{target}", () => guidance.targetRoleName); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredDropPlanScrollBehavior(): 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 resolveDropPlanRenderer(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 drop plan and open the matching rendered map section. */ +export function FirstDropPlanCallout({ song, workspaceInstanceKey }: FirstDropPlanCalloutProps) { + const calloutId = `workspace-surface-drop-plan-${useId()}`; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableDropPlanSongIdentity(song, workspaceInstanceKey); + const named = useMemo(() => resolveFirstDropPlan(song), [song]); + const [openedDropPlan, setOpenedDropPlan] = useState(null); + const [navigationFailed, setNavigationFailed] = useState(false); + const guidanceKind = named?.dropPlanGuidance?.kind ?? null; + const guidanceTargetRoleName = + named?.dropPlanGuidance?.kind === "role" ? named.dropPlanGuidance.targetRoleName : null; + + useEffect(() => { + setOpenedDropPlan(null); + setNavigationFailed(false); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.sectionLabel, + named?.landingRoleId, + named?.landingRoleName, + named?.dropPlan, + named?.dropPlanSource, + guidanceKind, + guidanceTargetRoleName, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedDropPlan !== null && + openedDropPlan.songIdentity === songIdentity && + openedDropPlan.sectionId === named.sectionId && + openedDropPlan.sectionIndex === named.sectionIndex && + openedDropPlan.sectionLabel === named.sectionLabel && + openedDropPlan.landingRoleId === named.landingRoleId && + openedDropPlan.landingRoleName === named.landingRoleName && + openedDropPlan.dropPlan === named.dropPlan && + openedDropPlan.dropPlanSource === named.dropPlanSource && + openedDropPlan.dropPlanGuidanceKind === guidanceKind && + openedDropPlan.dropPlanTargetRoleName === guidanceTargetRoleName && + openedDropPlan.atSeconds === named.atSeconds; + const at = formatDropPlanTime(named.atSeconds); + const copyValues: DropPlanCopyValues = { + role: named.landingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatDropPlanCopy(t("firstDropPlanOpenAction"), copyValues); + const body = formatDropPlanCopy(t("firstDropPlanBody"), copyValues); + const armed = formatDropPlanCopy(t("firstDropPlanArmed"), copyValues); + const dropPlan = localizedDropPlan( + named.dropPlan, + named.dropPlanSource, + named.dropPlanGuidance, + t("firstDropPlanGeneratedGuidance"), + t("firstDropPlanGeneratedSoloGuidance") + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstDropPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstDropPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..fc1745aa2 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropPlanCallout.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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; + +describe("FirstDropPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English drop plan is unavailable", () => { + render(); + + expect( + screen.getByText( + "No drop plan is available. Stay on tonight's map for the next rehearsal cue." + ) + ).toBeTruthy(); + }); + + it("does not assert why the Korean drop plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect( + screen.getByText( + "사용 가능한 드롭 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.drop-state.test.tsx b/apps/desktop/src/features/workspace/Workspace.drop-state.test.tsx new file mode 100644 index 000000000..99adcee6d --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.drop-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 analyzedSongWithDropPlan(): 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: false, handoff_to: [], handoff_from: [] } + ]; + + const chorus = structuredClone(verse); + chorus.id = "chorus-drop-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.dropPlan = "Hit this drop; come in together when the texture fills."; + vocal.dropPlanSource = "model"; + song.sections = [verse, chorus]; + return song; +} + +describe("Workspace drop 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 drop armed after an immutable practice-progress update", () => { + const song = analyzedSongWithDropPlan(); + 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 drop at 0:30" })); + expect(screen.getByText(/Land Lead Vocal together at 0:30 when the texture fills\./)).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(/Land Lead Vocal together at 0:30 when the texture fills\./)).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..2f42692f0 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 { FirstDropPlanCallout } from "./FirstDropPlanCallout"; 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/firstDropPlan.accompaniment-provenance.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts new file mode 100644 index 000000000..48c7285c0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.accompaniment-provenance.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +const MODEL_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +describe("resolveFirstDropPlan accompaniment provenance", () => { + it("does not name a shared accompaniment role from persisted drop 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 { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + } + keys.dropPlan = MODEL_DROP_PLAN; + keys.dropPlanSource = "model"; + + const thin = structuredClone(template); + thin.id = "verse-thin"; + thin.label = "verse"; + thin.timeRange = { start: 0, end: 10 }; + thin.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + thin.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: [] } + ]; + + const drop = structuredClone(template); + drop.id = "chorus-drop"; + drop.label = "chorus"; + drop.timeRange = { start: 10, end: 30 }; + drop.roles = [structuredClone(bass), structuredClone(keys), structuredClone(vocal)]; + drop.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 = [thin, drop]; + + expect(resolveFirstDropPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts new file mode 100644 index 000000000..9d76cb902 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.demo.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +describe("resolveFirstDropPlan demo topology", () => { + it("keeps heuristic demo topology unnamed until real stem activity corroborates a drop", () => { + expect(resolveFirstDropPlan(createDemoRehearsalSong())).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts new file mode 100644 index 000000000..61aef9afd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.model-guidance.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +it("rejects non-template model drop 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-thin"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys]; + 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: false, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-drop"; + current.label = "chorus"; + current.timeRange = { start: 10, end: 30 }; + vocal.dropPlan = "Model says: hit the chorus hard."; + vocal.dropPlanSource = "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(resolveFirstDropPlan(song)).toBeNull(); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts new file mode 100644 index 000000000..ffcd25a28 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.proxy-authority.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +describe("resolveFirstDropPlan proxy authority", () => { + it("does not read inherited or Proxy-substituted dropPlan as rehearsal copy", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === "bass-guitar" || node.role_id === "keys-right" + })); + 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 === "dropPlan") { + return "Hit this drop; come in together when the texture fills."; + } + return Reflect.get(_target, property); + } + }); + chorus.roles = chorus.roles.map((role) => (role.id === "lead-vocal" ? hostile : role)); + song.sections = [verse, chorus]; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts new file mode 100644 index 000000000..ece8f2ace --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.source-continuity.test.ts @@ -0,0 +1,56 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstDropPlan } from "./firstDropPlan"; + +const DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +describe("resolveFirstDropPlan 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 { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + } + vocal.dropPlan = DROP_PLAN; + vocal.dropPlanSource = "model"; + + const previous = structuredClone(seed); + previous.id = "verse-thin"; + previous.label = "verse"; + previous.timeRange = { start: 0, end: 10 }; + previous.roles = [bass, keys]; + 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: false, handoff_to: [], handoff_from: [] } + ]; + + const current = structuredClone(seed); + current.id = "chorus-drop"; + 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 = resolveFirstDropPlan(song); + expect(resolved?.sectionId).toBe("chorus-drop"); + expect(resolved?.landingRoleId).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe(DROP_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.test.ts b/apps/desktop/src/features/workspace/firstDropPlan.test.ts new file mode 100644 index 000000000..f5dcfac42 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatDropPlanTime, resolveFirstDropPlan } from "./firstDropPlan"; + +const DEMO_DROP_PLAN = "Hit this drop; come in together when the texture fills."; + +function withDropSection( + overrides: { + id?: string; + start?: number; + end?: number; + previousStart?: number; + dropPlan?: string; + label?: + | "intro" + | "verse" + | "pre-chorus" + | "chorus" + | "bridge" + | "outro" + | "tag" + | "pickup" + | "stop" + | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + wasActive?: boolean; + previousActiveCount?: 1 | 2 | 3; + keepCompanion?: 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 { dropPlan?: string }).dropPlan; + delete (keys as { dropPlanSource?: string }).dropPlanSource; + delete (vocal as { dropPlan?: string }).dropPlan; + delete (vocal as { dropPlanSource?: string }).dropPlanSource; + delete (bass as { dropPlan?: string }).dropPlan; + delete (bass as { dropPlanSource?: string }).dropPlanSource; + + 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", + dropPlan: overrides.dropPlan ?? DEMO_DROP_PLAN + }; + + const current = structuredClone(verse); + current.id = overrides.id ?? "chorus-drop"; + current.label = overrides.label ?? "chorus"; + current.timeRange = { start: landingStart, end: overrides.end ?? landingStart + 20 }; + current.roles = overrides.keepCompanion ? [landing, bass] : [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) : Boolean(overrides.keepCompanion), + handoff_to: [], + handoff_from: [] + } + ]; + if (roleId === "keys-right") { + current.partGraph[1]!.is_active = overrides.isActive ?? true; + current.partGraph[2]!.is_active = true; + current.roles = [landing, bass, vocal]; + } + if (roleId === "bass-guitar") { + current.partGraph[0]!.is_active = overrides.isActive ?? true; + current.partGraph[2]!.is_active = true; + current.roles = [landing, keys, vocal]; + } + + const previous = structuredClone(current); + previous.id = `${current.id}-thin`; + previous.label = "verse"; + previous.timeRange = { start: previousStart, end: landingStart }; + previous.roles = [structuredClone(bass), structuredClone(keys)]; + previous.roles.forEach((role) => { + delete (role as { dropPlan?: string }).dropPlan; + delete (role as { dropPlanSource?: string }).dropPlanSource; + }); + const previousVocalActive = overrides.previousActiveCount === 3; + const previousKeysActive = overrides.previousActiveCount !== 1; + previous.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }, + { + role_id: "keys-right", + is_active: previousKeysActive, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: previousVocalActive, + handoff_to: [], + handoff_from: [] + } + ]; + if (overrides.wasActive === true) { + previous.partGraph = previous.partGraph.map((node) => ({ + ...node, + is_active: node.role_id === roleId ? true : node.is_active + })); + } + + song.sections = [previous, current]; + return song; +} + +describe("resolveFirstDropPlan", () => { + it("picks the earliest drop plan and the part that lands the filled texture", () => { + const resolved = resolveFirstDropPlan(withDropSection()); + expect(resolved?.section.id).toBe("chorus-drop"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe(DEMO_DROP_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatDropPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatDropPlanTime(Number.NaN)).toBe("0:00"); + expect(formatDropPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a drop 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, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withDropSection(); + delete song.sections[1]!.roles.find((role) => role.id === "lead-vocal")!.dropPlan; + 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_DROP_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.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_DROP_PLAN + }; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("skips a blank drop plan", () => { + expect(resolveFirstDropPlan(withDropSection({ dropPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line drop plan", () => { + expect( + resolveFirstDropPlan(withDropSection({ dropPlan: "Come in together.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two drop plans", () => { + const song = withDropSection({ + id: "chorus-late-drop", + start: 40, + end: 56, + previousStart: 24, + roleId: "lead-vocal", + dropPlan: "Late drop." + }); + 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", + dropPlan: "Earlier drop." + }, + ...earlier.roles.filter((role) => role.id !== "lead-vocal") + ]; + earlier.timeRange = { start: 8, end: 24 }; + const earlierThin = structuredClone(song.sections[0]!); + earlierThin.id = "verse-before-early"; + earlierThin.timeRange = { start: 0, end: 8 }; + song.sections[0]!.timeRange = { start: 24, end: 40 }; + song.sections = [earlierThin, earlier, song.sections[0]!, song.sections[1]!]; + + const resolved = resolveFirstDropPlan(song); + expect(resolved?.section.id).toBe("chorus-early"); + expect(resolved?.landingRole.id).toBe("lead-vocal"); + expect(resolved?.dropPlan).toBe("Earlier drop."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time drop-plan ties with locale-independent id ordering", () => { + const song = withDropSection({ id: "ä-drop", start: 10, end: 26 }); + const umlautThin = song.sections[0]!; + const umlaut = song.sections[1]!; + const asciiThin = structuredClone(umlautThin); + asciiThin.id = "z-drop-thin"; + const ascii = structuredClone(umlaut); + ascii.id = "z-drop"; + song.sections = [umlautThin, umlaut, asciiThin, ascii]; + + expect(resolveFirstDropPlan(song)?.section.id).toBe("z-drop"); + }); + + it("prefers a high-priority landing part over a low-priority part in the same section", () => { + const song = withDropSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + dropPlan: "Low-priority drop." + }); + 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, + dropPlan: "High-priority drop." + }; + 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(resolveFirstDropPlan(song)?.landingRole.id).toBe("lead-vocal"); + expect(resolveFirstDropPlan(song)?.dropPlan).toBe("High-priority drop."); + }); + + it("skips a drop plan whose graph node is inactive", () => { + expect(resolveFirstDropPlan(withDropSection({ isActive: false }))).toBeNull(); + }); + + it("skips a drop plan whose previous graph node was already active", () => { + expect(resolveFirstDropPlan(withDropSection({ wasActive: true, previousActiveCount: 3 }))).toBeNull(); + }); + + it("skips a drop whose previous graph already had three sources", () => { + expect(resolveFirstDropPlan(withDropSection({ previousActiveCount: 3 }))).toBeNull(); + }); + + it("skips a drop plan whose rest and landing windows do not abut", () => { + const song = withDropSection({ start: 12 }); + song.sections[0]!.timeRange = { start: 0, end: 10 }; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("skips a drop plan whose rehearsal window is unbounded", () => { + expect(resolveFirstDropPlan(withDropSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a drop plan whose end precedes its start", () => { + expect(resolveFirstDropPlan(withDropSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length drop-plan window", () => { + expect(resolveFirstDropPlan(withDropSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a drop plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstDropPlan( + withDropSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstDropPlan(null as never)).toBeNull(); + }); + + it("skips non-object roles and graph nodes without inventing a landing part", () => { + const song = withDropSection(); + song.sections[1]!.roles = [null as never, ...song.sections[1]!.roles]; + song.sections[1]!.partGraph = [null as never, ...song.sections[1]!.partGraph]; + expect(resolveFirstDropPlan(song)?.landingRole.id).toBe("lead-vocal"); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withDropSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("keeps the drop plan unnamed when role identities are duplicated", () => { + const song = withDropSection(); + 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(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name a full stop as a drop", () => { + const song = withDropSection(); + song.sections[1]!.partGraph = song.sections[1]!.partGraph.map((node) => ({ + ...node, + is_active: false + })); + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name an unchanged dense texture as a drop", () => { + const song = withDropSection({ previousActiveCount: 3 }); + song.sections[1]!.partGraph = song.sections[0]!.partGraph.map((node) => ({ ...node })); + expect(resolveFirstDropPlan(song)).toBeNull(); + }); + + it("does not name a density drop as a drop", () => { + const song = withDropSection(); + song.sections[0]!.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[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(resolveFirstDropPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropPlan.ts b/apps/desktop/src/features/workspace/firstDropPlan.ts new file mode 100644 index 000000000..e465bb26a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropPlan.ts @@ -0,0 +1,442 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_DROP_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 DROP_PLAN_SOLO = "Hit this drop; come in together when the texture fills."; +const DROP_PLAN_PREFIX = "Hit this drop with "; +const DROP_PLAN_SUFFIX = "; come in together when the texture fills."; + +type DropPlanSource = "model" | "user"; + +/** Structured localization guidance for model-generated drop-plan copy. */ +export type DropPlanGuidance = + | Readonly<{ kind: "solo" }> + | Readonly<{ kind: "role"; targetRoleName: string }>; + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type OwnedDropPlan = Readonly<{ + text: string; + source: DropPlanSource | null; + guidance: DropPlanGuidance | null; +}>; + +/** Tonight's first drop plan: the earliest labeled full-band arrival after a thin texture. */ +export type FirstDropPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + landingRole: RehearsalRole; + landingRoleId: string; + landingRoleName: string; + dropPlan: string; + dropPlanSource: DropPlanSource | null; + dropPlanGuidance: DropPlanGuidance | null; + atSeconds: number; +}; + +/** Format a non-negative drop-plan time as m:ss for rehearsal copy. */ +export function formatDropPlanTime(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 drop template while bounding its model-owned target and localization guidance. */ +function boundedGeneratedDropPlan(value: string): OwnedDropPlan | null { + if (value === DROP_PLAN_SOLO) { + return { text: DROP_PLAN_SOLO, source: "model", guidance: { kind: "solo" } }; + } + if (!value.startsWith(DROP_PLAN_PREFIX) || !value.endsWith(DROP_PLAN_SUFFIX)) { + return null; + } + const target = value.slice(DROP_PLAN_PREFIX.length, -DROP_PLAN_SUFFIX.length); + if (target.trim().length === 0) { + return null; + } + const fixedLength = Array.from(DROP_PLAN_PREFIX + DROP_PLAN_SUFFIX).length; + const boundedTarget = truncateCodePoints(target, MAX_DROP_PLAN_CHARACTERS - fixedLength); + return { + text: `${DROP_PLAN_PREFIX}${boundedTarget}${DROP_PLAN_SUFFIX}`, + source: "model", + guidance: { kind: "role", targetRoleName: boundedTarget } + }; +} + +/** Return a bounded snapshotted own drop plan and its explicit provenance, or null when malformed. */ +function ownedDropPlan(role: unknown): OwnedDropPlan | null { + if (!isRuntimeObject(role)) { + return null; + } + const dropPlan = ownDataValue(role, "dropPlan"); + const dropPlanSource = ownDataValue(role, "dropPlanSource"); + if (typeof dropPlan !== "string") { + return null; + } + if (dropPlanSource !== undefined && dropPlanSource !== "model" && dropPlanSource !== "user") { + return null; + } + const trimmed = dropPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + if (dropPlanSource === "model") { + return boundedGeneratedDropPlan(trimmed); + } + return { + text: truncateCodePoints(trimmed, MAX_DROP_PLAN_CHARACTERS), + source: dropPlanSource ?? null, + 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 dropSourceId(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) => dropSourceId(roleId))); +} + +/** Resolve a drop plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstDropPlan(song: RehearsalSong): FirstDropPlan | 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 || previousSourceIds.size > 2 || currentSourceIds.size < 3) { + return []; + } + for (const sourceId of previousSourceIds) { + if (!currentSourceIds.has(sourceId)) { + return []; + } + } + let entered = false; + for (const sourceId of currentSourceIds) { + if (!previousSourceIds.has(sourceId)) { + entered = true; + break; + } + } + if (!entered) { + return []; + } + + const landingRole = pickLandingRole( + rankedActiveRoles(section as RehearsalSection).flatMap((metadata) => { + if ( + previousActiveIds.has(metadata.id) || + ACCOMPANIMENT_SOURCE_ROLE_IDS.has(metadata.id) + ) { + return []; + } + const dropPlan = ownedDropPlan(metadata.role); + return dropPlan === null + ? [] + : [ + { + ...metadata, + dropPlan: dropPlan.text, + dropPlanSource: dropPlan.source, + dropPlanGuidance: dropPlan.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, + dropPlan: landingRole.dropPlan, + dropPlanSource: landingRole.dropPlanSource, + dropPlanGuidance: landingRole.dropPlanGuidance, + 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 drop plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstDropPlan(song: RehearsalSong): FirstDropPlan | null { + try { + return resolveSafeFirstDropPlan(song); + } catch { + return null; + } +} \ No newline at end of file diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..e039035b6 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-drop-plan next-action copy particle-safe and tonally consistent", () => { + const t = createTranslator("ko"); + expect(t("firstDropPlanOpenAction")).toBe("{at} {role} 드롭 열기"); + expect(t("firstDropPlanBody")).toBe("{at} {section}에서 {role} 파트가 드롭을 맞습니다."); + expect(t("firstDropPlanArmed")).toBe( + "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오세요." + ); + expect(t("firstDropPlanGeneratedGuidance")).toBe( + "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." + ); + expect(t("firstDropPlanGeneratedSoloGuidance")).toBe( + "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요." + ); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..f7f6d34dd 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}.", + "firstDropPlanLabel": "Tonight's first drop plan", + "firstDropPlanOpenAction": "Open {role} drop at {at}", + "firstDropPlanBody": "{role} lands the {section} drop at {at}.", + "firstDropPlanArmed": "Land {role} together at {at} when the texture fills.", + "firstDropPlanGeneratedGuidance": "Hit this drop with {target}; come in together when the texture fills.", + "firstDropPlanGeneratedSoloGuidance": "Hit this drop; come in together when the texture fills.", + "firstDropPlanUnavailable": "No drop plan is available. Stay on tonight's map for the next rehearsal cue.", + "firstDropPlanNavigationFailed": "Could not open this drop 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..9e69c184d 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} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstDropPlanLabel": "오늘 첫 드롭 계획", + "firstDropPlanOpenAction": "{at} {role} 드롭 열기", + "firstDropPlanBody": "{at} {section}에서 {role} 파트가 드롭을 맞습니다.", + "firstDropPlanArmed": "{at}에서 {role} 파트로 함께 드롭하세요. 텍스처가 채워질 때 들어오세요.", + "firstDropPlanGeneratedGuidance": "{target} 파트와 이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.", + "firstDropPlanGeneratedSoloGuidance": "이 드롭을 맞으세요. 텍스처가 채워질 때 함께 들어오세요.", + "firstDropPlanUnavailable": "사용 가능한 드롭 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", + "firstDropPlanNavigationFailed": "곡 맵에서 이 드롭을 열 수 없습니다. 아래 맵에서 해당 구간을 찾아주세요." } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..139931371 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; + dropPlan?: string; + dropPlanSource?: ProvenanceSource; }; /** Documented. */ @@ -1500,7 +1502,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "manualOverrides", "overlapWarnings", "transcription", - "practiceProgress" + "practiceProgress", + "dropPlan", + "dropPlanSource" ], path ); @@ -1588,6 +1592,30 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if ( + value.dropPlan !== undefined && + ( + typeof value.dropPlan !== "string" || + value.dropPlan.trim().length === 0 || + value.dropPlan.includes("\n") || + value.dropPlan.includes("\r") + ) + ) { + return invalidField(`${path}.dropPlan`); + } + if ( + value.dropPlanSource !== undefined && + !isOneOf(PROVENANCE_SOURCES, value.dropPlanSource) + ) { + return invalidField(`${path}.dropPlanSource`); + } + if (value.dropPlanSource !== undefined && value.dropPlan === undefined) { + return invalidField(`${path}.dropPlanSource`); + } + if (value.dropPlan !== undefined && value.dropPlanSource === undefined) { + return invalidField(`${path}.dropPlanSource`); + } + return null; } diff --git a/packages/shared-types/test/dropPlanProvenance.test.ts b/packages/shared-types/test/dropPlanProvenance.test.ts new file mode 100644 index 000000000..6f055dc8d --- /dev/null +++ b/packages/shared-types/test/dropPlanProvenance.test.ts @@ -0,0 +1,47 @@ +import { createDemoRehearsalSong, parseRehearsalSong } from "../src/index"; +import { describe, expect, it } from "vitest"; + +describe("dropPlan provenance", () => { + it.each(["model", "user"] as const)("admits a %s drop plan source", (source) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + role.dropPlanSource = source; + expect(parseRehearsalSong(song).sections[0]!.roles[0]!.dropPlanSource).toBe(source); + }); + + it("rejects an unknown drop plan source", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + role.dropPlanSource = "inferred" as never; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); + + it("rejects a drop plan source without copy", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + delete role.dropPlan; + role.dropPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); + + it("rejects drop plan copy without provenance", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = "Hit this drop; come in together when the texture fills."; + delete role.dropPlanSource; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlanSource/); + }); + + it.each(["", " ", "land here\nthen hold", "land here\rthen hold"])( + "rejects a drop plan source with blank or multiline copy %j", + (dropPlan) => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + role.dropPlan = dropPlan; + role.dropPlanSource = "model"; + expect(() => parseRehearsalSong(song)).toThrow(/dropPlan/); + } + ); +}); diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..fb03976b0 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].dropPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.dropPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..a64350672 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -22,6 +22,11 @@ logger = logging.getLogger(__name__) +_OTHER_STEM_ROLE_IDS = frozenset({"keys-left", "keys-right", "acoustic-guitar"}) +_DROP_PLAN_SOLO = "Hit this drop; come in together when the texture fills." +_DROP_PLAN_PREFIX = "Hit this drop with " +_DROP_PLAN_SUFFIX = "; come in together when the texture fills." + class RoleExtractor: """Extracts roles and builds the part graph for song sections.""" @@ -71,8 +76,13 @@ def extract( # Real activity-based topology current_activity = activity_maps[i] next_activity = activity_maps[i + 1] if i + 1 < len(activity_maps) else None + previous_activity = activity_maps[i - 1] if i > 0 else None topology = self._build_activity_topology( - section_id, roles, current_activity, next_activity + section_id, + roles, + current_activity, + next_activity, + previous_activity, ) else: # Fallback to heuristic-based topology @@ -330,12 +340,80 @@ 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_count(cls, role_ids: set[str]) -> int: + """Count distinct source-separation stems among the given roles.""" + return len({cls._source_id(role_id) for role_id in role_ids}) + + def _activity_drop_plan( + self, + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + previous_role_activity: dict[str, bool] | None, + ) -> str | None: + """Return bounded drop guidance only for a corroborated density fill. + + A drop plan is emitted only when real stem activity shows this role + entering after a thin texture: the previous section had one or two + distinct sources, the current section holds at least three, every + previous source stays, and at least one new source enters. Heuristic + fallback topology and first-section (no previous activity) produce no + plan. A density drop is not a drop plan. The shared ``other`` stem may + corroborate density but never proves which keyboard or guitar part owns + the landing. + """ + if previous_role_activity is None: + return None + previous_active = self._active_role_ids(previous_role_activity) + current_active = self._active_role_ids(role_activity) + if role_id not in current_active or role_id in previous_active: + return None + if role_id in _OTHER_STEM_ROLE_IDS: + return None + previous_source_ids = {self._source_id(candidate_id) for candidate_id in previous_active} + current_source_ids = {self._source_id(candidate_id) for candidate_id in current_active} + if previous_source_ids - current_source_ids: + return None + entered = current_active - previous_active + previous_sources = self._source_count(previous_active) + current_sources = self._source_count(current_active) + if previous_sources < 1 or previous_sources > 2 or current_sources < 3: + return None + + named_entered = entered - _OTHER_STEM_ROLE_IDS + if named_entered == {role_id}: + return _DROP_PLAN_SOLO + + partners = sorted(named_entered - {role_id}) + if len(partners) != 1: + return None + 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"{_DROP_PLAN_PREFIX}{other_name}{_DROP_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, ) -> SectionRoleTopology: """Build topology from real stem activity detection.""" handoffs = compute_handoffs(role_activity, next_role_activity) @@ -357,7 +435,18 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + drop_plan = self._activity_drop_plan( + role_id, + roles, + role_activity, + previous_role_activity, + ) + if drop_plan is not None: + role = role.copy() + role["dropPlan"] = drop_plan + role["dropPlanSource"] = "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..f81c50613 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] + dropPlan: NotRequired[str] + dropPlanSource: NotRequired[Literal["model", "user"]] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_drop_plan.py b/services/analysis-engine/tests/test_drop_plan.py new file mode 100644 index 000000000..7abb2c302 --- /dev/null +++ b/services/analysis-engine/tests/test_drop_plan.py @@ -0,0 +1,320 @@ +"""Tests for corroborated drop-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 = "Hit this drop; come in together when the texture fills." +_PREFIX = "Hit this drop with " +_SUFFIX = "; come in together when the texture fills." + + +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 _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_drop_emits_solo_plan_for_a_two_to_three_fill() -> None: + """A thin verse filling to three sources names the entering landing.""" + 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( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + assert vocal["dropPlanSource"] == "model" + assert all( + "dropPlan" not in role or role["id"] == "lead-vocal" for role in topology["active_roles"] + ) + + +def test_activity_drop_keeps_shared_accompaniment_source_across_role_swap() -> None: + """A role swap inside the shared other stem does not invent a source dropout.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=False, vocal=True, guitar=True) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + + +def test_activity_drop_names_two_named_entrances_as_partners() -> None: + """Two named parts entering together point at each other.""" + extractor = RoleExtractor() + previous = _activity(bass=False, keys_right=True, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + + topology = extractor._build_activity_topology( + "chorus-1", + _roles(extractor), + current, + None, + previous, + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["dropPlan"] == f"{_PREFIX}Bass Guitar{_SUFFIX}" + assert roles_by_id["bass-guitar"]["dropPlan"] == f"{_PREFIX}Lead Vocal{_SUFFIX}" + assert "dropPlan" not in roles_by_id["keys-right"] + + +def test_activity_drop_stays_unnamed_without_previous_activity() -> None: + """The first section cannot be a drop.""" + extractor = RoleExtractor() + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "verse-1", + _roles(extractor), + current, + None, + None, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_on_heuristic_fallback() -> None: + """Heuristic topology must not invent a drop plan.""" + extractor = RoleExtractor() + result = extractor.extract([{"id": "intro"}, {"id": "verse-1"}]) + for topology in result["topologies"]: + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_for_a_density_drop() -> None: + """A staying sparse hold is a breakdown, not a drop.""" + 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, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_a_previous_source_leaves() -> None: + """A mixed dropout is not a corroborated full-band arrival.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=False) + current = _activity(bass=False, keys_right=True, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "mix-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_previous_graph_is_already_dense() -> None: + """Three previous sources are already full-band, so a new entrance is not a drop.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=True, vocal=True) + current = _activity(bass=True, keys_right=True, vocal=True, guitar=True) + topology = extractor._build_activity_topology( + "already-dense-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_current_graph_stays_thin() -> None: + """Two current sources are not a full-band arrival.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=False, vocal=False) + current = _activity(bass=True, keys_right=False, vocal=True) + topology = extractor._build_activity_topology( + "thin-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_previous_graph_is_empty() -> None: + """Zero previous sources cannot corroborate a fill after a thin texture.""" + extractor = RoleExtractor() + previous = _activity(bass=False, keys_right=False, vocal=False) + current = _activity(bass=True, keys_right=True, vocal=True) + topology = extractor._build_activity_topology( + "from-silence-1", + _roles(extractor), + current, + None, + previous, + ) + assert all("dropPlan" not in role for role in topology["active_roles"]) + + +def test_activity_drop_does_not_assign_an_other_stem_landing() -> None: + """The shared other stem may corroborate density but never owns the landing.""" + extractor = RoleExtractor() + previous = _activity(bass=True, keys_right=False, vocal=False) + 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, + ) + roles_by_id = {role["id"]: role for role in topology["active_roles"]} + assert roles_by_id["lead-vocal"]["dropPlan"] == _SOLO_PLAN + for ambiguous_role_id in ("keys-left", "keys-right", "acoustic-guitar"): + assert "dropPlan" not in roles_by_id[ambiguous_role_id] + + +def test_activity_drop_counts_accompaniment_stems_as_one_source() -> None: + """Keys and acoustic guitar share one accompaniment source for density.""" + extractor = RoleExtractor() + previous = _activity( + bass=True, + keys_right=True, + vocal=False, + keys_left=True, + guitar=True, + ) + current = _activity( + bass=True, + keys_right=True, + vocal=True, + keys_left=True, + guitar=True, + ) + topology = extractor._build_activity_topology( + "fill-1", + _roles(extractor), + current, + None, + previous, + ) + vocal = next(role for role in topology["active_roles"] if role["id"] == "lead-vocal") + assert vocal["dropPlan"] == _SOLO_PLAN + + +def test_extract_emits_drop_across_real_stem_boundaries() -> None: + """Live activity maps pass previous-section evidence into drop emission.""" + extractor = RoleExtractor() + sr = 8 + bass = np.concatenate([np.ones(sr, dtype=np.float32), np.ones(sr, dtype=np.float32)]) + other = np.concatenate([np.zeros(sr, dtype=np.float32), np.ones(sr, dtype=np.float32)]) + vocal = np.concatenate([np.zeros(sr, dtype=np.float32), np.ones(sr, 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("dropPlan") == _SOLO_PLAN + assert result["topologies"][0]["active_roles"] + assert all("dropPlan" not in role for role in result["topologies"][0]["active_roles"]) + + +def test_activity_drop_stays_unnamed_when_partner_has_no_display_name() -> None: + """A two-named-entrance fill without a named partner stays unnamed.""" + extractor = RoleExtractor() + incomplete = {key: value for key, value in _roles(extractor).items() if key != "vocal"} + plan = extractor._activity_drop_plan( + "bass-guitar", + incomplete, + _activity(bass=True, keys_right=True, vocal=True), + _activity(bass=False, keys_right=True, vocal=False), + ) + assert plan is None + + +def test_activity_drop_stays_unnamed_when_more_than_one_named_partner_enters() -> None: + """More than two named entrances cannot name a single landing partner.""" + extractor = RoleExtractor() + plan = extractor._activity_drop_plan( + "bass-guitar", + _roles(extractor), + _activity( + bass=True, + keys_right=True, + vocal=True, + extra={"drums": True}, + ), + _activity(bass=False, keys_right=True, vocal=False), + ) + assert plan is None + + +def test_activity_drop_stays_unnamed_when_role_is_inactive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An inactive role cannot own a drop even if source counts look filled.""" + extractor = RoleExtractor() + + def _forced_three_current_sources(_cls: type[RoleExtractor], role_ids: set[str]) -> int: + """Keep previous density valid while reporting a filled current graph.""" + return 1 if len(role_ids) <= 2 else 3 + + monkeypatch.setattr(RoleExtractor, "_source_count", classmethod(_forced_three_current_sources)) + plan = extractor._activity_drop_plan( + "lead-vocal", + _roles(extractor), + _activity(bass=True, keys_right=True, vocal=False), + _activity(bass=True, keys_right=True, vocal=False), + ) + assert plan is None