import 'dart:async'; import 'dart:js_interop'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:web/web.dart' as web; class VoiceMeterFrame { const VoiceMeterFrame({required this.level, required this.samples}); final double level; final List samples; } class BrowserVoiceMeter { web.AudioContext? _context; web.AnalyserNode? _analyser; web.MediaStream? _stream; web.MediaStreamAudioSourceNode? _source; Timer? _timer; Float32List? _buffer; var _noiseFloor = 0.012; var _voiceCeiling = 0.08; var _smoothedLevel = 0.0; bool get isSupported => true; Future start(void Function(VoiceMeterFrame frame) onFrame) async { await stop(); final stream = await web.window.navigator.mediaDevices .getUserMedia( web.MediaStreamConstraints( audio: { 'echoCancellation': true, 'noiseSuppression': true, 'autoGainControl': true, 'channelCount': 1, }.jsify()!, video: false.toJS, ), ) .toDart; final context = web.AudioContext(); if (context.state == 'suspended') { await context.resume().toDart; } final source = context.createMediaStreamSource(stream); final analyser = context.createAnalyser() ..fftSize = 1024 ..smoothingTimeConstant = 0.16; source.connect(analyser); _context = context; _stream = stream; _source = source; _analyser = analyser; _buffer = Float32List(analyser.fftSize); _noiseFloor = 0.012; _voiceCeiling = 0.08; _smoothedLevel = 0.0; _timer = Timer.periodic(const Duration(milliseconds: 45), (_) { final frame = _readFrame(); onFrame(frame); }); } Future stop() async { _timer?.cancel(); _timer = null; _source?.disconnect(); _source = null; final stream = _stream; if (stream != null) { for (final track in stream.getTracks().toDart) { track.stop(); } } _stream = null; _analyser = null; final context = _context; _context = null; if (context != null && context.state != 'closed') { await context.close().toDart; } } void dispose() { _timer?.cancel(); _timer = null; } VoiceMeterFrame _readFrame() { final analyser = _analyser; final buffer = _buffer; if (analyser == null || buffer == null) { return const VoiceMeterFrame(level: 0, samples: []); } analyser.getFloatTimeDomainData(buffer.toJS); var sumSquares = 0.0; var peak = 0.0; for (final sample in buffer) { final centered = sample.abs(); peak = math.max(peak, centered); sumSquares += centered * centered; } final rms = math.sqrt(sumSquares / buffer.length); final rawLevel = math.max(peak * 0.70, rms * 2.8); if (rawLevel < _noiseFloor) { _noiseFloor = _noiseFloor * 0.88 + rawLevel * 0.12; } else { _noiseFloor = _noiseFloor * 0.992 + rawLevel * 0.008; } if (rawLevel > _voiceCeiling) { _voiceCeiling = _voiceCeiling * 0.68 + rawLevel * 0.32; } else { _voiceCeiling = math.max( _noiseFloor + 0.035, _voiceCeiling * 0.992 + rawLevel * 0.008, ); } final range = math.max(0.035, _voiceCeiling - _noiseFloor); final gated = ((rawLevel - _noiseFloor - 0.008) / range).clamp(0.0, 1.0); final level = math.pow(gated, 0.58).toDouble(); _smoothedLevel += (level - _smoothedLevel) * (level > _smoothedLevel ? 0.5 : 0.22); return VoiceMeterFrame( level: _smoothedLevel, samples: _bucketSamples(buffer, _noiseFloor, range), ); } List _bucketSamples( Float32List buffer, double noiseFloor, double range, ) { const buckets = 12; final bucketSize = (buffer.length / buckets).floor(); return List.generate(buckets, (bucket) { final start = bucket * bucketSize; final end = bucket == buckets - 1 ? buffer.length : math.min(start + bucketSize, buffer.length); var peak = 0.0; var sumSquares = 0.0; for (var index = start; index < end; index++) { final sample = buffer[index].abs(); peak = math.max(peak, sample); sumSquares += sample * sample; } final count = math.max(1, end - start); final rms = math.sqrt(sumSquares / count); final raw = math.max(peak * 0.75, rms * 2.8); final normalized = ((raw - noiseFloor) / range).clamp(0.0, 1.0); return (0.08 + math.pow(normalized, 0.58) * 0.92).clamp(0.0, 1.0); }); } }