import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/services.dart'; import '../utils/audio_math.dart'; import '../utils/constants.dart'; /// Flutter-side wrapper around the Android [AudioEngine] native platform channel. /// /// Responsibilities: /// • Request / release AudioRecord and AudioTrack via MethodChannel. /// • Stream captured PCM chunks as [Float64List] via [captureStream]. /// • Accept [Float64List] playback buffers and forward them as Int16 bytes. /// • Manage speaker / earpiece routing. /// /// All audio is 8 kHz, mono, 16-bit PCM (Int16 LE). class AudioService { // ── Platform channels ────────────────────────────────────────────── static const MethodChannel _method = MethodChannel('com.example.call/audio_control'); static const EventChannel _event = EventChannel('com.example.call/audio_capture'); // ── Capture stream ───────────────────────────────────────────────── StreamSubscription? _captureSub; final _captureController = StreamController.broadcast(); /// Broadcast stream of decoded Float64 audio chunks from the microphone. /// Each chunk is [C.lpcSubframeSamples] (160) samples = 20 ms. Stream get captureStream => _captureController.stream; // ── State ────────────────────────────────────────────────────────── bool _capturing = false; bool _playing = false; bool _speakerOn = true; bool get isCapturing => _capturing; bool get isPlaying => _playing; bool get speakerOn => _speakerOn; // ── Capture ──────────────────────────────────────────────────────── /// Start microphone capture at [C.sampleRate] Hz. /// /// [source] is the Android [AudioSource] constant (default = UNPROCESSED = 9). /// The stream emits [Float64List] chunks of [C.lpcSubframeSamples] samples. Future startCapture({int source = 9}) async { if (_capturing) return; _capturing = true; // Subscribe to the native EventChannel BEFORE calling startCapture so no // audio chunks are dropped between the two calls. _captureSub = _event.receiveBroadcastStream().listen( (dynamic data) { if (data is Uint8List) { final floats = AudioMath.int16BytesToFloat(data); _captureController.add(floats); } }, onError: (Object e) { _captureController.addError(e); }, ); await _method.invokeMethod('startCapture', { 'sampleRate': C.sampleRate, 'source': source, }); } /// Stop microphone capture. Future stopCapture() async { if (!_capturing) return; _capturing = false; await _method.invokeMethod('stopCapture'); await _captureSub?.cancel(); _captureSub = null; } // ── Playback ─────────────────────────────────────────────────────── /// Start the AudioTrack in streaming mode. Future startPlayback() async { if (_playing) return; _playing = true; await _method.invokeMethod('startPlayback', { 'sampleRate': C.sampleRate, }); } /// Write a Float64 buffer to the AudioTrack for immediate playback. /// /// Converts to Int16 LE bytes before sending over the platform channel. Future writePlayback(Float64List samples) async { if (!_playing) return; final bytes = AudioMath.floatToInt16Bytes(samples); await _method.invokeMethod('writePlayback', {'samples': bytes}); } /// Write raw Int16 LE PCM bytes directly (no conversion needed). Future writePlaybackBytes(Uint8List pcmBytes) async { if (!_playing) return; await _method.invokeMethod('writePlayback', {'samples': pcmBytes}); } /// Stop the AudioTrack. Future stopPlayback() async { if (!_playing) return; _playing = false; await _method.invokeMethod('stopPlayback'); } // ── Audio routing ────────────────────────────────────────────────── /// Enable loudspeaker mode (required for acoustic FSK coupling). Future setSpeakerMode(bool enabled) async { _speakerOn = enabled; await _method.invokeMethod('setSpeakerMode', {'enabled': enabled}); } /// Query current audio route info from the native layer. Future> getAudioRouteInfo() async { final result = await _method.invokeMapMethod( 'getAudioRouteInfo'); return result ?? {}; } // ── Lifecycle ────────────────────────────────────────────────────── /// Stop all audio and close the capture stream. Future dispose() async { await stopCapture(); await stopPlayback(); await _captureController.close(); } }