Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 9383f719c9b37ff76411858f
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/sound.ts
// Sound on the device itself, with Web Audio: the station's loudness meter and soothing sounds,
// and the parent's alert chime. Every sound is generated here; there are no audio files.
import type { SoundName } from "./protocol";
/** Measures how loud a microphone is, as a 0 to 100 score (see THRESHOLDS in protocol.ts). */
export class Loudness {
private analyser: AnalyserNode;
private buf: Float32Array;
constructor(ctx: AudioContext, track: MediaStreamTrack) {
const source = ctx.createMediaStreamSource(new MediaStream([track]));
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 2048;
this.buf = new Float32Array(this.analyser.fftSize);
source.connect(this.analyser);
}
score(): number {
this.analyser.getFloatTimeDomainData(this.buf);
let sum = 0;
for (let i = 0; i < this.buf.length; i++) sum += this.buf[i] * this.buf[i];
const db = 20 * Math.log10(Math.sqrt(sum / this.buf.length) + 1e-9);
return Math.max(0, Math.min(100, Math.round(((db + 70) * 100) / 60)));
}
}
const RATE = 22050;
function noise(seconds: number, color: "white" | "pink" | "brown"): AudioBuffer {
const buffer = new AudioBuffer({ numberOfChannels: 1, length: RATE * seconds, sampleRate: RATE });
const data = buffer.getChannelData(0);
let b0 = 0, b1 = 0, b2 = 0, last = 0;
for (let i = 0; i < data.length; i++) {
const w = Math.random() * 2 - 1;
if (color === "white") data[i] = w * 0.5;
else if (color === "pink") {
b0 = 0.99765 * b0 + w * 0.099046;
b1 = 0.963 * b1 + w * 0.2965164;
b2 = 0.57 * b2 + w * 1.0526913;
data[i] = (b0 + b1 + b2 + w * 0.1848) * 0.12;
} else {
last = (last + 0.02 * w) / 1.02;
data[i] = last * 3.2;
}
}
return buffer;
}
const NOTE: Record<string, number> = { C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23, G4: 392, A4: 440, B4: 493.88, C5: 523.25 };
// Brahms' Lullaby (1868, public domain), as [note, beats].
const LULLABY: [string, number][] = [
["E4", .5], ["E4", .5], ["G4", 2], ["E4", .5], ["E4", .5], ["G4", 2],
["E4", .5], ["G4", .5], ["C5", 1], ["B4", 1.5], ["A4", .5], ["A4", 1], ["G4", 2],
["D4", .5], ["E4", .5], ["F4", 1], ["D4", 2], ["D4", .5], ["E4", .5], ["F4", 2],
["D4", .5], ["F4", .5], ["B4", .5], ["A4", .5], ["G4", 1], ["B4", 1], ["C5", 2],
["C4", .5], ["C4", .5], ["C5", 2], ["A4", .5], ["F4", .5], ["G4", 2],
["E4", .5], ["C4", .5], ["F4", 1], ["G4", 1], ["A4", 1], ["G4", 2],
["C4", .5], ["C4", .5], ["C5", 2], ["A4", .5], ["F4", .5], ["G4", 2],
["E4", .5], ["C4", .5], ["F4", 1], ["E4", .5], ["D4", .5], ["C4", 3],
];
/** Renders the lullaby once, as a soft music box, with a pause before it repeats. */
async function lullaby(): Promise<AudioBuffer> {
const beat = 60 / 76;
const total = LULLABY.reduce((a, [, b]) => a + b, 0) * beat + 3;
const ctx = new OfflineAudioContext(1, Math.ceil(RATE * total), RATE);
const out = ctx.createGain();
out.gain.value = 0.5;
out.connect(ctx.destination);
let t = 0.2;
for (const [n, beats] of LULLABY) {
for (const [mult, level] of [[1, 0.5], [2, 0.12], [3, 0.05]]) {
const osc = ctx.createOscillator();
osc.frequency.value = NOTE[n] * mult;
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(level, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0005, t + 1.8);
osc.connect(g).connect(out);
osc.start(t);
osc.stop(t + 1.9);
}
t += beats * beat;
}
return ctx.startRendering();
}
/** One calm heartbeat (lub, dub) at 64 beats a minute. */
async function heartbeat(): Promise<AudioBuffer> {
const cycle = 60 / 64;
const ctx = new OfflineAudioContext(1, Math.ceil(RATE * cycle), RATE);
const lowpass = ctx.createBiquadFilter();
lowpass.type = "lowpass";
lowpass.frequency.value = 160;
lowpass.connect(ctx.destination);
for (const [at, freq, level] of [[0.02, 58, 0.9], [0.3, 70, 0.6]]) {
const osc = ctx.createOscillator();
osc.frequency.setValueAtTime(freq, at);
osc.frequency.exponentialRampToValueAtTime(freq * 0.6, at + 0.15);
const g = ctx.createGain();
g.gain.setValueAtTime(0, at);
g.gain.linearRampToValueAtTime(level, at + 0.015);
g.gain.exponentialRampToValueAtTime(0.001, at + 0.2);
osc.connect(g).connect(lowpass);
osc.start(at);
osc.stop(at + 0.25);
}
return ctx.startRendering();
}
/** Plays one soothing sound on a loop, fading in and out. */
export class Soother {
private nodes: AudioNode[] = [];
private sources: AudioScheduledSourceNode[] = [];
private master: GainNode;
playing: SoundName | null = null;
constructor(private ctx: AudioContext) {
this.master = ctx.createGain();
this.master.gain.value = 0;
this.master.connect(ctx.destination);
}
async play(name: SoundName, volume: number) {
this.stopNow();
const ctx = this.ctx;
const loop = (buffer: AudioBuffer) => {
const src = ctx.createBufferSource();
src.buffer = buffer;
src.loop = true;
this.sources.push(src);
return src;
};
const filter = (type: BiquadFilterType, frequency: number, q = 0.7) => {
const f = ctx.createBiquadFilter();
f.type = type;
f.frequency.value = frequency;
f.Q.value = q;
this.nodes.push(f);
return f;
};
if (name === "white") {
loop(noise(4, "white")).connect(filter("lowpass", 7000)).connect(this.master);
} else if (name === "rain") {
loop(noise(4, "pink")).connect(filter("highpass", 400)).connect(filter("lowpass", 5000)).connect(this.master);
} else if (name === "waves") {
const swell = ctx.createGain();
swell.gain.value = 0.55;
const lfo = ctx.createOscillator();
lfo.frequency.value = 0.09;
const depth = ctx.createGain();
depth.gain.value = 0.45;
lfo.connect(depth).connect(swell.gain);
this.sources.push(lfo);
this.nodes.push(swell, depth);
loop(noise(6, "brown")).connect(filter("lowpass", 900)).connect(swell).connect(this.master);
} else if (name === "heartbeat") {
loop(await heartbeat()).connect(this.master);
} else {
loop(await lullaby()).connect(this.master);
}
for (const s of this.sources) s.start();
this.playing = name;
this.setVolume(volume, 2);
}
setVolume(volume: number, fade = 0.3) {
const g = this.master.gain;
g.cancelScheduledValues(this.ctx.currentTime);
g.setValueAtTime(g.value, this.ctx.currentTime);
g.linearRampToValueAtTime(Math.max(0, Math.min(1, volume)) * 0.8, this.ctx.currentTime + fade);
}
stop() {
if (!this.playing) return;
this.setVolume(0, 1.5);
const sources = this.sources;
const nodes = this.nodes;
this.sources = [];
this.nodes = [];
this.playing = null;
setTimeout(() => this.dispose(sources, nodes), 1700);
}
private stopNow() {
this.dispose(this.sources, this.nodes);
this.sources = [];
this.nodes = [];
this.playing = null;
}
private dispose(sources: AudioScheduledSourceNode[], nodes: AudioNode[]) {
for (const s of sources) { try { s.stop(); } catch { /* never started */ } s.disconnect(); }
for (const n of nodes) n.disconnect();
}
}
/** A gentle two-note chime for a noise alert. */
export function chime(ctx: AudioContext) {
const t0 = ctx.currentTime + 0.05;
for (const [i, freq] of [659.25, 880, 659.25, 880].entries()) {
const t = t0 + i * 0.32 + (i > 1 ? 0.5 : 0);
const osc = ctx.createOscillator();
osc.frequency.value = freq;
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(0.28, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.9);
osc.connect(g).connect(ctx.destination);
osc.start(t);
osc.stop(t + 1);
}
}
Change history
Move store links to openapps.free
Release with preview
Bind agent address in policy
Use OpenApps service URLs and add contribution strip (retry)
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync