From bf4edf83f24a69818df92f076d879b9b3c5c7c13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:18:19 +0000 Subject: [PATCH] feat(workspace): name tonight's first confidence change on the map The ready rehearsal board already shows confidence badges, but it never named the first section where that confidence changes, so players still guessed which span to confirm by ear before counting in. Security Notes: no new filesystem, URL, subprocess, IPC, or model authority. Copy is derived from already-validated rehearsal analysis fields already rendered in the workspace. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/SectionRoadmap.test.tsx | 41 ++++ .../src/features/workspace/SectionRoadmap.tsx | 10 + .../src/features/workspace/Workspace.test.tsx | 48 ++++ .../src/features/workspace/Workspace.tsx | 24 ++ .../workspace/firstConfidenceChange.test.ts | 207 ++++++++++++++++++ .../workspace/firstConfidenceChange.ts | 144 ++++++++++++ apps/desktop/src/locales/en/common.json | 9 + apps/desktop/src/locales/ko/common.json | 9 + 12 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstConfidenceChange.test.ts create mode 100644 apps/desktop/src/features/workspace/firstConfidenceChange.ts diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..0f904897c 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 (including tonight's first confidence change and the next ear check), and rehearsal priorities. - 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..ad2321b0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -84,7 +84,7 @@ Last updated: 2026-03-11 - 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 - simplification, transposition, capo, tuning, or setup cues where applicable - - role-specific rehearsal priorities and confidence flags + - role-specific rehearsal priorities and confidence flags, with the ready workspace naming tonight's first confidence change and the next ear check - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..7ae23c901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,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 confidence change on the ready rehearsal map and tell the player to confirm that section by ear before counting in. - 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..7ce78f3fa 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 and the next instrument check, plus tonight's first confidence change and the next ear 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/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.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..6c615c46d 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -34,6 +34,47 @@ describe("SectionRoadmap", () => { expect(screen.getAllByText("음역").length).toBeGreaterThan(0); expect(screen.getByText("C#2 — E3")).toBeTruthy(); expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); + expect(screen.getByText("verse 들어가기 전에 이 중간 읽기를 귀로 확인해 보세요.")).toBeTruthy(); + }); + + it("names the next ear check on the section where confidence changes", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + verse, + { + ...verse, + id: "chorus-1", + label: "chorus", + confidence: { ...verse.confidence, level: "low" }, + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + render(); + + expect(screen.queryByTestId("confidence-next-action-verse-1")).toBeNull(); + expect(screen.getByTestId("confidence-next-action-chorus-1")).toHaveTextContent( + "Confirm this section by ear before chorus." + ); + }); + + it("marks only the destination card when consecutive sections share a label", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", confidence: { ...verse.confidence, level: "medium" } }, + { ...verse, id: "verse-b", label: "verse", confidence: { ...verse.confidence, level: "low" } } + ]; + + render(); + + expect(screen.queryByTestId("confidence-next-action-verse-a")).toBeNull(); + expect(screen.getByTestId("confidence-next-action-verse-b")).toHaveTextContent( + "Confirm this section by ear before verse." + ); }); it("omits the range row when both notes are unnamed", () => { diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..77a6cd00d 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -3,6 +3,7 @@ import { useId, useMemo } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; +import { confidenceWordKey, fillConfidenceCopy, firstConfidenceChange, isConfidenceChangeTarget } from "./firstConfidenceChange"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const confidenceChange = useMemo(() => firstConfidenceChange(song), [song]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -120,6 +122,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove} + {confidenceChange && isConfidenceChangeTarget(confidenceChange, section.id) ? ( +

+ {fillConfidenceCopy( + t(confidenceChange.kind === "change" ? "sectionConfidenceNextActionChange" : "sectionConfidenceNextActionHold"), + { sectionLabel: section.label, level: t(confidenceWordKey(confidenceChange.toLevel)) } + )} +

+ ) : null} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..d711c4314 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,41 @@ describe("Workspace", () => { ); }); + it("names tonight's held confidence and the next ear check when the form does not change", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-confidence-change"); + expect(callout).toHaveTextContent("Tonight's first confidence change"); + expect(callout).toHaveTextContent( + "Tonight's confidence stays medium through the form. Confirm the verse by ear before you count in." + ); + }); + + it("names tonight's first confidence change and the next ear check", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + verse, + { + ...verse, + id: "chorus-1", + label: "chorus", + confidence: { ...verse.confidence, level: "low" }, + roles: verse.roles.map((role) => ({ ...role, id: `${role.id}-chorus` })) + } + ]; + + render(); + + expect(screen.getByTestId("first-confidence-change")).toHaveTextContent( + "Confidence changes at chorus: low, after verse's medium. Confirm the chorus by ear before you count in." + ); + }); + it("asks for an ear check when the selected part has no named span", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -325,5 +360,18 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByText("오늘 먼저 바뀌는 확신")).toBeTruthy(); + }); + + it("asks for an ear check when no named section confidence exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0] = { ...song.sections[0]!, label: " ", confidence: { ...song.sections[0]!.confidence, level: "ready" as "low" } }; + + render(); + + expect(screen.getByTestId("first-confidence-change")).toHaveTextContent( + "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are before you count in." + ); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..989c41ca8 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { confidenceWordKey, fillConfidenceCopy, firstConfidenceChange } from "./firstConfidenceChange"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,20 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstConfidence = useMemo(() => firstConfidenceChange(song), [song]); + const firstConfidenceCopy = firstConfidence + ? firstConfidence.kind === "change" + ? fillConfidenceCopy(t("workspaceFirstConfidenceChange"), { + fromSection: firstConfidence.fromSectionLabel, + fromLevel: t(confidenceWordKey(firstConfidence.fromLevel)), + toSection: firstConfidence.toSectionLabel, + toLevel: t(confidenceWordKey(firstConfidence.toLevel)) + }) + : fillConfidenceCopy(t("workspaceFirstConfidenceHold"), { + sectionLabel: firstConfidence.toSectionLabel, + level: t(confidenceWordKey(firstConfidence.toLevel)) + }) + : t("workspaceFirstConfidenceMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +325,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstConfidenceTitle")}

+

{firstConfidenceCopy}

+
+

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts b/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts new file mode 100644 index 000000000..a61c44cea --- /dev/null +++ b/apps/desktop/src/features/workspace/firstConfidenceChange.test.ts @@ -0,0 +1,207 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + confidenceWordKey, + fillConfidenceCopy, + firstConfidenceChange, + isConfidenceChangeTarget, + sectionConfidenceLevel +} from "./firstConfidenceChange"; + +function cloneSong(song: RehearsalSong): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ ...role })) + })) + }; +} + +function appendSection( + song: RehearsalSong, + label: string, + level: "low" | "medium" | "high" +): RehearsalSong { + const first = song.sections[0]!; + return { + ...song, + sections: [ + ...song.sections, + { + ...first, + id: `${label}-section`, + label, + confidence: { + ...first.confidence, + level + }, + roles: first.roles.map((role) => ({ ...role, id: `${role.id}-${label}` })) + } + ] + }; +} + +describe("sectionConfidenceLevel", () => { + it("returns a named rehearsal confidence level", () => { + expect(sectionConfidenceLevel({ level: "low" })).toBe("low"); + expect(sectionConfidenceLevel({ level: "medium" })).toBe("medium"); + expect(sectionConfidenceLevel({ level: "high" })).toBe("high"); + }); + + it("fails closed on missing, blank, or invented levels", () => { + expect(sectionConfidenceLevel(null)).toBeNull(); + expect(sectionConfidenceLevel({ level: " " })).toBeNull(); + expect(sectionConfidenceLevel({ level: "ready" })).toBeNull(); + expect(sectionConfidenceLevel({ notes: "low" })).toBeNull(); + }); +}); + +describe("firstConfidenceChange", () => { + it("holds the demo song's one named confidence so the room does not invent a reset", () => { + expect(firstConfidenceChange(createDemoRehearsalSong())).toEqual({ + kind: "same", + fromSectionId: "verse-1", + fromSectionLabel: "verse", + fromLevel: "medium", + toSectionId: "verse-1", + toSectionLabel: "verse", + toLevel: "medium" + }); + }); + + it("names the first consecutive confidence change in form order", () => { + const song = appendSection( + appendSection(createDemoRehearsalSong(), "pre-chorus", "medium"), + "chorus", + "low" + ); + + expect(firstConfidenceChange(song)).toEqual({ + kind: "change", + fromSectionId: "pre-chorus-section", + fromSectionLabel: "pre-chorus", + fromLevel: "medium", + toSectionId: "chorus-section", + toSectionLabel: "chorus", + toLevel: "low" + }); + }); + + it("skips unlabeled or invalid-confidence sections until a named pair exists", () => { + const song = cloneSong(createDemoRehearsalSong()); + song.sections[0] = { + ...song.sections[0]!, + label: " ", + confidence: { ...song.sections[0]!.confidence, level: "ready" as "low" } + }; + const withChorus = appendSection(song, "chorus", "high"); + const withBridge = appendSection(withChorus, "bridge", "low"); + + expect(firstConfidenceChange(withBridge)).toEqual({ + kind: "change", + fromSectionId: "chorus-section", + fromSectionLabel: "chorus", + fromLevel: "high", + toSectionId: "bridge-section", + toSectionLabel: "bridge", + toLevel: "low" + }); + }); + + it("fails closed on malformed runtime roots and members", () => { + expect(firstConfidenceChange(null as unknown as RehearsalSong)).toBeNull(); + expect(firstConfidenceChange({ sections: "nope" } as unknown as RehearsalSong)).toBeNull(); + expect( + firstConfidenceChange({ + ...createDemoRehearsalSong(), + sections: [null, "skip", { label: "verse" }] + } as unknown as RehearsalSong) + ).toBeNull(); + }); + + it("fails closed when repeated section ids cannot identify one destination card", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "duplicate", confidence: { ...verse.confidence, level: "medium" } }, + { ...verse, id: "duplicate", confidence: { ...verse.confidence, level: "low" } } + ]; + + expect(firstConfidenceChange(song)).toBeNull(); + }); + + it("rejects a repeated section id even when one occurrence has no eligible confidence evidence", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "duplicate", label: " ", confidence: { ...verse.confidence, level: "ready" as "low" } }, + { ...verse, id: "duplicate", label: "chorus", confidence: { ...verse.confidence, level: "low" } } + ]; + + expect(firstConfidenceChange(song)).toBeNull(); + }); + + it("validates later section ids before returning an earlier confidence transition", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", confidence: { ...verse.confidence, level: "medium" } }, + { ...verse, id: "chorus-a", label: "chorus", confidence: { ...verse.confidence, level: "low" } }, + { ...verse, id: "verse-a", label: "outro", confidence: { ...verse.confidence, level: "high" } } + ]; + + expect(firstConfidenceChange(song)).toBeNull(); + }); +}); + +describe("isConfidenceChangeTarget", () => { + it("marks only the arrival section identity as the next-action card", () => { + const change: ReturnType = { + kind: "change", + fromSectionId: "verse-1", + fromSectionLabel: "verse", + fromLevel: "medium", + toSectionId: "chorus-1", + toSectionLabel: "chorus", + toLevel: "low" + }; + + expect(isConfidenceChangeTarget(change, "chorus-1")).toBe(true); + expect(isConfidenceChangeTarget(change, " verse-1 ")).toBe(false); + expect(isConfidenceChangeTarget(change, " ")).toBe(false); + }); + + it("uses stable section identity when repeated labels would otherwise mark multiple cards", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { ...verse, id: "verse-a", label: "verse", confidence: { ...verse.confidence, level: "medium" } }, + { ...verse, id: "verse-b", label: "verse", confidence: { ...verse.confidence, level: "low" } } + ]; + + const change = firstConfidenceChange(song); + expect(change?.kind).toBe("change"); + expect(isConfidenceChangeTarget(change!, "verse-a")).toBe(false); + expect(isConfidenceChangeTarget(change!, "verse-b")).toBe(true); + }); +}); + +describe("fillConfidenceCopy", () => { + it("keeps rehearsal confidence values literal while filling trusted tokens", () => { + expect( + fillConfidenceCopy("Confirm {toLevel} before the {toSection}.", { + toLevel: "low {toSection}", + toSection: "chorus" + }) + ).toBe("Confirm low {toSection} before the chorus."); + }); +}); + +describe("confidenceWordKey", () => { + it("maps each named level onto its i18n word key", () => { + expect(confidenceWordKey("low")).toBe("confidenceWordLow"); + expect(confidenceWordKey("medium")).toBe("confidenceWordMedium"); + expect(confidenceWordKey("high")).toBe("confidenceWordHigh"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstConfidenceChange.ts b/apps/desktop/src/features/workspace/firstConfidenceChange.ts new file mode 100644 index 000000000..4b1b7a8a0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstConfidenceChange.ts @@ -0,0 +1,144 @@ +import type { ConfidenceLevel, RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named section-confidence change, or a same-level hold through the form. */ +export type FirstConfidenceChange = { + kind: "change" | "same"; + fromSectionId: string; + fromSectionLabel: string; + fromLevel: ConfidenceLevel; + toSectionId: string; + toSectionLabel: string; + toLevel: ConfidenceLevel; +}; + +type NamedConfidence = { + sectionId: string; + sectionLabel: string; + level: ConfidenceLevel; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Return a named rehearsal confidence level or fail closed. */ +export function sectionConfidenceLevel(confidenceValue: unknown): ConfidenceLevel | null { + if (!isRuntimeObject(confidenceValue)) { + return null; + } + const level = confidenceValue.level; + if (level === "low" || level === "medium" || level === "high") { + return level; + } + return null; +} + +/** + * Pick the first named section-confidence change a player should confirm by ear. + * + * Walks labeled sections in form order and returns the first consecutive pair + * whose confidence level differs. When every named section holds the same + * level, the result is a same-level hold so the room does not invent a reset. + * Stable section ids, rather than display labels, own roadmap targeting. + * All meaningful section ids are validated for uniqueness before confidence + * evidence is derived so an ineligible or later duplicate cannot create + * ambiguous cards. + */ +export function firstConfidenceChange(song: RehearsalSong): FirstConfidenceChange | null { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const seenSectionIds = new Set(); + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + if (!sectionId) { + continue; + } + if (seenSectionIds.has(sectionId)) { + return null; + } + seenSectionIds.add(sectionId); + } + + const namedLevels: NamedConfidence[] = []; + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + const sectionLabel = meaningfulRangeText(sectionValue.label); + const level = sectionConfidenceLevel(sectionValue.confidence); + if (!sectionId || !sectionLabel || level === null) { + continue; + } + namedLevels.push({ + sectionId, + sectionLabel, + level + }); + } + + if (namedLevels.length === 0) { + return null; + } + + const first = namedLevels[0]; + if (!first) { + return null; + } + for (let index = 1; index < namedLevels.length; index += 1) { + const previous = namedLevels[index - 1]; + const current = namedLevels[index]; + if (previous.level !== current.level) { + return { + kind: "change", + fromSectionId: previous.sectionId, + fromSectionLabel: previous.sectionLabel, + fromLevel: previous.level, + toSectionId: current.sectionId, + toSectionLabel: current.sectionLabel, + toLevel: current.level + }; + } + } + + return { + kind: "same", + fromSectionId: first.sectionId, + fromSectionLabel: first.sectionLabel, + fromLevel: first.level, + toSectionId: first.sectionId, + toSectionLabel: first.sectionLabel, + toLevel: first.level + }; +} + +/** Fill trusted `{token}` placeholders for confidence-change rehearsal copy. */ +export function fillConfidenceCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} + +/** True when this stable section identity is the map card owning the next confidence action. */ +export function isConfidenceChangeTarget(change: FirstConfidenceChange, sectionId: string): boolean { + const identity = meaningfulRangeText(sectionId); + return Boolean(identity) && identity === change.toSectionId; +} + +/** Map a validated confidence level onto the matching i18n word key. */ +export function confidenceWordKey(level: ConfidenceLevel): "confidenceWordLow" | "confidenceWordMedium" | "confidenceWordHigh" { + if (level === "low") { + return "confidenceWordLow"; + } + if (level === "high") { + return "confidenceWordHigh"; + } + return "confidenceWordMedium"; +} + diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..847b31f46 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,15 @@ "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.", + "workspaceFirstConfidenceTitle": "Tonight's first confidence change", + "workspaceFirstConfidenceChange": "Confidence changes at {toSection}: {toLevel}, after {fromSection}'s {fromLevel}. Confirm the {toSection} by ear before you count in.", + "workspaceFirstConfidenceHold": "Tonight's confidence stays {level} through the form. Confirm the {sectionLabel} by ear before you count in.", + "workspaceFirstConfidenceMissing": "Tonight's first confidence change still needs an ear check. Confirm how sure the first two sections are before you count in.", + "sectionConfidenceNextActionChange": "Confirm this section by ear before {sectionLabel}.", + "sectionConfidenceNextActionHold": "Give this {level} read an ear check before {sectionLabel}.", + "confidenceWordLow": "low", + "confidenceWordMedium": "medium", + "confidenceWordHigh": "high", "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..d76b604e3 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,15 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstConfidenceTitle": "오늘 먼저 바뀌는 확신", + "workspaceFirstConfidenceChange": "{fromSection}의 {fromLevel} 다음, {toSection}에서 {toLevel}으로 바뀝니다. {toSection} 들어가기 전에 귀로 한 번 더 확인해 보세요.", + "workspaceFirstConfidenceHold": "오늘 확신은 {level}으로 끝까지 갑니다. {sectionLabel} 들어가기 전에 귀로 한 번 더 확인해 보세요.", + "workspaceFirstConfidenceMissing": "오늘 먼저 바뀌는 확신은 아직 귀로 확인이 필요합니다. 처음 두 구간이 얼마나 확실한지 확인한 뒤 카운트하세요.", + "sectionConfidenceNextActionChange": "{sectionLabel} 들어가기 전에 이 구간을 귀로 확인해 보세요.", + "sectionConfidenceNextActionHold": "{sectionLabel} 들어가기 전에 이 {level} 읽기를 귀로 확인해 보세요.", + "confidenceWordLow": "낮음", + "confidenceWordMedium": "중간", + "confidenceWordHigh": "높음", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." }