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