跳转至

Flutter 音频处理

使用 TTS(文本转语音)将文字转为可播放的音频,支持多种音色选择。


功能预览

音频处理页面

左侧导航选择 🔊 音频处理 → 输入文本 → 选择音色 → 生成并播放语音


目录


基础概念

文本转语音(TTS)API 将文本转换为音频文件(mp3 / wav / opus):

{
  'model': 'tts-1',
  'input': '欢迎使用 Flutter 教程',
  'voice': 'alloy',
  'response_format': 'mp3',
  'speed': 1.0,
}

常用模型与音色:

模型 说明 可选音色
tts-1 / tts-1-hd OpenAI 标准 TTS alloy, echo, fable, onyx, nova, shimmer
doubao-tts-pro-44100 字节豆包 TTS 多种中文音色
fish-speech-v1.4 开源多音色 TTS 多种语言

调用 TTS API

import 'package:path_provider/path_provider.dart';
import 'dart:io';

Future<String> textToSpeech(String text, {
  String voice = 'alloy',
  double speed = 1.0,
}) async {
  final response = await http.post(
    Uri.parse('${dotenv.env['APINEXUS_BASE_URL']!}/audio/speech'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ${dotenv.env['APINEXUS_API_KEY']!}',
    },
    body: json.encode({
      'model': 'tts-1',
      'input': text,
      'voice': voice,
      'response_format': 'mp3',
      'speed': speed,
    }),
  );

  // 保存到本地文件
  final dir = await getTemporaryDirectory();
  final file = File('${dir.path}/speech_${DateTime.now().millisecondsSinceEpoch}.mp3');
  await file.writeAsBytes(response.bodyBytes);
  return file.path;
}

播放音频文件

使用 audioplayers 包播放:

dependencies:
  audioplayers: ^6.1.0
import 'package:audioplayers/audioplayers.dart';

class AudioPlayerService {
  final AudioPlayer _player = AudioPlayer();
  bool _isPlaying = false;

  Future<void> play(String filePath) async {
    await _player.play(DeviceFileSource(filePath));
    _isPlaying = true;
  }

  Future<void> pause() async {
    await _player.pause();
    _isPlaying = false;
  }

  Future<void> stop() async {
    await _player.stop();
    _isPlaying = false;
  }

  bool get isPlaying => _isPlaying;

  Stream<Duration> get onPositionChanged => _player.onPositionChanged;
  Stream<Duration?> get onDuration => _player.onDurationChanged;

  void dispose() {
    _player.dispose();
  }
}

流式播放

如果 TTS API 支持流式响应,可以一边接收一边播放(chunked audio):

Future<List<int>> streamTTS(String text) async {
  final request = http.Request(
    'POST',
    Uri.parse('${dotenv.env['APINEXUS_BASE_URL']!}/audio/speech'),
  );
  request.headers['Content-Type'] = 'application/json';
  request.headers['Authorization'] =
      'Bearer ${dotenv.env['APINEXUS_API_KEY']!}';
  request.body = json.encode({
    'model': 'tts-1',
    'input': text,
    'voice': 'alloy',
    'response_format': 'mp3',
  });

  final response = await request.send();
  final bytes = await response.stream.toBytes();

  // 保存到临时文件
  final dir = await getTemporaryDirectory();
  final file = File('${dir.path}/stream_tts.mp3');
  await file.writeAsBytes(bytes);
  return bytes;
}

音色选择

不同模型支持不同的音色。以下是 OpenAI TTS 标准音色说明:

音色 说明 适用场景
alloy 中性偏年轻男声 通用聊天、播报
echo 成熟稳重男声 新闻、说明类内容
fable 故事讲述风格 儿童故事、有声书
onyx 深沉男声 广告旁白、电影预告片
nova 年轻女声 播客、讲解
shimmer 柔和女声 情感类内容

在 UI 中选择音色:

final voices = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
String selected = 'alloy';

DropdownButton<String>(
  value: selected,
  items: voices.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
  onChanged: (v) => setState(() => selected = v!),
);

完整示例

在 Flutter 应用中集成 TTS 播放:

class AudioPage extends StatefulWidget {
  const AudioPage({super.key});

  @override
  State<AudioPage> createState() => _AudioPageState();
}

class _AudioPageState extends State<AudioPage> {
  final _controller = TextEditingController(
    text: '欢迎使用 Flutter 音频处理教程。本教程将教你如何将文字转换为语音。',
  );
  final _audio = AudioPlayerService();
  String _voice = 'alloy';
  double _speed = 1.0;
  String _status = '';
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _audio.onPositionChanged.listen((d) => setState(() => _position = d));
    _audio.onDurationChanged.listen((d) => setState(() => _duration = d));
  }

  @override
  void dispose() {
    _audio.dispose();
    super.dispose();
  }

  Future<void> _generateAndPlay() async {
    if (_controller.text.isEmpty || _loading) return;
    setState(() {
      _loading = true;
      _status = '正在生成音频...';
    });
    try {
      final filePath = await textToSpeech(
        _controller.text,
        voice: _voice,
        speed: _speed,
      );
      setState(() => _status = '生成完成,开始播放');
      await _audio.play(filePath);
    } catch (e) {
      setState(() => _status = '失败: $e');
    } finally {
      setState(() => _loading = false);
    }
  }

  String _formatDuration(Duration d) {
    final min = d.inMinutes;
    final sec = d.inSeconds.remainder(60);
    return '${min}:${sec.toString().padLeft(2, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TTS 文本转语音')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _controller,
              maxLines: 4,
              decoration: const InputDecoration(
                labelText: '输入要朗读的文字',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                const Text('音色:'),
                const SizedBox(width: 8),
                Expanded(
                  child: DropdownButton<String>(
                    isExpanded: true,
                    value: _voice,
                    items: ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
                        .map((v) => DropdownMenuItem(value: v, child: Text(v)))
                        .toList(),
                    onChanged: (v) => setState(() => _voice = v!),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                const Text('速度:'),
                Expanded(
                  child: Slider(
                    min: 0.25,
                    max: 4.0,
                    value: _speed,
                    onChanged: (v) => setState(() => _speed = v),
                  ),
                ),
                Text(_speed.toStringAsFixed(1) + 'x'),
              ],
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: ElevatedButton(
                    onPressed: _loading ? null : _generateAndPlay,
                    child: Text(_loading ? '生成中...' : '生成并播放'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: ElevatedButton(
                    onPressed: () => _audio.pause(),
                    child: const Text('暂停'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            if (_duration.inSeconds > 0) ...[
              Slider(
                min: 0,
                max: _duration.inSeconds.toDouble(),
                value: _position.inSeconds.toDouble(),
                onChanged: (_) {},
              ),
              Text('${_formatDuration(_position)} / ${_formatDuration(_duration)}'),
            ],
            const SizedBox(height: 12),
            Text(_status, style: Theme.of(context).textTheme.bodySmall),
          ],
        ),
      ),
    );
  }
}

继续学习