Add live microphone waveform
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user