Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions SCORE_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> — 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
Expand Down
3 changes: 3 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ Save the file. Score reloads within ~300ms. If the reload fails, the previous ve
| `score play <file.js>` | Play a song |
| `score play --watch <file.js>` | Play with live reload on save |
| `score play --trust <file.js>` | Skip the security scan (dev only) |
| `score playlist` | Play all examples and songs in sequence |
| `score playlist <files...>` | Play specific song files |
| `score playlist my-set.playlist` | Play from a playlist file |
| `score new song <name>` | Create a song from the template |
| `score doctor` | Check system requirements |
| `score --version` | Print version |
Expand Down
203 changes: 203 additions & 0 deletions packages/cli/src/commands/playlist.ts
Original file line number Diff line number Diff line change
@@ -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<void> =>
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+)/)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this regex matches the first bpm: occurrence in the file — including inside comments. A song with // was bpm: 120 above bpm: 140 will silently report 120. Could tighten to /^\s*bpm:\s*(\d+)/m to anchor to line-start and skip most comment cases. Not a blocker but worth hardening.

const keyMatch = src.match(/key:\s*'([^']+)'/)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only matches single-quoted keys — key: "Amin" or key: \ will fall through to null and the key won't appear in playlist output. Could extend the pattern to accept all three quote styles: /key:\s*['"]([^'"`]+)['"`]/`


const sections = [...src.matchAll(/(Intro|Drop|Breakdown|Buildup|Outro)\(\s*(\d+)/g)]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded to the current 5 DSL section types and scans all text including comments. Works for the songs we ship today, but won't scale if users add custom section types or if the DSL grows. A more robust approach would be to dynamically import the song file and read song.arrangement directly — the --trust flag already makes that safe in this context.

.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<string> => {
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 = <T>(arr: ReadonlyArray<T>): ReadonlyArray<T> =>
[...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 }
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible bug: songs without arrangement get bars: 1, which means barDurationSec(bpm, 1) gives ~1.87s at 128 BPM. The child process is killed after ~2.87s (duration + 1) — almost certainly too short for any real song loop.

Suggest a named default constant so it is easy to tune:

const DEFAULT_LOOP_BARS = 8
return { file, bars: hasArrangement(meta) ? null : DEFAULT_LOOP_BARS }

@nestorwheelock nestorwheelock Mar 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha my first iteration I put that 8 bar loop in there first so I could hear everything but I didn't want to invent anything or alter anyone's arrangements so I took out the default loop for the short samples so not to be opinionated but I agree if it's just a short beat we should hear it a few times to actually hear it.

198f121
"Songs without arrangement play their pattern once — what's written
is what plays. If the author wants more, they write more. "

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arrangements were just wired in recently, and when I built that I wasn't thinking about playlist at all. Arrangements aren't required for a song — they're optional. So bars: 1 will silently kill some songs after ~2 seconds since not all songs will have an arrangement. "What's written is what plays" is the right philosophy, but without a default loop bar count, songs without arrangement effectively play nothing. A DEFAULT_LOOP_BARS = 8 constant keeps the intent clear and gives users a usable default when no arrangement is present.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_LOOP_BARS should only apply when no arrangement is present — if the song has an arrangement, it plays as written. It's purely a fallback so songs without arrangement are actually audible.


const readPlaylistFile = (filePath: string): ReadonlyArray<string> => {
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<ChildProcess> =>
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<void> => {
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage:')
console.log(' score playlist Play all examples and songs')
console.log(' score playlist <files...> 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<PlaylistEntry> = 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)' : ''}`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Polish / pre-publish: no pluralization logic here — 1 tracks queued is grammatically incorrect. Before publishing, worth adding a simple ternary:

`Score: Playlist — ${String(ordered.length)} ${ordered.length === 1 ? 'track' : 'tracks'} queued${shuffled ? ' (shuffled)' : ''}`

Not a blocker for merge but should be fixed before a public release.


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')
}
14 changes: 10 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -13,9 +14,10 @@ if (command === '--version' || command === '-v') {
}

const commands: Record<string, (args: string[]) => Promise<void> | 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 ?? '']
Expand All @@ -24,7 +26,11 @@ if (!handler) {
console.log('')
console.log('Usage:')
console.log(' score play <song.js> Play a song file')
console.log(' score play <song.js> --watch Live reload on file save')
console.log(' score play <song.js> --watch Live reload on file save')
console.log(' score playlist Play all examples and songs')
console.log(' score playlist <files...> 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 <name> Create a new song from template')
console.log(' score doctor Check system requirements')
console.log(' score --version Show version')
Expand Down
Loading