diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..72e69fd45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- After a part is selected, the role strip must name tonight's setup from `setupNote` / transposition / simplification and point at the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..1aa737eed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-18 ## Brand source @@ -68,6 +68,7 @@ Last updated: 2026-03-11 - BandScope is not only a shell around chord labels, stems, and ranges. - The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority. - These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer. +- Ready-workspace role-strip setup must arm tonight's `setupNote` (then transposition / simplification) and name the first analyzed entrance on the groove map. Isolation playback stays out of this lane. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..503edb8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The ready workspace can set up tonight's selected part from the analyzed setup cue and name the first entrance on the groove map, instead of leaving `Transcribe Bass` inert. - 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -15,7 +16,15 @@ ### Fixed +- Keep disabled Stem Player controls discoverable by their visible labels for + assistive technology and speech input while retaining the translated + unavailable reason in each accessible name and tooltip. +- Reject sentinel, malformed, and inverted setup ranges before they can enable + a buyer-visible rehearsal action or render as playable evidence. +- Localize Groove Map states and the unavailable Loop control through the + owned translation boundary, with literal fail-closed placeholder handling. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..5b9d65151 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +After a part is selected, the role-strip setup control must name tonight's setup cue and the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx index 2745d4d79..4943f2234 100644 --- a/apps/desktop/src/features/workspace/GrooveMap.tsx +++ b/apps/desktop/src/features/workspace/GrooveMap.tsx @@ -2,18 +2,22 @@ import { memo, useMemo } from "react"; import type { TranscriptionNote } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; +import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n"; const EMPTY_NOTES: TranscriptionNote[] = []; -/** Documented. */ +/** Inputs for the selected role's rehearsal groove map. */ interface GrooveMapProps { notes?: TranscriptionNote[]; isLoading?: boolean; + entranceOnset?: number; + roleName: string; } -/** Documented. */ -function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { +/** Render the selected role's transcription and optional first-entrance emphasis. */ +function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName }: GrooveMapProps) { const renderedNotes = notes ?? EMPTY_NOTES; + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Find max offset to determine timeline width const maxTime = useMemo(() => { @@ -36,6 +40,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { return map; }, [uniquePitches]); + const entranceIndex = useMemo(() => { + if (entranceOnset === undefined) { + return -1; + } + return renderedNotes.findIndex((note) => note.onset === entranceOnset); + }, [entranceOnset, renderedNotes]); + if (isLoading) { return (
); @@ -55,10 +66,8 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { if (renderedNotes.length === 0) { return ( -
- No bass line transcription yet. Use it when you want to check the groove before rehearsal. +
+ {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
); } @@ -68,13 +77,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" role="region" tabIndex={0} - aria-label="Bass transcription groove map" + aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })} >
- Transcription complete. {renderedNotes.length} notes analyzed. + {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}

- {renderedNotes.length} notes mapped for rehearsal + {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}

@@ -95,20 +104,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { const leftPercent = (note.onset / maxTime) * 100; const widthPercent = ((note.offset - note.onset) / maxTime) * 100; const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`; + const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset; return (
- {noteLabel} + {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
); diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx new file mode 100644 index 000000000..6fd44fec9 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx @@ -0,0 +1,180 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GrooveMap } from "./GrooveMap"; +import { Workspace } from "./Workspace"; + +/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */ +function replaceRole(song: ReturnType, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) { + song.sections = song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role)) + })); +} + +describe("Workspace review regressions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps copy interpolation free of dynamically constructed regular expressions", () => { + const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8"); + expect(source).not.toContain("new RegExp("); + }); + + it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => { + render( + + ); + + const region = screen.getByRole("region", { name: "Lead Guitar transcription groove map" }); + expect(region.className).toContain("focus-visible:ring-2"); + expect(document.querySelectorAll("#workspace-groove-entrance")).toHaveLength(1); + expect(screen.getAllByTitle(/Tonight's entrance/)).toHaveLength(2); + }); + + it("uses the selected role name in groove-map empty and loading copy", () => { + const { rerender } = render(); + expect(screen.getByText("No Lead Guitar transcription yet. Use it when you want to check the groove before rehearsal.")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Checking the Lead Guitar line... 45%")).toBeTruthy(); + }); + + it("localizes GrooveMap states and the unavailable loop control", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const roleName = song.sections[0]!.roles[0]!.name; + const { rerender } = render(); + + expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy(); + + rerender(); + expect(screen.getByText(`${roleName} 파트를 확인하는 중... 45%`)).toBeTruthy(); + expect(screen.getByRole("button", { name: "취소" })).toBeTruthy(); + + rerender(); + fireEvent.click(screen.getByRole("tab", { name: roleName })); + expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복"); + }); + + it("keeps range-backed setup available when no exact first note exists", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: "C#2", + highestNote: "E3" + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i }); + expect(setupButton).toBeEnabled(); + const visibleLabel = setupButton.textContent?.trim() ?? ""; + expect(visibleLabel).not.toBe(""); + expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel); + }); + + it("keeps placeholder-looking role names literal in setup copy", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + name: "{low}", + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: "C#2", + highestNote: "E3" + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "{low}" })); + + expect( + screen.getByRole("button", { + name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map" + }) + ).toBeEnabled(); + }); + + it("keeps disabled stem controls discoverable by their visible labels", () => { + const song = createDemoRehearsalSong(); + + render(); + + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem"); + expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent( + "Solo / mute others" + ); + }); + + it("natively disables setup when a cue has neither an entrance nor a playable range", () => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote: " ", + highestNote: " " + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { + name: "No first entrance or playable range yet. Stay on tonight's map." + }); + expect(setupButton).toBeDisabled(); + }); + + it.each([ + ["none", "none"], + ["E3", "C#2"], + ["low", "high"] + ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => { + const song = createDemoRehearsalSong(); + const roleId = song.sections[0]!.roles[0]!.id; + replaceRole(song, roleId, (role) => ({ + ...role, + setupNote: "Tune down a whole step.", + transcription: undefined, + range: { + ...role.range, + lowestNote, + highestNote + } + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name })); + + const setupButton = screen.getByRole("button", { + name: "No first entrance or playable range yet. Stay on tonight's map." + }); + expect(setupButton).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..38e439216 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -28,6 +29,7 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); it("updates practice progress immutably through onSongUpdate", () => { @@ -85,20 +87,23 @@ describe("Workspace", () => { expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); - it("enables bass transcription from selected role metadata rather than role id text", () => { + it("enables tonight's setup from the role setup cue rather than the role name", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "low-end", - name: "Bass Guitar" + name: "Bass Guitar", + setupNote: "Keep the attack short so the verse breathes." }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement; - expect(transcribeButton.disabled).toBe(false); - expect(transcribeButton.title).toBe("Transcribe part"); + const setupButton = screen.getByRole("button", { + name: /Set up Bass Guitar · then start in C#2–E3\. Setup: Keep the attack short so the verse breathes/i + }) as HTMLButtonElement; + expect(setupButton.disabled).toBe(false); + expect(screen.queryByRole("button", { name: "Transcribe Bass" })).toBeNull(); }); it("renders bass transcription in the dark rehearsal cockpit system", () => { @@ -115,7 +120,7 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i }); + const grooveMap = screen.getByRole("region", { name: /bass guitar transcription groove map/i }); expect(grooveMap.className).toContain("bg-slate-950"); expect(screen.getByText("E2")).toBeTruthy(); expect(screen.getByText("G2")).toBeTruthy(); @@ -326,4 +331,81 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("arms tonight's setup and names the first later-section entrance", () => { + const song = createDemoRehearsalSong(); + const verseRole = song.sections[0]!.roles[0]!; + song.sections[0]!.roles[0] = { + ...verseRole, + transcription: undefined + }; + song.sections.push({ + ...song.sections[0]!, + id: "chorus-1", + label: "chorus", + timeRange: { start: 40, end: 64 }, + roles: [ + { + ...verseRole, + transcription: [{ pitch: "A2", onset: 42, offset: 42.75, velocity: 0.7 }] + } + ] + }); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + fireEvent.click( + screen.getByRole("button", { + name: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/i + }) + ); + + expect( + screen.getByText( + "Tonight's Bass Guitar setup: Keep the attack short so the verse breathes. Start on A2 from 0:42 on the groove map." + ) + ).toBeTruthy(); + expect(document.activeElement?.id).toBe("workspace-role-setup"); + expect(document.getElementById("workspace-groove-entrance")).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalled(); + expect( + screen.getByRole("button", { + name: "Play stem. Isolation is not ready. Set up tonight's part first." + }) + ).toBeTruthy(); + }); + + it("keeps setup unavailable when the role has no setup cue", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + setupNote: " ", + transpositionPlan: "", + simplification: " " + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const setupButton = screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." }); + expect(setupButton).toBeDisabled(); + fireEvent.click(setupButton); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("localizes tonight's setup action without broken Korean particles", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect( + screen.getByRole("button", { + name: /Bass Guitar 세팅 · .*세팅: Keep the attack short so the verse breathes/ + }) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..55bf0163d 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,8 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze"; +import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; @@ -41,6 +41,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi } type Translator = ReturnType; +type TranscriptionNote = NonNullable[number]; /** Documented. */ function preventUnavailableAction(event: MouseEvent): void { @@ -58,6 +59,47 @@ function nonBlankText(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +/** Remove terminal sentence punctuation before embedding a cue in a larger sentence. */ +function sentenceFragment(value: string): string { + return value.replace(/[.!?。!?]+$/u, "").trimEnd(); +} + +/** Return the earliest analyzed note so setup can name the first attack. */ +function firstTranscriptionNote(notes: RehearsalRole["transcription"]): TranscriptionNote | undefined { + if (!notes || notes.length === 0) { + return undefined; + } + + let earliest = notes[0]!; + for (const note of notes) { + if (note.onset < earliest.onset) { + earliest = note; + } + } + return earliest; +} + +/** Prefer the role's setup cue, then transpose, then simplification. */ +function roleSetupCue(role: RehearsalRole | undefined): string | undefined { + return nonBlankText(role?.setupNote) ?? nonBlankText(role?.transpositionPlan) ?? nonBlankText(role?.simplification); +} + +/** Scroll and focus the setup card the player should follow next. */ +function focusRoleSetup(): void { + const node = document.getElementById("workspace-role-setup"); + if (!(node instanceof HTMLElement)) { + return; + } + const prefersReducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + node.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest" + }); + node.focus(); +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -121,6 +163,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [armedSetupRoleId, setArmedSetupRoleId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -150,7 +193,33 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp if (!activeRole) return undefined; return roleMap.get(activeRole); }, [activeRole, roleMap]); - const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; + const activeRoleTranscription = useMemo(() => { + if (!activeRole) return undefined; + const notes: TranscriptionNote[] = []; + for (const section of song.sections) { + for (const role of section.roles) { + if (role.id !== activeRole || !role.transcription) continue; + for (const note of role.transcription) { + notes.push(note); + } + } + } + if (notes.length === 0) return undefined; + notes.sort((left, right) => left.onset - right.onset); + return notes; + }, [activeRole, song.sections]); + const firstNote = firstTranscriptionNote(activeRoleTranscription); + const activeRoleRange = playableRange( + activeRoleDetails?.range.lowestNote, + activeRoleDetails?.range.highestNote + ); + const roleRangeLow = activeRoleRange?.lowestNote; + const roleRangeHigh = activeRoleRange?.highestNote; + const setupCue = roleSetupCue(activeRoleDetails); + const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : ""; + const hasPlayableRange = activeRoleRange !== null; + const hasStartEvidence = Boolean(firstNote || hasPlayableRange); + const canArmTonightSetup = Boolean(setupCue && hasStartEvidence); const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); const firstRangeCopy = firstRange ? fillRangeCopy( @@ -225,6 +294,66 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole"); + const setupUnavailableLabel = setupCue && !hasStartEvidence + ? t("workspaceSetupStartUnavailable") + : t("workspaceSetupUnavailable"); + const setupActionLabel = !canArmTonightSetup + ? setupUnavailableLabel + : firstNote + ? fillTranslation(t("workspaceSetupActionWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset) + }) + : fillTranslation(t("workspaceSetupActionWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh! + }); + const setupAriaLabel = !canArmTonightSetup + ? setupUnavailableLabel + : firstNote + ? fillTranslation(t("workspaceSetupAriaWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset), + setup: setupSentenceCue + }) + : fillTranslation(t("workspaceSetupAriaWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh!, + setup: setupSentenceCue + }); + const setupStatus = firstNote + ? fillTranslation(t("workspaceSetupArmedWithNote"), { + role: roleName, + pitch: firstNote.pitch, + start: formatTimelineTime(firstNote.onset), + setup: setupSentenceCue + }) + : fillTranslation(t("workspaceSetupArmedWithRange"), { + role: roleName, + low: roleRangeLow!, + high: roleRangeHigh!, + setup: setupSentenceCue + }); + + /** Arm tonight's setup and move focus to the setup card. */ + const armTonightSetup = (): void => { + if (!activeRole || !canArmTonightSetup) { + return; + } + setArmedSetupRoleId(activeRole); + focusRoleSetup(); + }; + + /** Keep the role board and armed setup on the same selected part. */ + const handleRoleChange = (roleId: string | null): void => { + setActiveRole(roleId); + setArmedSetupRoleId(null); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -364,7 +493,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -376,8 +505,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp - {canTranscribeBass ? ( + {canArmTonightSetup ? ( ) : ( )}
@@ -438,14 +569,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
+

- {roleTranspositionPlan} + {setupCue ?? roleTranspositionPlan}

+ {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? ( +

{roleTranspositionPlan}

+ ) : null}
{song.collaboration && ( @@ -498,7 +640,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp )} - + {armedSetupRoleId === activeRole ? ( +

+ {setupStatus} +

+ ) : null} + )} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..a0a1369c1 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, fillTranslation } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,14 @@ describe("i18n", () => { } }); }); + + describe("fillTranslation", () => { + it("keeps unknown placeholders and placeholder-shaped values literal", () => { + expect( + fillTranslation("Set up {role} in {range}", { + role: "{range}" + }) + ).toBe("Set up {range} in {range}"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..0dcf9ad30 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -6,6 +6,8 @@ export type Locale = "en" | "ko"; /** Documented. */ export type TranslationKey = keyof typeof enCommon; +const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g; + const dictionaries = { en: enCommon, ko: koCommon @@ -18,6 +20,16 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */ +export function fillTranslation( + template: string, + values: Readonly> +): string { + return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) => + Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder + ); +} + /** 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..ecc2d0eed 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,6 +50,27 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", + "workspaceThisRole": "This part", + "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}", + "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}", + "workspaceSetupAriaWithNote": "Set up {role} · then start on {pitch} from {start}. Setup: {setup}. Use tonight's groove map", + "workspaceSetupAriaWithRange": "Set up {role} · then start in {low}–{high}. Setup: {setup}. Use tonight's map", + "workspaceSetupArmedWithNote": "Tonight's {role} setup: {setup}. Start on {pitch} from {start} on the groove map.", + "workspaceSetupArmedWithRange": "Tonight's {role} setup: {setup}. Start in {low}–{high} on the map.", + "workspaceSetupUnavailable": "No setup cue yet. Stay on tonight's map.", + "workspaceSetupStartUnavailable": "No first entrance or playable range yet. Stay on tonight's map.", + "workspacePlayStemUnavailable": "Isolation is not ready. Set up tonight's part first.", + "workspaceSoloUnavailable": "Solo stays off until isolation is honest. Set up tonight's part first.", + "workspaceLoopAction": "Loop section", + "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.", + "grooveMapLoading": "Checking the {role} line... 45%", + "grooveMapCancel": "Cancel", + "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.", + "grooveMapRegionLabel": "{role} transcription groove map", + "grooveMapComplete": "Transcription complete. {count} notes analyzed.", + "grooveMapMapped": "{count} notes mapped for rehearsal", + "grooveMapEntranceTitle": "Tonight's entrance · {note}", + "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", "sectionGrooveLabel": "Groove", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..72122a8c9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,6 +50,27 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", + "workspaceThisRole": "이 파트", + "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작", + "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작", + "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요", + "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요", + "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.", + "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.", + "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.", + "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.", + "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.", + "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.", + "workspaceLoopAction": "구간 반복", + "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.", + "grooveMapLoading": "{role} 파트를 확인하는 중... 45%", + "grooveMapCancel": "취소", + "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.", + "grooveMapRegionLabel": "{role} 채보 그루브 맵", + "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.", + "grooveMapMapped": "합주용 음표 {count}개 매핑됨", + "grooveMapEntranceTitle": "오늘의 진입점 · {note}", + "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..a3f798e23 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -31,7 +31,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | -| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, and optional `entranceOnset` to mark tonight's first attack; preserve scrollable region semantics, focus-visible styling, and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py index ec6b39a32..370d556c7 100644 --- a/services/analysis-engine/tests/test_release_metadata.py +++ b/services/analysis-engine/tests/test_release_metadata.py @@ -62,6 +62,18 @@ def test_changelog_contains_root_package_release_entry() -> None: assert f"## [{root_package_version()}]" in changelog +def test_changelog_preserves_dependency_security_baseline_as_fixed() -> None: + """Keep the shipped dependency-security repair classified as a fix in Unreleased.""" + changelog = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + release_marker = "## [0.1.3]" + assert release_marker in changelog + unreleased = changelog.split(release_marker, maxsplit=1)[0] + fixed = unreleased.split("### Fixed", maxsplit=1)[1] + security_fix = "Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108" + + assert security_fix in fixed + + def test_changelog_level_three_headings_are_surrounded_by_blank_lines() -> None: """Ensure changelog subsections stay compatible with Markdown heading lint.""" lines = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8").splitlines()