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 | */ | |
| 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 | } |