xue_hua_audio 1.0.6 copy "xue_hua_audio: ^1.0.6" to clipboard
xue_hua_audio: ^1.0.6 copied to clipboard

Cross-platform Flutter FFI audio plugin — multi-track playback, progress streams, and microphone recording via Rust/rodio.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:xue_hua_audio/xue_hua_audio.dart';

import 'audio_samples.dart';

String _formatDuration(double secs) {
  final total = secs.round().clamp(0, 359999);
  final minutes = total ~/ 60;
  final seconds = total % 60;
  return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}

StreamSubscription<XueHuaPlaybackProgress> _subscribeTrackProgress({
  required XueHuaAudioTrack track,
  required void Function(XueHuaPlaybackProgress progress) onProgress,
  void Function(Object error)? onError,
  VoidCallback? onDone,
}) {
  onProgress(track.playbackProgress());
  return track.progressStream.listen(
    onProgress,
    onError: onError,
    onDone: onDone,
  );
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final player = await XueHuaAudio.initialize();
  runApp(AudioDemoApp(engine: player.engine));
}

class AudioDemoApp extends StatelessWidget {
  const AudioDemoApp({super.key, required this.engine});

  final XueHuaAudioEngine engine;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Xuehua Audio Demo',
      home: AudioDemoPage(engine: engine),
    );
  }
}

class AudioDemoPage extends StatefulWidget {
  const AudioDemoPage({super.key, required this.engine});

  final XueHuaAudioEngine engine;

  @override
  State<AudioDemoPage> createState() => _AudioDemoPageState();
}

class _AudioDemoPageState extends State<AudioDemoPage> {
  String _status = '就绪';

  Future<void> _setStatus(String message) async {
    if (!mounted) return;
    setState(() => _status = message);
  }

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Xuehua Audio Demo'),
          bottom: const TabBar(
            tabs: [
              Tab(text: '录制'),
              Tab(text: '播放'),
            ],
          ),
        ),
        body: Column(
          children: [
            Material(
              color: Theme.of(context).colorScheme.surfaceContainerHighest,
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Row(
                  children: [
                    const Icon(Icons.info_outline, size: 18),
                    const SizedBox(width: 8),
                    Expanded(child: SelectableText(_status)),
                  ],
                ),
              ),
            ),
            Expanded(
              child: TabBarView(
                children: [
                  RecordingDemoTab(engine: widget.engine, onStatus: _setStatus),
                  PlaybackDemoTab(engine: widget.engine, onStatus: _setStatus),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class RecordingDemoTab extends StatefulWidget {
  const RecordingDemoTab({
    super.key,
    required this.engine,
    required this.onStatus,
  });

  final XueHuaAudioEngine engine;
  final Future<void> Function(String message) onStatus;

  @override
  State<RecordingDemoTab> createState() => _RecordingDemoTabState();
}

class _RecordingDemoTabState extends State<RecordingDemoTab> {
  XuehuaRecordingSession? _session;
  StreamSubscription<XueHuaRecordingProgress>? _progressSub;
  StreamSubscription<XueHuaRecordingCompleted>? _completedSub;

  List<String> _devices = [];
  int? _selectedDeviceIndex;
  XueHuaRecordingProgress? _progress;
  String? _recordedPath;
  XueHuaAudioTrack? _playbackTrack;
  StreamSubscription<XueHuaPlaybackProgress>? _playbackProgressSub;
  XueHuaPlaybackProgress? _playbackProgress;

  Future<void> _cancelPlaybackProgress() async {
    await _playbackProgressSub?.cancel();
    _playbackProgressSub = null;
    _playbackProgress = null;
  }

  @override
  void initState() {
    super.initState();
    unawaited(_loadDevices());
  }

  Future<void> _loadDevices() async {
    try {
      final devices = await widget.engine.listInputDevices();
      if (!mounted) return;
      setState(() {
        _devices = devices;
        _selectedDeviceIndex = devices.isEmpty ? null : 0;
      });
    } on XueHuaAudioError catch (error) {
      await widget.onStatus('加载输入设备失败: $error');
    }
  }

  Future<bool> _ensureMicPermission() async {
    final status = await Permission.microphone.request();
    if (status.isGranted) return true;
    await widget.onStatus('麦克风权限被拒绝');
    return false;
  }

  Future<void> _run(String action, Future<void> Function() task) async {
    try {
      await task();
      await widget.onStatus('$action 成功');
    } on XueHuaAudioError catch (error) {
      await widget.onStatus('$action 失败: $error');
    } catch (error) {
      await widget.onStatus('$action 失败: $error');
    }
  }

  Future<String> _nextOutputPath() async {
    final dir = await getTemporaryDirectory();
    return p.join(
      dir.path,
      'xuehua_recording_${DateTime.now().microsecondsSinceEpoch}.wav',
    );
  }

  Future<void> _startRecording() async {
    if (!await _ensureMicPermission()) return;
    await _progressSub?.cancel();
    await _completedSub?.cancel();
    await _session?.dispose();
    await _playbackTrack?.stopAndCleanup();
    await _cancelPlaybackProgress();
    _playbackTrack = null;

    final session = await widget.engine.createRecordingSession();
    final outputPath = await _nextOutputPath();
    _progressSub = session.progressStream.listen((progress) {
      if (!mounted) return;
      setState(() => _progress = progress);
    });
    _completedSub = session.completedStream.listen((completed) {
      if (!mounted) return;
      setState(() => _recordedPath = completed.outputPath);
      unawaited(widget.onStatus('录制完成: ${completed.outputPath}'));
    });

    await session.start(
      outputPath: outputPath,
      deviceIndex: _selectedDeviceIndex,
    );
    setState(() {
      _session = session;
      _recordedPath = null;
      _progress = null;
    });
  }

  Future<void> _stopRecording() async {
    final session = _session;
    if (session == null) return;
    final path = await session.stop();
    setState(() {
      _recordedPath = path;
      _progress = null;
    });
  }

  Future<void> _playRecording() async {
    final path = _recordedPath;
    if (path == null) return;
    await _playbackTrack?.stopAndCleanup();
    await _cancelPlaybackProgress();
    final track = await widget.engine.loadLocal(path: path);
    _playbackProgressSub = _subscribeTrackProgress(
      track: track,
      onProgress: (progress) {
        if (!mounted) return;
        setState(() => _playbackProgress = progress);
      },
      onError: (error) {
        unawaited(widget.onStatus('回放进度监听失败: $error'));
      },
      onDone: () {
        _playbackProgressSub = null;
      },
    );
    setState(() => _playbackTrack = track);
  }

  @override
  void dispose() {
    unawaited(_progressSub?.cancel());
    unawaited(_completedSub?.cancel());
    unawaited(_session?.dispose());
    unawaited(_playbackTrack?.stopAndCleanup());
    unawaited(_cancelPlaybackProgress());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final session = _session;
    final isRecording = session?.isRecording ?? false;
    final isPaused = session?.isPaused ?? false;
    final level = _progress?.level ?? 0.0;
    final durationSecs = _progress?.durationSecs ?? 0.0;

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Card(
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('输入设备', style: Theme.of(context).textTheme.titleMedium),
                const SizedBox(height: 8),
                if (_devices.isEmpty)
                  const Text('未检测到麦克风设备')
                else
                  DropdownButton<int>(
                    isExpanded: true,
                    value: _selectedDeviceIndex,
                    items: [
                      for (var i = 0; i < _devices.length; i++)
                        DropdownMenuItem(value: i, child: Text(_devices[i])),
                    ],
                    onChanged: isRecording
                        ? null
                        : (value) =>
                              setState(() => _selectedDeviceIndex = value),
                  ),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    FilledButton(
                      onPressed: isRecording
                          ? null
                          : () => _run('开始录制', _startRecording),
                      child: const Text('开始录制'),
                    ),
                    OutlinedButton(
                      onPressed: !isRecording || isPaused
                          ? null
                          : () => _run('暂停录制', () async {
                              await session!.pause();
                              setState(() {});
                            }),
                      child: const Text('暂停'),
                    ),
                    OutlinedButton(
                      onPressed: !isRecording || !isPaused
                          ? null
                          : () => _run('恢复录制', () async {
                              await session!.resume();
                              setState(() {});
                            }),
                      child: const Text('恢复'),
                    ),
                    OutlinedButton(
                      onPressed: !isRecording
                          ? null
                          : () => _run('停止录制', _stopRecording),
                      child: const Text('停止'),
                    ),
                    OutlinedButton(
                      onPressed: _recordedPath == null
                          ? null
                          : () => _run('播放录音', _playRecording),
                      child: const Text('播放录音'),
                    ),
                  ],
                ),
                const SizedBox(height: 12),
                Text(
                  _formatDuration(durationSecs),
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
                const SizedBox(height: 8),
                LinearProgressIndicator(value: level.clamp(0.0, 1.0)),
                const SizedBox(height: 4),
                Text(
                  [
                    if (isRecording && !isPaused) '录制中',
                    if (isRecording && isPaused) '已暂停',
                    if (!isRecording && _recordedPath != null) '已完成',
                  ].join(' · '),
                  style: Theme.of(context).textTheme.labelSmall,
                ),
                if (_recordedPath != null) ...[
                  const SizedBox(height: 8),
                  Text(
                    _recordedPath!,
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ],
                if (_playbackTrack != null && _playbackProgress != null) ...[
                  const SizedBox(height: 16),
                  Text('录音回放', style: Theme.of(context).textTheme.titleSmall),
                  const SizedBox(height: 8),
                  Row(
                    children: [
                      Text(
                        _formatDuration(_playbackProgress!.positionSecs),
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                      Expanded(
                        child: LinearProgressIndicator(
                          value: (_playbackProgress!.progress ?? 0.0).clamp(
                            0.0,
                            1.0,
                          ),
                        ),
                      ),
                      Text(
                        _playbackProgress!.durationSecs == null
                            ? '--:--'
                            : _formatDuration(_playbackProgress!.durationSecs!),
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                    ],
                  ),
                  Text(
                    [
                      if (_playbackProgress!.isPlaying) '播放中',
                      if (_playbackProgress!.isPaused) '已暂停',
                      if (_playbackProgress!.isFinished) '已播完',
                    ].join(' · '),
                    style: Theme.of(context).textTheme.labelSmall,
                  ),
                ],
              ],
            ),
          ),
        ),
      ],
    );
  }
}

class PlaybackDemoTab extends StatefulWidget {
  const PlaybackDemoTab({
    super.key,
    required this.engine,
    required this.onStatus,
  });

  final XueHuaAudioEngine engine;
  final Future<void> Function(String message) onStatus;

  @override
  State<PlaybackDemoTab> createState() => _PlaybackDemoTabState();
}

class _PlaybackDemoTabState extends State<PlaybackDemoTab> {
  final Map<String, XueHuaAudioTrack> _tracks = {};
  final Map<String, XueHuaPlaybackProgress> _progress = {};
  final Map<String, double> _volumes = {
    for (final sample in audioSamples) sample.$2: 1.0,
  };
  final Set<String> _seekingKeys = {};
  final Map<String, double> _seekDragValues = {};
  final Set<String> _finishedNotified = {};

  final Map<String, StreamSubscription<XueHuaPlaybackProgress>> _progressSubs =
      {};

  XueHuaAudioEngine get _engine => widget.engine;

  void _watchProgress(String assetKey, XueHuaAudioTrack track) {
    unawaited(_progressSubs.remove(assetKey)?.cancel());
    _progressSubs[assetKey] = _subscribeTrackProgress(
      track: track,
      onProgress: (progress) {
        if (!mounted) return;
        if (progress.isFinished && !_finishedNotified.contains(assetKey)) {
          _finishedNotified.add(assetKey);
          final label = audioSamples
              .firstWhere((sample) => sample.$2 == assetKey)
              .$1;
          unawaited(widget.onStatus('$label 播放完成'));
          final finishedTrack = _tracks.remove(assetKey);
          if (finishedTrack != null) {
            unawaited(finishedTrack.stopAndCleanup());
          }
        }
        setState(() {
          _progress[assetKey] = progress;
        });
      },
      onError: (Object error) {
        unawaited(widget.onStatus('进度监听失败: $error'));
      },
      onDone: () {
        _progressSubs.remove(assetKey);
      },
    );
  }

  Future<void> _run(String action, Future<void> Function() task) async {
    try {
      await task();
      await widget.onStatus('$action 成功');
    } on XueHuaAudioError catch (error) {
      await widget.onStatus('$action 失败: $error');
    } catch (error) {
      await widget.onStatus('$action 失败: $error');
    }
  }

  Future<void> _playAsset(String assetKey) async {
    await _progressSubs.remove(assetKey)?.cancel();
    final existing = _tracks.remove(assetKey);
    if (existing != null) {
      await existing.stopAndCleanup();
    }
    _progress.remove(assetKey);
    _finishedNotified.remove(assetKey);

    final track = await _engine.loadAsset(assetKey: assetKey);
    final volume = _volumes[assetKey] ?? 1.0;
    await track.setVolume(volume: volume);
    _tracks[assetKey] = track;
    _watchProgress(assetKey, track);
  }

  Future<void> _stopAsset(String assetKey) async {
    await _progressSubs.remove(assetKey)?.cancel();
    final track = _tracks.remove(assetKey);
    if (track != null) {
      await track.stopAndCleanup();
    }
    _progress.remove(assetKey);
    _finishedNotified.remove(assetKey);
    _seekingKeys.remove(assetKey);
    _seekDragValues.remove(assetKey);
  }

  Future<void> _playAll() async {
    for (final sample in audioSamples) {
      await _playAsset(sample.$2);
    }
  }

  Future<void> _stopAll() async {
    for (final sub in _progressSubs.values) {
      await sub.cancel();
    }
    _progressSubs.clear();
    final tracks = _tracks.values.toList();
    _tracks.clear();
    for (final track in tracks) {
      await track.stopAndCleanup();
    }
    await _engine.stopAllWithCleanup();
  }

  @override
  void dispose() {
    for (final sub in _progressSubs.values) {
      unawaited(sub.cancel());
    }
    _progressSubs.clear();
    for (final track in _tracks.values) {
      unawaited(track.stopAndCleanup());
    }
    _tracks.clear();
    unawaited(_engine.stopAllWithCleanup());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
          child: Row(
            children: [
              FilledButton(
                onPressed: () => _run('全部播放', _playAll),
                child: const Text('全部播放'),
              ),
              const SizedBox(width: 8),
              OutlinedButton(
                onPressed: () => _run('全部停止', _stopAll),
                child: const Text('全部停止'),
              ),
            ],
          ),
        ),
        Expanded(
          child: ListView.separated(
            padding: const EdgeInsets.all(16),
            itemCount: audioSamples.length,
            separatorBuilder: (_, _) => const SizedBox(height: 12),
            itemBuilder: (context, index) {
              final (label, assetKey) = audioSamples[index];
              final track = _tracks[assetKey];
              final volume = _volumes[assetKey] ?? 1.0;
              final progress = _progress[assetKey];
              final isSeeking = _seekingKeys.contains(assetKey);
              final sliderValue = isSeeking
                  ? (_seekDragValues[assetKey] ?? progress?.progress ?? 0.0)
                  : (progress?.progress ?? 0.0);
              final durationSecs = progress?.durationSecs;

              return Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        label,
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        assetKey,
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                      const SizedBox(height: 12),
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: [
                          FilledButton(
                            onPressed: () => _run('播放 $label', () async {
                              await _playAsset(assetKey);
                              setState(() {});
                            }),
                            child: const Text('播放'),
                          ),
                          OutlinedButton(
                            onPressed: track == null
                                ? null
                                : () => _run('暂停 $label', () async {
                                    await track.pause();
                                  }),
                            child: const Text('暂停'),
                          ),
                          OutlinedButton(
                            onPressed: track == null
                                ? null
                                : () => _run('恢复 $label', () async {
                                    await track.resume();
                                  }),
                            child: const Text('恢复'),
                          ),
                          OutlinedButton(
                            onPressed: track == null
                                ? null
                                : () => _run('停止 $label', () async {
                                    await _stopAsset(assetKey);
                                    setState(() {});
                                  }),
                            child: const Text('停止'),
                          ),
                        ],
                      ),
                      if (track != null && progress != null) ...[
                        const SizedBox(height: 8),
                        Row(
                          children: [
                            Text(
                              _formatDuration(progress.positionSecs),
                              style: Theme.of(context).textTheme.bodySmall,
                            ),
                            Expanded(
                              child: Slider(
                                value: sliderValue.clamp(0.0, 1.0),
                                onChangeStart: durationSecs == null
                                    ? null
                                    : (_) {
                                        _seekingKeys.add(assetKey);
                                      },
                                onChanged: durationSecs == null
                                    ? null
                                    : (value) {
                                        _seekDragValues[assetKey] = value;
                                        setState(() {});
                                      },
                                onChangeEnd: durationSecs == null
                                    ? null
                                    : (value) => _run('跳转 $label', () async {
                                        _seekingKeys.remove(assetKey);
                                        _seekDragValues.remove(assetKey);
                                        await track.seekTo(
                                          positionSecs: value * durationSecs,
                                        );
                                      }),
                              ),
                            ),
                            Text(
                              durationSecs == null
                                  ? '--:--'
                                  : _formatDuration(durationSecs),
                              style: Theme.of(context).textTheme.bodySmall,
                            ),
                          ],
                        ),
                        Text(
                          [
                            if (progress.isPlaying) '播放中',
                            if (progress.isPaused) '已暂停',
                            if (progress.isFinished) '已播完',
                          ].join(' · '),
                          style: Theme.of(context).textTheme.labelSmall,
                        ),
                      ],
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          const Text('音量'),
                          Expanded(
                            child: Slider(
                              value: volume,
                              onChanged: track == null
                                  ? null
                                  : (value) async {
                                      _volumes[assetKey] = value;
                                      await track.setVolume(volume: value);
                                      setState(() {});
                                    },
                            ),
                          ),
                          Text('${(volume * 100).round()}%'),
                        ],
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}
0
likes
155
points
594
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Cross-platform Flutter FFI audio plugin — multi-track playback, progress streams, and microphone recording via Rust/rodio.

Repository (GitHub)
View/report issues

License

Apache-2.0 (license)

Dependencies

flutter, flutter_rust_bridge, freezed_annotation, http, path, plugin_platform_interface

More

Packages that depend on xue_hua_audio

Packages that implement xue_hua_audio