diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index ee294cf..3a36a5c 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -43,6 +43,7 @@ jobs: "docs/troubleshooting.md:docs/troubleshooting_RU.md" "docs/implementation-plan.md:docs/implementation-plan_RU.md" "docs/roadmap.md:docs/roadmap_RU.md" + "docs/architecture/android-live-audio.md:docs/architecture/android-live-audio_RU.md" "docs/research/acoustic-1-phy-sources.md:docs/research/acoustic-1-phy-sources_RU.md" "docs/research/acoustic-2-measurement-sources.md:docs/research/acoustic-2-measurement-sources_RU.md" "docs/research/device-acceptance-sources.md:docs/research/device-acceptance-sources_RU.md" diff --git a/apps/audio_modem/android/app/src/main/AndroidManifest.xml b/apps/audio_modem/android/app/src/main/AndroidManifest.xml index d153bcd..e5ab774 100644 --- a/apps/audio_modem/android/app/src/main/AndroidManifest.xml +++ b/apps/audio_modem/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + if (focusChange != AudioManager.AUDIOFOCUS_GAIN) { + stopActiveRoute() + } + } + + private val routingListener = AudioRouting.OnRoutingChangedListener { + stopActiveRoute() + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler(this) + EventChannel(flutterEngine.dartExecutor.binaryMessenger, "$CHANNEL/capture_frames") + .setStreamHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getAvailability" -> result.success(availability()) + "startPlayback" -> startPlayback(call, result) + "startCapture" -> startCapture(call, result) + "stop" -> { + stopActiveRoute() + result.success(null) + } + "dispose" -> { + stopActiveRoute() + result.success(null) + } + else -> result.notImplemented() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + captureSink = events + } + + override fun onCancel(arguments: Any?) { + captureSink = null + stopActiveRoute() + } + + override fun onStop() { + stopActiveRoute() + super.onStop() + } + + override fun onDestroy() { + stopActiveRoute() + super.onDestroy() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != PERMISSION_REQUEST_CAPTURE) return + val result = pendingCaptureResult ?: return + pendingCaptureResult = null + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + startCaptureAfterPermission(result) + } else { + result.error("permission_denied", "RECORD_AUDIO was not granted.", null) + } + } + + private fun availability(): Map { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return mapOf( + "available" to false, + "reason" to "Android foreground live audio v1 requires API 26 or later.", + ) + } + return mapOf("available" to true) + } + + private fun startPlayback(call: MethodCall, result: MethodChannel.Result) { + if (!ensureAvailable(result) || !validateFormat(call, result)) return + val frames = call.argument("pcmFrames") + if (frames == null || frames.isEmpty() || frames.size % 2 != 0) { + result.error("invalid_pcm", "Playback requires non-empty PCM16 LE frames.", null) + return + } + if (!active.compareAndSet(false, true)) { + result.error("route_active", "A live-audio operation is already active.", null) + return + } + + val focus = requestPlaybackFocus() + if (focus != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + active.set(false) + result.error("focus_unavailable", "Audio focus was not granted for playback.", null) + return + } + + val minBuffer = AudioTrack.getMinBufferSize( + SAMPLE_RATE_HZ, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + abandonPlaybackFocus() + active.set(false) + result.error("track_unavailable", "Android did not provide a PCM playback buffer.", null) + return + } + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(SAMPLE_RATE_HZ) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build(), + ) + .setBufferSizeInBytes(maxOf(minBuffer, 4096)) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + if (track.state != AudioTrack.STATE_INITIALIZED) { + track.release() + abandonPlaybackFocus() + active.set(false) + result.error("track_unavailable", "Android could not initialize the requested PCM track.", null) + return + } + if (!hasAudioModemV1Format(track.sampleRate, track.channelCount, track.audioFormat)) { + track.release() + abandonPlaybackFocus() + active.set(false) + result.error("format_mismatch", "Android track did not preserve 48000 Hz mono pcm_s16le.", null) + return + } + track.addOnRoutingChangedListener(routingListener, mainHandler) + audioTrack = track + pendingPlaybackResult = result + playbackThread = Thread { + try { + track.play() + var offset = 0 + while (active.get() && offset < frames.size) { + val written = track.write( + frames, + offset, + frames.size - offset, + AudioTrack.WRITE_BLOCKING, + ) + if (written <= 0) throw IllegalStateException("AudioTrack write failed: $written") + offset += written + } + if (active.get()) { + completePlaybackSuccess() + } else { + completePlaybackError("playback_stopped", "Android playback was stopped before completion.") + } + } catch (error: Throwable) { + completePlaybackError("playback_failed", error.message) + } finally { + stopActiveRoute(notifyPlaybackStop = false) + } + }.also { it.start() } + } + + private fun startCapture(call: MethodCall, result: MethodChannel.Result) { + if (!ensureAvailable(result) || !validateFormat(call, result)) return + if (captureSink == null) { + result.error("capture_listener_missing", "Capture frames listener is not attached.", null) + return + } + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + pendingCaptureResult = result + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), PERMISSION_REQUEST_CAPTURE) + return + } + startCaptureAfterPermission(result) + } + + private fun startCaptureAfterPermission(result: MethodChannel.Result) { + if (!active.compareAndSet(false, true)) { + result.error("route_active", "A live-audio operation is already active.", null) + return + } + val minBuffer = AudioRecord.getMinBufferSize( + SAMPLE_RATE_HZ, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + active.set(false) + result.error("record_unavailable", "Android did not provide a PCM capture buffer.", null) + return + } + val record = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.MIC) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(SAMPLE_RATE_HZ) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build(), + ) + .setBufferSizeInBytes(maxOf(minBuffer, 4096)) + .build() + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + active.set(false) + result.error("record_unavailable", "Android could not initialize the requested PCM record.", null) + return + } + if (!hasAudioModemV1Format(record.sampleRate, record.channelCount, record.audioFormat)) { + record.release() + active.set(false) + result.error("format_mismatch", "Android record did not preserve 48000 Hz mono pcm_s16le.", null) + return + } + record.addOnRoutingChangedListener(routingListener, mainHandler) + audioRecord = record + try { + record.startRecording() + } catch (error: Throwable) { + record.release() + audioRecord = null + active.set(false) + result.error("capture_failed", error.message, null) + return + } + captureThread = Thread { + val buffer = ByteArray(maxOf(minBuffer, 4096)) + try { + while (active.get()) { + val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING) + if (read <= 0) throw IllegalStateException("AudioRecord read failed: $read") + captureSink?.success(buffer.copyOf(read)) + } + } catch (error: Throwable) { + mainHandler.post { captureSink?.error("capture_failed", error.message, null) } + } finally { + stopActiveRoute() + } + }.also { it.start() } + result.success(null) + } + + private fun ensureAvailable(result: MethodChannel.Result): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true + result.error("api_unsupported", "Android foreground live audio v1 requires API 26 or later.", null) + return false + } + + private fun validateFormat(call: MethodCall, result: MethodChannel.Result): Boolean { + val sampleRate = call.argument("sampleRateHz") + val channels = call.argument("channels") + val format = call.argument("sampleFormat") + if (sampleRate == SAMPLE_RATE_HZ && channels == CHANNELS && format == SAMPLE_FORMAT) { + return true + } + result.error( + "format_mismatch", + "Android live-audio v1 requires 48000 Hz mono pcm_s16le.", + null, + ) + return false + } + + private fun hasAudioModemV1Format(sampleRate: Int, channelCount: Int, encoding: Int): Boolean = + sampleRate == SAMPLE_RATE_HZ && + channelCount == CHANNELS && + encoding == AudioFormat.ENCODING_PCM_16BIT + + private fun requestPlaybackFocus(): Int { + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .setOnAudioFocusChangeListener(focusListener) + .build() + focusRequest = request + return audioManager.requestAudioFocus(request) + } + + private fun abandonPlaybackFocus() { + focusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + focusRequest = null + } + + private fun completePlaybackSuccess() { + val result = pendingPlaybackResult ?: return + pendingPlaybackResult = null + mainHandler.post { result.success(null) } + } + + private fun completePlaybackError(code: String, message: String?) { + val result = pendingPlaybackResult ?: return + pendingPlaybackResult = null + mainHandler.post { result.error(code, message, null) } + } + + @Synchronized + private fun stopActiveRoute(notifyPlaybackStop: Boolean = true) { + active.set(false) + if (notifyPlaybackStop) { + completePlaybackError("playback_stopped", "Android playback was stopped or interrupted.") + } + audioTrack?.let { track -> + try { + if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.stop() + track.flush() + } catch (_: IllegalStateException) { + // The route is already stopping or stopped. + } + track.removeOnRoutingChangedListener(routingListener) + track.release() + } + audioTrack = null + audioRecord?.let { record -> + try { + if (record.recordingState == AudioRecord.RECORDSTATE_RECORDING) record.stop() + } catch (_: IllegalStateException) { + // The route is already stopping or stopped. + } + record.removeOnRoutingChangedListener(routingListener) + record.release() + } + audioRecord = null + abandonPlaybackFocus() + } +} diff --git a/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart b/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart index cecbf1e..ee53cfd 100644 --- a/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart +++ b/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart @@ -1,4 +1,4 @@ -// Product-layer bridge adapter. Rust remains the only source of ADLP and WAV codec logic. +// Product-layer bridge adapter. Rust remains the only source of ADLP and WAV/PCM codec logic. import 'package:flutter/foundation.dart'; @@ -21,6 +21,22 @@ class WavBuildResult { final Uint8List wavBytes; } +class LivePcmBuildResult { + const LivePcmBuildResult({ + required this.sessionId, + required this.profile, + required this.carrier, + required this.sampleRateHz, + required this.pcmFrames, + }); + + final int sessionId; + final String profile; + final String carrier; + final int sampleRateHz; + final Uint8List pcmFrames; +} + class WavDecodeResult { const WavDecodeResult({ required this.sessionId, @@ -95,6 +111,19 @@ abstract interface class WavBootstrapBridge { required Uint8List wavBytes, required String carrier, }); + + Future encodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }); + + Future decodeLivePcmText({ + required Uint8List pcmFrames, + required String carrier, + }); } class NativeWavBootstrapBridge implements WavBootstrapBridge { @@ -208,6 +237,50 @@ class NativeWavBootstrapBridge implements WavBootstrapBridge { samplesConsumed: result.samplesConsumed, ); } + + @override + Future encodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }) async { + final result = await rust.encodeTextToLivePcm( + sessionId: sessionId, + senderCallsign: senderCallsign, + text: text, + profile: profile, + carrier: carrier, + ); + return LivePcmBuildResult( + sessionId: result.sessionId, + profile: result.profile, + carrier: result.carrier, + sampleRateHz: result.sampleRateHz, + pcmFrames: result.pcmFrames, + ); + } + + @override + Future decodeLivePcmText({ + required Uint8List pcmFrames, + required String carrier, + }) async { + final result = await rust.decodeLivePcmText( + pcmFrames: pcmFrames, + carrier: carrier, + ); + return WavDecodeResult( + sessionId: result.sessionId, + senderCallsign: result.senderCallsign, + profile: result.profile, + carrier: result.carrier, + text: result.text, + sampleRateHz: result.sampleRateHz, + samplesConsumed: result.samplesConsumed, + ); + } } class UnavailableWavBootstrapBridge implements WavBootstrapBridge { @@ -249,4 +322,19 @@ class UnavailableWavBootstrapBridge implements WavBootstrapBridge { required Uint8List wavBytes, required String carrier, }) => Future.error(StateError(reason)); + + @override + Future encodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }) => Future.error(StateError(reason)); + + @override + Future decodeLivePcmText({ + required Uint8List pcmFrames, + required String carrier, + }) => Future.error(StateError(reason)); } diff --git a/apps/audio_modem/lib/main.dart b/apps/audio_modem/lib/main.dart index a331db5..57e36cd 100644 --- a/apps/audio_modem/lib/main.dart +++ b/apps/audio_modem/lib/main.dart @@ -1,11 +1,13 @@ // AudioModem Flutter workbench: UI stays transport-aware while Rust owns ADLP and WAV codec behavior. +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'bridge/wav_bootstrap_bridge.dart'; +import 'platform/android_live_audio_adapter.dart'; import 'platform/live_audio_adapter.dart'; import 'platform/payload_file_adapter.dart'; import 'platform/wav_file_adapter.dart'; @@ -21,12 +23,13 @@ Future main() async { 'Rust/WAV bridge initialization failed: $error', ); } + final liveAudioAdapter = await AndroidLiveAudioAdapter.create(); runApp( AudioModemApp( bridge: bridge, fileAdapter: const PlatformWavFileAdapter(), payloadFileAdapter: const PlatformPayloadFileAdapter(), - liveAudioAdapter: const UnavailableLiveAudioAdapter(), + liveAudioAdapter: liveAudioAdapter, ), ); } @@ -104,11 +107,16 @@ class _TransferWorkbenchState extends State { Uint8List? _activeWavBytes; CarrierKind? _activeCarrier; String? _bridgeError; + StreamSubscription? _captureSubscription; + BytesBuilder? _capturedPcm; + bool _isCapturing = false; bool get _isWorking => _task.isBusy; @override void dispose() { + unawaited(_captureSubscription?.cancel()); + unawaited(widget.liveAudioAdapter.dispose()); _callsign.dispose(); _text.dispose(); super.dispose(); @@ -174,11 +182,7 @@ class _TransferWorkbenchState extends State { Future _buildAndVerifyWav() async { if (_route != TransferRoute.wav) { - final reason = - widget.liveAudioAdapter.availability.reason ?? - 'Live-audio маршрут недоступен. Выберите WAV-маршрут.'; - _setTask(TransferTaskState.unavailable(reason)); - _showMessage(reason); + await _sendLiveSpeakerText(); return; } if (!widget.bridge.isAvailable) { @@ -275,6 +279,219 @@ class _TransferWorkbenchState extends State { } } + Future _sendLiveSpeakerText() async { + if (_route != TransferRoute.speaker) { + const reason = + 'Android live-audio v1 не поддерживает этот маршрут. Выберите WAV или «Динамик».'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (_objectKind != TransferObjectKind.text) { + const reason = + 'Android live-audio v1 передаёт только text object. Для файла используйте WAV workflow.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (!widget.bridge.isAvailable) { + const reason = 'Rust bridge недоступен для подготовки live PCM.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (!widget.liveAudioAdapter.availability.isAvailable) { + final reason = + widget.liveAudioAdapter.availability.reason ?? + 'Android foreground live audio недоступен.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (_carrier != CarrierKind.acoustic1) { + const reason = + 'Android live-audio v1 требует experimental Acoustic-1 PCM carrier. Bootstrap доступен через WAV.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); + return; + } + _setTask(TransferTaskState.preparing('Сборка Acoustic-1 PCM в Rust…')); + try { + final built = await widget.bridge.encodeTextToLivePcm( + sessionId: DateTime.now().microsecondsSinceEpoch, + senderCallsign: _callsign.text.trim(), + text: _text.text, + profile: _preset.bridgeProfile, + carrier: _carrier.bridgeCarrier, + ); + _setTask(TransferTaskState.verifying('Проверка PCM через Rust decoder…')); + await widget.bridge.decodeLivePcmText( + pcmFrames: built.pcmFrames, + carrier: built.carrier, + ); + _setTask( + TransferTaskState.preparing( + 'Foreground playback через Android adapter…', + ), + ); + await widget.liveAudioAdapter.startPlayback( + pcmFrames: built.pcmFrames, + format: PcmStreamFormat.audioModemV1, + ); + _setTask( + TransferTaskState.completed( + 'Android playback lifecycle завершён.', + detail: 'Rust подтвердил PCM framing; это не подтверждает приём или декодирование другим устройством.', + ), + ); + _showMessage('PCM передан в Android AudioTrack без receiver claim.'); + } catch (error) { + if (!mounted) { + return; + } + _setTask( + TransferTaskState.rejected( + 'Android live-audio attempt отклонён или прерван.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); + _showMessage( + 'Android live-audio attempt не завершён. WAV workflow остаётся доступен.', + ); + } finally { + await widget.liveAudioAdapter.stop(); + } + } + + Future _startLiveCapture() async { + if (_isCapturing) { + return; + } + if (!widget.bridge.isAvailable) { + const reason = 'Rust bridge недоступен для проверки captured PCM.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (!widget.liveAudioAdapter.availability.isAvailable) { + final reason = + widget.liveAudioAdapter.availability.reason ?? + 'Android foreground live audio недоступен.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (_carrier != CarrierKind.acoustic1) { + const reason = + 'Android capture v1 требует experimental Acoustic-1 PCM carrier. Выберите Acoustic-1 перед началом приёма.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); + return; + } + final captured = BytesBuilder(copy: false); + try { + final subscription = widget.liveAudioAdapter + .startCapture(format: PcmStreamFormat.audioModemV1) + .listen( + captured.add, + onError: (Object error, StackTrace stackTrace) { + if (!mounted) { + return; + } + setState(() { + _isCapturing = false; + _captureSubscription = null; + _task = TransferTaskState.rejected( + 'Android capture прерван.', + detail: error.toString(), + ); + _bridgeError = error.toString(); + }); + unawaited(widget.liveAudioAdapter.stop()); + }, + ); + if (!mounted) { + await subscription.cancel(); + return; + } + setState(() { + _capturedPcm = captured; + _captureSubscription = subscription; + _isCapturing = true; + _task = TransferTaskState.capturing( + 'Android microphone capture активен.', + ); + }); + } catch (error) { + _setTask( + TransferTaskState.rejected( + 'Android capture не удалось запустить.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); + } + } + + Future _stopLiveCapture() async { + final captured = _capturedPcm; + final subscription = _captureSubscription; + if (!_isCapturing || captured == null) { + return; + } + setState(() { + _isCapturing = false; + _captureSubscription = null; + _task = TransferTaskState.verifying('Остановка capture и проверка PCM…'); + }); + await subscription?.cancel(); + await widget.liveAudioAdapter.stop(); + final pcmFrames = captured.takeBytes(); + _capturedPcm = null; + if (pcmFrames.isEmpty) { + const reason = 'Android capture не вернул PCM frames для проверки.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); + return; + } + try { + final decoded = await widget.bridge.decodeLivePcmText( + pcmFrames: pcmFrames, + carrier: CarrierKind.acoustic1.bridgeCarrier, + ); + if (!mounted) { + return; + } + setState(() { + _decodedWav = decoded; + _decodedFileWav = null; + _activeWavName = 'Android foreground capture (not persisted)'; + _activeWavBytes = null; + _activeCarrier = CarrierKind.acoustic1; + _task = TransferTaskState.completed( + 'Captured PCM декодирован Rust.', + detail: 'Это локальный decoder result; он не является device-acceptance observation без отдельного report.', + ); + }); + _showMessage( + 'Captured PCM проверен Rust decoder без route-support claim.', + ); + } catch (error) { + if (!mounted) { + return; + } + _setTask( + TransferTaskState.rejected( + 'Rust decoder отклонил captured PCM.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); + _showMessage('Captured PCM не дал проверенного ADLP object.'); + } + } + Future _verifyCurrentWav() async { final wavBytes = _activeWavBytes; final carrier = _activeCarrier; @@ -551,7 +768,9 @@ class _TransferWorkbenchState extends State { ), const SizedBox(height: 8), Text( - 'Соберите и проверьте WAV.', + _route == TransferRoute.wav + ? 'Соберите и проверьте WAV.' + : 'Подготовьте foreground PCM attempt.', style: Theme.of(context).textTheme.displaySmall ?.copyWith(fontWeight: FontWeight.w700), ), @@ -814,7 +1033,11 @@ class _TransferWorkbenchState extends State { ) : const Icon(Icons.graphic_eq), label: Text( - _isWorking ? _task.message : 'Собрать и проверить WAV', + _isWorking + ? _task.message + : _route == TransferRoute.wav + ? 'Собрать и проверить WAV' + : 'Передать Android PCM', ), ), if (!widget.bridge.isAvailable) ...[ @@ -873,7 +1096,7 @@ class _TransferWorkbenchState extends State { ], const SizedBox(height: 12), const Text( - 'WAV можно экспортировать и импортировать локально. Live-audio adapter contract добавлен, но capture, playback, cable, Bluetooth и radio adapters ещё не реализованы.', + 'WAV можно экспортировать и импортировать локально. Android API 26+ foreground text/Acoustic-1 adapter экспериментален: результат playback/capture не доказывает доставку, приём другим устройством или supported route. Cable, Bluetooth и radio adapters отсутствуют.', style: TextStyle( fontSize: 12, height: 1.4, @@ -992,6 +1215,60 @@ class _TransferWorkbenchState extends State { ), ), ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.mic_none_outlined, + size: 36, + color: Color(0xFFFFB000), + ), + const SizedBox(height: 12), + Text( + 'Android foreground capture · experimental', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + widget.liveAudioAdapter.availability.isAvailable + ? 'Текущий carrier: ${_carrier.label}. Capture доступен только для Acoustic-1, запрашивает microphone permission при нажатии и хранит PCM только до остановки и Rust verification.' + : (widget.liveAudioAdapter.availability.reason ?? + 'Android foreground capture недоступен в этой сборке.'), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: + !widget.liveAudioAdapter.availability.isAvailable || + (!_isCapturing && + (_isWorking || !widget.bridge.isAvailable)) + ? null + : (_isCapturing ? _stopLiveCapture : _startLiveCapture), + icon: Icon( + _isCapturing ? Icons.stop_circle_outlined : Icons.mic, + ), + label: Text( + _isCapturing + ? 'Остановить и проверить PCM' + : 'Начать Android capture', + ), + ), + const SizedBox(height: 10), + const Text( + 'Не используйте этот control как evidence для совместимости. Physical-route report создаётся только после реальных повторяемых hardware observations согласно device-acceptance protocol.', + style: TextStyle( + fontSize: 12, + height: 1.4, + color: Color(0xFF6D6A62), + ), + ), + ], + ), + ), + ), ], ); } diff --git a/apps/audio_modem/lib/platform/android_live_audio_adapter.dart b/apps/audio_modem/lib/platform/android_live_audio_adapter.dart new file mode 100644 index 0000000..d2bb0fd --- /dev/null +++ b/apps/audio_modem/lib/platform/android_live_audio_adapter.dart @@ -0,0 +1,174 @@ +// Android foreground live-audio adapter v1. It owns no ADLP/DSP logic and +// remains unavailable outside its declared API/platform scope. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'live_audio_adapter.dart'; + +class AndroidLiveAudioAdapter implements LiveAudioAdapter { + AndroidLiveAudioAdapter._({ + required this.availability, + MethodChannel? methods, + EventChannel? captureEvents, + }) : _methods = methods ?? _defaultMethods, + _captureEvents = captureEvents ?? _defaultCaptureEvents; + + @visibleForTesting + factory AndroidLiveAudioAdapter.forTesting({ + required MethodChannel methods, + required EventChannel captureEvents, + LiveAudioAvailability availability = const LiveAudioAvailability.available( + supportedFormat: PcmStreamFormat.audioModemV1, + ), + }) => AndroidLiveAudioAdapter._( + availability: availability, + methods: methods, + captureEvents: captureEvents, + ); + + static const _channelName = 'org.audiomodem.audio_modem/live_audio_v1'; + static const MethodChannel _defaultMethods = MethodChannel(_channelName); + static const EventChannel _defaultCaptureEvents = EventChannel( + '$_channelName/capture_frames', + ); + + final MethodChannel _methods; + final EventChannel _captureEvents; + + @override + final LiveAudioAvailability availability; + + bool _disposed = false; + + static Future create() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return const UnavailableLiveAudioAdapter( + 'Android foreground live audio is unavailable on this platform. Build or import a WAV instead.', + ); + } + try { + final result = await _defaultMethods.invokeMapMethod( + 'getAvailability', + ); + if (result?['available'] != true) { + return UnavailableLiveAudioAdapter( + result?['reason'] as String? ?? + 'Android foreground live audio is unavailable on this device.', + ); + } + return AndroidLiveAudioAdapter._( + availability: const LiveAudioAvailability.available( + supportedFormat: PcmStreamFormat.audioModemV1, + ), + ); + } on PlatformException catch (error) { + return UnavailableLiveAudioAdapter( + 'Android live-audio initialization is unavailable: ${error.message ?? error.code}', + ); + } + } + + void _ensureActiveAndFormat(PcmStreamFormat format) { + if (_disposed) { + throw StateError('Android live-audio adapter has been disposed.'); + } + if (!availability.isAvailable) { + throw StateError( + availability.reason ?? 'Android live audio is unavailable.', + ); + } + if (!format.isAudioModemV1) { + throw ArgumentError.value( + format, + 'format', + 'Android live-audio v1 requires 48 kHz mono signed PCM16 LE.', + ); + } + } + + Map _formatArguments(PcmStreamFormat format) => { + 'sampleRateHz': format.sampleRateHz, + 'channels': format.channels, + 'sampleFormat': 'pcm_s16le', + }; + + @override + Future startPlayback({ + required Uint8List pcmFrames, + required PcmStreamFormat format, + }) async { + _ensureActiveAndFormat(format); + if (pcmFrames.isEmpty || pcmFrames.length.isOdd) { + throw ArgumentError.value( + pcmFrames, + 'pcmFrames', + 'Playback requires non-empty PCM16 LE frames.', + ); + } + await _methods.invokeMethod('startPlayback', { + ..._formatArguments(format), + 'pcmFrames': pcmFrames, + }); + } + + @override + Stream startCapture({required PcmStreamFormat format}) { + _ensureActiveAndFormat(format); + late final StreamController controller; + StreamSubscription? subscription; + + controller = StreamController( + onListen: () async { + subscription = _captureEvents.receiveBroadcastStream().listen( + (Object? event) { + if (event is Uint8List && event.isNotEmpty && event.length.isEven) { + controller.add(event); + } else { + controller.addError( + StateError( + 'Android adapter emitted invalid PCM16 capture frames.', + ), + ); + } + }, + onError: controller.addError, + onDone: controller.close, + ); + try { + await _methods.invokeMethod( + 'startCapture', + _formatArguments(format), + ); + } catch (error, stackTrace) { + controller.addError(error, stackTrace); + await controller.close(); + } + }, + onCancel: () async { + await subscription?.cancel(); + await stop(); + }, + ); + return controller.stream; + } + + @override + Future stop() async { + if (_disposed) { + return; + } + await _methods.invokeMethod('stop'); + } + + @override + Future dispose() async { + if (_disposed) { + return; + } + _disposed = true; + await _methods.invokeMethod('dispose'); + } +} diff --git a/apps/audio_modem/lib/src/rust/api/wav_bootstrap.dart b/apps/audio_modem/lib/src/rust/api/wav_bootstrap.dart index 424a26a..ec29e00 100644 --- a/apps/audio_modem/lib/src/rust/api/wav_bootstrap.dart +++ b/apps/audio_modem/lib/src/rust/api/wav_bootstrap.dart @@ -7,9 +7,9 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `as_str`, `decode_selected_wav`, `parse_carrier`, `parse_profile` +// These functions are ignored because they are not marked as `pub`: `as_str`, `decode_selected_wav`, `parse_carrier`, `parse_live_pcm_carrier`, `parse_profile` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `Carrier` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt` /// Encodes one text-only ADLP object into a selected canonical 48 kHz WAV carrier. Future encodeTextToWav({ @@ -45,6 +45,22 @@ Future encodeFileToWav({ carrier: carrier, ); +/// Encodes a text ADLP object as experimental Acoustic-1 PCM16 LE frames. +/// No live device is accessed here; the bootstrap carrier is intentionally excluded. +Future encodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, +}) => AudioModemRust.instance.api.crateApiWavBootstrapEncodeTextToLivePcm( + sessionId: sessionId, + senderCallsign: senderCallsign, + text: text, + profile: profile, + carrier: carrier, +); + /// Decodes and verifies selected-carrier WAV bytes before exposing text metadata to Flutter. Future decodeWavText({ required List wavBytes, @@ -63,6 +79,16 @@ Future decodeWavFile({ carrier: carrier, ); +/// Decodes one bounded capture buffer as an experimental Acoustic-1 text object. +/// The caller owns capture lifecycle; this method verifies carrier and ADLP integrity. +Future decodeLivePcmText({ + required List pcmFrames, + required String carrier, +}) => AudioModemRust.instance.api.crateApiWavBootstrapDecodeLivePcmText( + pcmFrames: pcmFrames, + carrier: carrier, +); + class DecodedFileTransfer { final int sessionId; final String senderCallsign; @@ -157,6 +183,41 @@ class DecodedTextTransfer { samplesConsumed == other.samplesConsumed; } +class EncodedPcmTransfer { + final int sessionId; + final String profile; + final String carrier; + final int sampleRateHz; + final Uint8List pcmFrames; + + const EncodedPcmTransfer({ + required this.sessionId, + required this.profile, + required this.carrier, + required this.sampleRateHz, + required this.pcmFrames, + }); + + @override + int get hashCode => + sessionId.hashCode ^ + profile.hashCode ^ + carrier.hashCode ^ + sampleRateHz.hashCode ^ + pcmFrames.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EncodedPcmTransfer && + runtimeType == other.runtimeType && + sessionId == other.sessionId && + profile == other.profile && + carrier == other.carrier && + sampleRateHz == other.sampleRateHz && + pcmFrames == other.pcmFrames; +} + class EncodedWavTransfer { final int sessionId; final String profile; diff --git a/apps/audio_modem/lib/src/rust/frb_generated.dart b/apps/audio_modem/lib/src/rust/frb_generated.dart index 788cf09..2317c5d 100644 --- a/apps/audio_modem/lib/src/rust/frb_generated.dart +++ b/apps/audio_modem/lib/src/rust/frb_generated.dart @@ -73,7 +73,7 @@ class AudioModemRust String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 2115612387; + int get rustContentHash => -1462031589; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -85,6 +85,11 @@ class AudioModemRust } abstract class AudioModemRustApi extends BaseApi { + Future crateApiWavBootstrapDecodeLivePcmText({ + required List pcmFrames, + required String carrier, + }); + Future crateApiWavBootstrapDecodeWavFile({ required List wavBytes, required String carrier, @@ -105,6 +110,14 @@ abstract class AudioModemRustApi extends BaseApi { required String carrier, }); + Future crateApiWavBootstrapEncodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }); + Future crateApiWavBootstrapEncodeTextToWav({ required int sessionId, required String senderCallsign, @@ -123,6 +136,41 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform required super.portManager, }); + @override + Future crateApiWavBootstrapDecodeLivePcmText({ + required List pcmFrames, + required String carrier, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(pcmFrames, serializer); + sse_encode_String(carrier, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 1, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_decoded_text_transfer, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiWavBootstrapDecodeLivePcmTextConstMeta, + argValues: [pcmFrames, carrier], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWavBootstrapDecodeLivePcmTextConstMeta => + const TaskConstMeta( + debugName: "decode_live_pcm_text", + argNames: ["pcmFrames", "carrier"], + ); + @override Future crateApiWavBootstrapDecodeWavFile({ required List wavBytes, @@ -137,7 +185,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 1, + funcId: 2, port: port_, ); }, @@ -172,7 +220,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 2, + funcId: 3, port: port_, ); }, @@ -217,7 +265,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 3, + funcId: 4, port: port_, ); }, @@ -254,6 +302,47 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform ], ); + @override + Future crateApiWavBootstrapEncodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_CastedPrimitive_u_64(sessionId, serializer); + sse_encode_String(senderCallsign, serializer); + sse_encode_String(text, serializer); + sse_encode_String(profile, serializer); + sse_encode_String(carrier, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_encoded_pcm_transfer, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiWavBootstrapEncodeTextToLivePcmConstMeta, + argValues: [sessionId, senderCallsign, text, profile, carrier], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWavBootstrapEncodeTextToLivePcmConstMeta => + const TaskConstMeta( + debugName: "encode_text_to_live_pcm", + argNames: ["sessionId", "senderCallsign", "text", "profile", "carrier"], + ); + @override Future crateApiWavBootstrapEncodeTextToWav({ required int sessionId, @@ -274,7 +363,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 4, + funcId: 6, port: port_, ); }, @@ -345,6 +434,21 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform ); } + @protected + EncodedPcmTransfer dco_decode_encoded_pcm_transfer(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + return EncodedPcmTransfer( + sessionId: dco_decode_CastedPrimitive_u_64(arr[0]), + profile: dco_decode_String(arr[1]), + carrier: dco_decode_String(arr[2]), + sampleRateHz: dco_decode_u_32(arr[3]), + pcmFrames: dco_decode_list_prim_u_8_strict(arr[4]), + ); + } + @protected EncodedWavTransfer dco_decode_encoded_wav_transfer(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -454,6 +558,25 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform ); } + @protected + EncodedPcmTransfer sse_decode_encoded_pcm_transfer( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_sessionId = sse_decode_CastedPrimitive_u_64(deserializer); + var var_profile = sse_decode_String(deserializer); + var var_carrier = sse_decode_String(deserializer); + var var_sampleRateHz = sse_decode_u_32(deserializer); + var var_pcmFrames = sse_decode_list_prim_u_8_strict(deserializer); + return EncodedPcmTransfer( + sessionId: var_sessionId, + profile: var_profile, + carrier: var_carrier, + sampleRateHz: var_sampleRateHz, + pcmFrames: var_pcmFrames, + ); + } + @protected EncodedWavTransfer sse_decode_encoded_wav_transfer( SseDeserializer deserializer, @@ -561,6 +684,19 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform sse_encode_u_32(self.samplesConsumed, serializer); } + @protected + void sse_encode_encoded_pcm_transfer( + EncodedPcmTransfer self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_CastedPrimitive_u_64(self.sessionId, serializer); + sse_encode_String(self.profile, serializer); + sse_encode_String(self.carrier, serializer); + sse_encode_u_32(self.sampleRateHz, serializer); + sse_encode_list_prim_u_8_strict(self.pcmFrames, serializer); + } + @protected void sse_encode_encoded_wav_transfer( EncodedWavTransfer self, diff --git a/apps/audio_modem/lib/src/rust/frb_generated.io.dart b/apps/audio_modem/lib/src/rust/frb_generated.io.dart index 5227d9e..6806659 100644 --- a/apps/audio_modem/lib/src/rust/frb_generated.io.dart +++ b/apps/audio_modem/lib/src/rust/frb_generated.io.dart @@ -34,6 +34,9 @@ abstract class AudioModemRustApiImplPlatform @protected DecodedTextTransfer dco_decode_decoded_text_transfer(dynamic raw); + @protected + EncodedPcmTransfer dco_decode_encoded_pcm_transfer(dynamic raw); + @protected EncodedWavTransfer dco_decode_encoded_wav_transfer(dynamic raw); @@ -68,6 +71,11 @@ abstract class AudioModemRustApiImplPlatform SseDeserializer deserializer, ); + @protected + EncodedPcmTransfer sse_decode_encoded_pcm_transfer( + SseDeserializer deserializer, + ); + @protected EncodedWavTransfer sse_decode_encoded_wav_transfer( SseDeserializer deserializer, @@ -112,6 +120,12 @@ abstract class AudioModemRustApiImplPlatform SseSerializer serializer, ); + @protected + void sse_encode_encoded_pcm_transfer( + EncodedPcmTransfer self, + SseSerializer serializer, + ); + @protected void sse_encode_encoded_wav_transfer( EncodedWavTransfer self, diff --git a/apps/audio_modem/lib/src/rust/frb_generated.web.dart b/apps/audio_modem/lib/src/rust/frb_generated.web.dart index 1a62336..3455bcc 100644 --- a/apps/audio_modem/lib/src/rust/frb_generated.web.dart +++ b/apps/audio_modem/lib/src/rust/frb_generated.web.dart @@ -36,6 +36,9 @@ abstract class AudioModemRustApiImplPlatform @protected DecodedTextTransfer dco_decode_decoded_text_transfer(dynamic raw); + @protected + EncodedPcmTransfer dco_decode_encoded_pcm_transfer(dynamic raw); + @protected EncodedWavTransfer dco_decode_encoded_wav_transfer(dynamic raw); @@ -70,6 +73,11 @@ abstract class AudioModemRustApiImplPlatform SseDeserializer deserializer, ); + @protected + EncodedPcmTransfer sse_decode_encoded_pcm_transfer( + SseDeserializer deserializer, + ); + @protected EncodedWavTransfer sse_decode_encoded_wav_transfer( SseDeserializer deserializer, @@ -114,6 +122,12 @@ abstract class AudioModemRustApiImplPlatform SseSerializer serializer, ); + @protected + void sse_encode_encoded_pcm_transfer( + EncodedPcmTransfer self, + SseSerializer serializer, + ); + @protected void sse_encode_encoded_wav_transfer( EncodedWavTransfer self, diff --git a/apps/audio_modem/lib/transfer/transfer_task.dart b/apps/audio_modem/lib/transfer/transfer_task.dart index 712bcbd..e8865b0 100644 --- a/apps/audio_modem/lib/transfer/transfer_task.dart +++ b/apps/audio_modem/lib/transfer/transfer_task.dart @@ -3,6 +3,7 @@ enum TransferTaskPhase { idle, preparing, + capturing, verifying, completed, rejected, @@ -25,6 +26,9 @@ class TransferTaskState { factory TransferTaskState.preparing(String message) => TransferTaskState._(phase: TransferTaskPhase.preparing, message: message); + factory TransferTaskState.capturing(String message) => + TransferTaskState._(phase: TransferTaskPhase.capturing, message: message); + factory TransferTaskState.verifying(String message) => TransferTaskState._(phase: TransferTaskPhase.verifying, message: message); @@ -56,6 +60,7 @@ class TransferTaskState { bool get isBusy => phase == TransferTaskPhase.preparing || + phase == TransferTaskPhase.capturing || phase == TransferTaskPhase.verifying; bool get isTerminal => @@ -67,6 +72,7 @@ class TransferTaskState { String get label => switch (phase) { TransferTaskPhase.idle => 'ОЖИДАНИЕ', TransferTaskPhase.preparing => 'ПОДГОТОВКА', + TransferTaskPhase.capturing => 'ПРИЁМ PCM', TransferTaskPhase.verifying => 'ПРОВЕРКА', TransferTaskPhase.completed => 'ГОТОВО', TransferTaskPhase.rejected => 'ОТКЛОНЕНО', diff --git a/apps/audio_modem/rust/src/api/wav_bootstrap.rs b/apps/audio_modem/rust/src/api/wav_bootstrap.rs index 5df07f4..6cd6074 100644 --- a/apps/audio_modem/rust/src/api/wav_bootstrap.rs +++ b/apps/audio_modem/rust/src/api/wav_bootstrap.rs @@ -9,6 +9,7 @@ use audio_modem_core::{ const MAX_BRIDGE_TEXT_BYTES: usize = 8 * 1024; const MAX_BRIDGE_FILE_BYTES: usize = 8 * 1024; +const MAX_LIVE_PCM_BYTES: usize = 4 * 1024 * 1024; #[derive(Clone, Debug)] pub struct EncodedWavTransfer { @@ -19,6 +20,15 @@ pub struct EncodedWavTransfer { pub wav_bytes: Vec, } +#[derive(Clone, Debug)] +pub struct EncodedPcmTransfer { + pub session_id: u64, + pub profile: String, + pub carrier: String, + pub sample_rate_hz: u32, + pub pcm_frames: Vec, +} + #[derive(Clone, Debug)] pub struct DecodedTextTransfer { pub session_id: u64, @@ -114,6 +124,38 @@ pub fn encode_file_to_wav( }) } +/// Encodes a text ADLP object as experimental Acoustic-1 PCM16 LE frames. +/// No live device is accessed here; the bootstrap carrier is intentionally excluded. +pub fn encode_text_to_live_pcm( + session_id: u64, + sender_callsign: String, + text: String, + profile: String, + carrier: String, +) -> Result { + if session_id == 0 { + return Err("session_id must be positive".to_owned()); + } + if text.len() > MAX_BRIDGE_TEXT_BYTES { + return Err("text exceeds the 8 KiB application limit".to_owned()); + } + let profile = parse_profile(&profile)?; + let carrier = parse_live_pcm_carrier(&carrier)?; + let object = WireObject::text(session_id, sender_callsign, text, profile) + .map_err(|error| error.to_string())?; + let pcm_frames = acoustic1::encode_pcm16le(&object).map_err(|error| error.to_string())?; + if pcm_frames.len() > MAX_LIVE_PCM_BYTES { + return Err("encoded PCM exceeds the live adapter frame-buffer limit".to_owned()); + } + Ok(EncodedPcmTransfer { + session_id, + profile: profile.as_str().to_owned(), + carrier: carrier.as_str().to_owned(), + sample_rate_hz: SAMPLE_RATE_HZ, + pcm_frames, + }) +} + /// Decodes and verifies selected-carrier WAV bytes before exposing text metadata to Flutter. pub fn decode_wav_text(wav_bytes: Vec, carrier: String) -> Result { let carrier = parse_carrier(&carrier)?; @@ -158,6 +200,36 @@ pub fn decode_wav_file(wav_bytes: Vec, carrier: String) -> Result, + carrier: String, +) -> Result { + if pcm_frames.len() > MAX_LIVE_PCM_BYTES { + return Err("captured PCM exceeds the live adapter frame-buffer limit".to_owned()); + } + let carrier = parse_live_pcm_carrier(&carrier)?; + let decoded = acoustic1::decode_pcm16le(&pcm_frames).map_err(|error| error.to_string())?; + let object = decoded.object; + if object.manifest.object_kind != ObjectKind::Text { + return Err("the PCM contains a non-text ADLP object".to_owned()); + } + let text = String::from_utf8(object.payload) + .map_err(|_| "the ADLP text payload is not valid UTF-8".to_owned())?; + let samples_consumed = u32::try_from(decoded.samples_consumed) + .map_err(|_| "decoded sample count exceeds bridge return range".to_owned())?; + Ok(DecodedTextTransfer { + session_id: object.manifest.session_id, + sender_callsign: object.manifest.sender_callsign, + profile: object.manifest.profile.as_str().to_owned(), + carrier: carrier.as_str().to_owned(), + text, + sample_rate_hz: decoded.sample_rate_hz, + samples_consumed, + }) +} + fn decode_selected_wav( wav_bytes: &[u8], carrier: Carrier, @@ -215,6 +287,15 @@ fn parse_carrier(value: &str) -> Result { } } +fn parse_live_pcm_carrier(value: &str) -> Result { + match parse_carrier(value)? { + Carrier::Acoustic1 => Ok(Carrier::Acoustic1), + Carrier::Bootstrap => { + Err("live PCM currently requires the experimental acoustic1 carrier".to_owned()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -274,6 +355,38 @@ mod tests { assert_eq!(decoded.carrier, "acoustic1"); } + #[test] + fn bridge_round_trips_experimental_acoustic1_live_pcm_text() { + let encoded = encode_text_to_live_pcm( + 13, + "AC1PCM".to_owned(), + "Rust-owned PCM boundary".to_owned(), + "balanced".to_owned(), + "acoustic1".to_owned(), + ) + .unwrap(); + let decoded = decode_live_pcm_text(encoded.pcm_frames, "acoustic1".to_owned()).unwrap(); + assert_eq!(decoded.session_id, 13); + assert_eq!(decoded.sender_callsign, "AC1PCM"); + assert_eq!(decoded.text, "Rust-owned PCM boundary"); + assert_eq!(decoded.sample_rate_hz, SAMPLE_RATE_HZ); + } + + #[test] + fn live_pcm_rejects_bootstrap_and_oversized_capture() { + assert!(encode_text_to_live_pcm( + 14, + "AC1PCM".to_owned(), + "wrong carrier".to_owned(), + "balanced".to_owned(), + "bootstrap".to_owned(), + ) + .is_err()); + assert!( + decode_live_pcm_text(vec![0; MAX_LIVE_PCM_BYTES + 1], "acoustic1".to_owned(),).is_err() + ); + } + #[test] fn bridge_rejects_an_acoustic1_wav_with_bootstrap_decoder() { let encoded = encode_text_to_wav( diff --git a/apps/audio_modem/rust/src/frb_generated.rs b/apps/audio_modem/rust/src/frb_generated.rs index 3da9b0d..0ce8d6b 100644 --- a/apps/audio_modem/rust/src/frb_generated.rs +++ b/apps/audio_modem/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2115612387; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1462031589; // Section: executor @@ -46,6 +46,43 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs +fn wire__crate__api__wav_bootstrap__decode_live_pcm_text_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decode_live_pcm_text", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_pcm_frames = >::sse_decode(&mut deserializer); + let api_carrier = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::wav_bootstrap::decode_live_pcm_text( + api_pcm_frames, + api_carrier, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__wav_bootstrap__decode_wav_file_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -163,6 +200,49 @@ fn wire__crate__api__wav_bootstrap__encode_file_to_wav_impl( }, ) } +fn wire__crate__api__wav_bootstrap__encode_text_to_live_pcm_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "encode_text_to_live_pcm", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_session_id = ::sse_decode(&mut deserializer); + let api_sender_callsign = ::sse_decode(&mut deserializer); + let api_text = ::sse_decode(&mut deserializer); + let api_profile = ::sse_decode(&mut deserializer); + let api_carrier = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::wav_bootstrap::encode_text_to_live_pcm( + api_session_id, + api_sender_callsign, + api_text, + api_profile, + api_carrier, + )?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__wav_bootstrap__encode_text_to_wav_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -265,6 +345,24 @@ impl SseDecode for crate::api::wav_bootstrap::DecodedTextTransfer { } } +impl SseDecode for crate::api::wav_bootstrap::EncodedPcmTransfer { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_sessionId = ::sse_decode(deserializer); + let mut var_profile = ::sse_decode(deserializer); + let mut var_carrier = ::sse_decode(deserializer); + let mut var_sampleRateHz = ::sse_decode(deserializer); + let mut var_pcmFrames = >::sse_decode(deserializer); + return crate::api::wav_bootstrap::EncodedPcmTransfer { + session_id: var_sessionId, + profile: var_profile, + carrier: var_carrier, + sample_rate_hz: var_sampleRateHz, + pcm_frames: var_pcmFrames, + }; + } +} + impl SseDecode for crate::api::wav_bootstrap::EncodedWavTransfer { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -339,19 +437,31 @@ fn pde_ffi_dispatcher_primary_impl( ) { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 1 => { + 1 => wire__crate__api__wav_bootstrap__decode_live_pcm_text_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 2 => { wire__crate__api__wav_bootstrap__decode_wav_file_impl(port, ptr, rust_vec_len, data_len) } - 2 => { + 3 => { wire__crate__api__wav_bootstrap__decode_wav_text_impl(port, ptr, rust_vec_len, data_len) } - 3 => wire__crate__api__wav_bootstrap__encode_file_to_wav_impl( + 4 => wire__crate__api__wav_bootstrap__encode_file_to_wav_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 5 => wire__crate__api__wav_bootstrap__encode_text_to_live_pcm_impl( port, ptr, rust_vec_len, data_len, ), - 4 => wire__crate__api__wav_bootstrap__encode_text_to_wav_impl( + 6 => wire__crate__api__wav_bootstrap__encode_text_to_wav_impl( port, ptr, rust_vec_len, @@ -430,6 +540,30 @@ impl flutter_rust_bridge::IntoIntoDart flutter_rust_bridge::for_generated::DartAbi { + [ + self.session_id.into_into_dart().into_dart(), + self.profile.into_into_dart().into_dart(), + self.carrier.into_into_dart().into_dart(), + self.sample_rate_hz.into_into_dart().into_dart(), + self.pcm_frames.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::wav_bootstrap::EncodedPcmTransfer +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::wav_bootstrap::EncodedPcmTransfer +{ + fn into_into_dart(self) -> crate::api::wav_bootstrap::EncodedPcmTransfer { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::wav_bootstrap::EncodedWavTransfer { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -489,6 +623,17 @@ impl SseEncode for crate::api::wav_bootstrap::DecodedTextTransfer { } } +impl SseEncode for crate::api::wav_bootstrap::EncodedPcmTransfer { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.session_id, serializer); + ::sse_encode(self.profile, serializer); + ::sse_encode(self.carrier, serializer); + ::sse_encode(self.sample_rate_hz, serializer); + >::sse_encode(self.pcm_frames, serializer); + } +} + impl SseEncode for crate::api::wav_bootstrap::EncodedWavTransfer { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/apps/audio_modem/test/android_live_audio_adapter_test.dart b/apps/audio_modem/test/android_live_audio_adapter_test.dart new file mode 100644 index 0000000..b6019e8 --- /dev/null +++ b/apps/audio_modem/test/android_live_audio_adapter_test.dart @@ -0,0 +1,94 @@ +import 'package:audio_modem/platform/android_live_audio_adapter.dart'; +import 'package:audio_modem/platform/live_audio_adapter.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const methods = MethodChannel('test.audiomodem/live_audio/methods'); + const captureEvents = EventChannel('test.audiomodem/live_audio/capture'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + messenger.setMockMethodCallHandler(methods, null); + messenger.setMockStreamHandler(captureEvents, null); + }); + + test( + 'Android adapter rejects incompatible PCM before native invocation', + () async { + final adapter = AndroidLiveAudioAdapter.forTesting( + methods: methods, + captureEvents: captureEvents, + ); + var invoked = false; + messenger.setMockMethodCallHandler(methods, (_) async { + invoked = true; + return null; + }); + + const incompatible = PcmStreamFormat( + sampleRateHz: 44100, + channels: 1, + sampleFormat: PcmSampleFormat.signedPcm16LittleEndian, + ); + await expectLater( + adapter.startPlayback( + pcmFrames: Uint8List.fromList([0, 1]), + format: incompatible, + ), + throwsA(isA()), + ); + expect(invoked, isFalse); + }, + ); + + test( + 'Android adapter sends exact v1 PCM arguments to native playback', + () async { + final adapter = AndroidLiveAudioAdapter.forTesting( + methods: methods, + captureEvents: captureEvents, + ); + MethodCall? call; + messenger.setMockMethodCallHandler(methods, (value) async { + call = value; + return null; + }); + + await adapter.startPlayback( + pcmFrames: Uint8List.fromList([0, 1, 2, 3]), + format: PcmStreamFormat.audioModemV1, + ); + + expect(call?.method, 'startPlayback'); + expect(call?.arguments['sampleRateHz'], 48000); + expect(call?.arguments['channels'], 1); + expect(call?.arguments['sampleFormat'], 'pcm_s16le'); + expect(call?.arguments['pcmFrames'], Uint8List.fromList([0, 1, 2, 3])); + }, + ); + + test('Android adapter forwards only valid capture PCM16 frames', () async { + final adapter = AndroidLiveAudioAdapter.forTesting( + methods: methods, + captureEvents: captureEvents, + ); + messenger.setMockMethodCallHandler(methods, (_) async => null); + messenger.setMockStreamHandler( + captureEvents, + MockStreamHandler.inline( + onListen: (_, events) { + events.success(Uint8List.fromList([4, 5])); + }, + ), + ); + + final frames = await adapter + .startCapture(format: PcmStreamFormat.audioModemV1) + .first; + expect(frames, Uint8List.fromList([4, 5])); + }); +} diff --git a/apps/audio_modem/test/widget_test.dart b/apps/audio_modem/test/widget_test.dart index 643f4a1..eaea7bb 100644 --- a/apps/audio_modem/test/widget_test.dart +++ b/apps/audio_modem/test/widget_test.dart @@ -97,6 +97,30 @@ class _FakeWavBridge implements WavBootstrapBridge { samplesConsumed: 768, ); } + + @override + Future encodeTextToLivePcm({ + required int sessionId, + required String senderCallsign, + required String text, + required String profile, + required String carrier, + }) async { + encodedCarrier = carrier; + return LivePcmBuildResult( + sessionId: sessionId, + profile: profile, + carrier: carrier, + sampleRateHz: 48000, + pcmFrames: Uint8List.fromList([0, 1]), + ); + } + + @override + Future decodeLivePcmText({ + required Uint8List pcmFrames, + required String carrier, + }) => decodeWav(wavBytes: pcmFrames, carrier: carrier); } class _FakeWavFileAdapter implements WavFileAdapter { @@ -397,7 +421,7 @@ void main() { final buildButton = find.widgetWithText( FilledButton, - 'Собрать и проверить WAV', + 'Передать Android PCM', ); await tester.ensureVisible(buildButton); await tester.tap(buildButton); diff --git a/crates/audio-modem-core/src/acoustic1.rs b/crates/audio-modem-core/src/acoustic1.rs index 44fb668..f09dd45 100644 --- a/crates/audio-modem-core/src/acoustic1.rs +++ b/crates/audio-modem-core/src/acoustic1.rs @@ -101,6 +101,20 @@ pub fn encode_wav(object: &WireObject) -> Result, Acoustic1Error> { Ok(make_canonical_wav(&encode_samples(object)?)?) } +/// Encodes one Acoustic-1 object as mono signed PCM16 little-endian frames. +/// +/// This is the only raw-frame form intended for a future live adapter. The +/// carrier, FEC and ADLP object remain Rust-owned; callers must not generate +/// symbols or apply a format conversion themselves. +pub fn encode_pcm16le(object: &WireObject) -> Result, Acoustic1Error> { + let samples = encode_samples(object)?; + let mut pcm = Vec::with_capacity(samples.len() * 2); + for sample in samples { + pcm.extend_from_slice(&sample.to_le_bytes()); + } + Ok(pcm) +} + pub fn decode_wav(wav: &[u8]) -> Result { let (sample_rate_hz, samples) = parse_canonical_wav(wav)?; if sample_rate_hz != SAMPLE_RATE_HZ { @@ -108,7 +122,23 @@ pub fn decode_wav(wav: &[u8]) -> Result "sample rate must be 48 kHz", ))); } + decode_samples(&samples) +} + +/// Decodes mono signed PCM16 little-endian frames using the Acoustic-1 carrier. +pub fn decode_pcm16le(pcm: &[u8]) -> Result { + let (frames, remainder) = pcm.as_chunks::<2>(); + if !remainder.is_empty() { + return Err(Acoustic1Error::TruncatedSignal); + } + let samples = frames + .iter() + .map(|sample| i16::from_le_bytes(*sample)) + .collect::>(); + decode_samples(&samples) +} +fn decode_samples(samples: &[i16]) -> Result { for config in PROFILE_CONFIGS { let minimum_samples = (PREAMBLE_BITS + 16 + 56) * config.samples_per_symbol; if samples.len() < minimum_samples { @@ -116,7 +146,7 @@ pub fn decode_wav(wav: &[u8]) -> Result } let maximum_offset = MAX_SYNC_OFFSET_SAMPLES.min(samples.len() - minimum_samples); for offset in 0..=maximum_offset { - if let Ok(decoded) = decode_candidate(&samples, offset, config) { + if let Ok(decoded) = decode_candidate(samples, offset, config) { return Ok(decoded); } } @@ -353,6 +383,26 @@ mod tests { } } + #[test] + fn every_profile_round_trips_through_acoustic1_pcm16le() { + for config in PROFILE_CONFIGS { + let object = test_object(config.profile); + let pcm = encode_pcm16le(&object).unwrap(); + let decoded = decode_pcm16le(&pcm).unwrap(); + assert_eq!(decoded.object, object); + assert_eq!(decoded.sample_rate_hz, SAMPLE_RATE_HZ); + assert_eq!(decoded.frame_start_candidate_samples, 0); + } + } + + #[test] + fn odd_length_pcm16le_is_rejected() { + assert!(matches!( + decode_pcm16le(&[0]), + Err(Acoustic1Error::TruncatedSignal) + )); + } + #[test] fn golden_acoustic1_fixture_decodes_and_matches_deterministic_encoder() { let fixture = include_bytes!("../tests/fixtures/acoustic-1-v1-text-balanced.wav"); diff --git a/docs/README.md b/docs/README.md index 701101f..61b82ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ This directory holds the human-readable technical documentation for AudioModem. | Project | [Roadmap](roadmap.md) | [Дорожная карта](roadmap_RU.md) | Verified work and upcoming milestones | | Project | [Implementation plan](implementation-plan.md) | [План реализации](implementation-plan_RU.md) | Test-gated order from core contracts through Flutter shell and route evidence | | Architecture | [Flutter ↔ Rust WAV bridge](architecture/flutter-rust-bridge.md) | [Flutter ↔ Rust WAV bridge на русском](architecture/flutter-rust-bridge_RU.md) | Native facade and in-memory WAV verification boundary | +| Architecture | [Android foreground live audio](architecture/android-live-audio.md) | [Android foreground live audio на русском](architecture/android-live-audio_RU.md) | Experimental source adapter and evidence boundary | | Research | [Flutter Rust Bridge integration](research/flutter-rust-bridge-integration.md) | [Flutter Rust Bridge integration на русском](research/flutter-rust-bridge-integration_RU.md) | Generated-code layout and regeneration command | | Research | [Acoustic-1 PHY sources](research/acoustic-1-phy-sources.md) | [Источники Acoustic-1 PHY](research/acoustic-1-phy-sources_RU.md) | Design sources and explicit receiver/FEC constraints | | Research | [Acoustic-2 measurement sources](research/acoustic-2-measurement-sources.md) | [Источники измерений Acoustic-2](research/acoustic-2-measurement-sources_RU.md) | Controlled PCM transform and timing-acquisition constraints | diff --git a/docs/README_RU.md b/docs/README_RU.md index 522e1ee..edeab7b 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -17,6 +17,7 @@ | Project | [Roadmap](roadmap.md) | [Дорожная карта](roadmap_RU.md) | Проверенная работа и ближайшие milestones | | Project | [Implementation plan](implementation-plan.md) | [План реализации](implementation-plan_RU.md) | Test-gated порядок от core contracts через Flutter shell к route evidence | | Architecture | [Flutter ↔ Rust WAV bridge](architecture/flutter-rust-bridge.md) | [Flutter ↔ Rust WAV bridge на русском](architecture/flutter-rust-bridge_RU.md) | Граница native facade и проверки WAV в памяти | +| Architecture | [Android foreground live audio](architecture/android-live-audio.md) | [Android foreground live audio на русском](architecture/android-live-audio_RU.md) | Experimental source adapter и evidence boundary | | Research | [Flutter Rust Bridge integration](research/flutter-rust-bridge-integration.md) | [Flutter Rust Bridge integration на русском](research/flutter-rust-bridge-integration_RU.md) | Структура generated code и команда повторной генерации | | Research | [Acoustic-1 PHY sources](research/acoustic-1-phy-sources.md) | [Источники Acoustic-1 PHY](research/acoustic-1-phy-sources_RU.md) | Источники дизайна и явные receiver/FEC constraints | | Research | [Acoustic-2 measurement sources](research/acoustic-2-measurement-sources.md) | [Источники измерений Acoustic-2](research/acoustic-2-measurement-sources_RU.md) | Ограничения controlled PCM transforms и timing acquisition | diff --git a/docs/architecture/android-live-audio.md b/docs/architecture/android-live-audio.md new file mode 100644 index 0000000..f73b1c6 --- /dev/null +++ b/docs/architecture/android-live-audio.md @@ -0,0 +1,46 @@ +# Android foreground live-audio implementation slice + +**Status:** Experimental source implementation · **Last reviewed:** 2026-08-21 · **English (canonical)** · [Русский](android-live-audio_RU.md) + +This document describes the source implementation bounded by the [Android foreground live-audio adapter v1 RFC](../../spec/android-live-audio-adapter-v1.md). It does not establish an Android target build, a granted runtime permission, an observed physical route, acoustic interoperability, device compatibility or support status. + +## Implemented boundary + +| Layer | Implemented responsibility | Not implemented or not established | +| --- | --- | --- | +| Rust core | Acoustic-1 encodes/decodes mono signed PCM16 LE directly, reusing its existing carrier/FEC/ADLP path. | Live timing recovery, AGC, echo cancellation, resampling or a physical-channel claim. | +| Rust bridge | Bounded text-to-live-PCM and captured-PCM-to-verified-text methods accept only `acoustic1`; bootstrap remains WAV-only. | File live PCM, unbounded stream decode or hardware access. | +| Flutter | A typed Android adapter validates v1 PCM, starts user-initiated playback/capture and passes capture frames to Rust only after the user stops capture. | ADLP/DSP in Dart, background operation, automatic resume, device picker or retained raw PCM. | +| Android host | `AudioTrack` streaming playback, `AudioRecord` capture, contextual `RECORD_AUDIO`, playback focus, stop/release on interruption/lifecycle stop, and an EventChannel for transient capture frames. | Bluetooth/cable/radio routing, `setPreferredDevice`, foreground service, duplex, Android device validation or support claim. | + +The only encoded live object is a bounded text object using experimental `acoustic1`. Before `AudioTrack` starts, Flutter asks Rust to encode PCM and decodes the same in-memory PCM through Rust once as a codec boundary check. That check proves neither acoustic output nor receiver delivery. + +```text +text → Rust ADLP + Acoustic-1 PCM → Flutter Android adapter → AudioTrack + │ +AudioRecord → transient EventChannel PCM → Flutter → Rust Acoustic-1 + ADLP verification +``` + +Capture starts only after the user presses the Android capture control. The Kotlin host requests `RECORD_AUDIO` only then. On user stop, route/lifecycle stop or event cancellation, the host stops and releases native objects; Flutter discards the temporary PCM buffer after decode attempt. Playback requests focus immediately before `AudioTrack.play()` and stops/releases on focus loss. These choices implement the Android focus and permission constraints documented in the RFC. [1] [2] [3] + +## Evidence state + +The committed Rust tests, Flutter MethodChannel tests and portable widget tests exercise contract paths. They are not native Android or hardware tests. A target build, permission prompt, focus transition, capture device format, actual output route and speaker-to-microphone decode remain unverified until run on real hardware. + +| Claim candidate | Current evidence | Permitted wording | +| --- | --- | --- | +| Rust live PCM conversion | Rust core/bridge unit tests | `source-tested PCM contract` | +| Flutter lifecycle mapping | Portable MethodChannel and widget tests | `source-tested adapter behavior` | +| Android compilation/runtime | None committed yet | No claim | +| Physical route | No measurement report | No claim | +| Platform or route support | No reviewed build and route evidence | No claim | + +The [Android speaker-to-microphone template](../../tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json) remains unexecuted. A future real `physical_route` record must include the Android adapter observation fields enforced by the validator and must not retain or upload raw PCM by default. + +## References + +[1] [Android Developers: `AudioRecord` API reference](https://developer.android.com/reference/android/media/AudioRecord) + +[2] [Android Developers: `AudioTrack` API reference](https://developer.android.com/reference/android/media/AudioTrack) + +[3] [Android Developers: Manage audio focus](https://developer.android.com/media/optimize/audio-focus) diff --git a/docs/architecture/android-live-audio_RU.md b/docs/architecture/android-live-audio_RU.md new file mode 100644 index 0000000..38fea20 --- /dev/null +++ b/docs/architecture/android-live-audio_RU.md @@ -0,0 +1,48 @@ +# Android foreground live-audio implementation slice + +[English (canonical)](android-live-audio.md) · **Русский перевод** + +> **Translation of:** [docs/architecture/android-live-audio.md](android-live-audio.md). **Last synced:** 2026-08-21. + +**Статус:** Experimental source implementation. Документ описывает source implementation в границах [Android foreground live-audio adapter v1 RFC](../../spec/android-live-audio-adapter-v1_RU.md). Он не устанавливает Android target build, granted runtime permission, observed physical route, acoustic interoperability, device compatibility или support status. + +## Реализованная граница + +| Слой | Реализованная ответственность | Не реализовано или не установлено | +| --- | --- | --- | +| Rust core | Acoustic-1 напрямую encodes/decodes mono signed PCM16 LE, используя существующий carrier/FEC/ADLP path. | Live timing recovery, AGC, echo cancellation, resampling или physical-channel claim. | +| Rust bridge | Bounded text-to-live-PCM и captured-PCM-to-verified-text methods принимают только `acoustic1`; bootstrap остаётся WAV-only. | File live PCM, unbounded stream decode или hardware access. | +| Flutter | Typed Android adapter валидирует v1 PCM, запускает user-initiated playback/capture и передаёт capture frames в Rust только после user stop. | ADLP/DSP в Dart, background operation, automatic resume, device picker или retained raw PCM. | +| Android host | `AudioTrack` streaming playback, `AudioRecord` capture, contextual `RECORD_AUDIO`, playback focus, stop/release при interruption/lifecycle stop и EventChannel для transient capture frames. | Bluetooth/cable/radio routing, `setPreferredDevice`, foreground service, duplex, Android device validation или support claim. | + +Единственный encoded live object — bounded text object с experimental `acoustic1`. Перед `AudioTrack` Flutter просит Rust encode PCM и один раз декодирует тот же in-memory PCM через Rust как codec boundary check. Эта проверка не доказывает acoustic output или receiver delivery. + +```text +text → Rust ADLP + Acoustic-1 PCM → Flutter Android adapter → AudioTrack + │ +AudioRecord → transient EventChannel PCM → Flutter → Rust Acoustic-1 + ADLP verification +``` + +Capture начинается только после нажатия Android capture control. Kotlin host запрашивает `RECORD_AUDIO` только тогда. При user stop, route/lifecycle stop или event cancellation host stop/release native objects; Flutter discard temporary PCM buffer после decode attempt. Playback запрашивает focus непосредственно перед `AudioTrack.play()` и stop/release при focus loss. Эти решения реализуют Android focus и permission constraints из RFC. [1] [2] [3] + +## Evidence state + +Committed Rust tests, Flutter MethodChannel tests и portable widget tests проверяют contract paths. Это не native Android или hardware tests. Target build, permission prompt, focus transition, capture device format, actual output route и speaker-to-microphone decode остаются непроверенными до запуска на real hardware. + +| Claim candidate | Current evidence | Permitted wording | +| --- | --- | --- | +| Rust live PCM conversion | Rust core/bridge unit tests | `source-tested PCM contract` | +| Flutter lifecycle mapping | Portable MethodChannel и widget tests | `source-tested adapter behavior` | +| Android compilation/runtime | Коммитнутых данных нет | No claim | +| Physical route | Нет measurement report | No claim | +| Platform или route support | Нет reviewed build и route evidence | No claim | + +[Android speaker-to-microphone template](../../tools/device-acceptance/fixtures/android-speaker-microphone-template-v1.json) остаётся unexecuted. Future real `physical_route` record обязан включать Android adapter observation fields, enforced validator'ом, и не должен retain/upload raw PCM по умолчанию. + +## References + +[1] [Android Developers: `AudioRecord` API reference](https://developer.android.com/reference/android/media/AudioRecord) + +[2] [Android Developers: `AudioTrack` API reference](https://developer.android.com/reference/android/media/AudioTrack) + +[3] [Android Developers: Manage audio focus](https://developer.android.com/media/optimize/audio-focus) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e5ca7f1..b30515f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -76,4 +76,4 @@ After repeated reviewed evidence, compatibility documentation may describe the e ## Immediate execution order -The active milestone is **Stage E Android route design review**. The repository now has a bilingual Android foreground RFC, research decision, unexecuted reporting template and validator extension that requires exact v1 PCM, permission, focus, stop and privacy fields for future Android speaker-to-microphone measurements. The next implementation change may add a native Android adapter only after this design slice is reviewed and merged. It must begin unavailable outside Android API 26+ foreground scope and must not create a physical-route claim without real reviewed observations. +The active milestone is **Stage E Android source implementation validation**. The repository now has a bilingual Android foreground RFC, research decision, unexecuted reporting template, validator extension and experimental Kotlin/Flutter/Rust adapter source. The next gate is a reproducible Android build followed by real reviewed observations; source and portable tests alone do not establish runtime behavior or a physical route. The adapter must remain unavailable outside Android API 26+ foreground scope and must not create a physical-route claim without reviewed evidence. diff --git a/docs/implementation-plan_RU.md b/docs/implementation-plan_RU.md index 60b77fc..74792d5 100644 --- a/docs/implementation-plan_RU.md +++ b/docs/implementation-plan_RU.md @@ -78,4 +78,4 @@ Text не должен молча использоваться как image ил ## Ближайший порядок исполнения -Активный milestone — **Stage E Android route design review**. В repository теперь есть bilingual Android foreground RFC, research decision, unexecuted reporting template и validator extension, требующий exact v1 PCM, permission, focus, stop и privacy fields для future Android speaker-to-microphone measurements. Next implementation change может добавить native Android adapter только после review и merge этого design slice. Он должен начинаться unavailable вне Android API 26+ foreground scope и не может создавать physical-route claim без real reviewed observations. +Активный milestone — **Stage E Android source implementation validation**. В repository теперь есть bilingual Android foreground RFC, research decision, unexecuted reporting template, validator extension и experimental Kotlin/Flutter/Rust adapter source. Next gate — reproducible Android build, затем real reviewed observations; source и portable tests сами по себе не устанавливают runtime behavior или physical route. Adapter остаётся unavailable вне Android API 26+ foreground scope и не может создавать physical-route claim без reviewed evidence. diff --git a/docs/reference/platform-support.md b/docs/reference/platform-support.md index 9a0e3d0..b71b072 100644 --- a/docs/reference/platform-support.md +++ b/docs/reference/platform-support.md @@ -8,7 +8,7 @@ AudioModem keeps Flutter runners for Android, iOS, Windows, macOS, Linux and Web | Platform | Flutter runner | Rust/WAV integration | Local WAV workflow | Live audio | Notes | | --- | --- | --- | --- | --- | --- | -| Android | Scaffolded | Bridge source integrated; target build/run not verified | Source implementation; target dialog not verified | Draft experimental design | [Foreground adapter RFC](../../spec/android-live-audio-adapter-v1.md) and validator extension exist; target build, permission behavior, adapter and route evidence remain unverified. | +| Android | Scaffolded | Bridge source integrated; target build/run not verified | Source implementation; target dialog not verified | Experimental source implementation | [Foreground adapter RFC](../../spec/android-live-audio-adapter-v1.md), Kotlin/Flutter/Rust adapter source and validator extension exist; target build, permission behavior, runtime and route evidence remain unverified. | | iOS | Scaffolded | Bridge source integrated; target build/run not verified | Source implementation; target dialog not verified | Planned | Requires target build, permission and route-adapter acceptance tests. | | Windows | Scaffolded | Bridge source integrated; target build/run not verified | Source implementation; target dialog not verified | Planned | Requires native device enumeration and routing tests. | | macOS | Scaffolded | Bridge source integrated; target build/run not verified | Source implementation; target dialog not verified | Planned | Requires native device enumeration and routing tests. | diff --git a/docs/reference/platform-support_RU.md b/docs/reference/platform-support_RU.md index 841b4ba..2097acd 100644 --- a/docs/reference/platform-support_RU.md +++ b/docs/reference/platform-support_RU.md @@ -10,7 +10,7 @@ AudioModem хранит Flutter runners для Android, iOS, Windows, macOS, Lin | Платформа | Flutter runner | Rust/WAV integration | Локальный WAV workflow | Live audio | Примечание | | --- | --- | --- | --- | --- | --- | -| Android | Scaffolded | Bridge source integrated; target build/run не проверен | Source implementation; target dialog не проверен | Draft experimental design | Существуют [foreground adapter RFC](../../spec/android-live-audio-adapter-v1_RU.md) и validator extension; target build, permission behavior, adapter и route evidence остаются непроверенными. | +| Android | Scaffolded | Bridge source integrated; target build/run не проверен | Source implementation; target dialog не проверен | Experimental source implementation | Существуют [foreground adapter RFC](../../spec/android-live-audio-adapter-v1_RU.md), Kotlin/Flutter/Rust adapter source и validator extension; target build, permission behavior, runtime и route evidence остаются непроверенными. | | iOS | Scaffolded | Bridge source integrated; target build/run не проверен | Source implementation; target dialog не проверен | Планируется | Нужны target build, permission и route-adapter acceptance tests. | | Windows | Scaffolded | Bridge source integrated; target build/run не проверен | Source implementation; target dialog не проверен | Планируется | Нужны native device enumeration и routing tests. | | macOS | Scaffolded | Bridge source integrated; target build/run не проверен | Source implementation; target dialog не проверен | Планируется | Нужны native device enumeration и routing tests. | diff --git a/docs/roadmap.md b/docs/roadmap.md index fc456a8..97a8d36 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -**Last reviewed:** 2026-08-20 · **English (canonical)** · [Русский](roadmap_RU.md) +**Last reviewed:** 2026-08-21 · **English (canonical)** · [Русский](roadmap_RU.md) This roadmap describes intended order, not dates or guaranteed delivery. It distinguishes verified repository state from research and planned product capability. @@ -14,8 +14,8 @@ This roadmap describes intended order, not dates or guaranteed delivery. It dist | Acoustic-1 | Experimental controlled carrier | B-FSK framing, bounded synchronisation, Hamming(7,4), profile-driven symbols and golden vectors exist for PCM/WAV tests; no speaker-to-microphone claim. | | Acoustic-2 | Experimental measurement harness | Declared integer PCM transforms, bounded acquisition observables and golden measurement contract exist around Acoustic-1; no device or channel metric claim. | | Device-acceptance infrastructure | Completed reporting/tooling contract | Schema, validator, privacy-preserving intake template and decision gates prepare future evidence; no physical-route observation or supported device claim exists. | -| Live-audio adapter contract | Completed unavailable scaffold | Typed PCM/lifecycle boundary and unavailable behavior tests exist; no plugin, permission, capture, playback or physical route is enabled. | -| Android foreground live-audio design | Draft experimental route design | Android API 26+ RFC, research decision and route-observation validator extension exist; no Android adapter, permission flow, target build or physical-route result exists. | +| Live-audio adapter contract | Completed typed boundary | Unavailable behavior remains the default outside declared scope; typed PCM/lifecycle tests exist. | +| Android foreground live audio | Experimental source implementation | Android API 26+ Kotlin/Flutter/Rust source, permission/focus/stop contract tests and route-observation validator extension exist; target build, runtime behavior and physical-route result remain unverified. | | Audio adapters | Planned | Capture/playback, cable, Bluetooth and radio-interface adapters with per-platform acceptance tests. | | Trust and encryption | Planned RFC | Key lifecycle, manual/QR exchange, authenticated encryption, verification UX and independent security review. | | Release engineering | Planned | Signed packages, compatibility matrix, changelog, SBOM/checksums and clear support policy. | diff --git a/docs/roadmap_RU.md b/docs/roadmap_RU.md index 99ab94b..3095ad1 100644 --- a/docs/roadmap_RU.md +++ b/docs/roadmap_RU.md @@ -16,8 +16,8 @@ | Acoustic-1 | Экспериментальный controlled carrier | B-FSK framing, bounded synchronisation, Hamming(7,4), profile-driven symbols и golden vectors существуют для PCM/WAV tests; speaker-to-microphone claim отсутствует. | | Acoustic-2 | Экспериментальный measurement harness | Вокруг Acoustic-1 существуют declared integer PCM transforms, bounded acquisition observables и golden measurement contract; device или channel metric claim отсутствует. | | Device-acceptance infrastructure | Завершён reporting/tooling contract | Schema, validator, privacy-preserving intake template и decision gates готовят future evidence; physical-route observation или supported device claim отсутствуют. | -| Live-audio adapter contract | Завершён unavailable scaffold | Существуют typed PCM/lifecycle boundary и unavailable behavior tests; plugin, permission, capture, playback или physical route не включены. | -| Android foreground live-audio design | Draft experimental route design | Существуют Android API 26+ RFC, research decision и route-observation validator extension; Android adapter, permission flow, target build или physical-route result отсутствуют. | +| Live-audio adapter contract | Завершён typed boundary | Unavailable behavior остаётся default вне declared scope; существуют typed PCM/lifecycle tests. | +| Android foreground live audio | Experimental source implementation | Существуют Android API 26+ Kotlin/Flutter/Rust source, permission/focus/stop contract tests и route-observation validator extension; target build, runtime behavior и physical-route result остаются непроверенными. | | Audio adapters | Планируется | Capture/playback, cable, Bluetooth и radio-interface adapters с per-platform acceptance tests. | | Trust и encryption | Planned RFC | Key lifecycle, manual/QR exchange, authenticated encryption, verification UX и independent security review. | | Release engineering | Планируется | Signed packages, compatibility matrix, changelog, SBOM/checksums и clear support policy. | diff --git a/site/index.html b/site/index.html index 1af1d49..099c63b 100644 --- a/site/index.html +++ b/site/index.html @@ -15,7 +15,7 @@

First test

Start with a reproducible WAV round trip.

WAV removes unknown live-audio characteristics. The native app can export a verified WAV and import it for Rust-side validation; Acoustic-1 has a controlled B-FSK carrier and Acoustic-2 measures declared PCM transforms, neither of which is a live-audio claim.

CLI / bootstrapcargo run -p adlp-cli -- encode-text hello.wav N1 "Hello" reliable
  cargo run -p adlp-cli -- decode hello.wav
Open the short guide →

Architecture

A small app over an independent Rust core.

FlutterUI, routes, file selection and diagnostics
→
Rust bridgeOne codec implementation for the client
→
ADLP + DSPContainer, WAV and future PHY profiles
Architecture and stack →
-

Status

Current slice—without promises beyond the implementation.

✓ ADLP v1 + CRC-32C
done
✓ Deterministic PCM/WAV bootstrap
done
✓ Flutter ↔ Rust bridge
native text/file-to-WAV verification
✓ Local WAV import / export
native dialogs + Rust validation
✓ Experimental file-object WAV
bounded local file selection, receipt and save; no live route
✓ Experimental Acoustic-1
B-FSK controlled PCM/WAV codec + golden vector
✓ Acoustic-2 measurement
declared PCM transforms + acquisition observables
✓ Device-acceptance protocol
evidence schema and review gates; no device result
✓ Live-audio adapter contract
typed unavailable scaffold; no capture, playback or route
• Android foreground route RFC
draft evidence design; no Android adapter or device observation
• Live audio and encryption
planned
See roadmap →
+

Status

Current slice—without promises beyond the implementation.

✓ ADLP v1 + CRC-32C
done
✓ Deterministic PCM/WAV bootstrap
done
✓ Flutter ↔ Rust bridge
native text/file-to-WAV verification
✓ Local WAV import / export
native dialogs + Rust validation
✓ Experimental file-object WAV
bounded local file selection, receipt and save; no live route
✓ Experimental Acoustic-1
B-FSK controlled PCM/WAV codec + golden vector
✓ Acoustic-2 measurement
declared PCM transforms + acquisition observables
✓ Device-acceptance protocol
evidence schema and review gates; no device result
✓ Live-audio adapter contract
typed unavailable scaffold; no capture, playback or route
• Android foreground adapter source
experimental Kotlin/Flutter/Rust source; no target build or device observation
• Live audio and encryption
planned
See roadmap →
diff --git a/site/ru/index.html b/site/ru/index.html index aaee836..c9fa348 100644 --- a/site/ru/index.html +++ b/site/ru/index.html @@ -15,7 +15,7 @@

Первый тест

Начните с воспроизводимого WAV round trip.

WAV исключает неизвестные свойства live-аудиотракта. Native app может экспортировать проверенный WAV и импортировать его для Rust-side validation; Acoustic-1 имеет controlled B-FSK carrier, а Acoustic-2 измеряет declared PCM transforms — ни один из них не является live-audio claim.

CLI / bootstrapcargo run -p adlp-cli -- encode-text hello.wav N1 "Привет" reliable
  cargo run -p adlp-cli -- decode hello.wav
Открыть краткое руководство →

Архитектура

Небольшое приложение поверх независимого Rust core.

FlutterUI, маршруты, выбор файла и диагностика
→
Rust bridgeОдна реализация codec для клиента
→
ADLP + DSPКонтейнер, WAV и будущие PHY profiles
Архитектура и стек →
-

Статус

Текущий срез — без обещаний сверх реализации.

✓ ADLP v1 + CRC-32C
готово
✓ Детерминированный PCM/WAV bootstrap
готово
✓ Flutter ↔ Rust bridge
native text/file-to-WAV verification
✓ Локальный WAV import / export
native dialogs + Rust validation
✓ Experimental file-object WAV
bounded local file selection, receipt и save; live route отсутствует
✓ Experimental Acoustic-1
B-FSK controlled PCM/WAV codec + golden vector
✓ Acoustic-2 measurement
declared PCM transforms + acquisition observables
✓ Device-acceptance protocol
evidence schema и review gates; device result отсутствует
✓ Live-audio adapter contract
typed unavailable scaffold; capture, playback и route отсутствуют
• Android foreground route RFC
draft evidence design; Android adapter и device observation отсутствуют
• Live audio и encryption
план
Посмотреть дорожную карту →
+

Статус

Текущий срез — без обещаний сверх реализации.

✓ ADLP v1 + CRC-32C
готово
✓ Детерминированный PCM/WAV bootstrap
готово
✓ Flutter ↔ Rust bridge
native text/file-to-WAV verification
✓ Локальный WAV import / export
native dialogs + Rust validation
✓ Experimental file-object WAV
bounded local file selection, receipt и save; live route отсутствует
✓ Experimental Acoustic-1
B-FSK controlled PCM/WAV codec + golden vector
✓ Acoustic-2 measurement
declared PCM transforms + acquisition observables
✓ Device-acceptance protocol
evidence schema и review gates; device result отсутствует
✓ Live-audio adapter contract
typed unavailable scaffold; capture, playback и route отсутствуют
• Android foreground adapter source
experimental Kotlin/Flutter/Rust source; target build и device observation отсутствуют
• Live audio и encryption
план
Посмотреть дорожную карту →
diff --git a/spec/android-live-audio-adapter-v1.md b/spec/android-live-audio-adapter-v1.md index 743d283..46c31de 100644 --- a/spec/android-live-audio-adapter-v1.md +++ b/spec/android-live-audio-adapter-v1.md @@ -1,8 +1,8 @@ # Android foreground live-audio adapter v1 -**Status:** Draft experimental platform-route design · **English (canonical)** · [Русский](android-live-audio-adapter-v1_RU.md) +**Status:** Experimental source implementation · **English (canonical)** · [Русский](android-live-audio-adapter-v1_RU.md) -This RFC specializes the [constrained live-audio adapter v1](live-audio-adapter-v1.md) for a first Android implementation. It does not enable Android audio, establish a device result, or make Android, speaker-to-microphone, cable, Bluetooth, headset or radio support claim. +This RFC specializes the [constrained live-audio adapter v1](live-audio-adapter-v1.md) for a first Android implementation. Kotlin/Flutter/Rust source exists, but no Android target runtime or physical route has been verified. It does not establish a device result or make Android, speaker-to-microphone, cable, Bluetooth, headset or radio support claim. ## Scope @@ -19,7 +19,7 @@ The adapter initially targets the OS-selected default capture/output path only. ## Permission, focus and lifecycle -`RECORD_AUDIO` is declared only when this RFC becomes an approved implementation change. Capture starts only after the user presses a capture action, the runtime permission is granted, and `AudioRecord` is initialized. The permission request must be contextual, cancellable and degrade to the existing WAV workflow when denied or revoked. [1] [3] +The experimental source declares `RECORD_AUDIO`, but capture starts only after the user presses a capture action, the runtime permission is granted, and `AudioRecord` is initialized. The permission request must be contextual, cancellable and degrade to the existing WAV workflow when denied or revoked. [1] [3] Playback requests `AudioFocusRequest` immediately before `AudioTrack.play()`. A denied focus request rejects the attempt. A delayed request remains pending and must not write/play PCM until focus is granted. Any permanent or transient focus loss, route change, user stop, record/track failure or application lifecycle stop terminates the operation; v1 never auto-resumes. Android’s focus guidance requires applications to react to focus loss and abandon focus when playback ends. [4] @@ -32,7 +32,7 @@ playing|capturing ──(stop, focus loss, route change, lifecycle stop, native any state ──(dispose)──> disposed ``` -The current Flutter `LiveAudioAdapter` interface stays unchanged for this RFC. A later implementation may expose platform diagnostics through a separate event stream or status object, but must not expose unbounded raw microphone PCM as diagnostic data or make an unavailable adapter initialize Android audio APIs. +The current Flutter `LiveAudioAdapter` interface stays unchanged for this RFC. The experimental source exposes capture frames only through a bounded transient event stream; it must not expose unbounded raw microphone PCM as diagnostic data or make an unavailable adapter initialize Android audio APIs. ## Native operation rules @@ -64,7 +64,7 @@ The first physical experiment may use only a declared `acoustic1` fixture and th ## Non-goals -This RFC does not approve implementation yet. It excludes Android API levels below 26, Web, iOS, Windows, macOS and Linux adapters; `MediaRecorder`; direct Rust microphone access; Bluetooth/cable/radio routing; device picker UI; background service; notification controls; persistence/history; duplex; echo cancellation; AGC; resampling; timing recovery changes; physical performance metrics; authentication; encryption; file transfer changes; and a supported-route claim. +This RFC does not establish a supported implementation. It excludes Android API levels below 26, Web, iOS, Windows, macOS and Linux adapters; `MediaRecorder`; direct Rust microphone access; Bluetooth/cable/radio routing; device picker UI; background service; notification controls; persistence/history; duplex; echo cancellation; AGC; resampling; timing recovery changes; physical performance metrics; authentication; encryption; file transfer changes; and a supported-route claim. ## References diff --git a/spec/android-live-audio-adapter-v1_RU.md b/spec/android-live-audio-adapter-v1_RU.md index b9da78e..703841f 100644 --- a/spec/android-live-audio-adapter-v1_RU.md +++ b/spec/android-live-audio-adapter-v1_RU.md @@ -4,7 +4,7 @@ > **Translation of:** [spec/android-live-audio-adapter-v1.md](android-live-audio-adapter-v1.md). **Last synced:** 2026-08-21. -**Статус:** Draft experimental platform-route design. Это не включает Android audio, не устанавливает device result и не заявляет Android, speaker-to-microphone, cable, Bluetooth, headset или radio support. +**Статус:** Experimental source implementation. Kotlin/Flutter/Rust source существует, но Android target runtime или physical route не проверены. Это не устанавливает device result и не заявляет Android, speaker-to-microphone, cable, Bluetooth, headset или radio support. ## Scope @@ -21,7 +21,7 @@ Requested application format — существующий `PcmStreamFormat.audio ## Permission, focus и lifecycle -`RECORD_AUDIO` объявляется только когда этот RFC станет approved implementation change. Capture начинается лишь после нажатия user capture action, runtime permission grant и initialized `AudioRecord`. Permission request должен быть contextual и cancellable, а после denial или revocation application деградирует к существующему WAV workflow. [1] [3] +Experimental source объявляет `RECORD_AUDIO`, но capture начинается лишь после нажатия user capture action, runtime permission grant и initialized `AudioRecord`. Permission request должен быть contextual и cancellable, а после denial или revocation application деградирует к существующему WAV workflow. [1] [3] Playback запрашивает `AudioFocusRequest` непосредственно перед `AudioTrack.play()`. Denied focus request отклоняет attempt. Delayed request остаётся pending и не может write/play PCM до focus grant. Permanent или transient focus loss, route change, user stop, record/track failure или application lifecycle stop завершает operation; v1 никогда auto-resume. Android focus guidance требует реакции на focus loss и abandon focus после playback end. [4] @@ -34,7 +34,7 @@ playing|capturing ──(stop, focus loss, route change, lifecycle stop, native any state ──(dispose)──> disposed ``` -Текущий Flutter `LiveAudioAdapter` interface для этого RFC не меняется. Later implementation может показать platform diagnostics через отдельный event stream или status object, но не должен exposing unbounded raw microphone PCM как diagnostic data или заставлять unavailable adapter инициализировать Android audio APIs. +Текущий Flutter `LiveAudioAdapter` interface для этого RFC не меняется. Experimental source показывает capture frames только через bounded transient event stream; он не должен exposing unbounded raw microphone PCM как diagnostic data или заставлять unavailable adapter инициализировать Android audio APIs. ## Native operation rules @@ -66,7 +66,7 @@ Accepted operation означает только platform-side lifecycle complet ## Non-goals -Этот RFC ещё не одобряет implementation. Он исключает Android API ниже 26, Web, iOS, Windows, macOS и Linux adapters; `MediaRecorder`; direct Rust microphone access; Bluetooth/cable/radio routing; device picker UI; background service; notification controls; persistence/history; duplex; echo cancellation; AGC; resampling; timing recovery changes; physical performance metrics; authentication; encryption; file-transfer changes и supported-route claim. +Этот RFC не устанавливает supported implementation. Он исключает Android API ниже 26, Web, iOS, Windows, macOS и Linux adapters; `MediaRecorder`; direct Rust microphone access; Bluetooth/cable/radio routing; device picker UI; background service; notification controls; persistence/history; duplex; echo cancellation; AGC; resampling; timing recovery changes; physical performance metrics; authentication; encryption; file-transfer changes и supported-route claim. ## References