Skip to content
166 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.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}