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.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..b9b7a8c74 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"); @@ -117,50 +142,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 - }); - - 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 - }); + it("fails browser analysis closed instead of synthesizing a rehearsal result", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); - 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..c87010fd7 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, @@ -27,20 +26,16 @@ 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; @@ -115,65 +110,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 +208,28 @@ export async function selectLocalAudioSource(): Promise { + try { + const response = await invokeAnalysis("select_demo_audio_source"); + return { + ok: true, + bootstrap: parseProjectBootstrapSummary(response) + }; + } 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; @@ -331,7 +317,12 @@ export async function importYoutubeUrl(url: string): Promise { + it("accepts the bundled CC0 package and verifies every recorded hash", () => { + const manifest = bundledManifest(); + expect(manifest.artifactKind).toBe(DEMO_PROVENANCE_KIND); + expect(manifest.song.license).toBe("CC0-1.0"); + expect(manifest.song.title).toBe("Late Night Set"); + expect(manifest.song.permittedUses).toEqual([ + "evaluation", + "redistribution", + "rehearsal-demo" + ]); + for (const asset of manifest.assets) { + const bytes = readFileSync(path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, asset.path)); + expect(bytes.byteLength).toBe(asset.bytes); + expect(createHash("sha256").update(bytes).digest("hex")).toBe(asset.sha256); + } + const audio = manifest.assets.find((asset) => asset.role === "audio"); + expect(audio?.path).toBe("late-night-set.wav"); + const wav = readFileSync(path.join(workspaceRoot, DEMO_RESOURCE_DIRECTORY, "late-night-set.wav")); + expect(wav.subarray(0, 4).toString("ascii")).toBe("RIFF"); + expect(wav.subarray(8, 12).toString("ascii")).toBe("WAVE"); + }); + + it("rejects unknown fields, the wrong kind, and a missing asset role", () => { + const manifest = bundledManifest(); + expect(() => parseDemoProvenanceManifest(null)).toThrow(/root/); + expect(() => parseDemoProvenanceManifest([])).toThrow(/root/); + expect(() => parseDemoProvenanceManifest({ ...manifest, extra: true })).toThrow( + /Invalid demo provenance field 'extra'/ + ); + expect(() => parseDemoProvenanceManifest({ ...manifest, artifactKind: "other" })).toThrow( + /artifactKind/ + ); + expect(() => parseDemoProvenanceManifest({ ...manifest, manifestVersion: 2 })).toThrow( + /manifestVersion/ + ); + expect(() => parseDemoProvenanceManifest({ ...manifest, song: null })).toThrow(/song/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + song: { ...manifest.song, extra: "nope" } + }) + ).toThrow(/song\.extra/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + song: { ...manifest.song, license: "MIT" } + }) + ).toThrow(/license/); + const withoutAudio = { + ...manifest, + assets: manifest.assets.filter((asset) => asset.role !== "audio") + }; + expect(() => parseDemoProvenanceManifest(withoutAudio)).toThrow(/assets/); + }); + + it("rejects traversal paths, dot segments, non-hex hashes, and malformed assets", () => { + const manifest = bundledManifest(); + const [audio, license, annotations] = manifest.assets; + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, path: "../secret.wav" }, license, annotations] + }) + ).toThrow(/assets\[0\]\.path/); + for (const dotSegment of [".", ".."]) { + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, path: dotSegment }, license, annotations] + }) + ).toThrow(/assets\[0\]\.path/); + } + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, sha256: "not-a-hash" }, license, annotations] + }) + ).toThrow(/sha256/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, bytes: 1.5 }, license, annotations] + }) + ).toThrow(/bytes/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, extra: true }, license, annotations] + }) + ).toThrow(/assets\[0\]\.extra/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [null, license, annotations] + }) + ).toThrow(/assets\[0\]/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + assets: [{ ...audio, role: "stems" }, license, annotations] + }) + ).toThrow(/assets\.role/); + expect(() => + parseDemoProvenanceManifest({ + ...manifest, + song: { ...manifest.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..5f3ee2417 --- /dev/null +++ b/apps/desktop/src/lib/demo.ts @@ -0,0 +1,159 @@ +/** Kind discriminator for the public licensed-demo provenance manifest. */ +const DEMO_PROVENANCE_KIND = "bandscope.licensed-demo" 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 }; + +/** Permitted asset roles inside one licensed demo package. */ +export type DemoAssetRole = "audio" | "license" | "annotations"; + +/** One hashed file in the licensed demo package. */ +export type DemoProvenanceAsset = { + path: string; + role: DemoAssetRole; + sha256: string; + bytes: number; + mediaType: string; +}; + +/** Provenance contract for the redistributable BandScope demo song. */ +export type DemoProvenanceManifest = { + manifestVersion: 1; + artifactKind: typeof DEMO_PROVENANCE_KIND; + song: { + id: string; + title: string; + performer: string; + license: "CC0-1.0"; + licenseUrl: string; + permittedUses: string[]; + }; + assets: 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"]; + +/** 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 one licensed-demo provenance manifest and reject unknown fields. */ +export function parseDemoProvenanceManifest(payload: unknown): DemoProvenanceManifest { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Invalid demo provenance field 'root'"); + } + const record = payload as Record; + const allowed = new Set(["manifestVersion", "artifactKind", "song", "assets"]); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new Error(`Invalid demo provenance field '${key}'`); + } + } + if (record.manifestVersion !== 1) { + throw new Error("Invalid demo provenance field 'manifestVersion'"); + } + if (record.artifactKind !== DEMO_PROVENANCE_KIND) { + throw new Error("Invalid demo provenance field 'artifactKind'"); + } + if (record.song === null || typeof record.song !== "object" || Array.isArray(record.song)) { + throw new Error("Invalid demo provenance field 'song'"); + } + const songRecord = record.song as Record; + for (const key of Object.keys(songRecord)) { + if ( + key !== "id" && + key !== "title" && + key !== "performer" && + key !== "license" && + key !== "licenseUrl" && + key !== "permittedUses" + ) { + throw new Error(`Invalid demo provenance field 'song.${key}'`); + } + } + const permittedUses = songRecord.permittedUses; + if (!Array.isArray(permittedUses) || permittedUses.length === 0 || permittedUses.length > 8) { + throw new Error("Invalid demo provenance field 'song.permittedUses'"); + } + const uses = permittedUses.map((entry, index) => asNonEmptyString(entry, `song.permittedUses[${index}]`)); + if (songRecord.license !== "CC0-1.0") { + throw new Error("Invalid demo provenance field 'song.license'"); + } + if (!Array.isArray(record.assets) || record.assets.length !== 3) { + throw new Error("Invalid demo provenance field 'assets'"); + } + const assets: DemoProvenanceAsset[] = record.assets.map((entry, index) => { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Invalid demo provenance field 'assets[${index}]'`); + } + const asset = entry as Record; + for (const key of Object.keys(asset)) { + if (key !== "path" && key !== "role" && key !== "sha256" && key !== "bytes" && key !== "mediaType") { + throw new Error(`Invalid demo provenance field 'assets[${index}].${key}'`); + } + } + const assetPath = asNonEmptyString(asset.path, `assets[${index}].path`); + if (assetPath === "." || assetPath === ".." || !RELATIVE_FILE_PATTERN.test(assetPath)) { + throw new Error(`Invalid demo provenance field 'assets[${index}].path'`); + } + const sha256 = asNonEmptyString(asset.sha256, `assets[${index}].sha256`); + if (!SHA256_PATTERN.test(sha256)) { + throw new Error(`Invalid demo provenance field 'assets[${index}].sha256'`); + } + if ( + !Number.isSafeInteger(asset.bytes) || + (asset.bytes as number) <= 0 || + (asset.bytes as number) > MAX_ASSET_BYTES + ) { + throw new Error(`Invalid demo provenance field 'assets[${index}].bytes'`); + } + return { + path: assetPath, + role: asAssetRole(asset.role), + sha256, + bytes: asset.bytes as number, + mediaType: asNonEmptyString(asset.mediaType, `assets[${index}].mediaType`) + }; + }); + const roles = new Set(assets.map((asset) => asset.role)); + for (const role of REQUIRED_ROLES) { + if (!roles.has(role)) { + throw new Error("Invalid demo provenance field 'assets.role'"); + } + } + const encoded = JSON.stringify(payload); + if (encoded.length > MAX_MANIFEST_BYTES) { + throw new Error("Invalid demo provenance field 'root'"); + } + return { + manifestVersion: 1, + artifactKind: DEMO_PROVENANCE_KIND, + song: { + id: asNonEmptyString(songRecord.id, "song.id"), + title: asNonEmptyString(songRecord.title, "song.title"), + performer: asNonEmptyString(songRecord.performer, "song.performer"), + license: "CC0-1.0", + licenseUrl: asNonEmptyString(songRecord.licenseUrl, "song.licenseUrl"), + permittedUses: uses + }, + assets + }; +} 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..72796421e --- /dev/null +++ b/docs/activation/licensed-demo.md @@ -0,0 +1,50 @@ +# 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 the WAV with `scripts/generate_licensed_demo_wav.py`, then refresh `provenance.json` with the final file's byte size and SHA-256 before packaging or committing it. + +## 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..4199a7de7 --- /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 = 2 +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 < 1.0 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())