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} 들어가기 전에 이 음역을 악기로 확인해 보세요." }