fix: server side tts on ios
This commit is contained in:
@@ -2438,7 +2438,7 @@ class ApiService {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<int>> generateSpeech({
|
||||
Future<({Uint8List bytes, String mimeType})> generateSpeech({
|
||||
required String text,
|
||||
String? voice,
|
||||
}) async {
|
||||
@@ -2450,12 +2450,75 @@ class ApiService {
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
// Return audio data as bytes
|
||||
final data = response.data;
|
||||
if (data is List<int>) return data;
|
||||
if (data is Uint8List) return data.toList();
|
||||
if (data is List) return (data).cast<int>();
|
||||
return [];
|
||||
final rawMimeType = response.headers.value('content-type');
|
||||
final audioBytes = _coerceAudioBytes(response.data);
|
||||
final resolvedMimeType = _resolveAudioMimeType(rawMimeType, audioBytes);
|
||||
|
||||
return (bytes: audioBytes, mimeType: resolvedMimeType);
|
||||
}
|
||||
|
||||
Uint8List _coerceAudioBytes(Object? data) {
|
||||
if (data is Uint8List && data.isNotEmpty) {
|
||||
return Uint8List.fromList(data);
|
||||
}
|
||||
if (data is List<int>) {
|
||||
return Uint8List.fromList(data);
|
||||
}
|
||||
if (data is List) {
|
||||
return Uint8List.fromList(data.cast<int>());
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
String _resolveAudioMimeType(String? rawMimeType, Uint8List bytes) {
|
||||
final sanitized = rawMimeType?.split(';').first.trim();
|
||||
if (sanitized != null && sanitized.isNotEmpty) {
|
||||
return sanitized;
|
||||
}
|
||||
if (_matchesPrefix(bytes, const [0x52, 0x49, 0x46, 0x46]) &&
|
||||
_matchesPrefix(bytes, const [0x57, 0x41, 0x56, 0x45], offset: 8)) {
|
||||
return 'audio/wav';
|
||||
}
|
||||
if (_matchesPrefix(bytes, const [0x4F, 0x67, 0x67, 0x53])) {
|
||||
return 'audio/ogg';
|
||||
}
|
||||
if (_matchesPrefix(bytes, const [0x66, 0x4C, 0x61, 0x43])) {
|
||||
return 'audio/flac';
|
||||
}
|
||||
if (_looksLikeMp4(bytes)) {
|
||||
return 'audio/mp4';
|
||||
}
|
||||
if (_looksLikeMpeg(bytes)) {
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
|
||||
bool _matchesPrefix(Uint8List bytes, List<int> signature, {int offset = 0}) {
|
||||
if (bytes.length < offset + signature.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < signature.length; i++) {
|
||||
if (bytes[offset + i] != signature[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _looksLikeMp4(Uint8List bytes) {
|
||||
return bytes.length >= 8 &&
|
||||
_matchesPrefix(bytes, const [0x66, 0x74, 0x79, 0x70], offset: 4);
|
||||
}
|
||||
|
||||
bool _looksLikeMpeg(Uint8List bytes) {
|
||||
if (bytes.length >= 3 &&
|
||||
bytes[0] == 0x49 &&
|
||||
bytes[1] == 0x44 &&
|
||||
bytes[2] == 0x33) {
|
||||
return true;
|
||||
}
|
||||
return bytes.length >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0;
|
||||
}
|
||||
|
||||
// Server audio transcription removed; rely on on-device STT in UI layer
|
||||
|
||||
@@ -27,11 +27,11 @@ class BackgroundStreamingHandler {
|
||||
void Function(List<String> streamIds)? onStreamsSuspending;
|
||||
void Function()? onBackgroundTaskExpiring;
|
||||
void Function(List<String> streamIds, int estimatedSeconds)?
|
||||
onBackgroundTaskExtended;
|
||||
onBackgroundTaskExtended;
|
||||
void Function()? onBackgroundKeepAlive;
|
||||
bool Function()? shouldContinueInBackground;
|
||||
void Function(String error, String errorType, List<String> streamIds)?
|
||||
onServiceFailed;
|
||||
onServiceFailed;
|
||||
|
||||
void _setupMethodCallHandler() {
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
|
||||
@@ -53,7 +53,7 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
error: '$errorType: $error',
|
||||
data: {'affectedStreams': streamIds},
|
||||
);
|
||||
|
||||
|
||||
// Attempt immediate recovery for failed streams
|
||||
for (final streamId in streamIds) {
|
||||
final callback = _streamRecoveryCallbacks[streamId];
|
||||
@@ -145,25 +145,25 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (_activeStreams.isNotEmpty && _isInBackground) {
|
||||
_backgroundHandler.keepAlive();
|
||||
|
||||
|
||||
// Check for stale streams during background operation
|
||||
_checkStreamHealth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _checkStreamHealth() {
|
||||
final now = DateTime.now();
|
||||
final staleStreams = <String>[];
|
||||
|
||||
|
||||
for (final entry in _streamMetadata.entries) {
|
||||
final streamId = entry.key;
|
||||
final metadata = entry.value;
|
||||
final lastUpdate = metadata['lastUpdate'] as DateTime?;
|
||||
|
||||
|
||||
if (lastUpdate != null) {
|
||||
final timeSinceUpdate = now.difference(lastUpdate);
|
||||
|
||||
|
||||
// If no update in 90 seconds while in background, consider stale
|
||||
if (timeSinceUpdate > const Duration(seconds: 90)) {
|
||||
DebugLogger.warning(
|
||||
@@ -173,14 +173,12 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Attempt recovery for stale streams
|
||||
for (final streamId in staleStreams) {
|
||||
final callback = _streamRecoveryCallbacks[streamId];
|
||||
if (callback != null && _retryAttempts[streamId] == null) {
|
||||
DebugLogger.stream(
|
||||
'Initiating recovery for stale stream: $streamId',
|
||||
);
|
||||
DebugLogger.stream('Initiating recovery for stale stream: $streamId');
|
||||
_attemptStreamRecovery(streamId, callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import 'package:dio/dio.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Parser for Server-Sent Events (SSE) streaming responses.
|
||||
///
|
||||
///
|
||||
/// This matches the web client's EventSourceParserStream behavior,
|
||||
/// parsing SSE data chunks and extracting OpenAI-compatible deltas.
|
||||
class SSEStreamParser {
|
||||
/// Parse an SSE response stream from Dio into text chunks.
|
||||
///
|
||||
///
|
||||
/// Returns a stream of content strings extracted from OpenAI-style
|
||||
/// completion chunks.
|
||||
///
|
||||
///
|
||||
/// [heartbeatTimeout] - Maximum time without data before considering
|
||||
/// the connection stale (default: 2 minutes)
|
||||
/// [onHeartbeat] - Callback invoked when any data is received
|
||||
@@ -24,46 +24,43 @@ class SSEStreamParser {
|
||||
}) async* {
|
||||
DateTime lastDataReceived = DateTime.now();
|
||||
Timer? heartbeatTimer;
|
||||
|
||||
|
||||
// Set up heartbeat monitoring
|
||||
if (heartbeatTimeout.inMilliseconds > 0) {
|
||||
heartbeatTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(timer) {
|
||||
final timeSinceLastData = DateTime.now().difference(lastDataReceived);
|
||||
if (timeSinceLastData > heartbeatTimeout) {
|
||||
DebugLogger.warning(
|
||||
'SSE stream heartbeat timeout: No data received for ${timeSinceLastData.inSeconds}s',
|
||||
data: {'timeout': heartbeatTimeout.inSeconds},
|
||||
);
|
||||
timer.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
final timeSinceLastData = DateTime.now().difference(lastDataReceived);
|
||||
if (timeSinceLastData > heartbeatTimeout) {
|
||||
DebugLogger.warning(
|
||||
'SSE stream heartbeat timeout: No data received for ${timeSinceLastData.inSeconds}s',
|
||||
data: {'timeout': heartbeatTimeout.inSeconds},
|
||||
);
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Buffer for accumulating incomplete SSE messages
|
||||
String buffer = '';
|
||||
|
||||
|
||||
await for (final chunk in responseBody.stream) {
|
||||
// Update last data timestamp and invoke heartbeat callback
|
||||
lastDataReceived = DateTime.now();
|
||||
onHeartbeat?.call();
|
||||
|
||||
|
||||
// Convert bytes to string (Dio ResponseBody.stream always emits Uint8List)
|
||||
final text = utf8.decode(chunk as List<int>, allowMalformed: true);
|
||||
buffer += text;
|
||||
|
||||
|
||||
// Process complete SSE messages (delimited by double newline)
|
||||
final messages = buffer.split('\n\n');
|
||||
|
||||
|
||||
// Keep the last (potentially incomplete) message in the buffer
|
||||
buffer = messages.removeLast();
|
||||
|
||||
|
||||
for (final message in messages) {
|
||||
if (message.trim().isEmpty) continue;
|
||||
|
||||
|
||||
// Parse SSE message
|
||||
final content = _parseSSEMessage(message);
|
||||
if (content != null) {
|
||||
@@ -72,7 +69,7 @@ class SSEStreamParser {
|
||||
DebugLogger.stream('SSE stream completed with [DONE] signal');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Split large deltas into smaller chunks for smoother UI updates
|
||||
if (splitLargeDeltas && content.length > 5) {
|
||||
yield* _splitIntoChunks(content);
|
||||
@@ -82,7 +79,7 @@ class SSEStreamParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process any remaining buffered data
|
||||
if (buffer.trim().isNotEmpty) {
|
||||
final content = _parseSSEMessage(buffer);
|
||||
@@ -103,34 +100,34 @@ class SSEStreamParser {
|
||||
heartbeatTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Parse a single SSE message and extract content.
|
||||
static String? _parseSSEMessage(String message) {
|
||||
try {
|
||||
// SSE format: "data: <json>\n" or just the JSON
|
||||
String dataLine = message.trim();
|
||||
|
||||
|
||||
// Remove "data: " prefix if present
|
||||
if (dataLine.startsWith('data: ')) {
|
||||
dataLine = dataLine.substring(6).trim();
|
||||
} else if (dataLine.startsWith('data:')) {
|
||||
dataLine = dataLine.substring(5).trim();
|
||||
}
|
||||
|
||||
|
||||
// Handle [DONE] signal
|
||||
if (dataLine == '[DONE]' || dataLine == 'DONE') {
|
||||
return '[DONE]';
|
||||
}
|
||||
|
||||
|
||||
// Skip empty data
|
||||
if (dataLine.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Parse JSON
|
||||
try {
|
||||
final json = jsonDecode(dataLine) as Map<String, dynamic>;
|
||||
|
||||
|
||||
// Handle errors
|
||||
if (json['error'] != null) {
|
||||
DebugLogger.error(
|
||||
@@ -140,7 +137,7 @@ class SSEStreamParser {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Extract content from OpenAI-style response
|
||||
// Format: { choices: [{ delta: { content: "..." } }] }
|
||||
final choices = json['choices'];
|
||||
@@ -156,13 +153,13 @@ class SSEStreamParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Alternative format: { content: "..." }
|
||||
final directContent = json['content'];
|
||||
if (directContent is String && directContent.isNotEmpty) {
|
||||
return directContent;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} on FormatException catch (e) {
|
||||
DebugLogger.warning(
|
||||
@@ -181,24 +178,24 @@ class SSEStreamParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Split large content into smaller chunks for smoother streaming.
|
||||
/// This matches the web client's streamLargeDeltasAsRandomChunks behavior.
|
||||
static Stream<String> _splitIntoChunks(String content) async* {
|
||||
var remaining = content;
|
||||
|
||||
|
||||
while (remaining.isNotEmpty) {
|
||||
// Random chunk size between 1-3 characters
|
||||
final chunkSize = (remaining.length < 3)
|
||||
? remaining.length
|
||||
: 1 + (DateTime.now().millisecond % 3);
|
||||
|
||||
|
||||
final chunk = remaining.substring(0, chunkSize);
|
||||
yield chunk;
|
||||
|
||||
|
||||
// Small delay for smoother visual effect (matching web client)
|
||||
await Future.delayed(const Duration(milliseconds: 5));
|
||||
|
||||
|
||||
remaining = remaining.substring(chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user