audio_stream_player 1.1.0
audio_stream_player: ^1.1.0 copied to clipboard
Low-latency PCM audio streaming player. Feed raw audio chunks from TTS or realtime voice APIs and hear them as they arrive, with buffer introspection and underrun events.
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:audio_stream_player/audio_stream_player.dart';
import 'package:flutter/material.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'audio_stream_player example',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const DemoPage(),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
static const sampleRate = 24000;
AudioStreamPlayer? _player;
StreamSubscription<PlayerState>? _stateSub;
StreamSubscription<void>? _underrunSub;
Timer? _gaugeTimer;
PlayerState _state = PlayerState.idle;
Duration _buffered = Duration.zero;
int _underruns = 0;
double _volume = 1.0;
bool _streaming = false;
bool _simulateNetworkJitter = true;
final List<String> _log = [];
@override
void initState() {
super.initState();
_createPlayer();
}
Future<void> _createPlayer() async {
final player = await AudioStreamPlayer.create(sampleRate: sampleRate);
_stateSub = player.onStateChanged.listen((state) {
setState(() => _state = state);
_addLog('state → ${state.name}');
});
_underrunSub = player.onUnderrun.listen((_) {
setState(() => _underruns++);
_addLog('underrun (buffer starved)');
});
_gaugeTimer = Timer.periodic(const Duration(milliseconds: 100), (_) async {
final p = _player;
if (p == null) return;
final buffered = await p.bufferedDuration();
if (mounted) setState(() => _buffered = buffered);
});
setState(() => _player = player);
}
/// Feeds a synthesized utterance in small chunks with network-like jitter,
/// exactly the way bytes trickle in from a streaming TTS API.
Future<void> _streamUtterance() async {
final player = _player;
if (player == null || _streaming) return;
setState(() => _streaming = true);
_addLog('streaming utterance...');
try {
await player.play();
final random = Random();
for (final chunk in _synthesizeUtterance(random)) {
await player.feed(chunk);
if (_simulateNetworkJitter) {
// Chunks carry ~100ms of audio but arrive every 50-120ms, so the
// buffer slowly builds; an occasional 400ms hiccup drains it and
// demonstrates underrun recovery.
final hiccup = random.nextInt(10) == 0;
await Future<void>.delayed(
Duration(milliseconds: hiccup ? 400 : 50 + random.nextInt(70)),
);
}
if (!_streaming) return; // stopped mid-stream
}
_addLog('end of stream, awaiting drain...');
await player.endOfStream();
_addLog('drained: utterance fully played');
} finally {
if (mounted) setState(() => _streaming = false);
}
}
/// Robot-speech synthesis: pitched tone bursts with speech-like cadence,
/// enveloped to avoid clicks, as ~100ms s16le chunks.
Iterable<Uint8List> _synthesizeUtterance(Random random) sync* {
const chunkFrames = sampleRate ~/ 10;
final pentatonic = [220.0, 247.5, 277.2, 330.0, 370.0];
final syllables = 6 + random.nextInt(6);
final samples = <double>[];
for (var s = 0; s < syllables; s++) {
final f = pentatonic[random.nextInt(pentatonic.length)];
final durMs = 120 + random.nextInt(180);
final n = sampleRate * durMs ~/ 1000;
for (var i = 0; i < n; i++) {
final t = i / sampleRate;
final envelope = sin(pi * i / n); // smooth attack/decay
final vibrato = 1 + 0.01 * sin(2 * pi * 6 * t);
samples.add(
0.5 *
envelope *
(sin(2 * pi * f * vibrato * t) +
0.4 * sin(2 * pi * 2 * f * t) +
0.15 * sin(2 * pi * 3 * f * t)),
);
}
// Inter-syllable gap.
samples.addAll(
List.filled(sampleRate * (30 + random.nextInt(70)) ~/ 1000, 0.0),
);
}
for (var offset = 0; offset < samples.length; offset += chunkFrames) {
final end = min(offset + chunkFrames, samples.length);
final bytes = ByteData(2 * (end - offset));
for (var i = offset; i < end; i++) {
bytes.setInt16(
2 * (i - offset),
(samples[i] * 32767).round(),
Endian.little,
);
}
yield bytes.buffer.asUint8List();
}
}
Future<void> _stop() async {
setState(() => _streaming = false);
await _player?.stop();
}
void _addLog(String message) {
setState(() {
_log.insert(0, message);
if (_log.length > 30) _log.removeLast();
});
}
@override
void dispose() {
_gaugeTimer?.cancel();
_stateSub?.cancel();
_underrunSub?.cancel();
_player?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final player = _player;
return Scaffold(
appBar: AppBar(title: const Text('audio_stream_player')),
body: player == null
? const Center(child: CircularProgressIndicator())
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Chip(label: Text(_state.name)),
Text(
'buffered: '
'${(_buffered.inMilliseconds / 1000).toStringAsFixed(2)}s',
),
Text('underruns: $_underruns'),
],
),
const SizedBox(height: 8),
_BufferWave(
amplitude: min(_buffered.inMilliseconds / 300, 1),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _streaming ? null : _streamUtterance,
icon: const Icon(Icons.record_voice_over),
label: const Text('Stream utterance'),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: _state == PlayerState.playing
? () => player.pause()
: _state == PlayerState.paused
? () => player.play()
: null,
icon: Icon(
_state == PlayerState.playing
? Icons.pause
: Icons.play_arrow,
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: _state == PlayerState.idle ? null : _stop,
icon: const Icon(Icons.stop),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Simulate network jitter'),
value: _simulateNetworkJitter,
onChanged: (v) =>
setState(() => _simulateNetworkJitter = v),
),
Row(
children: [
const Icon(Icons.volume_up, size: 20),
Expanded(
child: Slider(
value: _volume,
onChanged: (v) {
setState(() => _volume = v);
player.setVolume(v);
},
),
),
],
),
const Divider(),
Expanded(
child: ListView.builder(
itemCount: _log.length,
itemBuilder: (context, i) => Text(
_log[i],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
],
),
),
);
}
}
/// Buffered-audio gauge drawn as a travelling wave: flat when the buffer is
/// empty, full height at 300ms buffered.
class _BufferWave extends StatefulWidget {
const _BufferWave({required this.amplitude});
/// Buffer fill fraction, 0..1.
final double amplitude;
@override
State<_BufferWave> createState() => _BufferWaveState();
}
class _BufferWaveState extends State<_BufferWave>
with SingleTickerProviderStateMixin {
late final AnimationController _phase = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
@override
void dispose() {
_phase.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.amplitude),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, amplitude, _) => SizedBox(
height: 48,
// Without the boundary every wave frame repaints the whole page,
// which is enough jank to starve the feed loop on debug builds.
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _phase,
builder: (context, _) => CustomPaint(
size: Size.infinite,
painter: _WavePainter(
phase: _phase.value,
amplitude: amplitude,
color: color,
),
),
),
),
),
);
}
}
class _WavePainter extends CustomPainter {
const _WavePainter({
required this.phase,
required this.amplitude,
required this.color,
});
final double phase;
final double amplitude;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final mid = size.height / 2;
final maxAmp = size.height / 2 - 4;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
void wave(double amp, double phaseOffset, int alpha, double strokeWidth) {
paint
..color = color.withAlpha(alpha)
..strokeWidth = strokeWidth;
final path = Path();
for (var x = 0.0; x <= size.width; x += 2) {
final t = x / size.width;
// Taper toward the edges so the wave fades out at both ends.
final taper = sin(pi * t);
final y = mid +
amp * maxAmp * taper * sin(2 * pi * (2.5 * t - phase) + phaseOffset);
if (x == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
}
wave(amplitude, 0, 255, 2.5);
wave(amplitude * 0.6, pi / 2, 90, 2);
}
@override
bool shouldRepaint(_WavePainter old) =>
old.phase != phase || old.amplitude != amplitude || old.color != color;
}