diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..7b6a45868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Import a bounded, validated BandScope metadata-handoff JSON file, require an explicit fresh local-audio pairing, and reuse the handoff's focused rehearsal roles for reanalysis (#739). Competing source actions and project replacement remain locked while handoff validation is pending; project loading reciprocally locks source/handoff changes and saving the prior project until the load settles; source/project/analysis handlers and the handoff import/open/change/clear handlers independently reject stale overlapping transitions even if UI disablement is bypassed; once a handoff validates, YouTube import stays unavailable until the handoff is cleared or consumed so only an explicit recipient-selected local file can satisfy the pairing boundary. - 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/apps/desktop/src/App.handoff-project-load.test.tsx b/apps/desktop/src/App.handoff-project-load.test.tsx new file mode 100644 index 000000000..30af800a1 --- /dev/null +++ b/apps/desktop/src/App.handoff-project-load.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { App } from "./App"; +import { loadProject } from "./lib/analysis"; + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () =>
Score view
+})); + +vi.mock("./features/workspace/Workspace", () => ({ + Workspace: () =>
Workspace result
+})); + +vi.mock("./lib/analysis", () => ({ + MAX_YOUTUBE_URL_LENGTH: 2048, + createDefaultAnalysisRequest: vi.fn(() => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["keys-right"] + })), + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(() => true), + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource: vi.fn(), + startAnalysisJob: vi.fn(), + subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined) +})); + +vi.mock("./lib/handoff", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + readMetadataHandoffFile: vi.fn() + }; +}); + +const mockedLoadProject = vi.mocked(loadProject); + +function loadedSong(): RehearsalSong { + return { + id: "loaded-song", + title: "Loaded song", + sections: [], + exportSummary: { + format: "cue-sheet", + headline: "Loaded rehearsal project", + focusSections: [] + } + } as RehearsalSong; +} + +describe("App project-load source exclusion", () => { + beforeEach(() => { + mockedLoadProject.mockReset(); + }); + + it("locks source and handoff controls until project loading settles", async () => { + let resolveProject: ((song: RehearsalSong) => void) | null = null; + mockedLoadProject.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProject = resolve; + }) + ); + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + expect(screen.getByRole("button", { name: /open project/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /choose local audio/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /import handoff/i })).toBeDisabled(); + expect(screen.getByLabelText(/youtube url/i)).toBeDisabled(); + + resolveProject?.(loadedSong()); + await screen.findByText("Workspace result"); + await waitFor(() => { + expect(screen.getByRole("button", { name: /open project/i })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: /choose local audio/i })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: /import handoff/i })).not.toBeDisabled(); + expect(screen.getByLabelText(/youtube url/i)).not.toBeDisabled(); + }); + }); +}); diff --git a/apps/desktop/src/App.handoff-youtube-guard.test.tsx b/apps/desktop/src/App.handoff-youtube-guard.test.tsx new file mode 100644 index 000000000..fdb373041 --- /dev/null +++ b/apps/desktop/src/App.handoff-youtube-guard.test.tsx @@ -0,0 +1,124 @@ +import type { ButtonHTMLAttributes } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MetadataHandoffArtifact } from "@bandscope/shared-types"; +import { App } from "./App"; +import { importYoutubeUrl } from "./lib/analysis"; +import { readMetadataHandoffFile } from "./lib/handoff"; + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () =>
Score view
+})); + +vi.mock("./features/workspace/Workspace", () => ({ + Workspace: () =>
Workspace result
+})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ disabled, ...props }: ButtonHTMLAttributes) => ( + +
+ + +
@@ -702,13 +827,28 @@ export function App() { value={youtubeUrl} maxLength={MAX_YOUTUBE_URL_LENGTH} onChange={(e) => setYoutubeUrl(e.target.value)} - disabled={analysisInFlight || isStarting || isImporting} + disabled={ + analysisInFlight || + isStarting || + isSelectingLocalAudio || + isImporting || + isReadingHandoff || + isLoadingProject || + pendingHandoff !== null + } className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" aria-label={t("youtubeUrlAriaLabel")} aria-invalid={selectionError && selectionErrorSource === "youtube" ? true : undefined} aria-describedby={selectionError && selectionErrorSource === "youtube" ? "selection-error" : undefined} /> - {youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? ( + {youtubeUrl && + !analysisInFlight && + !isStarting && + !isImporting && + !isSelectingLocalAudio && + !isReadingHandoff && + !isLoadingProject && + pendingHandoff === null ? (
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/import/HandoffImportControl.handler-guard.test.tsx b/apps/desktop/src/features/import/HandoffImportControl.handler-guard.test.tsx new file mode 100644 index 000000000..0ec8db09e --- /dev/null +++ b/apps/desktop/src/features/import/HandoffImportControl.handler-guard.test.tsx @@ -0,0 +1,129 @@ +import type { ButtonHTMLAttributes } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MetadataHandoffArtifact } from "@bandscope/shared-types"; +import { HandoffImportControl } from "./HandoffImportControl"; +import { readMetadataHandoffFile } from "../../lib/handoff"; + +vi.mock("@/components/ui/button", () => ({ + Button: ({ disabled, ...props }: ButtonHTMLAttributes) => ( + + + {handoff ? ( +
+
+

{handoff.workspace.title}

+

+ {handoff.song.title} · {roleFocusCount} {t("handoffFocusedRoles")} +

+
+ +
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..872f5b28b 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -117,6 +117,23 @@ describe("analysis bridge", () => { expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); }); + it("rejects a status response whose job identity differs from the requested job", async () => { + tauriWindow.__TAURI_INVOKE__ = vi.fn().mockResolvedValue({ + jobId: "job-foreign", + state: "queued", + requestedAt: "2026-08-19T00:00:00.000Z", + updatedAt: "2026-08-19T00:00:00.000Z", + progressLabel: "Queued for analysis" + }); + + await expect(getAnalysisJobStatus("job-requested")).rejects.toThrow( + "Invalid analysis job status response" + ); + expect(tauriWindow.__TAURI_INVOKE__).toHaveBeenCalledWith("get_analysis_job_status", { + jobId: "job-requested" + }); + }); + it("reports staged browser fallback progress before returning the demo result", async () => { const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..6709183b7 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -273,11 +273,16 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise { const response = await invokeAnalysis("get_analysis_job_status", { jobId }); + let status: AnalysisJobStatus; try { - return parseAnalysisJobStatus(response); + status = parseAnalysisJobStatus(response); } catch { throw new Error("Invalid analysis job status response"); } + if (status.jobId !== jobId) { + throw new Error("Invalid analysis job status response"); + } + return status; } /** Documented. */ diff --git a/apps/desktop/src/lib/handoff.test.ts b/apps/desktop/src/lib/handoff.test.ts new file mode 100644 index 000000000..0b972597b --- /dev/null +++ b/apps/desktop/src/lib/handoff.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + AnalysisJobRequest, + MetadataHandoffArtifact, + ProjectBootstrapSummary +} from "@bandscope/shared-types"; +import { + MAX_HANDOFF_FILE_BYTES, + createAnalysisRequestForSelection, + handoffRoleFocus, + readMetadataHandoffFile, + type MetadataHandoffFile +} from "./handoff"; + +function validHandoff(): MetadataHandoffArtifact { + return { + artifactKind: "bandscope.metadata-handoff", + artifactVersion: 1, + createdAt: "2026-08-03T03:20:00.000Z", + workspace: { + id: "workspace-1", + title: "Friday rehearsal", + workspaceVersion: 1 + }, + song: { + id: "song-1", + title: "Late Night Set", + exportSummary: { + format: "cue-sheet", + headline: "Start with the chorus entrance.", + focusSections: ["chorus"] + } + }, + sections: [ + { + id: "verse-1", + label: "verse", + timeRange: { start: 0, end: 30 }, + confidence: { level: "medium", source: "model", notes: "Check pickup." }, + roleBuckets: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument", + confidence: { level: "high", source: "model", notes: "" }, + rehearsalPriority: "high" + }, + { + id: "lead-vocal", + name: "Lead Vocal", + roleType: "vocal", + confidence: { level: "medium", source: "model", notes: "" }, + rehearsalPriority: "medium" + } + ] + }, + { + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 60 }, + confidence: { level: "high", source: "model", notes: "" }, + roleBuckets: [ + { + id: "bass-guitar", + name: "Bass Guitar", + roleType: "instrument", + confidence: { level: "high", source: "model", notes: "" }, + rehearsalPriority: "high" + } + ] + } + ], + sourceAssets: [] + }; +} + +function handoffFile( + name: string, + bytes: Uint8Array, + reportedSize = bytes.byteLength +): MetadataHandoffFile & { slice: ReturnType } { + const payload = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer; + const blob = new Blob([payload]); + const slice = vi.fn((start?: number, end?: number) => blob.slice(start, end)); + return { name, size: reportedSize, slice }; +} + +function jsonFile(payload: unknown, name = "friday-handoff.json"): ReturnType { + return handoffFile(name, new TextEncoder().encode(JSON.stringify(payload))); +} + +function selectedSource(): ProjectBootstrapSummary { + return { + projectId: "recipient-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/recipient-project", + cacheRoot: "/tmp/bandscope/cache/recipient-project", + tempRoot: "/tmp/bandscope/temp/recipient-project", + source: { + sourcePath: "/Users/recipient/Music/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 1_024_000 + } + }; +} + +describe("metadata handoff import", () => { + it("accepts bounded UTF-8 JSON and validates the artifact contract", async () => { + const result = await readMetadataHandoffFile(jsonFile(validHandoff(), "FRIDAY-HANDOFF.JSON")); + + expect(result).toEqual({ + ok: true, + fileName: "FRIDAY-HANDOFF.JSON", + artifact: validHandoff(), + roleFocus: ["bass-guitar", "lead-vocal"] + }); + }); + + it("rejects a non-JSON filename before reading bytes", async () => { + const file = jsonFile(validHandoff(), "friday-handoff.txt"); + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "unsupported_file" + }); + expect(file.slice).not.toHaveBeenCalled(); + }); + + it.each([ + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + MAX_HANDOFF_FILE_BYTES + 1 + ])("rejects unsafe reported size %s before reading bytes", async (reportedSize) => { + const file = handoffFile( + "friday-handoff.json", + new Uint8Array([0x7b, 0x7d]), + reportedSize + ); + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "too_large" + }); + expect(file.slice).not.toHaveBeenCalled(); + }); + + it("rechecks the actual byte length after bounded reading", async () => { + const file = handoffFile( + "friday-handoff.json", + new Uint8Array(MAX_HANDOFF_FILE_BYTES + 1), + 1 + ); + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "too_large" + }); + expect(file.slice).toHaveBeenCalledWith(0, MAX_HANDOFF_FILE_BYTES + 1); + }); + + it("rejects a truncated or unstable browser file read", async () => { + const file = handoffFile( + "friday-handoff.json", + new Uint8Array([0x7b, 0x7d]), + 1 + ); + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "read_failed" + }); + }); + + it("rejects malformed UTF-8 without replacement-character parsing", async () => { + const file = handoffFile("friday-handoff.json", new Uint8Array([0xc3, 0x28])); + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "invalid_utf8" + }); + }); + + it("distinguishes malformed JSON from an invalid BandScope artifact", async () => { + const malformedJson = handoffFile( + "friday-handoff.json", + new TextEncoder().encode("{not-json") + ); + await expect(readMetadataHandoffFile(malformedJson)).resolves.toEqual({ + ok: false, + code: "invalid_json" + }); + + const wrongArtifact = jsonFile({ artifactKind: "other-product" }); + await expect(readMetadataHandoffFile(wrongArtifact)).resolves.toEqual({ + ok: false, + code: "invalid_artifact" + }); + }); + + it("reports read failures without exposing the underlying exception", async () => { + const file: MetadataHandoffFile = { + name: "friday-handoff.json", + size: 12, + slice: vi.fn( + () => + ({ + arrayBuffer: vi.fn(async () => { + throw new Error("/Users/private/secret.json could not be read"); + }) + }) as unknown as Blob + ) + }; + + await expect(readMetadataHandoffFile(file)).resolves.toEqual({ + ok: false, + code: "read_failed" + }); + }); +}); + +describe("handoff-focused analysis request", () => { + const defaultRequest: AnalysisJobRequest = { + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["keys-right"] + }; + + it("deduplicates role focus while preserving first-seen order", () => { + expect(handoffRoleFocus(validHandoff())).toEqual(["bass-guitar", "lead-vocal"]); + }); + + it("keeps the default request until the user selects local audio", () => { + expect(createAnalysisRequestForSelection(defaultRequest, null, validHandoff())).toEqual( + defaultRequest + ); + }); + + it("keeps the existing local-audio path when no handoff is pending", () => { + expect(createAnalysisRequestForSelection(defaultRequest, selectedSource(), null)).toEqual({ + sourceKind: "local_audio", + projectId: "recipient-project", + sourceLabel: "late-night-set.wav", + roleFocus: ["keys-right"] + }); + }); + + it("uses received role focus with the recipient's local audio source", () => { + expect( + createAnalysisRequestForSelection(defaultRequest, selectedSource(), validHandoff()) + ).toEqual({ + sourceKind: "local_audio", + projectId: "recipient-project", + sourceLabel: "late-night-set.wav", + roleFocus: ["bass-guitar", "lead-vocal"] + }); + }); +}); diff --git a/apps/desktop/src/lib/handoff.ts b/apps/desktop/src/lib/handoff.ts new file mode 100644 index 000000000..e3ac319a6 --- /dev/null +++ b/apps/desktop/src/lib/handoff.ts @@ -0,0 +1,128 @@ +import { + parseAnalysisJobRequest, + parseMetadataHandoffArtifact, + type AnalysisJobRequest, + type MetadataHandoffArtifact, + type ProjectBootstrapSummary +} from "@bandscope/shared-types"; +import { createReanalysisRequestFromHandoff } from "./export"; + +/** + * Maximum number of bytes read from one untrusted metadata handoff file. + * The bound is enforced before UTF-8 decoding or JSON parsing. + */ +const MAX_HANDOFF_FILE_BYTES = 1_048_576; +export { MAX_HANDOFF_FILE_BYTES }; + +/** Stable, payload-free error classifications for handoff import UI copy. */ +export type HandoffImportErrorCode = + | "unsupported_file" + | "too_large" + | "invalid_utf8" + | "invalid_json" + | "invalid_artifact" + | "read_failed"; + +/** Minimum browser File/Blob contract required for allocation-bounded reading. */ +export type MetadataHandoffFile = { + name: string; + size: number; + slice(start?: number, end?: number): Blob; +}; + +/** Result of reading and validating one local metadata handoff file. */ +export type MetadataHandoffImportResult = + | { + ok: true; + fileName: string; + artifact: MetadataHandoffArtifact; + roleFocus: string[]; + } + | { + ok: false; + code: HandoffImportErrorCode; + }; + +/** Return unique role IDs in their first-seen section order. */ +export function handoffRoleFocus(artifact: MetadataHandoffArtifact): string[] { + const roleIds = new Set(); + for (const section of artifact.sections) { + for (const role of section.roleBuckets) { + roleIds.add(role.id); + } + } + return Array.from(roleIds); +} + +/** Read a bounded slice, decode strict UTF-8, and validate one untrusted handoff. */ +export async function readMetadataHandoffFile( + file: MetadataHandoffFile +): Promise { + if (!file.name.toLowerCase().endsWith(".json")) { + return { ok: false, code: "unsupported_file" }; + } + if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_HANDOFF_FILE_BYTES) { + return { ok: false, code: "too_large" }; + } + + let bytes: ArrayBuffer; + try { + bytes = await file.slice(0, MAX_HANDOFF_FILE_BYTES + 1).arrayBuffer(); + } catch { + return { ok: false, code: "read_failed" }; + } + if (bytes.byteLength > MAX_HANDOFF_FILE_BYTES) { + return { ok: false, code: "too_large" }; + } + if (bytes.byteLength !== file.size) { + return { ok: false, code: "read_failed" }; + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return { ok: false, code: "invalid_utf8" }; + } + + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return { ok: false, code: "invalid_json" }; + } + + let artifact: MetadataHandoffArtifact; + try { + artifact = parseMetadataHandoffArtifact(payload); + } catch { + return { ok: false, code: "invalid_artifact" }; + } + + return { + ok: true, + fileName: file.name, + artifact, + roleFocus: handoffRoleFocus(artifact) + }; +} + +/** Build the analysis request for the explicit local source and optional handoff. */ +export function createAnalysisRequestForSelection( + defaultRequest: AnalysisJobRequest, + selectedSource: ProjectBootstrapSummary | null, + handoff: MetadataHandoffArtifact | null +): AnalysisJobRequest { + if (!selectedSource) { + return parseAnalysisJobRequest(defaultRequest); + } + if (handoff) { + return createReanalysisRequestFromHandoff(handoff, selectedSource); + } + return parseAnalysisJobRequest({ + sourceKind: "local_audio", + projectId: selectedSource.projectId, + sourceLabel: selectedSource.source.fileName, + roleFocus: [...defaultRequest.roleFocus] + }); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..e7af8785d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,18 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "importHandoff": "Import Handoff", + "replaceHandoff": "Replace Handoff", + "validatingHandoff": "Validating handoff", + "handoffFileAriaLabel": "Handoff JSON file", + "handoffFocusedRoles": "focused roles", + "clearImportedHandoff": "Clear imported handoff", + "handoffErrorUnsupportedFile": "Choose a BandScope handoff JSON file.", + "handoffErrorTooLarge": "The handoff file is too large.", + "handoffErrorInvalidUtf8": "The handoff file is not valid UTF-8 text.", + "handoffErrorInvalidJson": "The handoff file is not valid JSON.", + "handoffErrorInvalidArtifact": "The file is not a supported BandScope handoff.", + "handoffErrorReadFailed": "The handoff file could not be read.", "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}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5af3e088c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,18 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "importHandoff": "인계 파일 가져오기", + "replaceHandoff": "인계 파일 바꾸기", + "validatingHandoff": "인계 파일 확인 중", + "handoffFileAriaLabel": "인계 JSON 파일", + "handoffFocusedRoles": "집중 역할", + "clearImportedHandoff": "가져온 인계 파일 지우기", + "handoffErrorUnsupportedFile": "BandScope 인계 JSON 파일을 선택하세요.", + "handoffErrorTooLarge": "인계 파일이 너무 큽니다.", + "handoffErrorInvalidUtf8": "인계 파일이 올바른 UTF-8 텍스트가 아닙니다.", + "handoffErrorInvalidJson": "인계 파일이 올바른 JSON이 아닙니다.", + "handoffErrorInvalidArtifact": "지원되는 BandScope 인계 파일이 아닙니다.", + "handoffErrorReadFailed": "인계 파일을 읽을 수 없습니다.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..1ce522bcf 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -22,7 +22,9 @@ export default defineConfig({ include: [ "src/App.tsx", "src/lib/export.ts", + "src/lib/handoff.ts", "src/i18n/index.ts", + "src/features/import/HandoffImportControl.tsx", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", "src/features/score/scoreStorage.ts" diff --git a/docs/workflows/metadata-handoff-import.md b/docs/workflows/metadata-handoff-import.md new file mode 100644 index 000000000..3a1f53b52 --- /dev/null +++ b/docs/workflows/metadata-handoff-import.md @@ -0,0 +1,56 @@ +# Metadata handoff import + +BandScope metadata handoffs let one musician share rehearsal scope without embedding or transmitting audio. The receiving BandScope installation imports a small JSON artifact, shows its workspace and song context, and reuses the focused role identifiers only after the recipient explicitly chooses a local audio file. Any source references in the artifact remain inert metadata and are never treated as authority to read recipient files. + +## Recipient workflow + +1. Select **Import Handoff** in the source controls. +2. Choose a `.json` handoff exported by BandScope. Importing or replacing a handoff clears any previously selected audio so unrelated source context cannot be reused accidentally. All competing source actions and **Open Project** remain disabled while the selected handoff is being read and validated, preventing an in-flight handoff result from being applied after a project replacement. Project loading uses the symmetric lock: while an **Open Project** operation is pending, local-audio selection, handoff import/replacement, YouTube import, analysis start, project reload, and saving the prior project remain disabled until the load settles, so a stale async source result cannot overwrite the newly loaded project context. After handoff validation, **Choose local audio** is the only source-selection action available for the pending handoff; YouTube import stays unavailable until the handoff is cleared or consumed by a successful analysis. +3. Confirm the displayed workspace, song, and focused-role count. +4. Select the recipient's own local audio copy. +5. Start analysis. BandScope creates a local-audio analysis request with the imported role focus. +6. Clear or replace the pending handoff at any time before the analysis starts. Clearing the handoff does not discard an audio source selected after that handoff. + +Importing metadata never starts analysis automatically and never dereferences file paths or URLs carried by the artifact. Source selection, project loading, analysis-start, and handoff-control handlers independently reject stale overlapping transitions even if a UI component fails to enforce its disabled state. In particular, a picker result delivered after the parent has disabled handoff import is discarded before file validation, and stale import/clear button activation is ignored; DOM disablement is an accessibility/usability layer, not the sole state-authority boundary. + +## Focus enforcement and cache behavior + +The analysis engine builds and caches the complete reusable song analysis, then projects the response onto `roleFocus` before returning it to the desktop. This keeps stem and analysis caches reusable across bandmates while ensuring that a focused handoff does not silently return unrelated role rows. + +For a non-empty role focus: + +- each section retains only requested role payloads; +- part-graph nodes outside the focus are removed; +- `handoff_to` and `handoff_from` links are limited to retained roles; +- export focus sections are recalculated from sections that contain a retained role. + +An explicitly empty `roleFocus` continues to mean “all analyzed roles.” The full cached analysis is never overwritten by a recipient-specific projection. + +## Validation boundary + +The desktop reads at most 1 MiB plus one sentinel byte from the selected file. It then requires: + +- a `.json` file name; +- a safe integer file size within the limit; +- strict UTF-8 decoding; +- valid JSON; +- the supported `bandscope.metadata-handoff` artifact kind and version; +- the complete shared-types handoff contract. + +Failures are mapped to bounded localized error codes. Local paths, parser payload fragments, and file contents are not echoed into the interface. + +## Privacy and authority + +A handoff carries metadata references and focused role identifiers, not audio bytes. It grants no filesystem, network, calendar, database, or model authority. A pending handoff cannot be paired through YouTube import: the recipient explicitly chooses a local audio source, and the existing local-first analysis boundary remains authoritative. + +## Developer API + +The UI boundary is implemented by: + +- `readMetadataHandoffFile` for bounded file intake and validation; +- `handoffRoleFocus` for ordered role deduplication; +- `createAnalysisRequestForSelection` for preserving the normal request path until both a local source and valid handoff are present; +- `HandoffImportControl` for accessible import, replace, progress, summary, clear, validation-activity controls, and handler-level rejection when source-transition authority is no longer held; +- `_focus_rehearsal_song` for non-mutating backend result projection over complete cached analysis. + +Tests cover valid import, malformed and oversized input, invalid UTF-8, unsupported artifacts, cancellation, replacement, deduplication, payload-free errors, source-action and project-load exclusion during handoff validation, reciprocal source/handoff exclusion during project loading, handler-level rejection of bypassed overlapping local-source/project/analysis transitions and stale handoff picker/change/clear events, YouTube exclusion while a validated handoff awaits its local source, explicit local-source selection and re-selection, cache-safe role projection, graph-link filtering, and successful pending-state cleanup. \ No newline at end of file diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index b376de293..8988c51ea 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -594,6 +594,54 @@ def _build_job_status( return status +def _focus_rehearsal_song( + result: RehearsalSong, + role_focus: list[str], +) -> RehearsalSong: + """Project one complete analysis onto requested roles without mutating its cache value.""" + if not role_focus: + return result + + focus_ids = set(role_focus) + focused_sections: list[RehearsalSectionPayload] = [] + focus_sections: list[str] = [] + for section in result["sections"]: + focused_roles = [role for role in section["roles"] if role["id"] in focus_ids] + focused_graph: list[PartGraphNodePayload] = [] + for node in section["partGraph"]: + if node["role_id"] not in focus_ids: + continue + focused_graph.append( + { + **node, + "handoff_to": [ + role_id for role_id in node["handoff_to"] if role_id in focus_ids + ], + "handoff_from": [ + role_id for role_id in node["handoff_from"] if role_id in focus_ids + ], + } + ) + focused_sections.append( + { + **section, + "roles": focused_roles, + "partGraph": focused_graph, + } + ) + if focused_roles and section["label"] not in focus_sections: + focus_sections.append(section["label"]) + + return { + **result, + "sections": focused_sections, + "exportSummary": { + **result["exportSummary"], + "focusSections": focus_sections, + }, + } + + def _analysis_cache_path(request: AnalysisJobRequest) -> Path | None: """Return the per-track cache path for a local-audio request when caching is enabled.""" if request["sourceKind"] != "local_audio" or "localSource" not in request: @@ -1065,6 +1113,10 @@ def run_analysis_job_updates( if cache_path is not None: cached_result = _load_cached_analysis(cache_path) if cached_result is not None: + focused_cached_result = _focus_rehearsal_song( + cached_result, + request["roleFocus"], + ) return [ _build_job_status( job_id=job_id, @@ -1083,7 +1135,7 @@ def run_analysis_job_updates( progress_stage="ready", progress_percent=100, cache_status="hit", - result=cached_result, + result=focused_cached_result, ), ] @@ -1192,7 +1244,8 @@ def run_analysis_job_updates( ) ) - result = build_demo_rehearsal_song(audio_features) + complete_result = build_demo_rehearsal_song(audio_features) + focused_result = _focus_rehearsal_song(complete_result, request["roleFocus"]) updates.append( _build_job_status( job_id=job_id, @@ -1211,7 +1264,7 @@ def run_analysis_job_updates( ) if cache_path is not None: final_cache_status = ( - "stored" if _store_cached_analysis(cache_path, request, result) else "miss" + "stored" if _store_cached_analysis(cache_path, request, complete_result) else "miss" ) updates.append( _build_job_status( @@ -1222,7 +1275,7 @@ def run_analysis_job_updates( progress_stage="ready", progress_percent=100, cache_status=final_cache_status, - result=result, + result=focused_result, ) ) return updates diff --git a/services/analysis-engine/tests/test_role_focus.py b/services/analysis-engine/tests/test_role_focus.py new file mode 100644 index 000000000..fcd6a390d --- /dev/null +++ b/services/analysis-engine/tests/test_role_focus.py @@ -0,0 +1,226 @@ +"""Regression tests for role-focused rehearsal analysis requests.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from bandscope_analysis import api + + +def _role(role_id: str, name: str) -> api.RehearsalRolePayload: + """Return one complete role payload for focus-filter tests.""" + return { + "id": role_id, + "name": name, + "roleType": "instrument", + "harmony": {"chord": "C", "functionLabel": "I", "source": "model"}, + "cue": {"kind": "count", "value": "1"}, + "range": {"lowestNote": "C2", "highestNote": "C4"}, + "confidence": {"level": "high", "source": "model", "notes": ""}, + "rehearsalPriority": "high", + "simplification": "", + "setupNote": "", + "manualOverrides": [], + "overlapWarnings": [], + } + + +def _song() -> api.RehearsalSong: + """Return a two-section song with cross-role part-graph links.""" + bass = _role("bass-guitar", "Bass Guitar") + keys = _role("keys-right", "Keyboard Right Hand") + vocal = _role("lead-vocal", "Lead Vocal") + return { + "id": "focus-song", + "title": "Focus Song", + "sections": [ + { + "id": "verse-1", + "label": "verse", + "groove": "straight", + "timeRange": {"start": 0, "end": 30}, + "confidence": {"level": "high", "source": "model", "notes": ""}, + "roles": [bass, keys], + "partGraph": [ + { + "role_id": "bass-guitar", + "is_active": True, + "handoff_to": ["keys-right", "lead-vocal"], + "handoff_from": [], + }, + { + "role_id": "keys-right", + "is_active": True, + "handoff_to": [], + "handoff_from": ["bass-guitar"], + }, + ], + }, + { + "id": "chorus-1", + "label": "chorus", + "groove": "lift", + "timeRange": {"start": 30, "end": 60}, + "confidence": {"level": "medium", "source": "model", "notes": ""}, + "roles": [vocal], + "partGraph": [ + { + "role_id": "lead-vocal", + "is_active": True, + "handoff_to": [], + "handoff_from": ["bass-guitar"], + } + ], + }, + ], + "exportSummary": { + "format": "cue-sheet", + "headline": "Focus the requested players.", + "focusSections": ["verse", "chorus"], + }, + } + + +def _role_ids(result: api.RehearsalSong) -> list[list[str]]: + """Return role identifiers section by section.""" + return [[role["id"] for role in section["roles"]] for section in result["sections"]] + + +def test_analysis_result_enforces_requested_role_focus( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A focused request returns only requested roles and in-scope graph links.""" + monkeypatch.setattr(api, "build_demo_rehearsal_song", lambda _features=None: _song()) + + updates = api.run_analysis_job_updates( + "job-focus", + { + "sourceKind": "demo", + "sourceLabel": "Focus Song", + "roleFocus": ["bass-guitar"], + }, + "2026-08-03T06:00:00Z", + ) + + result = updates[-1]["result"] + assert _role_ids(result) == [["bass-guitar"], []] + assert result["sections"][0]["partGraph"] == [ + { + "role_id": "bass-guitar", + "is_active": True, + "handoff_to": [], + "handoff_from": [], + } + ] + assert result["sections"][1]["partGraph"] == [] + assert result["exportSummary"]["focusSections"] == ["verse"] + + +def test_empty_role_focus_preserves_the_complete_analysis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An empty focus remains the explicit all-roles request.""" + complete = _song() + monkeypatch.setattr(api, "build_demo_rehearsal_song", lambda _features=None: complete) + + result = api.run_analysis_job_updates( + "job-all", + {"sourceKind": "demo", "sourceLabel": "Focus Song", "roleFocus": []}, + "2026-08-03T06:00:00Z", + )[-1]["result"] + + assert result == complete + + +def test_cached_full_analysis_is_focused_per_request_without_cache_rewrite( + tmp_path: Path, +) -> None: + """One full cache entry safely serves different role-focused recipients.""" + request: api.AnalysisJobRequest = { + "sourceKind": "local_audio", + "sourceLabel": "focus.wav", + "roleFocus": ["keys-right"], + "projectId": "focus-project", + "localSource": { + "sourcePath": "/tmp/focus.wav", + "fileName": "focus.wav", + "extension": "wav", + "fileSizeBytes": 1024, + }, + "cacheRoot": str(tmp_path), + } + cache_path = api._analysis_cache_path(request) + assert cache_path is not None + cache_path.parent.mkdir(parents=True) + complete = _song() + cache_path.write_text( + json.dumps( + { + "schemaVersion": api.ANALYSIS_CACHE_SCHEMA_VERSION, + "source": { + "fileName": "focus.wav", + "extension": "wav", + "fileSizeBytes": 1024, + }, + "result": complete, + } + ), + encoding="utf-8", + ) + + result = api.run_analysis_job_updates( + "job-cache", + request, + "2026-08-03T06:00:00Z", + )[-1]["result"] + + assert _role_ids(result) == [["keys-right"], []] + cached_payload: dict[str, Any] = json.loads(cache_path.read_text(encoding="utf-8")) + assert _role_ids(cached_payload["result"]) == [ + ["bass-guitar", "keys-right"], + ["lead-vocal"], + ] + + +def test_fresh_focused_analysis_stores_complete_result_in_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cache miss stores the complete analysis before projecting role focus.""" + complete = _song() + monkeypatch.setattr(api, "_build_local_audio_features", lambda _request: None) + monkeypatch.setattr(api, "build_demo_rehearsal_song", lambda _features=None: complete) + request: api.AnalysisJobRequest = { + "sourceKind": "local_audio", + "sourceLabel": "fresh.wav", + "roleFocus": ["keys-right"], + "projectId": "fresh-project", + "localSource": { + "sourcePath": "/tmp/fresh.wav", + "fileName": "fresh.wav", + "extension": "wav", + "fileSizeBytes": 2048, + }, + "cacheRoot": str(tmp_path), + } + + cache_path = api._analysis_cache_path(request) + assert cache_path is not None + assert not cache_path.exists() + + result = api.run_analysis_job_updates( + "job-fresh", + request, + "2026-08-03T06:00:00Z", + )[-1]["result"] + + assert _role_ids(result) == [["keys-right"], []] + cached_payload: dict[str, Any] = json.loads(cache_path.read_text(encoding="utf-8")) + assert _role_ids(cached_payload["result"]) == [ + ["bass-guitar", "keys-right"], + ["lead-vocal"], + ]