Skip to content
Merged
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
41 changes: 40 additions & 1 deletion packages/cli/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { createTransport, createStepSequencer } from '@score/sequencer'
import { resolveFreq } from '@score/dsl'
import type {
SongDefinition, InstrumentDescriptor,
KickProps, SnareProps, HiHatProps, SynthDSLProps, SampleProps, ThereminDSLProps, SaxDSLProps,
KickProps, SnareProps, HiHatProps, SynthDSLProps, SampleProps, ThereminDSLProps, SaxDSLProps, ArpDSLProps,
} from '@score/dsl'

type Context = ReturnType<typeof webAudioBackend.createContext>
Expand Down Expand Up @@ -293,6 +293,45 @@ export const createScoreEngine = async (song: SongDefinition): Promise<ScoreEngi
})
break
}
case 'arp': {
const props = comp.props as ArpDSLProps
const notes = props.notes
const mode = props.mode ?? 'up'
const rate = props.rate ?? 1
// Hardware-boundary exception: arp step counter — sequential, const-bound state
const arpState = { noteIndex: 0, pingDir: 1 }
const defaultPattern = Array.from({ length: 16 }, () => 1)
const rawPattern = props.pattern ?? defaultPattern
createStepSequencer(transport, { pattern: rawPattern }, (val: number | string, _step, pos) => {
const active = typeof val === 'number' ? val : resolveFreq(val)
if (active <= 0) return
const idx = Math.floor(arpState.noteIndex / rate) % notes.length
const note = notes[idx] ?? notes[0] ?? 'C4'
const freq = resolveFreq(note)
if (freq > 0) triggerSynth(ctx, pos.time, {
wave: props.wave ?? 'triangle',
gain: props.gain ?? 0.3,
envelope: props.envelope,
} as SynthDSLProps, freq, dest)
// Advance note index based on mode
if (mode === 'up') {
arpState.noteIndex += 1
} else if (mode === 'down') {
arpState.noteIndex -= 1
} else if (mode === 'pingpong') {
arpState.noteIndex += arpState.pingDir
const realIdx = Math.floor(arpState.noteIndex / rate) % notes.length
if (realIdx >= notes.length - 1 || realIdx <= 0) {
arpState.pingDir *= -1
}
} else {
// random — deterministic based on noteIndex+time
const seed = (arpState.noteIndex * 7919) >>> 0
arpState.noteIndex = seed % notes.length
}
})
break
}
}
})

Expand Down
4 changes: 3 additions & 1 deletion packages/dsl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@score/core": "workspace:*"
"@score/core": "workspace:*",
"@score/math": "workspace:*",
"@score/pattern": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.5.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/dsl/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ export { Song } from './song.js'
export { Intro, Buildup, Drop, Breakdown, Outro } from './sections.js'
export { Track } from './track.js'
export { Sequence } from './sequence.js'
export { Kick, Snare, HiHat, Synth, Sample, Theremin, Sax } from './instruments.js'
export { Kick, Snare, HiHat, Synth, Sample, Theremin, Sax, Arp } from './instruments.js'
export { noteHz, resolveFreq } from './notes.js'
export { drift, keepFor } from './modifiers.js'
export type {
SongDefinition,
SongProps,
Expand All @@ -19,4 +20,5 @@ export type {
SampleProps,
ThereminDSLProps,
SaxDSLProps,
ArpDSLProps,
} from './types.js'
25 changes: 24 additions & 1 deletion packages/dsl/src/instruments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type { BackendNode } from '@score/core'
import { uid } from '@score/core'
import type { InstrumentDescriptor, KickProps, SnareProps, HiHatProps, SynthDSLProps, SampleProps, ThereminDSLProps, SaxDSLProps } from './types.js'
import type { InstrumentDescriptor, KickProps, SnareProps, HiHatProps, SynthDSLProps, SampleProps, ThereminDSLProps, SaxDSLProps, ArpDSLProps } from './types.js'

const makeDescriptor = (
instrumentType: InstrumentDescriptor['instrumentType'],
Expand Down Expand Up @@ -48,3 +48,26 @@ export const Synth = (props?: SynthDSLProps): InstrumentDescriptor => makeDescr
export const Sample = (props: SampleProps): InstrumentDescriptor => makeDescriptor('sample', props)
export const Theremin = (props?: ThereminDSLProps): InstrumentDescriptor => makeDescriptor('theremin', props ?? {})
export const Sax = (props?: SaxDSLProps): InstrumentDescriptor => makeDescriptor('sax', props ?? {})
/**
* Arpeggiator instrument — cycles through a chord's notes in sequence on each trigger step.
*
* The engine advances through `notes` each time a step is active, cycling based on `mode`.
* Each note is synthesised using an oscillator+ADSR, exactly like `Synth`.
*
* @param props - `notes` is required. All other props are optional.
* @returns An `InstrumentDescriptor` of type `'arp'`.
*
* @example
* ```js
* import { Song, Arp } from '@score/dsl'
* const arp = Arp({
* notes: ['C4', 'E4', 'G4', 'B4'],
* mode: 'up',
* wave: 'triangle',
* gain: 0.3,
* envelope: { attack: 0.01, decay: 0.1, sustain: 0.6, release: 0.05 },
* })
* export default Song({ bpm: 128, tracks: [arp] })
* ```
*/
export const Arp = (props: ArpDSLProps): InstrumentDescriptor => makeDescriptor('arp', props)
119 changes: 119 additions & 0 deletions packages/dsl/src/modifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// DSL pattern modifiers — drift and keepFor
// These operate on note-name patterns and sit at the DSL level (not engine layer).
//
// drift() — slowly wanders note pitch around a center using an OU process
// keepFor() — locks a pattern to the same bar evaluation for N bars

import { createOUProcess } from '@score/math'
import type { PatternFn, PatternInput } from '@score/pattern'
import { resolvePattern } from '@score/pattern'

// ── Note transpose helper ─────────────────────────────────────────────────────

/** Note letters in semitone order, sharps preferred. */
const CHROMATIC = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const

/**
* Convert a note name to MIDI number (C4 = 60).
* Returns -1 on invalid input.
*/
const noteToMidi = (note: string): number => {
const m = /^([A-G])(#|b)?(-?\d+)$/.exec(note)
if (!m) return -1
const letter = m[1] as string
const acc = m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0
const octave = parseInt(m[3] as string, 10)
const semitones: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }
return (octave + 1) * 12 + (semitones[letter] ?? 0) + acc
}

/**
* Convert a MIDI number to a note name (sharps preferred, C4 = 60).
*/
const midiToNote = (midi: number): string => {
const octave = Math.floor(midi / 12) - 1
const semitone = ((midi % 12) + 12) % 12
return `${CHROMATIC[semitone] ?? 'C'}${String(octave)}`
}

/**
* Transpose a note name by `semitones` steps.
* If the note is invalid (e.g. `0` coerced to string), returns the input unchanged.
*/
const transpose = (note: string, semitones: number): string => {
const midi = noteToMidi(note)
if (midi < 0) return note
return midiToNote(midi + semitones)
}

// ── drift ─────────────────────────────────────────────────────────────────────

/**
* Slowly drift note pitches around a center note using an Ornstein-Uhlenbeck process.
*
* Each bar, the OU process advances one step and returns a semitone offset.
* All steps within the same bar share the same offset, keeping the pitch coherent.
* The process is mean-reverting — it always gravitates back toward `center`.
*
* This is a stateful generator (hardware-boundary exception), not a pure function.
* The same drift instance should be shared across a song, not recreated per bar.
*
* @param center - The home note (e.g. `'A4'`). The process gravitates toward this pitch.
* @param sigma - Volatility in semitones. `1` = subtle, `3` = wide drift. Default: `2`.
* @param theta - Mean reversion speed. `0.3` = slow wander, `1.0` = snaps back fast. Default: `0.3`.
* @returns A `PatternFn<string>` that emits transposed note names.
*
* @example
* ```ts
* // Bass line that wanders ±2 semitones around A2
* const bassPattern = drift('A2', 2, 0.3)
* const bass = Synth({ wave: 'sawtooth', pattern: bassPattern })
* ```
*
* @see {@link keepFor} — lock a pattern for N bars
*/
export const drift = (center: string, sigma = 2, theta = 0.3): PatternFn<string> => {
const ou = createOUProcess(theta, 0, sigma)
// Hardware-boundary exception: sequential OU generator carries step state
const state = { lastBar: -1, offset: 0 }

return (_step: number, bar: number): string => {
if (bar !== state.lastBar) {
state.offset = Math.round(ou.next(0.1))
state.lastBar = bar
}
return transpose(center, state.offset)
}
}

// ── keepFor ───────────────────────────────────────────────────────────────────

/**
* Lock a pattern to the same bar evaluation for `bars` consecutive bars.
*
* Useful in live coding to "freeze" a pattern that uses bar-varying functions
* (`every`, `degrade`, `drift`) so it holds steady before the next variation.
* Every `bars`-bar block evaluates the inner pattern at the same fixed bar anchor.
*
* @param bars - Number of bars to hold the same pattern evaluation. Must be ≥ 1.
* @param pattern - Source pattern (array or step function).
* @returns A step function that repeats the same bar-evaluation for `bars` bars.
*
* @example
* ```ts
* // Hold the same random degraded pattern for 4 bars before it rerolls
* const hat = HiHat({ pattern: keepFor(4, degrade(0.3, [1, 1, 1, 1])) })
*
* // Lock a drift pattern in place for 8 bars
* const bass = Synth({ pattern: keepFor(8, drift('A2', 2)) })
* ```
*
* @see {@link drift} — slowly evolving pitch patterns
*/
export const keepFor = <T>(bars: number, pattern: PatternInput<T>): PatternFn<T> =>
(step: number, bar: number): T => {
const frozenBar = Math.floor(bar / bars) * bars
const len = Array.isArray(pattern) ? pattern.length : 16
const arr = resolvePattern(pattern, len, frozenBar)
return arr[step % arr.length] as T
}
28 changes: 26 additions & 2 deletions packages/dsl/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,34 @@ export type SaxDSLProps = {
readonly effects?: ReadonlyArray<EffectDescriptor>
}

export type ArpDSLProps = {
/** Note names to arpeggiate in order, e.g. `['C4', 'E4', 'G4', 'B4']`. Required. */
readonly notes: string[]
/** Arpeggio traversal mode. Default: `'up'`. */
readonly mode?: 'up' | 'down' | 'pingpong' | 'random'
/** Steps per note advance — `1` = change note every step, `2` = every other step. Default: `1`. */
readonly rate?: number
/** Oscillator wave type. Default: `'triangle'`. */
readonly wave?: 'sine' | 'square' | 'sawtooth' | 'triangle'
/** Peak output gain 0–1. Default: `0.3`. */
readonly gain?: number
/** ADSR envelope. */
readonly envelope?: {
readonly attack?: number
readonly decay?: number
readonly sustain?: number
readonly release?: number
}
/** Trigger pattern — non-zero = play, 0 = rest. Default: all steps active. */
readonly pattern?: (number | string)[]
/** Effects chain. */
readonly effects?: ReadonlyArray<EffectDescriptor>
}

export type InstrumentDescriptor = {
readonly _type: 'InstrumentDescriptor'
readonly instrumentType: 'kick' | 'snare' | 'hihat' | 'synth' | 'sample' | 'theremin' | 'sax'
readonly props: KickProps | SnareProps | HiHatProps | SynthDSLProps | SampleProps | ThereminDSLProps | SaxDSLProps
readonly instrumentType: 'kick' | 'snare' | 'hihat' | 'synth' | 'sample' | 'theremin' | 'sax' | 'arp'
readonly props: KickProps | SnareProps | HiHatProps | SynthDSLProps | SampleProps | ThereminDSLProps | SaxDSLProps | ArpDSLProps
// Minimal AudioComponent shape so Track() accepts it
readonly id: string
readonly type: string
Expand Down
74 changes: 74 additions & 0 deletions packages/dsl/tests/instruments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { Kick, Snare, HiHat, Synth, Sample, Theremin, Sax, Arp } from '../src/instruments.js'

describe('Arp', () => {
it('returns an InstrumentDescriptor with instrumentType arp', () => {
const arp = Arp({ notes: ['C4', 'E4', 'G4'] })
expect(arp._type).toBe('InstrumentDescriptor')
expect(arp.instrumentType).toBe('arp')
})

it('stores notes in props', () => {
const arp = Arp({ notes: ['A3', 'C4', 'E4'] })
const props = arp.props as { notes: string[] }
expect(props.notes).toEqual(['A3', 'C4', 'E4'])
})

it('stores optional mode, wave, gain in props', () => {
const arp = Arp({ notes: ['C4'], mode: 'down', wave: 'sawtooth', gain: 0.5 })
const props = arp.props as { mode: string; wave: string; gain: number }
expect(props.mode).toBe('down')
expect(props.wave).toBe('sawtooth')
expect(props.gain).toBe(0.5)
})

it('has a unique id per instance', () => {
const a = Arp({ notes: ['C4'] })
const b = Arp({ notes: ['C4'] })
expect(a.id).not.toBe(b.id)
})

it('has no-op connect/disconnect/dispose', () => {
const arp = Arp({ notes: ['C4'] })
expect(() => { arp.connect({} as never) }).not.toThrow()
expect(() => { arp.disconnect() }).not.toThrow()
expect(() => { arp.dispose() }).not.toThrow()
})

it('stores envelope in props', () => {
const env = { attack: 0.01, decay: 0.1, sustain: 0.6, release: 0.05 }
const arp = Arp({ notes: ['C4'], envelope: env })
const props = arp.props as { envelope: typeof env }
expect(props.envelope).toEqual(env)
})
})

describe('existing instrument factories still work after type extension', () => {
it('Kick returns kick descriptor', () => {
expect(Kick().instrumentType).toBe('kick')
})

it('Snare returns snare descriptor', () => {
expect(Snare().instrumentType).toBe('snare')
})

it('HiHat returns hihat descriptor', () => {
expect(HiHat().instrumentType).toBe('hihat')
})

it('Synth returns synth descriptor', () => {
expect(Synth().instrumentType).toBe('synth')
})

it('Sample returns sample descriptor', () => {
expect(Sample({ path: './kick.wav' }).instrumentType).toBe('sample')
})

it('Theremin returns theremin descriptor', () => {
expect(Theremin().instrumentType).toBe('theremin')
})

it('Sax returns sax descriptor', () => {
expect(Sax().instrumentType).toBe('sax')
})
})
Loading
Loading