From d7b419fea60cb3bcea456552f1fe075f07ac2a73 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 13 May 2026 16:01:18 +0700 Subject: [PATCH] Use waveform recorder for voice capture --- lib/audio/browser_voice_meter.dart | 2 - lib/audio/browser_voice_meter_stub.dart | 16 - lib/audio/browser_voice_meter_web.dart | 165 -------- lib/screens/mapflow_shell.dart | 518 +++++++----------------- pubspec.lock | 24 ++ pubspec.yaml | 2 + 6 files changed, 164 insertions(+), 563 deletions(-) delete mode 100644 lib/audio/browser_voice_meter.dart delete mode 100644 lib/audio/browser_voice_meter_stub.dart delete mode 100644 lib/audio/browser_voice_meter_web.dart diff --git a/lib/audio/browser_voice_meter.dart b/lib/audio/browser_voice_meter.dart deleted file mode 100644 index d34dba6..0000000 --- a/lib/audio/browser_voice_meter.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'browser_voice_meter_stub.dart' - if (dart.library.js_interop) 'browser_voice_meter_web.dart'; diff --git a/lib/audio/browser_voice_meter_stub.dart b/lib/audio/browser_voice_meter_stub.dart deleted file mode 100644 index dfc1cee..0000000 --- a/lib/audio/browser_voice_meter_stub.dart +++ /dev/null @@ -1,16 +0,0 @@ -class VoiceMeterFrame { - const VoiceMeterFrame({required this.level, required this.samples}); - - final double level; - final List samples; -} - -class BrowserVoiceMeter { - bool get isSupported => false; - - Future start(void Function(VoiceMeterFrame frame) onFrame) async {} - - Future stop() async {} - - void dispose() {} -} diff --git a/lib/audio/browser_voice_meter_web.dart b/lib/audio/browser_voice_meter_web.dart deleted file mode 100644 index 058a058..0000000 --- a/lib/audio/browser_voice_meter_web.dart +++ /dev/null @@ -1,165 +0,0 @@ -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); - }); - } -} diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 9b561bf..ab55660 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -1,17 +1,16 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart' hide Path; -import 'package:record/record.dart'; +import 'package:waveform_flutter/waveform_flutter.dart'; +import 'package:waveform_recorder/waveform_recorder.dart'; import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; import '../auth/telegram_session.dart' as telegram_session; -import '../audio/browser_voice_meter.dart'; import '../models/place_models.dart'; import '../state/place_controller.dart'; @@ -724,30 +723,27 @@ class _AddExperienceFlowState extends ConsumerState { static const _nearbyPlaceRadiusMeters = 200; final _api = MapflowApi(); - final _recorder = AudioRecorder(); - final _browserVoiceMeter = BrowserVoiceMeter(); - final _waveSamples = List.filled(160, 0.0); - final _visualRandom = math.Random(); + final _waveController = WaveformRecorderController( + interval: const Duration(milliseconds: 45), + config: const RecordConfig( + numChannels: 1, + sampleRate: 44100, + autoGain: true, + echoCancel: true, + noiseSuppress: true, + ), + ); Future>? _nearbyPlacesFuture; - Timer? _timer; - Timer? _visualTimer; - Timer? _amplitudeTimer; - StreamSubscription? _audioStreamSub; + StreamSubscription? _amplitudeSub; var _step = 0; - var _seconds = 0; var _informationUnits = 0.0; var _recording = false; var _submitting = false; var _micAllowed = true; - var _usingBrowserVoiceMeter = false; - var _ambientLevel = 0.008; - var _voiceCeiling = 0.045; var _noiseDb = -72.0; var _voicePeakDb = -34.0; var _liveLevel = 0.0; - var _visualPhase = 0.0; - DateTime? _lastAudioChunkAt; DateTime? _lastInformationAt; @override @@ -757,12 +753,8 @@ class _AddExperienceFlowState extends ConsumerState { @override void dispose() { - _timer?.cancel(); - _visualTimer?.cancel(); - _amplitudeTimer?.cancel(); - _audioStreamSub?.cancel(); - _browserVoiceMeter.dispose(); - _recorder.dispose(); + _amplitudeSub?.cancel(); + _waveController.dispose(); super.dispose(); } @@ -788,179 +780,49 @@ class _AddExperienceFlowState extends ConsumerState { } Future _startRecording() async { - if (_browserVoiceMeter.isSupported) { - await _browserVoiceMeter.start(_handleBrowserVoiceFrame); - _lastAudioChunkAt = DateTime.now(); - _lastInformationAt = _lastAudioChunkAt; - _startIdleWave(); - _startDurationTimer(); - setState(() { - _micAllowed = true; - _recording = true; - _usingBrowserVoiceMeter = true; - _liveLevel = 0; - _informationUnits = 0; - }); - return; - } - - final hasPermission = await _recorder.hasPermission(); - if (!hasPermission) { - setState(() => _micAllowed = false); - return; - } - - final stream = await _recorder.startStream( - const RecordConfig( - encoder: AudioEncoder.pcm16bits, - sampleRate: 44100, - numChannels: 1, - echoCancel: true, - noiseSuppress: true, - autoGain: true, - ), - ); - _audioStreamSub = stream.listen(_handleAudioChunk); - _lastAudioChunkAt = DateTime.now(); - _lastInformationAt = _lastAudioChunkAt; - _startIdleWave(); - _startAmplitudePolling(); - _startDurationTimer(); + await _waveController.startRecording(); + await _amplitudeSub?.cancel(); + _lastInformationAt = DateTime.now(); + _amplitudeSub = _waveController.amplitudeStream.listen(_handleAmplitude); setState(() { _micAllowed = true; _recording = true; - _usingBrowserVoiceMeter = false; _liveLevel = 0; _informationUnits = 0; }); } Future _stopRecording() async { - _timer?.cancel(); - _visualTimer?.cancel(); - _amplitudeTimer?.cancel(); - if (_usingBrowserVoiceMeter) { - await _browserVoiceMeter.stop(); - } else { - await _audioStreamSub?.cancel(); - _audioStreamSub = null; - await _recorder.stop(); - } - _lastAudioChunkAt = null; + await _amplitudeSub?.cancel(); + _amplitudeSub = null; + await _waveController.stopRecording(); _lastInformationAt = null; if (!mounted) { return; } setState(() { _recording = false; - _usingBrowserVoiceMeter = false; _liveLevel = 0; }); } - void _startDurationTimer() { - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) { - return; - } - setState(() => _seconds += 1); - ref - .read(placeControllerProvider.notifier) - .setReviewDuration(Duration(seconds: _seconds)); - }); - } - - void _startIdleWave() { - _visualTimer?.cancel(); - _visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) { - if (!mounted || !_recording) { - return; - } - - final lastSignalAt = _lastAudioChunkAt; - final hasFreshSignal = - lastSignalAt != null && - DateTime.now().difference(lastSignalAt).inMilliseconds < 220; - if (hasFreshSignal) { - return; - } - - _visualPhase += 0.24; - final idleSamples = List.generate(8, (index) { - final wave = math.sin(_visualPhase + index * 0.72) * 0.5 + 0.5; - final noise = _visualRandom.nextDouble() * 0.16; - return (0.10 + wave * 0.18 + noise).clamp(0.0, 1.0); - }); - setState(() { - _waveSamples - ..removeRange(0, idleSamples.length) - ..addAll(idleSamples); - }); - }); - } - - void _startAmplitudePolling() { - _amplitudeTimer?.cancel(); - _amplitudeTimer = Timer.periodic(const Duration(milliseconds: 90), ( - _, - ) async { - if (!mounted || !_recording) { - return; - } - - final amplitude = await _recorder.getAmplitude(); - if (!mounted || !_recording) { - return; - } - - _handleAmplitude(amplitude.current); - }); - } - - void _handleAmplitude(double currentDb) { + void _handleAmplitude(Amplitude amplitude) { + final currentDb = amplitude.current; final now = DateTime.now(); final level = _normalizeDbLevel(currentDb); final informationDelta = _consumeInformationDelta(level, now); - _visualPhase += 0.38; - final samples = List.generate(8, (index) { - final wave = math.sin(_visualPhase + index * 0.68) * 0.5 + 0.5; - return (0.08 + level * 0.70 + wave * level * 0.24).clamp(0.0, 1.0); - }); setState(() { - _waveSamples - ..removeRange(0, samples.length) - ..addAll(samples); _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, ); }); - } - - void _handleBrowserVoiceFrame(VoiceMeterFrame frame) { - if (!mounted || !_recording) { - return; - } - - final now = DateTime.now(); - final informationDelta = _consumeInformationDelta(frame.level, now); - _lastAudioChunkAt = now; - setState(() { - if (frame.samples.isNotEmpty) { - _waveSamples - ..removeRange(0, frame.samples.length) - ..addAll(frame.samples); - } - _liveLevel = _smoothLevel(_liveLevel, frame.level); - _informationUnits = math.min( - _minimumInformationUnits, - _informationUnits + informationDelta, - ); - }); + ref + .read(placeControllerProvider.notifier) + .setReviewDuration(_waveController.timeElapsed); } double _normalizeDbLevel(double currentDb) { @@ -994,88 +856,6 @@ class _AddExperienceFlowState extends ConsumerState { return voicedAmount.clamp(0.0, 1.0) * deltaSeconds; } - void _handleAudioChunk(Uint8List chunk) { - if (chunk.length < 2) { - return; - } - - final bytes = ByteData.sublistView(chunk); - final sampleCount = chunk.length ~/ 2; - final bucketCount = 12.clamp(1, sampleCount); - final bucketSize = (sampleCount / bucketCount).ceil(); - final peaks = []; - var totalRms = 0.0; - for (var bucket = 0; bucket < bucketCount; bucket++) { - final startSample = bucket * bucketSize; - final endSample = math.min(startSample + bucketSize, sampleCount); - var peak = 0.0; - var sumSquares = 0.0; - var bucketSamples = 0; - for ( - var sampleIndex = startSample; - sampleIndex < endSample; - sampleIndex++ - ) { - final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0; - final absoluteSample = sample.abs(); - peak = math.max(peak, absoluteSample); - sumSquares += absoluteSample * absoluteSample; - bucketSamples += 1; - } - final rms = bucketSamples == 0 - ? 0.0 - : math.sqrt(sumSquares / bucketSamples); - totalRms += rms; - peaks.add(math.max(peak, rms * 2.6)); - } - if (peaks.isEmpty) { - return; - } - - final now = DateTime.now(); - final previousChunkAt = _lastAudioChunkAt ?? now; - _lastAudioChunkAt = now; - final currentRms = totalRms / peaks.length; - _ambientLevel = currentRms < _ambientLevel - ? (_ambientLevel * 0.86 + currentRms * 0.14) - : (_ambientLevel * 0.985 + currentRms * 0.015); - _voiceCeiling = currentRms > _voiceCeiling - ? (_voiceCeiling * 0.68 + currentRms * 0.32) - : math.max(_ambientLevel + 0.018, _voiceCeiling * 0.992); - final dynamicRange = math.max(0.012, _voiceCeiling - _ambientLevel); - final normalizedPeaks = peaks - .map((peak) => ((peak - _ambientLevel) / dynamicRange).clamp(0.0, 1.0)) - .map((peak) => math.pow(peak, 0.62).toDouble()) - .toList(); - final voicedAmount = - normalizedPeaks.fold(0, (sum, value) => sum + value) / - normalizedPeaks.length; - final hasRecentAmplitude = - now.difference(previousChunkAt).inMilliseconds < 120; - final informationDelta = hasRecentAmplitude - ? 0.0 - : _consumeInformationDelta(voicedAmount, now); - - if (!mounted) { - return; - } - setState(() { - _waveSamples - ..removeRange(0, normalizedPeaks.length) - ..addAll( - normalizedPeaks.map((peak) { - final visibleSignal = 0.12 + peak * 0.88; - return visibleSignal.clamp(0.0, 1.0); - }), - ); - _liveLevel = _smoothLevel(_liveLevel, voicedAmount); - _informationUnits = math.min( - _minimumInformationUnits, - _informationUnits + informationDelta, - ); - }); - } - @override Widget build(BuildContext context) { final controller = ref.read(placeControllerProvider.notifier); @@ -1090,7 +870,7 @@ class _AddExperienceFlowState extends ConsumerState { isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, - samples: _waveSamples, + waveController: _waveController, liveLevel: _liveLevel, canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, @@ -1289,7 +1069,7 @@ class _VoiceStep extends StatelessWidget { required this.isRecording, required this.isSubmitting, required this.micAllowed, - required this.samples, + required this.waveController, required this.liveLevel, required this.canContinue, required this.onToggleRecording, @@ -1302,7 +1082,7 @@ class _VoiceStep extends StatelessWidget { final bool isRecording; final bool isSubmitting; final bool micAllowed; - final List samples; + final WaveformRecorderController waveController; final double liveLevel; final bool canContinue; final Future Function() onToggleRecording; @@ -1328,8 +1108,8 @@ class _VoiceStep extends StatelessWidget { ], Expanded( child: Center( - child: _VoiceInformationField( - samples: samples, + child: _LibraryWaveSurface( + controller: waveController, active: isRecording, progress: informationProgress, liveLevel: liveLevel, @@ -1505,15 +1285,15 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> } } -class _VoiceInformationField extends StatelessWidget { - const _VoiceInformationField({ - required this.samples, +class _LibraryWaveSurface extends StatelessWidget { + const _LibraryWaveSurface({ + required this.controller, required this.active, required this.progress, required this.liveLevel, }); - final List samples; + final WaveformRecorderController controller; final bool active; final double progress; final double liveLevel; @@ -1523,166 +1303,144 @@ class _VoiceInformationField extends StatelessWidget { return Stack( alignment: Alignment.center, children: [ - Container( - width: 220, - height: 220, + DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFF0C0718), boxShadow: [ BoxShadow( color: const Color( 0xFFFF2D75, - ).withValues(alpha: active ? 0.18 : 0.08), - blurRadius: 90, - spreadRadius: 18, + ).withValues(alpha: active ? 0.20 + liveLevel * 0.18 : 0.06), + blurRadius: 110, + spreadRadius: 24, ), BoxShadow( color: const Color( 0xFF38F5D3, - ).withValues(alpha: active ? 0.12 : 0.05), - blurRadius: 120, + ).withValues(alpha: active ? 0.10 + liveLevel * 0.12 : 0.04), + blurRadius: 130, spreadRadius: 14, ), ], ), + child: const SizedBox.square(dimension: 210), ), - LayoutBuilder( - builder: (context, constraints) { - return CustomPaint( - size: Size( - constraints.maxWidth, - constraints.maxHeight.clamp(280.0, 520.0), - ), - painter: _VoiceInformationPainter( - samples: samples, - active: active, - progress: progress, - liveLevel: liveLevel, - ), - ); - }, + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: active + ? AnimatedWaveList( + key: ValueKey(controller.startTime), + stream: controller.amplitudeStream, + barBuilder: (animation, amplitude) => _VoiceWaveBar( + animation: animation, + amplitude: amplitude, + progress: progress, + ), + ) + : _IdleWaveBars(progress: progress), + ), ), ], ); } } -class _VoiceInformationPainter extends CustomPainter { - const _VoiceInformationPainter({ - required this.samples, - required this.active, +class _VoiceWaveBar extends StatelessWidget { + const _VoiceWaveBar({ + required this.animation, + required this.amplitude, required this.progress, - required this.liveLevel, }); - final List samples; - final bool active; + final Animation animation; + final Amplitude amplitude; final double progress; - final double liveLevel; @override - void paint(Canvas canvas, Size size) { - const columns = 18; - const rows = 12; - const gap = 6.0; - final cellSize = math - .min( - (size.width - gap * (columns - 1)) / columns, - (size.height - gap * (rows - 1)) / rows, - ) - .clamp(8.0, 22.0); - final cellWidth = cellSize; - final cellHeight = cellSize; - final gridWidth = columns * cellWidth + (columns - 1) * gap; - final gridHeight = rows * cellHeight + (rows - 1) * gap; - final startX = (size.width - gridWidth) / 2; - final startY = (size.height - gridHeight) / 2; - final activeCells = (progress * columns * rows).round(); + Widget build(BuildContext context) { + final level = _amplitudeLevel(amplitude.current); + final height = 14 + level * 210; + final color = Color.lerp( + Colors.white.withValues(alpha: 0.28), + const Color(0xFFFF2D75), + progress.clamp(0.0, 1.0), + )!; - final backgroundPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.105) - ..style = PaintingStyle.fill; - final borderPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.045) - ..style = PaintingStyle.stroke - ..strokeWidth = 1; - final glowPaint = Paint() - ..color = const Color(0xFFE11D48).withValues(alpha: active ? 0.18 : 0.08) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); - - for (var row = 0; row < rows; row++) { - for (var column = 0; column < columns; column++) { - final cellIndex = row * columns + column; - final sampleIndex = samples.isEmpty - ? 0 - : ((cellIndex / (columns * rows - 1)) * (samples.length - 1)) - .round(); - final signal = samples.isEmpty - ? 0.0 - : samples[sampleIndex].clamp(0.0, 1.0); - final fillOrder = _cellOrder(cellIndex, columns * rows); - final filled = fillOrder < activeCells; - final x = startX + column * (cellWidth + gap); - final y = startY + row * (cellHeight + gap); - final rect = RRect.fromRectAndRadius( - Rect.fromLTWH(x, y, cellWidth, cellHeight), - const Radius.circular(4), - ); - - canvas.drawRRect(rect, backgroundPaint); - canvas.drawRRect(rect, borderPaint); - - final centerDistance = (row - (rows - 1) / 2).abs(); - final columnSignal = (signal * 0.78 + liveLevel * 0.22).clamp(0.0, 1.0); - final waveReach = 0.45 + columnSignal * rows * 0.44; - final hashLift = _hashUnit(cellIndex) * 0.55; - final inWave = centerDistance <= waveReach + hashLift; - if (inWave) { - final waveAlpha = active - ? 0.10 + columnSignal * 0.18 - : 0.08 + columnSignal * 0.06; - final wavePaint = Paint() - ..color = Colors.white.withValues(alpha: waveAlpha) - ..style = PaintingStyle.fill; - canvas.drawRRect(rect, wavePaint); - } - - if (filled) { - final paint = Paint() - ..shader = LinearGradient( + return SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: Align( + alignment: Alignment.center, + child: Container( + width: 5, + height: height, + margin: const EdgeInsets.symmetric(horizontal: 2.5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, colors: [ - const Color( - 0xFFE11D48, - ).withValues(alpha: 0.78 + columnSignal * 0.18), - const Color( - 0xFFFF6B8A, - ).withValues(alpha: 0.58 + columnSignal * 0.18), + color.withValues(alpha: 0.42), + color, + const Color(0xFFFF7A90), ], - ).createShader(rect.outerRect) - ..style = PaintingStyle.fill; - canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint); - canvas.drawRRect(rect, paint); - } - } - } + ), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.38), + blurRadius: 18, + spreadRadius: 1, + ), + ], + ), + ), + ), + ); } - int _cellOrder(int index, int total) { - return (((index + 11) * 73) % total).toInt(); + double _amplitudeLevel(double db) { + final normalized = ((db.clamp(-76.0, -6.0) + 76.0) / 70.0).clamp(0.0, 1.0); + return math.pow(normalized, 0.62).toDouble(); } +} - double _hashUnit(int index) { - final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; - return value - value.floorToDouble(); - } +class _IdleWaveBars extends StatelessWidget { + const _IdleWaveBars({required this.progress}); + + final double progress; @override - bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { - return oldDelegate.samples != samples || - oldDelegate.active != active || - oldDelegate.progress != progress || - oldDelegate.liveLevel != liveLevel; + Widget build(BuildContext context) { + final color = Color.lerp( + Colors.white.withValues(alpha: 0.18), + const Color(0xFFFF2D75), + progress.clamp(0.0, 1.0), + )!; + + return Center( + child: SizedBox( + height: 220, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(26, (index) { + final distance = (index - 12.5).abs(); + final height = 18 + math.max(0.0, 1 - distance / 13) * 56; + return Container( + width: 4, + height: height, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(20), + ), + ); + }), + ), + ), + ); } } diff --git a/pubspec.lock b/pubspec.lock index c5e2374..8599621 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -877,6 +885,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + waveform_flutter: + dependency: "direct main" + description: + name: waveform_flutter + sha256: "08c9e98d4cf119428d8b3c083ed42c11c468623eaffdf30420ae38e36662922a" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + waveform_recorder: + dependency: "direct main" + description: + name: waveform_recorder + sha256: "1ca0a19b143d1bdef2adfb3d28f0627c18aee5285235c8cf81a89bf29a0420e1" + url: "https://pub.dev" + source: hosted + version: "1.8.0" web: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8ec8757..9828e1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: web: ^1.1.1 geolocator: ^14.0.2 record: ^6.2.0 + waveform_recorder: ^1.8.0 + waveform_flutter: ^1.2.0 dev_dependencies: flutter_test: