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.