1/** 2 * Mic blow / voice detection for birthday candle (AnalyserNode RMS). 3 * Triggers on a sharp blow OR sustained talking into the mic. 4 */ 5(function (global) { 6 'use strict'; 7 8 var NOISE_FLOOR_CAP = 0.028; 9 var DISPLAY_CEILING = 0.52; 10 11 function MicBlowDetector(opts) { 12 opts = opts || {}; 13 this.threshold = opts.threshold != null ? opts.threshold : 0.03; 14 this.peakThreshold = opts.peakThreshold != null ? opts.peakThreshold : 0.075; 15 this.holdMs = opts.holdMs != null ? opts.holdMs : 120; 16 this.calibrationMs = opts.calibrationMs != null ? opts.calibrationMs : 500; 17 this.autoGainControl = opts.autoGainControl != null ? opts.autoGainControl : true; 18 this.stream = null; 19 this.audioContext = opts.audioContext || null; 20 this.analyser = null; 21 this.source = null; 22 this.rafId = null; 23 this.active = false; 24 this.aboveSince = 0; 25 this.startedAt = 0; 26 this.noiseFloor = 0.006; 27 this.onBlow = typeof opts.onBlow === 'function' ? opts.onBlow : null; 28 this.onError = typeof opts.onError === 'function' ? opts.onError : null; 29 this.onLevel = typeof opts.onLevel === 'function' ? opts.onLevel : null; 30 } 31 32 MicBlowDetector.prototype.ensureAudioContext = function () { 33 if (!this.audioContext) { 34 var Ctx = global.AudioContext || global.webkitAudioContext; 35 if (!Ctx) return null; 36 this.audioContext = new Ctx(); 37 } 38 return this.audioContext; 39 }; 40 41 MicBlowDetector.prototype.resumeContext = function () { 42 var ctx = this.ensureAudioContext(); 43 if (!ctx) return Promise.resolve(); 44 if (ctx.state === 'running') return Promise.resolve(); 45 return ctx.resume().catch(function () { /* ignore */ }); 46 }; 47 48 MicBlowDetector.prototype.getRms = function () { 49 if (!this.analyser) return 0; 50 var buf = new Uint8Array(this.analyser.fftSize); 51 this.analyser.getByteTimeDomainData(buf); 52 var sum = 0; 53 for (var i = 0; i < buf.length; i++) { 54 var v = (buf[i] - 128) / 128; 55 sum += v * v; 56 } 57 return Math.sqrt(sum / buf.length); 58 }; 59 60 MicBlowDetector.prototype.effectiveThreshold = function () { 61 return Math.max(this.threshold, Math.min(this.noiseFloor * 2.4, NOISE_FLOOR_CAP)); 62 }; 63 64 MicBlowDetector.prototype.sampleNoiseFloor = function (rms) { 65 if (rms > 0.045) return; 66 this.noiseFloor = Math.min(NOISE_FLOOR_CAP, Math.max(this.noiseFloor, rms * 1.12)); 67 }; 68 69 MicBlowDetector.prototype.triggerBlow = function () { 70 this.stop(); 71 if (this.onBlow) this.onBlow(); 72 }; 73 74 MicBlowDetector.prototype.wake = function () { 75 return this.resumeContext(); 76 }; 77 78 MicBlowDetector.prototype.start = function () { 79 var self = this; 80 if (!global.navigator || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 81 if (self.onError) self.onError(new Error('Microphone unavailable')); 82 return Promise.reject(new Error('Microphone unavailable')); 83 } 84 85 if (!self.ensureAudioContext()) { 86 if (self.onError) self.onError(new Error('Web Audio unavailable')); 87 return Promise.reject(new Error('Web Audio unavailable')); 88 } 89 90 self.resumeContext(); 91 92 return navigator.mediaDevices.getUserMedia({ 93 audio: { 94 echoCancellation: false, 95 noiseSuppression: false, 96 autoGainControl: self.autoGainControl 97 } 98 }).then(function (stream) { 99 self.stream = stream; 100 self.analyser = self.audioContext.createAnalyser(); 101 self.analyser.fftSize = 1024; 102 self.analyser.smoothingTimeConstant = 0.45; 103 self.source = self.audioContext.createMediaStreamSource(stream); 104 self.source.connect(self.analyser); 105 self.active = true; 106 self.startedAt = global.performance.now(); 107 self.aboveSince = 0; 108 return self.resumeContext(); 109 }).then(function () { 110 self.loop(); 111 }).catch(function (err) { 112 if (self.onError) self.onError(err); 113 throw err; 114 }); 115 }; 116 117 MicBlowDetector.prototype.loop = function () { 118 var self = this; 119 if (!self.active || !self.analyser) return; 120 121 self.resumeContext(); 122 123 var rms = self.getRms(); 124 var now = global.performance.now();
125 var elapsed = now - self.startedAt; 126 127 if (elapsed < self.calibrationMs) { 128 self.sampleNoiseFloor(rms); 129 } 130 131 if (self.onLevel) { 132 self.onLevel(rms, self.effectiveThreshold(), self.peakThreshold); 133 } 134 135 if (rms >= self.peakThreshold) { 136 self.triggerBlow(); 137 return; 138 } 139 140 var gate = self.effectiveThreshold(); 141 if (rms >= gate) { 142 if (!self.aboveSince) self.aboveSince = now; 143 if (now - self.aboveSince >= self.holdMs) { 144 self.triggerBlow(); 145 return; 146 } 147 } else { 148 self.aboveSince = 0; 149 } 150 151 self.rafId = global.requestAnimationFrame(function () { 152 self.loop(); 153 }); 154 }; 155 156 MicBlowDetector.prototype.stop = function () { 157 this.active = false; 158 if (this.rafId) { 159 global.cancelAnimationFrame(this.rafId); 160 this.rafId = null; 161 } 162 if (this.source) { 163 try { this.source.disconnect(); } catch (e) { /* ignore */ } 164 this.source = null; 165 } 166 if (this.stream) { 167 this.stream.getTracks().forEach(function (t) { t.stop(); }); 168 this.stream = null; 169 } 170 if (this.audioContext) { 171 this.audioContext.close().catch(function () { /* ignore */ }); 172 this.audioContext = null; 173 } 174 this.analyser = null; 175 }; 176 177 global.MicBlowDetector = MicBlowDetector; 178 global.MicBlowDetector.DISPLAY_CEILING = DISPLAY_CEILING; 179})(typeof window !== 'undefined' ? window : this);
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.