This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/data/api/voice_api.dart
T
bvandeusen d441dcf954 fix(voice): fix silent STT failure — use AAC/M4A, store onError, guard NaN amplitude
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>
2026-04-06 17:39:21 -04:00

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);
}
}
}