diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..8eea35b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Display evidence-backed setup notes, simplification actions, and overlap warnings in the active rehearsal Workspace, with English/Korean action labels and no inference from blank or legacy `none` sentinel values. - 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -14,6 +15,7 @@ ### Fixed +- Deduplicate equivalent overlap guidance after trimming and case normalization while preserving the first source warning. - 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. ## [0.1.3] - 2026-04-29 @@ -74,4 +76,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..5ae8e8fe3 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; @@ -221,6 +221,82 @@ describe("Workspace", () => { expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0); }); + it("shows normalized setup, simplification, and ordered overlap actions in the active workspace", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + name: "Bass Guitar", + setupNote: " Lower the keyboard stand before the count-in. ", + simplification: " Hold roots on beats one and three. ", + overlapWarnings: [ + " ", + " Leave the pickup to the lead vocal. ", + "Leave the pickup to the lead vocal.", + "LEAVE THE PICKUP TO THE LEAD VOCAL.", + "NONE", + "Double only after the chorus entrance." + ] + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const guidance = screen.getByRole("region", { name: "Actionable rehearsal guidance" }); + expect(within(guidance).getByText("Set up before the take")).toBeTruthy(); + expect(within(guidance).getByText("Lower the keyboard stand before the count-in.")).toBeTruthy(); + expect(within(guidance).getByText("Simplify if the pass breaks down")).toBeTruthy(); + expect(within(guidance).getByText("Hold roots on beats one and three.")).toBeTruthy(); + expect(within(guidance).getByText("Resolve these overlaps")).toBeTruthy(); + + const warnings = within(guidance).getByRole("list", { name: "Resolve these overlaps" }); + expect(within(warnings).getAllByRole("listitem").map((item) => item.textContent)).toEqual([ + "Leave the pickup to the lead vocal.", + "Double only after the chorus entrance." + ]); + expect(within(guidance).queryByText(/^none$/i)).toBeNull(); + }); + + it("does not infer rehearsal guidance from blank or legacy sentinel evidence", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + name: "Bass Guitar", + transpositionPlan: " none ", + setupNote: " NONE ", + simplification: " ", + overlapWarnings: ["", " none ", " "] + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.queryByRole("region", { name: "Actionable rehearsal guidance" })).toBeNull(); + }); + + it("localizes actionable rehearsal guidance in Korean", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + name: "Bass Guitar", + setupNote: "앰프 게인을 먼저 낮추세요.", + simplification: "첫 박의 근음만 유지하세요.", + overlapWarnings: ["보컬 픽업과 겹치지 않게 쉬세요."] + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const guidance = screen.getByRole("region", { name: "실행 가능한 합주 가이드" }); + expect(within(guidance).getByText("연주 전에 준비하세요")).toBeTruthy(); + expect(within(guidance).getByText("합주가 흔들리면 이렇게 단순화하세요")).toBeTruthy(); + expect(within(guidance).getByText("이 겹침을 먼저 해결하세요")).toBeTruthy(); + const warnings = within(guidance).getByRole("list", { name: "이 겹침을 먼저 해결하세요" }); + expect(within(warnings).getByText("보컬 픽업과 겹치지 않게 쉬세요.")).toBeTruthy(); + }); + it("exports a metadata-only handoff artifact from the workspace", async () => { const song = createDemoRehearsalSong(); const sourceBootstrap: ProjectBootstrapSummary = { diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..9335281e6 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, useId, 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"; @@ -52,12 +52,39 @@ function formatStatusLabel(status: string): string { return status.replaceAll("_", " "); } -/** Documented. */ +/** Return trimmed source text when the analysis supplied nonblank evidence. */ function nonBlankText(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } +/** + * Return buyer-visible guidance only when the producer supplied meaningful evidence. + * + * Historical analysis payloads used case-insensitive `none` text as an absence + * sentinel. The active Workspace must not turn that missing evidence into an + * instruction, warning, or transposition plan. + */ +function actionableGuidanceText(value: string | undefined): string | undefined { + const normalized = nonBlankText(value); + return normalized?.toLowerCase() === "none" ? undefined : normalized; +} + +/** Preserve unique meaningful overlap-warning order without mutating analysis output. */ +function actionableOverlapWarnings(values: readonly string[]): string[] { + const warnings: string[] = []; + const seen = new Set(); + for (const value of values) { + const normalized = actionableGuidanceText(value); + const dedupeKey = normalized?.toLowerCase(); + if (normalized && dedupeKey && !seen.has(dedupeKey)) { + seen.add(dedupeKey); + warnings.push(normalized); + } + } + return warnings; +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -122,6 +149,7 @@ 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 overlapWarningsHeadingId = useId(); // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -222,9 +250,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp nonBlankText(activeRoleDetails?.harmonicExplanation) ?? nonBlankText(activeRoleDetails?.harmony.functionLabel) ?? t("workspaceHarmonyExplainFallback"); - const roleTranspositionPlan = - nonBlankText(activeRoleDetails?.transpositionPlan) ?? - nonBlankText(activeRoleDetails?.simplification); + const roleTranspositionPlan = actionableGuidanceText(activeRoleDetails?.transpositionPlan); + const roleSetupNote = actionableGuidanceText(activeRoleDetails?.setupNote); + const roleSimplification = actionableGuidanceText(activeRoleDetails?.simplification); + const roleOverlapWarnings = actionableOverlapWarnings(activeRoleDetails?.overlapWarnings ?? []); + const hasActionableGuidance = Boolean( + roleSetupNote || roleSimplification || roleOverlapWarnings.length > 0 + ); /** Documented. */ const handleExportCueSheet = () => { @@ -438,16 +470,62 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
-
-
+ )}
+ {hasActionableGuidance && ( +
+
+ {roleSetupNote && ( +
+

+ {t("workspaceSetupLabel")} +

+

{roleSetupNote}

+
+ )} + {roleSimplification && ( +
+

+ {t("workspaceSimplificationLabel")} +

+

{roleSimplification}

+
+ )} + {roleOverlapWarnings.length > 0 && ( +
+

+ {t("workspaceOverlapWarningsLabel")} +

+
    + {roleOverlapWarnings.map((warning, warningIndex) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+
+ )} {song.collaboration && (
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..aaa87f66e 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -46,7 +46,11 @@ "workspaceApprovalsLabel": "Approvals", "workspaceHarmonyExplainLabel": "Why it works", "workspaceHarmonyExplainFallback": "The role-specific harmonic reason will appear here after the room confirms it.", - "workspaceTranspositionLabel": "Transpose / simplify", + "workspaceTranspositionLabel": "Transpose", + "workspaceGuidanceRegionLabel": "Actionable rehearsal guidance", + "workspaceSetupLabel": "Set up before the take", + "workspaceSimplificationLabel": "Simplify if the pass breaks down", + "workspaceOverlapWarningsLabel": "Resolve these overlaps", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..6041c5376 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -46,7 +46,11 @@ "workspaceApprovalsLabel": "승인", "workspaceHarmonyExplainLabel": "이 화성이 먹히는 이유", "workspaceHarmonyExplainFallback": "역할별 화성 이유는 합주실에서 확인되면 여기에 정리됩니다.", - "workspaceTranspositionLabel": "전조 / 단순화", + "workspaceTranspositionLabel": "전조", + "workspaceGuidanceRegionLabel": "실행 가능한 합주 가이드", + "workspaceSetupLabel": "연주 전에 준비하세요", + "workspaceSimplificationLabel": "합주가 흔들리면 이렇게 단순화하세요", + "workspaceOverlapWarningsLabel": "이 겹침을 먼저 해결하세요", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성",