PageSourceSearch

https://pupwife.com/scripts/effects/micBlowDetector.js

js pupwife.com collected 2026-09-30 15:04:43 UTC 6,174 bytes, 179 lines download raw bytes

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.