Use waveform recorder for voice capture
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m26s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m26s
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
export 'browser_voice_meter_stub.dart'
|
||||
if (dart.library.js_interop) 'browser_voice_meter_web.dart';
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
pubspec.lock
24
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user