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}
-
-
-
-
{t("workspaceTranspositionLabel")}
+ {roleTranspositionPlan && (
+
+
+
+
{t("workspaceTranspositionLabel")}
+
+
+ {roleTranspositionPlan}
+
-
- {roleTranspositionPlan}
-
-
+ )}
+ {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": "역할과 화성",