diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..14898c5a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# AGENTS.md
## Project overview
-- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
+- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and offers a download of tonight's first-action handoff; when a valid named playable range exists the handoff JSON leads with that structured action, and otherwise it omits `firstAction` rather than inventing one.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..7906e3cfc 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -82,10 +82,10 @@ 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, with the ready workspace naming tonight's first span and the next instrument check
+ - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and a download of tonight's first-action handoff
- 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
+ - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form, with the metadata handoff JSON leading with tonight's first playable-range action
## Confidence, edits, and provenance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..d8bcde620 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Lead the metadata handoff JSON with tonight's first playable-range action and name that download on the rehearsal map.
- 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 b5a34c1fa..81ce5c424 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into
Three layers, decoupled through shared contracts:
-- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
+- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, the next instrument check, and a download of tonight's first-action handoff. `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/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..47b9b85da 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -360,9 +360,7 @@ describe("App", () => {
});
it("short-circuits confidence evaluation when encountering a low confidence section", async () => {
- const loadedProject = succeededResult().result; // medium is first
- // Add low and high sections. High shouldn't matter since low is lowest.
- // And low will trigger the early break in the loop.
+ const loadedProject = succeededResult().result;
loadedProject.sections.push(
{
...loadedProject.sections[0],
@@ -923,7 +921,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy());
- fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
+ fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i }));
const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
@@ -1264,7 +1262,6 @@ describe("App", () => {
expect(tauriInvoke).not.toHaveBeenCalled();
});
-
it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
@@ -1294,7 +1291,6 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
- // Should not show error, should remain in empty state
await waitFor(() => {
expect(mockLoadProject).toHaveBeenCalledTimes(1);
});
@@ -1359,7 +1355,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1367,7 +1362,6 @@ describe("App", () => {
mockSaveProject.mockResolvedValueOnce(undefined);
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1379,7 +1373,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1387,7 +1380,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1399,7 +1391,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1407,7 +1398,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce(new Error("User cancelled"));
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1422,7 +1412,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1430,7 +1419,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("Disk full");
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1468,7 +1456,6 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
@@ -1476,7 +1463,6 @@ describe("App", () => {
mockSaveProject.mockRejectedValueOnce("User cancelled");
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
@@ -1491,19 +1477,15 @@ describe("App", () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render();
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});
- // Mock prompt to simulate user entering a new chord
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7");
- // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit)
fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]);
- // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated)
await waitFor(() => {
expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0);
});
@@ -1552,7 +1534,6 @@ describe("App", () => {
});
});
-
it("renders Settings and Help as focusable aria-disabled controls", () => {
render();
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
@@ -1589,8 +1570,6 @@ describe("App", () => {
fireEvent.click(scoreButton);
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
- // Projects opened from a .bscope file have no live workspace, so score
- // storage is gated behind the active-project notice.
expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
@@ -1604,9 +1583,6 @@ describe("App", () => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
- // The compact nav is a separate rendered bar (shown on small viewports) with
- // its own set of buttons; exercise it directly so the mobile navigation path
- // is covered, not just the sidebar one.
const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i });
expect(compactScoreButton).toBeEnabled();
diff --git a/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx b/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx
new file mode 100644
index 000000000..f3cbef497
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Workspace } from "./Workspace";
+
+const originalLanguage = navigator.language;
+const originalCreateObjectUrl = URL.createObjectURL;
+const originalRevokeObjectUrl = URL.revokeObjectURL;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language
+ });
+}
+
+describe("Workspace handoff export contract", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ vi.restoreAllMocks();
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: originalCreateObjectUrl
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: originalRevokeObjectUrl
+ });
+ });
+
+ it("keeps the full-band handoff lead stable when the UI role changes", async () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:full-band-handoff");
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: createObjectUrl
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: vi.fn()
+ });
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+ fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-range handoff" }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text()) as {
+ firstAction?: { roleId?: string; roleName?: string };
+ sections?: Array<{ roleBuckets?: Array<{ name?: string }> }>;
+ };
+
+ expect(payload.firstAction?.roleId).toBe("bass-guitar");
+ expect(payload.firstAction?.roleName).toBe("Bass Guitar");
+ expect(payload.sections?.[0]?.roleBuckets?.map((role) => role.name)).toEqual(
+ expect.arrayContaining(["Bass Guitar", "Lead Vocal"])
+ );
+ });
+
+ it("names the Korean download as the first playable-range share file", () => {
+ setNavigatorLanguage("ko-KR");
+ render();
+
+ expect(screen.getByRole("button", { name: "오늘 첫 연주 구간 공유 파일 다운로드" })).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..6ec60049a 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -196,6 +196,87 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first-range handoff download and leads the file with that action", async () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:handoff");
+ const revokeObjectUrl = vi.fn();
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: createObjectUrl
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: revokeObjectUrl
+ });
+
+ render();
+
+ const download = screen.getByRole("button", { name: "Download tonight's first-range handoff" });
+ fireEvent.click(download);
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text());
+ expect(Object.keys(payload)).toEqual([
+ "artifactKind",
+ "artifactVersion",
+ "createdAt",
+ "workspace",
+ "song",
+ "firstAction",
+ "sections",
+ "sourceAssets"
+ ]);
+ expect(payload.firstAction).toEqual({
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: true
+ });
+ expect(click).toHaveBeenCalledTimes(1);
+ expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff");
+ });
+
+ it("does not invent a first-action handoff lead when the first range still needs an ear check", async () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
+ ...role,
+ range: { lowestNote: "", highestNote: "none" },
+ overlapWarnings: []
+ }));
+ const createObjectUrl = vi.fn(() => "blob:handoff-missing");
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: createObjectUrl
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: vi.fn()
+ });
+
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-range handoff" }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text());
+ expect(payload.firstAction).toBeUndefined();
+ expect(Object.keys(payload)).toEqual([
+ "artifactKind",
+ "artifactVersion",
+ "createdAt",
+ "workspace",
+ "song",
+ "sections",
+ "sourceAssets"
+ ]);
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -249,7 +330,7 @@ describe("Workspace", () => {
});
render();
- fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
+ fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i }));
const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
@@ -278,7 +359,7 @@ describe("Workspace", () => {
});
render();
- fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
+ fireEvent.click(screen.getByRole("button", { name: /first-range handoff/i }));
const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
@@ -325,5 +406,6 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "오늘 첫 연주 구간 공유 파일 다운로드" })).toBeTruthy();
});
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..44ea89f1a 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { firstHandoffAction } from "./firstHandoffAction";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -244,7 +245,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const json = generateMetadataHandoffJson(song, {
sourceBootstrap: parsedSourceBootstrap,
workspaceId: song.id,
- workspaceTitle: song.title
+ workspaceTitle: song.title,
+ firstAction: firstHandoffAction(song)
});
downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_handoff.json`);
};
@@ -294,7 +296,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
className="min-h-10 border-teal-300/25 bg-teal-300/10 font-semibold text-teal-50 shadow-sm hover:bg-teal-300/20 hover:text-white"
>
- Export Handoff (JSON)
+ {t("workspaceFirstRangeDownloadHandoff")}
diff --git a/apps/desktop/src/features/workspace/firstHandoffAction.test.ts b/apps/desktop/src/features/workspace/firstHandoffAction.test.ts
new file mode 100644
index 000000000..afa292021
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstHandoffAction.test.ts
@@ -0,0 +1,133 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { firstHandoffAction } from "./firstHandoffAction";
+
+describe("firstHandoffAction", () => {
+ it("leads with the first clashing span and keeps role identity structured", () => {
+ expect(firstHandoffAction(createDemoRehearsalSong())).toEqual({
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: true
+ });
+ });
+
+ it("uses the check lead when the first span has no clash", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
+ ...role,
+ overlapWarnings: []
+ }));
+
+ expect(firstHandoffAction(song)).toEqual({
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: false
+ });
+ });
+
+ it("returns null when no named span exists", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({
+ ...role,
+ range: { lowestNote: "", highestNote: "none" },
+ overlapWarnings: []
+ }));
+
+ expect(firstHandoffAction(song)).toBeNull();
+ });
+
+ it("fails closed on malformed runtime collections", () => {
+ expect(firstHandoffAction(null as unknown as RehearsalSong)).toBeNull();
+ expect(firstHandoffAction({ sections: null } as unknown as RehearsalSong)).toBeNull();
+ });
+
+ it("skips malformed roles, blank ids, and non-form sections until a named span exists", () => {
+ const song = createDemoRehearsalSong();
+ const validRole = song.sections[0]!.roles[0]!;
+ const malformedSection = {
+ ...song.sections[0],
+ roles: [
+ null,
+ { ...validRole, id: " ", overlapWarnings: [" none "] },
+ { ...validRole, range: null, overlapWarnings: ["Density"] },
+ validRole
+ ]
+ };
+
+ expect(
+ firstHandoffAction({ ...song, sections: [malformedSection] } as unknown as RehearsalSong)
+ ).toEqual({
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: true
+ });
+ });
+
+ it("skips sections whose form label is not a contracted rehearsal form", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0] = {
+ ...song.sections[0]!,
+ label: "solo" as RehearsalSong["sections"][number]["label"]
+ };
+
+ expect(firstHandoffAction(song)).toBeNull();
+ });
+
+ it("keeps formula-shaped role names literal so JSON encoding can neutralize them later", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ name: '=HYPERLINK("http://evil")'
+ };
+
+ expect(firstHandoffAction(song)).toMatchObject({
+ roleName: '=HYPERLINK("http://evil")',
+ clash: true
+ });
+ });
+
+ it("validates a role name without trimming the literal display name in the handoff", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ name: " Bass Guitar "
+ };
+
+ expect(firstHandoffAction(song)).toMatchObject({
+ roleName: " Bass Guitar ",
+ clash: true
+ });
+ });
+
+ it("validates nonblank ids without changing their literal handoff identity", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0] = {
+ ...song.sections[0]!,
+ id: " verse-1 ",
+ roles: [
+ {
+ ...song.sections[0]!.roles[0]!,
+ id: " bass-guitar "
+ }
+ ]
+ };
+
+ expect(firstHandoffAction(song)).toMatchObject({
+ sectionId: " verse-1 ",
+ roleId: " bass-guitar ",
+ clash: true
+ });
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstHandoffAction.ts b/apps/desktop/src/features/workspace/firstHandoffAction.ts
new file mode 100644
index 000000000..14f3405f1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstHandoffAction.ts
@@ -0,0 +1,104 @@
+import {
+ SECTION_FORM_LABELS,
+ type MetadataHandoffFirstAction,
+ type RehearsalSong,
+ type SectionFormLabel
+} from "@bandscope/shared-types";
+import { meaningfulRangeText, playableRange } from "./firstRangeSqueeze";
+
+/** Return whether an untrusted runtime value is a plain object record. */
+function isRuntimeObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Return whether a runtime label is a contracted section form. */
+function isSectionFormLabel(value: string): value is SectionFormLabel {
+ return (SECTION_FORM_LABELS as readonly string[]).includes(value);
+}
+
+/**
+ * Build tonight's first playable-range action for a full-band metadata handoff.
+ *
+ * Uses the same span authority as the ready map. Values stay literal so the
+ * handoff encoder, not this helper, is the serialization boundary. Malformed
+ * songs and unnamed spans fail closed instead of inventing a lead. Transient
+ * workspace role filters must not change a shared artifact's first action.
+ */
+export function firstHandoffAction(song: RehearsalSong): MetadataHandoffFirstAction | null {
+ const runtimeSong: unknown = song;
+ if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
+ return null;
+ }
+
+ let fallback: MetadataHandoffFirstAction | null = null;
+
+ for (const sectionValue of runtimeSong.sections) {
+ if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) {
+ continue;
+ }
+ const sectionId = sectionValue.id;
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ if (
+ typeof sectionId !== "string" ||
+ !meaningfulRangeText(sectionId) ||
+ !sectionLabel ||
+ !isSectionFormLabel(sectionLabel)
+ ) {
+ continue;
+ }
+
+ for (const roleValue of sectionValue.roles) {
+ if (!isRuntimeObject(roleValue)) {
+ continue;
+ }
+ const roleId = roleValue.id;
+ const roleName = roleValue.name;
+ if (
+ typeof roleId !== "string" ||
+ !meaningfulRangeText(roleId) ||
+ typeof roleName !== "string" ||
+ !meaningfulRangeText(roleName)
+ ) {
+ continue;
+ }
+ if (!isRuntimeObject(roleValue.range)) {
+ continue;
+ }
+
+ const range = playableRange(roleValue.range.lowestNote, roleValue.range.highestNote);
+ if (!range) {
+ continue;
+ }
+
+ let clash = false;
+ if (Array.isArray(roleValue.overlapWarnings)) {
+ for (const warning of roleValue.overlapWarnings) {
+ if (meaningfulRangeText(warning)) {
+ clash = true;
+ break;
+ }
+ }
+ }
+
+ const candidate: MetadataHandoffFirstAction = {
+ sectionId,
+ sectionLabel,
+ roleId,
+ roleName,
+ lowestNote: range.lowestNote,
+ highestNote: range.highestNote,
+ clash
+ };
+
+ if (clash) {
+ return candidate;
+ }
+
+ if (!fallback) {
+ fallback = candidate;
+ }
+ }
+ }
+
+ return fallback;
+}
diff --git a/apps/desktop/src/lib/export-versioning.test.ts b/apps/desktop/src/lib/export-versioning.test.ts
new file mode 100644
index 000000000..3c26e789d
--- /dev/null
+++ b/apps/desktop/src/lib/export-versioning.test.ts
@@ -0,0 +1,55 @@
+import { createDemoRehearsalSong, type ProjectBootstrapSummary } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { createMetadataHandoffArtifact, createReanalysisRequestFromHandoff } from "./export";
+
+const replacementSource: ProjectBootstrapSummary = {
+ 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
+ }
+};
+
+const firstAction = {
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: true
+} as const;
+
+describe("metadata handoff export versioning", () => {
+ it("keeps legacy handoffs on v1 and emits first-action handoffs as v2", () => {
+ const song = createDemoRehearsalSong();
+
+ expect(createMetadataHandoffArtifact(song, { createdAt: "2026-09-01T00:00:00.000Z" }).artifactVersion).toBe(1);
+ expect(createMetadataHandoffArtifact(song, {
+ createdAt: "2026-09-01T00:00:00.000Z",
+ firstAction
+ })).toMatchObject({
+ artifactVersion: 2,
+ firstAction
+ });
+ });
+
+ it("re-analyzes both legacy v1 and first-action v2 handoffs", () => {
+ const song = createDemoRehearsalSong();
+ const v1 = createMetadataHandoffArtifact(song, { createdAt: "2026-09-01T00:00:00.000Z" });
+ const v2 = createMetadataHandoffArtifact(song, {
+ createdAt: "2026-09-01T00:00:00.000Z",
+ firstAction
+ });
+
+ expect(createReanalysisRequestFromHandoff(v1, replacementSource)).toEqual(
+ createReanalysisRequestFromHandoff(v2, replacementSource)
+ );
+ });
+});
diff --git a/apps/desktop/src/lib/export.test.ts b/apps/desktop/src/lib/export.test.ts
index 265e983d4..4a5d3f886 100644
--- a/apps/desktop/src/lib/export.test.ts
+++ b/apps/desktop/src/lib/export.test.ts
@@ -200,6 +200,60 @@ describe("export generation", () => {
confidence: { level: "high", source: "model", notes: "" },
rehearsalPriority: "high"
});
+ expect(parsed.firstAction).toBeUndefined();
+ });
+
+ it("leads the handoff JSON with tonight's first action when a lead is provided", () => {
+ const formulaRoleName = '=HYPERLINK("http://evil")';
+ const songWithFormulaRoleName: RehearsalSong = {
+ ...mockSong,
+ sections: [{
+ ...mockSong.sections[0]!,
+ roles: [{
+ ...mockSong.sections[0]!.roles[0]!,
+ name: formulaRoleName
+ }]
+ }]
+ };
+ const jsonStr = generateMetadataHandoffJson(songWithFormulaRoleName, {
+ createdAt: "2026-06-15T08:30:00.000Z",
+ firstAction: {
+ sectionId: "s1",
+ sectionLabel: "verse",
+ roleId: "r1",
+ roleName: formulaRoleName,
+ lowestNote: "C2",
+ highestNote: "C3",
+ clash: false
+ }
+ });
+ const parsed = JSON.parse(jsonStr);
+ expect(Object.keys(parsed)).toEqual([
+ "artifactKind",
+ "artifactVersion",
+ "createdAt",
+ "workspace",
+ "song",
+ "firstAction",
+ "sections",
+ "sourceAssets"
+ ]);
+ expect(parsed.firstAction).toEqual({
+ sectionId: "s1",
+ sectionLabel: "verse",
+ roleId: "r1",
+ roleName: formulaRoleName,
+ lowestNote: "C2",
+ highestNote: "C3",
+ clash: false
+ });
+ });
+
+ it("does not invent a first action when the handoff lead is omitted or null", () => {
+ expect(JSON.parse(generateMetadataHandoffJson(mockSong, { createdAt: "2026-06-15T08:30:00.000Z" })).firstAction).toBeUndefined();
+ expect(
+ JSON.parse(generateMetadataHandoffJson(mockSong, { createdAt: "2026-06-15T08:30:00.000Z", firstAction: null })).firstAction
+ ).toBeUndefined();
});
it("uses the song identity as the default handoff workspace identity", () => {
diff --git a/apps/desktop/src/lib/export.ts b/apps/desktop/src/lib/export.ts
index 3d4493b1d..1953498af 100644
--- a/apps/desktop/src/lib/export.ts
+++ b/apps/desktop/src/lib/export.ts
@@ -5,6 +5,7 @@ import {
parseRehearsalSong,
type AnalysisJobRequest,
type MetadataHandoffArtifact,
+ type MetadataHandoffFirstAction,
type ProjectBootstrapSummary,
type RehearsalSong
} from "@bandscope/shared-types";
@@ -87,6 +88,7 @@ export function createMetadataHandoffArtifact(
sourceBootstrap?: ProjectBootstrapSummary | null;
workspaceId?: string;
workspaceTitle?: string;
+ firstAction?: MetadataHandoffFirstAction | null;
} = {}
): MetadataHandoffArtifact {
const parsedSong = parseRehearsalSong(song);
@@ -94,9 +96,8 @@ export function createMetadataHandoffArtifact(
? parseProjectBootstrapSummary(options.sourceBootstrap)
: null;
- return parseMetadataHandoffArtifact({
- artifactKind: "bandscope.metadata-handoff",
- artifactVersion: 1,
+ const payload = {
+ artifactKind: "bandscope.metadata-handoff" as const,
createdAt: options.createdAt ?? new Date().toISOString(),
workspace: {
id: options.workspaceId ?? parsedSong.id,
@@ -124,15 +125,38 @@ export function createMetadataHandoffArtifact(
sourceAssets: sourceBootstrap
? [
{
- referenceKind: "local_audio",
- sourceMode: "reference",
+ referenceKind: "local_audio" as const,
+ sourceMode: "reference" as const,
fileName: sourceBootstrap.source.fileName,
extension: sourceBootstrap.source.extension,
fileSizeBytes: sourceBootstrap.source.fileSizeBytes,
- status: "referenced"
+ status: "referenced" as const
}
]
: []
+ };
+
+ if (options.firstAction) {
+ return parseMetadataHandoffArtifact({
+ artifactKind: payload.artifactKind,
+ artifactVersion: 2,
+ createdAt: payload.createdAt,
+ workspace: payload.workspace,
+ song: payload.song,
+ firstAction: options.firstAction,
+ sections: payload.sections,
+ sourceAssets: payload.sourceAssets
+ });
+ }
+
+ return parseMetadataHandoffArtifact({
+ artifactKind: payload.artifactKind,
+ artifactVersion: 1,
+ createdAt: payload.createdAt,
+ workspace: payload.workspace,
+ song: payload.song,
+ sections: payload.sections,
+ sourceAssets: payload.sourceAssets
});
}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..b6371d116 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,7 @@
"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.",
+ "workspaceFirstRangeDownloadHandoff": "Download tonight's first-range handoff",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..b2ef5711b 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,7 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstRangeDownloadHandoff": "오늘 첫 연주 구간 공유 파일 다운로드",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 3cf5261b9..a95c48f0d 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -23,7 +23,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c
## Exported rehearsal deliverables
-- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model.
+- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model. When a valid named playable range exists, the metadata handoff JSON leads with tonight's first playable-range action so a bandmate opening the file can start from the same check the board already named; otherwise it omits `firstAction` rather than inventing guidance.
- Exported artifacts should stay compact and rehearsal-friendly rather than becoming DAW sessions or engraved scores.
## Delivery flow
diff --git a/docs/doctoring/handoff-first-action.md b/docs/doctoring/handoff-first-action.md
new file mode 100644
index 000000000..a35123cc7
--- /dev/null
+++ b/docs/doctoring/handoff-first-action.md
@@ -0,0 +1,62 @@
+# Metadata handoff first-action lead
+
+## Decision
+
+The ready workspace names tonight's first playable range. The metadata handoff still dumped workspace, song, and every section×role bucket first, so a bandmate opening tonight's handoff had to hunt for that same check. When `firstHandoffAction` can name a valid span, `createMetadataHandoffArtifact` now emits a version-2 handoff with a required structured `firstAction`; when no lead can be named, it emits the unchanged version-1 shape and does not invent an action. The encoder stays locale-free and full-band, so a transient workspace role filter cannot change the shared action. Localized next-action copy remains a UI concern.
+
+Version 1 is immutable: it has no `firstAction` key and existing strict v1 readers continue to accept v1 exports. Version 2 adds `firstAction`. The current BandScope parser accepts both v1 and v2 so legacy handoffs remain importable. Older v1-only readers are expected to reject v2 rather than silently reinterpret a schema they do not understand; compatibility is preserved by keeping v1 generation unchanged when the new field is absent, not by relabeling a changed payload as v1.
+
+```mermaid
+flowchart LR
+ A[Untrusted song payload] --> B[firstHandoffAction]
+ B -->|named span| C[v2 + required firstAction]
+ B -->|no span| D[unchanged v1]
+ C --> E[version-aware parser]
+ D --> E
+ E --> F[Handoff JSON]
+```
+
+## Security Notes
+
+### Attack surface
+
+Handoff JSON is derived from untrusted analysis payloads. Section labels, role names, range labels, and ids can carry formula-shaped values (`=`, `+`, `-`, `@`), quotes, and control characters.
+
+### Trust boundary
+
+`JSON.stringify` after `parseMetadataHandoffArtifact` is the only handoff encoder. `firstHandoffAction` treats the song as untrusted runtime data and fails closed. Lead values stay literal until that encoder runs. Filename sanitization for the download remains `sanitizeFilename`. Source paths and transcription data remain excluded. This path does not write CSV and does not dereference artifact source references.
+
+The v2 parser validates `firstAction` separately, then removes only that field and delegates every unchanged field to the v1 parser. That keeps the established v1 validation authority in force for workspace, song, sections, role buckets and source-asset references instead of maintaining two divergent validators.
+
+### Logging and privacy
+
+This export path does not add logging, telemetry, network transmission, or server-side retention. The generated Blob exists only in the local download flow until its object URL is revoked. The downloaded JSON can contain song titles, section labels, role names, and other user-entered rehearsal metadata already present in the handoff; those values may identify people or projects, so their exposure follows wherever the user saves or shares the downloaded file rather than a new BandScope logging channel.
+
+### Mitigations
+
+- Do not invent a first-action object when the helper cannot name a playable span.
+- Keep the v1 schema strict; a v1 payload carrying `firstAction` is invalid.
+- Require `firstAction` for v2 and reject unsupported artifact versions.
+- Keep formula-shaped role names and range labels literal in the helper so encoding stays centralized in `export.ts`.
+- Keep the handoff lead full-band so a transient workspace role filter cannot silently change the identity of the downloaded artifact.
+- Reject extra first-action keys, blank ids, non-form section labels and non-boolean clash flags at parse time.
+- Do not weaken or duplicate the existing cue-sheet formula-injection tests.
+
+### Test points
+
+- `packages/shared-types/test/metadata-handoff-versioning.test.ts` proves v1 remains strict, v2 requires a valid action, unsupported versions fail closed and the current parser accepts both supported versions.
+- `apps/desktop/src/lib/export-versioning.test.ts` proves exports without a lead stay v1, exports with a lead become v2 and both versions feed the same local re-analysis request boundary.
+- `apps/desktop/src/lib/export.test.ts` proves metadata-only exports exclude source paths/transcription and encode the lead literally after song identity.
+- `apps/desktop/src/features/workspace/firstHandoffAction.test.ts` proves fail-closed matching, clash preference and literal formula-shaped role names.
+- `apps/desktop/src/features/workspace/Workspace.handoffExport.test.tsx` proves selecting a UI role does not change the full-band handoff lead or body.
+- `apps/desktop/src/features/workspace/Workspace.test.tsx` proves the handoff download is named as tonight's first-action handoff and that the file leads with that action.
+
+### Realistic threats
+
+A crafted project that puts `=CMD` in a role name could otherwise teach the wrong first action if the helper invented a span, or could confuse a downstream consumer that eval'd handoff JSON. BandScope does not eval the artifact. Recipients still pair the handoff with their own local audio; this lead does not grant filesystem authority.
+
+A schema-version mismatch is also a data-integrity risk: shipping a changed object while continuing to label it version 1 makes strict recipients reject nominally compatible files and makes permissive recipients guess. The explicit v2 boundary removes that ambiguity.
+
+### Remaining risk
+
+V1-only BandScope builds cannot consume a v2 first-action handoff. That is an explicit version boundary rather than hidden incompatibility. Current builds remain backward-compatible with v1. Downstream tools that interpret JSON string values as spreadsheet formulas or scripts remain outside this encoder; cue-sheet CSV sanitization stays in `escapeCsvField` and is not reused here.
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index cba4606a2..9ab71342f 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -1,1935 +1,9 @@
-export /** Documented. */
-const SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "m4a"] as const;
-export /** Documented. */
-const SECTION_FORM_LABELS = [
- "intro",
- "verse",
- "pre-chorus",
- "chorus",
- "bridge",
- "outro",
- "tag",
- "pickup",
- "stop",
- "handoff"
-] as const;
-export /** Documented. */
-const MAX_SECTION_TIME_SECONDS = 4_294_967_295;
-
-/** Documented. */
-export type SectionFormLabel = (typeof SECTION_FORM_LABELS)[number];
-
-/** Documented. */
-export type ProjectSummary = {
- id: string;
- title: string;
- status: "idle" | "running" | "done" | "failed";
- supportedAudioFormats: readonly (typeof SUPPORTED_AUDIO_FORMATS)[number][];
-};
-
-/** Documented. */
-export type ConfidenceLevel = "low" | "medium" | "high";
-/** Documented. */
-export type ProvenanceSource = "model" | "user";
-/** Documented. */
-export type CueAnchorKind = "lyric" | "count" | "transition";
-/** Documented. */
-export type RehearsalPriority = "low" | "medium" | "high";
-/** Documented. */
-export type ExportFormat = "cue-sheet" | "chart-summary";
-/** Documented. */
-export type CollaborationAssignmentStatus = "todo" | "in_progress" | "ready" | "blocked";
-/** Documented. */
-export type CollaborationCommentStatus = "open" | "resolved";
-/** Documented. */
-export type CollaborationApprovalStatus = "pending" | "approved" | "changes_requested";
-/** Documented. */
-export type CollaborationSyncMode = "local_only" | "planned_cloud";
-
-/** Documented. */
-export type ConfidenceMarker = {
- level: ConfidenceLevel;
- source: ProvenanceSource;
- notes: string;
-};
-
-/** Documented. */
-export type CueAnchor = {
- kind: CueAnchorKind;
- value: string;
-};
-
-/** Documented. */
-export type RangeSummary = {
- lowestNote: string;
- highestNote: string;
-};
-
-/** Documented. */
-export type TranscriptionNote = {
- pitch: string;
- onset: number;
- offset: number;
- velocity: number;
-};
-
-/** Documented. */
-export type RehearsalAssignment = {
- id: string;
- assignee: string;
- summary: string;
- sectionId: string;
- roleId?: string;
- status: CollaborationAssignmentStatus;
-};
-
-/** Documented. */
-export type RehearsalComment = {
- id: string;
- author: string;
- body: string;
- sectionId: string;
- roleId?: string;
- status: CollaborationCommentStatus;
-};
-
-/** Documented. */
-export type RehearsalApproval = {
- id: string;
- scope: string;
- owner: string;
- status: CollaborationApprovalStatus;
-};
-
-/** Documented. */
-export type RehearsalCollaboration = {
- syncMode: CollaborationSyncMode;
- syncNote: string;
- assignments: RehearsalAssignment[];
- comments: RehearsalComment[];
- approvals: RehearsalApproval[];
-};
-
-/** Documented. */
-export type RehearsalHarmony = {
- chord: string;
- functionLabel: string;
- source: ProvenanceSource;
-};
-
-/** Documented. */
-export type ManualOverride =
- {
- field: "harmony";
- value: RehearsalHarmony & { source: "user" };
- source: "user";
- };
-
-/** Documented. */
-export type RehearsalRole = {
- id: string;
- name: string;
- roleType: "instrument" | "vocal" | "hand";
- harmony: RehearsalHarmony;
- harmonicExplanation?: string;
- cue: CueAnchor;
- range: RangeSummary;
- confidence: ConfidenceMarker;
- rehearsalPriority: RehearsalPriority;
- simplification: string;
- setupNote: string;
- transpositionPlan?: string;
- manualOverrides: ManualOverride[];
- overlapWarnings: string[];
- transcription?: TranscriptionNote[];
- practiceProgress?: number;
-};
-
-/** Documented. */
-export type PartGraphNode = {
- role_id: string;
- is_active: boolean;
- handoff_to: string[];
- handoff_from: string[];
-};
-
-/** Documented. */
-export type SectionTimeRange = {
- start: number;
- end: number;
-};
-
-/** Documented. */
-export type RehearsalSection = {
- id: string;
- label: SectionFormLabel;
- groove: string;
- timeRange: SectionTimeRange;
- confidence: ConfidenceMarker;
- roles: RehearsalRole[];
- partGraph: PartGraphNode[];
-};
-
-/** Documented. */
-export type ExportSummary = {
- format: ExportFormat;
- headline: string;
- focusSections: string[];
-};
-
-
-/** Documented. */
-export type PackState = "queued" | "analyzing" | "ready" | "failed";
-
-/** Documented. */
-export type SongRehearsalPack =
- | {
- id: string;
- packState: "queued" | "analyzing";
- engineState: AnalysisJobState;
- sourceLabel: string;
- }
- | {
- id: string;
- packState: "ready";
- engineState?: AnalysisJobState;
- song: RehearsalSong;
- sourceLabel: string;
- }
- | {
- id: string;
- packState: "failed";
- engineState?: AnalysisJobState;
- error: AnalysisJobError;
- sourceLabel: string;
- };
-
-/** Documented. */
-export type RehearsalWorkspace = {
- id: string;
- title: string;
- songs: SongRehearsalPack[];
- workspaceVersion: number;
-};
-
-/** Documented. */
-export type ScoreAttachment = {
- id: string;
- fileName: string;
-};
-
-/** Documented. */
-export type RehearsalSong = {
- id: string;
- title: string;
- tempo?: number;
- sections: RehearsalSection[];
- exportSummary: ExportSummary;
- collaboration?: RehearsalCollaboration;
- scoreAttachments?: ScoreAttachment[];
-};
-
-/** Documented. */
-export type AnalysisSourceKind = "demo" | "local_audio";
-/** Documented. */
-export type AnalysisJobState = "queued" | "running" | "succeeded" | "failed";
-/** Documented. */
-export type AnalysisJobStage = "queued" | "decode" | "separate" | "analyze" | "persist" | "ready";
-/** Documented. */
-export type AnalysisCacheStatus = "disabled" | "miss" | "hit" | "stored";
-/** Documented. */
-export type AnalysisJobErrorCode = "invalid_request" | "not_found" | "engine_unavailable";
-
-/** Documented. */
-export type LocalAudioSource = {
- sourcePath: string;
- fileName: string;
- extension: (typeof SUPPORTED_AUDIO_FORMATS)[number];
- fileSizeBytes: number;
-};
-
-/** Documented. */
-export type ProjectBootstrapSummary = {
- projectId: string;
- sourceMode: "reference";
- projectRoot: string;
- cacheRoot: string;
- tempRoot: string;
- source: LocalAudioSource;
-};
-
-/** Documented. */
-export type MetadataHandoffSourceAsset = {
- referenceKind: "local_audio";
- sourceMode: "reference";
- fileName: string;
- extension: (typeof SUPPORTED_AUDIO_FORMATS)[number];
- fileSizeBytes: number;
- status: "referenced" | "missing";
-};
-
-/** Documented. */
-export type MetadataHandoffRoleBucket = {
- id: string;
- name: string;
- roleType: RehearsalRole["roleType"];
- confidence: ConfidenceMarker;
- rehearsalPriority: RehearsalPriority;
-};
-
-/** Documented. */
-export type MetadataHandoffSection = {
- id: string;
- label: SectionFormLabel;
- timeRange: SectionTimeRange;
- confidence: ConfidenceMarker;
- roleBuckets: MetadataHandoffRoleBucket[];
-};
-
-/** Documented. */
-export type MetadataHandoffArtifact = {
- artifactKind: "bandscope.metadata-handoff";
- artifactVersion: 1;
- createdAt: string;
- workspace: {
- id: string;
- title: string;
- workspaceVersion: number;
- };
- song: {
- id: string;
- title: string;
- exportSummary: ExportSummary;
- };
- sections: MetadataHandoffSection[];
- sourceAssets: MetadataHandoffSourceAsset[];
-};
-
-/** Documented. */
-export type AnalysisJobRequest =
- | {
- sourceKind: "demo";
- sourceLabel: string;
- roleFocus: string[];
- }
- | {
- sourceKind: "local_audio";
- projectId: string;
- sourceLabel: string;
- roleFocus: string[];
- };
-
-/** Documented. */
-export type AnalysisJobError = {
- code: AnalysisJobErrorCode;
- message: string;
-};
-
-/** Documented. */
-export type AnalysisJobStatus = {
- jobId: string;
- state: AnalysisJobState;
- requestedAt: string;
- updatedAt: string;
- progressLabel?: string;
- progressStage?: AnalysisJobStage;
- progressPercent?: number;
- cacheStatus?: AnalysisCacheStatus;
- result?: RehearsalSong;
- error?: AnalysisJobError;
-};
-
-/** Documented. */
-export type AnalysisJobSnapshot = {
- jobId: string;
- request: AnalysisJobRequest;
- status: AnalysisJobStatus;
- startedAt?: string;
- finishedAt?: string;
- error?: AnalysisJobError;
- metadata?: Record;
-};
-
-const CONFIDENCE_LEVELS = ["low", "medium", "high"] as const;
-const PROJECT_STATUSES = ["idle", "running", "done", "failed"] as const;
-const REHEARSAL_PRIORITIES = ["low", "medium", "high"] as const;
-const PROVENANCE_SOURCES = ["model", "user"] as const;
-const CUE_ANCHOR_KINDS = ["lyric", "count", "transition"] as const;
-const ROLE_TYPES = ["instrument", "vocal", "hand"] as const;
-const EXPORT_FORMATS = ["cue-sheet", "chart-summary"] as const;
-const COLLABORATION_ASSIGNMENT_STATUSES = ["todo", "in_progress", "ready", "blocked"] as const;
-const COLLABORATION_COMMENT_STATUSES = ["open", "resolved"] as const;
-const COLLABORATION_APPROVAL_STATUSES = ["pending", "approved", "changes_requested"] as const;
-const COLLABORATION_SYNC_MODES = ["local_only", "planned_cloud"] as const;
-const ANALYSIS_SOURCE_KINDS = ["demo", "local_audio"] as const;
-const ANALYSIS_JOB_STATES = ["queued", "running", "succeeded", "failed"] as const;
-const ANALYSIS_JOB_STAGES = ["queued", "decode", "separate", "analyze", "persist", "ready"] as const;
-const ANALYSIS_CACHE_STATUSES = ["disabled", "miss", "hit", "stored"] as const;
-const ANALYSIS_JOB_ERROR_CODES = ["invalid_request", "not_found", "engine_unavailable"] as const;
-const PACK_STATES = ["queued", "analyzing", "ready", "failed"] as const;
-const HANDOFF_ASSET_STATUSES = ["referenced", "missing"] as const;
-
-type ValidationOptions = {
- acceptLegacySectionTimeRanges: boolean;
-};
-
-const STRICT_VALIDATION_OPTIONS: ValidationOptions = {
- acceptLegacySectionTimeRanges: false
-};
-
-const LEGACY_VALIDATION_OPTIONS: ValidationOptions = {
- acceptLegacySectionTimeRanges: true
-};
-
-/** Documented. */
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-/** Documented. */
-function isDenseArray(value: unknown): value is unknown[] {
- if (!Array.isArray(value)) return false;
- // Performance: avoid Array.from() allocation while keeping a one-time length boundary.
- const arrayLength = Number(value.length);
- if (!Number.isSafeInteger(arrayLength) || arrayLength < 0 || arrayLength > 0xffffffff) {
- return false;
- }
- for (let i = 0; i < arrayLength; i++) {
- if (!(i in value)) {
- return false;
- }
- }
- return true;
-}
-
-/** Documented. */
-function isOneOf(options: readonly T[], value: unknown): value is T {
- return typeof value === "string" && options.includes(value as T);
-}
-
-/** Documented. */
-function invalidField(path: string): string {
- return `Invalid rehearsal song contract: invalid field '${path}'`;
-}
-
-/** Documented. */
-function unexpectedKey(value: Record, allowedKeys: readonly string[], path: string): string | null {
- for (const key of Object.keys(value)) {
- if (!allowedKeys.includes(key)) {
- return invalidField(path ? `${path}.${key}` : key);
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function invalidProjectSummaryField(path: string): string {
- return `Invalid project summary contract: invalid field '${path}'`;
-}
-
-const demoRehearsalSongSeed: RehearsalSong = {
- id: "demo-song",
- title: "Late Night Set",
- tempo: 120,
- sections: [
- {
- id: "verse-1",
- label: "verse",
- groove: "Straight eighths with a late snare feel",
- timeRange: {
- start: 10,
- end: 30
- },
- confidence: {
- level: "medium",
- source: "model",
- notes: "Double-check the pickup into the chorus."
- },
- roles: [
- {
- id: "bass-guitar",
- name: "Bass Guitar",
- roleType: "instrument",
- harmony: {
- chord: "C#m7",
- functionLabel: "vi pedal anchor",
- source: "model"
- },
- harmonicExplanation: "The bass holds the vi center so the rest of the section can lean into the pickup without losing the tonal floor.",
- cue: {
- kind: "transition",
- value: "Hold through the pickup before the downbeat.",
- },
- range: {
- lowestNote: "C#2",
- highestNote: "E3"
- },
- confidence: {
- level: "medium",
- source: "model",
- notes: "Watch the slide into the turnaround."
- },
- rehearsalPriority: "high",
- simplification: "Stay on roots if the chorus entrance gets muddy.",
- setupNote: "Keep the attack short so the verse breathes.",
- transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.",
- manualOverrides: [],
- overlapWarnings: [
- "Density warning: competing with Keyboard Left Hand in low register."
- ]
- },
- {
- id: "keys-right",
- name: "Keyboard 1 Right Hand",
- roleType: "hand",
- harmony: {
- chord: "Emaj7",
- functionLabel: "Imaj7 color",
- source: "model"
- },
- harmonicExplanation: "The right hand supplies the major-7 color that distinguishes the verse from the more open chorus voicing.",
- cue: {
- kind: "count",
- value: "Enter on beat 2 after the pickup."
- },
- range: {
- lowestNote: "B3",
- highestNote: "G#5"
- },
- confidence: {
- level: "medium",
- source: "model",
- notes: "Top note voicing may need a quick ear check."
- },
- rehearsalPriority: "high",
- simplification: "Drop the top extension if the chorus turnaround still feels busy.",
- setupNote: "Keep the patch bright enough to stay over the guitars.",
- transpositionPlan: "If the band rehearses in D, keep the voicing in first inversion so the top line still sings.",
- manualOverrides: [],
- overlapWarnings: [
- "Melodic overlap: top notes conflict with Lead Vocal range."
- ]
- },
- {
- id: "lead-vocal",
- name: "Lead Vocal",
- roleType: "vocal",
- harmony: {
- chord: "C#m7",
- functionLabel: "vi melodic pull",
- source: "model"
- },
- harmonicExplanation: "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.",
- cue: {
- kind: "lyric",
- value: "city lights"
- },
- range: {
- lowestNote: "G#3",
- highestNote: "C#5"
- },
- confidence: {
- level: "high",
- source: "user",
- notes: "Singer confirmed the pickup phrasing in rehearsal notes."
- },
- rehearsalPriority: "medium",
- simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.",
- setupNote: "Watch the breath before the last line of the verse.",
- transpositionPlan: "If the room wants more ease, move the section down a whole step and keep the pickup breath mark in the same place.",
- manualOverrides: [
- {
- field: "harmony",
- value: {
- chord: "C#m11",
- functionLabel: "vi suspended lift",
- source: "user"
- },
- source: "user"
- }
- ],
- overlapWarnings: [
- "Melodic overlap: competing with Keyboard 1 Right Hand."
- ]
- }
- ],
- partGraph: [
- { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] },
- { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] },
- { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] }
- ]
- }
- ],
- exportSummary: {
- format: "cue-sheet",
- headline: "Start with verse entrances before the chorus lift.",
- focusSections: ["verse"]
- },
- collaboration: {
- syncMode: "planned_cloud",
- syncNote: "Keep assignments local for now. Cloud sync opens only after security and conflict-resolution rules land.",
- assignments: [
- {
- id: "assign-bass-entrance",
- assignee: "Rhythm Section",
- summary: "Lock the bass entrance against the pickup so the chorus lift lands together.",
- sectionId: "verse-1",
- roleId: "bass-guitar",
- status: "in_progress"
- },
- {
- id: "assign-vocal-key",
- assignee: "Lead Vocal",
- summary: "Confirm whether the verse should stay in C# minor or move down for the first rehearsal pass.",
- sectionId: "verse-1",
- roleId: "lead-vocal",
- status: "todo"
- }
- ],
- comments: [
- {
- id: "comment-keys-color",
- author: "MD",
- body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.",
- sectionId: "verse-1",
- roleId: "keys-right",
- status: "open"
- },
- {
- id: "comment-vocal-breath",
- author: "Lead Vocal",
- body: "Breath mark is confirmed before the last lyric pickup.",
- sectionId: "verse-1",
- roleId: "lead-vocal",
- status: "resolved"
- }
- ],
- approvals: [
- {
- id: "approval-harmony-pass",
- scope: "Verse harmony pass",
- owner: "MD",
- status: "pending"
- },
- {
- id: "approval-vocal-shape",
- scope: "Lead vocal simplification",
- owner: "Lead Vocal",
- status: "approved"
- }
- ]
- }
-};
-
-/** Documented. */
-export function createDefaultProjectSummary(input: {
- id: string;
- title: string;
-}): ProjectSummary {
- return {
- id: input.id,
- title: input.title,
- status: "idle",
- supportedAudioFormats: SUPPORTED_AUDIO_FORMATS
- };
-}
-
-/** Documented. */
-export function validateProjectSummary(value: unknown): string | null {
- if (!isRecord(value)) {
- return invalidProjectSummaryField("root");
- }
- const allowedKeys = ["id", "title", "status", "supportedAudioFormats"] as const;
- for (const key of Object.keys(value)) {
- if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
- return invalidProjectSummaryField(key);
- }
- }
- if (typeof value.id !== "string" || value.id.trim().length === 0) {
- return invalidProjectSummaryField("id");
- }
- if (typeof value.title !== "string" || value.title.trim().length === 0) {
- return invalidProjectSummaryField("title");
- }
- if (!isOneOf(PROJECT_STATUSES, value.status)) {
- return invalidProjectSummaryField("status");
- }
- if (!isDenseArray(value.supportedAudioFormats)) {
- return invalidProjectSummaryField("supportedAudioFormats");
- }
- for (const [index, format] of value.supportedAudioFormats.entries()) {
- if (!isOneOf(SUPPORTED_AUDIO_FORMATS, format)) {
- return invalidProjectSummaryField(`supportedAudioFormats[${index}]`);
- }
- }
-
- return null;
-}
-
-/** Documented. */
-export function isProjectSummary(value: unknown): value is ProjectSummary {
- return validateProjectSummary(value) === null;
-}
-
-/** Documented. */
-export function parseProjectSummary(value: unknown): ProjectSummary {
- const validationError = validateProjectSummary(value);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(value as ProjectSummary);
-}
-
-/** Documented. */
-export function createDemoRehearsalSong(): RehearsalSong {
- return structuredClone(demoRehearsalSongSeed);
-}
-
-/** Documented. */
-export function createDemoAnalysisJobRequest(): AnalysisJobRequest {
- return {
- sourceKind: "demo",
- sourceLabel: demoRehearsalSongSeed.title,
- roleFocus: demoRehearsalSongSeed.sections[0].roles.map((role) => role.id)
- };
-}
-
-/** Documented. */
-export function createProjectBootstrapSummary(input: {
- projectId: string;
- projectRoot: string;
- cacheRoot: string;
- tempRoot: string;
- source: LocalAudioSource;
-}): ProjectBootstrapSummary {
- return {
- projectId: input.projectId,
- sourceMode: "reference",
- projectRoot: input.projectRoot,
- cacheRoot: input.cacheRoot,
- tempRoot: input.tempRoot,
- source: input.source
- };
-}
-
-/** Documented. */
-function validateProjectBootstrapSummary(value: unknown): string | null {
- if (!isRecord(value)) {
- return "Invalid project bootstrap summary: invalid field 'root'";
- }
- const allowedKeys = ["projectId", "sourceMode", "projectRoot", "cacheRoot", "tempRoot", "source"] as const;
- for (const key of Object.keys(value)) {
- if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
- return `Invalid project bootstrap summary: invalid field '${key}'`;
- }
- }
- if (typeof value.projectId !== "string" || value.projectId.trim().length === 0) {
- return "Invalid project bootstrap summary: invalid field 'projectId'";
- }
- if (value.sourceMode !== "reference") {
- return "Invalid project bootstrap summary: invalid field 'sourceMode'";
- }
- if (typeof value.projectRoot !== "string" || value.projectRoot.trim().length === 0) {
- return "Invalid project bootstrap summary: invalid field 'projectRoot'";
- }
- if (typeof value.cacheRoot !== "string" || value.cacheRoot.trim().length === 0) {
- return "Invalid project bootstrap summary: invalid field 'cacheRoot'";
- }
- if (typeof value.tempRoot !== "string" || value.tempRoot.trim().length === 0) {
- return "Invalid project bootstrap summary: invalid field 'tempRoot'";
- }
- const sourceError = validateLocalAudioSource(value.source);
- if (sourceError) {
- return sourceError.replace("Invalid local audio source", "Invalid project bootstrap summary.source");
- }
-
- return null;
-}
-
-/** Documented. */
-export function parseProjectBootstrapSummary(value: unknown): ProjectBootstrapSummary {
- const validationError = validateProjectBootstrapSummary(value);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(value as ProjectBootstrapSummary);
-}
-
-/** Documented. */
-function validateMetadataHandoffSourceAsset(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["referenceKind", "sourceMode", "fileName", "extension", "fileSizeBytes", "status"], path);
- if (extraKey) {
- return extraKey;
- }
- if (value.referenceKind !== "local_audio") {
- return invalidField(`${path}.referenceKind`);
- }
- if (value.sourceMode !== "reference") {
- return invalidField(`${path}.sourceMode`);
- }
- if (typeof value.fileName !== "string" || value.fileName.trim().length === 0) {
- return invalidField(`${path}.fileName`);
- }
- if (!isOneOf(SUPPORTED_AUDIO_FORMATS, value.extension)) {
- return invalidField(`${path}.extension`);
- }
- if (typeof value.fileSizeBytes !== "number" || !Number.isFinite(value.fileSizeBytes) || value.fileSizeBytes <= 0) {
- return invalidField(`${path}.fileSizeBytes`);
- }
- if (!isOneOf(HANDOFF_ASSET_STATUSES, value.status)) {
- return invalidField(`${path}.status`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateMetadataHandoffRoleBucket(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "name", "roleType", "confidence", "rehearsalPriority"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (typeof value.name !== "string") {
- return invalidField(`${path}.name`);
- }
- if (!isOneOf(ROLE_TYPES, value.roleType)) {
- return invalidField(`${path}.roleType`);
- }
- const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
- if (confidenceError) {
- return confidenceError;
- }
- if (!isOneOf(REHEARSAL_PRIORITIES, value.rehearsalPriority)) {
- return invalidField(`${path}.rehearsalPriority`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateMetadataHandoffSection(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "label", "timeRange", "confidence", "roleBuckets"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (!isOneOf(SECTION_FORM_LABELS, value.label)) {
- return invalidField(`${path}.label`);
- }
- const timeRangeError = validateSectionTimeRange(value.timeRange, `${path}.timeRange`);
- if (timeRangeError) {
- return timeRangeError;
- }
- const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
- if (confidenceError) {
- return confidenceError;
- }
- if (!isDenseArray(value.roleBuckets)) {
- return invalidField(`${path}.roleBuckets`);
- }
- for (const [index, role] of value.roleBuckets.entries()) {
- const roleError = validateMetadataHandoffRoleBucket(role, `${path}.roleBuckets[${index}]`);
- if (roleError) {
- return roleError;
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function validateMetadataHandoffArtifact(value: unknown): string | null {
- if (!isRecord(value)) {
- return invalidField("root");
- }
- const extraKey = unexpectedKey(
- value,
- ["artifactKind", "artifactVersion", "createdAt", "workspace", "song", "sections", "sourceAssets"],
- ""
- );
- if (extraKey) {
- return extraKey;
- }
- if (value.artifactKind !== "bandscope.metadata-handoff") {
- return invalidField("artifactKind");
- }
- if (value.artifactVersion !== 1) {
- return invalidField("artifactVersion");
- }
- if (typeof value.createdAt !== "string" || value.createdAt.trim().length === 0) {
- return invalidField("createdAt");
- }
- if (!isRecord(value.workspace)) {
- return invalidField("workspace");
- }
- const workspaceExtraKey = unexpectedKey(value.workspace, ["id", "title", "workspaceVersion"], "workspace");
- if (workspaceExtraKey) {
- return workspaceExtraKey;
- }
- if (typeof value.workspace.id !== "string") {
- return invalidField("workspace.id");
- }
- if (typeof value.workspace.title !== "string") {
- return invalidField("workspace.title");
- }
- if (
- typeof value.workspace.workspaceVersion !== "number" ||
- !Number.isInteger(value.workspace.workspaceVersion) ||
- value.workspace.workspaceVersion < 1
- ) {
- return invalidField("workspace.workspaceVersion");
- }
- if (!isRecord(value.song)) {
- return invalidField("song");
- }
- const songExtraKey = unexpectedKey(value.song, ["id", "title", "exportSummary"], "song");
- if (songExtraKey) {
- return songExtraKey;
- }
- if (typeof value.song.id !== "string") {
- return invalidField("song.id");
- }
- if (typeof value.song.title !== "string") {
- return invalidField("song.title");
- }
- const exportSummaryError = validateExportSummary(value.song.exportSummary, "song.exportSummary");
- if (exportSummaryError) {
- return exportSummaryError;
- }
- if (!isDenseArray(value.sections)) {
- return invalidField("sections");
- }
- for (const [index, section] of value.sections.entries()) {
- const sectionError = validateMetadataHandoffSection(section, `sections[${index}]`);
- if (sectionError) {
- return sectionError;
- }
- }
- if (!isDenseArray(value.sourceAssets)) {
- return invalidField("sourceAssets");
- }
- for (const [index, sourceAsset] of value.sourceAssets.entries()) {
- const sourceAssetError = validateMetadataHandoffSourceAsset(sourceAsset, `sourceAssets[${index}]`);
- if (sourceAssetError) {
- return sourceAssetError;
- }
- }
-
- return null;
-}
-
-/** Documented. */
-export function isMetadataHandoffArtifact(value: unknown): value is MetadataHandoffArtifact {
- return validateMetadataHandoffArtifact(value) === null;
-}
-
-/** Documented. */
-export function parseMetadataHandoffArtifact(value: unknown): MetadataHandoffArtifact {
- const validationError = validateMetadataHandoffArtifact(value);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(value as MetadataHandoffArtifact);
-}
-
-/** Documented. */
-function validateLocalAudioSource(value: unknown): string | null {
- if (!isRecord(value)) {
- return "Invalid local audio source: invalid field 'root'";
- }
- const allowedKeys = ["sourcePath", "fileName", "extension", "fileSizeBytes"] as const;
- for (const key of Object.keys(value)) {
- if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
- return `Invalid local audio source: invalid field '${key}'`;
- }
- }
- if (typeof value.sourcePath !== "string" || value.sourcePath.trim().length === 0) {
- return "Invalid local audio source: invalid field 'sourcePath'";
- }
- if (typeof value.fileName !== "string" || value.fileName.trim().length === 0) {
- return "Invalid local audio source: invalid field 'fileName'";
- }
- if (!isOneOf(SUPPORTED_AUDIO_FORMATS, value.extension)) {
- return "Invalid local audio source: invalid field 'extension'";
- }
- if (typeof value.fileSizeBytes !== "number" || !Number.isFinite(value.fileSizeBytes) || value.fileSizeBytes <= 0) {
- return "Invalid local audio source: invalid field 'fileSizeBytes'";
- }
-
- return null;
-}
-
-/** Documented. */
-export function parseLocalAudioSource(value: unknown): LocalAudioSource {
- const validationError = validateLocalAudioSource(value);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(value as LocalAudioSource);
-}
-
-/** Documented. */
-export function createAnalysisJobStatus(input:
- | {
- jobId: string;
- state: "queued" | "running";
- progressLabel?: string;
- progressStage?: AnalysisJobStage;
- progressPercent?: number;
- cacheStatus?: AnalysisCacheStatus;
- requestedAt?: string;
- updatedAt?: string;
- }
- | {
- jobId: string;
- state: "succeeded";
- result: RehearsalSong;
- progressLabel?: string;
- progressStage?: AnalysisJobStage;
- progressPercent?: number;
- cacheStatus?: AnalysisCacheStatus;
- requestedAt?: string;
- updatedAt?: string;
- }
- | {
- jobId: string;
- state: "failed";
- error: AnalysisJobError;
- progressLabel?: string;
- progressStage?: AnalysisJobStage;
- progressPercent?: number;
- cacheStatus?: AnalysisCacheStatus;
- requestedAt?: string;
- updatedAt?: string;
- }
-): AnalysisJobStatus {
- const now = new Date().toISOString();
- const status: AnalysisJobStatus = {
- jobId: input.jobId,
- state: input.state,
- requestedAt: input.requestedAt ?? now,
- updatedAt: input.updatedAt ?? now,
- };
-
- if (input.progressLabel !== undefined) {
- status.progressLabel = input.progressLabel;
- }
- if (input.progressStage !== undefined) {
- status.progressStage = input.progressStage;
- }
- if (input.progressPercent !== undefined) {
- status.progressPercent = input.progressPercent;
- }
- if (input.cacheStatus !== undefined) {
- status.cacheStatus = input.cacheStatus;
- }
- if ("result" in input) {
- status.result = input.result;
- }
- if ("error" in input) {
- status.error = input.error;
- }
-
- return status;
-}
-
-/** Documented. */
-function validateAnalysisJobRequest(value: unknown): string | null {
- if (!isRecord(value)) {
- return "Invalid analysis job request: invalid field 'root'";
- }
- if (!isOneOf(ANALYSIS_SOURCE_KINDS, value.sourceKind)) {
- return "Invalid analysis job request: invalid field 'sourceKind'";
- }
- if (typeof value.sourceLabel !== "string" || value.sourceLabel.trim().length === 0) {
- return "Invalid analysis job request: invalid field 'sourceLabel'";
- }
- if (!isDenseArray(value.roleFocus)) {
- return "Invalid analysis job request: invalid field 'roleFocus'";
- }
- for (const [index, role] of value.roleFocus.entries()) {
- if (typeof role !== "string") {
- return `Invalid analysis job request: invalid field 'roleFocus[${index}]'`;
- }
- }
- const allowedKeys = new Set(
- value.sourceKind === "local_audio"
- ? ["sourceKind", "projectId", "sourceLabel", "roleFocus"]
- : ["sourceKind", "sourceLabel", "roleFocus"]
- );
- for (const key of Object.keys(value)) {
- if (!allowedKeys.has(key)) {
- return `Invalid analysis job request: invalid field '${key}'`;
- }
- }
- if (value.sourceKind === "local_audio") {
- if (typeof value.projectId !== "string" || value.projectId.trim().length === 0) {
- return "Invalid analysis job request: invalid field 'projectId'";
- }
- }
-
- return null;
-}
-
-/** Documented. */
-export function parseAnalysisJobRequest(value: unknown): AnalysisJobRequest {
- const validationError = validateAnalysisJobRequest(value);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(value as AnalysisJobRequest);
-}
-
-/** Documented. */
-function validateAnalysisJobError(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["code", "message"], path);
- if (extraKey) {
- return extraKey;
- }
- if (!isOneOf(ANALYSIS_JOB_ERROR_CODES, value.code)) {
- return invalidField(`${path}.code`);
- }
- if (typeof value.message !== "string") {
- return invalidField(`${path}.message`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateAnalysisJobStatus(
- value: unknown,
- options: ValidationOptions = STRICT_VALIDATION_OPTIONS
-): string | null {
- if (!isRecord(value)) {
- return invalidField("root");
- }
- const allowedKeysByState: Record = {
- queued: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus"],
- running: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus"],
- succeeded: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus", "result"],
- failed: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus", "error"]
- };
- if (typeof value.jobId !== "string") {
- return invalidField("jobId");
- }
- if (!isOneOf(ANALYSIS_JOB_STATES, value.state)) {
- return invalidField("state");
- }
- const extraKey = unexpectedKey(value, allowedKeysByState[value.state], "");
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.requestedAt !== "string") {
- return invalidField("requestedAt");
- }
- if (typeof value.updatedAt !== "string") {
- return invalidField("updatedAt");
- }
- if (value.progressLabel !== undefined && typeof value.progressLabel !== "string") {
- return invalidField("progressLabel");
- }
- if (value.progressStage !== undefined && !isOneOf(ANALYSIS_JOB_STAGES, value.progressStage)) {
- return invalidField("progressStage");
- }
- if (
- value.progressPercent !== undefined &&
- (
- typeof value.progressPercent !== "number" ||
- !Number.isInteger(value.progressPercent) ||
- value.progressPercent < 0 ||
- value.progressPercent > 100
- )
- ) {
- return invalidField("progressPercent");
- }
- if (value.cacheStatus !== undefined && !isOneOf(ANALYSIS_CACHE_STATUSES, value.cacheStatus)) {
- return invalidField("cacheStatus");
- }
- if (value.result !== undefined) {
- const resultError = validateRehearsalSong(value.result, options);
- if (resultError) {
- return resultError;
- }
- }
- if (value.error !== undefined) {
- const errorValidation = validateAnalysisJobError(value.error, "error");
- if (errorValidation) {
- return errorValidation;
- }
- }
- if (value.state === "succeeded" && value.result === undefined) {
- return invalidField("result");
- }
- if (value.state === "failed" && value.error === undefined) {
- return invalidField("error");
- }
-
- return null;
-}
-
-/** Documented. */
-export function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus {
- return validateAnalysisJobStatus(value) === null;
-}
-
-/** Documented. */
-export function parseAnalysisJobStatus(value: unknown): AnalysisJobStatus {
- const validationError = validateAnalysisJobStatus(value, LEGACY_VALIDATION_OPTIONS);
- if (validationError) {
- throw new Error(validationError);
- }
-
- const parsed = structuredClone(value as AnalysisJobStatus);
- if (parsed.result !== undefined) {
- parsed.result = parseRehearsalSong(parsed.result);
- }
- return parsed;
-}
-
-/** Documented. */
-function validateConfidenceMarker(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["level", "source", "notes"], path);
- if (extraKey) {
- return extraKey;
- }
- if (!isOneOf(CONFIDENCE_LEVELS, value.level)) {
- return invalidField(`${path}.level`);
- }
- if (!isOneOf(PROVENANCE_SOURCES, value.source)) {
- return invalidField(`${path}.source`);
- }
- if (typeof value.notes !== "string") {
- return invalidField(`${path}.notes`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateCueAnchor(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["kind", "value"], path);
- if (extraKey) {
- return extraKey;
- }
- if (!isOneOf(CUE_ANCHOR_KINDS, value.kind)) {
- return invalidField(`${path}.kind`);
- }
- if (typeof value.value !== "string") {
- return invalidField(`${path}.value`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRangeSummary(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["lowestNote", "highestNote"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.lowestNote !== "string") {
- return invalidField(`${path}.lowestNote`);
- }
- if (typeof value.highestNote !== "string") {
- return invalidField(`${path}.highestNote`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalHarmony(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["chord", "functionLabel", "source"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.chord !== "string") {
- return invalidField(`${path}.chord`);
- }
- if (typeof value.functionLabel !== "string") {
- return invalidField(`${path}.functionLabel`);
- }
- if (!isOneOf(PROVENANCE_SOURCES, value.source)) {
- return invalidField(`${path}.source`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalAssignment(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "assignee", "summary", "sectionId", "roleId", "status"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (typeof value.assignee !== "string") {
- return invalidField(`${path}.assignee`);
- }
- if (typeof value.summary !== "string") {
- return invalidField(`${path}.summary`);
- }
- if (typeof value.sectionId !== "string") {
- return invalidField(`${path}.sectionId`);
- }
- if (value.roleId !== undefined && typeof value.roleId !== "string") {
- return invalidField(`${path}.roleId`);
- }
- if (!isOneOf(COLLABORATION_ASSIGNMENT_STATUSES, value.status)) {
- return invalidField(`${path}.status`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalComment(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "author", "body", "sectionId", "roleId", "status"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (typeof value.author !== "string") {
- return invalidField(`${path}.author`);
- }
- if (typeof value.body !== "string") {
- return invalidField(`${path}.body`);
- }
- if (typeof value.sectionId !== "string") {
- return invalidField(`${path}.sectionId`);
- }
- if (value.roleId !== undefined && typeof value.roleId !== "string") {
- return invalidField(`${path}.roleId`);
- }
- if (!isOneOf(COLLABORATION_COMMENT_STATUSES, value.status)) {
- return invalidField(`${path}.status`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalApproval(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "scope", "owner", "status"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (typeof value.scope !== "string") {
- return invalidField(`${path}.scope`);
- }
- if (typeof value.owner !== "string") {
- return invalidField(`${path}.owner`);
- }
- if (!isOneOf(COLLABORATION_APPROVAL_STATUSES, value.status)) {
- return invalidField(`${path}.status`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalCollaboration(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["syncMode", "syncNote", "assignments", "comments", "approvals"], path);
- if (extraKey) {
- return extraKey;
- }
- if (!isOneOf(COLLABORATION_SYNC_MODES, value.syncMode)) {
- return invalidField(`${path}.syncMode`);
- }
- if (typeof value.syncNote !== "string") {
- return invalidField(`${path}.syncNote`);
- }
- if (!isDenseArray(value.assignments)) {
- return invalidField(`${path}.assignments`);
- }
- for (const [index, assignment] of value.assignments.entries()) {
- const assignmentError = validateRehearsalAssignment(assignment, `${path}.assignments[${index}]`);
- if (assignmentError) {
- return assignmentError;
- }
- }
- if (!isDenseArray(value.comments)) {
- return invalidField(`${path}.comments`);
- }
- for (const [index, comment] of value.comments.entries()) {
- const commentError = validateRehearsalComment(comment, `${path}.comments[${index}]`);
- if (commentError) {
- return commentError;
- }
- }
- if (!isDenseArray(value.approvals)) {
- return invalidField(`${path}.approvals`);
- }
- for (const [index, approval] of value.approvals.entries()) {
- const approvalError = validateRehearsalApproval(approval, `${path}.approvals[${index}]`);
- if (approvalError) {
- return approvalError;
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function validateManualOverride(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["field", "value", "source"], path);
- if (extraKey) {
- return extraKey;
- }
- if (value.field !== "harmony") {
- return invalidField(`${path}.field`);
- }
- if (value.source !== "user") {
- return invalidField(`${path}.source`);
- }
-
- const harmonyError = validateRehearsalHarmony(value.value, `${path}.value`);
- if (harmonyError) {
- return harmonyError;
- }
- const harmonyValue = value.value as RehearsalHarmony;
- if (harmonyValue.source !== "user") {
- return invalidField(`${path}.value.source`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateTranscriptionNote(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["pitch", "onset", "offset", "velocity"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.pitch !== "string") {
- return invalidField(`${path}.pitch`);
- }
- if (typeof value.onset !== "number") {
- return invalidField(`${path}.onset`);
- }
- if (typeof value.offset !== "number") {
- return invalidField(`${path}.offset`);
- }
- if (typeof value.velocity !== "number") {
- return invalidField(`${path}.velocity`);
- }
- return null;
-}
-
-/** Documented. */
-function validateRehearsalRole(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(
- value,
- [
- "id",
- "name",
- "roleType",
- "harmony",
- "harmonicExplanation",
- "cue",
- "range",
- "confidence",
- "rehearsalPriority",
- "simplification",
- "setupNote",
- "transpositionPlan",
- "manualOverrides",
- "overlapWarnings",
- "transcription",
- "practiceProgress"
- ],
- path
- );
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (typeof value.name !== "string") {
- return invalidField(`${path}.name`);
- }
- if (!isOneOf(ROLE_TYPES, value.roleType)) {
- return invalidField(`${path}.roleType`);
- }
-
- const harmonyError = validateRehearsalHarmony(value.harmony, `${path}.harmony`);
- if (harmonyError) {
- return harmonyError;
- }
- if (value.harmonicExplanation !== undefined && typeof value.harmonicExplanation !== "string") {
- return invalidField(`${path}.harmonicExplanation`);
- }
-
- const cueError = validateCueAnchor(value.cue, `${path}.cue`);
- if (cueError) {
- return cueError;
- }
-
- const rangeError = validateRangeSummary(value.range, `${path}.range`);
- if (rangeError) {
- return rangeError;
- }
-
- const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
- if (confidenceError) {
- return confidenceError;
- }
-
- if (!isOneOf(REHEARSAL_PRIORITIES, value.rehearsalPriority)) {
- return invalidField(`${path}.rehearsalPriority`);
- }
- if (typeof value.simplification !== "string") {
- return invalidField(`${path}.simplification`);
- }
- if (typeof value.setupNote !== "string") {
- return invalidField(`${path}.setupNote`);
- }
- if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") {
- return invalidField(`${path}.transpositionPlan`);
- }
- if (!isDenseArray(value.manualOverrides)) {
- return invalidField(`${path}.manualOverrides`);
- }
- for (const [index, override] of value.manualOverrides.entries()) {
- const overrideError = validateManualOverride(override, `${path}.manualOverrides[${index}]`);
- if (overrideError) {
- return overrideError;
- }
- }
- if (!isDenseArray(value.overlapWarnings)) {
- return invalidField(`${path}.overlapWarnings`);
- }
- for (const [index, warning] of value.overlapWarnings.entries()) {
- if (typeof warning !== "string") {
- return invalidField(`${path}.overlapWarnings[${index}]`);
- }
- }
-
- if (value.transcription !== undefined) {
- if (!isDenseArray(value.transcription)) {
- return invalidField(`${path}.transcription`);
- }
- for (const [index, note] of value.transcription.entries()) {
- const noteError = validateTranscriptionNote(note, `${path}.transcription[${index}]`);
- if (noteError) {
- return noteError;
- }
- }
- }
-
- if (value.practiceProgress !== undefined) {
- if (typeof value.practiceProgress !== "number" || !Number.isFinite(value.practiceProgress) || !Number.isInteger(value.practiceProgress) || value.practiceProgress < 0 || value.practiceProgress > 100) {
- return invalidField(`${path}.practiceProgress`);
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function validatePartGraphNode(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["role_id", "is_active", "handoff_to", "handoff_from"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.role_id !== "string") {
- return invalidField(`${path}.role_id`);
- }
- if (typeof value.is_active !== "boolean") {
- return invalidField(`${path}.is_active`);
- }
- if (!isDenseArray(value.handoff_to)) {
- return invalidField(`${path}.handoff_to`);
- }
- for (const [index, handoff] of value.handoff_to.entries()) {
- if (typeof handoff !== "string") {
- return invalidField(`${path}.handoff_to[${index}]`);
- }
- }
- if (!isDenseArray(value.handoff_from)) {
- return invalidField(`${path}.handoff_from`);
- }
- for (const [index, handoff] of value.handoff_from.entries()) {
- if (typeof handoff !== "string") {
- return invalidField(`${path}.handoff_from[${index}]`);
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function validateSectionTimeRange(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["start", "end"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.start !== "number" || !Number.isFinite(value.start) || !Number.isInteger(value.start) || value.start < 0 || value.start > MAX_SECTION_TIME_SECONDS) {
- return invalidField(`${path}.start`);
- }
- if (typeof value.end !== "number" || !Number.isFinite(value.end) || !Number.isInteger(value.end) || value.end <= value.start || value.end > MAX_SECTION_TIME_SECONDS) {
- return invalidField(`${path}.end`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalSection(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "label", "groove", "timeRange", "confidence", "roles", "partGraph"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string") {
- return invalidField(`${path}.id`);
- }
- if (!isOneOf(SECTION_FORM_LABELS, value.label)) {
- return invalidField(`${path}.label`);
- }
- if (typeof value.groove !== "string") {
- return invalidField(`${path}.groove`);
- }
-
- const timeRangeError = validateSectionTimeRange(value.timeRange, `${path}.timeRange`);
- if (timeRangeError) {
- return timeRangeError;
- }
-
- const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
- if (confidenceError) {
- return confidenceError;
- }
-
- if (!isDenseArray(value.roles)) {
- return invalidField(`${path}.roles`);
- }
- for (const [index, role] of value.roles.entries()) {
- const roleError = validateRehearsalRole(role, `${path}.roles[${index}]`);
- if (roleError) {
- return roleError;
- }
- }
-
- if (!isDenseArray(value.partGraph)) {
- return invalidField(`${path}.partGraph`);
- }
- for (const [index, node] of value.partGraph.entries()) {
- const nodeError = validatePartGraphNode(node, `${path}.partGraph[${index}]`);
- if (nodeError) {
- return nodeError;
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function validateExportSummary(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["format", "headline", "focusSections"], path);
- if (extraKey) {
- return extraKey;
- }
- if (!isOneOf(EXPORT_FORMATS, value.format)) {
- return invalidField(`${path}.format`);
- }
- if (typeof value.headline !== "string") {
- return invalidField(`${path}.headline`);
- }
- if (!isDenseArray(value.focusSections)) {
- return invalidField(`${path}.focusSections`);
- }
- for (const [index, section] of value.focusSections.entries()) {
- if (typeof section !== "string") {
- return invalidField(`${path}.focusSections[${index}]`);
- }
- }
-
- return null;
-}
-
-/** Documented. */
-function legacySectionTimeRange(index: number): SectionTimeRange {
- return {
- start: index,
- end: index + 1
- };
-}
-
-/** Documented. */
-function migrateLegacySectionTimeRanges(value: unknown): unknown {
- if (!isRecord(value) || !isDenseArray(value.sections)) {
- return value;
- }
-
- const migrated = structuredClone(value) as Record & {
- sections: unknown[];
- };
- migrated.sections = migrated.sections.map((section, index) => {
- if (!isRecord(section) || section.timeRange !== undefined) {
- return section;
- }
-
- return {
- ...section,
- timeRange: legacySectionTimeRange(index)
- };
- });
-
- return migrated;
-}
-
-/** Documented. */
-function validateScoreAttachment(value: unknown, path: string): string | null {
- if (!isRecord(value)) {
- return invalidField(path);
- }
- const extraKey = unexpectedKey(value, ["id", "fileName"], path);
- if (extraKey) {
- return extraKey;
- }
- if (typeof value.id !== "string" || value.id.length === 0) {
- return invalidField(`${path}.id`);
- }
- if (typeof value.fileName !== "string" || value.fileName.length === 0) {
- return invalidField(`${path}.fileName`);
- }
-
- return null;
-}
-
-/** Documented. */
-function validateRehearsalSong(
- value: unknown,
- options: ValidationOptions = STRICT_VALIDATION_OPTIONS
-): string | null {
- const normalized = options.acceptLegacySectionTimeRanges
- ? migrateLegacySectionTimeRanges(value)
- : value;
- if (!isRecord(normalized)) {
- return invalidField("root");
- }
- const extraKey = unexpectedKey(
- normalized,
- ["id", "title", "tempo", "sections", "exportSummary", "collaboration", "scoreAttachments"],
- ""
- );
- if (extraKey) {
- return extraKey;
- }
- if (typeof normalized.id !== "string") {
- return invalidField("id");
- }
- if (typeof normalized.title !== "string") {
- return invalidField("title");
- }
- if (
- normalized.tempo !== undefined &&
- (typeof normalized.tempo !== "number" || !Number.isFinite(normalized.tempo) || normalized.tempo <= 0)
- ) {
- return invalidField("tempo");
- }
- if (!isDenseArray(normalized.sections)) {
- return invalidField("sections");
- }
- for (const [index, section] of normalized.sections.entries()) {
- const sectionError = validateRehearsalSection(section, `sections[${index}]`);
- if (sectionError) {
- return sectionError;
- }
- }
- if (normalized.collaboration !== undefined) {
- const collaborationError = validateRehearsalCollaboration(normalized.collaboration, "collaboration");
- if (collaborationError) {
- return collaborationError;
- }
- }
- if (normalized.scoreAttachments !== undefined) {
- if (!isDenseArray(normalized.scoreAttachments)) {
- return invalidField("scoreAttachments");
- }
- for (const [index, attachment] of normalized.scoreAttachments.entries()) {
- const attachmentError = validateScoreAttachment(attachment, `scoreAttachments[${index}]`);
- if (attachmentError) {
- return attachmentError;
- }
- }
- }
-
- return validateExportSummary(normalized.exportSummary, "exportSummary");
-}
-
-/** Documented. */
-export function isRehearsalSong(value: unknown): value is RehearsalSong {
- return validateRehearsalSong(value) === null;
-}
-
-/** Documented. */
-export function parseRehearsalSong(value: unknown): RehearsalSong {
- const migratedValue = migrateLegacySectionTimeRanges(value);
- const validationError = validateRehearsalSong(migratedValue);
- if (validationError) {
- throw new Error(validationError);
- }
-
- return structuredClone(migratedValue as RehearsalSong);
-}
-
-
-/** Documented. */
-function validateSongRehearsalPack(
- value: unknown,
- path: string,
- options: ValidationOptions = STRICT_VALIDATION_OPTIONS
-): string | null {
- if (!isRecord(value)) return invalidField(path);
-
- if (typeof value.id !== "string") return invalidField(`${path}.id`);
- if (!isOneOf(PACK_STATES, value.packState)) return invalidField(`${path}.packState`);
- if (typeof value.sourceLabel !== "string") return invalidField(`${path}.sourceLabel`);
- if (value.engineState !== undefined && !isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);
-
- if (value.packState === "queued" || value.packState === "analyzing") {
- const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel"], path);
- if (extraKey) return extraKey;
- if (!isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);
- } else if (value.packState === "ready") {
- const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "song"], path);
- if (extraKey) return extraKey;
- if (value.song === undefined) return invalidField(`${path}.song`);
- const songError = validateRehearsalSong(value.song, options);
- if (songError) return songError;
- } else {
- const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "error"], path);
- if (extraKey) return extraKey;
- if (value.error === undefined) return invalidField(`${path}.error`);
- const errorValidation = validateAnalysisJobError(value.error, `${path}.error`);
- if (errorValidation) return errorValidation;
- }
- return null;
-}
-
-/** Documented. */
-export function parseSongRehearsalPack(value: unknown): SongRehearsalPack {
- const validationError = validateSongRehearsalPack(value, "root", LEGACY_VALIDATION_OPTIONS);
- if (validationError) throw new Error(validationError);
- const parsed = structuredClone(value as SongRehearsalPack);
- if (parsed.packState === "ready") {
- parsed.song = parseRehearsalSong(parsed.song);
- }
- return parsed;
-}
-
-/** Documented. */
-function validateRehearsalWorkspace(
- value: unknown,
- options: ValidationOptions = STRICT_VALIDATION_OPTIONS
-): string | null {
- if (!isRecord(value)) return invalidField("root");
- const extraKey = unexpectedKey(value, ["id", "title", "songs", "workspaceVersion"], "");
- if (extraKey) return extraKey;
- if (typeof value.id !== "string") return invalidField("id");
- if (typeof value.title !== "string") return invalidField("title");
- if (typeof value.workspaceVersion !== "number") return invalidField("workspaceVersion");
- if (!isDenseArray(value.songs)) return invalidField("songs");
-
- for (const [index, song] of value.songs.entries()) {
- const packError = validateSongRehearsalPack(song, `songs[${index}]`, options);
- if (packError) return packError;
- }
- return null;
-}
-
-/** Documented. */
-export function isRehearsalWorkspace(value: unknown): value is RehearsalWorkspace {
- return validateRehearsalWorkspace(value) === null;
-}
-
-/** Documented. */
-export function parseRehearsalWorkspace(value: unknown): RehearsalWorkspace {
- const validationError = validateRehearsalWorkspace(value, LEGACY_VALIDATION_OPTIONS);
- if (validationError) throw new Error(validationError);
- const parsed = structuredClone(value as RehearsalWorkspace);
- parsed.songs = parsed.songs.map((pack) => (
- pack.packState === "ready"
- ? { ...pack, song: parseRehearsalSong(pack.song) }
- : pack
- ));
- return parsed;
-}
+export * from "./legacy-index";
+export {
+ isMetadataHandoffArtifact,
+ parseMetadataHandoffArtifact,
+ type MetadataHandoffArtifact,
+ type MetadataHandoffArtifactV1,
+ type MetadataHandoffArtifactV2,
+ type MetadataHandoffFirstAction
+} from "./metadata-handoff";
diff --git a/packages/shared-types/src/legacy-index.ts b/packages/shared-types/src/legacy-index.ts
new file mode 100644
index 000000000..cba4606a2
--- /dev/null
+++ b/packages/shared-types/src/legacy-index.ts
@@ -0,0 +1,1935 @@
+export /** Documented. */
+const SUPPORTED_AUDIO_FORMATS = ["wav", "mp3", "flac", "m4a"] as const;
+export /** Documented. */
+const SECTION_FORM_LABELS = [
+ "intro",
+ "verse",
+ "pre-chorus",
+ "chorus",
+ "bridge",
+ "outro",
+ "tag",
+ "pickup",
+ "stop",
+ "handoff"
+] as const;
+export /** Documented. */
+const MAX_SECTION_TIME_SECONDS = 4_294_967_295;
+
+/** Documented. */
+export type SectionFormLabel = (typeof SECTION_FORM_LABELS)[number];
+
+/** Documented. */
+export type ProjectSummary = {
+ id: string;
+ title: string;
+ status: "idle" | "running" | "done" | "failed";
+ supportedAudioFormats: readonly (typeof SUPPORTED_AUDIO_FORMATS)[number][];
+};
+
+/** Documented. */
+export type ConfidenceLevel = "low" | "medium" | "high";
+/** Documented. */
+export type ProvenanceSource = "model" | "user";
+/** Documented. */
+export type CueAnchorKind = "lyric" | "count" | "transition";
+/** Documented. */
+export type RehearsalPriority = "low" | "medium" | "high";
+/** Documented. */
+export type ExportFormat = "cue-sheet" | "chart-summary";
+/** Documented. */
+export type CollaborationAssignmentStatus = "todo" | "in_progress" | "ready" | "blocked";
+/** Documented. */
+export type CollaborationCommentStatus = "open" | "resolved";
+/** Documented. */
+export type CollaborationApprovalStatus = "pending" | "approved" | "changes_requested";
+/** Documented. */
+export type CollaborationSyncMode = "local_only" | "planned_cloud";
+
+/** Documented. */
+export type ConfidenceMarker = {
+ level: ConfidenceLevel;
+ source: ProvenanceSource;
+ notes: string;
+};
+
+/** Documented. */
+export type CueAnchor = {
+ kind: CueAnchorKind;
+ value: string;
+};
+
+/** Documented. */
+export type RangeSummary = {
+ lowestNote: string;
+ highestNote: string;
+};
+
+/** Documented. */
+export type TranscriptionNote = {
+ pitch: string;
+ onset: number;
+ offset: number;
+ velocity: number;
+};
+
+/** Documented. */
+export type RehearsalAssignment = {
+ id: string;
+ assignee: string;
+ summary: string;
+ sectionId: string;
+ roleId?: string;
+ status: CollaborationAssignmentStatus;
+};
+
+/** Documented. */
+export type RehearsalComment = {
+ id: string;
+ author: string;
+ body: string;
+ sectionId: string;
+ roleId?: string;
+ status: CollaborationCommentStatus;
+};
+
+/** Documented. */
+export type RehearsalApproval = {
+ id: string;
+ scope: string;
+ owner: string;
+ status: CollaborationApprovalStatus;
+};
+
+/** Documented. */
+export type RehearsalCollaboration = {
+ syncMode: CollaborationSyncMode;
+ syncNote: string;
+ assignments: RehearsalAssignment[];
+ comments: RehearsalComment[];
+ approvals: RehearsalApproval[];
+};
+
+/** Documented. */
+export type RehearsalHarmony = {
+ chord: string;
+ functionLabel: string;
+ source: ProvenanceSource;
+};
+
+/** Documented. */
+export type ManualOverride =
+ {
+ field: "harmony";
+ value: RehearsalHarmony & { source: "user" };
+ source: "user";
+ };
+
+/** Documented. */
+export type RehearsalRole = {
+ id: string;
+ name: string;
+ roleType: "instrument" | "vocal" | "hand";
+ harmony: RehearsalHarmony;
+ harmonicExplanation?: string;
+ cue: CueAnchor;
+ range: RangeSummary;
+ confidence: ConfidenceMarker;
+ rehearsalPriority: RehearsalPriority;
+ simplification: string;
+ setupNote: string;
+ transpositionPlan?: string;
+ manualOverrides: ManualOverride[];
+ overlapWarnings: string[];
+ transcription?: TranscriptionNote[];
+ practiceProgress?: number;
+};
+
+/** Documented. */
+export type PartGraphNode = {
+ role_id: string;
+ is_active: boolean;
+ handoff_to: string[];
+ handoff_from: string[];
+};
+
+/** Documented. */
+export type SectionTimeRange = {
+ start: number;
+ end: number;
+};
+
+/** Documented. */
+export type RehearsalSection = {
+ id: string;
+ label: SectionFormLabel;
+ groove: string;
+ timeRange: SectionTimeRange;
+ confidence: ConfidenceMarker;
+ roles: RehearsalRole[];
+ partGraph: PartGraphNode[];
+};
+
+/** Documented. */
+export type ExportSummary = {
+ format: ExportFormat;
+ headline: string;
+ focusSections: string[];
+};
+
+
+/** Documented. */
+export type PackState = "queued" | "analyzing" | "ready" | "failed";
+
+/** Documented. */
+export type SongRehearsalPack =
+ | {
+ id: string;
+ packState: "queued" | "analyzing";
+ engineState: AnalysisJobState;
+ sourceLabel: string;
+ }
+ | {
+ id: string;
+ packState: "ready";
+ engineState?: AnalysisJobState;
+ song: RehearsalSong;
+ sourceLabel: string;
+ }
+ | {
+ id: string;
+ packState: "failed";
+ engineState?: AnalysisJobState;
+ error: AnalysisJobError;
+ sourceLabel: string;
+ };
+
+/** Documented. */
+export type RehearsalWorkspace = {
+ id: string;
+ title: string;
+ songs: SongRehearsalPack[];
+ workspaceVersion: number;
+};
+
+/** Documented. */
+export type ScoreAttachment = {
+ id: string;
+ fileName: string;
+};
+
+/** Documented. */
+export type RehearsalSong = {
+ id: string;
+ title: string;
+ tempo?: number;
+ sections: RehearsalSection[];
+ exportSummary: ExportSummary;
+ collaboration?: RehearsalCollaboration;
+ scoreAttachments?: ScoreAttachment[];
+};
+
+/** Documented. */
+export type AnalysisSourceKind = "demo" | "local_audio";
+/** Documented. */
+export type AnalysisJobState = "queued" | "running" | "succeeded" | "failed";
+/** Documented. */
+export type AnalysisJobStage = "queued" | "decode" | "separate" | "analyze" | "persist" | "ready";
+/** Documented. */
+export type AnalysisCacheStatus = "disabled" | "miss" | "hit" | "stored";
+/** Documented. */
+export type AnalysisJobErrorCode = "invalid_request" | "not_found" | "engine_unavailable";
+
+/** Documented. */
+export type LocalAudioSource = {
+ sourcePath: string;
+ fileName: string;
+ extension: (typeof SUPPORTED_AUDIO_FORMATS)[number];
+ fileSizeBytes: number;
+};
+
+/** Documented. */
+export type ProjectBootstrapSummary = {
+ projectId: string;
+ sourceMode: "reference";
+ projectRoot: string;
+ cacheRoot: string;
+ tempRoot: string;
+ source: LocalAudioSource;
+};
+
+/** Documented. */
+export type MetadataHandoffSourceAsset = {
+ referenceKind: "local_audio";
+ sourceMode: "reference";
+ fileName: string;
+ extension: (typeof SUPPORTED_AUDIO_FORMATS)[number];
+ fileSizeBytes: number;
+ status: "referenced" | "missing";
+};
+
+/** Documented. */
+export type MetadataHandoffRoleBucket = {
+ id: string;
+ name: string;
+ roleType: RehearsalRole["roleType"];
+ confidence: ConfidenceMarker;
+ rehearsalPriority: RehearsalPriority;
+};
+
+/** Documented. */
+export type MetadataHandoffSection = {
+ id: string;
+ label: SectionFormLabel;
+ timeRange: SectionTimeRange;
+ confidence: ConfidenceMarker;
+ roleBuckets: MetadataHandoffRoleBucket[];
+};
+
+/** Documented. */
+export type MetadataHandoffArtifact = {
+ artifactKind: "bandscope.metadata-handoff";
+ artifactVersion: 1;
+ createdAt: string;
+ workspace: {
+ id: string;
+ title: string;
+ workspaceVersion: number;
+ };
+ song: {
+ id: string;
+ title: string;
+ exportSummary: ExportSummary;
+ };
+ sections: MetadataHandoffSection[];
+ sourceAssets: MetadataHandoffSourceAsset[];
+};
+
+/** Documented. */
+export type AnalysisJobRequest =
+ | {
+ sourceKind: "demo";
+ sourceLabel: string;
+ roleFocus: string[];
+ }
+ | {
+ sourceKind: "local_audio";
+ projectId: string;
+ sourceLabel: string;
+ roleFocus: string[];
+ };
+
+/** Documented. */
+export type AnalysisJobError = {
+ code: AnalysisJobErrorCode;
+ message: string;
+};
+
+/** Documented. */
+export type AnalysisJobStatus = {
+ jobId: string;
+ state: AnalysisJobState;
+ requestedAt: string;
+ updatedAt: string;
+ progressLabel?: string;
+ progressStage?: AnalysisJobStage;
+ progressPercent?: number;
+ cacheStatus?: AnalysisCacheStatus;
+ result?: RehearsalSong;
+ error?: AnalysisJobError;
+};
+
+/** Documented. */
+export type AnalysisJobSnapshot = {
+ jobId: string;
+ request: AnalysisJobRequest;
+ status: AnalysisJobStatus;
+ startedAt?: string;
+ finishedAt?: string;
+ error?: AnalysisJobError;
+ metadata?: Record;
+};
+
+const CONFIDENCE_LEVELS = ["low", "medium", "high"] as const;
+const PROJECT_STATUSES = ["idle", "running", "done", "failed"] as const;
+const REHEARSAL_PRIORITIES = ["low", "medium", "high"] as const;
+const PROVENANCE_SOURCES = ["model", "user"] as const;
+const CUE_ANCHOR_KINDS = ["lyric", "count", "transition"] as const;
+const ROLE_TYPES = ["instrument", "vocal", "hand"] as const;
+const EXPORT_FORMATS = ["cue-sheet", "chart-summary"] as const;
+const COLLABORATION_ASSIGNMENT_STATUSES = ["todo", "in_progress", "ready", "blocked"] as const;
+const COLLABORATION_COMMENT_STATUSES = ["open", "resolved"] as const;
+const COLLABORATION_APPROVAL_STATUSES = ["pending", "approved", "changes_requested"] as const;
+const COLLABORATION_SYNC_MODES = ["local_only", "planned_cloud"] as const;
+const ANALYSIS_SOURCE_KINDS = ["demo", "local_audio"] as const;
+const ANALYSIS_JOB_STATES = ["queued", "running", "succeeded", "failed"] as const;
+const ANALYSIS_JOB_STAGES = ["queued", "decode", "separate", "analyze", "persist", "ready"] as const;
+const ANALYSIS_CACHE_STATUSES = ["disabled", "miss", "hit", "stored"] as const;
+const ANALYSIS_JOB_ERROR_CODES = ["invalid_request", "not_found", "engine_unavailable"] as const;
+const PACK_STATES = ["queued", "analyzing", "ready", "failed"] as const;
+const HANDOFF_ASSET_STATUSES = ["referenced", "missing"] as const;
+
+type ValidationOptions = {
+ acceptLegacySectionTimeRanges: boolean;
+};
+
+const STRICT_VALIDATION_OPTIONS: ValidationOptions = {
+ acceptLegacySectionTimeRanges: false
+};
+
+const LEGACY_VALIDATION_OPTIONS: ValidationOptions = {
+ acceptLegacySectionTimeRanges: true
+};
+
+/** Documented. */
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Documented. */
+function isDenseArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) return false;
+ // Performance: avoid Array.from() allocation while keeping a one-time length boundary.
+ const arrayLength = Number(value.length);
+ if (!Number.isSafeInteger(arrayLength) || arrayLength < 0 || arrayLength > 0xffffffff) {
+ return false;
+ }
+ for (let i = 0; i < arrayLength; i++) {
+ if (!(i in value)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** Documented. */
+function isOneOf(options: readonly T[], value: unknown): value is T {
+ return typeof value === "string" && options.includes(value as T);
+}
+
+/** Documented. */
+function invalidField(path: string): string {
+ return `Invalid rehearsal song contract: invalid field '${path}'`;
+}
+
+/** Documented. */
+function unexpectedKey(value: Record, allowedKeys: readonly string[], path: string): string | null {
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.includes(key)) {
+ return invalidField(path ? `${path}.${key}` : key);
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function invalidProjectSummaryField(path: string): string {
+ return `Invalid project summary contract: invalid field '${path}'`;
+}
+
+const demoRehearsalSongSeed: RehearsalSong = {
+ id: "demo-song",
+ title: "Late Night Set",
+ tempo: 120,
+ sections: [
+ {
+ id: "verse-1",
+ label: "verse",
+ groove: "Straight eighths with a late snare feel",
+ timeRange: {
+ start: 10,
+ end: 30
+ },
+ confidence: {
+ level: "medium",
+ source: "model",
+ notes: "Double-check the pickup into the chorus."
+ },
+ roles: [
+ {
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ roleType: "instrument",
+ harmony: {
+ chord: "C#m7",
+ functionLabel: "vi pedal anchor",
+ source: "model"
+ },
+ harmonicExplanation: "The bass holds the vi center so the rest of the section can lean into the pickup without losing the tonal floor.",
+ cue: {
+ kind: "transition",
+ value: "Hold through the pickup before the downbeat.",
+ },
+ range: {
+ lowestNote: "C#2",
+ highestNote: "E3"
+ },
+ confidence: {
+ level: "medium",
+ source: "model",
+ notes: "Watch the slide into the turnaround."
+ },
+ rehearsalPriority: "high",
+ simplification: "Stay on roots if the chorus entrance gets muddy.",
+ setupNote: "Keep the attack short so the verse breathes.",
+ transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.",
+ manualOverrides: [],
+ overlapWarnings: [
+ "Density warning: competing with Keyboard Left Hand in low register."
+ ]
+ },
+ {
+ id: "keys-right",
+ name: "Keyboard 1 Right Hand",
+ roleType: "hand",
+ harmony: {
+ chord: "Emaj7",
+ functionLabel: "Imaj7 color",
+ source: "model"
+ },
+ harmonicExplanation: "The right hand supplies the major-7 color that distinguishes the verse from the more open chorus voicing.",
+ cue: {
+ kind: "count",
+ value: "Enter on beat 2 after the pickup."
+ },
+ range: {
+ lowestNote: "B3",
+ highestNote: "G#5"
+ },
+ confidence: {
+ level: "medium",
+ source: "model",
+ notes: "Top note voicing may need a quick ear check."
+ },
+ rehearsalPriority: "high",
+ simplification: "Drop the top extension if the chorus turnaround still feels busy.",
+ setupNote: "Keep the patch bright enough to stay over the guitars.",
+ transpositionPlan: "If the band rehearses in D, keep the voicing in first inversion so the top line still sings.",
+ manualOverrides: [],
+ overlapWarnings: [
+ "Melodic overlap: top notes conflict with Lead Vocal range."
+ ]
+ },
+ {
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ roleType: "vocal",
+ harmony: {
+ chord: "C#m7",
+ functionLabel: "vi melodic pull",
+ source: "model"
+ },
+ harmonicExplanation: "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.",
+ cue: {
+ kind: "lyric",
+ value: "city lights"
+ },
+ range: {
+ lowestNote: "G#3",
+ highestNote: "C#5"
+ },
+ confidence: {
+ level: "high",
+ source: "user",
+ notes: "Singer confirmed the pickup phrasing in rehearsal notes."
+ },
+ rehearsalPriority: "medium",
+ simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.",
+ setupNote: "Watch the breath before the last line of the verse.",
+ transpositionPlan: "If the room wants more ease, move the section down a whole step and keep the pickup breath mark in the same place.",
+ manualOverrides: [
+ {
+ field: "harmony",
+ value: {
+ chord: "C#m11",
+ functionLabel: "vi suspended lift",
+ source: "user"
+ },
+ source: "user"
+ }
+ ],
+ overlapWarnings: [
+ "Melodic overlap: competing with Keyboard 1 Right Hand."
+ ]
+ }
+ ],
+ partGraph: [
+ { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] },
+ { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] },
+ { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] }
+ ]
+ }
+ ],
+ exportSummary: {
+ format: "cue-sheet",
+ headline: "Start with verse entrances before the chorus lift.",
+ focusSections: ["verse"]
+ },
+ collaboration: {
+ syncMode: "planned_cloud",
+ syncNote: "Keep assignments local for now. Cloud sync opens only after security and conflict-resolution rules land.",
+ assignments: [
+ {
+ id: "assign-bass-entrance",
+ assignee: "Rhythm Section",
+ summary: "Lock the bass entrance against the pickup so the chorus lift lands together.",
+ sectionId: "verse-1",
+ roleId: "bass-guitar",
+ status: "in_progress"
+ },
+ {
+ id: "assign-vocal-key",
+ assignee: "Lead Vocal",
+ summary: "Confirm whether the verse should stay in C# minor or move down for the first rehearsal pass.",
+ sectionId: "verse-1",
+ roleId: "lead-vocal",
+ status: "todo"
+ }
+ ],
+ comments: [
+ {
+ id: "comment-keys-color",
+ author: "MD",
+ body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.",
+ sectionId: "verse-1",
+ roleId: "keys-right",
+ status: "open"
+ },
+ {
+ id: "comment-vocal-breath",
+ author: "Lead Vocal",
+ body: "Breath mark is confirmed before the last lyric pickup.",
+ sectionId: "verse-1",
+ roleId: "lead-vocal",
+ status: "resolved"
+ }
+ ],
+ approvals: [
+ {
+ id: "approval-harmony-pass",
+ scope: "Verse harmony pass",
+ owner: "MD",
+ status: "pending"
+ },
+ {
+ id: "approval-vocal-shape",
+ scope: "Lead vocal simplification",
+ owner: "Lead Vocal",
+ status: "approved"
+ }
+ ]
+ }
+};
+
+/** Documented. */
+export function createDefaultProjectSummary(input: {
+ id: string;
+ title: string;
+}): ProjectSummary {
+ return {
+ id: input.id,
+ title: input.title,
+ status: "idle",
+ supportedAudioFormats: SUPPORTED_AUDIO_FORMATS
+ };
+}
+
+/** Documented. */
+export function validateProjectSummary(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return invalidProjectSummaryField("root");
+ }
+ const allowedKeys = ["id", "title", "status", "supportedAudioFormats"] as const;
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
+ return invalidProjectSummaryField(key);
+ }
+ }
+ if (typeof value.id !== "string" || value.id.trim().length === 0) {
+ return invalidProjectSummaryField("id");
+ }
+ if (typeof value.title !== "string" || value.title.trim().length === 0) {
+ return invalidProjectSummaryField("title");
+ }
+ if (!isOneOf(PROJECT_STATUSES, value.status)) {
+ return invalidProjectSummaryField("status");
+ }
+ if (!isDenseArray(value.supportedAudioFormats)) {
+ return invalidProjectSummaryField("supportedAudioFormats");
+ }
+ for (const [index, format] of value.supportedAudioFormats.entries()) {
+ if (!isOneOf(SUPPORTED_AUDIO_FORMATS, format)) {
+ return invalidProjectSummaryField(`supportedAudioFormats[${index}]`);
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function isProjectSummary(value: unknown): value is ProjectSummary {
+ return validateProjectSummary(value) === null;
+}
+
+/** Documented. */
+export function parseProjectSummary(value: unknown): ProjectSummary {
+ const validationError = validateProjectSummary(value);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(value as ProjectSummary);
+}
+
+/** Documented. */
+export function createDemoRehearsalSong(): RehearsalSong {
+ return structuredClone(demoRehearsalSongSeed);
+}
+
+/** Documented. */
+export function createDemoAnalysisJobRequest(): AnalysisJobRequest {
+ return {
+ sourceKind: "demo",
+ sourceLabel: demoRehearsalSongSeed.title,
+ roleFocus: demoRehearsalSongSeed.sections[0].roles.map((role) => role.id)
+ };
+}
+
+/** Documented. */
+export function createProjectBootstrapSummary(input: {
+ projectId: string;
+ projectRoot: string;
+ cacheRoot: string;
+ tempRoot: string;
+ source: LocalAudioSource;
+}): ProjectBootstrapSummary {
+ return {
+ projectId: input.projectId,
+ sourceMode: "reference",
+ projectRoot: input.projectRoot,
+ cacheRoot: input.cacheRoot,
+ tempRoot: input.tempRoot,
+ source: input.source
+ };
+}
+
+/** Documented. */
+function validateProjectBootstrapSummary(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return "Invalid project bootstrap summary: invalid field 'root'";
+ }
+ const allowedKeys = ["projectId", "sourceMode", "projectRoot", "cacheRoot", "tempRoot", "source"] as const;
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
+ return `Invalid project bootstrap summary: invalid field '${key}'`;
+ }
+ }
+ if (typeof value.projectId !== "string" || value.projectId.trim().length === 0) {
+ return "Invalid project bootstrap summary: invalid field 'projectId'";
+ }
+ if (value.sourceMode !== "reference") {
+ return "Invalid project bootstrap summary: invalid field 'sourceMode'";
+ }
+ if (typeof value.projectRoot !== "string" || value.projectRoot.trim().length === 0) {
+ return "Invalid project bootstrap summary: invalid field 'projectRoot'";
+ }
+ if (typeof value.cacheRoot !== "string" || value.cacheRoot.trim().length === 0) {
+ return "Invalid project bootstrap summary: invalid field 'cacheRoot'";
+ }
+ if (typeof value.tempRoot !== "string" || value.tempRoot.trim().length === 0) {
+ return "Invalid project bootstrap summary: invalid field 'tempRoot'";
+ }
+ const sourceError = validateLocalAudioSource(value.source);
+ if (sourceError) {
+ return sourceError.replace("Invalid local audio source", "Invalid project bootstrap summary.source");
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function parseProjectBootstrapSummary(value: unknown): ProjectBootstrapSummary {
+ const validationError = validateProjectBootstrapSummary(value);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(value as ProjectBootstrapSummary);
+}
+
+/** Documented. */
+function validateMetadataHandoffSourceAsset(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["referenceKind", "sourceMode", "fileName", "extension", "fileSizeBytes", "status"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (value.referenceKind !== "local_audio") {
+ return invalidField(`${path}.referenceKind`);
+ }
+ if (value.sourceMode !== "reference") {
+ return invalidField(`${path}.sourceMode`);
+ }
+ if (typeof value.fileName !== "string" || value.fileName.trim().length === 0) {
+ return invalidField(`${path}.fileName`);
+ }
+ if (!isOneOf(SUPPORTED_AUDIO_FORMATS, value.extension)) {
+ return invalidField(`${path}.extension`);
+ }
+ if (typeof value.fileSizeBytes !== "number" || !Number.isFinite(value.fileSizeBytes) || value.fileSizeBytes <= 0) {
+ return invalidField(`${path}.fileSizeBytes`);
+ }
+ if (!isOneOf(HANDOFF_ASSET_STATUSES, value.status)) {
+ return invalidField(`${path}.status`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateMetadataHandoffRoleBucket(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "name", "roleType", "confidence", "rehearsalPriority"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.name !== "string") {
+ return invalidField(`${path}.name`);
+ }
+ if (!isOneOf(ROLE_TYPES, value.roleType)) {
+ return invalidField(`${path}.roleType`);
+ }
+ const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
+ if (confidenceError) {
+ return confidenceError;
+ }
+ if (!isOneOf(REHEARSAL_PRIORITIES, value.rehearsalPriority)) {
+ return invalidField(`${path}.rehearsalPriority`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateMetadataHandoffSection(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "label", "timeRange", "confidence", "roleBuckets"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (!isOneOf(SECTION_FORM_LABELS, value.label)) {
+ return invalidField(`${path}.label`);
+ }
+ const timeRangeError = validateSectionTimeRange(value.timeRange, `${path}.timeRange`);
+ if (timeRangeError) {
+ return timeRangeError;
+ }
+ const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
+ if (confidenceError) {
+ return confidenceError;
+ }
+ if (!isDenseArray(value.roleBuckets)) {
+ return invalidField(`${path}.roleBuckets`);
+ }
+ for (const [index, role] of value.roleBuckets.entries()) {
+ const roleError = validateMetadataHandoffRoleBucket(role, `${path}.roleBuckets[${index}]`);
+ if (roleError) {
+ return roleError;
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateMetadataHandoffArtifact(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return invalidField("root");
+ }
+ const extraKey = unexpectedKey(
+ value,
+ ["artifactKind", "artifactVersion", "createdAt", "workspace", "song", "sections", "sourceAssets"],
+ ""
+ );
+ if (extraKey) {
+ return extraKey;
+ }
+ if (value.artifactKind !== "bandscope.metadata-handoff") {
+ return invalidField("artifactKind");
+ }
+ if (value.artifactVersion !== 1) {
+ return invalidField("artifactVersion");
+ }
+ if (typeof value.createdAt !== "string" || value.createdAt.trim().length === 0) {
+ return invalidField("createdAt");
+ }
+ if (!isRecord(value.workspace)) {
+ return invalidField("workspace");
+ }
+ const workspaceExtraKey = unexpectedKey(value.workspace, ["id", "title", "workspaceVersion"], "workspace");
+ if (workspaceExtraKey) {
+ return workspaceExtraKey;
+ }
+ if (typeof value.workspace.id !== "string") {
+ return invalidField("workspace.id");
+ }
+ if (typeof value.workspace.title !== "string") {
+ return invalidField("workspace.title");
+ }
+ if (
+ typeof value.workspace.workspaceVersion !== "number" ||
+ !Number.isInteger(value.workspace.workspaceVersion) ||
+ value.workspace.workspaceVersion < 1
+ ) {
+ return invalidField("workspace.workspaceVersion");
+ }
+ if (!isRecord(value.song)) {
+ return invalidField("song");
+ }
+ const songExtraKey = unexpectedKey(value.song, ["id", "title", "exportSummary"], "song");
+ if (songExtraKey) {
+ return songExtraKey;
+ }
+ if (typeof value.song.id !== "string") {
+ return invalidField("song.id");
+ }
+ if (typeof value.song.title !== "string") {
+ return invalidField("song.title");
+ }
+ const exportSummaryError = validateExportSummary(value.song.exportSummary, "song.exportSummary");
+ if (exportSummaryError) {
+ return exportSummaryError;
+ }
+ if (!isDenseArray(value.sections)) {
+ return invalidField("sections");
+ }
+ for (const [index, section] of value.sections.entries()) {
+ const sectionError = validateMetadataHandoffSection(section, `sections[${index}]`);
+ if (sectionError) {
+ return sectionError;
+ }
+ }
+ if (!isDenseArray(value.sourceAssets)) {
+ return invalidField("sourceAssets");
+ }
+ for (const [index, sourceAsset] of value.sourceAssets.entries()) {
+ const sourceAssetError = validateMetadataHandoffSourceAsset(sourceAsset, `sourceAssets[${index}]`);
+ if (sourceAssetError) {
+ return sourceAssetError;
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function isMetadataHandoffArtifact(value: unknown): value is MetadataHandoffArtifact {
+ return validateMetadataHandoffArtifact(value) === null;
+}
+
+/** Documented. */
+export function parseMetadataHandoffArtifact(value: unknown): MetadataHandoffArtifact {
+ const validationError = validateMetadataHandoffArtifact(value);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(value as MetadataHandoffArtifact);
+}
+
+/** Documented. */
+function validateLocalAudioSource(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return "Invalid local audio source: invalid field 'root'";
+ }
+ const allowedKeys = ["sourcePath", "fileName", "extension", "fileSizeBytes"] as const;
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.includes(key as (typeof allowedKeys)[number])) {
+ return `Invalid local audio source: invalid field '${key}'`;
+ }
+ }
+ if (typeof value.sourcePath !== "string" || value.sourcePath.trim().length === 0) {
+ return "Invalid local audio source: invalid field 'sourcePath'";
+ }
+ if (typeof value.fileName !== "string" || value.fileName.trim().length === 0) {
+ return "Invalid local audio source: invalid field 'fileName'";
+ }
+ if (!isOneOf(SUPPORTED_AUDIO_FORMATS, value.extension)) {
+ return "Invalid local audio source: invalid field 'extension'";
+ }
+ if (typeof value.fileSizeBytes !== "number" || !Number.isFinite(value.fileSizeBytes) || value.fileSizeBytes <= 0) {
+ return "Invalid local audio source: invalid field 'fileSizeBytes'";
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function parseLocalAudioSource(value: unknown): LocalAudioSource {
+ const validationError = validateLocalAudioSource(value);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(value as LocalAudioSource);
+}
+
+/** Documented. */
+export function createAnalysisJobStatus(input:
+ | {
+ jobId: string;
+ state: "queued" | "running";
+ progressLabel?: string;
+ progressStage?: AnalysisJobStage;
+ progressPercent?: number;
+ cacheStatus?: AnalysisCacheStatus;
+ requestedAt?: string;
+ updatedAt?: string;
+ }
+ | {
+ jobId: string;
+ state: "succeeded";
+ result: RehearsalSong;
+ progressLabel?: string;
+ progressStage?: AnalysisJobStage;
+ progressPercent?: number;
+ cacheStatus?: AnalysisCacheStatus;
+ requestedAt?: string;
+ updatedAt?: string;
+ }
+ | {
+ jobId: string;
+ state: "failed";
+ error: AnalysisJobError;
+ progressLabel?: string;
+ progressStage?: AnalysisJobStage;
+ progressPercent?: number;
+ cacheStatus?: AnalysisCacheStatus;
+ requestedAt?: string;
+ updatedAt?: string;
+ }
+): AnalysisJobStatus {
+ const now = new Date().toISOString();
+ const status: AnalysisJobStatus = {
+ jobId: input.jobId,
+ state: input.state,
+ requestedAt: input.requestedAt ?? now,
+ updatedAt: input.updatedAt ?? now,
+ };
+
+ if (input.progressLabel !== undefined) {
+ status.progressLabel = input.progressLabel;
+ }
+ if (input.progressStage !== undefined) {
+ status.progressStage = input.progressStage;
+ }
+ if (input.progressPercent !== undefined) {
+ status.progressPercent = input.progressPercent;
+ }
+ if (input.cacheStatus !== undefined) {
+ status.cacheStatus = input.cacheStatus;
+ }
+ if ("result" in input) {
+ status.result = input.result;
+ }
+ if ("error" in input) {
+ status.error = input.error;
+ }
+
+ return status;
+}
+
+/** Documented. */
+function validateAnalysisJobRequest(value: unknown): string | null {
+ if (!isRecord(value)) {
+ return "Invalid analysis job request: invalid field 'root'";
+ }
+ if (!isOneOf(ANALYSIS_SOURCE_KINDS, value.sourceKind)) {
+ return "Invalid analysis job request: invalid field 'sourceKind'";
+ }
+ if (typeof value.sourceLabel !== "string" || value.sourceLabel.trim().length === 0) {
+ return "Invalid analysis job request: invalid field 'sourceLabel'";
+ }
+ if (!isDenseArray(value.roleFocus)) {
+ return "Invalid analysis job request: invalid field 'roleFocus'";
+ }
+ for (const [index, role] of value.roleFocus.entries()) {
+ if (typeof role !== "string") {
+ return `Invalid analysis job request: invalid field 'roleFocus[${index}]'`;
+ }
+ }
+ const allowedKeys = new Set(
+ value.sourceKind === "local_audio"
+ ? ["sourceKind", "projectId", "sourceLabel", "roleFocus"]
+ : ["sourceKind", "sourceLabel", "roleFocus"]
+ );
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.has(key)) {
+ return `Invalid analysis job request: invalid field '${key}'`;
+ }
+ }
+ if (value.sourceKind === "local_audio") {
+ if (typeof value.projectId !== "string" || value.projectId.trim().length === 0) {
+ return "Invalid analysis job request: invalid field 'projectId'";
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function parseAnalysisJobRequest(value: unknown): AnalysisJobRequest {
+ const validationError = validateAnalysisJobRequest(value);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(value as AnalysisJobRequest);
+}
+
+/** Documented. */
+function validateAnalysisJobError(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["code", "message"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (!isOneOf(ANALYSIS_JOB_ERROR_CODES, value.code)) {
+ return invalidField(`${path}.code`);
+ }
+ if (typeof value.message !== "string") {
+ return invalidField(`${path}.message`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateAnalysisJobStatus(
+ value: unknown,
+ options: ValidationOptions = STRICT_VALIDATION_OPTIONS
+): string | null {
+ if (!isRecord(value)) {
+ return invalidField("root");
+ }
+ const allowedKeysByState: Record = {
+ queued: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus"],
+ running: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus"],
+ succeeded: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus", "result"],
+ failed: ["jobId", "state", "requestedAt", "updatedAt", "progressLabel", "progressStage", "progressPercent", "cacheStatus", "error"]
+ };
+ if (typeof value.jobId !== "string") {
+ return invalidField("jobId");
+ }
+ if (!isOneOf(ANALYSIS_JOB_STATES, value.state)) {
+ return invalidField("state");
+ }
+ const extraKey = unexpectedKey(value, allowedKeysByState[value.state], "");
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.requestedAt !== "string") {
+ return invalidField("requestedAt");
+ }
+ if (typeof value.updatedAt !== "string") {
+ return invalidField("updatedAt");
+ }
+ if (value.progressLabel !== undefined && typeof value.progressLabel !== "string") {
+ return invalidField("progressLabel");
+ }
+ if (value.progressStage !== undefined && !isOneOf(ANALYSIS_JOB_STAGES, value.progressStage)) {
+ return invalidField("progressStage");
+ }
+ if (
+ value.progressPercent !== undefined &&
+ (
+ typeof value.progressPercent !== "number" ||
+ !Number.isInteger(value.progressPercent) ||
+ value.progressPercent < 0 ||
+ value.progressPercent > 100
+ )
+ ) {
+ return invalidField("progressPercent");
+ }
+ if (value.cacheStatus !== undefined && !isOneOf(ANALYSIS_CACHE_STATUSES, value.cacheStatus)) {
+ return invalidField("cacheStatus");
+ }
+ if (value.result !== undefined) {
+ const resultError = validateRehearsalSong(value.result, options);
+ if (resultError) {
+ return resultError;
+ }
+ }
+ if (value.error !== undefined) {
+ const errorValidation = validateAnalysisJobError(value.error, "error");
+ if (errorValidation) {
+ return errorValidation;
+ }
+ }
+ if (value.state === "succeeded" && value.result === undefined) {
+ return invalidField("result");
+ }
+ if (value.state === "failed" && value.error === undefined) {
+ return invalidField("error");
+ }
+
+ return null;
+}
+
+/** Documented. */
+export function isAnalysisJobStatus(value: unknown): value is AnalysisJobStatus {
+ return validateAnalysisJobStatus(value) === null;
+}
+
+/** Documented. */
+export function parseAnalysisJobStatus(value: unknown): AnalysisJobStatus {
+ const validationError = validateAnalysisJobStatus(value, LEGACY_VALIDATION_OPTIONS);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ const parsed = structuredClone(value as AnalysisJobStatus);
+ if (parsed.result !== undefined) {
+ parsed.result = parseRehearsalSong(parsed.result);
+ }
+ return parsed;
+}
+
+/** Documented. */
+function validateConfidenceMarker(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["level", "source", "notes"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (!isOneOf(CONFIDENCE_LEVELS, value.level)) {
+ return invalidField(`${path}.level`);
+ }
+ if (!isOneOf(PROVENANCE_SOURCES, value.source)) {
+ return invalidField(`${path}.source`);
+ }
+ if (typeof value.notes !== "string") {
+ return invalidField(`${path}.notes`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateCueAnchor(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["kind", "value"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (!isOneOf(CUE_ANCHOR_KINDS, value.kind)) {
+ return invalidField(`${path}.kind`);
+ }
+ if (typeof value.value !== "string") {
+ return invalidField(`${path}.value`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRangeSummary(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["lowestNote", "highestNote"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.lowestNote !== "string") {
+ return invalidField(`${path}.lowestNote`);
+ }
+ if (typeof value.highestNote !== "string") {
+ return invalidField(`${path}.highestNote`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalHarmony(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["chord", "functionLabel", "source"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.chord !== "string") {
+ return invalidField(`${path}.chord`);
+ }
+ if (typeof value.functionLabel !== "string") {
+ return invalidField(`${path}.functionLabel`);
+ }
+ if (!isOneOf(PROVENANCE_SOURCES, value.source)) {
+ return invalidField(`${path}.source`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalAssignment(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "assignee", "summary", "sectionId", "roleId", "status"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.assignee !== "string") {
+ return invalidField(`${path}.assignee`);
+ }
+ if (typeof value.summary !== "string") {
+ return invalidField(`${path}.summary`);
+ }
+ if (typeof value.sectionId !== "string") {
+ return invalidField(`${path}.sectionId`);
+ }
+ if (value.roleId !== undefined && typeof value.roleId !== "string") {
+ return invalidField(`${path}.roleId`);
+ }
+ if (!isOneOf(COLLABORATION_ASSIGNMENT_STATUSES, value.status)) {
+ return invalidField(`${path}.status`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalComment(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "author", "body", "sectionId", "roleId", "status"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.author !== "string") {
+ return invalidField(`${path}.author`);
+ }
+ if (typeof value.body !== "string") {
+ return invalidField(`${path}.body`);
+ }
+ if (typeof value.sectionId !== "string") {
+ return invalidField(`${path}.sectionId`);
+ }
+ if (value.roleId !== undefined && typeof value.roleId !== "string") {
+ return invalidField(`${path}.roleId`);
+ }
+ if (!isOneOf(COLLABORATION_COMMENT_STATUSES, value.status)) {
+ return invalidField(`${path}.status`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalApproval(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "scope", "owner", "status"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.scope !== "string") {
+ return invalidField(`${path}.scope`);
+ }
+ if (typeof value.owner !== "string") {
+ return invalidField(`${path}.owner`);
+ }
+ if (!isOneOf(COLLABORATION_APPROVAL_STATUSES, value.status)) {
+ return invalidField(`${path}.status`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalCollaboration(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["syncMode", "syncNote", "assignments", "comments", "approvals"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (!isOneOf(COLLABORATION_SYNC_MODES, value.syncMode)) {
+ return invalidField(`${path}.syncMode`);
+ }
+ if (typeof value.syncNote !== "string") {
+ return invalidField(`${path}.syncNote`);
+ }
+ if (!isDenseArray(value.assignments)) {
+ return invalidField(`${path}.assignments`);
+ }
+ for (const [index, assignment] of value.assignments.entries()) {
+ const assignmentError = validateRehearsalAssignment(assignment, `${path}.assignments[${index}]`);
+ if (assignmentError) {
+ return assignmentError;
+ }
+ }
+ if (!isDenseArray(value.comments)) {
+ return invalidField(`${path}.comments`);
+ }
+ for (const [index, comment] of value.comments.entries()) {
+ const commentError = validateRehearsalComment(comment, `${path}.comments[${index}]`);
+ if (commentError) {
+ return commentError;
+ }
+ }
+ if (!isDenseArray(value.approvals)) {
+ return invalidField(`${path}.approvals`);
+ }
+ for (const [index, approval] of value.approvals.entries()) {
+ const approvalError = validateRehearsalApproval(approval, `${path}.approvals[${index}]`);
+ if (approvalError) {
+ return approvalError;
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateManualOverride(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["field", "value", "source"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (value.field !== "harmony") {
+ return invalidField(`${path}.field`);
+ }
+ if (value.source !== "user") {
+ return invalidField(`${path}.source`);
+ }
+
+ const harmonyError = validateRehearsalHarmony(value.value, `${path}.value`);
+ if (harmonyError) {
+ return harmonyError;
+ }
+ const harmonyValue = value.value as RehearsalHarmony;
+ if (harmonyValue.source !== "user") {
+ return invalidField(`${path}.value.source`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateTranscriptionNote(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["pitch", "onset", "offset", "velocity"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.pitch !== "string") {
+ return invalidField(`${path}.pitch`);
+ }
+ if (typeof value.onset !== "number") {
+ return invalidField(`${path}.onset`);
+ }
+ if (typeof value.offset !== "number") {
+ return invalidField(`${path}.offset`);
+ }
+ if (typeof value.velocity !== "number") {
+ return invalidField(`${path}.velocity`);
+ }
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalRole(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(
+ value,
+ [
+ "id",
+ "name",
+ "roleType",
+ "harmony",
+ "harmonicExplanation",
+ "cue",
+ "range",
+ "confidence",
+ "rehearsalPriority",
+ "simplification",
+ "setupNote",
+ "transpositionPlan",
+ "manualOverrides",
+ "overlapWarnings",
+ "transcription",
+ "practiceProgress"
+ ],
+ path
+ );
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.name !== "string") {
+ return invalidField(`${path}.name`);
+ }
+ if (!isOneOf(ROLE_TYPES, value.roleType)) {
+ return invalidField(`${path}.roleType`);
+ }
+
+ const harmonyError = validateRehearsalHarmony(value.harmony, `${path}.harmony`);
+ if (harmonyError) {
+ return harmonyError;
+ }
+ if (value.harmonicExplanation !== undefined && typeof value.harmonicExplanation !== "string") {
+ return invalidField(`${path}.harmonicExplanation`);
+ }
+
+ const cueError = validateCueAnchor(value.cue, `${path}.cue`);
+ if (cueError) {
+ return cueError;
+ }
+
+ const rangeError = validateRangeSummary(value.range, `${path}.range`);
+ if (rangeError) {
+ return rangeError;
+ }
+
+ const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
+ if (confidenceError) {
+ return confidenceError;
+ }
+
+ if (!isOneOf(REHEARSAL_PRIORITIES, value.rehearsalPriority)) {
+ return invalidField(`${path}.rehearsalPriority`);
+ }
+ if (typeof value.simplification !== "string") {
+ return invalidField(`${path}.simplification`);
+ }
+ if (typeof value.setupNote !== "string") {
+ return invalidField(`${path}.setupNote`);
+ }
+ if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") {
+ return invalidField(`${path}.transpositionPlan`);
+ }
+ if (!isDenseArray(value.manualOverrides)) {
+ return invalidField(`${path}.manualOverrides`);
+ }
+ for (const [index, override] of value.manualOverrides.entries()) {
+ const overrideError = validateManualOverride(override, `${path}.manualOverrides[${index}]`);
+ if (overrideError) {
+ return overrideError;
+ }
+ }
+ if (!isDenseArray(value.overlapWarnings)) {
+ return invalidField(`${path}.overlapWarnings`);
+ }
+ for (const [index, warning] of value.overlapWarnings.entries()) {
+ if (typeof warning !== "string") {
+ return invalidField(`${path}.overlapWarnings[${index}]`);
+ }
+ }
+
+ if (value.transcription !== undefined) {
+ if (!isDenseArray(value.transcription)) {
+ return invalidField(`${path}.transcription`);
+ }
+ for (const [index, note] of value.transcription.entries()) {
+ const noteError = validateTranscriptionNote(note, `${path}.transcription[${index}]`);
+ if (noteError) {
+ return noteError;
+ }
+ }
+ }
+
+ if (value.practiceProgress !== undefined) {
+ if (typeof value.practiceProgress !== "number" || !Number.isFinite(value.practiceProgress) || !Number.isInteger(value.practiceProgress) || value.practiceProgress < 0 || value.practiceProgress > 100) {
+ return invalidField(`${path}.practiceProgress`);
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validatePartGraphNode(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["role_id", "is_active", "handoff_to", "handoff_from"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.role_id !== "string") {
+ return invalidField(`${path}.role_id`);
+ }
+ if (typeof value.is_active !== "boolean") {
+ return invalidField(`${path}.is_active`);
+ }
+ if (!isDenseArray(value.handoff_to)) {
+ return invalidField(`${path}.handoff_to`);
+ }
+ for (const [index, handoff] of value.handoff_to.entries()) {
+ if (typeof handoff !== "string") {
+ return invalidField(`${path}.handoff_to[${index}]`);
+ }
+ }
+ if (!isDenseArray(value.handoff_from)) {
+ return invalidField(`${path}.handoff_from`);
+ }
+ for (const [index, handoff] of value.handoff_from.entries()) {
+ if (typeof handoff !== "string") {
+ return invalidField(`${path}.handoff_from[${index}]`);
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateSectionTimeRange(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["start", "end"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.start !== "number" || !Number.isFinite(value.start) || !Number.isInteger(value.start) || value.start < 0 || value.start > MAX_SECTION_TIME_SECONDS) {
+ return invalidField(`${path}.start`);
+ }
+ if (typeof value.end !== "number" || !Number.isFinite(value.end) || !Number.isInteger(value.end) || value.end <= value.start || value.end > MAX_SECTION_TIME_SECONDS) {
+ return invalidField(`${path}.end`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalSection(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "label", "groove", "timeRange", "confidence", "roles", "partGraph"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string") {
+ return invalidField(`${path}.id`);
+ }
+ if (!isOneOf(SECTION_FORM_LABELS, value.label)) {
+ return invalidField(`${path}.label`);
+ }
+ if (typeof value.groove !== "string") {
+ return invalidField(`${path}.groove`);
+ }
+
+ const timeRangeError = validateSectionTimeRange(value.timeRange, `${path}.timeRange`);
+ if (timeRangeError) {
+ return timeRangeError;
+ }
+
+ const confidenceError = validateConfidenceMarker(value.confidence, `${path}.confidence`);
+ if (confidenceError) {
+ return confidenceError;
+ }
+
+ if (!isDenseArray(value.roles)) {
+ return invalidField(`${path}.roles`);
+ }
+ for (const [index, role] of value.roles.entries()) {
+ const roleError = validateRehearsalRole(role, `${path}.roles[${index}]`);
+ if (roleError) {
+ return roleError;
+ }
+ }
+
+ if (!isDenseArray(value.partGraph)) {
+ return invalidField(`${path}.partGraph`);
+ }
+ for (const [index, node] of value.partGraph.entries()) {
+ const nodeError = validatePartGraphNode(node, `${path}.partGraph[${index}]`);
+ if (nodeError) {
+ return nodeError;
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateExportSummary(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["format", "headline", "focusSections"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (!isOneOf(EXPORT_FORMATS, value.format)) {
+ return invalidField(`${path}.format`);
+ }
+ if (typeof value.headline !== "string") {
+ return invalidField(`${path}.headline`);
+ }
+ if (!isDenseArray(value.focusSections)) {
+ return invalidField(`${path}.focusSections`);
+ }
+ for (const [index, section] of value.focusSections.entries()) {
+ if (typeof section !== "string") {
+ return invalidField(`${path}.focusSections[${index}]`);
+ }
+ }
+
+ return null;
+}
+
+/** Documented. */
+function legacySectionTimeRange(index: number): SectionTimeRange {
+ return {
+ start: index,
+ end: index + 1
+ };
+}
+
+/** Documented. */
+function migrateLegacySectionTimeRanges(value: unknown): unknown {
+ if (!isRecord(value) || !isDenseArray(value.sections)) {
+ return value;
+ }
+
+ const migrated = structuredClone(value) as Record & {
+ sections: unknown[];
+ };
+ migrated.sections = migrated.sections.map((section, index) => {
+ if (!isRecord(section) || section.timeRange !== undefined) {
+ return section;
+ }
+
+ return {
+ ...section,
+ timeRange: legacySectionTimeRange(index)
+ };
+ });
+
+ return migrated;
+}
+
+/** Documented. */
+function validateScoreAttachment(value: unknown, path: string): string | null {
+ if (!isRecord(value)) {
+ return invalidField(path);
+ }
+ const extraKey = unexpectedKey(value, ["id", "fileName"], path);
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof value.id !== "string" || value.id.length === 0) {
+ return invalidField(`${path}.id`);
+ }
+ if (typeof value.fileName !== "string" || value.fileName.length === 0) {
+ return invalidField(`${path}.fileName`);
+ }
+
+ return null;
+}
+
+/** Documented. */
+function validateRehearsalSong(
+ value: unknown,
+ options: ValidationOptions = STRICT_VALIDATION_OPTIONS
+): string | null {
+ const normalized = options.acceptLegacySectionTimeRanges
+ ? migrateLegacySectionTimeRanges(value)
+ : value;
+ if (!isRecord(normalized)) {
+ return invalidField("root");
+ }
+ const extraKey = unexpectedKey(
+ normalized,
+ ["id", "title", "tempo", "sections", "exportSummary", "collaboration", "scoreAttachments"],
+ ""
+ );
+ if (extraKey) {
+ return extraKey;
+ }
+ if (typeof normalized.id !== "string") {
+ return invalidField("id");
+ }
+ if (typeof normalized.title !== "string") {
+ return invalidField("title");
+ }
+ if (
+ normalized.tempo !== undefined &&
+ (typeof normalized.tempo !== "number" || !Number.isFinite(normalized.tempo) || normalized.tempo <= 0)
+ ) {
+ return invalidField("tempo");
+ }
+ if (!isDenseArray(normalized.sections)) {
+ return invalidField("sections");
+ }
+ for (const [index, section] of normalized.sections.entries()) {
+ const sectionError = validateRehearsalSection(section, `sections[${index}]`);
+ if (sectionError) {
+ return sectionError;
+ }
+ }
+ if (normalized.collaboration !== undefined) {
+ const collaborationError = validateRehearsalCollaboration(normalized.collaboration, "collaboration");
+ if (collaborationError) {
+ return collaborationError;
+ }
+ }
+ if (normalized.scoreAttachments !== undefined) {
+ if (!isDenseArray(normalized.scoreAttachments)) {
+ return invalidField("scoreAttachments");
+ }
+ for (const [index, attachment] of normalized.scoreAttachments.entries()) {
+ const attachmentError = validateScoreAttachment(attachment, `scoreAttachments[${index}]`);
+ if (attachmentError) {
+ return attachmentError;
+ }
+ }
+ }
+
+ return validateExportSummary(normalized.exportSummary, "exportSummary");
+}
+
+/** Documented. */
+export function isRehearsalSong(value: unknown): value is RehearsalSong {
+ return validateRehearsalSong(value) === null;
+}
+
+/** Documented. */
+export function parseRehearsalSong(value: unknown): RehearsalSong {
+ const migratedValue = migrateLegacySectionTimeRanges(value);
+ const validationError = validateRehearsalSong(migratedValue);
+ if (validationError) {
+ throw new Error(validationError);
+ }
+
+ return structuredClone(migratedValue as RehearsalSong);
+}
+
+
+/** Documented. */
+function validateSongRehearsalPack(
+ value: unknown,
+ path: string,
+ options: ValidationOptions = STRICT_VALIDATION_OPTIONS
+): string | null {
+ if (!isRecord(value)) return invalidField(path);
+
+ if (typeof value.id !== "string") return invalidField(`${path}.id`);
+ if (!isOneOf(PACK_STATES, value.packState)) return invalidField(`${path}.packState`);
+ if (typeof value.sourceLabel !== "string") return invalidField(`${path}.sourceLabel`);
+ if (value.engineState !== undefined && !isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);
+
+ if (value.packState === "queued" || value.packState === "analyzing") {
+ const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel"], path);
+ if (extraKey) return extraKey;
+ if (!isOneOf(ANALYSIS_JOB_STATES, value.engineState)) return invalidField(`${path}.engineState`);
+ } else if (value.packState === "ready") {
+ const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "song"], path);
+ if (extraKey) return extraKey;
+ if (value.song === undefined) return invalidField(`${path}.song`);
+ const songError = validateRehearsalSong(value.song, options);
+ if (songError) return songError;
+ } else {
+ const extraKey = unexpectedKey(value, ["id", "packState", "engineState", "sourceLabel", "error"], path);
+ if (extraKey) return extraKey;
+ if (value.error === undefined) return invalidField(`${path}.error`);
+ const errorValidation = validateAnalysisJobError(value.error, `${path}.error`);
+ if (errorValidation) return errorValidation;
+ }
+ return null;
+}
+
+/** Documented. */
+export function parseSongRehearsalPack(value: unknown): SongRehearsalPack {
+ const validationError = validateSongRehearsalPack(value, "root", LEGACY_VALIDATION_OPTIONS);
+ if (validationError) throw new Error(validationError);
+ const parsed = structuredClone(value as SongRehearsalPack);
+ if (parsed.packState === "ready") {
+ parsed.song = parseRehearsalSong(parsed.song);
+ }
+ return parsed;
+}
+
+/** Documented. */
+function validateRehearsalWorkspace(
+ value: unknown,
+ options: ValidationOptions = STRICT_VALIDATION_OPTIONS
+): string | null {
+ if (!isRecord(value)) return invalidField("root");
+ const extraKey = unexpectedKey(value, ["id", "title", "songs", "workspaceVersion"], "");
+ if (extraKey) return extraKey;
+ if (typeof value.id !== "string") return invalidField("id");
+ if (typeof value.title !== "string") return invalidField("title");
+ if (typeof value.workspaceVersion !== "number") return invalidField("workspaceVersion");
+ if (!isDenseArray(value.songs)) return invalidField("songs");
+
+ for (const [index, song] of value.songs.entries()) {
+ const packError = validateSongRehearsalPack(song, `songs[${index}]`, options);
+ if (packError) return packError;
+ }
+ return null;
+}
+
+/** Documented. */
+export function isRehearsalWorkspace(value: unknown): value is RehearsalWorkspace {
+ return validateRehearsalWorkspace(value) === null;
+}
+
+/** Documented. */
+export function parseRehearsalWorkspace(value: unknown): RehearsalWorkspace {
+ const validationError = validateRehearsalWorkspace(value, LEGACY_VALIDATION_OPTIONS);
+ if (validationError) throw new Error(validationError);
+ const parsed = structuredClone(value as RehearsalWorkspace);
+ parsed.songs = parsed.songs.map((pack) => (
+ pack.packState === "ready"
+ ? { ...pack, song: parseRehearsalSong(pack.song) }
+ : pack
+ ));
+ return parsed;
+}
diff --git a/packages/shared-types/src/metadata-handoff.ts b/packages/shared-types/src/metadata-handoff.ts
new file mode 100644
index 000000000..9ddc4f2bd
--- /dev/null
+++ b/packages/shared-types/src/metadata-handoff.ts
@@ -0,0 +1,190 @@
+import {
+ SECTION_FORM_LABELS,
+ parseMetadataHandoffArtifact as parseLegacyMetadataHandoffArtifact,
+ type MetadataHandoffArtifact as LegacyMetadataHandoffArtifact,
+ type SectionFormLabel
+} from "./legacy-index";
+
+/** The original metadata handoff contract accepted by existing v1 readers. */
+export type MetadataHandoffArtifactV1 = LegacyMetadataHandoffArtifact;
+
+/** Tonight's first concrete instrument check carried by a v2 handoff. */
+export type MetadataHandoffFirstAction = {
+ sectionId: string;
+ sectionLabel: SectionFormLabel;
+ roleId: string;
+ roleName: string;
+ lowestNote: string;
+ highestNote: string;
+ clash: boolean;
+};
+
+/** Metadata handoff v2 adds one required first action without changing the v1 schema. */
+export type MetadataHandoffArtifactV2 = Omit & {
+ artifactVersion: 2;
+ firstAction: MetadataHandoffFirstAction;
+};
+
+/** A metadata handoff accepted by this BandScope version. */
+export type MetadataHandoffArtifact = MetadataHandoffArtifactV1 | MetadataHandoffArtifactV2;
+
+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;
+
+/** Build the stable public validation error for one metadata-handoff field. */
+function invalidField(path: string): Error {
+ return new Error(`Invalid rehearsal song contract: invalid field '${path}'`);
+}
+
+/** Return whether a runtime value is a non-array object record. */
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Return whether a runtime value is an admitted section-form label. */
+function isSectionFormLabel(value: unknown): value is SectionFormLabel {
+ return typeof value === "string" && (SECTION_FORM_LABELS as readonly string[]).includes(value);
+}
+
+/** Convert a supported scientific-pitch label into chromatic ordering. */
+function notePitchValue(note: string): number | null {
+ const match = NOTE_PATTERN.exec(note.trim());
+ 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]];
+}
+
+/** Shape-check the v2 lead and verify its playable span before it becomes authority. */
+function validateFirstAction(value: unknown): MetadataHandoffFirstAction {
+ if (!isRecord(value)) {
+ throw invalidField("firstAction");
+ }
+
+ const allowedKeys = [
+ "sectionId",
+ "sectionLabel",
+ "roleId",
+ "roleName",
+ "lowestNote",
+ "highestNote",
+ "clash"
+ ] as const;
+ for (const key of Object.keys(value)) {
+ if (!(allowedKeys as readonly string[]).includes(key)) {
+ throw invalidField(`firstAction.${key}`);
+ }
+ }
+
+ for (const key of ["sectionId", "roleId", "roleName", "lowestNote", "highestNote"] as const) {
+ const field = value[key];
+ if (typeof field !== "string" || field.trim().length === 0) {
+ throw invalidField(`firstAction.${key}`);
+ }
+ }
+ if (!isSectionFormLabel(value.sectionLabel)) {
+ throw invalidField("firstAction.sectionLabel");
+ }
+ if (typeof value.clash !== "boolean") {
+ throw invalidField("firstAction.clash");
+ }
+
+ const lowestPitch = notePitchValue(value.lowestNote as string);
+ if (lowestPitch === null) {
+ throw invalidField("firstAction.lowestNote");
+ }
+ const highestPitch = notePitchValue(value.highestNote as string);
+ if (highestPitch === null || lowestPitch > highestPitch) {
+ throw invalidField("firstAction.highestNote");
+ }
+
+ return value as MetadataHandoffFirstAction;
+}
+
+/** Require the v2 lead to point at exactly one exported section and role bucket. */
+function validateFirstActionReferences(
+ firstAction: MetadataHandoffFirstAction,
+ artifact: MetadataHandoffArtifactV1
+): void {
+ const matchingSections = artifact.sections.filter((section) => section.id === firstAction.sectionId);
+ if (matchingSections.length !== 1) {
+ throw invalidField("firstAction.sectionId");
+ }
+
+ const section = matchingSections[0]!;
+ if (section.label !== firstAction.sectionLabel) {
+ throw invalidField("firstAction.sectionLabel");
+ }
+
+ const matchingRoles = section.roleBuckets.filter((role) => role.id === firstAction.roleId);
+ if (matchingRoles.length !== 1) {
+ throw invalidField("firstAction.roleId");
+ }
+ if (matchingRoles[0]!.name !== firstAction.roleName) {
+ throw invalidField("firstAction.roleName");
+ }
+}
+
+/**
+ * Parse both the immutable v1 handoff and the first-action v2 handoff.
+ *
+ * V1 is delegated unchanged to the original strict parser. V2 validates its
+ * new field, then reuses that same v1 parser for every unchanged field so the
+ * new version cannot silently weaken the established metadata boundary.
+ */
+export function parseMetadataHandoffArtifact(value: unknown): MetadataHandoffArtifact {
+ if (!isRecord(value)) {
+ return parseLegacyMetadataHandoffArtifact(value);
+ }
+
+ if (value.artifactVersion === 1) {
+ return parseLegacyMetadataHandoffArtifact(value);
+ }
+ if (value.artifactVersion !== 2) {
+ throw invalidField("artifactVersion");
+ }
+ if (!Object.prototype.hasOwnProperty.call(value, "firstAction")) {
+ throw invalidField("firstAction");
+ }
+
+ const firstAction = validateFirstAction(value.firstAction);
+ const legacyFields: Record = { ...value };
+ delete legacyFields.firstAction;
+ const legacyArtifact = parseLegacyMetadataHandoffArtifact({
+ ...legacyFields,
+ artifactVersion: 1
+ });
+ validateFirstActionReferences(firstAction, legacyArtifact);
+
+ return structuredClone(value as MetadataHandoffArtifactV2);
+}
+
+/** Return whether a runtime value is a valid metadata handoff version. */
+export function isMetadataHandoffArtifact(value: unknown): value is MetadataHandoffArtifact {
+ try {
+ parseMetadataHandoffArtifact(value);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts
index 564ee1827..a8ab33e8f 100644
--- a/packages/shared-types/test/index.test.ts
+++ b/packages/shared-types/test/index.test.ts
@@ -621,7 +621,7 @@ describe("shared type helpers", () => {
expect(parseMetadataHandoffArtifact(artifact)).toEqual(artifact);
expect(() => parseMetadataHandoffArtifact({
...artifact,
- artifactVersion: 2
+ artifactVersion: 3
})).toThrow("artifactVersion");
expect(() => parseMetadataHandoffArtifact({
...artifact,
diff --git a/packages/shared-types/test/metadata-handoff-versioning.test.ts b/packages/shared-types/test/metadata-handoff-versioning.test.ts
new file mode 100644
index 000000000..0bf365128
--- /dev/null
+++ b/packages/shared-types/test/metadata-handoff-versioning.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import {
+ createDemoRehearsalSong,
+ isMetadataHandoffArtifact,
+ parseMetadataHandoffArtifact
+} from "../src/index";
+
+function legacyV1Artifact() {
+ const song = createDemoRehearsalSong();
+ return {
+ artifactKind: "bandscope.metadata-handoff",
+ artifactVersion: 1,
+ createdAt: "2026-09-01T00:00:00.000Z",
+ workspace: {
+ id: "workspace-1",
+ title: "Friday rehearsal",
+ workspaceVersion: 1
+ },
+ song: {
+ id: song.id,
+ title: song.title,
+ exportSummary: song.exportSummary
+ },
+ sections: [
+ {
+ id: song.sections[0]!.id,
+ label: song.sections[0]!.label,
+ timeRange: song.sections[0]!.timeRange,
+ confidence: song.sections[0]!.confidence,
+ roleBuckets: [
+ {
+ id: song.sections[0]!.roles[0]!.id,
+ name: song.sections[0]!.roles[0]!.name,
+ roleType: song.sections[0]!.roles[0]!.roleType,
+ confidence: song.sections[0]!.roles[0]!.confidence,
+ rehearsalPriority: song.sections[0]!.roles[0]!.rehearsalPriority
+ }
+ ]
+ }
+ ],
+ sourceAssets: []
+ } as const;
+}
+
+const firstAction = {
+ sectionId: "verse-1",
+ sectionLabel: "verse",
+ roleId: "bass-guitar",
+ roleName: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ clash: true
+} as const;
+
+function v2(overrides: Record = {}) {
+ return {
+ ...legacyV1Artifact(),
+ artifactVersion: 2,
+ firstAction,
+ ...overrides
+ };
+}
+
+describe("metadata handoff versioning", () => {
+ it("keeps the v1 schema strict and accepts the first-action field only under v2", () => {
+ const v1 = legacyV1Artifact();
+
+ expect(parseMetadataHandoffArtifact(v1)).toEqual(v1);
+ expect(() => parseMetadataHandoffArtifact({ ...v1, firstAction })).toThrow("firstAction");
+ expect(parseMetadataHandoffArtifact(v2())).toEqual(v2());
+ });
+
+ it("fails closed on unsupported or malformed v2 envelopes", () => {
+ expect(() => parseMetadataHandoffArtifact(null)).toThrow("root");
+ expect(() => parseMetadataHandoffArtifact({ ...legacyV1Artifact(), artifactVersion: 3 })).toThrow("artifactVersion");
+ expect(() => parseMetadataHandoffArtifact({ ...legacyV1Artifact(), artifactVersion: 2 })).toThrow("firstAction");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: null }))).toThrow("firstAction");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, extraField: true } }))).toThrow("firstAction.extraField");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, sectionId: 3 } }))).toThrow("firstAction.sectionId");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, roleId: " " } }))).toThrow("firstAction.roleId");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, sectionLabel: 3 } }))).toThrow("firstAction.sectionLabel");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, sectionLabel: "solo" } }))).toThrow("firstAction.sectionLabel");
+ expect(() => parseMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, clash: "yes" } }))).toThrow("firstAction.clash");
+ expect(() => parseMetadataHandoffArtifact(v2({ unexpected: true }))).toThrow("unexpected");
+ });
+
+ it("rejects fabricated or internally inconsistent v2 first actions", () => {
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, lowestNote: "H2" } })
+ )
+ ).toThrow("firstAction.lowestNote");
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, lowestNote: "E3", highestNote: "C#2" } })
+ )
+ ).toThrow("firstAction.highestNote");
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, sectionId: "missing-section" } })
+ )
+ ).toThrow("firstAction.sectionId");
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, sectionLabel: "chorus" } })
+ )
+ ).toThrow("firstAction.sectionLabel");
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, roleId: "missing-role" } })
+ )
+ ).toThrow("firstAction.roleId");
+ expect(() =>
+ parseMetadataHandoffArtifact(
+ v2({ firstAction: { ...firstAction, roleName: "Someone Else" } })
+ )
+ ).toThrow("firstAction.roleName");
+ });
+
+ it("exposes a boolean guard for both supported versions and invalid input", () => {
+ expect(isMetadataHandoffArtifact(legacyV1Artifact())).toBe(true);
+ expect(isMetadataHandoffArtifact(v2())).toBe(true);
+ expect(isMetadataHandoffArtifact(v2({ firstAction: { ...firstAction, roleName: "" } }))).toBe(false);
+ });
+});