rtc_prd/airhub_app/lib/services/tts_service.dart
seaislee1209 84243f2be4 feat: TTS语音合成 + 唱片架播放状态 + 气泡持续显示 + 音乐Prompt优化
- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成
- 新增 PillProgressButton 组件(药丸形进度按钮)
- 新增 TTSService 单例,后台生成不中断
- 音频保存到 Capybara audio/ 目录
- 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标)
- 播放时气泡持续显示当前歌名,暂停后隐藏
- 音乐总监Prompt去固定模板,歌名不再重复
- 新增 API 参考文档(豆包语音合成)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 22:51:26 +08:00

191 lines
5.7 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
/// Singleton service that manages TTS generation in the background.
/// Survives page navigation — when user leaves and comes back,
/// generation continues and result is available.
class TTSService extends ChangeNotifier {
TTSService._();
static final TTSService instance = TTSService._();
static const String _kServerBase = 'http://localhost:3000';
// ── Current task state ──
bool _isGenerating = false;
double _progress = 0.0; // 0.0 ~ 1.0
String _statusMessage = '';
String? _currentStoryTitle; // Which story is being generated
// ── Result ──
String? _audioUrl;
String? _completedStoryTitle; // Which story the audio belongs to
bool _justCompleted = false; // Flash animation trigger
// ── Error ──
String? _error;
// ── Getters ──
bool get isGenerating => _isGenerating;
double get progress => _progress;
String get statusMessage => _statusMessage;
String? get currentStoryTitle => _currentStoryTitle;
String? get audioUrl => _audioUrl;
String? get completedStoryTitle => _completedStoryTitle;
bool get justCompleted => _justCompleted;
String? get error => _error;
/// Check if audio is ready for a specific story.
bool hasAudioFor(String title) {
return _completedStoryTitle == title && _audioUrl != null;
}
/// Check if currently generating for a specific story.
bool isGeneratingFor(String title) {
return _isGenerating && _currentStoryTitle == title;
}
/// Clear the "just completed" flag (after flash animation plays).
void clearJustCompleted() {
_justCompleted = false;
notifyListeners();
}
/// Set audio URL directly (e.g. from pre-check).
void setExistingAudio(String title, String url) {
_completedStoryTitle = title;
_audioUrl = url;
_justCompleted = false;
notifyListeners();
}
/// Check server for existing audio file.
Future<void> checkExistingAudio(String title) async {
if (title.isEmpty) return;
try {
final resp = await http.get(
Uri.parse(
'$_kServerBase/api/tts_check?title=${Uri.encodeComponent(title)}',
),
);
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body);
if (data['exists'] == true && data['audio_url'] != null) {
_completedStoryTitle = title;
_audioUrl = '$_kServerBase/${data['audio_url']}';
notifyListeners();
}
}
} catch (_) {}
}
/// Start TTS generation. Safe to call even if page navigates away.
Future<void> generate({
required String title,
required String content,
}) async {
if (_isGenerating) return;
_isGenerating = true;
_progress = 0.0;
_statusMessage = '正在连接...';
_currentStoryTitle = title;
_audioUrl = null;
_completedStoryTitle = null;
_justCompleted = false;
_error = null;
notifyListeners();
try {
final client = http.Client();
final request = http.Request(
'POST',
Uri.parse('$_kServerBase/api/create_tts'),
);
request.headers['Content-Type'] = 'application/json';
request.body = jsonEncode({'title': title, 'content': content});
final streamed = await client.send(request);
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
for (final line in chunk.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
final data = jsonDecode(line.substring(6));
final stage = data['stage'] as String? ?? '';
final message = data['message'] as String? ?? '';
switch (stage) {
case 'connecting':
_updateProgress(0.10, '正在连接...');
break;
case 'generating':
_updateProgress(0.30, '语音生成中...');
break;
case 'saving':
_updateProgress(0.88, '正在保存...');
break;
case 'done':
if (data['audio_url'] != null) {
_audioUrl = '$_kServerBase/${data['audio_url']}';
_completedStoryTitle = title;
_justCompleted = true;
_updateProgress(1.0, '生成完成');
}
break;
case 'error':
throw Exception(message);
default:
// Progress slowly increases during generation
if (_progress < 0.85) {
_updateProgress(_progress + 0.02, message);
}
}
} catch (e) {
if (e is Exception &&
e.toString().contains('语音合成失败')) {
rethrow;
}
}
}
}
client.close();
_isGenerating = false;
if (_audioUrl == null) {
_error = '未获取到音频';
_statusMessage = '生成失败';
}
notifyListeners();
} catch (e) {
debugPrint('TTS generation error: $e');
_isGenerating = false;
_progress = 0.0;
_error = e.toString();
_statusMessage = '生成失败';
_justCompleted = false;
notifyListeners();
}
}
void _updateProgress(double progress, String message) {
_progress = progress.clamp(0.0, 1.0);
_statusMessage = message;
notifyListeners();
}
/// Reset all state (e.g. when switching stories).
void reset() {
if (_isGenerating) return; // Don't reset during generation
_progress = 0.0;
_statusMessage = '';
_currentStoryTitle = null;
_audioUrl = null;
_completedStoryTitle = null;
_justCompleted = false;
_error = null;
notifyListeners();
}
}