-
Notifications
You must be signed in to change notification settings - Fork 1
feat(cli): add score playlist command #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
637f2f7
85d8486
d2dd1f3
d076ea9
16ccf12
198f121
29d6bad
3ab8271
339ece1
7be6d5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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+)/) | ||
| const keyMatch = src.match(/key:\s*'([^']+)'/) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This only matches single-quoted keys — |
||
|
|
||
| const sections = [...src.matchAll(/(Intro|Drop|Breakdown|Buildup|Outro)\(\s*(\d+)/g)] | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| .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 } | ||
| } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Possible bug: songs without arrangement get 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 }
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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)' : ''}`) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Polish / pre-publish: no pluralization logic here — `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') | ||
| } | ||
There was a problem hiding this comment.
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: 120abovebpm: 140will silently report 120. Could tighten to/^\s*bpm:\s*(\d+)/mto anchor to line-start and skip most comment cases. Not a blocker but worth hardening.