d441dcf954
Three bugs causing silent STT failure on Android: 1. AudioEncoder.opus produces an OGG container on Android but the file was named .webm — faster-whisper rejected it due to format mismatch. Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions). Updated voice_api.dart to send audio/mp4 MIME type accordingly. 2. onError callback was never stored, so errors in _startListening() and _handleSilence() were silently swallowed. Now stored as _onError and called before exitVoiceMode() on any failure. 3. Amplitude stream can emit NaN or ±Infinity on some Android devices during recorder initialisation. NaN < -40.0 is false, so silence was never detected. Now treated explicitly as silence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
82 lines
2.2 KiB
Dart
82 lines
2.2 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
import 'api_client.dart';
|
|
|
|
class VoiceStatus {
|
|
final bool enabled;
|
|
final bool stt;
|
|
final bool tts;
|
|
|
|
const VoiceStatus({
|
|
required this.enabled,
|
|
required this.stt,
|
|
required this.tts,
|
|
});
|
|
|
|
/// True only when voice is enabled AND both STT and TTS are ready.
|
|
bool get fullyAvailable => enabled && stt && tts;
|
|
|
|
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
|
|
enabled: json['enabled'] as bool? ?? false,
|
|
stt: json['stt'] as bool? ?? false,
|
|
tts: json['tts'] as bool? ?? false,
|
|
);
|
|
}
|
|
|
|
class VoiceApi {
|
|
final Dio _dio;
|
|
const VoiceApi(this._dio);
|
|
|
|
/// Check whether voice features are available on this server.
|
|
Future<VoiceStatus> checkStatus() async {
|
|
try {
|
|
final response = await _dio.get('/api/voice/status');
|
|
return VoiceStatus.fromJson(response.data as Map<String, dynamic>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// POST WebM/Opus audio bytes and return the transcript string.
|
|
/// Returns empty string on empty or error response.
|
|
Future<String> transcribe(Uint8List audioBytes) async {
|
|
try {
|
|
final formData = FormData.fromMap({
|
|
'audio': MultipartFile.fromBytes(
|
|
audioBytes,
|
|
filename: 'audio.m4a',
|
|
contentType: DioMediaType('audio', 'mp4'),
|
|
),
|
|
});
|
|
final response = await _dio.post(
|
|
'/api/voice/transcribe',
|
|
data: formData,
|
|
options: Options(
|
|
receiveTimeout: const Duration(seconds: 60),
|
|
contentType: 'multipart/form-data',
|
|
),
|
|
);
|
|
final data = response.data as Map<String, dynamic>;
|
|
return (data['transcript'] as String? ?? '').trim();
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
|
|
/// POST text and return raw WAV bytes.
|
|
Future<Uint8List> synthesise(String text) async {
|
|
try {
|
|
final response = await _dio.post(
|
|
'/api/voice/synthesise',
|
|
data: {'text': text},
|
|
options: Options(responseType: ResponseType.bytes),
|
|
);
|
|
return Uint8List.fromList(response.data as List<int>);
|
|
} on DioException catch (e) {
|
|
throw dioToApp(e);
|
|
}
|
|
}
|
|
}
|