diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 5c1c16989..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2024-05-18 - Added focus visible styles for keyboard navigation -**Learning:** Interactive inline buttons (like the chord editor) and scrollable regions with `tabIndex={0}` do not automatically get focus visible styles, meaning keyboard users tabbing through won't know they are focused on them. Unlike central ` + ) : null} + {onUseOwnSong ? ( + + ) : null} + + ) : null} ); diff --git a/apps/desktop/src/lib/analysis.reload.test.ts b/apps/desktop/src/lib/analysis.reload.test.ts new file mode 100644 index 000000000..10520e573 --- /dev/null +++ b/apps/desktop/src/lib/analysis.reload.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +const licensedDemoBootstrap = { + projectId: "licensed-demo-project", + sourceMode: "reference" as const, + projectRoot: "/tmp/bandscope/projects/licensed-demo-project", + cacheRoot: "/tmp/bandscope/cache/licensed-demo-project", + tempRoot: "/tmp/bandscope/temp/licensed-demo-project", + source: { + sourcePath: "/tmp/bandscope/resources/demo/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav" as const, + fileSizeBytes: 441044 + } +}; + +function createLicensedDemoInvoke() { + const engineResult = { ...createDemoRehearsalSong(), title: "Analyzed Track" }; + return vi.fn(async (command: string) => { + if (command === "select_demo_audio_source") { + return licensedDemoBootstrap; + } + if (command === "start_analysis_job" || command === "get_analysis_job_status") { + return { + jobId: "job-licensed-demo", + state: "succeeded", + requestedAt: "2026-09-02T00:00:00.000Z", + updatedAt: "2026-09-02T00:00:01.000Z", + result: engineResult + }; + } + throw new Error(`Unexpected command: ${command}`); + }); +} + +describe("licensed demo renderer reload", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + window.sessionStorage.clear(); + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; + }); + + it("keeps the canonical demo title when a running job is polled after module reload", async () => { + const nativeInvoke = createLicensedDemoInvoke(); + tauriWindow.__TAURI_INVOKE__ = nativeInvoke; + + const analysisBeforeReload = await import("./analysis"); + const selection = await analysisBeforeReload.selectDemoAudioSource(); + expect(selection.ok).toBe(true); + if (!selection.ok) { + throw new Error("licensed demo selection must succeed through the native bridge"); + } + + const startedStatus = await analysisBeforeReload.startAnalysisJob({ + sourceKind: "local_audio", + projectId: selection.bootstrap.projectId, + sourceLabel: selection.bootstrap.source.fileName, + roleFocus: ["bass-guitar"] + }); + expect(startedStatus.result?.title).toBe("Late Night Set"); + + vi.resetModules(); + const analysisAfterReload = await import("./analysis"); + const polledStatus = await analysisAfterReload.getAnalysisJobStatus("job-licensed-demo"); + + expect(polledStatus.result?.title).toBe("Late Night Set"); + }); + + it("keeps the canonical demo title when session storage is unavailable", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("session storage unavailable"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("session storage unavailable"); + }); + const nativeInvoke = createLicensedDemoInvoke(); + tauriWindow.__TAURI_INVOKE__ = nativeInvoke; + + const analysis = await import("./analysis"); + const selection = await analysis.selectDemoAudioSource(); + expect(selection.ok).toBe(true); + if (!selection.ok) { + throw new Error("licensed demo selection must succeed through the native bridge"); + } + + const startedStatus = await analysis.startAnalysisJob({ + sourceKind: "local_audio", + projectId: selection.bootstrap.projectId, + sourceLabel: selection.bootstrap.source.fileName, + roleFocus: ["bass-guitar"] + }); + + expect(nativeInvoke).toHaveBeenCalledWith("start_analysis_job", { + request: expect.objectContaining({ sourceLabel: "Late Night Set" }) + }); + expect(startedStatus.result?.title).toBe("Late Night Set"); + }); +}); diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..0e2247e8a 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -4,6 +4,7 @@ import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, importYoutubeUrl, + selectDemoAudioSource, startAnalysisJob } from "./analysis"; @@ -20,6 +21,30 @@ describe("analysis bridge", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("fails closed when the licensed demo is requested outside Tauri", async () => { + const selection = await selectDemoAudioSource(); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "The licensed demo song could not be loaded. Use your own song to start tonight." + } + }); + }); + + it("does not invent a browser demo bootstrap when Tauri internals lack invoke", async () => { + tauriWindow.__TAURI_INTERNALS__ = {}; + + const selection = await selectDemoAudioSource(); + + expect(selection.ok).toBe(false); + if (selection.ok) { + throw new Error("browser demo intake must fail closed"); + } + expect(selection.error.message).toMatch(/use your own song/i); + }); + it("imports a standard YouTube URL through the browser fallback when Tauri is absent", async () => { const selection = await importYoutubeUrl("https://www.youtube.com/watch?v=4ozX4yFUC34"); @@ -99,6 +124,68 @@ describe("analysis bridge", () => { expect(selection.ok).toBe(true); }); + it("preserves the canonical licensed-demo title through start and polling", async () => { + const engineResult = { + ...createDemoRehearsalSong(), + title: "Analyzed Track" + }; + const nativeInvoke = vi.fn(async (command: string) => { + if (command === "select_demo_audio_source") { + return { + projectId: "licensed-demo-project", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/licensed-demo-project", + cacheRoot: "/tmp/bandscope/cache/licensed-demo-project", + tempRoot: "/tmp/bandscope/temp/licensed-demo-project", + source: { + sourcePath: "/tmp/bandscope/resources/demo/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 441044 + } + }; + } + if (command === "start_analysis_job" || command === "get_analysis_job_status") { + return { + jobId: "job-licensed-demo", + state: "succeeded", + requestedAt: "2026-03-12T00:00:00.000Z", + updatedAt: "2026-03-12T00:00:01.000Z", + progressLabel: "Analysis ready for late-night-set.wav", + progressStage: "ready", + progressPercent: 100, + cacheStatus: "stored", + result: engineResult + }; + } + throw new Error(`Unexpected command: ${command}`); + }); + tauriWindow.__TAURI_INVOKE__ = nativeInvoke; + + const selection = await selectDemoAudioSource(); + expect(selection.ok).toBe(true); + if (!selection.ok) { + throw new Error("licensed demo selection must succeed through the native bridge"); + } + + const status = await startAnalysisJob({ + sourceKind: "local_audio", + projectId: selection.bootstrap.projectId, + sourceLabel: selection.bootstrap.source.fileName, + roleFocus: ["bass-guitar"] + }); + + expect(nativeInvoke).toHaveBeenCalledWith("start_analysis_job", { + request: expect.objectContaining({ sourceLabel: "Late Night Set" }) + }); + expect(status.progressLabel).toBe("Analysis ready for Late Night Set"); + expect(status.result?.title).toBe("Late Night Set"); + + const polledStatus = await getAnalysisJobStatus("job-licensed-demo"); + expect(polledStatus.progressLabel).toBe("Analysis ready for Late Night Set"); + expect(polledStatus.result?.title).toBe("Late Night Set"); + }); + it("normalizes legacy analysis job status responses before returning them", async () => { const legacyResult = createDemoRehearsalSong() as unknown as { sections: Array>; @@ -117,50 +204,17 @@ describe("analysis bridge", () => { expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); }); - it("reports staged browser fallback progress before returning the demo result", async () => { - const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); - - expect(queued).toMatchObject({ - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0 - }); - - const running = await getAnalysisJobStatus(queued.jobId); - expect(running).toMatchObject({ - state: "running", - progressLabel: "Decoding audio", - progressStage: "decode", - progressPercent: 20 - }); + it("fails browser analysis closed instead of synthesizing a rehearsal result", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Separating stems... (45%)", - progressStage: "separate", - progressPercent: 45 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Building rehearsal cues", - progressStage: "analyze", - progressPercent: 70 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Saving reusable features", - progressStage: "persist", - progressPercent: 90 - }); - - const ready = await getAnalysisJobStatus(queued.jobId); - expect(ready).toMatchObject({ - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100 + expect(status).toMatchObject({ + state: "failed", + error: { + code: "engine_unavailable", + message: "Analysis engine is unavailable." + } }); + expect(status.result).toBeUndefined(); }); it("ignores a non-function Tauri v1 invoke shim", async () => { diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..ed3813a22 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core"; import { createAnalysisJobStatus, createDemoAnalysisJobRequest, - createDemoRehearsalSong, createProjectBootstrapSummary, parseAnalysisJobStatus, parseAnalysisJobRequest, @@ -15,6 +14,7 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { DEMO_SONG_TITLE } from "./demo"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -27,23 +27,23 @@ declare global { } } -const browserJobStore = new Map(); -const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 }, - { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } -] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const DEMO_UNAVAILABLE_MESSAGE = + "The licensed demo song could not be loaded. Use your own song to start tonight."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", "Could not prepare the local project workspace.", "Could not prepare the local cache workspace.", - "Could not prepare the local temp workspace." + "Could not prepare the local temp workspace.", + DEMO_UNAVAILABLE_MESSAGE ]); const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; +const LICENSED_DEMO_PROJECT_STORAGE_KEY = "bandscope.licensedDemoProjectId"; +const LICENSED_DEMO_JOB_STORAGE_KEY = "bandscope.licensedDemoJobId"; +let currentLicensedDemoProjectId: string | null = null; +let currentLicensedDemoJobId: string | null = null; export { MAX_YOUTUBE_URL_LENGTH }; @@ -52,6 +52,76 @@ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } | { ok: false; error: AnalysisJobError }; +/** Read a bounded renderer-session marker without making analysis depend on storage availability. */ +function readSessionMarker(storageKey: string): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.sessionStorage.getItem(storageKey); + } catch { + return null; + } +} + +/** Persist one renderer-session marker without failing the native analysis path. */ +function writeSessionMarker(storageKey: string, storageValue: string): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.setItem(storageKey, storageValue); + } catch { + // Storage is a reload-continuity mirror only; the current renderer keeps identity in memory. + } +} + +/** Remember the active licensed-demo project in memory and mirror it for renderer reload continuity. */ +function rememberLicensedDemoProjectId(projectId: string): void { + currentLicensedDemoProjectId = projectId; + writeSessionMarker(LICENSED_DEMO_PROJECT_STORAGE_KEY, projectId); +} + +/** Resolve the active licensed-demo project, preferring current renderer authority over the reload mirror. */ +function readLicensedDemoProjectId(): string | null { + return currentLicensedDemoProjectId ?? readSessionMarker(LICENSED_DEMO_PROJECT_STORAGE_KEY); +} + +/** Remember the active licensed-demo job in memory and mirror it for renderer reload continuity. */ +function rememberLicensedDemoJobId(jobId: string): void { + currentLicensedDemoJobId = jobId; + writeSessionMarker(LICENSED_DEMO_JOB_STORAGE_KEY, jobId); +} + +/** Resolve the active licensed-demo job, preferring current renderer authority over the reload mirror. */ +function readLicensedDemoJobId(): string | null { + return currentLicensedDemoJobId ?? readSessionMarker(LICENSED_DEMO_JOB_STORAGE_KEY); +} + +/** Preserve the trusted demo title across the current renderer and ordinary WebView module reloads. */ +function withLicensedDemoTitle(analysisStatus: AnalysisJobStatus): AnalysisJobStatus { + if (readLicensedDemoJobId() !== analysisStatus.jobId) { + return analysisStatus; + } + if (!analysisStatus.result && analysisStatus.progressStage !== "ready") { + return analysisStatus; + } + return { + ...analysisStatus, + ...(analysisStatus.progressStage === "ready" + ? { progressLabel: `Analysis ready for ${DEMO_SONG_TITLE}` } + : {}), + ...(analysisStatus.result + ? { + result: { + ...analysisStatus.result, + title: DEMO_SONG_TITLE + } + } + : {}) + }; +} + /** Documented. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { @@ -115,65 +185,34 @@ function browserJobId(prefix: string): string { async function browserFallback(command: string, args?: Record): Promise { if (command === "start_analysis_job") { parseAnalysisJobRequest(args?.request); - const jobId = browserJobId("browser-job"); - const queued = createAnalysisJobStatus({ - jobId, - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0, - cacheStatus: "disabled" + return createAnalysisJobStatus({ + jobId: browserJobId("browser-job"), + state: "failed", + error: { + code: "engine_unavailable", + message: "Analysis engine is unavailable." + } }); - browserJobStore.set(jobId, queued); - return queued; } if (command === "select_local_audio_source") { throw new Error(UNSUPPORTED_LOCAL_AUDIO_MESSAGE); } + if (command === "select_demo_audio_source") { + throw new Error(DEMO_UNAVAILABLE_MESSAGE); + } + if (command === "get_analysis_job_status") { const jobId = String(args?.jobId ?? ""); - const existing = browserJobStore.get(jobId); - if (!existing) { - return createAnalysisJobStatus({ - jobId, - state: "failed", - error: { - code: "not_found", - message: "Analysis job was not found." - } - }); - } - if (existing.state === "queued" || existing.state === "running") { - const currentPercent = existing.progressPercent ?? 0; - const nextStep = BROWSER_PROGRESS_STEPS.find((step) => step.progressPercent > currentPercent); - if (nextStep) { - const running = createAnalysisJobStatus({ - jobId, - state: "running", - requestedAt: existing.requestedAt, - progressLabel: nextStep.progressLabel, - progressStage: nextStep.progressStage, - progressPercent: nextStep.progressPercent, - cacheStatus: "disabled" - }); - browserJobStore.set(jobId, running); - return running; - } - } - const succeeded = createAnalysisJobStatus({ + return createAnalysisJobStatus({ jobId, - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100, - cacheStatus: "disabled", - requestedAt: existing.requestedAt, - result: createDemoRehearsalSong() + state: "failed", + error: { + code: "not_found", + message: "Analysis job was not found." + } }); - browserJobStore.set(jobId, succeeded); - return succeeded; } if (command === "save_project") { @@ -244,6 +283,30 @@ export async function selectLocalAudioSource(): Promise { + try { + const response = await invokeAnalysis("select_demo_audio_source"); + const bootstrap = parseProjectBootstrapSummary(response); + rememberLicensedDemoProjectId(bootstrap.projectId); + return { + ok: true, + bootstrap + }; + } catch (error) { + return { + ok: false, + error: { + code: "invalid_request", + message: + error instanceof Error && SAFE_LOCAL_AUDIO_MESSAGES.has(error.message) + ? error.message + : DEMO_UNAVAILABLE_MESSAGE + } + }; + } +} + /** Documented. */ export async function startAnalysisJob(request: AnalysisJobRequest): Promise { let parsedRequest: AnalysisJobRequest; @@ -260,11 +323,22 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise { const response = await invokeAnalysis("get_analysis_job_status", { jobId }); try { - return parseAnalysisJobStatus(response); + return withLicensedDemoTitle(parseAnalysisJobStatus(response)); } catch { throw new Error("Invalid analysis job status response"); } @@ -296,7 +370,7 @@ export async function subscribeToAnalysisJobUpdates( try { const unlisten = await listen("analysis-job-updated", (event) => { try { - const status = parseAnalysisJobStatus(event.payload); + const status = withLicensedDemoTitle(parseAnalysisJobStatus(event.payload)); if (status.jobId === jobId) { onUpdate(status); } @@ -331,7 +405,12 @@ export async function importYoutubeUrl(url: string): Promise { + it("translates legacy public wire keys into semantic internal names", () => { + const parsedManifest = parseDemoProvenanceManifest(legacyWireManifest) as unknown as { + demoSong: { + songId: string; + songTitle: string; + performerName: string; + licenseExpression: string; + }; + demoAssets: Array<{ + assetPath: string; + assetRole: string; + assetSha256: string; + assetByteCount: number; + assetMediaType: string; + }>; + }; + + expect(parsedManifest.demoSong).toMatchObject({ + songId: "late-night-set", + songTitle: "Late Night Set", + performerName: "Contextual Wisdom Lab", + licenseExpression: "CC0-1.0" + }); + expect(parsedManifest.demoAssets[0]).toEqual({ + assetPath: "late-night-set.wav", + assetRole: "audio", + assetSha256: HASH, + assetByteCount: 441044, + assetMediaType: "audio/wav" + }); + }); +}); diff --git a/apps/desktop/src/lib/demo.test.ts b/apps/desktop/src/lib/demo.test.ts new file mode 100644 index 000000000..598b5b9ff --- /dev/null +++ b/apps/desktop/src/lib/demo.test.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + DEMO_PROVENANCE_KIND, + DEMO_RESOURCE_DIRECTORY, + parseDemoProvenanceManifest, + type DemoProvenanceManifest +} from "./demo"; + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../"); + +/** Legacy public wire shape retained only at the provenance anti-corruption boundary. */ +type DemoProvenanceWireFixture = { + manifestVersion: number; + artifactKind: string; + song: { + id: string; + title: string; + performer: string; + license: string; + licenseUrl: string; + permittedUses: string[]; + }; + assets: Array<{ + path: string; + role: string; + sha256: string; + bytes: number; + mediaType: string; + }>; +}; + +function bundledWireManifest(): DemoProvenanceWireFixture { + const rawManifest = readFileSync( + path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "provenance.json"), + "utf8" + ); + return JSON.parse(rawManifest) as DemoProvenanceWireFixture; +} + +function bundledManifest(): DemoProvenanceManifest { + return parseDemoProvenanceManifest(bundledWireManifest()); +} + +describe("licensed demo provenance", () => { + it("accepts the bundled CC0 package and verifies every recorded hash", () => { + const manifest = bundledManifest(); + expect(manifest.artifactKind).toBe(DEMO_PROVENANCE_KIND); + expect(manifest.demoSong.licenseExpression).toBe("CC0-1.0"); + expect(manifest.demoSong.songTitle).toBe("Late Night Set"); + expect(manifest.demoSong.permittedUses).toEqual([ + "evaluation", + "redistribution", + "rehearsal-demo" + ]); + for (const demoAsset of manifest.demoAssets) { + const assetBytes = readFileSync( + path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, demoAsset.assetPath) + ); + expect(assetBytes.byteLength).toBe(demoAsset.assetByteCount); + expect(createHash("sha256").update(assetBytes).digest("hex")).toBe(demoAsset.assetSha256); + } + const audioAsset = manifest.demoAssets.find((demoAsset) => demoAsset.assetRole === "audio"); + expect(audioAsset?.assetPath).toBe("late-night-set.wav"); + const demoWav = readFileSync( + path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "late-night-set.wav") + ); + expect(demoWav.subarray(0, 4).toString("ascii")).toBe("RIFF"); + expect(demoWav.subarray(8, 12).toString("ascii")).toBe("WAVE"); + }); + + it("rejects unknown fields, the wrong kind, and a missing asset role", () => { + const wireManifest = bundledWireManifest(); + expect(() => parseDemoProvenanceManifest(null)).toThrow(/root/); + expect(() => parseDemoProvenanceManifest([])).toThrow(/root/); + expect(() => parseDemoProvenanceManifest({ ...wireManifest, extra: true })).toThrow( + /Invalid demo provenance field 'extra'/ + ); + expect(() => + parseDemoProvenanceManifest({ ...wireManifest, artifactKind: "other" }) + ).toThrow(/artifactKind/); + expect(() => parseDemoProvenanceManifest({ ...wireManifest, manifestVersion: 2 })).toThrow( + /manifestVersion/ + ); + expect(() => parseDemoProvenanceManifest({ ...wireManifest, song: null })).toThrow(/song/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + song: { ...wireManifest.song, extra: "nope" } + }) + ).toThrow(/song\.extra/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + song: { ...wireManifest.song, license: "MIT" } + }) + ).toThrow(/license/); + const withoutAudio = { + ...wireManifest, + assets: wireManifest.assets.filter((demoAsset) => demoAsset.role !== "audio") + }; + expect(() => parseDemoProvenanceManifest(withoutAudio)).toThrow(/assets/); + }); + + it("rejects manifests whose UTF-8 serialization exceeds the byte ceiling", () => { + const wireManifest = bundledWireManifest(); + const oversizedUtf8Manifest = { ...wireManifest }; + Object.defineProperty(oversizedUtf8Manifest, "toJSON", { + enumerable: false, + value: () => "가".repeat(6000) + }); + + expect(() => parseDemoProvenanceManifest(oversizedUtf8Manifest)).toThrow(/too large/); + }); + + it("rejects traversal paths, dot segments, non-hex hashes, and malformed assets", () => { + const wireManifest = bundledWireManifest(); + const [audioAsset, licenseAsset, annotationAsset] = wireManifest.assets; + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, path: "../secret.wav" }, licenseAsset, annotationAsset] + }) + ).toThrow(/assets\[0\]\.path/); + for (const dotSegment of [".", ".."]) { + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, path: dotSegment }, licenseAsset, annotationAsset] + }) + ).toThrow(/assets\[0\]\.path/); + } + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, sha256: "not-a-hash" }, licenseAsset, annotationAsset] + }) + ).toThrow(/sha256/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, bytes: 1.5 }, licenseAsset, annotationAsset] + }) + ).toThrow(/bytes/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, extra: true }, licenseAsset, annotationAsset] + }) + ).toThrow(/assets\[0\]\.extra/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [null, licenseAsset, annotationAsset] + }) + ).toThrow(/assets\[0\]/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + assets: [{ ...audioAsset, role: "stems" }, licenseAsset, annotationAsset] + }) + ).toThrow(/assets\.role/); + expect(() => + parseDemoProvenanceManifest({ + ...wireManifest, + song: { ...wireManifest.song, permittedUses: [] } + }) + ).toThrow(/permittedUses/); + }); +}); diff --git a/apps/desktop/src/lib/demo.ts b/apps/desktop/src/lib/demo.ts new file mode 100644 index 000000000..c6c86fef9 --- /dev/null +++ b/apps/desktop/src/lib/demo.ts @@ -0,0 +1,178 @@ +/** Kind discriminator for the public licensed-demo provenance manifest. */ +const DEMO_PROVENANCE_KIND = "bandscope.licensed-demo" as const; + +/** Canonical buyer-facing title for the bundled licensed demo. */ +const DEMO_SONG_TITLE = "Late Night Set" as const; + +/** Relative directory that Tauri bundles as the licensed demo package. */ +const DEMO_RESOURCE_DIRECTORY = "apps/desktop/src-tauri/resources/demo"; + +export { DEMO_PROVENANCE_KIND, DEMO_RESOURCE_DIRECTORY, DEMO_SONG_TITLE }; + +/** Permitted asset roles inside one licensed demo package. */ +export type DemoAssetRole = "audio" | "license" | "annotations"; + +/** One hashed file in the licensed demo package after wire translation. */ +export type DemoProvenanceAsset = { + assetPath: string; + assetRole: DemoAssetRole; + assetSha256: string; + assetByteCount: number; + assetMediaType: string; +}; + +/** Provenance contract used internally after translating the public wire manifest. */ +export type DemoProvenanceManifest = { + manifestVersion: 1; + artifactKind: typeof DEMO_PROVENANCE_KIND; + demoSong: { + songId: string; + songTitle: string; + performerName: string; + licenseExpression: "CC0-1.0"; + licenseUrl: string; + permittedUses: string[]; + }; + demoAssets: DemoProvenanceAsset[]; +}; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const RELATIVE_FILE_PATTERN = /^[A-Za-z0-9._-]+$/; +const MAX_MANIFEST_BYTES = 16_384; +const MAX_ASSET_BYTES = 2_000_000; +const REQUIRED_ROLES: DemoAssetRole[] = ["audio", "license", "annotations"]; +const UTF8_TEXT_ENCODER = new TextEncoder(); + +/** Read one bounded non-empty provenance string. */ +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > 200) { + throw new Error(`Invalid demo provenance field '${field}'`); + } + return value.trim(); +} + +/** Narrow one untrusted asset role to the licensed-demo allowlist. */ +function asAssetRole(value: unknown): DemoAssetRole { + if (value === "audio" || value === "license" || value === "annotations") { + return value; + } + throw new Error("Invalid demo provenance field 'assets.role'"); +} + +/** Parse the stable public wire manifest into semantically specific internal names. */ +export function parseDemoProvenanceManifest(payload: unknown): DemoProvenanceManifest { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Invalid demo provenance field 'root'"); + } + const manifestRecord = payload as Record; + const allowedManifestFields = new Set(["manifestVersion", "artifactKind", "song", "assets"]); + for (const manifestField of Object.keys(manifestRecord)) { + if (!allowedManifestFields.has(manifestField)) { + throw new Error(`Invalid demo provenance field '${manifestField}'`); + } + } + if (manifestRecord.manifestVersion !== 1) { + throw new Error("Invalid demo provenance field 'manifestVersion'"); + } + if (manifestRecord.artifactKind !== DEMO_PROVENANCE_KIND) { + throw new Error("Invalid demo provenance field 'artifactKind'"); + } + if ( + manifestRecord.song === null || + typeof manifestRecord.song !== "object" || + Array.isArray(manifestRecord.song) + ) { + throw new Error("Invalid demo provenance field 'song'"); + } + const songRecord = manifestRecord.song as Record; + for (const songField of Object.keys(songRecord)) { + if ( + songField !== "id" && + songField !== "title" && + songField !== "performer" && + songField !== "license" && + songField !== "licenseUrl" && + songField !== "permittedUses" + ) { + throw new Error(`Invalid demo provenance field 'song.${songField}'`); + } + } + const permittedUses = songRecord.permittedUses; + if (!Array.isArray(permittedUses) || permittedUses.length === 0 || permittedUses.length > 8) { + throw new Error("Invalid demo provenance field 'song.permittedUses'"); + } + const permittedUseNames = permittedUses.map((permittedUse, permittedUseIndex) => + asNonEmptyString(permittedUse, `song.permittedUses[${permittedUseIndex}]`) + ); + if (songRecord.license !== "CC0-1.0") { + throw new Error("Invalid demo provenance field 'song.license'"); + } + if (!Array.isArray(manifestRecord.assets) || manifestRecord.assets.length !== 3) { + throw new Error("Invalid demo provenance field 'assets'"); + } + const demoAssets: DemoProvenanceAsset[] = manifestRecord.assets.map( + (assetEntry, assetIndex) => { + if (assetEntry === null || typeof assetEntry !== "object" || Array.isArray(assetEntry)) { + throw new Error(`Invalid demo provenance field 'assets[${assetIndex}]'`); + } + const assetRecord = assetEntry as Record; + for (const assetField of Object.keys(assetRecord)) { + if ( + assetField !== "path" && + assetField !== "role" && + assetField !== "sha256" && + assetField !== "bytes" && + assetField !== "mediaType" + ) { + throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].${assetField}'`); + } + } + const assetPath = asNonEmptyString(assetRecord.path, `assets[${assetIndex}].path`); + if (assetPath === "." || assetPath === ".." || !RELATIVE_FILE_PATTERN.test(assetPath)) { + throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].path'`); + } + const assetSha256 = asNonEmptyString(assetRecord.sha256, `assets[${assetIndex}].sha256`); + if (!SHA256_PATTERN.test(assetSha256)) { + throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].sha256'`); + } + if ( + !Number.isSafeInteger(assetRecord.bytes) || + (assetRecord.bytes as number) <= 0 || + (assetRecord.bytes as number) > MAX_ASSET_BYTES + ) { + throw new Error(`Invalid demo provenance field 'assets[${assetIndex}].bytes'`); + } + return { + assetPath, + assetRole: asAssetRole(assetRecord.role), + assetSha256, + assetByteCount: assetRecord.bytes as number, + assetMediaType: asNonEmptyString(assetRecord.mediaType, `assets[${assetIndex}].mediaType`) + }; + } + ); + const assetRoles = new Set(demoAssets.map((demoAsset) => demoAsset.assetRole)); + for (const requiredRole of REQUIRED_ROLES) { + if (!assetRoles.has(requiredRole)) { + throw new Error("Invalid demo provenance field 'assets.role'"); + } + } + const serializedManifest = JSON.stringify(payload); + const serializedManifestBytes = UTF8_TEXT_ENCODER.encode(serializedManifest).byteLength; + if (serializedManifestBytes > MAX_MANIFEST_BYTES) { + throw new Error("Invalid demo provenance field 'root': manifest is too large"); + } + return { + manifestVersion: 1, + artifactKind: DEMO_PROVENANCE_KIND, + demoSong: { + songId: asNonEmptyString(songRecord.id, "song.id"), + songTitle: asNonEmptyString(songRecord.title, "song.title"), + performerName: asNonEmptyString(songRecord.performer, "song.performer"), + licenseExpression: "CC0-1.0", + licenseUrl: asNonEmptyString(songRecord.licenseUrl, "song.licenseUrl"), + permittedUses: permittedUseNames + }, + demoAssets + }; +} diff --git a/apps/desktop/src/lib/job_runner.test.ts b/apps/desktop/src/lib/job_runner.test.ts new file mode 100644 index 000000000..b4cb7b692 --- /dev/null +++ b/apps/desktop/src/lib/job_runner.test.ts @@ -0,0 +1,14 @@ +import { createDemoAnalysisJobRequest } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { enqueueSong, retrySong } from "./job_runner"; + +describe("legacy analysis runner browser boundary", () => { + it("fails closed instead of synthesizing browser analysis", async () => { + await expect(enqueueSong(createDemoAnalysisJobRequest())).rejects.toThrow( + "Analysis engine is unavailable outside the desktop runtime." + ); + await expect(retrySong("browser-job")).rejects.toThrow( + "Analysis engine is unavailable outside the desktop runtime." + ); + }); +}); diff --git a/apps/desktop/src/lib/job_runner.ts b/apps/desktop/src/lib/job_runner.ts index b024ad5a4..bb32d78a1 100644 --- a/apps/desktop/src/lib/job_runner.ts +++ b/apps/desktop/src/lib/job_runner.ts @@ -2,18 +2,19 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { type RehearsalWorkspace, - type SongRehearsalPack, type AnalysisJobRequest, parseRehearsalWorkspace, - isRehearsalWorkspace, - createDemoRehearsalSong + isRehearsalWorkspace } from "@bandscope/shared-types"; -/** Documented. */ -export type WorkspaceUpdateCallback = (workspace: RehearsalWorkspace) => void; +/** Receives validated rehearsal-workspace updates from the native bridge. */ +export type WorkspaceUpdateCallback = (rehearsalWorkspace: RehearsalWorkspace) => void; -/** Documented. */ -type TauriInvoke = (command: string, args?: Record) => Promise; +/** Invokes one typed Tauri command with its bridge-owned argument envelope. */ +type TauriInvoke = ( + bridgeCommand: string, + commandArguments?: Record +) => Promise; declare global { interface Window { @@ -21,181 +22,125 @@ declare global { } } -/** Documented. */ -function getInvoke(): TauriInvoke | null { +/** Returns the native Tauri invoke boundary when desktop runtime authority is available. */ +function getTauriInvoke(): TauriInvoke | null { if (typeof window === "undefined" || !isTauri()) { return null; } return window.__TAURI_INVOKE__ ?? invoke; } -const mockWorkspace: RehearsalWorkspace = { +const browserFallbackWorkspace: RehearsalWorkspace = { id: "mock-ws", title: "Browser Mock Workspace", songs: [], workspaceVersion: 1 }; -const mockSongsById = new Map( - mockWorkspace.songs.map(song => [song.id, song]) -); +type BrowserWorkspaceListener = (workspaceEvent: { payload: unknown }) => void; +const browserWorkspaceListeners = new Set(); -type MockListener = (event: { payload: unknown }) => void; -const mockListeners = new Set(); - -/** Documented. */ -function getMockSong(jobId: string): SongRehearsalPack | undefined { - return mockSongsById.get(jobId); +/** Broadcasts the read-only browser fallback workspace to local listeners. */ +function triggerBrowserWorkspaceUpdate(): void { + const workspacePayload = structuredClone(browserFallbackWorkspace); + browserWorkspaceListeners.forEach((workspaceListener) => + workspaceListener({ payload: workspacePayload }) + ); } /** - * Triggers a mock workspace update to all listeners. + * Serves only the read-only browser workspace boundary. + * + * Analysis mutations fail closed so browser-only code can never manufacture a + * successful rehearsal result that could be mistaken for native analysis. */ -function triggerMockUpdate() { - const payload = structuredClone(mockWorkspace); - mockListeners.forEach(listener => listener({ payload })); -} - -/** Documented. */ -async function browserFallback(command: string, args?: Record): Promise { - if (command === "get_workspace_state") { - return structuredClone(mockWorkspace); - } - - if (command === "enqueue_song") { - const request = args?.request as AnalysisJobRequest; - const packId = `pack-${Date.now()}`; - const pack: SongRehearsalPack = { - id: packId, - packState: "queued", - sourceLabel: request.sourceKind === "local_audio" ? request.sourceLabel : "Demo Song", - engineState: "queued" - }; - mockWorkspace.songs.push(pack); - mockSongsById.set(pack.id, pack); - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - - setTimeout(() => { - // We use Object.assign to mutate the cached pack reference, avoiding an O(N) lookup. - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - - return; - } - - if (command === "retry_song") { - const jobId = args?.jobId as string; - const pack = getMockSong(jobId); - if (pack) { - pack.packState = "queued"; - pack.engineState = "queued"; - - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - setTimeout(() => { - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - } - return; +async function browserFallback( + bridgeCommand: string, + _commandArguments?: Record +): Promise { + if (bridgeCommand === "get_workspace_state") { + return structuredClone(browserFallbackWorkspace); } - if (command === "cancel_song") { - const jobId = args?.jobId as string; - mockWorkspace.songs = mockWorkspace.songs.filter(p => p.id !== jobId); - mockSongsById.delete(jobId); - triggerMockUpdate(); - return; + if ( + bridgeCommand === "enqueue_song" || + bridgeCommand === "retry_song" || + bridgeCommand === "cancel_song" + ) { + throw new Error("Analysis engine is unavailable outside the desktop runtime."); } - throw new Error(`Unknown analysis bridge command: ${command}`); + throw new Error(`Unknown analysis bridge command: ${bridgeCommand}`); } -/** Documented. */ -async function invokeRunner(command: string, args?: Record): Promise { - const invokeCommand = getInvoke(); - if (invokeCommand) { - return invokeCommand(command, args); +/** Routes a bridge command to native Tauri or the fail-closed browser boundary. */ +async function invokeRunner( + bridgeCommand: string, + commandArguments?: Record +): Promise { + const tauriInvoke = getTauriInvoke(); + if (tauriInvoke) { + return tauriInvoke(bridgeCommand, commandArguments); } - return browserFallback(command, args); + return browserFallback(bridgeCommand, commandArguments); } -/** Documented. */ -export async function enqueueSong(request: AnalysisJobRequest): Promise { - await invokeRunner("enqueue_song", { request }); +/** Enqueues one analysis request through the native desktop bridge. */ +export async function enqueueSong(analysisRequest: AnalysisJobRequest): Promise { + await invokeRunner("enqueue_song", { request: analysisRequest }); } -/** Documented. */ -export async function retrySong(jobId: string): Promise { - await invokeRunner("retry_song", { jobId }); +/** Retries one existing analysis job through the native desktop bridge. */ +export async function retrySong(analysisJobId: string): Promise { + await invokeRunner("retry_song", { jobId: analysisJobId }); } -/** Documented. */ -export async function cancelSong(jobId: string): Promise { - await invokeRunner("cancel_song", { jobId }); +/** Cancels one existing analysis job through the native desktop bridge. */ +export async function cancelSong(analysisJobId: string): Promise { + await invokeRunner("cancel_song", { jobId: analysisJobId }); } -/** Documented. */ -export async function subscribeToWorkspaceUpdates(callback: WorkspaceUpdateCallback): Promise { - const invokeCommand = getInvoke(); - - if (invokeCommand) { - return listen("workspace-updated", (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); +/** Subscribes to validated workspace updates from native Tauri or the browser placeholder. */ +export async function subscribeToWorkspaceUpdates( + workspaceCallback: WorkspaceUpdateCallback +): Promise { + const tauriInvoke = getTauriInvoke(); + + if (tauriInvoke) { + return listen("workspace-updated", (workspaceEvent) => { + if (isRehearsalWorkspace(workspaceEvent.payload)) { + workspaceCallback(parseRehearsalWorkspace(workspaceEvent.payload)); } else { // eslint-disable-next-line no-console -- Warn about invalid payload structure console.warn("Received invalid workspace update from Tauri"); } }); - } else { - // Browser fallback - /** - * Internal listener for fallback mock updates. - */ - const listener: MockListener = (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); - } - }; - mockListeners.add(listener); - return () => { - mockListeners.delete(listener); - }; } + + const workspaceListener: BrowserWorkspaceListener = (workspaceEvent) => { + if (isRehearsalWorkspace(workspaceEvent.payload)) { + workspaceCallback(parseRehearsalWorkspace(workspaceEvent.payload)); + } + }; + browserWorkspaceListeners.add(workspaceListener); + triggerBrowserWorkspaceUpdate(); + return () => { + browserWorkspaceListeners.delete(workspaceListener); + }; } -/** Documented. */ +/** Reads the current rehearsal workspace without surfacing bridge exceptions to the UI. */ export async function getWorkspaceState(): Promise { try { - const response = await invokeRunner("get_workspace_state"); - if (!response) return null; - return parseRehearsalWorkspace(response); - } catch (error) { + const workspaceResponse = await invokeRunner("get_workspace_state"); + if (!workspaceResponse) return null; + return parseRehearsalWorkspace(workspaceResponse); + } catch (workspaceError) { // eslint-disable-next-line no-console -- Error logging for workspace state fetch failure - console.error("Failed to get workspace state:", error instanceof Error ? error.message : "Unknown error"); + console.error( + "Failed to get workspace state:", + workspaceError instanceof Error ? workspaceError.message : "Unknown error" + ); return null; } } diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..7904f422d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -27,9 +27,9 @@ "confidenceLevelHigh": "Ready to trust", "provenanceSourceModel": "Auto-detected", "provenanceSourceUser": "User-confirmed", - "workspaceReadyToAnalyzeTitle": "Ready to Analyze", + "workspaceReadyToAnalyzeTitle": "Start tonight's rehearsal", "workspaceAnalyzingAudioTitle": "Analyzing Audio", - "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "workspaceEmptyState": "Try the licensed demo or use your own song. Your audio stays on this device.", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", @@ -149,6 +149,13 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "tryTheDemo": "Try the demo", + "useMyOwnSong": "Use my own song", + "demoSelectedNextAction": "Start analysis to open tonight's first cue.", + "localSelectedNextAction": "Start analysis to open your first cue.", + "demoUnavailable": "The licensed demo song could not be loaded. Use your own song to start tonight.", + "demoLimitation": "The demo is original BandScope audio for evaluation, not a commercial track.", + "chooseDifferentSong": "Choose a different song", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..50e3029a2 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -27,9 +27,9 @@ "confidenceLevelHigh": "믿고 가져가도 됨", "provenanceSourceModel": "자동 추정", "provenanceSourceUser": "사용자 확인", - "workspaceReadyToAnalyzeTitle": "분석 준비 완료", + "workspaceReadyToAnalyzeTitle": "오늘 합주를 시작하세요", "workspaceAnalyzingAudioTitle": "오디오 분석 중", - "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "workspaceEmptyState": "라이선스가 있는 데모를 써 보거나 내 곡을 사용하세요. 오디오는 이 기기에 남습니다.", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도", @@ -149,6 +149,13 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "tryTheDemo": "데모 써 보기", + "useMyOwnSong": "내 곡 사용", + "demoSelectedNextAction": "분석을 시작해 오늘 첫 큐를 여세요.", + "localSelectedNextAction": "분석을 시작해 첫 큐를 여세요.", + "demoUnavailable": "라이선스 데모 곡을 불러올 수 없습니다. 내 곡으로 오늘 합주를 시작하세요.", + "demoLimitation": "데모는 평가용 오리지널 BandScope 오디오이며 상업 음원이 아닙니다.", + "chooseDifferentSong": "다른 곡 선택", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..8454df2f7 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -22,7 +22,9 @@ export default defineConfig({ include: [ "src/App.tsx", "src/lib/export.ts", + "src/lib/demo.ts", "src/i18n/index.ts", + "src/features/workspace/WorkspaceStates.tsx", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", "src/features/score/scoreStorage.ts" diff --git a/docs/activation/licensed-demo.md b/docs/activation/licensed-demo.md new file mode 100644 index 000000000..10329d3ba --- /dev/null +++ b/docs/activation/licensed-demo.md @@ -0,0 +1,52 @@ +# Licensed demo and first-run rehearsal + +A buyer who launches BandScope with no song loaded must be able to start tonight's rehearsal without locating a file, inventing MIR terminology, or leaving the device. + +This package is the licensed demo slice of #964. It does **not** close the full first-run measurement program, and it does **not** invent a parallel MIR product (#828 still owns #770). + +## What the buyer sees + +1. Open BandScope. +2. The empty workspace names **Try the demo** and **Use my own song**. +3. Privacy copy: your audio stays on this device. +4. **Try the demo** validates the bundled original recording through the same local-audio intake as a user file, then enables **Start analysis**. +5. **Use my own song** opens the existing local-file picker. +6. Analysis is never started automatically. + +Korean and English keep the same choices, limitation, and next action. + +## Licensed package + +Canonical files live in `apps/desktop/src-tauri/resources/demo/`: + +| File | Role | +| --- | --- | +| `late-night-set.wav` | Original two-section evaluation audio (CC0 1.0) | +| `LICENSE` | CC0 1.0 waiver | +| `annotations.json` | Ground-truth verse/chorus times for later #770 evidence | +| `provenance.json` | Exact hashes, byte sizes, performer, and permitted uses | + +`Late Night Set` is original Contextual Wisdom Lab audio. It is not a commercial recording. Private or copyrighted benchmark assets stay out of this public package. + +After changing the source audio, regenerate `late-night-set.wav` with `scripts/generate_licensed_demo_wav.py`. Then refresh `provenance.json` with the final WAV's byte size and SHA-256, and finally update the matching SHA-256 entries in `supply-chain/supplemental-component-inventory.json` for the changed packaged assets before packaging or committing them. The inventory and provenance values must describe the final bytes that will ship. + +The checked-in provenance JSON is a stable public wire contract and therefore keeps its established `song.id`, `song.title`, `song.performer`, `song.license`, and asset `path`, `role`, `sha256`, `bytes`, and `mediaType` keys. `parseDemoProvenanceManifest` is the anti-corruption boundary: after validating those wire keys, organization-owned code uses the semantic internal vocabulary `demoSong.songId`, `songTitle`, `performerName`, `licenseExpression`, and `demoAssets[].assetPath`, `assetRole`, `assetSha256`, `assetByteCount`, and `assetMediaType`. New internal consumers must not propagate the legacy generic wire names beyond that boundary. + +## Production boundary + +`select_demo_audio_source` resolves the bundled WAV from the Tauri resource directory, rejects missing/symlink/non-WAV/wrong-size/non-RIFF files, then reuses the same project/cache/temp bootstrap as `select_local_audio_source`. Browser fallback fails closed and tells the musician to use their own song. No mocked analysis success is presented as a production pass. + +## Security Notes + +- Untrusted input: bundled resource bytes plus the same local-audio bootstrap as a user-selected file. +- Trust boundary: empty-card action → allowlisted Tauri command → resource-dir lookup → size/magic/symlink checks → app-owned project roots. The provenance manifest is not a filesystem authority document and never dereferences user paths or URLs. +- Safe failure: missing or altered demo assets surface payload-free copy that names **Use my own song**. Rejected paths are not rendered. +- Privacy: no telemetry, no demo download, no network path for the bundled audio. +- Test points: provenance hash/size contract, browser fail-closed demo intake, empty-card actions, Rust size/magic/symlink rejection. + +## Out of scope + +- No account, cloud upload, or telemetry consent. +- No copyrighted commercial song. +- No role/goal onboarding form in this slice. +- No dependency, lockfile, or vulnerability-suppression delta. Canonical npm HIGH findings remain #783-owned. diff --git a/scripts/generate_licensed_demo_wav.py b/scripts/generate_licensed_demo_wav.py new file mode 100644 index 000000000..ffd3fa805 --- /dev/null +++ b/scripts/generate_licensed_demo_wav.py @@ -0,0 +1,51 @@ +"""Generate the licensed BandScope demo WAV fixture. + +The output is original Contextual Wisdom Lab audio released under CC0 1.0. +""" + +from __future__ import annotations + +import argparse +import math +import struct +import wave +from pathlib import Path + +SAMPLE_RATE = 22050 +DURATION_SECONDS = 10 +AMPLITUDE = 0.2 + + +def write_demo_wav(path: Path) -> None: + """Write the two-section sine WAV used by the licensed demo package.""" + path.parent.mkdir(parents=True, exist_ok=True) + n_frames = SAMPLE_RATE * DURATION_SECONDS + with wave.open(str(path), "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(SAMPLE_RATE) + frames = bytearray() + for index in range(n_frames): + moment = index / SAMPLE_RATE + frequency = 220.0 if moment < DURATION_SECONDS / 2 else 330.0 + sample = int(AMPLITUDE * 32767.0 * math.sin(2.0 * math.pi * frequency * moment)) + frames.extend(struct.pack(" int: + """Write ``late-night-set.wav`` to the bundled demo resource directory.""" + parser = argparse.ArgumentParser(description="Generate the licensed BandScope demo WAV.") + parser.add_argument( + "--output", + type=Path, + default=Path("apps/desktop/src-tauri/resources/demo/late-night-set.wav"), + help="Destination WAV path.", + ) + args = parser.parse_args() + write_demo_wav(args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/analysis-engine/tests/test_demo_supply_chain_inventory.py b/services/analysis-engine/tests/test_demo_supply_chain_inventory.py new file mode 100644 index 000000000..bf7f70807 --- /dev/null +++ b/services/analysis-engine/tests/test_demo_supply_chain_inventory.py @@ -0,0 +1,74 @@ +"""Supply-chain regressions for the packaged licensed demo assets.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +INVENTORY_PATH = REPO_ROOT / "supply-chain" / "supplemental-component-inventory.json" +DEMO_RESOURCE_ROOT = REPO_ROOT / "apps" / "desktop" / "src-tauri" / "resources" / "demo" +DESKTOP_CORE_SOURCE_PATH = REPO_ROOT / "apps" / "desktop" / "core" / "src" / "lib.rs" +RUNTIME_DEMO_AUDIO_BYTES_PATTERN = re.compile( + r"pub const DEMO_AUDIO_BYTES: u64 = (?P\d+);" +) +EXPECTED_DEMO_ASSETS = { + "late-night-set.wav": "9e4d5598a8e0f2836b4e7637ec19adfb48ce93eb8d18a2984d20ae597d05a8fb", + "LICENSE": "1657b89949ca8bfb2920e26dceb4c1012d6212b5d77eda7d7f3921da29adde5e", + "annotations.json": "6ed9253d81168f9cb6d1d3fa905849c5baafc6d079e91e287b03cba4190ca7f7", + "provenance.json": "4d980735aa37d3fb0d4fc5e2e1c4013181f51346d26d75b207e20f9154cdf338", +} + + +def test_demo_package_is_listed_in_supplemental_inventory() -> None: + """Require every packaged demo asset to be inventory-traceable by checksum.""" + actual_demo_assets = { + asset_path.name + for asset_path in DEMO_RESOURCE_ROOT.iterdir() + if not asset_path.is_symlink() and asset_path.is_file() + } + assert actual_demo_assets == set(EXPECTED_DEMO_ASSETS) + + inventory_document = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) + bundled_assets = inventory_document.get("bundledAssets") + + assert isinstance(bundled_assets, list) + demo_asset_records = { + asset_record["storagePath"]: asset_record + for asset_record in bundled_assets + if isinstance(asset_record, dict) + and isinstance(asset_record.get("storagePath"), str) + and asset_record["storagePath"].startswith("apps/desktop/src-tauri/resources/demo/") + } + + expected_storage_paths = { + f"apps/desktop/src-tauri/resources/demo/{asset_name}" + for asset_name in EXPECTED_DEMO_ASSETS + } + assert set(demo_asset_records) == expected_storage_paths + + for asset_name, expected_sha256 in EXPECTED_DEMO_ASSETS.items(): + storage_path = f"apps/desktop/src-tauri/resources/demo/{asset_name}" + asset_record = demo_asset_records[storage_path] + asset_bytes = (DEMO_RESOURCE_ROOT / asset_name).read_bytes() + + assert asset_record["assetName"] == asset_name + assert asset_record["licenseExpression"] == "CC0-1.0" + assert asset_record["assetChecksum"] == f"sha256:{expected_sha256}" + assert hashlib.sha256(asset_bytes).hexdigest() == expected_sha256 + + +def test_runtime_demo_audio_size_matches_packaged_artifact() -> None: + """Keep the Rust runtime size guard synchronized with the bundled demo WAV.""" + desktop_core_source = DESKTOP_CORE_SOURCE_PATH.read_text(encoding="utf-8") + runtime_size_match = RUNTIME_DEMO_AUDIO_BYTES_PATTERN.search(desktop_core_source) + + assert runtime_size_match is not None + runtime_demo_audio_bytes = int(runtime_size_match.group("demo_audio_bytes")) + packaged_demo_audio_bytes = ( + DEMO_RESOURCE_ROOT / "late-night-set.wav" + ).stat().st_size + + assert runtime_demo_audio_bytes == packaged_demo_audio_bytes diff --git a/services/analysis-engine/tests/test_licensed_demo_duration_contract.py b/services/analysis-engine/tests/test_licensed_demo_duration_contract.py new file mode 100644 index 000000000..6ffd634b6 --- /dev/null +++ b/services/analysis-engine/tests/test_licensed_demo_duration_contract.py @@ -0,0 +1,71 @@ +"""Regressions for the licensed demo structural-analysis contract.""" + +from __future__ import annotations + +import json +import wave +from pathlib import Path + +import numpy as np + +from bandscope_analysis.sections.segmenter import MIN_SEGMENT_DURATION_SECONDS, segment_audio + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEMO_RESOURCE_ROOT = REPO_ROOT / "apps" / "desktop" / "src-tauri" / "resources" / "demo" +DEMO_AUDIO_PATH = DEMO_RESOURCE_ROOT / "late-night-set.wav" +DEMO_ANNOTATION_PATH = DEMO_RESOURCE_ROOT / "annotations.json" + + +def _read_demo_audio(audio_path: Path = DEMO_AUDIO_PATH) -> tuple[np.ndarray, int, float]: + """Decode one mono PCM demo fixture through the stdlib WAV reader.""" + with wave.open(str(audio_path), "rb") as wav_file: + sample_rate = wav_file.getframerate() + declared_frame_count = wav_file.getnframes() + assert wav_file.getnchannels() == 1 + assert wav_file.getsampwidth() == 2 + decoded_frame_bytes = wav_file.readframes(declared_frame_count) + audio_samples = np.frombuffer(decoded_frame_bytes, dtype=" None: + """Reject duration authority from a WAV header when the payload is truncated.""" + truncated_audio_path = tmp_path / "truncated-demo.wav" + with wave.open(str(truncated_audio_path), "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(10) + wav_file.writeframes(b"\x00\x00" * 100) + + complete_audio_bytes = truncated_audio_path.read_bytes() + truncated_audio_path.write_bytes(complete_audio_bytes[:-20]) + + audio_samples, sample_rate, duration_seconds = _read_demo_audio(truncated_audio_path) + assert audio_samples.size == 90 + assert duration_seconds == audio_samples.size / sample_rate + + +def test_licensed_demo_spans_two_structural_windows() -> None: + """Keep the bundled demo long enough to avoid the short-audio fallback.""" + _audio_samples, _sample_rate, duration_seconds = _read_demo_audio() + assert duration_seconds >= MIN_SEGMENT_DURATION_SECONDS * 2 + + annotation_document = json.loads(DEMO_ANNOTATION_PATH.read_text(encoding="utf-8")) + section_ranges = [section["timeRange"] for section in annotation_document["sections"]] + assert section_ranges == [{"start": 0, "end": 5}, {"start": 5, "end": 10}] + assert all( + section_range["end"] - section_range["start"] >= MIN_SEGMENT_DURATION_SECONDS + for section_range in section_ranges + ) + + +def test_licensed_demo_reaches_structural_segmentation() -> None: + """Prove the source fixture itself yields multiple structural candidates.""" + audio_samples, sample_rate, duration_seconds = _read_demo_audio() + detected_sections = segment_audio(audio_samples, sample_rate, duration_seconds) + assert len(detected_sections) >= 2 + assert all( + "Audio too short for structural analysis" not in section["confidence_notes"] + for section in detected_sections + ) diff --git a/supply-chain/supplemental-component-inventory.json b/supply-chain/supplemental-component-inventory.json index 784d90d57..8bd42dd17 100644 --- a/supply-chain/supplemental-component-inventory.json +++ b/supply-chain/supplemental-component-inventory.json @@ -23,6 +23,48 @@ "verification": "SHA256 verified in bandscope_analysis.separation.audio_separator.AudioStemSeparator._load_model_profile" } ], + "bundledAssets": [ + { + "assetName": "late-night-set.wav", + "assetVersion": "1.0.0", + "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/late-night-set.wav", + "licenseExpression": "CC0-1.0", + "assetChecksum": "sha256:9e4d5598a8e0f2836b4e7637ec19adfb48ce93eb8d18a2984d20ae597d05a8fb", + "storagePath": "apps/desktop/src-tauri/resources/demo/late-night-set.wav", + "mediaType": "audio/wav", + "releaseUsage": "Bundled original rehearsal demo audio for the first-run Try the demo flow." + }, + { + "assetName": "LICENSE", + "assetVersion": "1.0.0", + "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/LICENSE", + "licenseExpression": "CC0-1.0", + "assetChecksum": "sha256:1657b89949ca8bfb2920e26dceb4c1012d6212b5d77eda7d7f3921da29adde5e", + "storagePath": "apps/desktop/src-tauri/resources/demo/LICENSE", + "mediaType": "text/plain", + "releaseUsage": "Bundled CC0 1.0 license notice accompanying the rehearsal demo package." + }, + { + "assetName": "annotations.json", + "assetVersion": "1.0.0", + "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/annotations.json", + "licenseExpression": "CC0-1.0", + "assetChecksum": "sha256:6ed9253d81168f9cb6d1d3fa905849c5baafc6d079e91e287b03cba4190ca7f7", + "storagePath": "apps/desktop/src-tauri/resources/demo/annotations.json", + "mediaType": "application/json", + "releaseUsage": "Bundled ground-truth section annotations for the original rehearsal demo package." + }, + { + "assetName": "provenance.json", + "assetVersion": "1.0.0", + "sourceUrl": "local-repo://apps/desktop/src-tauri/resources/demo/provenance.json", + "licenseExpression": "CC0-1.0", + "assetChecksum": "sha256:4d980735aa37d3fb0d4fc5e2e1c4013181f51346d26d75b207e20f9154cdf338", + "storagePath": "apps/desktop/src-tauri/resources/demo/provenance.json", + "mediaType": "application/json", + "releaseUsage": "Bundled provenance manifest binding the rehearsal demo package to its hashes, sizes, performer, and permitted uses." + } + ], "notes": [ "Add ffmpeg, yt-dlp, model weights, or sidecar assets here before they ship.", "Track source URL, version, checksum, license, storage path, and release usage for every item."