PageSourceSearch

https://immerruf.de/js/audio-levels.js

js immerruf.de collected 2026-10-09 03:13:21 UTC 2,710 bytes, 78 lines download raw bytes

1// audio-levels.js — per-side loudness for the live call, so the UI can react to
2// who is actually speaking.
3//
4// One AudioContext, one analyser per named channel ("mic" = the visitor,
5// "bot" = Lina's reply stream). Nothing is polled on a timer: read() computes
6// the current RMS on demand, so the caller's existing rAF loop sets the rate and
7// an idle page costs nothing.
8//
9// Deliberately separate from silence-watch.js: that one only answers "is anyone
10// making a sound at all" (merged, 500ms poll). This one needs per-side amplitude
11// every frame.
12
13// Byte time-domain data centres on 128. Below this the signal is quantisation
14// noise, not speech — same floor silence-watch.js uses.
15const NOISE_FLOOR = 0.012;
16// RMS of normal speech sits around 0.05-0.15, so scale it up into 0..1.
17const GAIN = 7;
18// Attack fast (a syllable must land on the same frame), release slow (so the orb
19// glides down between words instead of strobing).
20const RISE = 0.5;
21const FALL = 0.12;
22
23export function createLevelMeter() {
24  const Ctx = window.AudioContext || window.webkitAudioContext;
25  let ctx = Ctx ? new Ctx() : null;
26  const channels = new Map(); // name -> { analyser, buffer, level }
27
28  // Safe to call before or after the first read(); a stream that can't be
29  // analysed simply doesn't contribute (never break the call over an animation).
30  function addStream(name, stream) {
31    if (!ctx || !stream) return;
32    try {
33      const analyser = ctx.createAnalyser();
34      analyser.fftSize = 512; // enough for amplitude, ~4x cheaper than 2048
35      ctx.createMediaStreamSource(stream).connect(analyser);
36      channels.set(name, {
37        analyser,
38        buffer: new Uint8Array(analyser.fftSize),
39        level: 0,
40      });
41      if (ctx.state === "suspended") ctx.resume().catch(() => {});
42    } catch (err) {
43      console.warn("Voicy: level meter could not attach", name, err);
44    }
45  }
46
47  function rms(ch) {
48    ch.analyser.getByteTimeDomainData(ch.buffer);
49    let sum = 0;
50    for (let i = 0; i < ch.buffer.length; i += 1) {
51      const v = (ch.buffer[i] - 128) / 128;
52      sum += v * v;
53    }
54    const r = Math.sqrt(sum / ch.buffer.length);
55    return r < NOISE_FLOOR ? 0 : Math.min(r * GAIN, 1);
56  }
57
58  // Current smoothed levels, 0..1 per channel. Call once per frame.
59  function read() {
60    const out = { mic: 0, bot: 0 };
61    channels.forEach((ch, name) => {
62      const target = rms(ch);
63      ch.level += (target - ch.level) * (target > ch.level ? RISE : FALL);
64      out[name] = ch.level;
65    });
66    return out;
67  }
68
69  function stop() {
70    channels.clear();
71    if (ctx) {
72      ctx.close().catch(() => {});
73      ctx = null;
74    }
75  }
76
77  return { addStream, read, stop };
78}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.