diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..05b66797b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +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. -- 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 reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, setup cues, and unlogged practice passes 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. ## Safety diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..6cff2e4ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - 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 + - the first named part that still has no practice mark, with a record-tonight's-first-pass next action - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..6c2facd0a 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 unlogged practice pass on the ready rehearsal map and tell the player to select that part and record it. - 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..8147db023 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, the next instrument check, and the first unlogged practice pass to record. `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/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..fd8554f0f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,53 @@ describe("Workspace", () => { ); }); + it("names tonight's first unlogged practice pass and tells the player to record it", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-unlogged-practice"); + expect(callout).toHaveTextContent("Tonight's first unlogged pass"); + expect(callout).toHaveTextContent( + "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass." + ); + }); + + it("asks the selected part to switch when it already has a practice mark", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 80 + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-unlogged-practice")).toHaveTextContent( + "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass." + ); + }); + + it("does not call malformed selected progress a completed practice mark", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 150 as unknown as number + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const callout = screen.getByTestId("first-unlogged-practice"); + expect(callout).toHaveTextContent( + "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged." + ); + expect(callout).not.toHaveTextContent("already has a practice mark"); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..180210a7f 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 { fillUnloggedPracticeCopy, firstUnloggedPractice } from "./firstUnloggedPractice"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstUnlogged = useMemo(() => firstUnloggedPractice(song, activeRole), [activeRole, song]); + const firstUnloggedCopy = + firstUnlogged.kind === "unlogged" + ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), { + roleName: firstUnlogged.roleName, + sectionLabel: firstUnlogged.sectionLabel + }) + : firstUnlogged.kind === "selected-logged" + ? t("workspaceFirstUnloggedPracticeSelectedReady") + : firstUnlogged.kind === "all-logged" + ? t("workspaceFirstUnloggedPracticeMissing") + : t("workspaceFirstUnloggedPracticeUnavailable"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +323,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstUnloggedPracticeTitle")}

+

{firstUnloggedCopy}

+
+

{t("workspaceSongTimelineLabel")}

@@ -512,4 +534,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts new file mode 100644 index 000000000..f97e306a2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -0,0 +1,181 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillUnloggedPracticeCopy, + firstUnloggedPractice, + hasLoggedPracticeProgress +} from "./firstUnloggedPractice"; + +describe("hasLoggedPracticeProgress", () => { + it("admits only 0–100 integers", () => { + expect(hasLoggedPracticeProgress(0)).toBe(true); + expect(hasLoggedPracticeProgress(100)).toBe(true); + expect(hasLoggedPracticeProgress(40)).toBe(true); + expect(hasLoggedPracticeProgress(undefined)).toBe(false); + expect(hasLoggedPracticeProgress(40.5)).toBe(false); + expect(hasLoggedPracticeProgress(-1)).toBe(false); + expect(hasLoggedPracticeProgress(101)).toBe(false); + expect(hasLoggedPracticeProgress("40")).toBe(false); + }); +}); + +describe("firstUnloggedPractice", () => { + it("names the first demo part that still has no practice mark", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong())).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("skips parts that already own a 0–100 mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 40 + }; + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand" + }); + }); + + it("limits the callout to the selected unlogged part", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("distinguishes a selected part with a trustworthy practice mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 0 + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" }); + }); + + it("skips malformed marks and duplicate role ids inside one section", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 150 as unknown as number + }; + song.sections[0]!.roles[1] = { + ...song.sections[0]!.roles[1]!, + id: "bass-guitar" + }; + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("treats the same named role across sections as one part", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections.push({ + ...structuredClone(verse), + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }); + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("reports unavailable when repeated section copies disagree about selected practice state", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + chorus.roles[0] = { + ...chorus.roles[0]!, + practiceProgress: 40 + }; + song.sections.push(chorus); + + expect(firstUnloggedPractice(song, "bass-guitar")).toEqual({ kind: "unavailable" }); + }); + + it("reports unavailable for a selected malformed practice mark instead of claiming it is logged", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 150 as unknown as number + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "unavailable" }); + }); + + it("returns all-logged only when every named role has trustworthy consistent marks", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "all-logged" }); + }); + + it("reports unavailable rather than all-logged when the remaining evidence is malformed", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index === 2 ? (150 as unknown as number) : index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); + }); + + it("rejects inherited identity and practice evidence", () => { + const inheritedRole = Object.create({ + id: "ghost-role", + name: "Ghost Role", + practiceProgress: 40 + }) as Record; + const song = { + sections: [ + { + label: "verse", + roles: [inheritedRole] + } + ] + } as unknown as RehearsalSong; + + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); + }); + + it("fails closed on malformed runtime roots and collections", () => { + for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { + expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toEqual({ kind: "unavailable" }); + } + }); +}); + +describe("fillUnloggedPracticeCopy", () => { + it("replaces tokens without inheriting object members", () => { + expect( + fillUnloggedPracticeCopy("{roleName} in {sectionLabel} before {sectionLabel}.", { + roleName: "Bass Guitar", + sectionLabel: "verse" + }) + ).toBe("Bass Guitar in verse before verse."); + expect(fillUnloggedPracticeCopy("Check {toString} before {missingToken}.", { roleName: "Bass Guitar" })).toBe( + "Check {toString} before {missingToken}." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts new file mode 100644 index 000000000..37c6e47fc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -0,0 +1,205 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Trustworthy state of tonight's first unlogged-practice decision. */ +export type FirstUnloggedPractice = + | { + kind: "unlogged"; + sectionLabel: string; + roleName: string; + } + | { kind: "selected-logged" } + | { kind: "all-logged" } + | { kind: "unavailable" }; + +type PracticeMark = + | { kind: "unlogged" } + | { kind: "logged"; value: number } + | { kind: "invalid" }; + +type RoleEvidence = { + roleName: string; + firstSectionLabel: string; + marks: PracticeMark[]; +}; + +/** 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 whether a record owns a field rather than inheriting it. */ +function owns(record: Record, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +/** Return whether a role already owns a 0–100 integer practice mark. */ +export function hasLoggedPracticeProgress(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; +} + +/** Admit one role-copy practice mark without granting inherited values authority. */ +function practiceMark(roleValue: Record): PracticeMark { + if (!owns(roleValue, "practiceProgress")) { + return { kind: "unlogged" }; + } + const value = roleValue.practiceProgress; + if (!hasLoggedPracticeProgress(value)) { + return { kind: "invalid" }; + } + return { kind: "logged", value }; +} + +/** Return whether every section copy agrees that the named part is still unlogged. */ +function isConsistentlyUnlogged(marks: PracticeMark[]): boolean { + return marks.length > 0 && marks.every((mark) => mark.kind === "unlogged"); +} + +/** Return whether every section copy owns the same trustworthy practice mark. */ +function isConsistentlyLogged(marks: PracticeMark[]): boolean { + if (marks.length === 0 || marks.some((mark) => mark.kind !== "logged")) { + return false; + } + const expected = (marks[0] as Extract).value; + return marks.every( + (mark) => mark.kind === "logged" && mark.value === expected + ); +} + +/** + * Resolve tonight's first trustworthy unlogged-practice state. + * + * The same role id may legitimately appear in several song sections. Those + * copies are one rehearsal part only when their display name agrees and their + * practice evidence is role-wide consistent. Duplicate ids inside one + * section, conflicting names, mixed logged/unlogged copies, malformed marks, + * inherited identity, or malformed collection evidence never become proof + * that a part—or the whole rehearsal—has already been logged. + */ +export function firstUnloggedPractice( + song: RehearsalSong, + activeRole: string | null = null +): FirstUnloggedPractice { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !owns(runtimeSong, "sections") || !Array.isArray(runtimeSong.sections)) { + return { kind: "unavailable" }; + } + + const evidenceByRole = new Map(); + const roleOrder: string[] = []; + const invalidRoleIds = new Set(); + let hasInvalidEvidence = false; + + for (const sectionValue of runtimeSong.sections) { + if ( + !isRuntimeObject(sectionValue) || + !owns(sectionValue, "label") || + !owns(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return { kind: "unavailable" }; + } + + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + return { kind: "unavailable" }; + } + + const sectionRoleIds = new Set(); + for (const roleValue of sectionValue.roles) { + if ( + !isRuntimeObject(roleValue) || + !owns(roleValue, "id") || + !owns(roleValue, "name") + ) { + hasInvalidEvidence = true; + continue; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName) { + hasInvalidEvidence = true; + continue; + } + + if (sectionRoleIds.has(roleId)) { + invalidRoleIds.add(roleId); + hasInvalidEvidence = true; + continue; + } + sectionRoleIds.add(roleId); + + const mark = practiceMark(roleValue); + if (mark.kind === "invalid") { + hasInvalidEvidence = true; + } + + const existing = evidenceByRole.get(roleId); + if (!existing) { + evidenceByRole.set(roleId, { + roleName, + firstSectionLabel: sectionLabel, + marks: [mark] + }); + roleOrder.push(roleId); + continue; + } + + if (existing.roleName !== roleName) { + invalidRoleIds.add(roleId); + hasInvalidEvidence = true; + } + existing.marks.push(mark); + } + } + + if (activeRole) { + const evidence = evidenceByRole.get(activeRole); + if (!evidence || invalidRoleIds.has(activeRole)) { + return { kind: "unavailable" }; + } + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (isConsistentlyLogged(evidence.marks)) { + return { kind: "selected-logged" }; + } + return { kind: "unavailable" }; + } + + for (const roleId of roleOrder) { + if (invalidRoleIds.has(roleId)) { + continue; + } + const evidence = evidenceByRole.get(roleId); + if (!evidence) { + hasInvalidEvidence = true; + continue; + } + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (!isConsistentlyLogged(evidence.marks)) { + hasInvalidEvidence = true; + } + } + + if (roleOrder.length === 0 || invalidRoleIds.size > 0 || hasInvalidEvidence) { + return { kind: "unavailable" }; + } + return { kind: "all-logged" }; +} + +/** Fill trusted `{token}` placeholders for unlogged-practice copy. */ +export function fillUnloggedPracticeCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..5cb997e4d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,11 @@ "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.", + "workspaceFirstUnloggedPracticeTitle": "Tonight's first unlogged pass", + "workspaceFirstUnloggedPracticeCheck": "{roleName} in {sectionLabel} has no practice logged yet. Select that part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeMissing": "Every named part already has a practice mark. Keep going until the room is ready.", + "workspaceFirstUnloggedPracticeSelectedReady": "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeUnavailable": "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged.", "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..59d80fc80 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,11 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstUnloggedPracticeTitle": "오늘 아직 기록 안 된 첫 연습", + "workspaceFirstUnloggedPracticeCheck": "{sectionLabel}의 {roleName}은 아직 연습 기록이 없습니다. 그 파트를 선택하고 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeMissing": "이름 있는 파트는 모두 연습 기록이 있습니다. 방이 준비될 때까지 이어서 연습하세요.", + "workspaceFirstUnloggedPracticeSelectedReady": "이 파트는 이미 연습 기록이 있습니다. 아직 기록 안 된 다음 파트로 바꿔 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeUnavailable": "이 파트의 연습 진행 정보가 서로 맞지 않거나 유효하지 않습니다. 기록된 것으로 보기 전에 연습 표시를 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..b51cef266 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -12,6 +12,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - section roadmap with entries, dropouts, pickups, stops, and handoffs - groove and timing cues - role ranges, overlap warnings, and simplification guidance +- the first named part that still has no practice mark, with a record-tonight's-first-pass next action - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority diff --git a/docs/doctoring/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md new file mode 100644 index 000000000..bf4695c96 --- /dev/null +++ b/docs/doctoring/first-unlogged-practice.md @@ -0,0 +1,15 @@ +# First unlogged practice pass + +The ready workspace names the first part that still has no stored `practiceProgress` so a player can select it and record tonight's first pass. + +## Authority + +`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires owned role/section identity, allows the same role id to recur across sections only when its display name remains consistent, treats a role as unlogged only when every admitted section copy omits `practiceProgress`, and rejects duplicate ids inside one section or malformed/conflicting practice evidence instead of inventing a pass. + +Issue `#1107` still owns the selected-part tracker copy. This slice does not start playback (`#961`) or change MIR (`#828` / `#770`). + +## Security notes + +- Untrusted input: `practiceProgress` and role/section identity inside a loaded project payload. +- Trust boundary: project JSON → lexical admission → React copy. +- Safe failure: inherited identity, malformed collections, conflicting section copies, and malformed marks never become rehearsal authority; no filesystem, URL, subprocess, IPC, or network dereference is added.