diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..26fb21d49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and offers a next action that finds that part on the section roadmap. - 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..4c4a57df2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - 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 + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, offering a roadmap find control, and naming the next instrument check - 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..9c5b8e89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- 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. +- Name tonight's first playable range on the ready rehearsal map, offer Find {section} for {role} on the roadmap so the player can locate that part, 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..560abc0ce 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 ready workspace names tonight's first playable range, offers a control that finds that part on the section roadmap, and tells the player to check that span on their instrument. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..2e92cba2a 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,5 +1,5 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useRef } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; @@ -12,13 +12,41 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + focusSectionId?: string | null; + focusRoleId?: string | null; + focusRequestSequence?: number; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ + song, + activeRole, + onSongUpdate, + focusSectionId = null, + focusRoleId = null, + focusRequestSequence = 0 +}: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const sectionCardRefs = useRef(new Map()); + + useEffect(() => { + if (!focusSectionId || focusRequestSequence < 1) { + return; + } + const sectionCard = sectionCardRefs.current.get(focusSectionId); + if (sectionCard && typeof sectionCard.scrollIntoView === "function") { + const reducedMotionPreferred = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + sectionCard.scrollIntoView({ + behavior: reducedMotionPreferred ? "auto" : "smooth", + inline: "center", + block: "nearest" + }); + } + }, [focusRequestSequence, focusSectionId]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -104,11 +132,29 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( - { + const sectionFocused = focusSectionId === section.id; + return ( +
{ + if (sectionNode) { + sectionCardRefs.current.set(section.id, sectionNode); + } else { + sectionCardRefs.current.delete(section.id); + } + }} + data-testid={`section-roadmap-section-${section.id}`} + aria-current={sectionFocused ? "location" : undefined} + className="w-80 flex-none shrink-0 snap-start" + > + @@ -127,10 +173,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma .filter(role => !activeRole || role.id === activeRole) .map(role => { const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote); + const roleFocused = sectionFocused && focusRoleId === role.id; return (
@@ -228,7 +279,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma })} - ))} +
+ ); + })}
); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..3db8d3b9a 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -140,6 +140,40 @@ describe("Workspace", () => { expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); + it("finds tonight's first range on the section roadmap", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute("aria-current", "location"); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).toHaveAttribute("aria-current", "true"); + }); + + it("hides the roadmap find control when the named section identity is duplicated", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections.push({ + ...song.sections[0]!, + id: "verse-2", + label: "verse" + }); + + render(); + + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); + }); + + it("localizes the first-range roadmap find control", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "로드맵에서 Bass Guitar verse 찾기" })).toBeTruthy(); + }); + it("names tonight's first playable range and the next instrument check", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -151,6 +185,7 @@ describe("Workspace", () => { expect(callout).toHaveTextContent( "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." ); + expect(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })).toBeTruthy(); }); it("asks for an ear check when the selected part has no named span", () => { @@ -167,6 +202,7 @@ describe("Workspace", () => { expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." ); + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); }); it("limits the range callout to the selected role", () => { diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..a84a7aa0f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,7 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillRangeCopy, firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,14 @@ interface WorkspaceProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** Request identity for a user-initiated section-roadmap focus action. */ +type RoadmapFocusRequest = { + rehearsalSourceIdentity: string; + sectionId: string; + roleId: string; + requestSequence: number; +}; + /** Documented. */ function formatTimelineTime(totalSeconds: number): string { const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; @@ -121,7 +129,13 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [roadmapFocusRequest, setRoadmapFocusRequest] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const parsedSourceBootstrap = useMemo( + () => safeProjectBootstrapSummary(sourceBootstrap), + [sourceBootstrap] + ); + const rehearsalSourceIdentity = parsedSourceBootstrap?.projectId ?? song.id; // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -163,6 +177,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstRangeBoard = firstRangeRoadmap(song, firstRange); + const firstRangeFindCopy = firstRangeBoard + ? fillRangeCopy(t("workspaceFirstRangeFindRoadmap"), { + sectionLabel: firstRangeBoard.sectionLabel, + roleName: firstRangeBoard.roleName + }) + : null; + const focusedSectionId = + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.sectionId + : null; + const focusedRoleId = + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.roleId + : null; + const focusRequestSequence = + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.requestSequence + : 0; + + /** Request the first-range roadmap cell on every activation, even when it is already highlighted. */ + const handleFindFirstRangeRoadmap = () => { + if (!firstRangeBoard) { + return; + } + setRoadmapFocusRequest((previousFocusRequest) => ({ + rehearsalSourceIdentity, + sectionId: firstRangeBoard.sectionId, + roleId: firstRangeBoard.roleId, + requestSequence: + previousFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? previousFocusRequest.requestSequence + 1 + : 1 + })); + }; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -240,7 +289,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp /** Documented. */ const handleExportHandoff = () => { - const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap); const json = generateMetadataHandoffJson(song, { sourceBootstrap: parsedSourceBootstrap, workspaceId: song.id, @@ -308,6 +356,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp >

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+ {firstRangeBoard && firstRangeFindCopy ? ( + + ) : null}
@@ -506,6 +565,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp song={song} activeRole={activeRole} onSongUpdate={onSongUpdate} + focusSectionId={focusedSectionId} + focusRoleId={focusedRoleId} + focusRequestSequence={focusRequestSequence} /> diff --git a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx new file mode 100644 index 000000000..3594da8ef --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx @@ -0,0 +1,211 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type ProjectBootstrapSummary } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; +import { firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze"; + +const originalMatchMedia = window.matchMedia; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +/** Record scrollIntoView so repeated Find activations can be counted. */ +function installScrollRecorder() { + const scrollRequests = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollRequests + }); + return scrollRequests; +} + +/** Stub matchMedia for the reduced-motion preference used by roadmap scrolling. */ +function setReducedMotionPreference(reducedMotionPreferred: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockReturnValue({ matches: reducedMotionPreferred }) + }); +} + +/** Create a valid local-audio project identity without exposing a real local path. */ +function projectBootstrap(projectId: string): ProjectBootstrapSummary { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/projects/${projectId}/cache`, + tempRoot: `/tmp/bandscope/projects/${projectId}/tmp`, + source: { + sourcePath: `/tmp/bandscope/projects/${projectId}/source.wav`, + fileName: "source.wav", + extension: "wav", + fileSizeBytes: 1024 + } + }; +} + +describe("first-range roadmap interaction regressions", () => { + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView + }); + } else { + delete (HTMLElement.prototype as { scrollIntoView?: typeof HTMLElement.prototype.scrollIntoView }).scrollIntoView; + } + + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia + }); + vi.restoreAllMocks(); + }); + + it("requests roadmap focus again when Find is activated twice for the same part", () => { + const scrollRequests = installScrollRecorder(); + const rehearsalSong = createDemoRehearsalSong(); + + render(); + const findRoadmapButton = screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }); + + fireEvent.click(findRoadmapButton); + fireEvent.click(findRoadmapButton); + + expect(scrollRequests).toHaveBeenCalledTimes(2); + }); + + it("does not carry a focused roadmap cell into a replacement rehearsal song", () => { + installScrollRecorder(); + const rehearsalSong = createDemoRehearsalSong(); + const replacementSong = { + ...createDemoRehearsalSong(), + id: "replacement-song" + }; + + const renderedWorkspace = render(); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute( + "aria-current", + "location" + ); + + renderedWorkspace.rerender(); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).not.toHaveAttribute( + "aria-current" + ); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).not.toHaveAttribute( + "aria-current" + ); + }); + + it("does not carry focus across local projects that reuse the analyzed-song identity", () => { + installScrollRecorder(); + const analyzedSong = { + ...createDemoRehearsalSong(), + id: "analyzed-song" + }; + const replacementAnalysis = { + ...createDemoRehearsalSong(), + id: "analyzed-song" + }; + + const renderedWorkspace = render( + + ); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute( + "aria-current", + "location" + ); + + renderedWorkspace.rerender( + + ); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).not.toHaveAttribute( + "aria-current" + ); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).not.toHaveAttribute( + "aria-current" + ); + }); + + it("fails closed when the target section identifier is duplicated elsewhere", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: rehearsalSong.sections[0]!.id, + label: "chorus" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + render(); + expect( + screen.queryByRole("button", { name: /Find .+ on the roadmap/ }) + ).toBeNull(); + expect( + screen.queryAllByTestId("section-roadmap-section-verse-1").filter( + (sectionCard) => sectionCard.getAttribute("aria-current") === "location" + ) + ).toHaveLength(0); + }); + + it("keeps the buyer-visible control when display names repeat but IDs remain unique", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + id: "bass-guitar-double" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + + render(); + expect(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })).toBeInTheDocument(); + }); + + it("fails closed when the target role identifier is duplicated on the section", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + name: "Bass Guitar Double" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + render(); + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); + }); + + it("avoids smooth scrolling when reduced motion is preferred", () => { + const scrollRequests = installScrollRecorder(); + setReducedMotionPreference(true); + const rehearsalSong = createDemoRehearsalSong(); + + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + + expect(scrollRequests).toHaveBeenCalledWith( + expect.objectContaining({ behavior: "auto" }) + ); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts index 643935954..392442b7e 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -1,6 +1,6 @@ import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; -import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; +import { firstRangeRoadmap, fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; function blankRoleRange(song: RehearsalSong): RehearsalSong { return { @@ -51,7 +51,9 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong()); expect(squeeze).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -67,7 +69,9 @@ describe("firstRangeSqueeze", () => { })); expect(firstRangeSqueeze(song)).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -114,7 +118,9 @@ describe("firstRangeSqueeze", () => { expect( firstRangeSqueeze({ ...song, sections: [malformedSection] } as unknown as RehearsalSong) ).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -126,7 +132,9 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong(), "lead-vocal"); expect(squeeze).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "lead-vocal", roleName: "Lead Vocal", lowestNote: "G#3", highestNote: "C#5", @@ -140,6 +148,91 @@ describe("firstRangeSqueeze", () => { }); }); +describe("firstRangeRoadmap", () => { + it("returns the unique section and part for tonight's first named span", () => { + const rehearsalSong = createDemoRehearsalSong(); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("limits the roadmap target to the selected role", () => { + const rehearsalSong = createDemoRehearsalSong(); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong, "lead-vocal"))).toEqual({ + sectionId: "verse-1", + roleId: "lead-vocal", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("allows repeated section labels when section identifiers remain unique", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: "verse-2", + label: "verse" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("fails closed when the matching section identifier is duplicated", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: "verse-1", + label: "chorus" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("allows repeated part display names when role identifiers remain unique", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + id: "bass-guitar-double" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("fails closed when the matching role identifier is duplicated", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles[1] = { + ...rehearsalSong.sections[0]!.roles[1]!, + id: "bass-guitar" + }; + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("fails closed on missing squeeze, blank identifiers, and malformed roots", () => { + const rehearsalSong = createDemoRehearsalSong(); + expect(firstRangeRoadmap(rehearsalSong, null)).toBeNull(); + expect(firstRangeRoadmap({} as RehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + rehearsalSong.sections[0]!.id = " "; + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); +}); + describe("fillRangeCopy", () => { it("replaces every token occurrence", () => { expect( @@ -164,4 +257,4 @@ describe("fillRangeCopy", () => { fillRangeCopy("Check {toString} before {missingToken}.", { sectionLabel: "verse" }) ).toBe("Check {toString} before {missingToken}."); }); -}); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts index 47270d2a9..048419859 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -2,13 +2,23 @@ import type { RehearsalSong } from "@bandscope/shared-types"; /** Tonight's first named playable span on the rehearsal map. */ export type FirstRangeSqueeze = { + sectionId?: string; sectionLabel: string; + roleId: string; roleName: string; lowestNote: string; highestNote: string; overlapWarning?: string; }; +/** Trusted roadmap cell for tonight's first playable span. */ +export type FirstRangeRoadmap = { + sectionId: string; + roleId: string; + sectionLabel: string; + roleName: string; +}; + const NATURAL_PITCH_CLASS = { C: 0, D: 2, @@ -108,6 +118,7 @@ export function firstRangeSqueeze( if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { continue; } + const sectionId = meaningfulRangeText(sectionValue.id); const sectionLabel = meaningfulRangeText(sectionValue.label); if (!sectionLabel) { continue; @@ -143,7 +154,9 @@ export function firstRangeSqueeze( } const candidate: FirstRangeSqueeze = { + sectionId, sectionLabel, + roleId, roleName, ...range, overlapWarning @@ -162,6 +175,80 @@ export function firstRangeSqueeze( return fallback; } +/** + * Offer the originating first-range section and part only when their IDs remain unique and trusted. + * + * The first-range selection carries the section/role identifiers that owned + * the range evidence. Display labels and names may legitimately repeat in a + * song, so they are presentation data rather than navigation authority. Fail + * closed when either identifier is absent/duplicated or the referenced runtime + * cell is malformed. Does not start playback; #961 owns the rehearsal player. + */ +export function firstRangeRoadmap( + rehearsalSong: RehearsalSong, + rangeSqueeze: FirstRangeSqueeze | null +): FirstRangeRoadmap | null { + if (!rangeSqueeze) { + return null; + } + + const targetSectionId = meaningfulRangeText(rangeSqueeze.sectionId); + const targetRoleId = meaningfulRangeText(rangeSqueeze.roleId); + if (!targetSectionId || !targetRoleId) { + return null; + } + + const runtimeSong: unknown = rehearsalSong; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + let sectionIdOccurrences = 0; + let targetSection: Record | null = null; + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + if (meaningfulRangeText(sectionValue.id) === targetSectionId) { + sectionIdOccurrences += 1; + targetSection = sectionValue; + } + } + + if (sectionIdOccurrences !== 1 || !targetSection || !Array.isArray(targetSection.roles)) { + return null; + } + + let roleIdOccurrences = 0; + let targetRole: Record | null = null; + for (const roleValue of targetSection.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + if (meaningfulRangeText(roleValue.id) === targetRoleId) { + roleIdOccurrences += 1; + targetRole = roleValue; + } + } + + if (roleIdOccurrences !== 1 || !targetRole) { + return null; + } + + const sectionLabel = meaningfulRangeText(targetSection.label); + const roleName = meaningfulRangeText(targetRole.name); + if (!sectionLabel || !roleName) { + return null; + } + + return { + sectionId: targetSectionId, + roleId: targetRoleId, + sectionLabel, + roleName + }; +} + /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ export function fillRangeCopy(template: string, values: Record): string { return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (placeholder, token: string) => { @@ -169,4 +256,4 @@ export function fillRangeCopy(template: string, values: Record): // never satisfy a token, or the raw function source would be rendered. return Object.prototype.hasOwnProperty.call(values, token) ? values[token] : placeholder; }); -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..2824457ff 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,7 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstRangeFindRoadmap": "Find {sectionLabel} for {roleName} on the roadmap", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5fee3d96f 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,7 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstRangeFindRoadmap": "로드맵에서 {roleName} {sectionLabel} 찾기", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..2e172f254 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -11,7 +11,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, and handoffs - groove and timing cues -- role ranges, overlap warnings, and simplification guidance +- role ranges, overlap warnings, and simplification guidance, with the ready workspace naming tonight's first span and offering a control that finds that part on the section roadmap - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority