| 1 | /** |
| 2 | * The sounds themselves, as numbers: each cue is a few notes with an |
| 3 | * envelope, rendered into samples here so nothing is downloaded and nothing |
| 4 | * is licensed. Pure, so the shapes are tested on their own |
| 5 | * (sound-synth.test.ts); lib/sounds.ts puts the samples in an AudioBuffer |
| 6 | * and plays them. |
| 7 | * |
| 8 | * Two sets with the same roles. "Soft" is warm and low: a sine with a |
| 9 | * little of its second and third harmonic, 190–250 ms, like a muted |
| 10 | * marimba. "Bright" is the same figures a fifth higher and a quarter |
| 11 | * shorter, nearly pure sines, like a small bell. Every note fades in over |
| 12 | * 6 ms and out over its last 12 ms, so there is never a click. |
| 13 | */ |
| 14 | import type { SoundCue, SoundSet } from "@g1t/contracts/sounds"; |
| 15 | |
| 16 | /** A note: when it starts and how long it lasts, in ms; `gain` scales it (1 is full). */ |
| 17 | export type Note = { freq: number; at: number; dur: number; gain?: number }; |
| 18 | |
| 19 | /** The timbre of a set: which overtones ride on each note, as fractions of the fundamental. */ |
| 20 | export type Timbre = { partials: { ratio: number; gain: number; decay: number }[] }; |
| 21 | |
| 22 | export type CueSpec = { notes: Note[]; timbre: Timbre }; |
| 23 | |
| 24 | // Pitches, in Hz (equal temperament, A4 = 440). |
| 25 | const C5 = 523.25; |
| 26 | const D5 = 587.33; |
| 27 | const E5 = 659.25; |
| 28 | const G5 = 783.99; |
| 29 | const A5 = 880; |
| 30 | const C6 = 1046.5; |
| 31 | const D6 = 1174.66; |
| 32 | const A4 = 440; |
| 33 | const CS5 = 554.37; |
| 34 | |
| 35 | /** Warm: a rounded fundamental with a touch of the octave and the twelfth, which die away first. */ |
| 36 | const WARM: Timbre = { |
| 37 | partials: [ |
| 38 | { ratio: 1, gain: 1, decay: 1 }, |
| 39 | { ratio: 2, gain: 0.22, decay: 0.55 }, |
| 40 | { ratio: 3, gain: 0.07, decay: 0.4 }, |
| 41 | ], |
| 42 | }; |
| 43 | |
| 44 | /** Bright: nearly a pure sine, with a faint octave for sparkle. */ |
| 45 | const BRIGHT: Timbre = { |
| 46 | partials: [ |
| 47 | { ratio: 1, gain: 1, decay: 1 }, |
| 48 | { ratio: 2, gain: 0.1, decay: 0.5 }, |
| 49 | ], |
| 50 | }; |
| 51 | |
| 52 | /** |
| 53 | * The Soft set. Each is short, two tones, and tells its own story: a |
| 54 | * message steps up a third (something arrived), a direct message leaps a |
| 55 | * fifth (someone is talking to you), a mention rings twice on the high note |
| 56 | * (look up), an agent finishing settles down a fifth (resolved), and |
| 57 | * sending is a single soft tick. `call` is reserved: a two-tone to repeat. |
| 58 | */ |
| 59 | export const SOFT: Record<SoundCue, Note[]> = { |
| 60 | message: [ |
| 61 | { freq: C5, at: 0, dur: 90, gain: 0.9 }, |
| 62 | { freq: E5, at: 70, dur: 120, gain: 0.8 }, |
| 63 | ], |
| 64 | direct: [ |
| 65 | { freq: D5, at: 0, dur: 90, gain: 0.9 }, |
| 66 | { freq: A5, at: 80, dur: 150, gain: 0.85 }, |
| 67 | ], |
| 68 | mention: [ |
| 69 | { freq: A5, at: 0, dur: 60, gain: 0.8 }, |
| 70 | { freq: D6, at: 60, dur: 90, gain: 0.9 }, |
| 71 | { freq: D6, at: 150, dur: 90, gain: 0.75 }, |
| 72 | ], |
| 73 | agent_done: [ |
| 74 | { freq: G5, at: 0, dur: 80, gain: 0.85 }, |
| 75 | { freq: C5, at: 80, dur: 160, gain: 0.9 }, |
| 76 | ], |
| 77 | sent: [{ freq: C6, at: 0, dur: 55, gain: 0.45 }], |
| 78 | call: [ |
| 79 | { freq: A4, at: 0, dur: 120, gain: 0.9 }, |
| 80 | { freq: CS5, at: 120, dur: 130, gain: 0.9 }, |
| 81 | ], |
| 82 | }; |
| 83 | |
| 84 | /** A fifth higher and a quarter shorter: the Bright set from the Soft figures. */ |
| 85 | function brighten(notes: Note[]): Note[] { |
| 86 | return notes.map((note) => ({ ...note, freq: note.freq * 1.5, at: note.at * 0.75, dur: note.dur * 0.75 })); |
| 87 | } |
| 88 | |
| 89 | export const BRIGHT_NOTES: Record<SoundCue, Note[]> = { |
| 90 | message: brighten(SOFT.message), |
| 91 | direct: brighten(SOFT.direct), |
| 92 | mention: brighten(SOFT.mention), |
| 93 | agent_done: brighten(SOFT.agent_done), |
| 94 | sent: brighten(SOFT.sent), |
| 95 | call: brighten(SOFT.call), |
| 96 | }; |
| 97 | |
| 98 | export function cueSpec(set: SoundSet, cue: SoundCue): CueSpec { |
| 99 | return set === "bright" ? { notes: BRIGHT_NOTES[cue], timbre: BRIGHT } : { notes: SOFT[cue], timbre: WARM }; |
| 100 | } |
| 101 | |
| 102 | /** How long a cue lasts, in ms: its last note's end. */ |
| 103 | export function cueLength(spec: CueSpec): number { |
| 104 | return Math.max(0, ...spec.notes.map((note) => note.at + note.dur)); |
| 105 | } |
| 106 | |
| 107 | /** The fade in and out of each note, in ms. */ |
| 108 | export const ATTACK_MS = 6; |
| 109 | export const RELEASE_MS = 12; |
| 110 | /** The loudest any rendered sample is: room left under full scale. */ |
| 111 | export const PEAK = 0.8; |
| 112 | |
| 113 | /** |
| 114 | * A note's loudness at `t` ms into it: a raised-cosine rise over the |
| 115 | * attack, an exponential fall to about a third by the end, and a |
| 116 | * raised-cosine release over the last `RELEASE_MS` to exactly zero. |
| 117 | */ |
| 118 | export function envelope(t: number, dur: number): number { |
| 119 | if (t < 0 || t >= dur) return 0; |
| 120 | const rise = t < ATTACK_MS ? 0.5 - 0.5 * Math.cos((Math.PI * t) / ATTACK_MS) : 1; |
| 121 | const fall = Math.exp((-1.1 * t) / dur); |
| 122 | const left = dur - t; |
| 123 | const release = left < RELEASE_MS ? 0.5 - 0.5 * Math.cos((Math.PI * left) / RELEASE_MS) : 1; |
| 124 | return rise * fall * release; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * The cue as mono samples at `sampleRate`, peaking at `PEAK`. Each note is |
| 129 | * the sum of its timbre's partials, each with the envelope; overlapping |
| 130 | * notes add. A quiet cue is scaled up to the peak and a loud one down, so |
| 131 | * the volume setting is the only thing that changes loudness. |
| 132 | */ |
| 133 | export function renderCue(spec: CueSpec, sampleRate: number): Float32Array<ArrayBuffer> { |
| 134 | const length = Math.ceil((cueLength(spec) / 1000) * sampleRate); |
| 135 | const out = new Float32Array(new ArrayBuffer(length * 4)); |
| 136 | for (const note of spec.notes) { |
| 137 | const start = Math.floor((note.at / 1000) * sampleRate); |
| 138 | const count = Math.floor((note.dur / 1000) * sampleRate); |
| 139 | const gain = note.gain ?? 1; |
| 140 | for (let i = 0; i < count && start + i < length; i++) { |
| 141 | const tMs = (i / sampleRate) * 1000; |
| 142 | const shape = envelope(tMs, note.dur); |
| 143 | if (shape === 0) continue; |
| 144 | let sample = 0; |
| 145 | for (const partial of spec.timbre.partials) { |
| 146 | // Higher partials fade faster than the fundamental: the tone mellows as it rings. |
| 147 | const fade = partial.decay === 1 ? 1 : Math.exp((-(1 - partial.decay) * 4 * tMs) / note.dur); |
| 148 | sample += partial.gain * fade * Math.sin((2 * Math.PI * note.freq * partial.ratio * i) / sampleRate); |
| 149 | } |
| 150 | out[start + i] += gain * shape * sample; |
| 151 | } |
| 152 | } |
| 153 | let peak = 0; |
| 154 | for (const sample of out) peak = Math.max(peak, Math.abs(sample)); |
| 155 | if (peak > 0) { |
| 156 | const scale = PEAK / peak; |
| 157 | for (let i = 0; i < out.length; i++) out[i] *= scale; |
| 158 | } |
| 159 | return out; |
| 160 | } |
| 161 | |
| 162 | /** The perceived loudness of a 0–100 setting as a gain: the ear hears a curve, not a line. */ |
| 163 | export function volumeGain(volume: number): number { |
| 164 | const v = Math.max(0, Math.min(100, volume)) / 100; |
| 165 | return v ** 1.6; |
| 166 | } |