diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..b9a67ce17 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, 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, and setup cues 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 3302a6fc3..ca0df5ac4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - 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 + - 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 - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 6027f1a81..0b6f7e784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,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. - 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 82c2c704a..b5a34c1fa 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). `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. `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 75a199246..5b32019d2 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -31,6 +31,52 @@ describe("SectionRoadmap", () => { expect(screen.getAllByText("큐").length).toBeGreaterThan(0); expect(screen.getAllByTitle("우선순위: high").length).toBeGreaterThan(0); expect(screen.getByText("사용자")).toBeTruthy(); + expect(screen.getAllByText("음역").length).toBeGreaterThan(0); + expect(screen.getByText("C#2 — E3")).toBeTruthy(); + expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); + }); + + it("omits the range row when both notes are unnamed", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + range: { lowestNote: " ", highestNote: "none" } + }; + + render(); + + expect(screen.queryByText("Range")).toBeNull(); + expect(screen.queryByText(/Check this span on your instrument/i)).toBeNull(); + }); + + it("omits the range row when the span is inverted instead of presenting it as valid", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + range: { lowestNote: "E3", highestNote: "C#2" } + }; + + render(); + + expect(screen.queryByText("Range")).toBeNull(); + expect(screen.queryByText(/Check this span on your instrument/i)).toBeNull(); + expect(screen.queryByText(/E3 — C#2/)).toBeNull(); + }); + + it("omits the range row when a note is not a scientific-pitch label", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + range: { lowestNote: "low-ish", highestNote: "E3" } + }; + + render(); + + expect(screen.queryByText("Range")).toBeNull(); + expect(screen.queryByText(/Check this span on your instrument/i)).toBeNull(); }); it("uses localized copy for chord edit prompts and control labels", () => { diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..834d1e8f0 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -2,6 +2,7 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; import { useId, useMemo } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; +import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -124,7 +125,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {section.roles .filter(role => !activeRole || role.id === activeRole) - .map(role => ( + .map(role => { + const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote); + return (
+ {validatedRange ? ( +
+ {t("sectionRangeLabel")} + + {validatedRange.lowestNote} — {validatedRange.highestNote} + +

+ {fillRangeCopy(t("sectionRangeNextAction"), { sectionLabel: section.label })} +

+
+ ) : null} + {role.setupNote && (
- ))} + ); + })}
))} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..7837bf80e 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -140,6 +140,62 @@ describe("Workspace", () => { expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); + it("names tonight's first playable range and the next instrument check", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-range-squeeze"); + expect(callout).toHaveTextContent("Tonight's first range"); + expect(callout).toHaveTextContent( + "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." + ); + }); + + it("asks for an ear check when the selected part has no named span", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + + render(); + + expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( + "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." + ); + }); + + it("limits the range callout to the selected role", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( + "Lead Vocal sits G#3–C#5 in verse. Hear that clash on your instrument before the verse." + ); + }); + + it("asks the player to check a named span when no clash is present", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + overlapWarnings: [] + })); + + render(); + + expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( + "Bass Guitar sits C#2–E3 in verse. Check that span on your instrument before the verse." + ); + }); + 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 71546b524..d44e20777 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -150,6 +151,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp return roleMap.get(activeRole); }, [activeRole, roleMap]); const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; + const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); + const firstRangeCopy = firstRange + ? fillRangeCopy( + t(firstRange.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), + { + roleName: firstRange.roleName, + lowestNote: firstRange.lowestNote, + highestNote: firstRange.highestNote, + sectionLabel: firstRange.sectionLabel + } + ) + : t("workspaceFirstRangeMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -288,6 +301,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp +
+

{t("workspaceFirstRangeTitle")}

+

{firstRangeCopy}

+
+

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts new file mode 100644 index 000000000..643935954 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -0,0 +1,167 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; + +function blankRoleRange(song: RehearsalSong): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "" }, + overlapWarnings: [] + })) + })) + }; +} + +describe("meaningfulRangeText", () => { + it("rejects blank, whitespace, and none sentinels", () => { + expect(meaningfulRangeText(undefined)).toBeUndefined(); + expect(meaningfulRangeText("")).toBeUndefined(); + expect(meaningfulRangeText(" ")).toBeUndefined(); + expect(meaningfulRangeText("none")).toBeUndefined(); + expect(meaningfulRangeText("NONE")).toBeUndefined(); + expect(meaningfulRangeText(" C#2 ")).toBe("C#2"); + }); +}); + +describe("playableRange", () => { + it("returns the trimmed ordered span for a valid scientific-pitch range", () => { + expect(playableRange(" C#2 ", "E3")).toEqual({ lowestNote: "C#2", highestNote: "E3" }); + expect(playableRange("E3", "E3")).toEqual({ lowestNote: "E3", highestNote: "E3" }); + }); + + it("fails closed on blank, none, non-pitch, or inverted spans", () => { + for (const [lowestNote, highestNote] of [ + ["", ""], + ["none", "E3"], + ["not-a-note", "E3"], + ["E3", "not-a-note"], + ["E3", "C#2"] + ]) { + expect(playableRange(lowestNote, highestNote)).toBeNull(); + } + }); +}); + +describe("firstRangeSqueeze", () => { + it("prefers the first named span that also carries a clash warning", () => { + const squeeze = firstRangeSqueeze(createDemoRehearsalSong()); + + expect(squeeze).toEqual({ + sectionLabel: "verse", + roleName: "Bass Guitar", + lowestNote: "C#2", + highestNote: "E3", + overlapWarning: "Density warning: competing with Keyboard Left Hand in low register." + }); + }); + + it("falls back to the first named span when clashes are only none sentinels", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + overlapWarnings: index === 0 ? [" none ", ""] : [] + })); + + expect(firstRangeSqueeze(song)).toEqual({ + sectionLabel: "verse", + roleName: "Bass Guitar", + lowestNote: "C#2", + highestNote: "E3", + overlapWarning: undefined + }); + }); + + it("skips roles whose span is blank or none until a named span exists", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + range: { lowestNote: "none", highestNote: "E3" }, + overlapWarnings: ["Density warning: competing with Keyboard Left Hand in low register."] + }; + + expect(firstRangeSqueeze(song)?.roleName).toBe("Keyboard 1 Right Hand"); + }); + + it("rejects malformed and inverted spans instead of calling them playable", () => { + for (const range of [ + { lowestNote: "not-a-note", highestNote: "E3" }, + { lowestNote: "E3", highestNote: "C#2" } + ]) { + const song = createDemoRehearsalSong(); + const selectedRole = song.sections[0]!.roles[0]!; + selectedRole.range = range; + + expect(firstRangeSqueeze(song, selectedRole.id)).toBeNull(); + } + }); + + it("fails closed on malformed runtime roots and collections", () => { + for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { + expect(firstRangeSqueeze(malformed as unknown as RehearsalSong)).toBeNull(); + } + + const song = createDemoRehearsalSong(); + const validRole = song.sections[0]!.roles[0]!; + const malformedSection = { + ...song.sections[0], + roles: [null, { ...validRole, range: null }, validRole] + }; + + expect( + firstRangeSqueeze({ ...song, sections: [malformedSection] } as unknown as RehearsalSong) + ).toEqual({ + sectionLabel: "verse", + roleName: "Bass Guitar", + lowestNote: "C#2", + highestNote: "E3", + overlapWarning: "Density warning: competing with Keyboard Left Hand in low register." + }); + }); + + it("limits the squeeze to the selected role", () => { + const squeeze = firstRangeSqueeze(createDemoRehearsalSong(), "lead-vocal"); + + expect(squeeze).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal", + lowestNote: "G#3", + highestNote: "C#5", + overlapWarning: "Melodic overlap: competing with Keyboard 1 Right Hand." + }); + }); + + it("returns null when no selected role has both notes", () => { + expect(firstRangeSqueeze(blankRoleRange(createDemoRehearsalSong()))).toBeNull(); + expect(firstRangeSqueeze(createDemoRehearsalSong(), "missing-role")).toBeNull(); + }); +}); + +describe("fillRangeCopy", () => { + it("replaces every token occurrence", () => { + expect( + fillRangeCopy("{roleName} in {sectionLabel} before the {sectionLabel}.", { + roleName: "Bass Guitar", + sectionLabel: "verse" + }) + ).toBe("Bass Guitar in verse before the verse."); + }); + + it("keeps replacement tokens and placeholder-shaped rehearsal values literal", () => { + expect( + fillRangeCopy("{roleName} in {sectionLabel}.", { + roleName: "Bass $& {sectionLabel}", + sectionLabel: "verse" + }) + ).toBe("Bass $& {sectionLabel} in verse."); + }); + + it("does not satisfy tokens with inherited object members", () => { + expect( + fillRangeCopy("Check {toString} before {missingToken}.", { sectionLabel: "verse" }) + ).toBe("Check {toString} before {missingToken}."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts new file mode 100644 index 000000000..47270d2a9 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -0,0 +1,172 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; + +/** Tonight's first named playable span on the rehearsal map. */ +export type FirstRangeSqueeze = { + sectionLabel: string; + roleName: string; + lowestNote: string; + highestNote: string; + overlapWarning?: string; +}; + +const NATURAL_PITCH_CLASS = { + C: 0, + D: 2, + E: 4, + F: 5, + G: 7, + A: 9, + B: 11 +} as const; + +const ACCIDENTAL_OFFSET: Record = { + "": 0, + "#": 1, + "♯": 1, + b: -1, + "♭": -1 +}; + +const NOTE_PATTERN = /^([A-Ga-g])([#b♯♭]?)(-?\d{1,2})$/u; + +/** 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 trimmed copy that is not a blank or `none` sentinel. */ +export function meaningfulRangeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || /^none$/i.test(trimmed)) { + return undefined; + } + return trimmed; +} + +/** Convert a bounded scientific-pitch label into chromatic ordering. */ +function notePitchValue(note: string): number | null { + const match = NOTE_PATTERN.exec(note); + if (!match) { + return null; + } + const letter = match[1].toUpperCase() as keyof typeof NATURAL_PITCH_CLASS; + const octave = Number(match[3]); + return (octave + 1) * 12 + NATURAL_PITCH_CLASS[letter] + ACCIDENTAL_OFFSET[match[2]]; +} + +/** + * Return a complete, ordered scientific-pitch range or fail closed. + * + * Shared by the first-range callout and the section roadmap so both surfaces + * only present spans that parse as scientific pitch labels in low-to-high + * order; malformed or inverted evidence is rejected instead of being shown + * as playable-range guidance. + */ +export function playableRange( + lowestNoteValue: unknown, + highestNoteValue: unknown +): Pick | null { + const lowestNote = meaningfulRangeText(lowestNoteValue); + const highestNote = meaningfulRangeText(highestNoteValue); + if (!lowestNote || !highestNote) { + return null; + } + + const lowestPitch = notePitchValue(lowestNote); + const highestPitch = notePitchValue(highestNote); + if (lowestPitch === null || highestPitch === null || lowestPitch > highestPitch) { + return null; + } + + return { lowestNote, highestNote }; +} + +/** + * Pick the first playable range a player should check before the next section. + * + * Prefers a named span that also carries a clash warning so the board names + * the squeeze that will waste rehearsal time. Falls back to the first named + * span when no clash is present. Runtime roots and collection members are + * treated as untrusted; malformed evidence is isolated instead of crashing + * the buyer-visible workspace or becoming playable-range authority. + */ +export function firstRangeSqueeze( + song: RehearsalSong, + activeRole: string | null = null +): FirstRangeSqueeze | null { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + let fallback: FirstRangeSqueeze | null = null; + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName || (activeRole && roleId !== activeRole)) { + continue; + } + if (!isRuntimeObject(roleValue.range)) { + continue; + } + + const range = playableRange(roleValue.range.lowestNote, roleValue.range.highestNote); + if (!range) { + continue; + } + + let overlapWarning: string | undefined; + if (Array.isArray(roleValue.overlapWarnings)) { + for (const warning of roleValue.overlapWarnings) { + const meaningfulWarning = meaningfulRangeText(warning); + if (meaningfulWarning) { + overlapWarning = meaningfulWarning; + break; + } + } + } + + const candidate: FirstRangeSqueeze = { + sectionLabel, + roleName, + ...range, + overlapWarning + }; + + if (overlapWarning) { + return candidate; + } + + if (!fallback) { + fallback = candidate; + } + } + } + + return fallback; +} + +/** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ +export function fillRangeCopy(template: string, values: Record): string { + return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (placeholder, token: string) => { + // Own-property lookup only: inherited members such as `toString` must + // never satisfy a token, or the raw function source would be rendered. + return Object.prototype.hasOwnProperty.call(values, token) ? values[token] : placeholder; + }); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..d803a765e 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "workspaceFirstRangeTitle": "Tonight's first range", + "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.", + "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 371884abb..0f6c6c66d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", + "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", + "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", + "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "sectionRangeLabel": "음역", + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." }