From c90b0b3d48be8305b2332f278f294eaec2393f5e Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:40:19 -0400 Subject: [PATCH] feat(voice): add VoiceApi and VoiceStatus model --- lib/data/api/voice_api.dart | 81 +++++++++++++++++++++++++++++++++++++ test/widget_test.dart | 32 +++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 lib/data/api/voice_api.dart diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart new file mode 100644 index 0000000..42e3dad --- /dev/null +++ b/lib/data/api/voice_api.dart @@ -0,0 +1,81 @@ +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 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 checkStatus() async { + try { + final response = await _dio.get('/api/voice/status'); + return VoiceStatus.fromJson(response.data as Map); + } 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 transcribe(Uint8List audioBytes) async { + try { + final formData = FormData.fromMap({ + 'audio': MultipartFile.fromBytes( + audioBytes, + filename: 'audio.webm', + contentType: DioMediaType('audio', 'webm'), + ), + }); + 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; + return (data['transcript'] as String? ?? '').trim(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST text and return raw WAV bytes. + Future 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); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 531745d..bdfc282 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:fabled_app/data/api/voice_api.dart'; import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -66,4 +67,35 @@ void main() { expect(item.status, isNull); }); }); + + group('VoiceStatus.fromJson', () { + test('parses enabled with stt and tts available', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': true, + 'tts': true, + }); + expect(status.enabled, isTrue); + expect(status.stt, isTrue); + expect(status.tts, isTrue); + }); + + test('parses disabled state', () { + final status = VoiceStatus.fromJson({ + 'enabled': false, + 'stt': false, + 'tts': false, + }); + expect(status.enabled, isFalse); + }); + + test('fullyAvailable is false when enabled but stt is false', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': false, + 'tts': true, + }); + expect(status.fullyAvailable, isFalse); + }); + }); }