- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成 - 新增 PillProgressButton 组件(药丸形进度按钮) - 新增 TTSService 单例,后台生成不中断 - 音频保存到 Capybara audio/ 目录 - 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标) - 播放时气泡持续显示当前歌名,暂停后隐藏 - 音乐总监Prompt去固定模板,歌名不再重复 - 新增 API 参考文档(豆包语音合成) Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
5.7 KiB
Dart
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();
|
|
}
|
|
}
|