4240c90d55
Voice: recreate AudioRecorder each session to avoid stale native state, improve permission handling (re-check after settings), show feedback after 3 consecutive empty transcripts, allow STT-only mode when TTS is unavailable. Knowledge: hydrate IDs immediately after loading more pages so scroll-based pagination doesn't stall at the bottom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
87 lines
2.5 KiB
Dart
87 lines
2.5 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 when voice is enabled and at least STT is ready.
|
|
/// TTS is optional — voice mode works without it (STT-only).
|
|
bool get fullyAvailable => enabled && stt;
|
|
|
|
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.
|
|
/// [context] is optional recent conversation text passed as initial_prompt
|
|
/// to Whisper, reducing mishearings of domain-specific words.
|
|
/// Returns empty string on empty or error response.
|
|
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
|
|
try {
|
|
final fields = <String, dynamic>{
|
|
'audio': MultipartFile.fromBytes(
|
|
audioBytes,
|
|
filename: 'audio.m4a',
|
|
contentType: DioMediaType('audio', 'mp4'),
|
|
),
|
|
if (context != null && context.isNotEmpty) 'context': context,
|
|
};
|
|
final formData = FormData.fromMap(fields);
|
|
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);
|
|
}
|
|
}
|
|
}
|