Use waveform recorder for voice capture
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m26s

This commit is contained in:
Ruslan Bakiev
2026-05-13 16:01:18 +07:00
parent 4a2e458a01
commit d7b419fea6
6 changed files with 164 additions and 563 deletions

View File

@@ -1,2 +0,0 @@
export 'browser_voice_meter_stub.dart'
if (dart.library.js_interop) 'browser_voice_meter_web.dart';

View File

@@ -1,16 +0,0 @@
class VoiceMeterFrame {
const VoiceMeterFrame({required this.level, required this.samples});
final double level;
final List<double> samples;
}
class BrowserVoiceMeter {
bool get isSupported => false;
Future<void> start(void Function(VoiceMeterFrame frame) onFrame) async {}
Future<void> stop() async {}
void dispose() {}
}

View File

@@ -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<double> 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<void> 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<void> 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<double> _bucketSamples(
Float32List buffer,
double noiseFloor,
double range,
) {
const buckets = 12;
final bucketSize = (buffer.length / buckets).floor();
return List<double>.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);
});
}
}

View File

@@ -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<AddExperienceFlow> {
static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi();
final _recorder = AudioRecorder();
final _browserVoiceMeter = BrowserVoiceMeter();
final _waveSamples = List<double>.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<List<PlaceRecommendation>>? _nearbyPlacesFuture;
Timer? _timer;
Timer? _visualTimer;
Timer? _amplitudeTimer;
StreamSubscription<Uint8List>? _audioStreamSub;
StreamSubscription<Amplitude>? _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<AddExperienceFlow> {
@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<AddExperienceFlow> {
}
Future<void> _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<void> _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<double>.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<double>.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<AddExperienceFlow> {
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 = <double>[];
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<double>(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<AddExperienceFlow> {
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<double> samples;
final WaveformRecorderController waveController;
final double liveLevel;
final bool canContinue;
final Future<void> 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<double> 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<double> samples;
final bool active;
final Animation<double> 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),
),
);
}),
),
),
);
}
}

View File

@@ -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:

View File

@@ -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: