diff --git a/README.md b/README.md index 54a9c9b..1af72ba 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ export default Song({ | `score play songs/track.js` | Play a finished song | | `score live songs/track.js` | Live coding — hot reload on save | | `score repl songs/track.js` | REPL — type commands, hear changes instantly | +| `score playlist` | Play all examples and songs in sequence | ## Target genres diff --git a/SCORE_HANDOFF.md b/SCORE_HANDOFF.md index 6b0291e..35a2d5c 100644 --- a/SCORE_HANDOFF.md +++ b/SCORE_HANDOFF.md @@ -154,6 +154,7 @@ Phase 10 ⚠️ CLI — core commands done, stem export + freeze/bounce remai ✅ score doctor — system health checks (Node, pnpm, audio backend) ✅ score new song — template generator with note name syntax ✅ --version / -v flag + ✅ score playlist — play all examples (8-bar) and songs (full form) in sequence ⬜ score export — WAV/stem render ⬜ score list — song inspection/info Phase 10b ⬜ score-audio MCP — effect catalog, signal flow, backend nodes, component catalog diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 4729a6f..4bbd458 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -116,6 +116,9 @@ Save the file. Score reloads within ~300ms. If the reload fails, the previous ve | `score play ` | Play a song | | `score play --watch ` | Play with live reload on save | | `score play --trust ` | Skip the security scan (dev only) | +| `score playlist` | Play all examples and songs in sequence | +| `score playlist ` | Play specific song files | +| `score playlist my-set.playlist` | Play from a playlist file | | `score new song ` | Create a song from the template | | `score doctor` | Check system requirements | | `score --version` | Print version | diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts new file mode 100644 index 0000000..014e58d --- /dev/null +++ b/packages/cli/src/commands/playlist.ts @@ -0,0 +1,203 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { resolve } from 'node:path' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +type PlaylistEntry = { + readonly file: string + readonly bars: number | null +} + +type SongMeta = { + readonly bpm: number + readonly key: string | null + readonly sections: ReadonlyArray<{ readonly sectionType: string; readonly bars: number }> +} + +const barDurationSec = (bpm: number, bars: number): number => (bars * 4 * 60) / bpm + +const formatDuration = (sec: number): string => { + const m = Math.floor(sec / 60) + const s = Math.round(sec % 60) + return m > 0 ? `${String(m)}m${String(s).padStart(2, '0')}s` : `${String(s)}s` +} + +const totalBarsFromSections = (sections: SongMeta['sections']): number => + sections.reduce((sum, s) => sum + s.bars, 0) + +const pause = (sec: number): Promise => + new Promise((res) => setTimeout(res, sec * 1000)) + +const parseSongMeta = (filePath: string): SongMeta => { + const src = readFileSync(filePath, 'utf-8') + const bpmMatch = src.match(/bpm:\s*(\d+)/) + const keyMatch = src.match(/key:\s*'([^']+)'/) + + const sections = [...src.matchAll(/(Intro|Drop|Breakdown|Buildup|Outro)\(\s*(\d+)/g)] + .map(([, type, bars]) => ({ + sectionType: (type ?? '').toLowerCase(), + bars: parseInt(bars ?? '0', 10), + })) + + return { + bpm: bpmMatch?.[1] ? parseInt(bpmMatch[1], 10) : 120, + key: keyMatch?.[1] ?? null, + sections, + } +} + +const discoverSongs = (dir: string): ReadonlyArray => { + if (!existsSync(dir)) return [] + return readdirSync(dir) + .filter((f) => f.endsWith('.js') && f !== 'new-song.js') + .sort() + .map((f) => resolve(dir, f)) +} + +const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 + +const shuffle = (arr: ReadonlyArray): ReadonlyArray => + [...arr] + .map((item) => ({ item, key: Math.random() })) + .sort((a, b) => a.key - b.key) + .map(({ item }) => item) + +const toEntry = (file: string): PlaylistEntry => { + const meta = parseSongMeta(file) + return { file, bars: hasArrangement(meta) ? null : 1 } +} + +const readPlaylistFile = (filePath: string): ReadonlyArray => { + const dir = resolve(filePath, '..') + return readFileSync(filePath, 'utf-8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => resolve(dir, line)) +} + +const isPlaylistFile = (path: string): boolean => path.endsWith('.playlist') + +const cliEntry = resolve( + fileURLToPath(import.meta.url), '..', '..', 'index.js', +) + +const playSongFile = (filePath: string, durationSec: number): Promise => + new Promise((res) => { + const child = spawn( + 'node', + [cliEntry, 'play', '--trust', filePath], + { stdio: 'inherit', env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + ) + + const timer = setTimeout(() => child.kill('SIGTERM'), durationSec * 1000) + + child.on('close', () => { + clearTimeout(timer) + res(child) + }) + }) + +const logEntry = ( + i: number, + total: number, + entry: PlaylistEntry, + meta: SongMeta, + bars: number, + duration: number, +): void => { + const mode = entry.bars === null ? 'full form' : 'once through' + const keyStr = meta.key ? ` — ${meta.key}` : '' + + console.log(`Score: [${String(i + 1)}/${String(total)}] ${entry.file}`) + console.log(`Score: ${String(meta.bpm)} BPM${keyStr} — ${String(bars)} bars — ${formatDuration(duration)} — ${mode}`) + + if (entry.bars === null && meta.sections.length > 0) { + const flow = meta.sections + .map((s) => `${s.sectionType}(${String(s.bars)}b)`) + .join(' → ') + console.log(`Score: Arrangement — ${flow}`) + } +} + +/** + * Play a set of songs back-to-back — like a DJ queue. + * + * Songs without arrangement play once through. Songs with sections + * (intro → drop → breakdown → outro) play the full form. + * + * @param args - Song file paths (`.js`) or a `.playlist` file. Empty = play everything in `examples/` and `songs/`. + * @returns Resolves when the last track finishes. + * + * @example + * ```ts + * await playlist([]) // all examples + songs + * await playlist(['songs/example-techno.js']) // one track + * await playlist(['my-set.playlist']) // from playlist file + * ``` + */ +export const playlist = async (args: string[]): Promise => { + if (args.includes('--help') || args.includes('-h')) { + console.log('Usage:') + console.log(' score playlist Play all examples and songs') + console.log(' score playlist Play specific song files') + console.log(' score playlist set.playlist Play from a playlist file') + console.log(' score playlist --shuffle Randomize play order') + return + } + + const cwd = process.cwd() + const shuffled = args.includes('--shuffle') || args.includes('-s') + const rawArgs = args.filter((a) => !a.startsWith('-')) + + const files = rawArgs.flatMap((f) => { + const resolved = resolve(cwd, f) + return isPlaylistFile(resolved) ? readPlaylistFile(resolved) : [resolved] + }) + + const entries: ReadonlyArray = files.length > 0 + ? files + .filter((f) => { + if (!existsSync(f)) { + console.log(`Score: Skipping — not found: ${f}`) + return false + } + return true + }) + .map(toEntry) + : [ + ...discoverSongs(resolve(cwd, 'examples')).map(toEntry), + ...discoverSongs(resolve(cwd, 'songs')).map(toEntry), + ] + + if (entries.length === 0) { + console.log('Score: No song files found') + return + } + + const ordered = shuffled ? shuffle(entries) : entries + + console.log(`Score: Playlist — ${String(ordered.length)} tracks queued${shuffled ? ' (shuffled)' : ''}`) + + const cleanup = (): void => { + console.log('\nScore: Playlist stopped') + process.exit(0) + } + + process.once('SIGINT', cleanup) + process.once('SIGTERM', cleanup) + + await ordered.reduce(async (prev, entry, i) => { + await prev + + const meta = parseSongMeta(entry.file) + const bars = entry.bars ?? totalBarsFromSections(meta.sections) + const duration = barDurationSec(meta.bpm, bars) + + logEntry(i, ordered.length, entry, meta, bars, duration) + await playSongFile(entry.file, duration + 1) + await pause(1) + }, Promise.resolve()) + + console.log('Score: Playlist complete') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6e4c772..af451c6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { register } from 'node:module' register(new URL('./score-loader.js', import.meta.url).href) import { play } from './commands/play.js' +import { playlist } from './commands/playlist.js' import { newSong } from './commands/new.js' import { doctor } from './commands/doctor.js' @@ -13,9 +14,10 @@ if (command === '--version' || command === '-v') { } const commands: Record Promise | void> = { - play: args => play(args), - new: args => { newSong(args); }, - doctor: args => { doctor(args); }, + play: args => play(args), + playlist: args => playlist(args), + new: args => { newSong(args); }, + doctor: args => { doctor(args); }, } const handler = commands[command ?? ''] @@ -24,7 +26,11 @@ if (!handler) { console.log('') console.log('Usage:') console.log(' score play Play a song file') - console.log(' score play --watch Live reload on file save') + console.log(' score play --watch Live reload on file save') + console.log(' score playlist Play all examples and songs') + console.log(' score playlist Play specific song files') + console.log(' score playlist set.playlist Play from a playlist file') + console.log(' score playlist --shuffle Randomize play order') console.log(' score new song Create a new song from template') console.log(' score doctor Check system requirements') console.log(' score --version Show version') diff --git a/packages/cli/tests/playlist.test.ts b/packages/cli/tests/playlist.test.ts new file mode 100644 index 0000000..68a5bb5 --- /dev/null +++ b/packages/cli/tests/playlist.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +// Mock child_process — playlist spawns subprocesses we don't want in tests +vi.mock('node:child_process', () => ({ + spawn: vi.fn(() => { + const handlers: Record void> = {} + const child = { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { handlers[event] = cb }), + kill: vi.fn(), + pid: 1234, + } + // Simulate immediate close so the playlist advances + setTimeout(() => handlers['close']?.(), 10) + return child + }), +})) + +import { playlist } from '../src/commands/playlist.js' + +const SONG_CONTENT = ` +import { Song, Kick } from '@score/dsl' +const kick = Kick({ pattern: [1, 0, 0, 0], volume: 0.9 }) +export default Song({ bpm: 128, key: 'Am', tracks: [kick] }) +` + +const SONG_WITH_ARRANGEMENT = ` +import { Song, Kick, Intro, Drop, Outro } from '@score/dsl' +const kick = Kick({ pattern: [1, 0, 0, 0], volume: 0.9 }) +export default Song({ + bpm: 140, + key: 'Dm', + tracks: [kick], + arrangement: [Intro(4, [kick]), Drop(16, [kick]), Outro(4, [kick])], +}) +` + +describe('playlist', () => { + const originalCwd = process.cwd() + + afterEach(() => { + vi.clearAllMocks() + process.chdir(originalCwd) + }) + + const makeTempDir = (): string => mkdtempSync(join(tmpdir(), 'score-playlist-')) + + it('discovers songs from examples/ and songs/ when no args given', async () => { + const dir = makeTempDir() + mkdirSync(join(dir, 'examples')) + mkdirSync(join(dir, 'songs')) + writeFileSync(join(dir, 'examples', '01-test.js'), SONG_CONTENT) + writeFileSync(join(dir, 'songs', 'full-song.js'), SONG_WITH_ARRANGEMENT) + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist([]) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('2 tracks queued') + expect(output).toContain('01-test.js') + expect(output).toContain('full-song.js') + expect(output).toContain('Playlist complete') + spy.mockRestore() + }) + + it('plays specific files when paths are given', async () => { + const dir = makeTempDir() + writeFileSync(join(dir, 'track.js'), SONG_CONTENT) + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist(['track.js']) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('1 tracks queued') + expect(output).toContain('track.js') + spy.mockRestore() + }) + + it('reads .playlist files', async () => { + const dir = makeTempDir() + writeFileSync(join(dir, 'a.js'), SONG_CONTENT) + writeFileSync(join(dir, 'b.js'), SONG_WITH_ARRANGEMENT) + writeFileSync(join(dir, 'my-set.playlist'), '# My set\na.js\nb.js\n') + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist(['my-set.playlist']) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('2 tracks queued') + spy.mockRestore() + }) + + it('skips missing files with a message', async () => { + const dir = makeTempDir() + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist(['nonexistent.js']) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('Skipping') + expect(output).toContain('No song files found') + spy.mockRestore() + }) + + it('detects once through mode for songs without arrangement', async () => { + const dir = makeTempDir() + writeFileSync(join(dir, 'loop.js'), SONG_CONTENT) + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist(['loop.js']) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('once through') + expect(output).toContain('128 BPM') + spy.mockRestore() + }) + + it('detects full form mode for songs with arrangement', async () => { + const dir = makeTempDir() + writeFileSync(join(dir, 'song.js'), SONG_WITH_ARRANGEMENT) + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist(['song.js']) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('full form') + expect(output).toContain('140 BPM') + expect(output).toContain('intro(4b) → drop(16b) → outro(4b)') + spy.mockRestore() + }) + + it('prints nothing found when directories are empty', async () => { + const dir = makeTempDir() + process.chdir(dir) + + const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + await playlist([]) + + const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') + expect(output).toContain('No song files found') + spy.mockRestore() + }) +})