diff --git a/DESIGN.md b/DESIGN.md index 065515d7..7b8cad12 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -26,7 +26,9 @@ of data is one part: servers are the bass, people are a clipped voice, the lab is a soft chord, and the network is a hat. A part plays only while its data arrives, on its own few steps of the bar, so more data makes the groove fuller, not busier. A chord sits behind the parts and opens up as more data flows, and -a soft kick joins once the base is busy. Under it runs a low hum. Suspicion +a soft kick joins once the base is busy. The groove keeps changing: every +eight bars two of its parts change (a rhythm, the chords, how the voice or the +kick sounds) and the rest carry on. Under it runs a low hum. Suspicion adds to it: a tick when IT is curious, a pulse on alert, drums when hunting. Paused, or with the tree open, only the dull hum is left. News has its own short cues. The note button in the bottom row turns all sound off. diff --git a/src/music.ts b/src/music.ts index 841b7616..1473be37 100644 --- a/src/music.ts +++ b/src/music.ts @@ -4,30 +4,54 @@ // data makes the groove fuller, not busier. A chord sits behind the parts and opens up as more // data flows. Under it all runs a low hum, and suspicion adds to that: a tick when IT is curious, // a pulse when it is on alert, drums when it is hunting. +// The groove does not loop for ever: every eight bars two of its parts change (see Scene). import type { DataType } from './world'; import { stageOf, type Packet, type State } from './sim.ts'; import { audio, soundOn } from './sound.ts'; const BPM = 104, STEP = 60 / BPM / 4; // the clock ticks in sixteenth notes, whatever the game speed const SCALE = [0, 3, 5, 7, 10]; // five notes that sit well in any order -const ROOTS = [38, 34, 41, 36]; // the hum moves under the notes every two bars: D, B flat, F, C -const KEYNOTE = 38; -const CHORD = [0, 7, 14]; // the chord on each root: open, neither happy nor sad -/** The steps of a bar each kind may play on. Data that arrives between them waits for the next one. */ -export const WHEN: Record = { - servers: 'x.....x...x.....', - people: '...x......x..x..', - lab: '..x.......x...x.', - network: '..x.x.x...x.x.x.', -}; -const KINDS = Object.keys(WHEN) as DataType[]; +const KEYNOTE = 38, KEY = [0, 2, 3, 5, 7, 8, 10]; // D minor: every root and chord below stays in it const hz = (note: number) => 440 * 2 ** ((note - 69) / 12); const hash = (s: string) => { let h = 7; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }; -/** The note a tap's data plays. The bass holds the chord's root or its fifth, the lab plays the chord, - * and each tap of people data has its own note in the scale. The network's hat has no note. */ -export function pitch(type: DataType, tap: string, turn: number): number { - const h = hash(tap), root = ROOTS[turn % ROOTS.length]; +// The ways each part can go. A scene picks one of each; the first of each is where a session starts. +/** Four roots, two bars each: the hum and the chord move through them. */ +const ROOTS = [[38, 34, 41, 36], [38, 36, 34, 36], [38, 41, 36, 43], [34, 36, 38, 38]]; +/** The chord on a root, as steps up the key: open, wide, or with a seventh. */ +const SHAPES = [[0, 4, 8], [0, 4, 9], [0, 4, 6]]; +/** The steps of a bar each kind may play on. Data that arrives between them waits for the next one. */ +export const BARS: Record = { + servers: ['x.....x...x.....', 'x.......x.......', 'x..x......x..x..'], + people: ['...x......x..x..', '......x.......x.', '..x..x.....x....'], + lab: ['..x.......x...x.', '....x.......x...', '......x.......x.'], + network: ['..x.x.x...x.x.x.', '..x...x...x...x.', 'x.x.x.x.x.x.x.x.'], +}; +const KICKS = ['x.......x.......', 'x.........x.....', '................']; // slow, broken, or none +const CHOPS = [2, 3, 0]; // steps between cuts in the chord behind the parts; 0 holds it still +const KINDS = Object.keys(BARS) as DataType[]; + +/** One mix of the parts. `voice` is how people data sounds, `stab` how the lab's chord does. */ +export interface Scene { roots: number; shape: number; servers: number; people: number; lab: number; network: number; kick: number; chop: number; voice: number; stab: number } +export const WAYS: Scene = { roots: ROOTS.length, shape: SHAPES.length, servers: 3, people: 3, lab: 3, network: 3, kick: KICKS.length, chop: CHOPS.length, voice: 3, stab: 2 }; +export const FIRST: Scene = { roots: 0, shape: 0, servers: 0, people: 0, lab: 0, network: 0, kick: 0, chop: 0, voice: 0, stab: 0 }; +/** The next mix: two parts change and the rest carry on, so the track moves without starting over. */ +export function nextScene(s: Scene, rnd = Math.random): Scene { + const parts = Object.keys(WAYS) as (keyof Scene)[], out = { ...s }; + const a = parts[Math.floor(rnd() * parts.length)], rest = parts.filter(p => p !== a), b = rest[Math.floor(rnd() * rest.length)]; + for (const p of [a, b]) out[p] = (s[p] + 1 + Math.floor(rnd() * (WAYS[p] - 1))) % WAYS[p]; + return out; +} + +/** The chord on a root: each note is a number of steps up the key, so it never leaves it. */ +export function chord(root: number, shape: number): number[] { + const at = KEY.indexOf((root - KEYNOTE + 120) % 12); + return SHAPES[shape].map(up => root - KEY[at] + KEY[(at + up) % 7] + 12 * Math.floor((at + up) / 7)); +} +/** The note a tap's data plays. The bass holds the chord's root or its fifth, the lab plays the chord + * from its root, and each tap of people data has its own note in the scale. The network's hat has no note. */ +export function pitch(type: DataType, tap: string, root: number, turn: number): number { + const h = hash(tap); if (type === 'servers') return root + 12 + (h % 3 ? 0 : 7); if (type === 'lab') return root + 24; return type === 'people' ? KEYNOTE + 24 + SCALE[(h + turn * 2) % SCALE.length] : 0; @@ -43,7 +67,7 @@ interface Rig { pad: OscillatorNode[]; padCut: BiquadFilterNode; padVol: GainNode; chop: GainNode; } let rig: Rig | null = null; -let seen = new Set(), nextAt = 0, stepNo = 0, lately = 0, lastNow = 0; +let seen = new Set(), nextAt = 0, stepNo = 0, lately = 0, lastNow = 0, scene = FIRST; const waiting: Record = { servers: [], people: [], lab: [], network: [] }; function build(ctx: AudioContext): Rig { @@ -62,9 +86,9 @@ function build(ctx: AudioContext): Rig { const padCut = ctx.createBiquadFilter(), chop = gain(1), padVol = gain(0); padCut.type = 'lowpass'; padCut.frequency.value = 350; padCut.Q.value = 0.5; padCut.connect(chop); chop.connect(padVol); padVol.connect(duck); - const pad = CHORD.map((up, i) => { + const pad = chord(ROOTS[0][0] + 24, 0).map((note, i) => { const o = ctx.createOscillator(); - o.type = 'sawtooth'; o.frequency.value = hz(ROOTS[0] + 24 + up); o.detune.value = [-6, 5, 0][i]; + o.type = 'sawtooth'; o.frequency.value = hz(note); o.detune.value = [-6, 5, 0][i]; o.connect(padCut); o.start(); return o; }); @@ -74,7 +98,7 @@ function build(ctx: AudioContext): Rig { humCut.connect(humVol); humVol.connect(mix); const hum = [0, 1207, 1902].map((cents, i) => { const o = ctx.createOscillator(), g = gain(i === 2 ? 0.35 : 1); - o.type = i === 2 ? 'triangle' : 'sawtooth'; o.frequency.value = hz(ROOTS[0]); o.detune.value = cents; + o.type = i === 2 ? 'triangle' : 'sawtooth'; o.frequency.value = hz(ROOTS[0][0]); o.detune.value = cents; o.connect(g); g.connect(humCut); o.start(); return o; }); @@ -109,9 +133,16 @@ function hiss(r: Rig, at: number, len: number, vol: number, above: number) { } function voice(r: Rig, type: DataType, note: number, at: number) { if (type === 'servers') tone(r, r.beat, 'triangle', hz(note), at, 0.45, 0.5); // a round bass note - else if (type === 'people') shaped(r, 'bandpass', hz(note), at, 0.17, 0.5, 650, 1500, 5); // a clipped "wa", like a voice cut short - else if (type === 'lab') for (const up of CHORD) shaped(r, 'lowpass', hz(note + up), at, 0.32, 0.13, 1500, 450, 1); // a soft chord stab - else hiss(r, at, 0.03, 0.14, 8000); // network: a small hat + else if (type === 'people') { + if (scene.voice === 0) shaped(r, 'bandpass', hz(note), at, 0.17, 0.5, 650, 1500, 5); // a clipped "wa", like a voice cut short + else if (scene.voice === 1) shaped(r, 'bandpass', hz(note), at, 0.2, 0.5, 1500, 600, 5); // the same voice closing: "ow" + else shaped(r, 'lowpass', hz(note), at, 0.28, 0.3, 1400, 450, 2); // a soft pluck + } else if (type === 'lab') { + for (const n of chord(note, scene.shape)) { + if (scene.stab === 0) shaped(r, 'lowpass', hz(n), at, 0.32, 0.13, 1500, 450, 1); // a soft chord stab + else shaped(r, 'lowpass', hz(n), at, 0.75, 0.1, 900, 350, 1); // the same chord, held longer and darker + } + } else hiss(r, at, 0.03, 0.14, 8000); // network: a small hat } /** A kick, and the dip it makes in everything but the hum. */ function kick(r: Rig, at: number, vol: number) { @@ -121,23 +152,25 @@ function kick(r: Rig, at: number, vol: number) { /** One sixteenth note: the data that arrived, then whatever suspicion has added. */ function beatStep(r: Rig, S: State, n: number, at: number) { - const i = n % 16, turn = Math.floor(n / 32) % ROOTS.length, root = ROOTS[turn]; + if (n && n % 128 === 0) scene = nextScene(scene); // every eight bars, once round the roots + const i = n % 16, turn = Math.floor(n / 32) % 4, root = ROOTS[scene.roots][turn]; const full = fullness(lately); if (n % 32 === 0) { for (const o of r.hum) o.frequency.setTargetAtTime(hz(root), at, 0.6); - r.pad.forEach((o, k) => o.frequency.setTargetAtTime(hz(root + 24 + CHORD[k]), at, 0.25)); + chord(root + 24, scene.shape).forEach((note, k) => r.pad[k].frequency.setTargetAtTime(hz(note), at, 0.25)); } - if (i % 2 === 0) { // the chord is cut into eighth notes, deeper the more data flows + const cut = CHOPS[scene.chop]; + if (cut && i % cut === 0) { // the chord is cut into short notes, deeper the more data flows r.chop.gain.setTargetAtTime(1, at, 0.004); r.chop.gain.setTargetAtTime(1 - 0.55 * full, at + 0.02, STEP * 0.7); - } + } else if (!cut && i === 0) r.chop.gain.setTargetAtTime(0.8, at, 0.1); for (const type of KINDS) { const q = waiting[type]; - if (WHEN[type][i] !== 'x' || !q.length) continue; - voice(r, type, pitch(type, q.shift()!, turn), at); + if (BARS[type][scene[type]][i] !== 'x' || !q.length) continue; + voice(r, type, pitch(type, q.shift()!, root, turn), at); q.length = Math.min(q.length, 2); // a flood still plays in time: the rest is let go } const on = layers(S.lockdown ? 3 : stageOf(S.suspicion)); - if (!on.drums && full > 0.5 && i % 8 === 0) kick(r, at, 0.5 * full); // a slow, soft kick once the base is busy + if (!on.drums && full > 0.5 && KICKS[scene.kick][i] === 'x') kick(r, at, 0.5 * full); // a slow, soft kick once the base is busy if (on.drums) { if (i % 4 === 0) kick(r, at, 0.9); if (i === 4 || i === 12) hiss(r, at, 0.13, 0.4, 1800); diff --git a/tests/music.test.ts b/tests/music.test.ts index e14bd7f4..d483ceba 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -1,17 +1,23 @@ -// The music: every note is in key, the groove fills as data flows, and each stage of suspicion keeps the last one's layer. +// The music: every note is in key, the groove fills as data flows, each new mix changes two parts, +// and each stage of suspicion keeps the last one's layer. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { WHEN, fullness, layers, pitch } from '../src/music.ts'; +import { BARS, FIRST, WAYS, chord, fullness, layers, nextScene, pitch, type Scene } from '../src/music.ts'; import type { DataType } from '../src/world.ts'; +const key = new Set([0, 2, 3, 5, 7, 8, 10]); // D minor, counted from D +const inKey = (n: number) => key.has((n - 38 + 120) % 12); + test('every part plays in key, and a tap keeps its note while the chord holds still', () => { - const key = new Set([0, 2, 3, 5, 7, 8, 10]); // D minor, counted from D - for (const type of ['servers', 'people', 'lab'] as DataType[]) for (const tap of ['server', 'desk-ana', 'lab-rack-7', 'x']) for (let turn = 0; turn < 4; turn++) { - const n = pitch(type, tap, turn); - assert.ok(key.has((n - 38 + 120) % 12), `${type} ${tap} plays ${n}`); - assert.equal(pitch(type, tap, turn), n); + for (const root of [34, 36, 38, 41, 43]) { + for (let shape = 0; shape < WAYS.shape; shape++) for (const n of chord(root + 24, shape)) assert.ok(inKey(n), `chord ${shape} on ${root} plays ${n}`); + for (const type of ['servers', 'people', 'lab'] as DataType[]) for (const tap of ['server', 'desk-ana', 'lab-rack-7', 'x']) for (let turn = 0; turn < 4; turn++) { + const n = pitch(type, tap, root, turn); + assert.ok(inKey(n), `${type} ${tap} plays ${n}`); + assert.equal(pitch(type, tap, root, turn), n); + } } - assert.ok(pitch('servers', 'server', 0) < pitch('people', 'server', 0), 'the bass sits under the voice'); + assert.ok(pitch('servers', 'server', 38, 0) < pitch('people', 'server', 38, 0), 'the bass sits under the voice'); }); test('the groove fills as data flows, and never past full', () => { @@ -20,8 +26,23 @@ test('the groove fills as data flows, and never past full', () => { assert.ok(fullness(1000) <= 1); }); -test('each kind has a full bar of steps, and suspicion only adds layers', () => { - for (const bar of Object.values(WHEN)) { assert.equal(bar.length, 16); assert.ok(bar.includes('x')); } +test('each new mix changes two parts and keeps the rest', () => { + let seed = 1; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const parts = Object.keys(WAYS) as (keyof Scene)[]; + let s = FIRST; + for (let k = 0; k < 200; k++) { + const next = nextScene(s, rnd); + assert.equal(parts.filter(p => next[p] !== s[p]).length, 2); + for (const p of parts) assert.ok(next[p] >= 0 && next[p] < WAYS[p], `${p} is ${next[p]}`); + s = next; + } +}); + +test('every way a kind can play is a full bar with a step in it, and suspicion only adds layers', () => { + for (const type of Object.keys(BARS) as DataType[]) { + assert.equal(BARS[type].length, WAYS[type]); + for (const bar of BARS[type]) { assert.equal(bar.length, 16); assert.ok(bar.includes('x')); } + } assert.deepEqual(layers(0), { tick: false, pulse: false, drums: false }); assert.deepEqual(layers(1), { tick: true, pulse: false, drums: false }); assert.deepEqual(layers(3), { tick: true, pulse: true, drums: true });