From 637f2f71637e112e50b329fe71c7cb01411ea44e Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:03:29 -0500 Subject: [PATCH 1/9] feat(cli): add score playlist command Plays all examples (8-bar samples) and songs (full form) in sequence. Discovers song files from examples/ and songs/ directories automatically. Supports --examples and --songs flags to filter. --- README.md | 1 + SCORE_HANDOFF.md | 1 + docs/GETTING_STARTED.md | 3 + packages/cli/src/commands/playlist.ts | 164 ++++++++++++++++++++++++++ packages/cli/src/index.ts | 13 +- 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/playlist.ts 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..9e550be 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 --examples` | Play examples only (8-bar samples) | +| `score playlist --songs` | Play full songs only | | `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..51d06c9 --- /dev/null +++ b/packages/cli/src/commands/playlist.ts @@ -0,0 +1,164 @@ +// playlist.ts — Play all examples (8-bar sample) and songs (full form) in sequence +// +// Usage: score playlist +// score playlist --examples Only examples +// score playlist --songs Only full songs + +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' + +// ── Types ─────────────────────────────────────────────────────────────────── + +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 }> +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +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)) + +// Parse song metadata from source text to avoid dynamic import side-effects +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, + } +} + +// Discover song files from a directory, sorted by name +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)) +} + +// Has arrangement sections → full form, otherwise 8-bar sample +const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 + +// ── Playback ──────────────────────────────────────────────────────────────── + +const cliEntry = resolve( + fileURLToPath(import.meta.url), '..', '..', 'index.js', +) + +const playSongFile = ( + filePath: string, + durationSec: number, + state: { child: ChildProcess | null }, +): Promise => + new Promise((res) => { + const child = spawn( + 'pw-jack', + ['node', cliEntry, 'play', '--trust', filePath], + { stdio: 'inherit', env: { ...process.env, NODE_NO_WARNINGS: '1' } }, + ) + + state.child = child + const timer = setTimeout(() => child.kill('SIGTERM'), durationSec * 1000) + + child.on('close', () => { + clearTimeout(timer) + state.child = null + res() + }) + }) + +// ── Main ──────────────────────────────────────────────────────────────────── + +export const playlist = async (args: string[]): Promise => { + const onlyExamples = args.includes('--examples') + const onlySongs = args.includes('--songs') + const cwd = process.cwd() + + const examplesDir = resolve(cwd, 'examples') + const songsDir = resolve(cwd, 'songs') + + const entries: PlaylistEntry[] = [ + ...(!onlySongs + ? discoverSongs(examplesDir).map((file) => { + const meta = parseSongMeta(file) + return { file, bars: hasArrangement(meta) ? null : 8 } as PlaylistEntry + }) + : []), + ...(!onlyExamples + ? discoverSongs(songsDir).map((file) => ({ file, bars: null }) as PlaylistEntry) + : []), + ] + + if (entries.length === 0) { + console.log('Score: No song files found in examples/ or songs/') + return + } + + console.log(`Score: Playlist mode — ${String(entries.length)} tracks queued`) + + const state = Object.seal({ child: null as ChildProcess | null }) + + const cleanup = (): void => { + if (state.child) state.child.kill('SIGTERM') + console.log('\nScore: Playlist stopped') + process.exit(0) + } + + process.once('SIGINT', cleanup) + process.once('SIGTERM', cleanup) + + await entries.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) + const mode = entry.bars === null ? 'full form' : '8-bar sample' + const keyStr = meta.key ? ` — ${meta.key}` : '' + + console.log(`Score: [${String(i + 1)}/${String(entries.length)}] ${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}`) + } + + await playSongFile(entry.file, duration + 1, state) + 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..678bef9 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,10 @@ 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 --examples Play examples only (8-bar samples)') + console.log(' score playlist --songs Play full songs only') console.log(' score new song Create a new song from template') console.log(' score doctor Check system requirements') console.log(' score --version Show version') From 85d84869739e3c1366a12007148fcff1ef76f6b2 Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:06:37 -0500 Subject: [PATCH 2/9] feat(cli): support positional args and .playlist files score playlist now accepts song paths as positional arguments and reads .playlist files (one path per line, # comments). No args defaults to discovering examples/ + songs/. --- docs/GETTING_STARTED.md | 4 +- packages/cli/src/commands/playlist.ts | 64 ++++++++++++++++++--------- packages/cli/src/index.ts | 4 +- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 9e550be..4bbd458 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -117,8 +117,8 @@ Save the file. Score reloads within ~300ms. If the reload fails, the previous ve | `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 --examples` | Play examples only (8-bar samples) | -| `score playlist --songs` | Play full songs only | +| `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 index 51d06c9..b39790f 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -1,8 +1,8 @@ -// playlist.ts — Play all examples (8-bar sample) and songs (full form) in sequence +// playlist.ts — Play song files in sequence // -// Usage: score playlist -// score playlist --examples Only examples -// score playlist --songs Only full songs +// Usage: score playlist Play all examples + songs +// score playlist songs/my-track.js Play specific files +// score playlist my-set.playlist Play from a playlist file import { spawn, type ChildProcess } from 'node:child_process' import { resolve } from 'node:path' @@ -69,6 +69,24 @@ const discoverSongs = (dir: string): ReadonlyArray => { // Has arrangement sections → full form, otherwise 8-bar sample const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 +// Build entry from a file path — auto-detect mode from arrangement +const toEntry = (file: string): PlaylistEntry => { + const meta = parseSongMeta(file) + return { file, bars: hasArrangement(meta) ? null : 8 } +} + +// Read a .playlist file — one path per line, # comments, blank lines ignored +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') + // ── Playback ──────────────────────────────────────────────────────────────── const cliEntry = resolve( @@ -100,31 +118,37 @@ const playSongFile = ( // ── Main ──────────────────────────────────────────────────────────────────── export const playlist = async (args: string[]): Promise => { - const onlyExamples = args.includes('--examples') - const onlySongs = args.includes('--songs') const cwd = process.cwd() + const rawArgs = args.filter((a) => !a.startsWith('-')) - const examplesDir = resolve(cwd, 'examples') - const songsDir = resolve(cwd, 'songs') + // Expand .playlist files into their listed paths, pass .js files through + const files = rawArgs.flatMap((f) => { + const resolved = resolve(cwd, f) + return isPlaylistFile(resolved) ? readPlaylistFile(resolved) : [resolved] + }) - const entries: PlaylistEntry[] = [ - ...(!onlySongs - ? discoverSongs(examplesDir).map((file) => { - const meta = parseSongMeta(file) - return { file, bars: hasArrangement(meta) ? null : 8 } as PlaylistEntry + // Explicit files → play those. No files → discover from examples/ + songs/ + const entries: ReadonlyArray = files.length > 0 + ? files + .filter((f) => { + if (!existsSync(f)) { + console.log(`Score: Skipping — not found: ${f}`) + return false + } + return true }) - : []), - ...(!onlyExamples - ? discoverSongs(songsDir).map((file) => ({ file, bars: null }) as PlaylistEntry) - : []), - ] + .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 in examples/ or songs/') + console.log('Score: No song files found') return } - console.log(`Score: Playlist mode — ${String(entries.length)} tracks queued`) + console.log(`Score: Playlist — ${String(entries.length)} tracks queued`) const state = Object.seal({ child: null as ChildProcess | null }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 678bef9..b2f126d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -28,8 +28,8 @@ if (!handler) { console.log(' score play Play a song file') console.log(' score play --watch Live reload on file save') console.log(' score playlist Play all examples and songs') - console.log(' score playlist --examples Play examples only (8-bar samples)') - console.log(' score playlist --songs Play full songs only') + console.log(' score playlist Play specific song files') + console.log(' score playlist set.playlist Play from a playlist file') console.log(' score new song Create a new song from template') console.log(' score doctor Check system requirements') console.log(' score --version Show version') From d2dd1f3d97e616d494b3b3c0cddd1253d48ddcce Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:16:13 -0500 Subject: [PATCH 3/9] refactor(cli): remove mutable state and pw-jack from playlist Replace Object.seal state hack with pure functions. Spawn node directly instead of pw-jack (platform-agnostic). Extract logEntry as a pure function. Zero let, zero mutation. --- packages/cli/src/commands/playlist.ts | 54 ++++++++++++++------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index b39790f..f6e784b 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -93,28 +93,44 @@ const cliEntry = resolve( fileURLToPath(import.meta.url), '..', '..', 'index.js', ) -const playSongFile = ( - filePath: string, - durationSec: number, - state: { child: ChildProcess | null }, -): Promise => +const playSongFile = (filePath: string, durationSec: number): Promise => new Promise((res) => { const child = spawn( - 'pw-jack', - ['node', cliEntry, 'play', '--trust', filePath], + 'node', + [cliEntry, 'play', '--trust', filePath], { stdio: 'inherit', env: { ...process.env, NODE_NO_WARNINGS: '1' } }, ) - state.child = child const timer = setTimeout(() => child.kill('SIGTERM'), durationSec * 1000) child.on('close', () => { clearTimeout(timer) - state.child = null - res() + res(child) }) }) +const logEntry = ( + i: number, + total: number, + entry: PlaylistEntry, + meta: SongMeta, + bars: number, + duration: number, +): void => { + const mode = entry.bars === null ? 'full form' : '8-bar sample' + 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}`) + } +} + // ── Main ──────────────────────────────────────────────────────────────────── export const playlist = async (args: string[]): Promise => { @@ -150,10 +166,7 @@ export const playlist = async (args: string[]): Promise => { console.log(`Score: Playlist — ${String(entries.length)} tracks queued`) - const state = Object.seal({ child: null as ChildProcess | null }) - const cleanup = (): void => { - if (state.child) state.child.kill('SIGTERM') console.log('\nScore: Playlist stopped') process.exit(0) } @@ -167,20 +180,9 @@ export const playlist = async (args: string[]): Promise => { const meta = parseSongMeta(entry.file) const bars = entry.bars ?? totalBarsFromSections(meta.sections) const duration = barDurationSec(meta.bpm, bars) - const mode = entry.bars === null ? 'full form' : '8-bar sample' - const keyStr = meta.key ? ` — ${meta.key}` : '' - - console.log(`Score: [${String(i + 1)}/${String(entries.length)}] ${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}`) - } - await playSongFile(entry.file, duration + 1, state) + logEntry(i, entries.length, entry, meta, bars, duration) + await playSongFile(entry.file, duration + 1) await pause(1) }, Promise.resolve()) From d076ea9362640aa3ecf05d4235871f80cd8966c8 Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:17:59 -0500 Subject: [PATCH 4/9] feat(cli): add TSDoc and tests for playlist command Add TSDoc block to the public playlist export per project standard. Add 7 tests covering: discovery, explicit files, .playlist files, missing file handling, 8-bar sample vs full form detection. --- packages/cli/src/commands/playlist.ts | 12 ++ packages/cli/tests/playlist.test.ts | 151 ++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 packages/cli/tests/playlist.test.ts diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index f6e784b..36b1b9b 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -133,6 +133,18 @@ const logEntry = ( // ── Main ──────────────────────────────────────────────────────────────────── +/** + * Play song files in sequence — examples get 8-bar samples, songs play full form. + * + * @param args - Positional file paths (`.js` or `.playlist`). Empty = discover all. + * + * @example + * ```ts + * await playlist([]) // all examples + songs + * await playlist(['songs/example-techno.js']) // one file + * await playlist(['my-set.playlist']) // from playlist file + * ``` + */ export const playlist = async (args: string[]): Promise => { const cwd = process.cwd() const rawArgs = args.filter((a) => !a.startsWith('-')) diff --git a/packages/cli/tests/playlist.test.ts b/packages/cli/tests/playlist.test.ts new file mode 100644 index 0000000..a0c5236 --- /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 8-bar sample 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('8-bar sample') + 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() + }) +}) From 16ccf12ecc22fc8b426edb77e1a7b0203929e130 Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:19:47 -0500 Subject: [PATCH 5/9] docs(cli): improve playlist TSDoc to match project voice Add @returns, use musical voice per TSDOC_STANDARD.md. --- packages/cli/src/commands/playlist.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index 36b1b9b..76c972d 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -134,14 +134,18 @@ const logEntry = ( // ── Main ──────────────────────────────────────────────────────────────────── /** - * Play song files in sequence — examples get 8-bar samples, songs play full form. + * Play a set of songs back-to-back — like a DJ queue. * - * @param args - Positional file paths (`.js` or `.playlist`). Empty = discover all. + * Loops without arrangement get an 8-bar preview. 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 file + * await playlist(['songs/example-techno.js']) // one track * await playlist(['my-set.playlist']) // from playlist file * ``` */ From 198f1215244fe7082f670cc07cbea51c4331ae0c Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:22:58 -0500 Subject: [PATCH 6/9] fix(cli): play patterns once through instead of looping 8 bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Songs without arrangement play their pattern once — what's written is what plays. If the author wants more, they write more. --- packages/cli/src/commands/playlist.ts | 6 +++--- packages/cli/tests/playlist.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index 76c972d..9a3d5cc 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -72,7 +72,7 @@ const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 // Build entry from a file path — auto-detect mode from arrangement const toEntry = (file: string): PlaylistEntry => { const meta = parseSongMeta(file) - return { file, bars: hasArrangement(meta) ? null : 8 } + return { file, bars: hasArrangement(meta) ? null : 1 } } // Read a .playlist file — one path per line, # comments, blank lines ignored @@ -117,7 +117,7 @@ const logEntry = ( bars: number, duration: number, ): void => { - const mode = entry.bars === null ? 'full form' : '8-bar sample' + 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}`) @@ -136,7 +136,7 @@ const logEntry = ( /** * Play a set of songs back-to-back — like a DJ queue. * - * Loops without arrangement get an 8-bar preview. Songs with sections + * 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/`. diff --git a/packages/cli/tests/playlist.test.ts b/packages/cli/tests/playlist.test.ts index a0c5236..68a5bb5 100644 --- a/packages/cli/tests/playlist.test.ts +++ b/packages/cli/tests/playlist.test.ts @@ -108,7 +108,7 @@ describe('playlist', () => { spy.mockRestore() }) - it('detects 8-bar sample mode for songs without arrangement', async () => { + it('detects once through mode for songs without arrangement', async () => { const dir = makeTempDir() writeFileSync(join(dir, 'loop.js'), SONG_CONTENT) process.chdir(dir) @@ -117,7 +117,7 @@ describe('playlist', () => { await playlist(['loop.js']) const output = spy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n') - expect(output).toContain('8-bar sample') + expect(output).toContain('once through') expect(output).toContain('128 BPM') spy.mockRestore() }) From 29d6bad79b3120fa91f3324071f541661cb20a1d Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:24:31 -0500 Subject: [PATCH 7/9] feat(cli): add --shuffle/-s flag to playlist command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Randomize play order with a pure functional shuffle — map to random keys, sort, map back. No mutation. --- packages/cli/src/commands/playlist.ts | 18 ++++++++++++++---- packages/cli/src/index.ts | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index 9a3d5cc..c4f4a6c 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -66,9 +66,16 @@ const discoverSongs = (dir: string): ReadonlyArray => { .map((f) => resolve(dir, f)) } -// Has arrangement sections → full form, otherwise 8-bar sample +// Has arrangement sections → full form, otherwise once through const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 +// Shuffle — sort by random key, pure and allocation-only +const shuffle = (arr: ReadonlyArray): ReadonlyArray => + [...arr] + .map((item) => ({ item, key: Math.random() })) + .sort((a, b) => a.key - b.key) + .map(({ item }) => item) + // Build entry from a file path — auto-detect mode from arrangement const toEntry = (file: string): PlaylistEntry => { const meta = parseSongMeta(file) @@ -151,6 +158,7 @@ const logEntry = ( */ export const playlist = async (args: string[]): Promise => { const cwd = process.cwd() + const shuffled = args.includes('--shuffle') || args.includes('-s') const rawArgs = args.filter((a) => !a.startsWith('-')) // Expand .playlist files into their listed paths, pass .js files through @@ -180,7 +188,9 @@ export const playlist = async (args: string[]): Promise => { return } - console.log(`Score: Playlist — ${String(entries.length)} tracks queued`) + 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') @@ -190,14 +200,14 @@ export const playlist = async (args: string[]): Promise => { process.once('SIGINT', cleanup) process.once('SIGTERM', cleanup) - await entries.reduce(async (prev, entry, i) => { + 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, entries.length, entry, meta, bars, duration) + logEntry(i, ordered.length, entry, meta, bars, duration) await playSongFile(entry.file, duration + 1) await pause(1) }, Promise.resolve()) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b2f126d..af451c6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -30,6 +30,7 @@ if (!handler) { 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') From 3ab8271fdc01302f3be88c4ae0f8f229e7f42635 Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:28:58 -0500 Subject: [PATCH 8/9] feat(cli): add --help/-h flag to playlist command Prints usage when called with --help, matching the pattern used by score new for inline help. --- packages/cli/src/commands/playlist.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index c4f4a6c..d090dca 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -157,6 +157,15 @@ const logEntry = ( * ``` */ 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('-')) From 7be6d5b5ae0fa122887a462820c9195d6b7b1e9d Mon Sep 17 00:00:00 2001 From: nestorwheelock Date: Sat, 21 Mar 2026 15:39:20 -0500 Subject: [PATCH 9/9] style(cli): strip inline comments from playlist command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match project convention — code reads without comments. Function names and types are the documentation. --- packages/cli/src/commands/playlist.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/cli/src/commands/playlist.ts b/packages/cli/src/commands/playlist.ts index d090dca..014e58d 100644 --- a/packages/cli/src/commands/playlist.ts +++ b/packages/cli/src/commands/playlist.ts @@ -1,16 +1,8 @@ -// playlist.ts — Play song files in sequence -// -// Usage: score playlist Play all examples + songs -// score playlist songs/my-track.js Play specific files -// score playlist my-set.playlist Play from a playlist file - 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' -// ── Types ─────────────────────────────────────────────────────────────────── - type PlaylistEntry = { readonly file: string readonly bars: number | null @@ -22,8 +14,6 @@ type SongMeta = { readonly sections: ReadonlyArray<{ readonly sectionType: string; readonly bars: number }> } -// ── Helpers ───────────────────────────────────────────────────────────────── - const barDurationSec = (bpm: number, bars: number): number => (bars * 4 * 60) / bpm const formatDuration = (sec: number): string => { @@ -38,7 +28,6 @@ const totalBarsFromSections = (sections: SongMeta['sections']): number => const pause = (sec: number): Promise => new Promise((res) => setTimeout(res, sec * 1000)) -// Parse song metadata from source text to avoid dynamic import side-effects const parseSongMeta = (filePath: string): SongMeta => { const src = readFileSync(filePath, 'utf-8') const bpmMatch = src.match(/bpm:\s*(\d+)/) @@ -57,7 +46,6 @@ const parseSongMeta = (filePath: string): SongMeta => { } } -// Discover song files from a directory, sorted by name const discoverSongs = (dir: string): ReadonlyArray => { if (!existsSync(dir)) return [] return readdirSync(dir) @@ -66,23 +54,19 @@ const discoverSongs = (dir: string): ReadonlyArray => { .map((f) => resolve(dir, f)) } -// Has arrangement sections → full form, otherwise once through const hasArrangement = (meta: SongMeta): boolean => meta.sections.length > 0 -// Shuffle — sort by random key, pure and allocation-only const shuffle = (arr: ReadonlyArray): ReadonlyArray => [...arr] .map((item) => ({ item, key: Math.random() })) .sort((a, b) => a.key - b.key) .map(({ item }) => item) -// Build entry from a file path — auto-detect mode from arrangement const toEntry = (file: string): PlaylistEntry => { const meta = parseSongMeta(file) return { file, bars: hasArrangement(meta) ? null : 1 } } -// Read a .playlist file — one path per line, # comments, blank lines ignored const readPlaylistFile = (filePath: string): ReadonlyArray => { const dir = resolve(filePath, '..') return readFileSync(filePath, 'utf-8') @@ -94,8 +78,6 @@ const readPlaylistFile = (filePath: string): ReadonlyArray => { const isPlaylistFile = (path: string): boolean => path.endsWith('.playlist') -// ── Playback ──────────────────────────────────────────────────────────────── - const cliEntry = resolve( fileURLToPath(import.meta.url), '..', '..', 'index.js', ) @@ -138,8 +120,6 @@ const logEntry = ( } } -// ── Main ──────────────────────────────────────────────────────────────────── - /** * Play a set of songs back-to-back — like a DJ queue. * @@ -170,13 +150,11 @@ export const playlist = async (args: string[]): Promise => { const shuffled = args.includes('--shuffle') || args.includes('-s') const rawArgs = args.filter((a) => !a.startsWith('-')) - // Expand .playlist files into their listed paths, pass .js files through const files = rawArgs.flatMap((f) => { const resolved = resolve(cwd, f) return isPlaylistFile(resolved) ? readPlaylistFile(resolved) : [resolved] }) - // Explicit files → play those. No files → discover from examples/ + songs/ const entries: ReadonlyArray = files.length > 0 ? files .filter((f) => {