From 370305d12f03ca198d6215c470ae38d0521ae4ae Mon Sep 17 00:00:00 2001 From: bwyard Date: Sat, 21 Mar 2026 16:39:20 -0500 Subject: [PATCH] =?UTF-8?q?feat(dsl,pattern,cli):=20Phase=209=20=E2=80=94?= =?UTF-8?q?=20Arp,=20humanize,=20drift,=20keepFor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pattern: humanize(amount, pattern) — deterministic velocity variation - dsl: Arp() instrument — arpeggiator factory with up/down/pingpong/random modes - dsl: ArpDSLProps type — notes, mode, rate, wave, envelope, gain, effects - dsl: drift(center, sigma, theta) — OU-process note pitch wandering - dsl: keepFor(bars, pattern) — freeze pattern evaluation for N bars - cli/engine: wire 'arp' instrumentType into createScoreEngine step sequencer - tests: humanize (6), Arp (7), drift (6), keepFor (5) — 24 new tests, 1133 total Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/engine.ts | 41 +++++++- packages/dsl/package.json | 4 +- packages/dsl/src/index.ts | 4 +- packages/dsl/src/instruments.ts | 25 ++++- packages/dsl/src/modifiers.ts | 119 ++++++++++++++++++++++ packages/dsl/src/types.ts | 28 ++++- packages/dsl/tests/instruments.test.ts | 74 ++++++++++++++ packages/dsl/tests/modifiers.test.ts | 100 ++++++++++++++++++ packages/pattern/src/index.ts | 2 +- packages/pattern/src/transforms.ts | 39 ++++++- packages/pattern/tests/transforms.test.ts | 42 +++++++- pnpm-lock.yaml | 6 ++ 12 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 packages/dsl/src/modifiers.ts create mode 100644 packages/dsl/tests/instruments.test.ts create mode 100644 packages/dsl/tests/modifiers.test.ts diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index 7127da7..f95db1f 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -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 @@ -293,6 +293,45 @@ export const createScoreEngine = async (song: SongDefinition): Promise 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 + } } }) diff --git a/packages/dsl/package.json b/packages/dsl/package.json index fd369a5..aff8d56 100644 --- a/packages/dsl/package.json +++ b/packages/dsl/package.json @@ -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", diff --git a/packages/dsl/src/index.ts b/packages/dsl/src/index.ts index 749a876..159900f 100644 --- a/packages/dsl/src/index.ts +++ b/packages/dsl/src/index.ts @@ -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, @@ -19,4 +20,5 @@ export type { SampleProps, ThereminDSLProps, SaxDSLProps, + ArpDSLProps, } from './types.js' diff --git a/packages/dsl/src/instruments.ts b/packages/dsl/src/instruments.ts index fc176e5..a913936 100644 --- a/packages/dsl/src/instruments.ts +++ b/packages/dsl/src/instruments.ts @@ -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'], @@ -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) diff --git a/packages/dsl/src/modifiers.ts b/packages/dsl/src/modifiers.ts new file mode 100644 index 0000000..619f018 --- /dev/null +++ b/packages/dsl/src/modifiers.ts @@ -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 = { 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` 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 => { + 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 = (bars: number, pattern: PatternInput): PatternFn => + (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 + } diff --git a/packages/dsl/src/types.ts b/packages/dsl/src/types.ts index f518b75..88e687b 100644 --- a/packages/dsl/src/types.ts +++ b/packages/dsl/src/types.ts @@ -88,10 +88,34 @@ export type SaxDSLProps = { readonly effects?: ReadonlyArray } +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 +} + 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 diff --git a/packages/dsl/tests/instruments.test.ts b/packages/dsl/tests/instruments.test.ts new file mode 100644 index 0000000..aada1f0 --- /dev/null +++ b/packages/dsl/tests/instruments.test.ts @@ -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') + }) +}) diff --git a/packages/dsl/tests/modifiers.test.ts b/packages/dsl/tests/modifiers.test.ts new file mode 100644 index 0000000..0d847e3 --- /dev/null +++ b/packages/dsl/tests/modifiers.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { drift, keepFor } from '../src/modifiers.js' + +describe('keepFor', () => { + it('returns the same value for all bars within a block', () => { + // degrade-like fn that varies by bar — keepFor(4) should freeze it + const varying = (step: number, bar: number): number => bar % 2 === 0 ? 1 : 0 + const frozen = keepFor(4, varying) + // bars 0-3 all map to frozenBar=0 → varying(0, 0) = 1 + expect(frozen(0, 0)).toBe(1) + expect(frozen(0, 1)).toBe(1) + expect(frozen(0, 2)).toBe(1) + expect(frozen(0, 3)).toBe(1) + // bars 4-7 map to frozenBar=4 → varying(0, 4) = 1 + expect(frozen(0, 4)).toBe(1) + // bars 4-7 with frozenBar=4 → varying(0, 4) = 1 (4 % 2 === 0) + expect(frozen(0, 6)).toBe(1) + }) + + it('advances to next frozen block after N bars', () => { + const varying = (step: number, bar: number): number => bar % 4 === 0 ? 1 : 2 + const frozen = keepFor(2, varying) + // frozenBar for bar=0 is 0 → varying(0,0)=1 + expect(frozen(0, 0)).toBe(1) + // frozenBar for bar=1 is 0 → varying(0,0)=1 + expect(frozen(0, 1)).toBe(1) + // frozenBar for bar=2 is 2 → varying(0,2)=2 + expect(frozen(0, 2)).toBe(2) + expect(frozen(0, 3)).toBe(2) + }) + + it('works with array patterns — same array value for all bars in block', () => { + const fn = keepFor(4, [1, 0, 1, 0]) + expect(fn(0, 0)).toBe(1) + expect(fn(0, 2)).toBe(1) // still bar 0 block + expect(fn(1, 0)).toBe(0) + expect(fn(1, 3)).toBe(0) // still bar 0 block + }) + + it('wraps step index correctly', () => { + const fn = keepFor(4, [1, 2, 3, 4]) + expect(fn(4, 0)).toBe(1) // step 4 → index 0 + expect(fn(5, 0)).toBe(2) // step 5 → index 1 + }) + + it('blocks of 1 bar behave like no freeze', () => { + const varying = (_step: number, bar: number): number => bar + const fn = keepFor(1, varying) + expect(fn(0, 0)).toBe(0) + expect(fn(0, 1)).toBe(1) + expect(fn(0, 2)).toBe(2) + }) +}) + +describe('drift', () => { + it('returns a function', () => { + const fn = drift('A4') + expect(typeof fn).toBe('function') + }) + + it('returns a string note on each call', () => { + const fn = drift('A4') + const val = fn(0, 0) + expect(typeof val).toBe('string') + expect(val).toMatch(/^[A-G]#?-?\d+$/) + }) + + it('sigma=0 always returns center note (no drift)', () => { + const fn = drift('A4', 0, 0.3) + // With sigma=0 the OU process never moves from 0, so offset rounds to 0 + expect(fn(0, 0)).toBe('A4') + expect(fn(0, 1)).toBe('A4') + expect(fn(0, 5)).toBe('A4') + }) + + it('returns the same value for all steps in the same bar', () => { + const fn = drift('C4', 3, 0.5) + const bar0val = fn(0, 0) + expect(fn(1, 0)).toBe(bar0val) + expect(fn(7, 0)).toBe(bar0val) + expect(fn(15, 0)).toBe(bar0val) + }) + + it('can produce different values across bars with non-zero sigma', () => { + const fn = drift('A4', 5, 0.8) + const vals = Array.from({ length: 20 }, (_, b) => fn(0, b)) + const unique = new Set(vals) + // With sigma=5 over 20 bars, very likely to drift at least once + expect(unique.size).toBeGreaterThan(1) + }) + + it('different instances are independent', () => { + const a = drift('C4', 2, 0.5) + const b = drift('C4', 2, 0.5) + // Both start from same center but are separate OU processes + // (they may produce same or different values — just check they run) + expect(typeof a(0, 0)).toBe('string') + expect(typeof b(0, 0)).toBe('string') + }) +}) diff --git a/packages/pattern/src/index.ts b/packages/pattern/src/index.ts index db8132b..223ee59 100644 --- a/packages/pattern/src/index.ts +++ b/packages/pattern/src/index.ts @@ -1,5 +1,5 @@ export { euclidean } from './euclidean.js' -export { fast, slow, rev, every, degrade, shift, stack, beat } from './transforms.js' +export { fast, slow, rev, every, degrade, shift, stack, beat, humanize } from './transforms.js' export { scaleNotes, chordNotes } from './scales.js' export { resolvePattern } from './types.js' export type { PatternInput, PatternArray, PatternFn } from './types.js' diff --git a/packages/pattern/src/transforms.ts b/packages/pattern/src/transforms.ts index 3699165..9f4601b 100644 --- a/packages/pattern/src/transforms.ts +++ b/packages/pattern/src/transforms.ts @@ -5,7 +5,7 @@ import { resolvePattern } from './types.js' * Double (or multiply) the playback speed of a pattern. * Each step plays `n` times faster — a 4/4 kick becomes a rapid 16th-note fill. * - * @param n - Speed multiplier. `2` = double time, `4` = quadruple time. Must be > 0. + * @param n - Speed multiplier. `2` = double time, `4` = quadruple time. Must be \> 0. * @param pattern - Source pattern as an array or step function. * @returns A step function playing the pattern at `n×` speed. * @@ -30,7 +30,7 @@ export const fast = (n: number, pattern: PatternInput): PatternFn => * Halve (or divide) the playback speed of a pattern. * Each step lasts `n` times longer — a driving 8th-note bass becomes a slow half-time groove. * - * @param n - Speed divisor. `2` = half time (each step doubled), `4` = quarter time. Must be > 0. + * @param n - Speed divisor. `2` = half time (each step doubled), `4` = quarter time. Must be \> 0. * @param pattern - Source pattern as an array or step function. * @returns A step function playing the pattern at `1/n` speed. * @@ -232,3 +232,38 @@ export const stack = ( * @see {@link stack} — for layering multiple beat patterns */ export const beat = (...steps: T[]): T[] => [...steps] + +/** + * Add subtle velocity variation to a pattern — makes mechanical sequences feel human. + * + * Each active hit is multiplied by `(1 + amount × jitter)` where jitter is a + * deterministic pseudo-random value in `[−1, 1]` derived from the step and bar number. + * Zero steps are always preserved. The variation is reproducible — the same step+bar + * always produces the same jitter, so patterns stay consistent within a bar. + * + * @param amount - Variation intensity, `0`–`1`. `0.05` = barely noticeable, `0.2` = loose live feel. + * @param pattern - Source pattern. Non-zero values receive velocity variation. + * @returns A step function applying velocity humanization. + * + * @example + * ```ts + * // Slightly humanized hi-hat — each hit varies ±15% in velocity + * const hat = HiHat({ pattern: humanize(0.15, [1, 1, 1, 1]) }) + * + * // Combine with degrade for a very loose feel + * const loose = HiHat({ pattern: humanize(0.2, degrade(0.1, [1, 1, 1, 1])) }) + * ``` + * + * @see {@link degrade} — randomly drop hits entirely + * @see {@link every} — apply transforms conditionally per bar + */ +export const humanize = (amount: number, pattern: PatternInput): PatternFn => + (step: number, bar: number): number => { + const arr = resolvePattern(pattern, Array.isArray(pattern) ? pattern.length : 16, bar) + const val = arr[step % arr.length] ?? 0 + if (val === 0) return 0 + // Deterministic hash jitter in [-1, 1] — reproducible per step+bar + const seed = ((step * 7919 + bar * 3571) ^ (step << 4)) >>> 0 + const jitter = (seed % 65536) / 32768 - 1 + return (val) * (1 + amount * jitter) + } diff --git a/packages/pattern/tests/transforms.test.ts b/packages/pattern/tests/transforms.test.ts index b549511..59ab6c9 100644 --- a/packages/pattern/tests/transforms.test.ts +++ b/packages/pattern/tests/transforms.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { slow, rev, every, degrade, shift } from '../src/transforms.js' +import { slow, rev, every, degrade, shift, humanize } from '../src/transforms.js' import { resolvePattern } from '../src/types.js' describe('resolvePattern', () => { @@ -98,3 +98,43 @@ describe('every', () => { expect([0, 1, 2, 3].map(s => everyFn(s, 1))).toEqual([1, 0, 0, 0]) }) }) + +describe('humanize', () => { + it('preserves zero steps', () => { + const fn = humanize(1.0, [0, 1, 0, 1]) + expect(fn(0, 0)).toBe(0) + expect(fn(2, 0)).toBe(0) + }) + + it('non-zero steps are non-zero after humanization', () => { + const fn = humanize(0.1, [1, 0, 1, 0]) + expect(fn(1, 0)).toBe(0) // step 1 → 0 in pattern, stays 0 + expect(fn(0, 0)).not.toBe(0) + expect(fn(2, 0)).not.toBe(0) + }) + + it('amount=0 leaves all values unchanged', () => { + const fn = humanize(0, [1, 0, 1, 0]) + expect(fn(0, 0)).toBe(1) + expect(fn(2, 0)).toBe(1) + }) + + it('is deterministic — same step+bar always produces same value', () => { + const fn = humanize(0.2, [1, 1, 1, 1]) + expect(fn(3, 5)).toBe(fn(3, 5)) + expect(fn(0, 0)).toBe(fn(0, 0)) + }) + + it('varies across steps for non-zero amount', () => { + const fn = humanize(0.3, [1, 1, 1, 1, 1, 1, 1, 1]) + const vals = [0, 1, 2, 3, 4, 5, 6, 7].map(s => fn(s, 0)) + const unique = new Set(vals) + expect(unique.size).toBeGreaterThan(1) + }) + + it('wraps pattern correctly', () => { + const fn = humanize(0, [1, 0]) + expect(fn(2, 0)).toBe(1) // step 2 → index 0 → 1 + expect(fn(3, 0)).toBe(0) // step 3 → index 1 → 0 + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc03c78..edddfa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: '@score/core': specifier: workspace:* version: link:../core + '@score/math': + specifier: workspace:* + version: link:../math + '@score/pattern': + specifier: workspace:* + version: link:../pattern devDependencies: '@types/node': specifier: ^25.5.0