Use real PCM voice waveform
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m57s

This commit is contained in:
Ruslan Bakiev
2026-05-09 17:41:34 +07:00
parent f9d6e4fa5b
commit 6055a101e8
3 changed files with 137 additions and 66 deletions

View File

@@ -7,7 +7,6 @@ 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:siri_wave/siri_wave.dart';
import '../api/mapflow_api.dart';
import '../auth/telegram_login_button.dart';
@@ -725,7 +724,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
final _api = MapflowApi();
final _recorder = AudioRecorder();
final _waveSamples = List<double>.filled(64, 0.04);
final _waveSamples = List<double>.filled(160, 0.0);
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
Timer? _timer;
@@ -821,23 +820,35 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
}
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 sampleCount = chunk.length ~/ 2;
final bucketCount = 10.clamp(1, sampleCount);
final bucketSize = (sampleCount / bucketCount).ceil();
final peaks = <double>[];
for (var bucket = 0; bucket < bucketCount; bucket++) {
final startSample = bucket * bucketSize;
final endSample = math.min(startSample + bucketSize, sampleCount);
var peak = 0.0;
for (
var sampleIndex = startSample;
sampleIndex < endSample;
sampleIndex++
) {
final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0;
peak = math.max(peak, sample.abs());
}
peaks.add((peak * 3.4).clamp(0.0, 1.0));
}
if (peaks.isEmpty) {
return;
}
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);
..removeRange(0, peaks.length)
..addAll(peaks);
});
}
@@ -1279,7 +1290,7 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
}
}
class _VoiceWave extends StatefulWidget {
class _VoiceWave extends StatelessWidget {
const _VoiceWave({
required this.samples,
required this.active,
@@ -1290,42 +1301,6 @@ class _VoiceWave extends StatefulWidget {
final bool active;
final double progress;
@override
State<_VoiceWave> createState() => _VoiceWaveState();
}
class _VoiceWaveState extends State<_VoiceWave> {
late final IOS9SiriWaveformController _controller;
@override
void initState() {
super.initState();
_controller = IOS9SiriWaveformController(
amplitude: 0.12,
speed: 0.08,
color1: const Color(0xFFFF2D75),
color2: const Color(0xFF38F5D3),
color3: const Color(0xFF7C5CFF),
);
_syncController();
}
@override
void didUpdateWidget(covariant _VoiceWave oldWidget) {
super.didUpdateWidget(oldWidget);
_syncController();
}
void _syncController() {
final lastLevel = widget.samples.isEmpty ? 0.04 : widget.samples.last;
_controller.amplitude = widget.active
? (0.18 + lastLevel * 0.78).clamp(0.0, 1.0)
: (0.10 + widget.progress * 0.12).clamp(0.0, 1.0);
_controller.speed = widget.active
? (0.10 + lastLevel * 0.36).clamp(0.0, 1.0)
: 0.035;
}
@override
Widget build(BuildContext context) {
return Stack(
@@ -1341,14 +1316,14 @@ class _VoiceWaveState extends State<_VoiceWave> {
BoxShadow(
color: const Color(
0xFFFF2D75,
).withValues(alpha: widget.active ? 0.18 : 0.08),
).withValues(alpha: active ? 0.18 : 0.08),
blurRadius: 90,
spreadRadius: 18,
),
BoxShadow(
color: const Color(
0xFF38F5D3,
).withValues(alpha: widget.active ? 0.12 : 0.05),
).withValues(alpha: active ? 0.12 : 0.05),
blurRadius: 120,
spreadRadius: 14,
),
@@ -1357,12 +1332,15 @@ class _VoiceWaveState extends State<_VoiceWave> {
),
LayoutBuilder(
builder: (context, constraints) {
return SiriWaveform.ios9(
controller: _controller,
options: IOS9SiriWaveformOptions(
width: constraints.maxWidth,
height: constraints.maxHeight.clamp(280.0, 520.0),
showSupportBar: false,
return CustomPaint(
size: Size(
constraints.maxWidth,
constraints.maxHeight.clamp(280.0, 520.0),
),
painter: _VoiceWavePainter(
samples: samples,
active: active,
progress: progress,
),
);
},
@@ -1372,6 +1350,108 @@ class _VoiceWaveState extends State<_VoiceWave> {
}
}
class _VoiceWavePainter extends CustomPainter {
const _VoiceWavePainter({
required this.samples,
required this.active,
required this.progress,
});
final List<double> samples;
final bool active;
final double progress;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final widthStep = samples.length <= 1
? size.width
: size.width / (samples.length - 1);
final maxHeight = size.height * 0.46;
final idleHeight = maxHeight * (0.03 + progress * 0.06);
final fillPath = Path();
final topPath = Path();
final bottomPath = Path();
final denominator = math.max(samples.length - 1, 1);
for (var index = 0; index < samples.length; index++) {
final x = index * widthStep;
final envelope = math.sin(index / denominator * math.pi);
final raw = samples[index].clamp(0.0, 1.0);
final height = active
? math.max(raw * envelope * maxHeight, idleHeight)
: idleHeight * envelope;
final top = Offset(x, centerY - height);
final bottom = Offset(x, centerY + height);
if (index == 0) {
topPath.moveTo(top.dx, top.dy);
bottomPath.moveTo(bottom.dx, bottom.dy);
fillPath.moveTo(top.dx, top.dy);
} else {
topPath.lineTo(top.dx, top.dy);
bottomPath.lineTo(bottom.dx, bottom.dy);
fillPath.lineTo(top.dx, top.dy);
}
}
for (var index = samples.length - 1; index >= 0; index--) {
final x = index * widthStep;
final envelope = math.sin(index / denominator * math.pi);
final raw = samples[index].clamp(0.0, 1.0);
final height = active
? math.max(raw * envelope * maxHeight, idleHeight)
: idleHeight * envelope;
fillPath.lineTo(x, centerY + height);
}
fillPath.close();
final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.20 : 0.08)
..strokeWidth = 16
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14);
final strokePaint = Paint()
..shader = const LinearGradient(
colors: [
Color(0xFF38F5D3),
Color(0xFFFFFFFF),
Color(0xFFFF2D75),
Color(0xFF7C5CFF),
],
).createShader(Offset.zero & size)
..strokeWidth = 4.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF38F5D3).withValues(alpha: active ? 0.12 : 0.04),
const Color(0xFFFF2D75).withValues(alpha: active ? 0.16 : 0.05),
],
).createShader(Offset.zero & size)
..style = PaintingStyle.fill;
canvas.drawPath(fillPath, fillPaint);
canvas.drawPath(topPath, glowPaint);
canvas.drawPath(bottomPath, glowPaint);
canvas.drawPath(topPath, strokePaint);
canvas.drawPath(bottomPath, strokePaint);
}
@override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) {
return oldDelegate.samples != samples ||
oldDelegate.active != active ||
oldDelegate.progress != progress;
}
}
class _StepLayout extends StatelessWidget {
const _StepLayout({required this.body, this.action});

View File

@@ -736,14 +736,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.4"
siri_wave:
dependency: "direct main"
description:
name: siri_wave
sha256: ea815d6627dc297f6be883bb0dd7a579a5f5f9729242d47c10e95850cccf169a
url: "https://pub.dev"
source: hosted
version: "2.3.1"
sky_engine:
dependency: transitive
description: flutter

View File

@@ -41,7 +41,6 @@ dependencies:
web: ^1.1.1
geolocator: ^14.0.2
record: ^6.2.0
siri_wave: ^2.3.1
dev_dependencies:
flutter_test: