monitor-free.openapps.xyz

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.
Owned app · rev 11d38ef641b0
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.
← All files
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
11d38ef641 · 17 hours ago
Release with preview
41c8af3ee5 · 19 hours ago
Bind agent address in policy
5b2d792529 · 19 hours ago
Use OpenApps service URLs and add contribution strip (retry)
be4477dcb3 · 19 hours ago
Bounded source sync
5c9ec00c29 · 2 days ago
Bounded source sync
b33aa231f4 · 2 days ago
Bounded source sync
6cb67b3c8e · 2 days ago
Bounded source sync
e054acaa29 · 2 days ago