Add live microphone waveform
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s

This commit is contained in:
Ruslan Bakiev
2026-05-09 14:08:27 +07:00
parent 56703c887f
commit b819b51c1f
9 changed files with 268 additions and 55 deletions

View File

@@ -1,10 +1,12 @@
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';
import 'package:latlong2/latlong.dart' hide Path;
import 'package:record/record.dart';
import '../api/mapflow_api.dart';
import '../auth/telegram_login_button.dart';
@@ -719,11 +721,16 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30;
final _recorder = AudioRecorder();
final _waveSamples = List<double>.filled(64, 0.04);
Timer? _timer;
StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0;
var _seconds = 0;
var _recording = false;
var _submitting = false;
var _micAllowed = true;
PlaceRecommendation? _selectedPlace;
@override
@@ -734,24 +741,89 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override
void dispose() {
_timer?.cancel();
_audioStreamSub?.cancel();
_recorder.dispose();
super.dispose();
}
void _toggleRecording() {
setState(() => _recording = !_recording);
Future<void> _toggleRecording() async {
if (_recording) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
setState(() => _seconds += 1);
ref
.read(placeControllerProvider.notifier)
.setReviewDuration(Duration(seconds: _seconds));
});
} else {
_timer?.cancel();
await _stopRecording();
return;
}
await _startRecording();
}
Future<void> _startRecording() async {
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);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
setState(() => _seconds += 1);
ref
.read(placeControllerProvider.notifier)
.setReviewDuration(Duration(seconds: _seconds));
});
setState(() {
_micAllowed = true;
_recording = true;
});
}
Future<void> _stopRecording() async {
_timer?.cancel();
await _audioStreamSub?.cancel();
_audioStreamSub = null;
await _recorder.stop();
if (!mounted) {
return;
}
setState(() => _recording = false);
}
void _handleAudioChunk(Uint8List chunk) {
if (chunk.length < 2) {
return;
}
final bytes = ByteData.sublistView(chunk);
var sum = 0.0;
var count = 0;
for (var index = 0; index + 1 < chunk.length; index += 2) {
final sample = bytes.getInt16(index, Endian.little) / 32768.0;
sum += sample * sample;
count += 1;
}
final rms = count == 0 ? 0.0 : math.sqrt(sum / count);
final level = (rms * 7.5).clamp(0.03, 1.0).toDouble();
if (!mounted) {
return;
}
setState(() {
_waveSamples
..removeAt(0)
..add(level);
});
}
String get _time =>
@@ -783,6 +855,8 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
time: _time,
isRecording: _recording,
isSubmitting: _submitting,
micAllowed: _micAllowed,
samples: _waveSamples,
canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording,
onNext: () async {
@@ -790,6 +864,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
if (selectedPlace == null) {
return;
}
if (_recording) {
await _stopRecording();
}
setState(() => _submitting = true);
controller.setReviewPlace(selectedPlace.name);
await controller.publishReview(coordinate: selectedPlace.coordinate);
@@ -935,6 +1012,8 @@ class _VoiceStep extends StatelessWidget {
required this.time,
required this.isRecording,
required this.isSubmitting,
required this.micAllowed,
required this.samples,
required this.canContinue,
required this.onToggleRecording,
required this.onNext,
@@ -947,8 +1026,10 @@ class _VoiceStep extends StatelessWidget {
final String time;
final bool isRecording;
final bool isSubmitting;
final bool micAllowed;
final List<double> samples;
final bool canContinue;
final VoidCallback onToggleRecording;
final Future<void> Function() onToggleRecording;
final VoidCallback onNext;
@override
@@ -965,26 +1046,17 @@ class _VoiceStep extends StatelessWidget {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
Text(
hasTelegramAuth
? 'Минимум $minimumSeconds секунд'
: 'Открой через Telegram',
textAlign: TextAlign.center,
),
Text(hasTelegramAuth ? 'Минимум $minimumSeconds секунд' : ''),
const SizedBox(height: 26),
if (isRecording || seconds > 0) ...[
_VoiceWave(seconds: seconds, active: isRecording),
const SizedBox(height: 20),
] else ...[
const SizedBox(height: 70),
],
_VoiceWave(samples: samples, active: isRecording),
const SizedBox(height: 24),
SizedBox(
width: 132,
height: 132,
child: FilledButton(
onPressed: isSubmitting || !hasTelegramAuth
? null
: onToggleRecording,
: () => onToggleRecording(),
style: FilledButton.styleFrom(shape: const CircleBorder()),
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
),
@@ -1000,6 +1072,10 @@ class _VoiceStep extends StatelessWidget {
LinearProgressIndicator(
value: (seconds / minimumSeconds).clamp(0.0, 1.0),
),
if (!micAllowed) ...[
const SizedBox(height: 12),
const Icon(Icons.mic_off_outlined, size: 22),
],
const Spacer(),
],
),
@@ -1012,45 +1088,106 @@ class _VoiceStep extends StatelessWidget {
}
class _VoiceWave extends StatelessWidget {
const _VoiceWave({required this.seconds, required this.active});
const _VoiceWave({required this.samples, required this.active});
final int seconds;
final List<double> samples;
final bool active;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return SizedBox(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < 23; index++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: 4,
height: _barHeight(index),
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: active ? 0.88 : 0.42),
borderRadius: BorderRadius.circular(8),
),
),
],
height: 112,
width: double.infinity,
child: CustomPaint(
painter: _VoiceWavePainter(
samples: samples,
active: active,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}
double _barHeight(int index) {
final phase = seconds * 0.72 + index * 0.58;
final height = 16.0 + (math.sin(phase).abs() * 28.0);
if (active) {
return height;
class _VoiceWavePainter extends CustomPainter {
const _VoiceWavePainter({
required this.samples,
required this.active,
required this.color,
});
final List<double> samples;
final bool active;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final top = <Offset>[];
final bottom = <Offset>[];
for (var index = 0; index < samples.length; index++) {
final x = samples.length == 1
? 0.0
: index / (samples.length - 1) * size.width;
final envelope = math.sin(index / (samples.length - 1) * math.pi);
final amplitude = samples[index] * envelope * size.height * 0.46;
final yTop = centerY - amplitude;
final yBottom = centerY + amplitude;
top.add(Offset(x, yTop));
bottom.add(Offset(x, yBottom));
}
return height * 0.55;
final topPath = Path()..addPolygon(top, false);
final bottomPath = Path()..addPolygon(bottom, false);
final shape = Path()
..addPolygon([...top, ...bottom.reversed], true)
..close();
final glow = Paint()
..color = color.withValues(alpha: active ? 0.18 : 0.08)
..strokeWidth = 18
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10);
final stroke = Paint()
..shader = LinearGradient(
colors: [
color.withValues(alpha: active ? 0.35 : 0.16),
color.withValues(alpha: active ? 0.95 : 0.42),
const Color(0xFFE11D48).withValues(alpha: active ? 0.86 : 0.34),
],
).createShader(Offset.zero & size)
..strokeWidth = 5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final fill = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: active ? 0.20 : 0.08),
const Color(0xFFE11D48).withValues(alpha: active ? 0.14 : 0.05),
],
).createShader(Offset.zero & size)
..style = PaintingStyle.fill;
canvas.drawPath(shape, fill);
canvas.drawPath(topPath, glow);
canvas.drawPath(bottomPath, glow);
canvas.drawPath(topPath, stroke);
canvas.drawPath(bottomPath, stroke);
canvas.drawCircle(
Offset(size.width / 2, centerY),
active ? 3.4 : 2.2,
Paint()..color = color.withValues(alpha: active ? 0.82 : 0.36),
);
}
@override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) {
return true;
}
}