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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ jobs:

- name: Test with coverage
run: pnpm test:coverage

- name: Install Playwright browsers
run: pnpm --filter @score/gui exec playwright install --with-deps chromium

- name: E2e tests (Electron via Xvfb)
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" pnpm --filter @score/gui test:e2e
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ sounds/**/*
# Claude Code local state
.claude/

# Bug reports written by the app — not committed
debug/

# Local only — not ready to publish
THESIS.md
mcp-servers/score-codebase/node_modules/
118 changes: 70 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
# Score

> A production-level, component-based EDM audio framework for creating music as code in TypeScript/JavaScript.
[![CI](https://github.com/bwyard/score/actions/workflows/ci.yml/badge.svg)](https://github.com/bwyard/score/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE)
[![Node ≥20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org)

**Author:** Bree Yard
**Status:** Active development — Phase 13 (Score Studio GUI). Tester release in progress.
> A production-grade, component-based EDM audio framework. Write music as code in TypeScript — no samples, no MIDI files, no drag-and-drop. Every note, pattern, and arrangement is a pure function of time.

**Author:** Bree Yard — [breeyard.dev](https://breeyard.dev)
**Status:** Phase 13 of 17 — stable core, Score Studio GUI in active development

<!-- TODO: add a GIF or screenshot of Score Studio here once captured -->
<!-- ![Score Studio](./docs/assets/score-studio.gif) -->

---

## What is Score?

Score is two things simultaneously:
Score is a professional music production framework and a live coding environment built on the Web Audio API.

**As a framework** — 15 TypeScript packages covering synthesis, effects, sequencing, mixing, modulation, MIDI, and an Electron GUI. Built with a strict functional architecture: no classes, no mutation, every component is a factory function.

1. **A framework** (TypeScript) — the engine, instruments, effects, sequencer, mixer, CLI, and GUI
2. **A language** (plain ESM) — the song file format that musicians and developers use to write music as code
**As a language** — song files are plain ESM. Import instruments, chain methods, export a `Song`. The engine evaluates it and plays it. Live reload while you write.

The core philosophy: **a song is a pure function of time.** Every note, pattern, effect value, and arrangement decision is written by hand. No AI generates any musical content — ever.
The core thesis: **a song is a pure function of time.** No AI generates musical content — ever.

---

Expand All @@ -23,15 +32,10 @@ The core philosophy: **a song is a pure function of time.** Every note, pattern,
```js
import { Song, Kick808, Snare909, Hihat808, Bass303, Pad } from '@score/dsl'

// Drums — euclidean shorthand: Kick808(n) spreads n hits over 16 steps
const kick = Kick808(4).volume(0.8)
const snare = Snare909(2).volume(0.55)
const hihat = Hihat808(8).volume(0.25)

// Or explicit step indices
// const kick = Kick808().hits(0, 4, 8, 12).volume(0.8)

// Melodic — note array pattern: strings are pitches, 0 is a rest
const bass = Bass303('A2')
.cutoff(600)
.resonance(0.4)
Expand All @@ -47,22 +51,25 @@ const pad = Pad('A3')
export default Song({ bpm: 128, tracks: [kick, snare, hihat, bass, pad] })
```

Chain methods are fully type-safe. `Bass303().cutoff(600).volume(0.8)` returns `Bass303Part`sub-type methods are preserved through every composition step.
Chain methods are fully type-safe`Bass303().cutoff(600).volume(0.8)` returns `Bass303Part`, sub-type methods preserved through every composition step.

---

## Score Studio (GUI)
## Score Studio

Score Studio is the Electron-based live coding environment that ships with Score.

Score Studio is the Electron-based GUI that ships with Score. Edit code on the left, hear and see changes in real time.
<!-- TODO: replace with actual demo link once deployed -->
<!-- **[→ Try the tester demo](https://score-tester.breeyard.dev)** -->

- **Live Code mode** — code editor wired to the engine. Ctrl+Enter evals. BPM, step toggles, and mixer changes write back to the code.
- **Performance mode** — full-screen audio visualizer. Theme declared in song file.
- **Punchcard grid** — click steps to toggle. Changes write back to the editor.
- **Mixer** — per-track volume/mute faders. Drag to update `.volume()` in code.
- **Import toggle** — hide/show the import block for a cleaner editing view.
- **Live Code editor** — edit code, hit Ctrl+Enter, hear changes immediately
- **Punchcard grid** — click steps to toggle hits; changes write back to the editor
- **Mixer** — per-track volume and mute faders; drag to update `.volume()` in code
- **Performance mode** — full-screen audio visualizer, theme declared in the song file
- **Bug reporting** — in-app report button captures logs, code, and engine state

```bash
# From the score repo root
# Start Score Studio
pnpm --filter @score/gui dev
```

Expand All @@ -71,13 +78,13 @@ pnpm --filter @score/gui dev
## CLI

```bash
score play <song.js> # Play a song file
score play <song.js> --watch # Live reload on every save
score new song <name> # Create a new song from template
score list <song.js> # Show song info and track list
score export <song.js> # Render to WAV
score repl # Interactive REPL
score doctor # Check system requirements
score play <song.js> # Play a song file
score play <song.js> --watch # Live reload on save
score new song <name> # Create a new song from template
score list <song.js> # Show song info and track list
score export <song.js> # Render to WAV
score repl # Interactive REPL
score doctor # Check system requirements
```

---
Expand All @@ -86,28 +93,41 @@ score doctor # Check system requirements

| Package | Purpose |
|---|---|
| `@score/core` | AudioContext backend, ScoreError, UID |
| `@score/components` | Kick808/909, Snare909, Hihat808, FMSynth, SubtractiveSynth, Bass303, Pad, Rhodes, Pluck |
| `@score/core` | Audio context backend, ScoreError, UID |
| `@score/components` | Kick808/909, Snare909, Hihat808, FMSynth, SubtractiveSynth, Bass303, Pad, Rhodes, Pluck, WobbleBass |
| `@score/effects` | Reverb, Delay, Filter, Compressor, EQ, Distortion, Chorus, Phaser, Flanger, Limiter, Gate, Saturation, AutoPan, BitCrusher, StereoWidener |
| `@score/dsl` | Song, Track, chain API (Bass303, Pad, Pluck, Rhodes, Kick808, etc.), `ChainMethods<T>` |
| `@score/sequencer` | Transport, step sequencer, bar callbacks |
| `@score/mixer` | Mixer, channel strips, return bus, master chain |
| `@score/math` | Chaos math — Lorenz, logistic map, OUProcess (stochastic basis) |
| `@score/modulation` | LFO, ADSR, ramp/sine/OU, automation wiring |
| `@score/pattern` | Euclidean rhythms, combinators, reverse, shift, degrade |
| `@score/visuals` | Visual themes, rendering targets, per-song canvas |
| `@score/cli` | play, repl, list, export, new, doctor |
| `@score/midi` | WebMIDI bridge, Pioneer XDJ profiles |
| `@score/session` | Jam session, WebSocket sync |
| `@score/mcp` | MCP tools for Claude Code integration |
| `@score/gui` | Score Studio — Electron DAW interface |
| `@score/instruments` | Instrument registry — kick, snare, hihat, synth, arp, subsynth, pad, sample |
| `@score/dsl` | Song, chain API (Bass303, Pad, Kick808, etc.), arrangement blocks, modulation descriptors |
| `@score/sequencer` | Transport, clock, step sequencer |
| `@score/mixer` | Channel strips, return bus, master chain |
| `@score/modulation` | LFO, ADSR, automation, ramp, chaos sources |
| `@score/math` | Chaos math — Lorenz attractor, logistic map, Ornstein-Uhlenbeck process |
| `@score/pattern` | Euclidean rhythms, reverse, shift, degrade, swing |
| `@score/musical` | Music theory — scales, chord resolution, frequency mapping |
| `@score/visuals` | Visual themes, audio-reactive canvas rendering |
| `@score/midi` | WebMIDI bridge, Pioneer XDJ-RX3 profile |
| `@score/session` | Jam session, live patch updates |
| `@score/cli` | play, repl, list, export, new, doctor commands |
| `@score/mcp` | MCP server for Claude Code integration |
| `@score/gui` | Score Studio — Electron DAW |

---

## Architecture

Score is a strict functional TypeScript monorepo built with Turborepo and pnpm workspaces.

- **Zero classes** — every component is a factory function returning a plain object
- **No mutation** — config objects are never mutated; state is threaded explicitly
- **Web Audio API** — all synthesis runs in the audio thread, scheduled against `audioContext.currentTime`
- **Hardware boundary pattern** — audio nodes are the only mutable state; everything above is pure
- **Test coverage** — 90/85/90/90 thresholds enforced in CI (statements/branches/functions/lines)

---

## Development

```bash
# Install dependencies
pnpm install

# Build all library packages (Turborepo, cached)
Expand All @@ -119,7 +139,10 @@ pnpm test
# Type-check everything
pnpm typecheck

# Start Score Studio (Electron dev server)
# Lint
pnpm lint

# Start Score Studio
pnpm --filter @score/gui dev
```

Expand All @@ -129,12 +152,11 @@ pnpm --filter @score/gui dev

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md).

Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) before participating. To report a security issue, see [SECURITY.md](./SECURITY.md).
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md).
To report a security issue see [SECURITY.md](./SECURITY.md).

---

## License

Apache License 2.0. Use freely personal, commercial, open source. See [LICENSE](./LICENSE). Author: Bree Yard.
Apache License 2.0 — use freely in personal, commercial, and open source projects. See [LICENSE](./LICENSE).
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,8 @@
"typescript": "^5.5.0",
"typescript-eslint": "^8.57.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"onlyBuiltDependencies": ["electron"]
}
}
57 changes: 48 additions & 9 deletions packages/cli/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const applyPitchTransforms = (
// function patterns are passed through as-is and evaluated per-tick in the sequencer.

const rotatePattern = (pattern: number[], phase: number): number[] => {
if (pattern.length === 0) return pattern
const offset = ((Math.round(phase * pattern.length) % pattern.length) + pattern.length) % pattern.length
return offset === 0 ? pattern : [...pattern.slice(offset), ...pattern.slice(0, offset)]
}
Expand Down Expand Up @@ -241,6 +242,7 @@ export const partToInstrumentDescriptor = (
...(part._fadeOutBars !== undefined ? { _fadeOutBars: part._fadeOutBars } : {}),
...(part._chokeGroup !== undefined ? { _chokeGroup: part._chokeGroup } : {}),
...(part._mute !== undefined ? { _mute: part._mute } : {}),
...(part._solo !== undefined ? { _solo: part._solo } : {}),
props: {
...normalizeVolumeField(part.instrumentType, part._volume),
...(resolvedPattern !== undefined ? { pattern: resolvedPattern } : {}),
Expand Down Expand Up @@ -272,6 +274,9 @@ export const partToInstrumentDescriptor = (
...(part._stepProb !== undefined ? { stepProb: part._stepProb } : {}),
...(part._every !== undefined ? { every: { n: part._every.n, transform: part._every.fn } } : {}),
...(part._stretch !== undefined ? { stretch: part._stretch } : {}),
...(part._stutter !== undefined ? { stutter: part._stutter } : {}),
// Map _tone chain field → props.tone (Snare, Snare909 bandpass frequency)
...(part._tone !== undefined ? { tone: part._tone } : {}),
...part.props,
},
}
Expand Down Expand Up @@ -312,6 +317,7 @@ const seqPatternExtras = (props: CommonProps) => ({
...(props['stepProb'] !== undefined ? { stepProb: props['stepProb'] as ReadonlyArray<number> } : {}),
...(props['every'] !== undefined ? { every: props['every'] as EveryTransform } : {}),
...(props['stretch'] !== undefined ? { stretch: props['stretch'] as number } : {}),
...(props['stutter'] !== undefined ? { stutter: props['stutter'] as number } : {}),
})

// ── computeNoteDur — note duration for Model B' melodic voice instruments ──────
Expand Down Expand Up @@ -401,22 +407,47 @@ export const triggerSnare = (ctx: Context, time: number, props: SnareProps, dest
}
}

// Bandpass bands matching createGenericHihat in @score/instruments.
// Three resonant peaks + ±2% per-hit detune for machine-gun prevention.
const HIHAT_BANDS = [
{ frequency: 3500, Q: 1.5 },
{ frequency: 6000, Q: 2.0 },
{ frequency: 10000, Q: 1.5 },
] as const
const HIHAT_HPF = 4000
const HIHAT_DETUNE = 0.02

export const triggerHiHat = (ctx: Context, time: number, props: HiHatProps, dest: GainNode): void => {
const gain = props.volume ?? 0.25
const dur = props.open ? 0.3 : 0.06
const noise = ctx.createNoise({ type: 'white' })
const filter = ctx.createFilter({ type: 'highpass', frequency: 8000 })
const vol = ctx.createGain({ gain: 0 })
noise.connect(filter)
filter.connect(vol)
const dur = props.open ? 0.3 : 0.06

const noise = ctx.createNoise({ type: 'white' })
const sumGain = ctx.createGain({ gain: 1 / HIHAT_BANDS.length })

const bands = HIHAT_BANDS.map(({ frequency, Q }) => {
const detunedFreq = frequency * (1 + (Math.random() * HIHAT_DETUNE * 2 - HIHAT_DETUNE))
const bp = ctx.createFilter({ type: 'bandpass', frequency: detunedFreq, Q })
noise.connect(bp)
bp.connect(sumGain)
return bp
})

const hpf = ctx.createFilter({ type: 'highpass', frequency: HIHAT_HPF })
const vol = ctx.createGain({ gain: 0 })

sumGain.connect(hpf)
hpf.connect(vol)
vol.connect(dest)

vol.scheduleEnvelope({ peak: gain, attack: 0.001, decay: dur - 0.001, sustain: 0, release: 0, startTime: time, duration: dur })
noise.start(time)
noise.stop(time + dur)
noise.onended = () => {
try { noise.disconnect() } catch { /* ok */ }
try { filter.disconnect() } catch { /* ok */ }
try { vol.disconnect() } catch { /* ok */ }
try { noise.disconnect() } catch { /* ok */ }
for (const bp of bands) { try { bp.disconnect() } catch { /* ok */ } }
try { sumGain.disconnect() } catch { /* ok */ }
try { hpf.disconnect() } catch { /* ok */ }
try { vol.disconnect() } catch { /* ok */ }
}
}

Expand Down Expand Up @@ -515,6 +546,7 @@ export type PatchProps = {
readonly index: number
readonly volume?: number
readonly mute?: boolean
readonly solo?: boolean
}>
}

Expand Down Expand Up @@ -738,6 +770,11 @@ const dispatchArp = (
const mode = props.mode ?? 'up'
const rate = props.rate ?? 1

if (notes.length === 0) {
console.error('[score-engine] arp: notes array is empty — track skipped')
return
}

// HARDWARE BOUNDARY: mutable step counter — sequential arpeggio state across callbacks
const arpState = { noteIndex: 0, pingDir: 1 }

Expand Down Expand Up @@ -826,6 +863,7 @@ export const createScoreEngine = async (song: SongDefinition): Promise<ScoreEngi
...(channelPan !== undefined ? { pan: channelPan } : {}),
})
if (comp._mute) channel.setMute(true)
if (comp._solo) channel.setSolo(true)
return channel.input as unknown as GainNode
})

Expand Down Expand Up @@ -1023,6 +1061,7 @@ export const createScoreEngine = async (song: SongDefinition): Promise<ScoreEngi
if (!channel) continue
if (t.volume !== undefined) channel.setVolume(t.volume)
if (t.mute !== undefined) channel.setMute(t.mute)
if (t.solo !== undefined) channel.setSolo(t.solo)
}
}
},
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/tests/engine-pop-prevention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,21 @@ describe('trigger functions — zero-gain onset invariant', () => {
})
})

it('triggerHiHat — internal GainNode initialises at gain 0 (regression: was full amplitude)', () => {
it('triggerHiHat — VCA initialises at gain 0, sumGain is a routing node (regression: was full amplitude)', () => {
withGainSpy((ctx, dest, gains) => {
triggerHiHat(ctx, 0, { volume: 0.4 } satisfies HiHatProps, dest)
expect(gains().every(g => g === 0)).toBe(true)
expect(gains().length).toBe(1)
const all = gains()
// Signal chain: sumGain (routing, non-zero) + vol (VCA, must be 0)
expect(all.length).toBe(2)
expect(all[all.length - 1]).toBe(0)
})
})

it('triggerHiHat open — open hi-hat also initialises at gain 0', () => {
it('triggerHiHat open — VCA also initialises at gain 0', () => {
withGainSpy((ctx, dest, gains) => {
triggerHiHat(ctx, 0, { volume: 0.4, open: true } satisfies HiHatProps, dest)
expect(gains().every(g => g === 0)).toBe(true)
const all = gains()
expect(all[all.length - 1]).toBe(0)
})
})

Expand Down
Loading
Loading