rtc_prd/airhub_app/lib/services/music_generation_service.dart
seaislee1209 8f5fb32b37 feat(story,music,server): 豆包故事生成 + 历史数据持久化 + 封面占位
- 接入火山引擎豆包 Chat API 生成儿童故事(SSE 流式进度)
- 新增 /api/stories 接口加载历史故事到书架
- 新增 /api/playlist 接口加载历史歌曲到唱片架
- 书架排序:预设故事在前,AI 生成在后
- AI 生成的故事显示"暂无封面"淡紫渐变占位
- 保存故事时传回真实标题+内容(不再用 mock)
- 修复 Windows GBK 编码导致的中文乱码问题
- 新增 MusicGenerationService 单例管理音乐生成
- 音乐页心情卡片 UI 重做 + 歌词可读性优化
- 添加豆包 API 参考文档和故事创作 prompt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:11:58 +08:00

222 lines
7.1 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
/// Lightweight singleton that runs music generation in the background.
/// Survives page navigation — results are held until the music page picks them up.
class MusicGenerationService {
MusicGenerationService._();
static final MusicGenerationService instance = MusicGenerationService._();
static const String _kServerBase = 'http://localhost:3000';
// ── Current task state ──
bool _isGenerating = false;
double _progress = 0.0; // 0~100
String _statusMessage = '';
String _currentStage = '';
// ── Completed result (held until consumed) ──
MusicGenResult? _pendingResult;
// ── Pending error (held until consumed) ──
String? _pendingError;
// ── Callback for live UI updates (set by the music page when visible) ──
void Function(double progress, String stage, String message)? onProgress;
void Function(MusicGenResult result)? onComplete;
void Function(String error)? onError;
// ── Getters ──
bool get isGenerating => _isGenerating;
double get progress => _progress;
String get statusMessage => _statusMessage;
String get currentStage => _currentStage;
/// Check and consume any pending result (called when music page resumes).
MusicGenResult? consumePendingResult() {
final result = _pendingResult;
_pendingResult = null;
return result;
}
/// Check and consume any pending error (called when music page resumes).
String? consumePendingError() {
final error = _pendingError;
_pendingError = null;
return error;
}
/// Start a generation task. Safe to call even if page navigates away.
Future<void> generate({required String text, required String mood}) async {
if (_isGenerating) return; // Only one task at a time
_isGenerating = true;
_progress = 5;
_statusMessage = '正在连接 AI...';
_currentStage = 'connecting';
_pendingResult = null;
_pendingError = null;
onProgress?.call(_progress, _currentStage, _statusMessage);
try {
final request = http.Request(
'POST',
Uri.parse('$_kServerBase/api/create_music'),
);
request.headers['Content-Type'] = 'application/json';
request.body = jsonEncode({'text': text, 'mood': mood});
final client = http.Client();
final response = await client.send(request).timeout(
const Duration(seconds: 360),
);
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
}
// Parse SSE stream
String buffer = '';
String? newTitle;
String? newLyrics;
String? newFilePath;
await for (final chunk in response.stream.transform(utf8.decoder)) {
buffer += chunk;
while (buffer.contains('\n\n')) {
final idx = buffer.indexOf('\n\n');
final line = buffer.substring(0, idx).trim();
buffer = buffer.substring(idx + 2);
if (!line.startsWith('data: ')) continue;
final jsonStr = line.substring(6);
try {
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
final stage = event['stage'] as String? ?? '';
final message = event['message'] as String? ?? '';
switch (stage) {
case 'lyrics':
_updateProgress(10, stage, 'AI 正在创作词曲...');
break;
case 'lyrics_done':
case 'lyrics_fallback':
_updateProgress(25, stage, '词曲创作完成,准备生成音乐...');
break;
case 'music':
_updateProgress(30, stage, '正在生成音乐,请耐心等待...');
break;
case 'saving':
_updateProgress(90, stage, '音乐生成完成,正在保存...');
break;
case 'done':
newFilePath = event['file_path'] as String?;
final metadata = event['metadata'] as Map<String, dynamic>?;
newLyrics = metadata?['lyrics'] as String? ?? '';
newTitle = metadata?['song_title'] as String?;
if ((newTitle == null || newTitle.isEmpty) && newFilePath != null) {
final fname = newFilePath.split('/').last;
newTitle = fname.replaceAll(RegExp(r'_\d{10,}\.mp3$'), '');
}
_updateProgress(100, stage, '新歌出炉!');
break;
case 'error':
_isGenerating = false;
_progress = 0;
final errMsg = message.isNotEmpty ? message : '网络开小差了,再试一次~';
_statusMessage = errMsg;
if (onError != null) {
onError!(errMsg);
} else {
_pendingError = errMsg;
}
client.close();
return;
}
} catch (e) {
debugPrint('SSE parse error: $e for line: $jsonStr');
}
}
}
client.close();
// Build result
_isGenerating = false;
_progress = 0;
if (newFilePath != null) {
final result = MusicGenResult(
title: newTitle ?? '新歌',
lyrics: newLyrics ?? '',
audioUrl: '$_kServerBase/$newFilePath',
);
// Always store as pending first; callback decides whether to consume
_pendingResult = result;
onComplete?.call(result);
}
} catch (e) {
debugPrint('Generate music error: $e');
_isGenerating = false;
_progress = 0;
const errMsg = '网络开小差了,再试一次~';
_statusMessage = errMsg;
if (onError != null) {
onError!(errMsg);
} else {
_pendingError = errMsg;
}
}
}
void _updateProgress(double progress, String stage, String message) {
_progress = progress;
_currentStage = stage;
_statusMessage = message;
onProgress?.call(progress, stage, message);
}
/// Fetch saved songs from the server (scans Capybara music/ folder).
Future<List<MusicGenResult>> fetchPlaylist() async {
try {
final response = await http.get(
Uri.parse('$_kServerBase/api/playlist'),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) return [];
final data = jsonDecode(response.body) as Map<String, dynamic>;
final list = data['playlist'] as List<dynamic>? ?? [];
return list.map((item) {
final m = item as Map<String, dynamic>;
return MusicGenResult(
title: m['title'] as String? ?? '',
lyrics: m['lyrics'] as String? ?? '',
audioUrl: '$_kServerBase/${m['audioUrl'] as String? ?? ''}',
);
}).toList();
} catch (e) {
debugPrint('Fetch playlist error: $e');
return [];
}
}
}
/// Result of a completed music generation.
class MusicGenResult {
final String title;
final String lyrics;
final String audioUrl;
const MusicGenResult({
required this.title,
required this.lyrics,
required this.audioUrl,
});
}