diff --git a/apps/mukke/CLAUDE.md b/apps/mukke/CLAUDE.md index 003975f88..f7da41e42 100644 --- a/apps/mukke/CLAUDE.md +++ b/apps/mukke/CLAUDE.md @@ -120,6 +120,28 @@ pnpm --filter @mukke/landing dev # Landing page { id, lyricsId, lineNumber, text, startTime, endTime } ``` +## Supported Audio Formats + +Playback uses HTML5 Audio (browser-native codec support). Upload accepts any `audio/*` MIME type. + +| Format | Extensions | Browser Playback | Notes | +|--------|-----------|-----------------|-------| +| MP3 | `.mp3` | All browsers | ID3 tag read/write supported | +| WAV | `.wav` | All browsers | Uncompressed PCM | +| OGG Vorbis | `.ogg` | Chrome, Firefox, Edge | No Safari support | +| FLAC | `.flac` | All modern browsers | Lossless | +| AAC/M4A | `.aac`, `.m4a` | All browsers | Common iOS format | +| OPUS | `.opus` | Chrome, Firefox, Edge | Best quality/size ratio | +| WebM | `.webm` | Chrome, Firefox, Edge | Container format | +| AIFF | `.aiff`, `.aif` | Safari, Chrome | Common macOS format | +| WMA | `.wma` | Edge only | Legacy Windows format | +| ALAC | `.alac` | Safari | Apple Lossless | +| APE | `.ape` | None natively | Monkey's Audio (upload/metadata only) | +| WavPack | `.wv` | None natively | Hybrid lossless (upload/metadata only) | +| DSF/DFF | `.dsf`, `.dff` | None natively | DSD audio (upload/metadata only) | + +**Note:** Formats without native browser playback can be uploaded and have metadata extracted (via `music-metadata`), but require server-side transcoding for playback (not yet implemented). + ## Key Technologies | Component | Technology | diff --git a/packages/shared-storage/src/utils.test.ts b/packages/shared-storage/src/utils.test.ts new file mode 100644 index 000000000..a71307cd2 --- /dev/null +++ b/packages/shared-storage/src/utils.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { getContentType, validateFileExtension, AUDIO_EXTENSIONS } from './utils'; + +describe('getContentType', () => { + describe('audio formats', () => { + const audioMappings: [string, string][] = [ + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.ogg', 'audio/ogg'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'], + ['.aiff', 'audio/aiff'], + ['.aif', 'audio/aiff'], + ['.opus', 'audio/opus'], + ['.wma', 'audio/x-ms-wma'], + ['.alac', 'audio/mp4'], + ['.ape', 'audio/x-ape'], + ['.wv', 'audio/x-wavpack'], + ['.dsf', 'audio/dsf'], + ['.dff', 'audio/dff'], + ]; + + it.each(audioMappings)('%s → %s', (ext, expected) => { + expect(getContentType(`song${ext}`)).toBe(expected); + }); + + it('handles uppercase extensions', () => { + expect(getContentType('song.FLAC')).toBe('audio/flac'); + expect(getContentType('song.M4A')).toBe('audio/mp4'); + }); + + it('returns application/octet-stream for unknown extensions', () => { + expect(getContentType('song.xyz')).toBe('application/octet-stream'); + }); + }); +}); + +describe('AUDIO_EXTENSIONS', () => { + it('contains all common audio formats', () => { + const expected = [ + '.mp3', + '.wav', + '.ogg', + '.m4a', + '.aac', + '.flac', + '.aiff', + '.aif', + '.opus', + '.wma', + '.webm', + '.alac', + '.ape', + '.wv', + '.dsf', + '.dff', + ]; + for (const ext of expected) { + expect(AUDIO_EXTENSIONS).toContain(ext); + } + }); +}); + +describe('validateFileExtension', () => { + it('validates audio files against AUDIO_EXTENSIONS', () => { + expect(validateFileExtension('song.flac', AUDIO_EXTENSIONS)).toBe(true); + expect(validateFileExtension('song.mp3', AUDIO_EXTENSIONS)).toBe(true); + expect(validateFileExtension('song.opus', AUDIO_EXTENSIONS)).toBe(true); + expect(validateFileExtension('song.exe', AUDIO_EXTENSIONS)).toBe(false); + expect(validateFileExtension('song.pdf', AUDIO_EXTENSIONS)).toBe(false); + }); +});