Skip to content
166 linesCodeBlameRaw
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 */
14import 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). */
17export 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. */
20export type Timbre = { partials: { ratio: number; gain: number; decay: number }[] };
21
22export type CueSpec = { notes: Note[]; timbre: Timbre };
23
24// Pitches, in Hz (equal temperament, A4 = 440).
25const C5 = 523.25;
26const D5 = 587.33;
27const E5 = 659.25;
28const G5 = 783.99;
29const A5 = 880;
30const C6 = 1046.5;
31const D6 = 1174.66;
32const A4 = 440;
33const CS5 = 554.37;
34
35/** Warm: a rounded fundamental with a touch of the octave and the twelfth, which die away first. */
36const 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. */
45const 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 */
59export 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. */
85function 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
89export 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
98export 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. */
103export 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. */
108export const ATTACK_MS = 6;
109export const RELEASE_MS = 12;
110/** The loudest any rendered sample is: room left under full scale. */
111export 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 */
118export 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 */
133export 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. */
163export function volumeGain(volume: number): number {
164 const v = Math.max(0, Math.min(100, volume)) / 100;
165 return v ** 1.6;
166}