From 7ae8088043546c34a14b0df497d35a8aebc886ec Mon Sep 17 00:00:00 2001 From: Steve 'Cutter' Blades Date: Mon, 27 Apr 2026 09:56:23 -0400 Subject: [PATCH] feat: Add audio-only playback support and refresh workspace tooling --- .github/copilot-instructions.md | 1 + .gitignore | 4 +- .prettierignore | 3 +- apps/demo/src/app/app.element.ts | 27 +- apps/framework-demo/src/App.spec.tsx | 8 +- .../FrameworkDemo.components.spec.tsx | 27 +- .../FrameworkDemo.hooks.spec.tsx | 63 + .../FilePickerRow/FilePickerRow.component.tsx | 8 +- .../hooks/useFilePickerRowProps.memo.tsx | 12 +- .../StageDisplay/StageDisplay.component.tsx | 8 +- .../StageDisplay/StageDisplay.module.css | 12 + .../hooks/useStageDisplayProps.memo.tsx | 3 + .../hooks/useFrameworkDemo.context.tsx | 6 + apps/storybook-hub/docs/architecture.mdx | 2 +- apps/storybook-hub/docs/contracts-loader.mdx | 8 +- apps/storybook-hub/docs/contracts-player.mdx | 6 +- apps/storybook-hub/docs/getting-started.mdx | 4 +- apps/storybook-hub/docs/migration-guide.mdx | 5 +- .../storybook-react/stories/react-example.mdx | 3 + .../stories/framework-agnostic-example.mdx | 3 + package.json | 44 +- packages/cdg-controls/package.json | 2 +- packages/cdg-loader/README.md | 12 +- .../cdg-loader/src/lib/loader-core.spec.ts | 188 ++- packages/cdg-loader/src/lib/loader-core.ts | 274 +++- packages/cdg-loader/src/lib/loader.spec.ts | 175 +++ packages/cdg-loader/src/lib/loader.ts | 3 + packages/cdg-loader/src/lib/loader.worker.ts | 3 +- packages/cdg-loader/src/lib/types.ts | 20 +- packages/cdg-player/README.md | 4 +- packages/cdg-player/src/lib/player.spec.ts | 72 + packages/cdg-player/src/lib/player.ts | 79 +- pnpm-lock.yaml | 1360 ++++++++++------- 33 files changed, 1745 insertions(+), 704 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index efae0a8..9eee00d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -53,3 +53,4 @@ - Run the smallest relevant validation for the change when possible. - For documentation changes, prefer `pnpm run ci:docs` and the relevant Storybook build. - For broader changes, use the repo validation commands already documented in Storybook contribution guidance. +- Keep coverage thresholds unchanged, but aim to stay at least 5 percentage points above the enforced minima when practical (currently branch >= 85% and functions >= 95%). diff --git a/.gitignore b/.gitignore index e057d98..8a35999 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ vitest.config.*.timestamp* /dist/ /coverage /artifacts/perf/** -!/artifacts/perf/README.md \ No newline at end of file +!/artifacts/perf/README.md +.nx/polygraph +.nx/self-healing \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index e23f730..7b03589 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ # Add files here to ignore them from prettier formatting /coverage /.nx/cache -/.nx/workspace-data \ No newline at end of file +/.nx/workspace-data +.nx/self-healing \ No newline at end of file diff --git a/apps/demo/src/app/app.element.ts b/apps/demo/src/app/app.element.ts index 4c06dc1..7211e24 100644 --- a/apps/demo/src/app/app.element.ts +++ b/apps/demo/src/app/app.element.ts @@ -65,8 +65,8 @@ const createAppTemplate = ({
- - + + ${showPerfDiagnostics ? '' : ''}
@@ -147,6 +147,7 @@ export class AppElement extends HTMLElement { private controlsModel: CdgControlsModel | null = null; private controlsParts: DisposableControl[] = []; private controlsUnsubscribe: (() => void) | null = null; + private canvasElement: HTMLCanvasElement | null = null; private statusElement: HTMLElement | null = null; private titleImage: HTMLElement | null = null; private titleMeta: HTMLElement | null = null; @@ -158,6 +159,7 @@ export class AppElement extends HTMLElement { private lastStatus: string | null = null; private statusFadeTimeoutId: number | null = null; private hasPlaybackStarted = false; + private hasGraphicsTrack = true; private readonly showPerfDiagnostics = isLocalDevelopmentRuntime(); private renderSamples: RenderSample[] = []; @@ -205,6 +207,7 @@ export class AppElement extends HTMLElement { const canvas = this.querySelector( '[data-role="canvas"]', ); + this.canvasElement = canvas; const audio = this.querySelector('[data-role="audio"]'); const transportContainer = this.querySelector( '[data-role="transport-bar"]', @@ -341,6 +344,7 @@ export class AppElement extends HTMLElement { } this.syncTitleImage('idle'); this.syncLayout('idle'); + this.syncStageGraphicsVisibility(); stage.addEventListener('click', (event) => { // Stage click toggles play/pause, except when interacting with floating settings. @@ -376,7 +380,9 @@ export class AppElement extends HTMLElement { this.player.stop(); this.hasPlaybackStarted = false; + this.hasGraphicsTrack = true; this.setTitleMetadata(null); + this.syncStageGraphicsVisibility(); this.syncTitleImage('loading'); this.syncLayout('loading'); this.setStatusMessage('Loading track...'); @@ -406,6 +412,9 @@ export class AppElement extends HTMLElement { }); } + this.hasGraphicsTrack = loadedTrack?.hasGraphics ?? false; + this.syncStageGraphicsVisibility(); + this.hasPlaybackStarted = false; this.syncTitleImage('ready'); this.setStatusMessage('Track loaded.'); @@ -423,6 +432,8 @@ export class AppElement extends HTMLElement { errorMessage: message, }); this.setStatusMessage(`Load failed: ${message}`); + this.hasGraphicsTrack = true; + this.syncStageGraphicsVisibility(); this.syncTitleImage('error'); this.syncLayout('error'); }) @@ -556,6 +567,7 @@ export class AppElement extends HTMLElement { const shouldShowPlayer = status !== 'idle'; this.appShell.classList.toggle('show-player', shouldShowPlayer); this.appShell.classList.toggle('is-playing', status === 'playing'); + this.appShell.classList.toggle('has-graphics', this.hasGraphicsTrack); if (status === 'ready' || status === 'playing' || status === 'paused') { this.appShell.classList.add('has-track'); @@ -565,6 +577,17 @@ export class AppElement extends HTMLElement { this.appShell.classList.remove('has-track'); } + /** + * Hides canvas when the loaded track has no graphics stream. + */ + private syncStageGraphicsVisibility(): void { + if (!this.canvasElement) { + return; + } + + this.canvasElement.style.display = this.hasGraphicsTrack ? 'block' : 'none'; + } + /** * Records one render telemetry event, keeps the sample window bounded, * then refreshes all diagnostics outputs (global artifact, HUD, export state). diff --git a/apps/framework-demo/src/App.spec.tsx b/apps/framework-demo/src/App.spec.tsx index 72f4177..01cd652 100644 --- a/apps/framework-demo/src/App.spec.tsx +++ b/apps/framework-demo/src/App.spec.tsx @@ -231,7 +231,9 @@ describe('App', () => { fireEvent.click(canvas.parentElement); expect(harness.controlsModel.togglePlayPause).toHaveBeenCalledTimes(2); - const fileInput = screen.getByLabelText('Select a karaoke zip (.zip)'); + const fileInput = screen.getByLabelText( + 'Select audio or karaoke zip (audio/*, .zip)', + ); const file = new File(['zip-content'], 'demo-track.zip', { type: 'application/zip', }); @@ -310,7 +312,9 @@ describe('App', () => { .disabled, ).toBe(true); - const fileInput = screen.getByLabelText('Select a karaoke zip (.zip)'); + const fileInput = screen.getByLabelText( + 'Select audio or karaoke zip (audio/*, .zip)', + ); const file = new File(['zip-content'], 'broken-track.zip', { type: 'application/zip', }); diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx index 45b92df..39ff29e 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx @@ -36,6 +36,8 @@ const createContextValue = (overrides: Record = {}) => ({ }, titleMetadata: null, setTitleMetadata: vi.fn(), + hasGraphicsTrack: true, + setHasGraphicsTrack: vi.fn(), perfSummary: null, hasTrack: false, showTitle: false, @@ -119,7 +121,9 @@ describe('Framework demo components', () => { render(); - const input = screen.getByLabelText('Select a karaoke zip (.zip)'); + const input = screen.getByLabelText( + 'Select audio or karaoke zip (audio/*, .zip)', + ); fireEvent.change(input, { target: { files: [] } }); expect(player.stop).not.toHaveBeenCalled(); @@ -255,4 +259,25 @@ describe('Framework demo components', () => { screen.getByText(/does not support CSS Anchor Positioning/i), ).toBeTruthy(); }); + + it('renders audio-only stage state with hidden canvas and placeholder perf copy', () => { + mockUseFrameworkDemoContext.mockReturnValue( + createContextValue({ + hasGraphicsTrack: false, + hasTrack: true, + showTitle: false, + showPerfDiagnostics: true, + perfSummary: null, + isStatusVisible: false, + }), + ); + + const { container } = render(); + + const canvas = container.querySelector('canvas'); + expect(canvas?.className).toMatch(/canvasHidden/); + expect( + screen.getByText('Speed check: play a song to collect samples...'), + ).toBeTruthy(); + }); }); diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx index a8de1b7..c03cc55 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx @@ -17,6 +17,7 @@ const createContextValue = (overrides: Record = {}) => ({ player: null, controlsModel: null, setTitleMetadata: vi.fn(), + setHasGraphicsTrack: vi.fn(), showPerfDiagnostics: false, perfSummary: null, showStatusMessage: vi.fn(), @@ -84,6 +85,40 @@ describe('Framework demo hook edge cases', () => { expect(controlsModel.setPitchSemitones).not.toHaveBeenCalled(); }); + it('parses valid settings values even when no controls model is available', () => { + mockUseFrameworkDemoContext.mockReturnValue( + createContextValue({ + controlsModel: null, + viewState: { + status: 'ready', + trackId: 'track', + currentTimeMs: 0, + durationMs: 120_000, + volume: 0.5, + playbackRate: 1, + pitchSemitones: 0, + isPlayable: true, + isPlaying: false, + progressPercent: 0, + }, + }), + ); + + const { result } = renderHook(() => useSettingsPanelProps()); + + expect(() => { + result.current.handleSetVolume({ + target: { value: '0.75' }, + } as never); + result.current.handleSetTempo({ + target: { value: '1.25' }, + } as never); + result.current.handleSetPitchSemitones({ + target: { value: '-4' }, + } as never); + }).not.toThrow(); + }); + it('ignores transport actions when the model is missing or seek input is invalid', () => { mockUseFrameworkDemoContext.mockReturnValue( createContextValue({ @@ -137,6 +172,34 @@ describe('Framework demo hook edge cases', () => { expect(controlsModel.seekPercent).not.toHaveBeenCalled(); }); + it('parses valid seek input even when the controls model is missing', () => { + mockUseFrameworkDemoContext.mockReturnValue( + createContextValue({ + controlsModel: null, + viewState: { + status: 'ready', + trackId: 'track', + currentTimeMs: 15_000, + durationMs: 60_000, + volume: 1, + playbackRate: 1, + pitchSemitones: 0, + isPlayable: true, + isPlaying: false, + progressPercent: 25, + }, + }), + ); + + const { result } = renderHook(() => useTransportBarProps()); + + expect(() => { + result.current.handleSeekPercent({ + target: { value: '42.5' }, + } as never); + }).not.toThrow(); + }); + it('handles absent players and non-Error load failures in the file picker hook', async () => { const showStatusMessage = vi.fn(); diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx index 6013625..151609e 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx @@ -2,7 +2,7 @@ import useFilePickerRowProps from './hooks/useFilePickerRowProps.memo'; import styles from './FilePickerRow.module.css'; /** - * File input row for loading karaoke zip files and exporting diagnostics. + * File input row for loading browser-supported audio or karaoke zip files. */ function FilePickerRow() { const { @@ -15,11 +15,13 @@ function FilePickerRow() { return ( // This row is intentionally simple: choose file + optional speed-report export.
- + {showPerfDiagnostics ? ( diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx index 25adfd4..dbeb0ec 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx @@ -19,6 +19,7 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { const { player, setTitleMetadata, + setHasGraphicsTrack, showPerfDiagnostics, perfSummary, showStatusMessage, @@ -37,6 +38,7 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { player.stop(); resetPlaybackStarted(); setTitleMetadata(null); + setHasGraphicsTrack(true); showStatusMessage('Loading track...'); try { @@ -57,6 +59,8 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { }); } + setHasGraphicsTrack(loadedTrack?.hasGraphics ?? false); + showStatusMessage('Track loaded.'); } catch (errorValue: unknown) { // Keep status user-friendly while preserving details through Error.message. @@ -67,7 +71,13 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { showStatusMessage(`Load failed: ${message}`); } }, - [player, resetPlaybackStarted, setTitleMetadata, showStatusMessage], + [ + player, + resetPlaybackStarted, + setTitleMetadata, + setHasGraphicsTrack, + showStatusMessage, + ], ); const handleTrackSelect = useCallback( diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx index 5e8019e..db33711 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx @@ -22,6 +22,7 @@ function StageDisplay({ children }: StageDisplayProps) { isStatusVisible, viewState, titleMetadata, + hasGraphicsTrack, perfSummary, hasTrack, showTitle, @@ -53,7 +54,12 @@ function StageDisplay({ children }: StageDisplayProps) { ) : null}
) : null} - +
diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.module.css b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.module.css index 92122c2..94bfbb4 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.module.css +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.module.css @@ -1,4 +1,8 @@ @layer components { + /* + * These top-level selectors are state/marker classes consumed by TSX. + * Visual behavior is defined in nested rules below (for example &.hasTrack). + */ .hasTrack { } @@ -20,6 +24,10 @@ .compatibilityWarning { } + /* Applied to canvas in audio-only mode; rule is scoped under .stage below. */ + .canvasHidden { + } + .stageWrap { --cdg-panel-opacity: 0; --cdg-panel-visibility: hidden; @@ -133,6 +141,10 @@ background: var(--cdg-color-stage); image-rendering: pixelated; } + + .canvasHidden { + display: none; + } } .titleImage { diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/hooks/useStageDisplayProps.memo.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/hooks/useStageDisplayProps.memo.tsx index 890baff..97d370a 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/hooks/useStageDisplayProps.memo.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/hooks/useStageDisplayProps.memo.tsx @@ -14,6 +14,7 @@ export type StageDisplayResolvedProps = { isStatusVisible: boolean; viewState: ReturnType['viewState']; titleMetadata: ReturnType['titleMetadata']; + hasGraphicsTrack: boolean; perfSummary: ReturnType['perfSummary']; hasTrack: boolean; showTitle: boolean; @@ -34,6 +35,7 @@ function useStageDisplayProps(): StageDisplayResolvedProps { controlsModel, viewState, titleMetadata, + hasGraphicsTrack, perfSummary, hasTrack, showTitle, @@ -57,6 +59,7 @@ function useStageDisplayProps(): StageDisplayResolvedProps { isStatusVisible, viewState, titleMetadata, + hasGraphicsTrack, perfSummary, hasTrack, showTitle, diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/hooks/useFrameworkDemo.context.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/hooks/useFrameworkDemo.context.tsx index 05eabf2..3031511 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/hooks/useFrameworkDemo.context.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/hooks/useFrameworkDemo.context.tsx @@ -286,6 +286,8 @@ export type FrameworkDemoContextValue = { setTitleMetadata: Dispatch< SetStateAction<{ title: string; artist: string } | null> >; + hasGraphicsTrack: boolean; + setHasGraphicsTrack: Dispatch>; perfSummary: PerfSummary | null; hasTrack: boolean; showTitle: boolean; @@ -329,6 +331,7 @@ export function FrameworkDemoProvider({ children }: PropsWithChildren) { title: string; artist: string; } | null>(null); + const [hasGraphicsTrack, setHasGraphicsTrack] = useState(true); const [perfSummary, setPerfSummary] = useState(null); // Turn many raw timing samples into a tiny report we can read at a glance. @@ -520,6 +523,8 @@ export function FrameworkDemoProvider({ children }: PropsWithChildren) { viewState, titleMetadata, setTitleMetadata, + hasGraphicsTrack, + setHasGraphicsTrack, perfSummary, hasTrack, showTitle, @@ -536,6 +541,7 @@ export function FrameworkDemoProvider({ children }: PropsWithChildren) { controlsModel, viewState, titleMetadata, + hasGraphicsTrack, perfSummary, hasTrack, showTitle, diff --git a/apps/storybook-hub/docs/architecture.mdx b/apps/storybook-hub/docs/architecture.mdx index 1b06c6a..b0a8ef6 100644 --- a/apps/storybook-hub/docs/architecture.mdx +++ b/apps/storybook-hub/docs/architecture.mdx @@ -11,7 +11,7 @@ CDGPlayer is package-first. Think of the system as a pipeline: Each package has a single responsibility: - `@cxing/cdg-core`: parser, instructions, render context -- `@cxing/cdg-loader`: zip/file/url loading + metadata (embedded audio tags first, filename fallback) +- `@cxing/cdg-loader`: audio/zip/file/url loading + metadata (embedded audio tags first, filename fallback); normalized payload may include optional graphics - `@cxing/cdg-player`: playback orchestration + render dispatch - `@cxing/cdg-controls`: UI controls model and adapters - `@cxing/logger`: scope-prefixed runtime logging with severity methods and debug gating diff --git a/apps/storybook-hub/docs/contracts-loader.mdx b/apps/storybook-hub/docs/contracts-loader.mdx index dbcb773..236932f 100644 --- a/apps/storybook-hub/docs/contracts-loader.mdx +++ b/apps/storybook-hub/docs/contracts-loader.mdx @@ -13,7 +13,7 @@ Metadata extraction behavior: Use loader when: -- you want to load karaoke content from user-selected zip files +- you want to load browser-supported audio files and karaoke zip bundles - you want a consistent parsed payload before passing to player - you want metadata (`title`, `artist`, etc.) along with media bytes @@ -32,7 +32,7 @@ import { createLoader } from '@cxing/cdg-loader'; const loader = createLoader(); const loadedTrack = await loader.load({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, options: { debug: true }, }); ``` @@ -43,12 +43,14 @@ const loadedTrack = await loader.load({ // same shape for framework-agnostic and framework integrations loadedTrack.metadata.title; loadedTrack.audioBuffer; +loadedTrack.audioMimeType; +loadedTrack.hasGraphics; loadedTrack.cdgBytes; ``` ## Common Mistakes -- Passing non-zip content to `kind: 'file'` without validating selection UX. +- Treating `hasGraphics` as always true; audio-only tracks return `hasGraphics === false` with `cdgBytes === null`. - Assuming metadata fields are always populated; handle empty strings safely because some files have no tags and no parseable filename pattern. - Re-loading repeatedly without cancellation logic in your app flow. diff --git a/apps/storybook-hub/docs/contracts-player.mdx b/apps/storybook-hub/docs/contracts-player.mdx index 2c41a6e..e8de57d 100644 --- a/apps/storybook-hub/docs/contracts-player.mdx +++ b/apps/storybook-hub/docs/contracts-player.mdx @@ -6,8 +6,6 @@ import { Meta } from '@storybook/addon-docs/blocks'; `@cxing/cdg-player` is the runtime coordinator for playback, timeline sync, rendering, and audio. -Phase status: Phase 4 complete. - Use player when: - you need a stable playback state machine (`idle`, `loading`, `ready`, `playing`, `paused`, `error`) @@ -28,7 +26,7 @@ const player = createPlayer({ }); await player.load({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, }); await player.play(); @@ -45,9 +43,11 @@ player.addEventListener('statechange', () => { ## Important Behavior Notes - Tempo is the preferred singer-facing API via `setTempo`, with `setPlaybackRate` retained for compatibility. +- Audio-only tracks are supported; when no graphics stream is present, playback still works while CDG render/sync paths are skipped. - Player runtime does not emit built-in render performance metric events. Performance monitoring/reporting is local-development tooling concern. - Debug trace logging is controlled by `options.debug`. - Failure paths use `error()` severity and are not hidden behind debug gating. +- `statechange` is the primary state event; load failures dispatch `error` with `detail.errorValue`. ## Common Mistakes diff --git a/apps/storybook-hub/docs/getting-started.mdx b/apps/storybook-hub/docs/getting-started.mdx index 045b0fb..8ccc80c 100644 --- a/apps/storybook-hub/docs/getting-started.mdx +++ b/apps/storybook-hub/docs/getting-started.mdx @@ -50,12 +50,14 @@ const player = createPlayer({ }); await player.load({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, }); await player.play(); ``` +For file-picker UX, prefer `accept="audio/*,.zip,application/zip"` so users can load either browser-supported audio or karaoke zip bundles. + ## Minimal Controls Setup ```ts diff --git a/apps/storybook-hub/docs/migration-guide.mdx b/apps/storybook-hub/docs/migration-guide.mdx index f7f35ef..e475e57 100644 --- a/apps/storybook-hub/docs/migration-guide.mdx +++ b/apps/storybook-hub/docs/migration-guide.mdx @@ -53,7 +53,7 @@ This guide helps teams migrate from the legacy monolithic packages (`CDGPlayer`, @cxing/cdg-loader - Zip/file/url loading with normalized payloads + Audio/zip/file/url loading with normalized payloads N/A (standalone in legacy) @@ -100,7 +100,7 @@ const player = createPlayer({ }); await player.load({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, }); ``` @@ -132,6 +132,7 @@ createVolumeControl({ - Prefer `setTempo({ value })` for singer-facing speed adjustments. - `setPlaybackRate({ value })` is still available as a compatibility alias. - Continue using `setPitchSemitones({ value })` for key shift. +- Loading no longer requires graphics; audio-only tracks are valid and remain fully playable. - Keep the control label as `Key`, but use a vertical slider with datalist labels shown as half-step values (`.5`, `1`, `1.5`, etc.) while still writing integer semitone values to `setPitchSemitones`. ## 5. Lifecycle and Teardown diff --git a/apps/storybook-react/stories/react-example.mdx b/apps/storybook-react/stories/react-example.mdx index c7675af..f881518 100644 --- a/apps/storybook-react/stories/react-example.mdx +++ b/apps/storybook-react/stories/react-example.mdx @@ -88,6 +88,9 @@ const onFileSelected = async (file: File) => { }; ``` +For file-picker UX, set `accept="audio/*,.zip,application/zip"`. +If a loaded track reports no graphics, keep transport controls enabled and hide only the canvas region. + ## Step 3: Bind React Controls to Model Methods ```tsx diff --git a/apps/storybook-web/stories/framework-agnostic-example.mdx b/apps/storybook-web/stories/framework-agnostic-example.mdx index abcf557..059b94b 100644 --- a/apps/storybook-web/stories/framework-agnostic-example.mdx +++ b/apps/storybook-web/stories/framework-agnostic-example.mdx @@ -120,6 +120,9 @@ fileInput.addEventListener('change', () => { }); ``` +For picker UX, configure the file input as `accept="audio/*,.zip,application/zip"`. +When `loadedTrack.hasGraphics` is `false`, keep transport controls active and hide the canvas region because the track is audio-only. + ## Step 4: Optional Stage Click Toggle ```ts diff --git a/package.json b/package.json index ae0dc4a..4dec58b 100644 --- a/package.json +++ b/package.json @@ -47,55 +47,55 @@ "prepare": "husky" }, "devDependencies": { - "@commitlint/cli": "20.5.0", + "@commitlint/cli": "20.5.2", "@commitlint/config-conventional": "20.5.0", "@commitlint/format": "20.5.0", "@eslint/js": "^10.0.1", - "@nx/devkit": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/eslint-plugin": "22.6.5", - "@nx/js": "22.6.5", - "@nx/vite": "22.6.5", - "@nx/vitest": "22.6.5", - "@nx/workspace": "22.6.5", + "@nx/devkit": "22.7.0", + "@nx/eslint": "22.7.0", + "@nx/eslint-plugin": "22.7.0", + "@nx/js": "22.7.0", + "@nx/vite": "22.7.0", + "@nx/vitest": "22.7.0", + "@nx/workspace": "22.7.0", "@remixicon/react": "^4.9.0", "@storybook/addon-docs": "10.3.5", "@storybook/addon-links": "^10.3.5", "@storybook/react-vite": "^10.3.5", "@storybook/web-components-vite": "^10.3.5", "@swc-node/register": "~1.11.1", - "@swc/core": "~1.15.24", + "@swc/core": "~1.15.32", "@swc/helpers": "~0.5.21", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.58.2", - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "~4.1.4", - "@vitest/ui": "~4.1.4", - "eslint": "~10.2.0", + "@vitest/coverage-v8": "~4.1.5", + "@vitest/ui": "~4.1.5", + "eslint": "~10.2.1", "husky": "^9.1.7", - "jsdom": "^29.0.2", + "jsdom": "^29.1.0", "jsonc-eslint-parser": "^3.1.0", - "nx": "22.6.5", - "prettier": "~3.8.2", + "nx": "22.7.0", + "prettier": "~3.8.3", "react": "^19.2.5", "react-dom": "^19.2.5", "storybook": "^10.3.5", "tslib": "^2.8.1", "typescript": "~5.9.3", - "typescript-eslint": "^8.58.2", - "verdaccio": "^6.5.0", - "vite": "^8.0.8", + "typescript-eslint": "^8.59.0", + "verdaccio": "^6.5.2", + "vite": "^8.0.10", "vite-plugin-dts": "~4.5.4", - "vitest": "~4.1.4" + "vitest": "~4.1.5" }, "pnpm": { "overrides": { - "tar": ">=7.5.7" + "tar": "7.5.13" } }, "nx": { diff --git a/packages/cdg-controls/package.json b/packages/cdg-controls/package.json index 90d7b5c..f6a523c 100644 --- a/packages/cdg-controls/package.json +++ b/packages/cdg-controls/package.json @@ -42,6 +42,6 @@ ], "sideEffects": false, "dependencies": { - "lucide": "^1.8.0" + "lucide": "^1.11.0" } } diff --git a/packages/cdg-loader/README.md b/packages/cdg-loader/README.md index 45ce7bc..a8fa847 100644 --- a/packages/cdg-loader/README.md +++ b/packages/cdg-loader/README.md @@ -1,6 +1,6 @@ # @cxing/cdg-loader -Loads karaoke inputs (zip/file/url/blob/arrayBuffer) into one normalized track payload. +Loads browser-supported audio and karaoke inputs (zip/file/url/blob/arrayBuffer) into one normalized track payload. Use this package when you want a stable pre-player loading contract and metadata extraction. @@ -18,7 +18,7 @@ import { createLoader } from '@cxing/cdg-loader'; const loader = createLoader(); const loadedTrack = await loader.load({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, options: { debug: false }, }); @@ -41,7 +41,9 @@ console.log(loadedTrack.audioBuffer, loadedTrack.cdgBytes); - `trackId` - `sourceSummary` - `audioBuffer` (`ArrayBuffer`) -- `cdgBytes` (`Uint8Array`) +- `audioMimeType` (`string`) +- `hasGraphics` (`boolean`) +- `cdgBytes` (`Uint8Array | null`) - `metadata` (`title`, `artist`, `album`) - `warnings` @@ -51,10 +53,10 @@ Use `probe(...)` to preflight archive structure before full load: ```ts const probe = await loader.probe({ - input: { kind: 'file', file: selectedZip }, + input: { kind: 'file', file: selectedFile }, }); -console.log(probe.karaokeLikely, probe.discoveredEntries); +console.log(probe.karaokeLikely, probe.audioLikely, probe.discoveredEntries); ``` ## Worker Transport diff --git a/packages/cdg-loader/src/lib/loader-core.spec.ts b/packages/cdg-loader/src/lib/loader-core.spec.ts index 7625fad..9307035 100644 --- a/packages/cdg-loader/src/lib/loader-core.spec.ts +++ b/packages/cdg-loader/src/lib/loader-core.spec.ts @@ -58,7 +58,7 @@ describe('CdgLoader core behavior', () => { options: { requestId: 'req-1' }, }); - expect(loaded.sourceSummary).toBe('track.zip'); + expect(loaded.sourceSummary).toBe('track-input'); expect(loaded.metadata).toEqual({ album: 'Album', artist: 'Artist', @@ -66,7 +66,9 @@ describe('CdgLoader core behavior', () => { }); expect(loaded.warnings).toEqual([]); expect(loaded.audioBuffer.byteLength).toBeGreaterThan(0); - expect(loaded.cdgBytes.byteLength).toBeGreaterThan(0); + expect(loaded.hasGraphics).toBe(true); + expect(loaded.cdgBytes).not.toBeNull(); + expect(loaded.cdgBytes?.byteLength ?? 0).toBeGreaterThan(0); }); it('prefers embedded metadata when tag parser succeeds', async () => { @@ -177,6 +179,7 @@ describe('CdgLoader core behavior', () => { }); expect(result.karaokeLikely).toBe(true); + expect(result.audioLikely).toBe(true); expect(result.hasExtraEntries).toBe(true); expect(result.extensionCaseIssues).toBe(true); expect(result.discoveredEntries).toEqual( @@ -304,9 +307,9 @@ describe('CdgLoader core behavior', () => { }, }); - expect(loaded.sourceSummary).toBe('track.zip'); + expect(loaded.sourceSummary).toBe('track-input'); expect(loaded.warnings).toEqual([ - 'Multiple mp3 files found; selected first match.', + 'Multiple supported audio files found; selected best match.', ]); }); @@ -355,6 +358,178 @@ describe('CdgLoader core behavior', () => { ).rejects.toMatchObject({ code: 'KARAOKE_FILES_MISSING' }); }); + it('loads an audio-only zip without graphics payload', async () => { + readMediaTagsMock.mockImplementation( + ( + _blob: unknown, + callbacks: { + onSuccess?: (value: unknown) => void; + onError?: (value: unknown) => void; + }, + ) => { + callbacks.onError?.(new Error('no tags')); + }, + ); + + const archiveBuffer = await createZipArrayBuffer({ + entries: [ + { + name: 'Artist - Song.mp3', + content: new Uint8Array([1, 2, 3]), + }, + ], + }); + + const loader = new CdgLoader(); + const loaded = await loader.load({ + input: { kind: 'arrayBuffer', arrayBuffer: archiveBuffer }, + }); + + expect(loaded.hasGraphics).toBe(false); + expect(loaded.cdgBytes).toBeNull(); + expect(loaded.audioMimeType).toBe('audio/mpeg'); + expect(loaded.metadata.title).toBe('Song'); + }); + + it('selects the audio entry whose stem matches the discovered graphics track', async () => { + readMediaTagsMock.mockImplementation( + ( + _blob: unknown, + callbacks: { + onSuccess?: (value: unknown) => void; + onError?: (value: unknown) => void; + }, + ) => { + callbacks.onError?.(new Error('no tags')); + }, + ); + + const archiveBuffer = await createZipArrayBuffer({ + entries: [ + { name: 'aaa-intro.mp3', content: new Uint8Array([1]) }, + { name: 'bbb-main.mp3', content: new Uint8Array([2]) }, + { name: 'bbb-main.cdg', content: new Uint8Array([3]) }, + ], + }); + + const loader = new CdgLoader(); + const loaded = await loader.load({ + input: { kind: 'arrayBuffer', arrayBuffer: archiveBuffer }, + }); + + expect(loaded.metadata.title).toBe('bbb-main'); + expect(loaded.hasGraphics).toBe(true); + }); + + it('keeps loading when multiple graphics tracks exist in non-strict mode', async () => { + readMediaTagsMock.mockImplementation( + ( + _blob: unknown, + callbacks: { + onSuccess?: (value: unknown) => void; + onError?: (value: unknown) => void; + }, + ) => { + callbacks.onError?.(new Error('no tags')); + }, + ); + + const archiveBuffer = await createZipArrayBuffer({ + entries: [ + { name: 'song.mp3', content: new Uint8Array([1]) }, + { name: 'song-a.cdg', content: new Uint8Array([2]) }, + { name: 'song-b.cdg', content: new Uint8Array([3]) }, + ], + }); + + const loader = new CdgLoader(); + const loaded = await loader.load({ + input: { kind: 'arrayBuffer', arrayBuffer: archiveBuffer }, + }); + + expect(loaded.warnings).toEqual([ + 'Multiple cdg files found; selected first match.', + ]); + expect(loaded.hasGraphics).toBe(true); + }); + + it('loads raw audio file input when zip parsing fails', async () => { + readMediaTagsMock.mockImplementation( + ( + _blob: unknown, + callbacks: { + onSuccess?: (value: unknown) => void; + onError?: (value: unknown) => void; + }, + ) => { + callbacks.onError?.(new Error('no tags')); + }, + ); + + const file = new File( + [new Uint8Array([8, 7, 6])], + 'Demo - Artist - Song.mp3', + { + type: 'audio/mpeg', + }, + ); + + const loader = new CdgLoader(); + const loaded = await loader.load({ + input: { kind: 'file', file }, + }); + + expect(loaded.hasGraphics).toBe(false); + expect(loaded.cdgBytes).toBeNull(); + expect(loaded.audioMimeType).toBe('audio/mpeg'); + expect(loaded.sourceSummary).toBe('Demo - Artist - Song.mp3'); + }); + + it('loads raw audio when support is inferred from file extension alone', async () => { + readMediaTagsMock.mockImplementation( + ( + _blob: unknown, + callbacks: { + onSuccess?: (value: unknown) => void; + onError?: (value: unknown) => void; + }, + ) => { + callbacks.onError?.(new Error('no tags')); + }, + ); + + const file = new File([new Uint8Array([8, 7, 6])], 'Demo Track.wav', { + type: '', + }); + + const loader = new CdgLoader(); + const loaded = await loader.load({ + input: { kind: 'file', file }, + }); + + expect(loaded.audioMimeType).toBe('audio/wav'); + expect(loaded.hasGraphics).toBe(false); + }); + + it('probes raw audio blob inputs by mime type when zip parsing fails', async () => { + const loader = new CdgLoader(); + + await expect( + loader.probe({ + input: { + kind: 'blob', + blob: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/ogg' }), + }, + }), + ).resolves.toEqual({ + karaokeLikely: false, + audioLikely: true, + discoveredEntries: [], + hasExtraEntries: false, + extensionCaseIssues: false, + }); + }); + it('throws FETCH_FAILED when url input returns non-ok response', async () => { vi.stubGlobal( 'fetch', @@ -417,14 +592,14 @@ describe('CdgLoader core behavior', () => { expect(loaded.sourceSummary).toBe('song-bundle.zip'); }); - it('maps invalid zip payloads to ARCHIVE_INVALID and probe fallback shape', async () => { + it('maps invalid raw payloads to AUDIO_FORMAT_UNSUPPORTED and probe fallback shape', async () => { const loader = new CdgLoader(); await expect( loader.load({ input: { kind: 'arrayBuffer', arrayBuffer: new Uint8Array([1]).buffer }, }), - ).rejects.toMatchObject({ code: 'ARCHIVE_INVALID' }); + ).rejects.toMatchObject({ code: 'AUDIO_FORMAT_UNSUPPORTED' }); await expect( loader.probe({ @@ -432,6 +607,7 @@ describe('CdgLoader core behavior', () => { }), ).resolves.toEqual({ karaokeLikely: false, + audioLikely: false, discoveredEntries: [], hasExtraEntries: false, extensionCaseIssues: false, diff --git a/packages/cdg-loader/src/lib/loader-core.ts b/packages/cdg-loader/src/lib/loader-core.ts index c2e54af..e213b37 100644 --- a/packages/cdg-loader/src/lib/loader-core.ts +++ b/packages/cdg-loader/src/lib/loader-core.ts @@ -10,6 +10,87 @@ import type { LoaderProbeResult, } from './types.js'; +const SUPPORTED_AUDIO_EXTENSIONS = new Set([ + 'mp3', + 'aac', + 'm4a', + 'mp4', + 'ogg', + 'opus', + 'wav', + 'webm', + 'flac', +]); + +const extensionFromName = ({ name }: { name: string }): string | null => { + const dotIndex = name.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex === name.length - 1) { + return null; + } + + return name.slice(dotIndex + 1).toLowerCase(); +}; + +const baseNameFromPath = ({ name }: { name: string }): string => { + const slashIndex = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + return slashIndex >= 0 ? name.slice(slashIndex + 1) : name; +}; + +const stemFromName = ({ name }: { name: string }): string => { + const baseName = baseNameFromPath({ name }); + const dotIndex = baseName.lastIndexOf('.'); + return dotIndex > 0 ? baseName.slice(0, dotIndex) : baseName; +}; + +const inferMimeTypeFromExtension = ({ + extension, +}: { + extension: string | null; +}): string => { + switch (extension) { + case 'mp3': + return 'audio/mpeg'; + case 'aac': + return 'audio/aac'; + case 'm4a': + return 'audio/mp4'; + case 'mp4': + return 'audio/mp4'; + case 'ogg': + return 'audio/ogg'; + case 'opus': + return 'audio/ogg'; + case 'wav': + return 'audio/wav'; + case 'webm': + return 'audio/webm'; + case 'flac': + return 'audio/flac'; + default: + return 'audio/mpeg'; + } +}; + +const isLikelySupportedAudio = ({ + name, + mimeType, +}: { + name: string; + mimeType?: string; +}): boolean => { + const normalizedMime = (mimeType ?? '').trim().toLowerCase(); + if (normalizedMime.startsWith('audio/')) { + return true; + } + + const extension = extensionFromName({ name }); + if (!extension) { + return false; + } + + return SUPPORTED_AUDIO_EXTENSIONS.has(extension); +}; + const fileNameFromInput = ({ input }: { input: LoaderInput }): string => { switch (input.kind) { case 'url': { @@ -19,16 +100,18 @@ const fileNameFromInput = ({ input }: { input: LoaderInput }): string => { case 'file': return input.file.name; case 'blob': - return 'track.zip'; + return input.blob.type.startsWith('audio/') + ? 'track-audio' + : 'track-input'; case 'arrayBuffer': - return 'track.zip'; + return 'track-input'; default: return 'track.zip'; } }; const metadataFromName = ({ name }: { name: string }): LoaderMetadata => { - const baseName = name.replace(/\.[^.]+$/u, ''); + const baseName = stemFromName({ name }); const parts = baseName .split(' - ') .map((part) => part.trim()) @@ -51,11 +134,13 @@ const asTagValue = ({ value }: { value: unknown }): string | null => { const readMetadataFromAudio = async ({ audioBuffer, + audioMimeType, }: { audioBuffer: ArrayBuffer; + audioMimeType: string; }): Promise> => { return new Promise((resolve) => { - const audioBlob = new Blob([audioBuffer], { type: 'audio/mpeg' }); + const audioBlob = new Blob([audioBuffer], { type: audioMimeType }); readMediaTags(audioBlob, { onSuccess: (tagResult) => { @@ -144,7 +229,7 @@ const asArrayBuffer = async ({ } }; -const getKaraokeEntries = ({ +const getTrackEntries = ({ zip, strictValidation, }: { @@ -152,22 +237,23 @@ const getKaraokeEntries = ({ strictValidation: boolean; }): { audio: JSZip.JSZipObject; - graphics: JSZip.JSZipObject; + audioMimeType: string; + graphics?: JSZip.JSZipObject; warnings: string[]; } => { const allEntries = Object.values(zip.files).filter((entry) => !entry.dir); const audioEntries = allEntries.filter((entry) => - /\.mp3$/iu.test(entry.name), + isLikelySupportedAudio({ name: entry.name }), ); const graphicsEntries = allEntries.filter((entry) => /\.cdg$/iu.test(entry.name), ); const warnings: string[] = []; - if (!audioEntries.length || !graphicsEntries.length) { + if (!audioEntries.length) { throw new LoaderError({ code: 'KARAOKE_FILES_MISSING', - message: 'Expected both mp3 and cdg files in archive.', + message: 'Expected at least one supported audio file in archive.', retriable: false, }); } @@ -176,11 +262,12 @@ const getKaraokeEntries = ({ if (strictValidation) { throw new LoaderError({ code: 'MULTIPLE_AUDIO_TRACKS', - message: 'Multiple mp3 files found and strict validation is enabled.', + message: + 'Multiple supported audio files found and strict validation is enabled.', retriable: false, }); } - warnings.push('Multiple mp3 files found; selected first match.'); + warnings.push('Multiple supported audio files found; selected best match.'); } if (graphicsEntries.length > 1) { @@ -194,20 +281,38 @@ const getKaraokeEntries = ({ warnings.push('Multiple cdg files found; selected first match.'); } - const audio = audioEntries.at(0); + const sortedAudioEntries = [...audioEntries].sort((left, right) => + left.name.localeCompare(right.name), + ); + + let audio = sortedAudioEntries.at(0); const graphics = graphicsEntries.at(0); - if (!audio || !graphics) { + if (graphics) { + const graphicsStem = stemFromName({ name: graphics.name }); + const matchingAudio = sortedAudioEntries.find( + (entry) => stemFromName({ name: entry.name }) === graphicsStem, + ); + if (matchingAudio) { + audio = matchingAudio; + } + } + + if (!audio) { throw new LoaderError({ - code: 'KARAOKE_FILES_MISSING', - message: 'Expected both mp3 and cdg files in archive.', + code: 'AUDIO_UNREADABLE', + message: 'Unable to select supported audio payload from archive.', retriable: false, }); } + const extension = extensionFromName({ name: audio.name }); + const audioMimeType = inferMimeTypeFromExtension({ extension }); + return { audio, - graphics, + audioMimeType, + ...(graphics ? { graphics } : {}), warnings, }; }; @@ -261,58 +366,113 @@ export class CdgLoader { } try { - const archiveBuffer = await asArrayBuffer({ + const sourceSummary = fileNameFromInput({ input }); + const sourceMimeType = + input.kind === 'file' + ? input.file.type + : input.kind === 'blob' + ? input.blob.type + : undefined; + + const payloadBuffer = await asArrayBuffer({ input, signal: controller.signal, }); logger.debug({ - message: 'load:archive-read', - bytes: archiveBuffer.byteLength, + message: 'load:payload-read', + bytes: payloadBuffer.byteLength, }); - const zip = await JSZip.loadAsync(archiveBuffer).catch( - (causeValue: unknown) => { + + const zip = await JSZip.loadAsync(payloadBuffer).catch(() => null); + + if (!zip) { + if ( + !isLikelySupportedAudio({ + name: sourceSummary, + ...(sourceMimeType ? { mimeType: sourceMimeType } : {}), + }) + ) { throw new LoaderError({ - code: 'ARCHIVE_INVALID', - message: 'Unable to parse archive as zip data.', + code: 'AUDIO_FORMAT_UNSUPPORTED', + message: + 'Input is neither a valid zip archive nor a supported audio file.', retriable: false, - causeValue, + context: { + source: sourceSummary, + details: sourceMimeType ?? 'unknown-mime', + }, }); - }, - ); + } + + const audioMimeType = sourceMimeType?.startsWith('audio/') + ? sourceMimeType + : inferMimeTypeFromExtension({ + extension: extensionFromName({ name: sourceSummary }), + }); + + const fallbackMetadata = metadataFromName({ name: sourceSummary }); + const embeddedMetadata = await readMetadataFromAudio({ + audioBuffer: payloadBuffer, + audioMimeType, + }); + + const metadata = mergeMetadata({ + preferred: embeddedMetadata, + fallback: fallbackMetadata, + }); + + return { + trackId: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + sourceSummary, + audioBuffer: payloadBuffer, + audioMimeType, + hasGraphics: false, + cdgBytes: null, + metadata, + warnings: [], + }; + } - const selection = getKaraokeEntries({ + const selection = getTrackEntries({ zip, strictValidation: options.strictValidation ?? false, }); logger.debug({ message: 'load:entries-selected', audio: selection.audio.name, - graphics: selection.graphics.name, + graphics: selection.graphics?.name ?? null, warnings: selection.warnings, }); - const [audioBuffer, cdgBytes] = await Promise.all([ - selection.audio.async('arraybuffer').catch((causeValue: unknown) => { + const audioBuffer = await selection.audio + .async('arraybuffer') + .catch((causeValue: unknown) => { throw new LoaderError({ code: 'AUDIO_UNREADABLE', - message: 'Unable to read mp3 payload from archive.', + message: 'Unable to read audio payload from archive.', retriable: false, causeValue, }); - }), - selection.graphics.async('uint8array').catch((causeValue: unknown) => { - throw new LoaderError({ - code: 'GRAPHICS_UNREADABLE', - message: 'Unable to read cdg payload from archive.', - retriable: false, - causeValue, - }); - }), - ]); + }); + + const cdgBytes = selection.graphics + ? await selection.graphics + .async('uint8array') + .catch((causeValue: unknown) => { + throw new LoaderError({ + code: 'GRAPHICS_UNREADABLE', + message: 'Unable to read cdg payload from archive.', + retriable: false, + causeValue, + }); + }) + : null; - const sourceSummary = fileNameFromInput({ input }); const fallbackMetadata = metadataFromName({ name: selection.audio.name }); - const embeddedMetadata = await readMetadataFromAudio({ audioBuffer }); + const embeddedMetadata = await readMetadataFromAudio({ + audioBuffer, + audioMimeType: selection.audioMimeType, + }); if (!hasAnyEmbeddedMetadata({ metadata: embeddedMetadata })) { logger.info({ message: 'metadata:fallback-filename', @@ -329,13 +489,15 @@ export class CdgLoader { message: 'load:success', sourceSummary, audioBytes: audioBuffer.byteLength, - cdgBytes: cdgBytes.byteLength, + cdgBytes: cdgBytes?.byteLength ?? 0, metadata, }); return { trackId: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, sourceSummary, audioBuffer, + audioMimeType: selection.audioMimeType, + hasGraphics: cdgBytes !== null, cdgBytes, metadata, warnings: selection.warnings, @@ -417,8 +579,20 @@ export class CdgLoader { const zip = await JSZip.loadAsync(archiveBuffer).catch(() => null); if (!zip) { + const inputName = fileNameFromInput({ input }); + const inputMimeType = + input.kind === 'file' + ? input.file.type + : input.kind === 'blob' + ? input.blob.type + : undefined; + return { karaokeLikely: false, + audioLikely: isLikelySupportedAudio({ + name: inputName, + ...(inputMimeType ? { mimeType: inputMimeType } : {}), + }), discoveredEntries: [], hasExtraEntries: false, extensionCaseIssues: false, @@ -429,9 +603,14 @@ export class CdgLoader { .filter((entry) => !entry.dir) .map((entry) => entry.name); - const karaokeEntries = names.filter((name) => /\.(mp3|cdg)$/iu.test(name)); - const lowerCasedMismatches = names.some( - (name) => /\.(MP3|CDG)$/u.test(name) && !/\.(mp3|cdg)$/u.test(name), + const audioEntries = names.filter((name) => + isLikelySupportedAudio({ name }), + ); + const karaokeEntries = names.filter( + (name) => isLikelySupportedAudio({ name }) || /\.cdg$/iu.test(name), + ); + const lowerCasedMismatches = names.some((name) => + /\.(MP3|AAC|M4A|MP4|OGG|OPUS|WAV|WEBM|FLAC|CDG)$/u.test(name), ); logger.debug({ @@ -443,6 +622,7 @@ export class CdgLoader { return { karaokeLikely: karaokeEntries.length >= 2, + audioLikely: audioEntries.length > 0, discoveredEntries: names, hasExtraEntries: names.length > karaokeEntries.length, extensionCaseIssues: lowerCasedMismatches, diff --git a/packages/cdg-loader/src/lib/loader.spec.ts b/packages/cdg-loader/src/lib/loader.spec.ts index 8ee476f..a08719c 100644 --- a/packages/cdg-loader/src/lib/loader.spec.ts +++ b/packages/cdg-loader/src/lib/loader.spec.ts @@ -26,6 +26,8 @@ describe('createLoader', () => { trackId: 'fallback', sourceSummary: 'sample.zip', audioBuffer: new ArrayBuffer(8), + audioMimeType: 'audio/mpeg', + hasGraphics: true, cdgBytes: new Uint8Array(24), metadata: { title: 'Title', @@ -37,6 +39,7 @@ describe('createLoader', () => { const probe = vi.fn(async () => ({ karaokeLikely: true, + audioLikely: true, discoveredEntries: ['song.mp3', 'song.cdg'], hasExtraEntries: false, extensionCaseIssues: false, @@ -142,6 +145,8 @@ describe('createLoader', () => { trackId: 'worker-track', sourceSummary: 'worker.zip', audioBuffer: new ArrayBuffer(4), + audioMimeType: 'audio/mpeg', + hasGraphics: true, cdgBytes: new Uint8Array(6), metadata: { title: 'Title', @@ -161,6 +166,7 @@ describe('createLoader', () => { ok: true, result: { karaokeLikely: true, + audioLikely: true, discoveredEntries: ['song.mp3', 'song.cdg'], hasExtraEntries: false, extensionCaseIssues: false, @@ -274,6 +280,8 @@ describe('createLoader', () => { trackId: 'ignored', sourceSummary: 'ignored.zip', audioBuffer: new ArrayBuffer(1), + audioMimeType: 'audio/mpeg', + hasGraphics: true, cdgBytes: new Uint8Array([1]), metadata: { title: 'Ignored', @@ -314,4 +322,171 @@ describe('createLoader', () => { await expect(loadPromise).rejects.toMatchObject({ code: 'ABORTED' }); }); + + it('serializes worker-safe options and ignores unrelated worker messages', async () => { + type MessageListener = (event: MessageEvent) => void; + + class MockWorker { + static instances: MockWorker[] = []; + + private listeners: Record = { + message: [], + error: [], + }; + + constructor() { + MockWorker.instances.push(this); + } + + postMessage = vi.fn((message: { type: string; requestId: string }) => { + if (message.type === 'load') { + this.emitMessage({ + type: 'probe-result', + requestId: message.requestId, + ok: true, + result: { + karaokeLikely: true, + audioLikely: true, + discoveredEntries: ['ignored'], + hasExtraEntries: false, + extensionCaseIssues: false, + }, + }); + this.emitMessage({ + type: 'load-result', + requestId: 'different-request', + ok: true, + result: { + trackId: 'ignored', + sourceSummary: 'ignored.zip', + audioBuffer: new ArrayBuffer(1), + audioMimeType: 'audio/mpeg', + hasGraphics: false, + cdgBytes: null, + metadata: { + title: 'Ignored', + artist: 'Ignored', + album: 'Ignored', + }, + warnings: [], + }, + }); + this.emitMessage({ + type: 'load-result', + requestId: message.requestId, + ok: true, + result: { + trackId: 'worker-track', + sourceSummary: 'worker.zip', + audioBuffer: new ArrayBuffer(4), + audioMimeType: 'audio/mpeg', + hasGraphics: false, + cdgBytes: null, + metadata: { + title: 'Title', + artist: 'Artist', + album: 'Album', + }, + warnings: [], + }, + }); + } + }); + + addEventListener(type: string, listener: EventListener): void { + this.listeners[type] ??= []; + this.listeners[type]?.push(listener as MessageListener); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners[type] = (this.listeners[type] ?? []).filter( + (item) => item !== (listener as MessageListener), + ); + } + + terminate = vi.fn(); + + private emitMessage(data: unknown): void { + for (const listener of this.listeners['message'] ?? []) { + listener({ data } as MessageEvent); + } + } + } + + vi.stubGlobal('Worker', MockWorker as unknown as typeof Worker); + + const loaded = await loadInWorker({ + input: { kind: 'arrayBuffer', arrayBuffer: new ArrayBuffer(8) }, + options: { + requestId: 'serialized-options', + strictValidation: true, + debug: true, + }, + }); + + expect(loaded.trackId).toBe('worker-track'); + expect(MockWorker.instances[0]?.postMessage).toHaveBeenCalledWith({ + type: 'load', + requestId: 'serialized-options', + input: { kind: 'arrayBuffer', arrayBuffer: expect.any(ArrayBuffer) }, + options: { + requestId: 'serialized-options', + strictValidation: true, + debug: true, + }, + }); + }); + + it('surfaces worker error-result messages for probe requests', async () => { + type MessageListener = (event: MessageEvent) => void; + + class MockWorker { + private listeners: Record = { + message: [], + error: [], + }; + + postMessage = vi.fn((message: { requestId: string; type: string }) => { + if (message.type === 'probe') { + for (const listener of this.listeners['message'] ?? []) { + listener({ + data: { + type: 'probe-result', + requestId: message.requestId, + ok: false, + error: { + code: 'INTERNAL', + message: 'probe worker failed', + retriable: true, + context: { source: 'worker' }, + }, + }, + } as MessageEvent); + } + } + }); + + addEventListener(type: string, listener: EventListener): void { + this.listeners[type] ??= []; + this.listeners[type]?.push(listener as MessageListener); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners[type] = (this.listeners[type] ?? []).filter( + (item) => item !== (listener as MessageListener), + ); + } + + terminate = vi.fn(); + } + + vi.stubGlobal('Worker', MockWorker as unknown as typeof Worker); + + await expect( + probeInWorker({ + input: { kind: 'arrayBuffer', arrayBuffer: new ArrayBuffer(2) }, + options: { debug: true }, + }), + ).rejects.toBeInstanceOf(LoaderError); + }); }); diff --git a/packages/cdg-loader/src/lib/loader.ts b/packages/cdg-loader/src/lib/loader.ts index 73eb3d3..a428d14 100644 --- a/packages/cdg-loader/src/lib/loader.ts +++ b/packages/cdg-loader/src/lib/loader.ts @@ -62,6 +62,7 @@ type ProbeResultMessage = ok: true; result: { karaokeLikely: boolean; + audioLikely: boolean; discoveredEntries: readonly string[]; hasExtraEntries: boolean; extensionCaseIssues: boolean; @@ -308,6 +309,7 @@ export const probeInWorker = async ({ options?: LoaderOptions; }): Promise<{ karaokeLikely: boolean; + audioLikely: boolean; discoveredEntries: readonly string[]; hasExtraEntries: boolean; extensionCaseIssues: boolean; @@ -346,6 +348,7 @@ export const probeInWorker = async ({ return runWorkerRequest<{ karaokeLikely: boolean; + audioLikely: boolean; discoveredEntries: readonly string[]; hasExtraEntries: boolean; extensionCaseIssues: boolean; diff --git a/packages/cdg-loader/src/lib/loader.worker.ts b/packages/cdg-loader/src/lib/loader.worker.ts index ff67c32..5417dba 100644 --- a/packages/cdg-loader/src/lib/loader.worker.ts +++ b/packages/cdg-loader/src/lib/loader.worker.ts @@ -98,7 +98,7 @@ const createTransferables = ({ result: LoadedTrack; }): Transferable[] => { const transfers: Transferable[] = [result.audioBuffer]; - if (result.cdgBytes.buffer instanceof ArrayBuffer) { + if (result.cdgBytes && result.cdgBytes.buffer instanceof ArrayBuffer) { transfers.push(result.cdgBytes.buffer); } return transfers; @@ -228,6 +228,7 @@ workerScope.addEventListener( message: 'probe:success', requestId: message.requestId, karaokeLikely: result.karaokeLikely, + audioLikely: result.audioLikely, }); } catch (errorValue: unknown) { postError({ diff --git a/packages/cdg-loader/src/lib/types.ts b/packages/cdg-loader/src/lib/types.ts index 1c41681..8a1d37c 100644 --- a/packages/cdg-loader/src/lib/types.ts +++ b/packages/cdg-loader/src/lib/types.ts @@ -13,9 +13,15 @@ export interface LoaderOptions { debug?: boolean; } -/** Lightweight probe output for archive preflight checks. */ +/** + * Lightweight probe output for preflight checks. + * `karaokeLikely` indicates a likely audio+graphics karaoke archive shape. + * `audioLikely` indicates the input likely contains browser-playable audio, + * including raw audio payloads that are not zip archives. + */ export interface LoaderProbeResult { karaokeLikely: boolean; + audioLikely: boolean; discoveredEntries: readonly string[]; hasExtraEntries: boolean; extensionCaseIssues: boolean; @@ -28,12 +34,19 @@ export interface LoaderMetadata { album: string; } -/** Successful load payload consumed by player runtime. */ +/** + * Successful load payload consumed by player runtime. + * `audioMimeType` is used when binding the in-memory audio blob to