audio_stream_player 1.1.0 copy "audio_stream_player: ^1.1.0" to clipboard
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.

example/lib/main.dart

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;
}
2
likes
160
points
197
downloads
screenshot

Documentation

API reference

Publisher

verified publisheradrianczuczka.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#audio #pcm #streaming #tts #voice

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, web

More

Packages that depend on audio_stream_player

Packages that implement audio_stream_player