@@ -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
) => (
+
+ )
+}));
+
+vi.mock("../../lib/handoff", async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ readMetadataHandoffFile: vi.fn()
+ };
+});
+
+const mockedReadMetadataHandoffFile = vi.mocked(readMetadataHandoffFile);
+
+/** Return a valid pending handoff for stale-control tests. */
+function pendingHandoff(): MetadataHandoffArtifact {
+ return {
+ artifactKind: "bandscope.metadata-handoff",
+ artifactVersion: 1,
+ createdAt: "2026-08-16T03:30:00.000Z",
+ workspace: { id: "workspace-guard", title: "Guarded rehearsal", workspaceVersion: 1 },
+ song: {
+ id: "song-guard",
+ title: "Guarded song",
+ exportSummary: {
+ format: "cue-sheet",
+ headline: "Preserve the active source transition.",
+ focusSections: ["chorus"]
+ }
+ },
+ sections: [],
+ sourceAssets: []
+ };
+}
+
+/** Return one browser-owned JSON file for stale picker tests. */
+function uploadFile(): File {
+ return new File(["{}"], "stale-handoff.json", { type: "application/json" });
+}
+
+describe("HandoffImportControl handler guards", () => {
+ beforeEach(() => {
+ mockedReadMetadataHandoffFile.mockReset();
+ });
+
+ it("rejects a bypassed picker-open action while the control is disabled", () => {
+ render(
+
+ );
+
+ const input = screen.getByLabelText(/handoff JSON file/i) as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, "click").mockImplementation(() => undefined);
+ const importButton = screen.getByRole("button", { name: /import handoff/i });
+ expect(importButton).toHaveAttribute("data-disabled", "true");
+
+ fireEvent.click(importButton);
+
+ expect(clickSpy).not.toHaveBeenCalled();
+ });
+
+ it("rejects a stale file-change event after the parent disables handoff import", () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByLabelText(/handoff JSON file/i) as HTMLInputElement;
+ rerender(
+
+ );
+ expect(input).toBeDisabled();
+
+ // A browser/custom shell can deliver a picker result after the parent state
+ // changed. Remove only the DOM enforcement so the component handler remains
+ // the final authority for the current disabled prop.
+ input.disabled = false;
+ fireEvent.change(input, { target: { files: [uploadFile()] } });
+
+ expect(mockedReadMetadataHandoffFile).not.toHaveBeenCalled();
+ expect(onHandoffChange).not.toHaveBeenCalled();
+ expect(onImportError).not.toHaveBeenCalled();
+ });
+
+ it("rejects a bypassed clear action while the control is disabled", () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ render(
+
+ );
+
+ const clearButton = screen.getByRole("button", { name: /clear imported handoff/i });
+ expect(clearButton).toHaveAttribute("data-disabled", "true");
+
+ fireEvent.click(clearButton);
+
+ expect(onHandoffChange).not.toHaveBeenCalled();
+ expect(onImportError).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/import/HandoffImportControl.test.tsx b/apps/desktop/src/features/import/HandoffImportControl.test.tsx
new file mode 100644
index 000000000..8bb3f8cc9
--- /dev/null
+++ b/apps/desktop/src/features/import/HandoffImportControl.test.tsx
@@ -0,0 +1,238 @@
+import { fireEvent, render, screen, waitFor } 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("../../lib/handoff", async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ readMetadataHandoffFile: vi.fn()
+ };
+});
+
+const mockedReadMetadataHandoffFile = vi.mocked(readMetadataHandoffFile);
+
+function handoff(): 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: "" },
+ 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"
+ }
+ ]
+ }
+ ],
+ sourceAssets: []
+ };
+}
+
+function uploadFile(): File {
+ return new File(["{}"], "friday-handoff.json", { type: "application/json" });
+}
+
+describe("HandoffImportControl", () => {
+ beforeEach(() => {
+ mockedReadMetadataHandoffFile.mockReset();
+ });
+
+ it("opens the hidden file picker from the visible import action", () => {
+ render(
+
+ );
+
+ const input = screen.getByLabelText(/handoff JSON file/i) as HTMLInputElement;
+ const clickSpy = vi.spyOn(input, "click").mockImplementation(() => undefined);
+
+ fireEvent.click(screen.getByRole("button", { name: /import handoff/i }));
+
+ expect(clickSpy).toHaveBeenCalledOnce();
+ });
+
+ it("opens an accessible JSON file picker and publishes a valid import", async () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ mockedReadMetadataHandoffFile.mockResolvedValueOnce({
+ ok: true,
+ fileName: "friday-handoff.json",
+ artifact: handoff(),
+ roleFocus: ["bass-guitar", "lead-vocal"]
+ });
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByLabelText(/handoff JSON file/i);
+ expect(input).toHaveAttribute("accept", ".json,application/json");
+ fireEvent.change(input, { target: { files: [uploadFile()] } });
+
+ await waitFor(() => {
+ expect(onHandoffChange).toHaveBeenCalledWith(handoff());
+ });
+ expect(onImportError).toHaveBeenCalledWith(null);
+
+ rerender(
+
+ );
+ expect(screen.getByText("Friday rehearsal")).toBeTruthy();
+ expect(screen.getByText(/Late Night Set · 2 focused roles/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /clear imported handoff/i })).toBeTruthy();
+ });
+
+ it("reports safe import failures without publishing state", async () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ mockedReadMetadataHandoffFile.mockResolvedValueOnce({
+ ok: false,
+ code: "invalid_json"
+ });
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText(/handoff JSON file/i), {
+ target: { files: [uploadFile()] }
+ });
+
+ await waitFor(() => {
+ expect(onImportError).toHaveBeenCalledWith("invalid_json");
+ });
+ expect(onHandoffChange).not.toHaveBeenCalled();
+ });
+
+ it("ignores an empty picker result", () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText(/handoff JSON file/i), {
+ target: { files: [] }
+ });
+
+ expect(mockedReadMetadataHandoffFile).not.toHaveBeenCalled();
+ expect(onHandoffChange).not.toHaveBeenCalled();
+ expect(onImportError).not.toHaveBeenCalled();
+ });
+
+ it("clears the pending handoff and any related error", () => {
+ const onHandoffChange = vi.fn();
+ const onImportError = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /clear imported handoff/i }));
+
+ expect(onHandoffChange).toHaveBeenCalledWith(null);
+ expect(onImportError).toHaveBeenCalledWith(null);
+ });
+
+ it("disables both import and clear actions while analysis owns the source controls", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: /replace handoff/i })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /clear imported handoff/i })).toBeDisabled();
+ expect(screen.getByLabelText(/handoff JSON file/i)).toBeDisabled();
+ });
+
+ it("reports bounded progress while the selected file is being validated", async () => {
+ let resolveImport: ((value: Awaited>) => void) | null = null;
+ const onReadingChange = vi.fn();
+ mockedReadMetadataHandoffFile.mockImplementationOnce(
+ () => new Promise((resolve) => {
+ resolveImport = resolve;
+ })
+ );
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText(/handoff JSON file/i), {
+ target: { files: [uploadFile()] }
+ });
+ expect(await screen.findByRole("button", { name: /validating handoff/i })).toBeDisabled();
+ expect(onReadingChange).toHaveBeenCalledWith(true);
+
+ resolveImport?.({ ok: false, code: "invalid_artifact" });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /import handoff/i })).not.toBeDisabled();
+ expect(onReadingChange).toHaveBeenLastCalledWith(false);
+ });
+ expect(onReadingChange).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/desktop/src/features/import/HandoffImportControl.tsx b/apps/desktop/src/features/import/HandoffImportControl.tsx
new file mode 100644
index 000000000..010552ff9
--- /dev/null
+++ b/apps/desktop/src/features/import/HandoffImportControl.tsx
@@ -0,0 +1,141 @@
+import { useMemo, useRef, useState, type ChangeEvent } from "react";
+import { FileJson, Loader2, X } from "lucide-react";
+import type { MetadataHandoffArtifact } from "@bandscope/shared-types";
+import { Button } from "@/components/ui/button";
+import { createTranslator, detectPreferredLocale } from "../../i18n";
+import {
+ handoffRoleFocus,
+ readMetadataHandoffFile,
+ type HandoffImportErrorCode
+} from "../../lib/handoff";
+
+interface HandoffImportControlProps {
+ disabled: boolean;
+ handoff: MetadataHandoffArtifact | null;
+ onHandoffChange: (handoff: MetadataHandoffArtifact | null) => void;
+ onImportError: (code: HandoffImportErrorCode | null) => void;
+ onReadingChange?: (isReading: boolean) => void;
+}
+
+/** Documented. */
+export function HandoffImportControl({
+ disabled,
+ handoff,
+ onHandoffChange,
+ onImportError,
+ onReadingChange
+}: HandoffImportControlProps) {
+ const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const inputRef = useRef(null);
+ const [isReading, setIsReading] = useState(false);
+ const roleFocusCount = handoff ? handoffRoleFocus(handoff).length : 0;
+ const controlsDisabled = disabled || isReading;
+
+ /** Open the browser-owned local file picker when this control owns source transition authority. */
+ const handleOpenPicker = () => {
+ if (controlsDisabled) {
+ return;
+ }
+ inputRef.current?.click();
+ };
+
+ /** Validate one selected handoff before publishing it to application state. */
+ const handleFileChange = async (event: ChangeEvent) => {
+ const input = event.currentTarget;
+ const file = input.files?.[0];
+ input.value = "";
+ if (controlsDisabled || !file) {
+ return;
+ }
+
+ setIsReading(true);
+ onReadingChange?.(true);
+ try {
+ const result = await readMetadataHandoffFile(file);
+ if (!result.ok) {
+ onImportError(result.code);
+ return;
+ }
+ onHandoffChange(result.artifact);
+ onImportError(null);
+ } finally {
+ setIsReading(false);
+ onReadingChange?.(false);
+ }
+ };
+
+ /** Remove the pending handoff only while this control owns source transition authority. */
+ const handleClear = () => {
+ if (controlsDisabled) {
+ return;
+ }
+ onHandoffChange(null);
+ onImportError(null);
+ };
+
+ return (
+
+
+
+ {isReading ? (
+
+ ) : (
+
+ )}
+ {isReading
+ ? t("validatingHandoff")
+ : handoff
+ ? t("replaceHandoff")
+ : t("importHandoff")}
+
+
+ {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"],
+ ]