From 34cff19c34e0d2716068da9f6582130a7870383b Mon Sep 17 00:00:00 2001 From: Matthew Baranov Date: Thu, 20 Aug 2026 18:56:11 +0000 Subject: [PATCH] feat: add bounded file WAV workflow --- .github/workflows/site.yml | 1 + .../lib/bridge/wav_bootstrap_bridge.dart | 110 +++- apps/audio_modem/lib/main.dart | 597 +++++++++++++++--- .../lib/platform/payload_file_adapter.dart | 62 ++ .../lib/src/rust/api/wav_bootstrap.dart | 83 ++- .../lib/src/rust/frb_generated.dart | 180 +++++- .../lib/src/rust/frb_generated.io.dart | 14 + .../lib/src/rust/frb_generated.web.dart | 14 + .../lib/transfer/transfer_task.dart | 76 +++ .../audio_modem/rust/src/api/wav_bootstrap.rs | 168 ++++- apps/audio_modem/rust/src/frb_generated.rs | 164 ++++- .../test/native_bridge_smoke_test.dart | 62 ++ apps/audio_modem/test/widget_test.dart | 204 +++++- crates/adlp-protocol/src/lib.rs | 81 +++ crates/audio-modem-core/src/lib.rs | 20 + .../tests/fixtures/adlp-v1-file-balanced.wav | Bin 0 -> 53804 bytes .../tests/fixtures/adlp-v1-file-source.bin | 1 + docs/README.md | 1 + docs/README_RU.md | 3 +- docs/architecture/flutter-rust-bridge.md | 23 +- docs/architecture/flutter-rust-bridge_RU.md | 23 +- docs/guides/getting-started.md | 15 +- docs/guides/getting-started_RU.md | 17 +- docs/implementation-plan.md | 79 +++ docs/implementation-plan_RU.md | 81 +++ docs/roadmap.md | 3 +- docs/roadmap_RU.md | 5 +- site/index.html | 2 +- site/ru/index.html | 2 +- tools/adlp-cli/src/main.rs | 52 +- 30 files changed, 1998 insertions(+), 145 deletions(-) create mode 100644 apps/audio_modem/lib/platform/payload_file_adapter.dart create mode 100644 apps/audio_modem/lib/transfer/transfer_task.dart create mode 100644 apps/audio_modem/test/native_bridge_smoke_test.dart create mode 100644 crates/audio-modem-core/tests/fixtures/adlp-v1-file-balanced.wav create mode 100644 crates/audio-modem-core/tests/fixtures/adlp-v1-file-source.bin create mode 100644 docs/implementation-plan.md create mode 100644 docs/implementation-plan_RU.md diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 655e136..1659393 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -39,6 +39,7 @@ jobs: "docs/reference/platform-support.md:docs/reference/platform-support_RU.md" "docs/reference/presets.md:docs/reference/presets_RU.md" "docs/troubleshooting.md:docs/troubleshooting_RU.md" + "docs/implementation-plan.md:docs/implementation-plan_RU.md" "docs/roadmap.md:docs/roadmap_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" diff --git a/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart b/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart index 3d866b8..cecbf1e 100644 --- a/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart +++ b/apps/audio_modem/lib/bridge/wav_bootstrap_bridge.dart @@ -41,6 +41,30 @@ class WavDecodeResult { final int samplesConsumed; } +class WavFileDecodeResult { + const WavFileDecodeResult({ + required this.sessionId, + required this.senderCallsign, + required this.profile, + required this.carrier, + required this.fileName, + required this.mimeType, + required this.payload, + required this.sampleRateHz, + required this.samplesConsumed, + }); + + final int sessionId; + final String senderCallsign; + final String profile; + final String carrier; + final String fileName; + final String mimeType; + final Uint8List payload; + final int sampleRateHz; + final int samplesConsumed; +} + abstract interface class WavBootstrapBridge { bool get isAvailable; @@ -52,22 +76,39 @@ abstract interface class WavBootstrapBridge { required String carrier, }); + Future encodeFile({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required Uint8List payload, + required String profile, + required String carrier, + }); + Future decodeWav({ required Uint8List wavBytes, required String carrier, }); + + Future decodeWavFile({ + required Uint8List wavBytes, + required String carrier, + }); } class NativeWavBootstrapBridge implements WavBootstrapBridge { NativeWavBootstrapBridge._(); + static Future? _initialization; + static Future create() async { if (kIsWeb) { throw UnsupportedError( 'The native Rust WAV bridge is not available in the web build yet.', ); } - await AudioModemRust.init(); + await (_initialization ??= AudioModemRust.init()); return NativeWavBootstrapBridge._(); } @@ -98,6 +139,34 @@ class NativeWavBootstrapBridge implements WavBootstrapBridge { ); } + @override + Future encodeFile({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required Uint8List payload, + required String profile, + required String carrier, + }) async { + final result = await rust.encodeFileToWav( + sessionId: sessionId, + senderCallsign: senderCallsign, + fileName: fileName, + mimeType: mimeType, + payload: payload, + profile: profile, + carrier: carrier, + ); + return WavBuildResult( + sessionId: result.sessionId, + profile: result.profile, + carrier: result.carrier, + sampleRateHz: result.sampleRateHz, + wavBytes: result.wavBytes, + ); + } + @override Future decodeWav({ required Uint8List wavBytes, @@ -117,6 +186,28 @@ class NativeWavBootstrapBridge implements WavBootstrapBridge { samplesConsumed: result.samplesConsumed, ); } + + @override + Future decodeWavFile({ + required Uint8List wavBytes, + required String carrier, + }) async { + final result = await rust.decodeWavFile( + wavBytes: wavBytes, + carrier: carrier, + ); + return WavFileDecodeResult( + sessionId: result.sessionId, + senderCallsign: result.senderCallsign, + profile: result.profile, + carrier: result.carrier, + fileName: result.fileName, + mimeType: result.mimeType, + payload: result.payload, + sampleRateHz: result.sampleRateHz, + samplesConsumed: result.samplesConsumed, + ); + } } class UnavailableWavBootstrapBridge implements WavBootstrapBridge { @@ -136,9 +227,26 @@ class UnavailableWavBootstrapBridge implements WavBootstrapBridge { required String carrier, }) => Future.error(StateError(reason)); + @override + Future encodeFile({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required Uint8List payload, + required String profile, + required String carrier, + }) => Future.error(StateError(reason)); + @override Future decodeWav({ required Uint8List wavBytes, required String carrier, }) => Future.error(StateError(reason)); + + @override + Future decodeWavFile({ + required Uint8List wavBytes, + required String carrier, + }) => Future.error(StateError(reason)); } diff --git a/apps/audio_modem/lib/main.dart b/apps/audio_modem/lib/main.dart index 7c01a68..a331db5 100644 --- a/apps/audio_modem/lib/main.dart +++ b/apps/audio_modem/lib/main.dart @@ -7,7 +7,9 @@ import 'package:flutter/material.dart'; import 'bridge/wav_bootstrap_bridge.dart'; import 'platform/live_audio_adapter.dart'; +import 'platform/payload_file_adapter.dart'; import 'platform/wav_file_adapter.dart'; +import 'transfer/transfer_task.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,6 +25,7 @@ Future main() async { AudioModemApp( bridge: bridge, fileAdapter: const PlatformWavFileAdapter(), + payloadFileAdapter: const PlatformPayloadFileAdapter(), liveAudioAdapter: const UnavailableLiveAudioAdapter(), ), ); @@ -33,11 +36,13 @@ class AudioModemApp extends StatelessWidget { super.key, required this.bridge, required this.fileAdapter, + this.payloadFileAdapter = const PlatformPayloadFileAdapter(), this.liveAudioAdapter = const UnavailableLiveAudioAdapter(), }); final WavBootstrapBridge bridge; final WavFileAdapter fileAdapter; + final PayloadFileAdapter payloadFileAdapter; final LiveAudioAdapter liveAudioAdapter; @override @@ -57,6 +62,7 @@ class AudioModemApp extends StatelessWidget { home: TransferWorkbench( bridge: bridge, fileAdapter: fileAdapter, + payloadFileAdapter: payloadFileAdapter, liveAudioAdapter: liveAudioAdapter, ), ); @@ -68,11 +74,13 @@ class TransferWorkbench extends StatefulWidget { super.key, required this.bridge, required this.fileAdapter, + required this.payloadFileAdapter, required this.liveAudioAdapter, }); final WavBootstrapBridge bridge; final WavFileAdapter fileAdapter; + final PayloadFileAdapter payloadFileAdapter; final LiveAudioAdapter liveAudioAdapter; @override @@ -85,15 +93,20 @@ class _TransferWorkbenchState extends State { TransferPreset _preset = TransferPreset.balanced; CarrierKind _carrier = CarrierKind.bootstrap; TransferRoute _route = TransferRoute.wav; + TransferObjectKind _objectKind = TransferObjectKind.text; int _tabIndex = 0; - bool _isWorking = false; + TransferTaskState _task = const TransferTaskState.idle(); WavBuildResult? _builtWav; WavDecodeResult? _decodedWav; + WavFileDecodeResult? _decodedFileWav; + SelectedPayloadFile? _selectedPayloadFile; String? _activeWavName; Uint8List? _activeWavBytes; CarrierKind? _activeCarrier; String? _bridgeError; + bool get _isWorking => _task.isBusy; + @override void dispose() { _callsign.dispose(); @@ -101,56 +114,164 @@ class _TransferWorkbenchState extends State { super.dispose(); } - Future _buildAndVerifyWav() async { - if (_route != TransferRoute.wav) { - _showMessage( - widget.liveAudioAdapter.availability.reason ?? - 'Live-audio маршрут недоступен. Выберите WAV-маршрут.', - ); - return; - } - if (!widget.bridge.isAvailable) { - _showMessage('Rust/WAV bridge недоступен в этой сборке.'); + void _setTask(TransferTaskState task, {String? bridgeError}) { + if (!mounted) { return; } setState(() { - _isWorking = true; - _bridgeError = null; + _task = task; + _bridgeError = bridgeError; + }); + } + + void _resetTaskAfterEdit() { + setState(() { + if (_task.isTerminal) { + _task = const TransferTaskState.idle(); + } }); + } + + Future _selectPayloadFile() async { + _setTask(TransferTaskState.preparing('Выбор файла для ADLP объекта…')); try { - final sessionId = DateTime.now().microsecondsSinceEpoch; - final built = await widget.bridge.encodeText( - sessionId: sessionId, - senderCallsign: _callsign.text.trim(), - text: _text.text, - profile: _preset.bridgeProfile, - carrier: _carrier.bridgeCarrier, - ); - final decoded = await widget.bridge.decodeWav( - wavBytes: built.wavBytes, - carrier: _carrier.bridgeCarrier, - ); + final selected = await widget.payloadFileAdapter.openPayload(); if (!mounted) { return; } + if (selected == null) { + _setTask(TransferTaskState.cancelled('Выбор файла отменён.')); + return; + } setState(() { - _builtWav = built; - _decodedWav = decoded; - _activeWavName = _suggestedWavName(built.sessionId, _carrier); - _activeWavBytes = built.wavBytes; - _activeCarrier = _carrier; + _selectedPayloadFile = selected; + _task = TransferTaskState.completed( + 'Файл выбран для WAV reference path.', + detail: '${selected.name} · ${selected.bytes.length} байт', + ); }); + } catch (error) { + _setTask( + TransferTaskState.rejected( + 'Не удалось прочитать выбранный файл.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); + } + } + + String _mimeTypeFor(String fileName) { + final normalized = fileName.toLowerCase(); + if (normalized.endsWith('.txt')) return 'text/plain'; + if (normalized.endsWith('.json')) return 'application/json'; + if (normalized.endsWith('.png')) return 'image/png'; + if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) { + return 'image/jpeg'; + } + return 'application/octet-stream'; + } + + Future _buildAndVerifyWav() async { + if (_route != TransferRoute.wav) { + final reason = + widget.liveAudioAdapter.availability.reason ?? + 'Live-audio маршрут недоступен. Выберите WAV-маршрут.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + if (!widget.bridge.isAvailable) { + const reason = 'Rust/WAV bridge недоступен в этой сборке.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); + return; + } + final selectedPayloadFile = _selectedPayloadFile; + if (_objectKind == TransferObjectKind.file && selectedPayloadFile == null) { + const reason = 'Сначала выберите файл для ADLP объекта.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); + return; + } + _setTask(TransferTaskState.preparing('Сборка ADLP/WAV в Rust…')); + try { + final sessionId = DateTime.now().microsecondsSinceEpoch; + if (_objectKind == TransferObjectKind.text) { + final built = await widget.bridge.encodeText( + sessionId: sessionId, + senderCallsign: _callsign.text.trim(), + text: _text.text, + profile: _preset.bridgeProfile, + carrier: _carrier.bridgeCarrier, + ); + _setTask(TransferTaskState.verifying('Проверка WAV Rust decoder…')); + final decoded = await widget.bridge.decodeWav( + wavBytes: built.wavBytes, + carrier: _carrier.bridgeCarrier, + ); + if (!mounted) { + return; + } + setState(() { + _builtWav = built; + _decodedWav = decoded; + _decodedFileWav = null; + _activeWavName = _suggestedWavName(built.sessionId, _carrier); + _activeWavBytes = built.wavBytes; + _activeCarrier = _carrier; + _task = TransferTaskState.completed( + 'Text WAV собран и проверен.', + detail: 'Rust decoder подтвердил framing, manifest и CRC-32C.', + ); + }); + } else { + final payload = selectedPayloadFile!; + final built = await widget.bridge.encodeFile( + sessionId: sessionId, + senderCallsign: _callsign.text.trim(), + fileName: payload.name, + mimeType: _mimeTypeFor(payload.name), + payload: payload.bytes, + profile: _preset.bridgeProfile, + carrier: _carrier.bridgeCarrier, + ); + _setTask( + TransferTaskState.verifying('Проверка file WAV Rust decoder…'), + ); + final decoded = await widget.bridge.decodeWavFile( + wavBytes: built.wavBytes, + carrier: _carrier.bridgeCarrier, + ); + if (!mounted) { + return; + } + setState(() { + _builtWav = built; + _decodedWav = null; + _decodedFileWav = decoded; + _activeWavName = _suggestedWavName(built.sessionId, _carrier); + _activeWavBytes = built.wavBytes; + _activeCarrier = _carrier; + _task = TransferTaskState.completed( + 'File WAV собран и проверен.', + detail: '${decoded.fileName} · ${decoded.payload.length} байт', + ); + }); + } _showMessage('WAV собран и проверен Rust decoder.'); } catch (error) { if (!mounted) { return; } - setState(() => _bridgeError = error.toString()); + _setTask( + TransferTaskState.rejected( + 'Rust bridge отклонил передачу.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); _showMessage('Rust bridge отклонил передачу. Проверьте поля объекта.'); - } finally { - if (mounted) { - setState(() => _isWorking = false); - } } } @@ -158,45 +279,70 @@ class _TransferWorkbenchState extends State { final wavBytes = _activeWavBytes; final carrier = _activeCarrier; if (wavBytes == null || carrier == null) { - _showMessage('Сначала соберите или импортируйте WAV.'); + const reason = 'Сначала соберите или импортируйте WAV.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); return; } - setState(() { - _isWorking = true; - _bridgeError = null; - }); + _setTask(TransferTaskState.verifying('Повторная проверка WAV…')); try { - final decoded = await widget.bridge.decodeWav( - wavBytes: wavBytes, - carrier: carrier.bridgeCarrier, - ); - if (!mounted) { - return; + if (_decodedFileWav != null) { + final decoded = await widget.bridge.decodeWavFile( + wavBytes: wavBytes, + carrier: carrier.bridgeCarrier, + ); + if (!mounted) { + return; + } + setState(() { + _decodedFileWav = decoded; + _decodedWav = null; + _task = TransferTaskState.completed( + 'File WAV повторно проверен.', + detail: '${decoded.fileName} · ${decoded.payload.length} байт', + ); + }); + } else { + final decoded = await widget.bridge.decodeWav( + wavBytes: wavBytes, + carrier: carrier.bridgeCarrier, + ); + if (!mounted) { + return; + } + setState(() { + _decodedWav = decoded; + _decodedFileWav = null; + _task = TransferTaskState.completed( + 'WAV повторно проверен.', + detail: 'Rust decoder подтвердил текущие WAV bytes.', + ); + }); } - setState(() => _decodedWav = decoded); _showMessage('WAV повторно проверен Rust decoder.'); } catch (error) { if (!mounted) { return; } - setState(() => _bridgeError = error.toString()); - } finally { - if (mounted) { - setState(() => _isWorking = false); - } + _setTask( + TransferTaskState.rejected( + 'Rust decoder отклонил текущий WAV.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); } } Future _exportBuiltWav() async { final built = _builtWav; if (built == null) { - _showMessage('Сначала соберите WAV во вкладке «Передать».'); + const reason = 'Сначала соберите WAV во вкладке «Передать».'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); return; } - setState(() { - _isWorking = true; - _bridgeError = null; - }); + _setTask(TransferTaskState.preparing('Сохранение WAV…')); try { final saved = await widget.fileAdapter.saveWav( suggestedName: _suggestedWavName(built.sessionId, _carrier), @@ -206,44 +352,63 @@ class _TransferWorkbenchState extends State { return; } if (saved == null) { + _setTask(TransferTaskState.cancelled('Экспорт WAV отменён.')); _showMessage('Экспорт WAV отменён.'); } else { + _setTask( + TransferTaskState.completed( + 'WAV сохранён.', + detail: 'Файл: ${saved.name}', + ), + ); _showMessage('WAV сохранён: ${saved.name}.'); } } catch (error) { if (!mounted) { return; } - setState(() => _bridgeError = error.toString()); + _setTask( + TransferTaskState.rejected( + 'Не удалось сохранить WAV.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); _showMessage('Не удалось сохранить WAV.'); - } finally { - if (mounted) { - setState(() => _isWorking = false); - } } } Future _importAndVerifyWav() async { if (!widget.bridge.isAvailable) { - _showMessage('Rust/WAV bridge недоступен в этой сборке.'); + const reason = 'Rust/WAV bridge недоступен в этой сборке.'; + _setTask(TransferTaskState.unavailable(reason)); + _showMessage(reason); return; } - setState(() { - _isWorking = true; - _bridgeError = null; - }); + _setTask(TransferTaskState.preparing('Выбор WAV для импорта…')); try { final selected = await widget.fileAdapter.openWav(); if (selected == null) { if (mounted) { + _setTask(TransferTaskState.cancelled('Импорт WAV отменён.')); _showMessage('Импорт WAV отменён.'); } return; } - final decoded = await widget.bridge.decodeWav( - wavBytes: selected.bytes, - carrier: _carrier.bridgeCarrier, - ); + _setTask(TransferTaskState.verifying('Проверка импортированного WAV…')); + WavDecodeResult? decodedText; + WavFileDecodeResult? decodedFile; + try { + decodedText = await widget.bridge.decodeWav( + wavBytes: selected.bytes, + carrier: _carrier.bridgeCarrier, + ); + } catch (_) { + decodedFile = await widget.bridge.decodeWavFile( + wavBytes: selected.bytes, + carrier: _carrier.bridgeCarrier, + ); + } if (!mounted) { return; } @@ -251,21 +416,74 @@ class _TransferWorkbenchState extends State { _activeWavName = selected.name; _activeWavBytes = selected.bytes; _activeCarrier = _carrier; - _decodedWav = decoded; + _decodedWav = decodedText; + _decodedFileWav = decodedFile; + _task = TransferTaskState.completed( + 'WAV импортирован и проверен.', + detail: decodedFile == null + ? 'Text object · источник: ${selected.name}' + : '${decodedFile.fileName} · ${decodedFile.payload.length} байт', + ); }); _showMessage('WAV импортирован и проверен Rust decoder.'); } catch (error) { if (!mounted) { return; } - setState(() => _bridgeError = error.toString()); + _setTask( + TransferTaskState.rejected( + 'Импортированный файл не является корректной WAV передачей.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); _showMessage( 'Импортированный файл не является корректной WAV передачей.', ); - } finally { - if (mounted) { - setState(() => _isWorking = false); + } + } + + Future _saveDecodedPayload() async { + final decoded = _decodedFileWav; + if (decoded == null) { + const reason = 'Нет проверенного file payload для сохранения.'; + _setTask(TransferTaskState.rejected(reason)); + _showMessage(reason); + return; + } + _setTask( + TransferTaskState.preparing('Сохранение проверенного file payload…'), + ); + try { + final saved = await widget.payloadFileAdapter.savePayload( + suggestedName: decoded.fileName, + bytes: decoded.payload, + ); + if (!mounted) { + return; } + if (saved == null) { + _setTask( + TransferTaskState.cancelled('Сохранение file payload отменено.'), + ); + return; + } + _setTask( + TransferTaskState.completed( + 'Проверенный file payload сохранён.', + detail: saved.name, + ), + ); + _showMessage('Файл сохранён: ${saved.name}.'); + } catch (error) { + _setTask( + TransferTaskState.rejected( + 'Не удалось сохранить file payload.', + detail: error.toString(), + ), + bridgeError: error.toString(), + ); + _showMessage('Не удалось сохранить file payload.'); } } @@ -367,24 +585,52 @@ class _TransferWorkbenchState extends State { TextField( controller: _callsign, maxLength: 32, - onChanged: (_) => setState(() {}), + onChanged: (_) { + _resetTaskAfterEdit(); + setState(() {}); + }, decoration: const InputDecoration( labelText: 'Позывной отправителя', prefixIcon: Icon(Icons.badge_outlined), ), ), const SizedBox(height: 8), - TextField( - controller: _text, - minLines: 5, - maxLines: 8, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - labelText: 'Текст', - alignLabelWithHint: true, - hintText: 'Введите сообщение для ADLP-контейнера', - ), + Text('Тип объекта', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: TransferObjectKind.values + .map( + (kind) => ButtonSegment( + value: kind, + icon: Icon(kind.icon), + label: Text(kind.label), + ), + ) + .toList(), + selected: {_objectKind}, + onSelectionChanged: (value) { + _resetTaskAfterEdit(); + setState(() => _objectKind = value.first); + }, ), + const SizedBox(height: 12), + if (_objectKind == TransferObjectKind.text) + TextField( + controller: _text, + minLines: 5, + maxLines: 8, + onChanged: (_) { + _resetTaskAfterEdit(); + setState(() {}); + }, + decoration: const InputDecoration( + labelText: 'Текст', + alignLabelWithHint: true, + hintText: 'Введите сообщение для ADLP-контейнера', + ), + ) + else + _payloadSelectionCard(context), const SizedBox(height: 18), Text( 'Пресет передачи', @@ -399,7 +645,10 @@ class _TransferWorkbenchState extends State { (preset) => ChoiceChip( label: Text(preset.label), selected: _preset == preset, - onSelected: (_) => setState(() => _preset = preset), + onSelected: (_) { + _resetTaskAfterEdit(); + setState(() => _preset = preset); + }, ), ) .toList(), @@ -415,7 +664,10 @@ class _TransferWorkbenchState extends State { (carrier) => ChoiceChip( label: Text(carrier.label), selected: _carrier == carrier, - onSelected: (_) => setState(() => _carrier = carrier), + onSelected: (_) { + _resetTaskAfterEdit(); + setState(() => _carrier = carrier); + }, tooltip: carrier.description, ), ) @@ -445,8 +697,10 @@ class _TransferWorkbenchState extends State { ) .toList(), selected: {_route}, - onSelectionChanged: (value) => - setState(() => _route = value.first), + onSelectionChanged: (value) { + _resetTaskAfterEdit(); + setState(() => _route = value.first); + }, ), ], ), @@ -454,9 +708,55 @@ class _TransferWorkbenchState extends State { ); } + Widget _payloadSelectionCard(BuildContext context) { + final selected = _selectedPayloadFile; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFF7E5), + border: Border.all(color: const Color(0xFFE2C987)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Файл для ADLP объекта', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 6), + Text( + selected == null + ? 'Файл не выбран. Rust facade примет не более 8 KiB payload.' + : '${selected.name} · ${selected.bytes.length} байт · ${_mimeTypeFor(selected.name)}', + style: const TextStyle(fontSize: 12, height: 1.35), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _isWorking ? null : _selectPayloadFile, + icon: const Icon(Icons.attach_file), + label: Text( + selected == null ? 'Выбрать файл' : 'Выбрать другой файл', + ), + ), + const SizedBox(height: 8), + const Text( + 'Это локальный WAV reference workflow. Выбор файла не включает микрофон, Bluetooth, кабель или радиомаршрут.', + style: TextStyle( + fontSize: 11, + height: 1.35, + color: Color(0xFF6D6A62), + ), + ), + ], + ), + ); + } + Widget _planCard(BuildContext context, int bytes) { final built = _builtWav; final decoded = _decodedWav; + final decodedFile = _decodedFileWav; return Card( color: const Color(0xFF242321), child: Padding( @@ -489,7 +789,19 @@ class _TransferWorkbenchState extends State { Metric(label: 'Профиль', value: _preset.profileId), Metric(label: 'Carrier', value: _carrier.label), Metric(label: 'Маршрут', value: _route.label), - Metric(label: 'Текст', value: '$bytes байт UTF-8'), + Metric(label: 'Объект', value: _objectKind.label), + Metric( + label: _objectKind == TransferObjectKind.text + ? 'Текст' + : 'Файл', + value: _objectKind == TransferObjectKind.text + ? '$bytes байт UTF-8' + : _selectedPayloadFile == null + ? 'не выбран' + : '${_selectedPayloadFile!.bytes.length} байт', + ), + const SizedBox(height: 16), + TaskStatePanel(task: _task), const SizedBox(height: 24), FilledButton.icon( onPressed: _isWorking || !widget.bridge.isAvailable @@ -502,7 +814,7 @@ class _TransferWorkbenchState extends State { ) : const Icon(Icons.graphic_eq), label: Text( - _isWorking ? 'Проверка Rust…' : 'Собрать и проверить WAV', + _isWorking ? _task.message : 'Собрать и проверить WAV', ), ), if (!widget.bridge.isAvailable) ...[ @@ -516,7 +828,8 @@ class _TransferWorkbenchState extends State { ), ), ], - if (built != null && decoded != null) ...[ + if (built != null && + (decoded != null || decodedFile != null)) ...[ const SizedBox(height: 16), const Divider(color: Color(0xFF6D6A62)), const SizedBox(height: 8), @@ -532,8 +845,14 @@ class _TransferWorkbenchState extends State { Metric(label: 'WAV', value: '${built.wavBytes.length} байт'), Metric( label: 'Decoder', - value: '${decoded.sampleRateHz ~/ 1000} kHz / CRC ok', + value: + '${(decoded?.sampleRateHz ?? decodedFile!.sampleRateHz) ~/ 1000} kHz / CRC ok', ), + if (decodedFile case final file?) + Metric( + label: 'Файл', + value: '${file.fileName} · ${file.payload.length} байт', + ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _isWorking ? null : _exportBuiltWav, @@ -570,6 +889,8 @@ class _TransferWorkbenchState extends State { Widget _buildReceive(BuildContext context) { final decoded = _decodedWav; + final decodedFile = _decodedFileWav; + final hasDecodedObject = decoded != null || decodedFile != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -598,14 +919,14 @@ class _TransferWorkbenchState extends State { ), const SizedBox(height: 16), Text( - decoded == null + !hasDecodedObject ? 'Нет WAV для проверки.' : 'WAV object проверен.', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - decoded == null + !hasDecodedObject ? 'Сначала соберите WAV во вкладке «Передать» или импортируйте готовую передачу.' : 'Decoder вернул только объект, прошедший framing, manifest и CRC-32C проверку.', ), @@ -627,17 +948,38 @@ class _TransferWorkbenchState extends State { '${decoded.samplesConsumed} @ ${decoded.sampleRateHz} Hz', ), ], + if (decodedFile != null) ...[ + const SizedBox(height: 20), + _receiptRow('Позывной', decodedFile.senderCallsign), + _receiptRow('Профиль', decodedFile.profile), + _receiptRow('Carrier', decodedFile.carrier), + _receiptRow('Файл', decodedFile.fileName), + _receiptRow('MIME', decodedFile.mimeType), + _receiptRow('Payload', '${decodedFile.payload.length} байт'), + _receiptRow( + 'Семплы', + '${decodedFile.samplesConsumed} @ ${decodedFile.sampleRateHz} Hz', + ), + ], const SizedBox(height: 20), OutlinedButton.icon( onPressed: _isWorking || - _builtWav == null || + _activeWavBytes == null || !widget.bridge.isAvailable ? null : _verifyCurrentWav, icon: const Icon(Icons.refresh), label: const Text('Повторно проверить WAV'), ), + if (decodedFile != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _isWorking ? null : _saveDecodedPayload, + icon: const Icon(Icons.download_outlined), + label: const Text('Сохранить проверенный файл'), + ), + ], const SizedBox(height: 12), FilledButton.icon( onPressed: _isWorking || !widget.bridge.isAvailable @@ -722,6 +1064,15 @@ enum TransferRoute { final IconData icon; } +enum TransferObjectKind { + text('Текст', Icons.notes_outlined), + file('Файл', Icons.insert_drive_file_outlined); + + const TransferObjectKind(this.label, this.icon); + final String label; + final IconData icon; +} + class SignalMark extends StatelessWidget { const SignalMark({super.key}); @@ -795,3 +1146,53 @@ class Metric extends StatelessWidget { ), ); } + +class TaskStatePanel extends StatelessWidget { + const TaskStatePanel({super.key, required this.task}); + + final TransferTaskState task; + + @override + Widget build(BuildContext context) { + final color = switch (task.phase) { + TransferTaskPhase.completed => const Color(0xFFFFB000), + TransferTaskPhase.rejected || + TransferTaskPhase.unavailable => const Color(0xFFFF8A65), + TransferTaskPhase.cancelled => const Color(0xFFC9C5B9), + _ => const Color(0xFFB8D8BA), + }; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: color.withValues(alpha: 0.55)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 5), + Text( + task.message, + style: const TextStyle(fontSize: 12, height: 1.35), + ), + if (task.detail case final detail?) ...[ + const SizedBox(height: 4), + Text( + detail, + style: const TextStyle(color: Color(0xFFC9C5B9), fontSize: 11), + ), + ], + ], + ), + ); + } +} diff --git a/apps/audio_modem/lib/platform/payload_file_adapter.dart b/apps/audio_modem/lib/platform/payload_file_adapter.dart new file mode 100644 index 0000000..ed34f7a --- /dev/null +++ b/apps/audio_modem/lib/platform/payload_file_adapter.dart @@ -0,0 +1,62 @@ +// Local payload-file seam. Flutter selects/saves bytes while Rust owns ADLP object validation and WAV codecs. + +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +class SelectedPayloadFile { + const SelectedPayloadFile({required this.name, required this.bytes}); + + final String name; + final Uint8List bytes; +} + +class SavedPayloadFile { + const SavedPayloadFile({required this.name, required this.location}); + + final String name; + final Uri location; +} + +abstract interface class PayloadFileAdapter { + Future openPayload(); + + Future savePayload({ + required String suggestedName, + required Uint8List bytes, + }); +} + +class PlatformPayloadFileAdapter implements PayloadFileAdapter { + const PlatformPayloadFileAdapter(); + + @override + Future openPayload() async { + final file = await FilePicker.pickFile( + dialogTitle: 'Выберите файл для ADLP передачи', + ); + if (file == null) { + return null; + } + return SelectedPayloadFile( + name: file.name, + bytes: await file.readAsBytes(), + ); + } + + @override + Future savePayload({ + required String suggestedName, + required Uint8List bytes, + }) async { + final location = await FilePicker.saveFile( + dialogTitle: 'Save decoded AudioModem file', + fileName: suggestedName, + bytes: bytes, + ); + if (location == null) { + return null; + } + return SavedPayloadFile(name: suggestedName, location: location); + } +} 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 f7f561e..424a26a 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`, `parse_carrier`, `parse_profile` +// These functions are ignored because they are not marked as `pub`: `as_str`, `decode_selected_wav`, `parse_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`, `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`, `fmt`, `fmt`, `fmt` /// Encodes one text-only ADLP object into a selected canonical 48 kHz WAV carrier. Future encodeTextToWav({ @@ -26,6 +26,25 @@ Future encodeTextToWav({ carrier: carrier, ); +/// Encodes one bounded file ADLP object into a selected canonical 48 kHz WAV carrier. +Future encodeFileToWav({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required List payload, + required String profile, + required String carrier, +}) => AudioModemRust.instance.api.crateApiWavBootstrapEncodeFileToWav( + sessionId: sessionId, + senderCallsign: senderCallsign, + fileName: fileName, + mimeType: mimeType, + payload: payload, + profile: profile, + carrier: carrier, +); + /// Decodes and verifies selected-carrier WAV bytes before exposing text metadata to Flutter. Future decodeWavText({ required List wavBytes, @@ -35,6 +54,66 @@ Future decodeWavText({ carrier: carrier, ); +/// Decodes and verifies selected-carrier WAV bytes before exposing file metadata and bytes to Flutter. +Future decodeWavFile({ + required List wavBytes, + required String carrier, +}) => AudioModemRust.instance.api.crateApiWavBootstrapDecodeWavFile( + wavBytes: wavBytes, + carrier: carrier, +); + +class DecodedFileTransfer { + final int sessionId; + final String senderCallsign; + final String profile; + final String carrier; + final String fileName; + final String mimeType; + final Uint8List payload; + final int sampleRateHz; + final int samplesConsumed; + + const DecodedFileTransfer({ + required this.sessionId, + required this.senderCallsign, + required this.profile, + required this.carrier, + required this.fileName, + required this.mimeType, + required this.payload, + required this.sampleRateHz, + required this.samplesConsumed, + }); + + @override + int get hashCode => + sessionId.hashCode ^ + senderCallsign.hashCode ^ + profile.hashCode ^ + carrier.hashCode ^ + fileName.hashCode ^ + mimeType.hashCode ^ + payload.hashCode ^ + sampleRateHz.hashCode ^ + samplesConsumed.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DecodedFileTransfer && + runtimeType == other.runtimeType && + sessionId == other.sessionId && + senderCallsign == other.senderCallsign && + profile == other.profile && + carrier == other.carrier && + fileName == other.fileName && + mimeType == other.mimeType && + payload == other.payload && + sampleRateHz == other.sampleRateHz && + samplesConsumed == other.samplesConsumed; +} + class DecodedTextTransfer { final int sessionId; final String senderCallsign; diff --git a/apps/audio_modem/lib/src/rust/frb_generated.dart b/apps/audio_modem/lib/src/rust/frb_generated.dart index 80c3d61..788cf09 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 => -1548122970; + int get rustContentHash => 2115612387; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -85,11 +85,26 @@ class AudioModemRust } abstract class AudioModemRustApi extends BaseApi { + Future crateApiWavBootstrapDecodeWavFile({ + required List wavBytes, + required String carrier, + }); + Future crateApiWavBootstrapDecodeWavText({ required List wavBytes, required String carrier, }); + Future crateApiWavBootstrapEncodeFileToWav({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required List payload, + required String profile, + required String carrier, + }); + Future crateApiWavBootstrapEncodeTextToWav({ required int sessionId, required String senderCallsign, @@ -109,7 +124,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform }); @override - Future crateApiWavBootstrapDecodeWavText({ + Future crateApiWavBootstrapDecodeWavFile({ required List wavBytes, required String carrier, }) { @@ -126,6 +141,41 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform port: port_, ); }, + codec: SseCodec( + decodeSuccessData: sse_decode_decoded_file_transfer, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiWavBootstrapDecodeWavFileConstMeta, + argValues: [wavBytes, carrier], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWavBootstrapDecodeWavFileConstMeta => + const TaskConstMeta( + debugName: "decode_wav_file", + argNames: ["wavBytes", "carrier"], + ); + + @override + Future crateApiWavBootstrapDecodeWavText({ + required List wavBytes, + required String carrier, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(wavBytes, serializer); + sse_encode_String(carrier, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 2, + port: port_, + ); + }, codec: SseCodec( decodeSuccessData: sse_decode_decoded_text_transfer, decodeErrorData: sse_decode_String, @@ -143,6 +193,67 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform argNames: ["wavBytes", "carrier"], ); + @override + Future crateApiWavBootstrapEncodeFileToWav({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required List payload, + 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(fileName, serializer); + sse_encode_String(mimeType, serializer); + sse_encode_list_prim_u_8_loose(payload, serializer); + sse_encode_String(profile, serializer); + sse_encode_String(carrier, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_encoded_wav_transfer, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiWavBootstrapEncodeFileToWavConstMeta, + argValues: [ + sessionId, + senderCallsign, + fileName, + mimeType, + payload, + profile, + carrier, + ], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWavBootstrapEncodeFileToWavConstMeta => + const TaskConstMeta( + debugName: "encode_file_to_wav", + argNames: [ + "sessionId", + "senderCallsign", + "fileName", + "mimeType", + "payload", + "profile", + "carrier", + ], + ); + @override Future crateApiWavBootstrapEncodeTextToWav({ required int sessionId, @@ -163,7 +274,7 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 2, + funcId: 4, port: port_, ); }, @@ -198,6 +309,25 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform return raw as String; } + @protected + DecodedFileTransfer dco_decode_decoded_file_transfer(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return DecodedFileTransfer( + sessionId: dco_decode_CastedPrimitive_u_64(arr[0]), + senderCallsign: dco_decode_String(arr[1]), + profile: dco_decode_String(arr[2]), + carrier: dco_decode_String(arr[3]), + fileName: dco_decode_String(arr[4]), + mimeType: dco_decode_String(arr[5]), + payload: dco_decode_list_prim_u_8_strict(arr[6]), + sampleRateHz: dco_decode_u_32(arr[7]), + samplesConsumed: dco_decode_u_32(arr[8]), + ); + } + @protected DecodedTextTransfer dco_decode_decoded_text_transfer(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -274,6 +404,33 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform return utf8.decoder.convert(inner); } + @protected + DecodedFileTransfer sse_decode_decoded_file_transfer( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_sessionId = sse_decode_CastedPrimitive_u_64(deserializer); + var var_senderCallsign = sse_decode_String(deserializer); + var var_profile = sse_decode_String(deserializer); + var var_carrier = sse_decode_String(deserializer); + var var_fileName = sse_decode_String(deserializer); + var var_mimeType = sse_decode_String(deserializer); + var var_payload = sse_decode_list_prim_u_8_strict(deserializer); + var var_sampleRateHz = sse_decode_u_32(deserializer); + var var_samplesConsumed = sse_decode_u_32(deserializer); + return DecodedFileTransfer( + sessionId: var_sessionId, + senderCallsign: var_senderCallsign, + profile: var_profile, + carrier: var_carrier, + fileName: var_fileName, + mimeType: var_mimeType, + payload: var_payload, + sampleRateHz: var_sampleRateHz, + samplesConsumed: var_samplesConsumed, + ); + } + @protected DecodedTextTransfer sse_decode_decoded_text_transfer( SseDeserializer deserializer, @@ -372,6 +529,23 @@ class AudioModemRustApiImpl extends AudioModemRustApiImplPlatform sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); } + @protected + void sse_encode_decoded_file_transfer( + DecodedFileTransfer 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.senderCallsign, serializer); + sse_encode_String(self.profile, serializer); + sse_encode_String(self.carrier, serializer); + sse_encode_String(self.fileName, serializer); + sse_encode_String(self.mimeType, serializer); + sse_encode_list_prim_u_8_strict(self.payload, serializer); + sse_encode_u_32(self.sampleRateHz, serializer); + sse_encode_u_32(self.samplesConsumed, serializer); + } + @protected void sse_encode_decoded_text_transfer( DecodedTextTransfer 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 6803e16..5227d9e 100644 --- a/apps/audio_modem/lib/src/rust/frb_generated.io.dart +++ b/apps/audio_modem/lib/src/rust/frb_generated.io.dart @@ -28,6 +28,9 @@ abstract class AudioModemRustApiImplPlatform @protected String dco_decode_String(dynamic raw); + @protected + DecodedFileTransfer dco_decode_decoded_file_transfer(dynamic raw); + @protected DecodedTextTransfer dco_decode_decoded_text_transfer(dynamic raw); @@ -55,6 +58,11 @@ abstract class AudioModemRustApiImplPlatform @protected String sse_decode_String(SseDeserializer deserializer); + @protected + DecodedFileTransfer sse_decode_decoded_file_transfer( + SseDeserializer deserializer, + ); + @protected DecodedTextTransfer sse_decode_decoded_text_transfer( SseDeserializer deserializer, @@ -92,6 +100,12 @@ abstract class AudioModemRustApiImplPlatform @protected void sse_encode_String(String self, SseSerializer serializer); + @protected + void sse_encode_decoded_file_transfer( + DecodedFileTransfer self, + SseSerializer serializer, + ); + @protected void sse_encode_decoded_text_transfer( DecodedTextTransfer 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 6917565..1a62336 100644 --- a/apps/audio_modem/lib/src/rust/frb_generated.web.dart +++ b/apps/audio_modem/lib/src/rust/frb_generated.web.dart @@ -30,6 +30,9 @@ abstract class AudioModemRustApiImplPlatform @protected String dco_decode_String(dynamic raw); + @protected + DecodedFileTransfer dco_decode_decoded_file_transfer(dynamic raw); + @protected DecodedTextTransfer dco_decode_decoded_text_transfer(dynamic raw); @@ -57,6 +60,11 @@ abstract class AudioModemRustApiImplPlatform @protected String sse_decode_String(SseDeserializer deserializer); + @protected + DecodedFileTransfer sse_decode_decoded_file_transfer( + SseDeserializer deserializer, + ); + @protected DecodedTextTransfer sse_decode_decoded_text_transfer( SseDeserializer deserializer, @@ -94,6 +102,12 @@ abstract class AudioModemRustApiImplPlatform @protected void sse_encode_String(String self, SseSerializer serializer); + @protected + void sse_encode_decoded_file_transfer( + DecodedFileTransfer self, + SseSerializer serializer, + ); + @protected void sse_encode_decoded_text_transfer( DecodedTextTransfer self, diff --git a/apps/audio_modem/lib/transfer/transfer_task.dart b/apps/audio_modem/lib/transfer/transfer_task.dart new file mode 100644 index 0000000..712bcbd --- /dev/null +++ b/apps/audio_modem/lib/transfer/transfer_task.dart @@ -0,0 +1,76 @@ +// AudioModem Flutter shell state: presentation owns user-visible task phases; Rust owns protocol and codec behavior. + +enum TransferTaskPhase { + idle, + preparing, + verifying, + completed, + rejected, + cancelled, + unavailable, +} + +class TransferTaskState { + const TransferTaskState._({ + required this.phase, + required this.message, + this.detail, + }); + + const TransferTaskState.idle() + : phase = TransferTaskPhase.idle, + message = 'Готово к новой передаче.', + detail = null; + + factory TransferTaskState.preparing(String message) => + TransferTaskState._(phase: TransferTaskPhase.preparing, message: message); + + factory TransferTaskState.verifying(String message) => + TransferTaskState._(phase: TransferTaskPhase.verifying, message: message); + + factory TransferTaskState.completed(String message, {String? detail}) => + TransferTaskState._( + phase: TransferTaskPhase.completed, + message: message, + detail: detail, + ); + + factory TransferTaskState.rejected(String message, {String? detail}) => + TransferTaskState._( + phase: TransferTaskPhase.rejected, + message: message, + detail: detail, + ); + + factory TransferTaskState.cancelled(String message) => + TransferTaskState._(phase: TransferTaskPhase.cancelled, message: message); + + factory TransferTaskState.unavailable(String message) => TransferTaskState._( + phase: TransferTaskPhase.unavailable, + message: message, + ); + + final TransferTaskPhase phase; + final String message; + final String? detail; + + bool get isBusy => + phase == TransferTaskPhase.preparing || + phase == TransferTaskPhase.verifying; + + bool get isTerminal => + phase == TransferTaskPhase.completed || + phase == TransferTaskPhase.rejected || + phase == TransferTaskPhase.cancelled || + phase == TransferTaskPhase.unavailable; + + String get label => switch (phase) { + TransferTaskPhase.idle => 'ОЖИДАНИЕ', + TransferTaskPhase.preparing => 'ПОДГОТОВКА', + TransferTaskPhase.verifying => 'ПРОВЕРКА', + TransferTaskPhase.completed => 'ГОТОВО', + TransferTaskPhase.rejected => 'ОТКЛОНЕНО', + TransferTaskPhase.cancelled => 'ОТМЕНЕНО', + TransferTaskPhase.unavailable => 'НЕДОСТУПНО', + }; +} diff --git a/apps/audio_modem/rust/src/api/wav_bootstrap.rs b/apps/audio_modem/rust/src/api/wav_bootstrap.rs index a6bf36a..5df07f4 100644 --- a/apps/audio_modem/rust/src/api/wav_bootstrap.rs +++ b/apps/audio_modem/rust/src/api/wav_bootstrap.rs @@ -1,5 +1,5 @@ -//! Typed WAV carrier API: user text and callsign in, verified ADLP metadata out. -//! No live device, encryption, identity verification or file-object workflow is exposed here. +//! Typed WAV carrier API: user text/file data and callsign in, verified ADLP metadata out. +//! No live device, encryption or identity verification is exposed here. use adlp_protocol::{ObjectKind, TransferProfile, WireObject}; use audio_modem_core::{ @@ -8,6 +8,7 @@ use audio_modem_core::{ }; const MAX_BRIDGE_TEXT_BYTES: usize = 8 * 1024; +const MAX_BRIDGE_FILE_BYTES: usize = 8 * 1024; #[derive(Clone, Debug)] pub struct EncodedWavTransfer { @@ -29,6 +30,19 @@ pub struct DecodedTextTransfer { pub samples_consumed: u32, } +#[derive(Clone, Debug)] +pub struct DecodedFileTransfer { + pub session_id: u64, + pub sender_callsign: String, + pub profile: String, + pub carrier: String, + pub file_name: String, + pub mime_type: String, + pub payload: Vec, + pub sample_rate_hz: u32, + pub samples_consumed: u32, +} + /// Encodes one text-only ADLP object into a selected canonical 48 kHz WAV carrier. pub fn encode_text_to_wav( session_id: u64, @@ -60,27 +74,50 @@ pub fn encode_text_to_wav( }) } +/// Encodes one bounded file ADLP object into a selected canonical 48 kHz WAV carrier. +pub fn encode_file_to_wav( + session_id: u64, + sender_callsign: String, + file_name: String, + mime_type: String, + payload: Vec, + profile: String, + carrier: String, +) -> Result { + if session_id == 0 { + return Err("session_id must be positive".to_owned()); + } + if payload.len() > MAX_BRIDGE_FILE_BYTES { + return Err("file exceeds the 8 KiB WAV carrier application limit".to_owned()); + } + let profile = parse_profile(&profile)?; + let carrier = parse_carrier(&carrier)?; + let object = WireObject::file( + session_id, + sender_callsign, + file_name, + mime_type, + payload, + profile, + ) + .map_err(|error| error.to_string())?; + let wav_bytes = match carrier { + Carrier::Bootstrap => encode_bootstrap_wav(&object).map_err(|error| error.to_string())?, + Carrier::Acoustic1 => acoustic1::encode_wav(&object).map_err(|error| error.to_string())?, + }; + Ok(EncodedWavTransfer { + session_id, + profile: profile.as_str().to_owned(), + carrier: carrier.as_str().to_owned(), + sample_rate_hz: SAMPLE_RATE_HZ, + wav_bytes, + }) +} + /// 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)?; - let (object, sample_rate_hz, samples_consumed) = match carrier { - Carrier::Bootstrap => { - let decoded = decode_bootstrap_wav(&wav_bytes).map_err(|error| error.to_string())?; - ( - decoded.object, - decoded.sample_rate_hz, - decoded.samples_consumed, - ) - } - Carrier::Acoustic1 => { - let decoded = acoustic1::decode_wav(&wav_bytes).map_err(|error| error.to_string())?; - ( - decoded.object, - decoded.sample_rate_hz, - decoded.samples_consumed, - ) - } - }; + let (object, sample_rate_hz, samples_consumed) = decode_selected_wav(&wav_bytes, carrier)?; if object.manifest.object_kind != ObjectKind::Text { return Err("the WAV contains a non-text ADLP object".to_owned()); } @@ -99,6 +136,52 @@ pub fn decode_wav_text(wav_bytes: Vec, carrier: String) -> Result, carrier: String) -> Result { + let carrier = parse_carrier(&carrier)?; + let (object, sample_rate_hz, samples_consumed) = decode_selected_wav(&wav_bytes, carrier)?; + if object.manifest.object_kind != ObjectKind::File { + return Err("the WAV contains a non-file ADLP object".to_owned()); + } + let samples_consumed = u32::try_from(samples_consumed) + .map_err(|_| "decoded sample count exceeds bridge return range".to_owned())?; + Ok(DecodedFileTransfer { + 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(), + file_name: object.manifest.file_name, + mime_type: object.manifest.mime_type, + payload: object.payload, + sample_rate_hz, + samples_consumed, + }) +} + +fn decode_selected_wav( + wav_bytes: &[u8], + carrier: Carrier, +) -> Result<(WireObject, u32, usize), String> { + match carrier { + Carrier::Bootstrap => { + let decoded = decode_bootstrap_wav(wav_bytes).map_err(|error| error.to_string())?; + Ok(( + decoded.object, + decoded.sample_rate_hz, + decoded.samples_consumed, + )) + } + Carrier::Acoustic1 => { + let decoded = acoustic1::decode_wav(wav_bytes).map_err(|error| error.to_string())?; + Ok(( + decoded.object, + decoded.sample_rate_hz, + decoded.samples_consumed, + )) + } + } +} + fn parse_profile(value: &str) -> Result { match value { "reliable" => Ok(TransferProfile::Reliable), @@ -203,4 +286,49 @@ mod tests { .unwrap(); assert!(decode_wav_text(encoded.wav_bytes, "bootstrap".to_owned()).is_err()); } + + #[test] + fn bridge_round_trips_bounded_file_metadata_and_payload() { + let encoded = encode_file_to_wav( + 10, + "FILE".to_owned(), + "sample.bin".to_owned(), + "application/octet-stream".to_owned(), + vec![0, 1, 2, 255], + "balanced".to_owned(), + "bootstrap".to_owned(), + ) + .unwrap(); + let decoded = decode_wav_file(encoded.wav_bytes, "bootstrap".to_owned()).unwrap(); + assert_eq!(decoded.session_id, 10); + assert_eq!(decoded.sender_callsign, "FILE"); + assert_eq!(decoded.file_name, "sample.bin"); + assert_eq!(decoded.mime_type, "application/octet-stream"); + assert_eq!(decoded.payload, vec![0, 1, 2, 255]); + } + + #[test] + fn bridge_rejects_file_from_text_decoder_and_oversized_file() { + let encoded = encode_file_to_wav( + 11, + "FILE".to_owned(), + "sample.bin".to_owned(), + "application/octet-stream".to_owned(), + vec![1], + "balanced".to_owned(), + "bootstrap".to_owned(), + ) + .unwrap(); + assert!(decode_wav_text(encoded.wav_bytes, "bootstrap".to_owned()).is_err()); + assert!(encode_file_to_wav( + 12, + "FILE".to_owned(), + "oversized.bin".to_owned(), + "application/octet-stream".to_owned(), + vec![0; MAX_BRIDGE_FILE_BYTES + 1], + "balanced".to_owned(), + "bootstrap".to_owned(), + ) + .is_err()); + } } diff --git a/apps/audio_modem/rust/src/frb_generated.rs b/apps/audio_modem/rust/src/frb_generated.rs index 9924cee..3da9b0d 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 = -1548122970; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 2115612387; // Section: executor @@ -46,6 +46,41 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs +fn wire__crate__api__wav_bootstrap__decode_wav_file_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_wav_file", + 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_wav_bytes = >::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_wav_file(api_wav_bytes, api_carrier)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__wav_bootstrap__decode_wav_text_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -81,6 +116,53 @@ fn wire__crate__api__wav_bootstrap__decode_wav_text_impl( }, ) } +fn wire__crate__api__wav_bootstrap__encode_file_to_wav_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_file_to_wav", + 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_file_name = ::sse_decode(&mut deserializer); + let api_mime_type = ::sse_decode(&mut deserializer); + let api_payload = >::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_file_to_wav( + api_session_id, + api_sender_callsign, + api_file_name, + api_mime_type, + api_payload, + 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, @@ -135,6 +217,32 @@ impl SseDecode for String { } } +impl SseDecode for crate::api::wav_bootstrap::DecodedFileTransfer { + // 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_senderCallsign = ::sse_decode(deserializer); + let mut var_profile = ::sse_decode(deserializer); + let mut var_carrier = ::sse_decode(deserializer); + let mut var_fileName = ::sse_decode(deserializer); + let mut var_mimeType = ::sse_decode(deserializer); + let mut var_payload = >::sse_decode(deserializer); + let mut var_sampleRateHz = ::sse_decode(deserializer); + let mut var_samplesConsumed = ::sse_decode(deserializer); + return crate::api::wav_bootstrap::DecodedFileTransfer { + session_id: var_sessionId, + sender_callsign: var_senderCallsign, + profile: var_profile, + carrier: var_carrier, + file_name: var_fileName, + mime_type: var_mimeType, + payload: var_payload, + sample_rate_hz: var_sampleRateHz, + samples_consumed: var_samplesConsumed, + }; + } +} + impl SseDecode for crate::api::wav_bootstrap::DecodedTextTransfer { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -232,9 +340,18 @@ fn pde_ffi_dispatcher_primary_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 1 => { + wire__crate__api__wav_bootstrap__decode_wav_file_impl(port, ptr, rust_vec_len, data_len) + } + 2 => { wire__crate__api__wav_bootstrap__decode_wav_text_impl(port, ptr, rust_vec_len, data_len) } - 2 => wire__crate__api__wav_bootstrap__encode_text_to_wav_impl( + 3 => wire__crate__api__wav_bootstrap__encode_file_to_wav_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 4 => wire__crate__api__wav_bootstrap__encode_text_to_wav_impl( port, ptr, rust_vec_len, @@ -258,6 +375,34 @@ fn pde_ffi_dispatcher_sync_impl( // Section: rust2dart +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::wav_bootstrap::DecodedFileTransfer { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.session_id.into_into_dart().into_dart(), + self.sender_callsign.into_into_dart().into_dart(), + self.profile.into_into_dart().into_dart(), + self.carrier.into_into_dart().into_dart(), + self.file_name.into_into_dart().into_dart(), + self.mime_type.into_into_dart().into_dart(), + self.payload.into_into_dart().into_dart(), + self.sample_rate_hz.into_into_dart().into_dart(), + self.samples_consumed.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::wav_bootstrap::DecodedFileTransfer +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::wav_bootstrap::DecodedFileTransfer +{ + fn into_into_dart(self) -> crate::api::wav_bootstrap::DecodedFileTransfer { + self + } +} // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::wav_bootstrap::DecodedTextTransfer { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { @@ -316,6 +461,21 @@ impl SseEncode for String { } } +impl SseEncode for crate::api::wav_bootstrap::DecodedFileTransfer { + // 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.sender_callsign, serializer); + ::sse_encode(self.profile, serializer); + ::sse_encode(self.carrier, serializer); + ::sse_encode(self.file_name, serializer); + ::sse_encode(self.mime_type, serializer); + >::sse_encode(self.payload, serializer); + ::sse_encode(self.sample_rate_hz, serializer); + ::sse_encode(self.samples_consumed, serializer); + } +} + impl SseEncode for crate::api::wav_bootstrap::DecodedTextTransfer { // 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/native_bridge_smoke_test.dart b/apps/audio_modem/test/native_bridge_smoke_test.dart new file mode 100644 index 0000000..89c75b3 --- /dev/null +++ b/apps/audio_modem/test/native_bridge_smoke_test.dart @@ -0,0 +1,62 @@ +// Native integration smoke test: Flutter calls the generated bridge while Rust remains the ADLP/WAV authority. + +import 'dart:typed_data'; +import 'dart:io' show Platform; + +import 'package:audio_modem/bridge/wav_bootstrap_bridge.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final runNativeSmoke = + Platform.environment['AUDIOMODEM_RUN_NATIVE_BRIDGE_SMOKE'] == '1'; + + test( + 'native bridge round-trips a bounded file object through WAV bytes', + () async { + final bridge = await NativeWavBootstrapBridge.create(); + final built = await bridge.encodeFile( + sessionId: 42, + senderCallsign: 'NATIVE', + fileName: 'native-fixture.bin', + mimeType: 'application/octet-stream', + payload: Uint8List.fromList([0, 1, 2, 255]), + profile: 'balanced', + carrier: 'bootstrap', + ); + final decoded = await bridge.decodeWavFile( + wavBytes: built.wavBytes, + carrier: 'bootstrap', + ); + + expect(built.sampleRateHz, 48000); + expect(decoded.sessionId, 42); + expect(decoded.senderCallsign, 'NATIVE'); + expect(decoded.fileName, 'native-fixture.bin'); + expect(decoded.mimeType, 'application/octet-stream'); + expect(decoded.payload, Uint8List.fromList([0, 1, 2, 255])); + }, + skip: !runNativeSmoke, + ); + + test( + 'native bridge rejects a file WAV when the selected carrier differs', + () async { + final bridge = await NativeWavBootstrapBridge.create(); + final built = await bridge.encodeFile( + sessionId: 43, + senderCallsign: 'NATIVE', + fileName: 'carrier-fixture.bin', + mimeType: 'application/octet-stream', + payload: Uint8List.fromList([1]), + profile: 'fast', + carrier: 'acoustic1', + ); + + await expectLater( + bridge.decodeWavFile(wavBytes: built.wavBytes, carrier: 'bootstrap'), + throwsA(isA()), + ); + }, + skip: !runNativeSmoke, + ); +} diff --git a/apps/audio_modem/test/widget_test.dart b/apps/audio_modem/test/widget_test.dart index 1d7d6bd..643f4a1 100644 --- a/apps/audio_modem/test/widget_test.dart +++ b/apps/audio_modem/test/widget_test.dart @@ -3,13 +3,19 @@ import 'dart:typed_data'; import 'package:audio_modem/bridge/wav_bootstrap_bridge.dart'; import 'package:audio_modem/main.dart'; import 'package:audio_modem/platform/live_audio_adapter.dart'; +import 'package:audio_modem/platform/payload_file_adapter.dart'; import 'package:audio_modem/platform/wav_file_adapter.dart'; +import 'package:audio_modem/transfer/transfer_task.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeWavBridge implements WavBootstrapBridge { + _FakeWavBridge({this.textDecodeFails = false}); + + final bool textDecodeFails; String? encodedCarrier; String? decodedCarrier; + String? encodedFileName; @override bool get isAvailable => true; @@ -19,6 +25,9 @@ class _FakeWavBridge implements WavBootstrapBridge { required Uint8List wavBytes, required String carrier, }) async { + if (textDecodeFails) { + throw StateError('The WAV contains a non-text ADLP object.'); + } decodedCarrier = carrier; return WavDecodeResult( sessionId: 7, @@ -48,6 +57,46 @@ class _FakeWavBridge implements WavBootstrapBridge { wavBytes: Uint8List.fromList([82, 73, 70, 70]), ); } + + @override + Future encodeFile({ + required int sessionId, + required String senderCallsign, + required String fileName, + required String mimeType, + required Uint8List payload, + required String profile, + required String carrier, + }) async { + encodedCarrier = carrier; + encodedFileName = fileName; + return WavBuildResult( + sessionId: sessionId, + profile: profile, + carrier: carrier, + sampleRateHz: 48000, + wavBytes: Uint8List.fromList([82, 73, 70, 70]), + ); + } + + @override + Future decodeWavFile({ + required Uint8List wavBytes, + required String carrier, + }) async { + decodedCarrier = carrier; + return WavFileDecodeResult( + sessionId: 8, + senderCallsign: 'N1', + profile: 'fast', + carrier: carrier, + fileName: 'sample.bin', + mimeType: 'application/octet-stream', + payload: Uint8List.fromList([0, 1, 2]), + sampleRateHz: 48000, + samplesConsumed: 768, + ); + } } class _FakeWavFileAdapter implements WavFileAdapter { @@ -72,6 +121,28 @@ class _FakeWavFileAdapter implements WavFileAdapter { } } +class _FakePayloadFileAdapter implements PayloadFileAdapter { + _FakePayloadFileAdapter({this.openedFile}); + + final SelectedPayloadFile? openedFile; + SavedPayloadFile? savedFile; + + @override + Future openPayload() async => openedFile; + + @override + Future savePayload({ + required String suggestedName, + required Uint8List bytes, + }) async { + savedFile = SavedPayloadFile( + name: suggestedName, + location: Uri.parse('memory://$suggestedName'), + ); + return savedFile; + } +} + void main() { test('live-audio v1 format is explicitly PCM16 mono at 48 kHz', () { expect(PcmStreamFormat.audioModemV1.isAudioModemV1, isTrue); @@ -100,6 +171,59 @@ void main() { ); }); + test( + 'file bridge contract preserves typed metadata without a file UI', + () async { + final bridge = _FakeWavBridge(); + final built = await bridge.encodeFile( + sessionId: 9, + senderCallsign: 'N1', + fileName: 'sample.bin', + mimeType: 'application/octet-stream', + payload: Uint8List.fromList([0, 1, 2]), + profile: 'balanced', + carrier: 'bootstrap', + ); + final decoded = await bridge.decodeWavFile( + wavBytes: built.wavBytes, + carrier: 'bootstrap', + ); + + expect(bridge.encodedFileName, 'sample.bin'); + expect(decoded.fileName, 'sample.bin'); + expect(decoded.mimeType, 'application/octet-stream'); + expect(decoded.payload, Uint8List.fromList([0, 1, 2])); + }, + ); + + test('unavailable Rust bridge rejects the file contract', () async { + const bridge = UnavailableWavBootstrapBridge('No native bridge.'); + await expectLater( + bridge.encodeFile( + sessionId: 1, + senderCallsign: 'N1', + fileName: 'sample.bin', + mimeType: 'application/octet-stream', + payload: Uint8List.fromList([1]), + profile: 'balanced', + carrier: 'bootstrap', + ), + throwsA(isA()), + ); + }); + + test('transfer task separates busy and terminal shell states', () { + final preparing = TransferTaskState.preparing('Preparing…'); + final completed = TransferTaskState.completed('Completed.'); + final unavailable = TransferTaskState.unavailable('No route.'); + + expect(preparing.isBusy, isTrue); + expect(preparing.isTerminal, isFalse); + expect(completed.isBusy, isFalse); + expect(completed.isTerminal, isTrue); + expect(unavailable.label, 'НЕДОСТУПНО'); + }); + testWidgets('send workbench builds and verifies an in-memory WAV flow', ( tester, ) async { @@ -134,6 +258,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('ПРОВЕРЕНО RUST'), findsOneWidget); + expect(find.text('ГОТОВО'), findsOneWidget); + expect(find.text('Text WAV собран и проверен.'), findsOneWidget); expect(find.text('4 байт'), findsOneWidget); expect(bridge.encodedCarrier, 'acoustic1'); expect(bridge.decodedCarrier, 'acoustic1'); @@ -174,6 +300,82 @@ void main() { expect(find.text('Источник: received.wav'), findsOneWidget); }); + testWidgets('file shell selects, verifies and exposes a bounded file WAV', ( + tester, + ) async { + final payloadAdapter = _FakePayloadFileAdapter( + openedFile: SelectedPayloadFile( + name: 'sample.bin', + bytes: Uint8List.fromList([0, 1, 2]), + ), + ); + await tester.pumpWidget( + AudioModemApp( + bridge: _FakeWavBridge(), + fileAdapter: _FakeWavFileAdapter(), + payloadFileAdapter: payloadAdapter, + ), + ); + + await tester.tap(find.text('Файл')); + await tester.pumpAndSettle(); + final selectFileButton = find.widgetWithText( + OutlinedButton, + 'Выбрать файл', + ); + await tester.ensureVisible(selectFileButton); + await tester.tap(selectFileButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('sample.bin · 3 байт'), findsNWidgets(2)); + + final buildButton = find.widgetWithText( + FilledButton, + 'Собрать и проверить WAV', + ); + await tester.ensureVisible(buildButton); + await tester.tap(buildButton); + await tester.pumpAndSettle(); + + expect(find.text('File WAV собран и проверен.'), findsOneWidget); + expect(find.textContaining('sample.bin · 3 байт'), findsWidgets); + }); + + testWidgets('file WAV import exposes only a verified payload save action', ( + tester, + ) async { + final payloadAdapter = _FakePayloadFileAdapter(); + await tester.pumpWidget( + AudioModemApp( + bridge: _FakeWavBridge(textDecodeFails: true), + fileAdapter: _FakeWavFileAdapter( + openedFile: SelectedWavFile( + name: 'received-file.wav', + bytes: Uint8List.fromList([82, 73, 70, 70]), + ), + ), + payloadFileAdapter: payloadAdapter, + ), + ); + + await tester.tap(find.byIcon(Icons.south_west)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Импортировать WAV')); + await tester.pumpAndSettle(); + + expect(find.text('sample.bin'), findsOneWidget); + + final savePayloadButton = find.widgetWithText( + OutlinedButton, + 'Сохранить проверенный файл', + ); + await tester.ensureVisible(savePayloadButton); + await tester.tap(savePayloadButton); + await tester.pumpAndSettle(); + + expect(payloadAdapter.savedFile?.name, 'sample.bin'); + }); + testWidgets('live route selection remains unavailable and does not encode', ( tester, ) async { @@ -201,7 +403,7 @@ void main() { await tester.tap(buildButton); await tester.pumpAndSettle(); - expect(find.text('No tested live route.'), findsOneWidget); + expect(find.text('No tested live route.'), findsNWidgets(2)); expect(bridge.encodedCarrier, isNull); }); } diff --git a/crates/adlp-protocol/src/lib.rs b/crates/adlp-protocol/src/lib.rs index 45db6bb..316cfdc 100644 --- a/crates/adlp-protocol/src/lib.rs +++ b/crates/adlp-protocol/src/lib.rs @@ -82,6 +82,7 @@ pub enum ProtocolError { UnsupportedObjectKind(u8), InvalidUtf8, FieldTooLong(&'static str), + MissingManifestField(&'static str), PayloadTooLarge, IntegrityMismatch, } @@ -104,6 +105,9 @@ impl fmt::Display for ProtocolError { Self::FieldTooLong(field) => { write!(formatter, "manifest field {field} exceeds its limit") } + Self::MissingManifestField(field) => { + write!(formatter, "manifest field {field} must not be empty") + } Self::PayloadTooLarge => write!(formatter, "payload exceeds the protocol limit"), Self::IntegrityMismatch => write!(formatter, "ADLP integrity check failed"), } @@ -135,6 +139,30 @@ impl WireObject { Ok(object) } + pub fn file( + session_id: u64, + callsign: impl Into, + file_name: impl Into, + mime_type: impl Into, + payload: Vec, + profile: TransferProfile, + ) -> Result { + let object = Self { + manifest: TransferManifest { + protocol_version: PROTOCOL_VERSION, + profile, + session_id, + object_kind: ObjectKind::File, + sender_callsign: callsign.into(), + mime_type: mime_type.into(), + file_name: file_name.into(), + }, + payload, + }; + object.validate()?; + Ok(object) + } + pub fn encode(&self) -> Result, ProtocolError> { self.validate()?; let manifest = &self.manifest; @@ -223,6 +251,20 @@ impl WireObject { validate_string("sender_callsign", &self.manifest.sender_callsign, 32)?; validate_string("mime_type", &self.manifest.mime_type, 96)?; validate_string("file_name", &self.manifest.file_name, 160)?; + if self.manifest.mime_type.is_empty() { + return Err(ProtocolError::MissingManifestField("mime_type")); + } + match self.manifest.object_kind { + ObjectKind::Text if !self.manifest.file_name.is_empty() => { + return Err(ProtocolError::MissingManifestField( + "file_name must be empty for text", + )); + } + ObjectKind::File if self.manifest.file_name.is_empty() => { + return Err(ProtocolError::MissingManifestField("file_name")); + } + _ => {} + } if self.payload.len() > MAX_PAYLOAD_BYTES { return Err(ProtocolError::PayloadTooLarge); } @@ -319,6 +361,45 @@ mod tests { ); } + #[test] + fn file_round_trip_preserves_manifest_and_binary_payload() { + let object = WireObject::file( + 43, + "N2", + "sample.bin", + "application/octet-stream", + vec![0, 1, 2, 255], + TransferProfile::Reliable, + ) + .unwrap(); + assert_eq!( + WireObject::decode(&object.encode().unwrap()).unwrap(), + object + ); + } + + #[test] + fn file_object_requires_a_name_and_mime_type() { + assert!(WireObject::file( + 43, + "N2", + "", + "application/octet-stream", + vec![1], + TransferProfile::Reliable, + ) + .is_err()); + assert!(WireObject::file( + 43, + "N2", + "sample.bin", + "", + vec![1], + TransferProfile::Reliable, + ) + .is_err()); + } + #[test] fn tampering_is_detected() { let object = WireObject::text(1, "N1", "hello", TransferProfile::Reliable).unwrap(); diff --git a/crates/audio-modem-core/src/lib.rs b/crates/audio-modem-core/src/lib.rs index 3ca45cb..757cea0 100644 --- a/crates/audio-modem-core/src/lib.rs +++ b/crates/audio-modem-core/src/lib.rs @@ -245,6 +245,8 @@ mod tests { use adlp_protocol::TransferProfile; const GOLDEN_WAV: &[u8] = include_bytes!("../tests/fixtures/adlp-v1-text-balanced.wav"); + const GOLDEN_FILE_WAV: &[u8] = include_bytes!("../tests/fixtures/adlp-v1-file-balanced.wav"); + const GOLDEN_FILE_PAYLOAD: &[u8] = include_bytes!("../tests/fixtures/adlp-v1-file-source.bin"); #[test] fn wav_round_trip_preserves_adlp_object() { @@ -280,4 +282,22 @@ mod tests { assert_eq!(decoded.samples_consumed, 20_160); assert_eq!(encode_wav(&expected).unwrap(), GOLDEN_WAV); } + + #[test] + fn golden_file_wav_fixture_decodes_and_matches_deterministic_encoder() { + let expected = WireObject::file( + 2, + "FILEFIXTURE", + "adlp-v1-file-source.bin", + "application/octet-stream", + GOLDEN_FILE_PAYLOAD.to_vec(), + TransferProfile::Balanced, + ) + .unwrap(); + let decoded = decode_wav(GOLDEN_FILE_WAV).unwrap(); + + assert_eq!(decoded.object, expected); + assert_eq!(decoded.sample_rate_hz, SAMPLE_RATE_HZ); + assert_eq!(encode_wav(&expected).unwrap(), GOLDEN_FILE_WAV); + } } diff --git a/crates/audio-modem-core/tests/fixtures/adlp-v1-file-balanced.wav b/crates/audio-modem-core/tests/fixtures/adlp-v1-file-balanced.wav new file mode 100644 index 0000000000000000000000000000000000000000..bbb4c52d41b3f9c02db0bd8a662f3340c4a113b7 GIT binary patch literal 53804 zcmeH}F=`w^5Jg8mfsWw77jfVPXTpIU3xqRg0;fKbQsGY|?bU z`Tgq8AoT6)KqWm32(WcPg19lc&Yg@!85c zCgeMn%#q2{;_3KoWgQdpol54&2Dw!jb zr^VCp*~&U5dPv>pE8|VhQfmsdEzi(&b zXZ8G64(;=;oLk*j_oL2NznAu(+c52u@^fwXr>Xtb`THB_1m_0&`}1c5x=(ffp4ltUQh&-<9Gn?CFXf$Quehn_rTJ>U(#b6` z>ON`yEp=O&h4w@Fio?jze9Dh^IjpHa<>`#-d^aCDFXidHYM%O2p8C^#%2OXVUOg|( zSM#)g>O=F@JoTqM^;hS+`OtYOub!9ssCl<}-F#^O)L+f3{b{}%Py1Kb)Pg} z&C`C=yxY8PKD2)~f9j*2m*!KR&g;gj=cW0Sr+w0V%By{7zM6OIhx$;S_DS<8Pkr2Y z^}IBn^6Ea_eCWK?pYp5wP=CtPKGi(+KO0Z`r~S};%By|U`E*{&yZKWeHBbAbyxO1U zyZNhqXg=lL`ltSsr}I*t`cR(wP@eiwUhPBkDepG#*?j2xqCD-B^3;d&Y9Dp}>hr4m zam%MZl&9YV<*5(l+lMwbmv`%*PN()?J)ibTdD;)O*9>UrJzp+1zSeNtZSL-Q%`Ht*^_)SvQh?^o@!dOy^k@^1Z8 zf6BYfOMNJ>?uYtNp88Xs`cPi&px=f6DJ~I8UHHl+SOEr*8RfKC!X6J3VjSz}&WvPs^u^r)tA^VDMztLMA*ukKSlFU?o;>ON_{8&CUp^LO*1eNtXMFZEILbl$W1 zoNeCKzX$41dAILD?N9U7eD|L>O#8{x;wjCie7rH=q4{b)w>|!p&U?J!S~t=@)%;rR z?YHed^A2f0d0ITB`EGoiXzmW3m)=hEDZjc8^`|`Tlk#pp)SvQh^HLu-zWdMLh`OKr z_Tx{_Hm|y2+NYYQ^Sb#^f6Di~c>}ah%ICMoQ+0m4aqbTFU!AAz(RtPRXY-+bQr@jk awLhI#&8z37`EESzpZe4M)p^>F8~-2iTa)wv literal 0 HcmV?d00001 diff --git a/crates/audio-modem-core/tests/fixtures/adlp-v1-file-source.bin b/crates/audio-modem-core/tests/fixtures/adlp-v1-file-source.bin new file mode 100644 index 0000000..08ab6a4 --- /dev/null +++ b/crates/audio-modem-core/tests/fixtures/adlp-v1-file-source.bin @@ -0,0 +1 @@ +AudioModem file-object golden payload v1. diff --git a/docs/README.md b/docs/README.md index 5648553..59ceadb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ This directory holds the human-readable technical documentation for AudioModem. | Operations | [Troubleshooting](troubleshooting.md) | [Диагностика](troubleshooting_RU.md) | Reproducible observations and reports | | Operations | [Device acceptance](operations/device-acceptance.md) | [Приёмка устройств](operations/device-acceptance_RU.md) | Evidence contract and decision gates for future live routes | | 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 | | 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 | diff --git a/docs/README_RU.md b/docs/README_RU.md index 58d41db..a2daae0 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -4,7 +4,7 @@ Этот каталог содержит читаемую техническую документацию AudioModem. Английские файлы являются каноническими. Русские переводы используют суффикс `_RU.md`, ссылаются на английский оригинал и носят информативный характер, пока RFC явно не установит иное. -> **Translation of:** [docs/README.md](README.md). **Last synced:** 2026-08-20. +> **Translation of:** [docs/README.md](README.md). **Last synced:** 2026-08-21. | Раздел | Английский оригинал | Русский перевод | Область | | --- | --- | --- | --- | @@ -15,6 +15,7 @@ | Operations | [Troubleshooting](troubleshooting.md) | [Диагностика](troubleshooting_RU.md) | Воспроизводимые наблюдения и отчёты | | Operations | [Device acceptance](operations/device-acceptance.md) | [Приёмка устройств](operations/device-acceptance_RU.md) | Evidence contract и decision gates для будущих live routes | | 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 в памяти | | 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 | diff --git a/docs/architecture/flutter-rust-bridge.md b/docs/architecture/flutter-rust-bridge.md index ca011d6..dfe102b 100644 --- a/docs/architecture/flutter-rust-bridge.md +++ b/docs/architecture/flutter-rust-bridge.md @@ -1,6 +1,6 @@ # Flutter ↔ Rust WAV bridge -**Last reviewed:** 2026-08-20 · **English (canonical)** · [Русский](flutter-rust-bridge_RU.md) +**Last reviewed:** 2026-08-21 · **English (canonical)** · [Русский](flutter-rust-bridge_RU.md) The native bridge exposes Rust ADLP plus two controlled WAV codecs to Flutter: deterministic bootstrap and experimental Acoustic-1. The app has a separate local file adapter for selecting or saving WAV bytes, while the bridge itself still does not introduce audio capture, playback, Bluetooth, background transmission or encryption. Its purpose is to make one real app-to-core round trip observable and testable without duplicating protocol or codec logic in Dart. @@ -18,18 +18,35 @@ The bridge crate is a thin native facade. `adlp-protocol` remains responsible fo | --- | --- | --- | --- | | `encodeTextToWav` | Positive `sessionId`, callsign, UTF-8 text, profile and explicit carrier (`bootstrap` or `acoustic1`) | Canonical 48 kHz mono 16-bit WAV bytes and transfer metadata | A typed Rust error becomes a Dart exception; no partial WAV is returned. | | `decodeWav` | WAV bytes from memory and explicit carrier | Verified text metadata, UTF-8 payload, sample rate and consumed samples | Wrong-carrier, framing, manifest or CRC-32C failure becomes an exception; no payload is returned. | +| `encodeFileToWav` | Positive `sessionId`, callsign, leaf file name, declared MIME type, bounded bytes, profile and explicit carrier | Canonical 48 kHz mono 16-bit WAV bytes and transfer metadata | Invalid metadata or an oversized payload becomes a Dart exception; no partial WAV is returned. | +| `decodeWavFile` | WAV bytes from memory and explicit carrier | Verified file metadata, original bytes, sample rate and consumed samples | A text object, wrong-carrier, framing, manifest or CRC-32C failure becomes an exception; no file bytes are returned. | The Flutter app chooses the session value in this first slice. The screen uses a positive timestamp-derived value only as local transfer metadata; it is not a timestamp claim, identity claim or cryptographic nonce. ## File adapter boundary -`PlatformWavFileAdapter` opens local platform dialogs and returns opaque WAV bytes or submits a verified in-memory buffer for saving. It does not parse the waveform, decide protocol validity, or read a payload. The Flutter workbench sends all selected bytes to `decodeWav`; a failed decode retains no received object. This keeps user filesystem interaction outside the deterministic protocol and codec boundary. +`PlatformWavFileAdapter` opens local platform dialogs and returns opaque WAV bytes or submits a verified in-memory buffer for saving. `PlatformPayloadFileAdapter` separately selects a local outbound file or saves only bytes returned by `decodeWavFile`. Neither adapter parses waveforms, decides protocol validity, infers a MIME type from received bytes, or reads a payload before Rust validation. The workbench tries a typed text decoder and then a typed file decoder for an imported WAV; a failed decode retains no received object. This keeps user filesystem interaction outside the deterministic protocol and codec boundary. ## Carriers, profiles and limits The bridge accepts `bootstrap` and `acoustic1` carriers. Bootstrap retains one deterministic symbol mapper for every profile. Acoustic-1 has a separate B-FSK mapper with profile-dependent symbol windows, Hamming(7,4), bounded frame acquisition and a 256-byte ADLP wire-object limit; its exact compatibility contract is in the [Acoustic-1 RFC](../../spec/acoustic-1.md). The UI keeps a carrier selection with an explicit experimental notice and reuses that selection for import, export and verification. -The bridge accepts `reliable`, `balanced`, `fast` and `narrowband`, which map directly to ADLP profile IDs 1–4. The native facade rejects text longer than **8 KiB** before WAV allocation. This lower application limit protects a mobile or desktop UI from accidentally creating impractically large buffers while the general protocol limit remains larger for future profiles. +The bridge accepts `reliable`, `balanced`, `fast` and `narrowband`, which map directly to ADLP profile IDs 1–4. The native facade rejects text or file payloads longer than **8 KiB** before WAV allocation. This lower application limit protects a mobile or desktop UI from accidentally creating impractically large buffers while the general protocol limit remains larger for future profiles. + +## Native integration smoke check + +Widget tests use fakes to keep the normal Flutter suite portable. A separate opt-in Linux smoke test loads the shared library from the freshly built bundle, calls the real generated bridge, round-trips a bounded file object and checks a wrong-carrier rejection. + +```bash +export PATH="/home/ubuntu/.local/flutter/bin:$HOME/.cargo/bin:$PATH" +cd apps/audio_modem +flutter build linux --debug +AUDIOMODEM_RUN_NATIVE_BRIDGE_SMOKE=1 \ + LD_LIBRARY_PATH="$PWD/build/linux/x64/debug/bundle/lib" \ + flutter test test/native_bridge_smoke_test.dart +``` + +This check proves the native application-to-core binding for the documented fixture. It does not test a microphone, speaker, cable, Bluetooth path, radio interface or any live route. ## Trust boundary diff --git a/docs/architecture/flutter-rust-bridge_RU.md b/docs/architecture/flutter-rust-bridge_RU.md index b4d9e66..c42a4ab 100644 --- a/docs/architecture/flutter-rust-bridge_RU.md +++ b/docs/architecture/flutter-rust-bridge_RU.md @@ -2,7 +2,7 @@ [English (canonical)](flutter-rust-bridge.md) · **Русский перевод** -> **Translation of:** [docs/architecture/flutter-rust-bridge.md](flutter-rust-bridge.md). **Last synced:** 2026-08-20. +> **Translation of:** [docs/architecture/flutter-rust-bridge.md](flutter-rust-bridge.md). **Last synced:** 2026-08-21. Native bridge открывает Flutter доступ к Rust ADLP и двум controlled WAV codecs: детерминированному bootstrap и экспериментальному Acoustic-1. В app есть отдельный local file adapter для выбора и сохранения WAV bytes, тогда как сам bridge всё ещё не добавляет audio capture, playback, Bluetooth, background transmission или encryption. Его цель — сделать один реальный app-to-core round trip наблюдаемым и тестируемым без дублирования protocol или codec logic в Dart. @@ -20,18 +20,35 @@ Bridge crate — тонкий native facade. `adlp-protocol` остаётся о | --- | --- | --- | --- | | `encodeTextToWav` | Положительный `sessionId`, callsign, UTF-8 text, profile и явный carrier (`bootstrap` или `acoustic1`) | Canonical 48 kHz mono 16-bit WAV bytes и transfer metadata | Typed Rust error становится Dart exception; partial WAV не возвращается. | | `decodeWav` | WAV bytes из памяти и явный carrier | Verified text metadata, UTF-8 payload, sample rate и consumed samples | Wrong-carrier, framing, manifest или CRC-32C failure становится exception; payload не возвращается. | +| `encodeFileToWav` | Положительный `sessionId`, callsign, leaf file name, declared MIME type, bounded bytes, profile и явный carrier | Canonical 48 kHz mono 16-bit WAV bytes и transfer metadata | Invalid metadata или oversized payload становится Dart exception; partial WAV не возвращается. | +| `decodeWavFile` | WAV bytes из памяти и явный carrier | Verified file metadata, original bytes, sample rate и consumed samples | Text object, wrong-carrier, framing, manifest или CRC-32C failure становится exception; file bytes не возвращаются. | В этом первом срезе Flutter app выбирает session value. Экран использует positive timestamp-derived value только как local transfer metadata; это не timestamp claim, identity claim или cryptographic nonce. ## Граница file adapter -`PlatformWavFileAdapter` открывает локальные platform dialogs и возвращает opaque WAV bytes или передаёт проверенный in-memory buffer на сохранение. Он не анализирует waveform, не принимает решение о protocol validity и не читает payload. Flutter workbench передаёт все выбранные bytes в `decodeWav`; при failed decode полученный object не сохраняется. Это оставляет user filesystem interaction за пределами deterministic protocol и codec boundary. +`PlatformWavFileAdapter` открывает локальные platform dialogs и возвращает opaque WAV bytes или передаёт проверенный in-memory buffer на сохранение. `PlatformPayloadFileAdapter` отдельно выбирает local outbound file или сохраняет только bytes, возвращённые `decodeWavFile`. Ни один adapter не анализирует waveform, не принимает решение о protocol validity, не выводит MIME type из received bytes и не читает payload до Rust validation. Workbench сначала пробует typed text decoder, затем typed file decoder для imported WAV; при failed decode полученный object не сохраняется. Это оставляет user filesystem interaction за пределами deterministic protocol и codec boundary. ## Carriers, profiles и limits Bridge принимает carriers `bootstrap` и `acoustic1`. Bootstrap сохраняет один deterministic symbol mapper для каждого profile. Acoustic-1 использует отдельный B-FSK mapper с profile-dependent symbol windows, Hamming(7,4), bounded frame acquisition и лимитом 256 bytes для ADLP wire object; точный compatibility contract указан в [Acoustic-1 RFC](../../spec/acoustic-1_RU.md). UI хранит carrier selection с явным experimental notice и использует тот же выбор для import, export и verification. -Bridge принимает `reliable`, `balanced`, `fast` и `narrowband`, напрямую соответствующие ADLP profile IDs 1–4. Native facade отклоняет text длиннее **8 KiB** до WAV allocation. Этот более низкий application limit защищает mobile или desktop UI от случайного создания непрактично больших buffers, пока общий protocol limit остаётся больше для future profiles. +Bridge принимает `reliable`, `balanced`, `fast` и `narrowband`, напрямую соответствующие ADLP profile IDs 1–4. Native facade отклоняет text или file payload длиннее **8 KiB** до WAV allocation. Этот более низкий application limit защищает mobile или desktop UI от случайного создания непрактично больших buffers, пока общий protocol limit остаётся больше для future profiles. + +## Native integration smoke check + +Widget tests используют fakes, чтобы normal Flutter suite оставался portable. Отдельный opt-in Linux smoke test загружает shared library из freshly built bundle, вызывает real generated bridge, round-trips bounded file object и проверяет wrong-carrier rejection. + +```bash +export PATH="/home/ubuntu/.local/flutter/bin:$HOME/.cargo/bin:$PATH" +cd apps/audio_modem +flutter build linux --debug +AUDIOMODEM_RUN_NATIVE_BRIDGE_SMOKE=1 \ + LD_LIBRARY_PATH="$PWD/build/linux/x64/debug/bundle/lib" \ + flutter test test/native_bridge_smoke_test.dart +``` + +Этот check доказывает native application-to-core binding для documented fixture. Он не тестирует microphone, speaker, cable, Bluetooth path, radio interface или любой live route. ## Trust boundary diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index e10f347..077dbb2 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -24,7 +24,7 @@ cargo run -p adlp-cli -- encode-text hello.wav N1 "Hello from AudioModem" reliab The command serializes an ADLP v1 text object, then maps it into a mono 48 kHz, signed 16-bit PCM WAV file. `N1` is a callsign displayed in open metadata. It does **not** identify or authenticate the sender. -## Move the file and decode it +## Move a text WAV and decode it Copy `hello.wav` to the receiving machine using any ordinary file channel, then run: @@ -34,6 +34,18 @@ cargo run -p adlp-cli -- decode hello.wav The decoder prints the ADLP version, profile, session value, callsign, sample count and text. A decoder must reject malformed bootstrap framing or a CRC-32C mismatch before it returns the payload. +## Encode a small file as WAV + +The Rust CLI can also verify a bounded ADLP `File` object before there is a Flutter file-transfer UI. The first facade limit is **8 KiB** of file payload. Supply an explicit MIME type when known; the default is `application/octet-stream`. + +```bash +printf 'fixture bytes\n' > sample.bin +cargo run -p adlp-cli -- encode-file sample.wav N1 sample.bin application/octet-stream balanced +cargo run -p adlp-cli -- decode sample.wav +``` + +The decoder prints the original leaf file name, declared MIME type and payload size after ADLP framing and CRC-32C validation. This is a reproducible Rust/WAV contract only. It does **not** yet provide a Flutter file picker/receipt workflow, persistence, file-size policy beyond the initial facade limit, or a physical audio route. + ## Controlled Acoustic-1 WAV experiment Acoustic-1 is a separate experimental B-FSK carrier. It uses a different encoder and decoder, so both commands must name the carrier explicitly. Its framing, bounded synchronisation, Hamming(7,4) layer and limits are defined in the [Acoustic-1 RFC](../../spec/acoustic-1.md). @@ -60,6 +72,7 @@ The command prints an accepted/rejected codec result, sample counts, bounded acq | This test proves | This test does not prove | | --- | --- | | The implemented ADLP v1 object can survive one deterministic WAV encode/decode cycle. | That speech-band audio, a speaker, microphone, cable, Bluetooth adapter or radio can carry the waveform. | +| A bounded file object preserves its name, declared MIME type and bytes through the Rust CLI WAV reference path. | That the Flutter application can send or save a file-object transfer yet. | | The final integrity check detects the intentional corrupted-frame cases covered by the Rust tests; Acoustic-1 also has bounded noise, framing, FEC and golden-vector regression cases. | That a callsign belongs to a person, device or key. | | An Acoustic-2 transform vector has deterministic PCM output and codec-observable acquisition values. | That synthetic gain/noise/clip/deletion values predict a physical audio channel. | | The test is portable between supported Rust development environments. | That a transmission is encrypted, confidential or authenticated. | diff --git a/docs/guides/getting-started_RU.md b/docs/guides/getting-started_RU.md index 25d055e..a84153c 100644 --- a/docs/guides/getting-started_RU.md +++ b/docs/guides/getting-started_RU.md @@ -2,7 +2,7 @@ [English (canonical)](getting-started.md) · **Русский перевод** -> **Translation of:** [docs/guides/getting-started.md](getting-started.md). **Last synced:** 2026-08-20. +> **Translation of:** [docs/guides/getting-started.md](getting-started.md). **Last synced:** 2026-08-21. Это руководство проверяет первый реализованный путь AudioModem. Оно не проверяет динамик, микрофон, Bluetooth-устройство или радиостанцию. Вместо этого используется lossless WAV, чтобы неудачный round trip можно было отнести к container или codec, а не к неизвестному live-аудиомаршруту. @@ -26,7 +26,7 @@ cargo run -p adlp-cli -- encode-text hello.wav N1 "Hello from AudioModem" reliab Команда сериализует ADLP v1 text object и отображает его в mono 48 kHz signed 16-bit PCM WAV. `N1` — callsign, видимый в открытой metadata. Он **не** идентифицирует и не аутентифицирует отправителя. -## Передача файла и декодирование +## Передача text WAV и декодирование Скопируйте `hello.wav` на принимающую машину любым обычным файловым каналом и выполните: @@ -36,6 +36,18 @@ cargo run -p adlp-cli -- decode hello.wav Decoder выводит ADLP version, profile, session value, callsign, sample count и text. Он обязан отклонить malformed bootstrap framing или CRC-32C mismatch до возврата payload. +## Кодирование небольшого файла в WAV + +Rust CLI также может проверить bounded ADLP `File` object до появления Flutter file-transfer UI. Первый facade limit — **8 KiB** file payload. Если MIME type известен, передайте его явно; default — `application/octet-stream`. + +```bash +printf 'fixture bytes\n' > sample.bin +cargo run -p adlp-cli -- encode-file sample.wav N1 sample.bin application/octet-stream balanced +cargo run -p adlp-cli -- decode sample.wav +``` + +После ADLP framing и CRC-32C validation decoder выводит original leaf file name, declared MIME type и payload size. Это только reproducible Rust/WAV contract. Он **не** даёт Flutter file picker/receipt workflow, persistence, file-size policy сверх initial facade limit или physical audio route. + ## Controlled Acoustic-1 WAV experiment Acoustic-1 — отдельный экспериментальный B-FSK carrier. Он использует другой encoder и decoder, поэтому обе команды должны явно называть carrier. Его framing, bounded synchronisation, Hamming(7,4) layer и limits определены в [Acoustic-1 RFC](../../spec/acoustic-1_RU.md). @@ -62,6 +74,7 @@ cargo run -p adlp-cli -- measure-acoustic1 acoustic1.wav 137 500 200 2885812225 | Тест доказывает | Тест не доказывает | | --- | --- | | Реализованный ADLP v1 object переживает один детерминированный WAV encode/decode cycle. | Что waveform пройдёт через speech-band audio, динамик, микрофон, кабель, Bluetooth adapter или радио. | +| Bounded file object сохраняет name, declared MIME type и bytes через Rust CLI WAV reference path. | Что Flutter application уже может отправить или сохранить file-object transfer. | | Final integrity check обнаруживает intentional corrupted-frame cases из Rust-тестов; Acoustic-1 также имеет bounded noise, framing, FEC и golden-vector regression cases. | Что callsign принадлежит человеку, устройству или ключу. | | Acoustic-2 transform vector имеет deterministic PCM output и codec-observable acquisition values. | Что synthetic gain/noise/clip/deletion values предсказывают physical audio channel. | | Тест переносим между Rust development environments. | Что передача зашифрована, конфиденциальна или authenticated. | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..3bc80c1 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,79 @@ +# Implementation plan + +**Status:** Maintainer execution plan · **Last reviewed:** 2026-08-21 · **English (canonical)** · [Русский](implementation-plan_RU.md) + +This plan turns the public roadmap into an implementation order. It is deliberately test-gated: a layer may be used by the next layer only after its explicit exit criteria are satisfied. The plan does not create a delivery date, a device-support claim, or a live-audio claim. + +## Current baseline + +| Layer | Verified today | Deliberately absent today | +| --- | --- | --- | +| ADLP Rust crate | Versioned text and file containers, transfer profiles, manifest validation and CRC-32C tests. | Encryption, identity authentication and any UI logic. | +| Audio Rust crate | Deterministic 48 kHz PCM/WAV bootstrap, text/file golden fixtures, experimental Acoustic-1 and controlled Acoustic-2 measurement harness. | Streaming encode/decode API, microphone input, speaker output and physical-channel compatibility. | +| Flutter Rust facade | Typed text/file-to-WAV and WAV-to-text/file calls; native availability reporting. | Codec reimplementation and platform-route ownership. | +| Flutter workbench | Text and bounded file selection, callsign, preset, carrier, local WAV export/import, verified text/file receipts and local verified-payload save. | Persistent history, file-size policy beyond the initial 8 KiB bridge limit, live capture/playback and a hardware support claim. | +| Live-audio boundary | PCM v1 contract and unavailable implementation. | Permission requests, audio session/focus, a plugin, a real platform adapter or route result. | + +> **Ownership rule:** Rust owns every byte-level protocol and codec decision. Flutter owns user intent, presentation, task state and platform-adapter selection. The bridge translates typed values only; it must not become a second protocol implementation. + +## Delivery sequence + +### Stage A — Freeze reproducible core behavior + +The next application work relies on a small set of Rust-owned, reproducible contracts. Existing text/WAV fixtures remain the reference. Before adding a new object kind or carrier, its Rust API, deterministic fixture, rejection cases and CLI behavior must land together. The Flutter application may mock that API while the contract is being prepared, but it must not infer bytes, checksums or decoder success itself. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Current ADLP and WAV tests pass. | Typed core/bridge contract for the next supported object workflow, plus deterministic fixture and invalid-input cases. | `cargo fmt`, clippy, workspace tests and fixture regression tests pass. | No live route or cross-platform feature is implied. | + +### Stage B — Build the Flutter transfer shell against fakes + +The shell is developed before it depends on a new native capability. It will model a transfer as a user-visible task with preparation, verification, export/import, completion, rejection and cancellation states. A fake bridge can produce deterministic results for widget tests; `UnavailableWavBootstrapBridge` and `UnavailableLiveAudioAdapter` remain required negative paths. + +The shell will first cover text and existing local WAV behavior. Its data model must reserve, but not pretend to implement, file-object payloads, route diagnostics and a future receive queue. Callsign stays display metadata and must not be presented as authentication. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| A documented bridge contract and fake response fixture exist. | Testable transfer controller/state model; send and receive views; preset/callsign input; clear unavailable and rejection surfaces. | Widget tests cover success, cancellation, malformed input, bridge failure and unavailable route states. | A fake transfer is not an encoded ADLP object and is not a hardware test. | + +### Stage C — Connect the shell to the already tested Rust facade + +When the shell's state model is stable, native integration binds its small bridge interface to the Rust facade. The verification criterion is a known fixture and decoded metadata, not merely a successful button press. Web remains a truthful unavailable build until a separately designed web-compatible core is available. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Shell tests pass against fake and unavailable dependencies. | Native integration tests for encode, decode, selected carrier, export/import and rejected input. | Flutter analysis/tests plus native Linux build succeed; decoded result matches the selected fixture. | Native fixture success does not prove speaker-to-microphone delivery. | + +### Stage D — Extend data workflows deliberately + +Text must not be silently overloaded to represent an image or file. The first bounded `File` payload workflow now exists in ADLP, Rust fixtures, CLI, the thin bridge and Flutter local selection/receipt/save UI. The next work is an explicit size/memory policy beyond the initial bridge limit and native fixture integration, preserving deterministic local WAV reproduction before any physical route is attempted. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Text flow and integration gates pass. | File-object RFC, Rust validation/fixtures, thin bridge values, Flutter file-selection and receipt UI. | Rust and Flutter fixtures preserve name, declared media type, bytes and rejection behavior. | WAV-file transfer does not demonstrate live acoustic file transfer. | + +### Stage E — Add one live-audio platform adapter + +Only after the Flutter shell and PCM contract are stable should work begin on one platform. The initial target must be selected explicitly in a route RFC; it will specify plugin/native APIs, session/permission policy, interruption behavior, one route, buffer boundaries, diagnostics and stop/dispose semantics. The adapter starts as experimental and defaults to unavailable outside its declared scope. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Route RFC, test method and target build are approved. | One adapter, unit/integration tests and a privacy-preserving device-acceptance template. | The adapter reports only its declared availability; a reviewed physical-route report exists for the exact scope. | One observation is not a broad compatibility, range, Bluetooth or radio claim. | + +### Stage F — Publish evidence, compatibility and releases + +After repeated reviewed evidence, compatibility documentation may describe the exact tested target, build, carrier, route and limitations. Release engineering follows only when supported scope has a reproducible build and a documented support policy. Trust and encryption remain an independent RFC track; they are not added as an opportunistic UI toggle. + +## Working rules + +| Rule | Practical consequence | +| --- | --- | +| Test the layer that owns the behavior. | Rust tests establish protocol/codec correctness; Flutter tests establish user-flow and presentation correctness; device reports establish only observed route behavior. | +| Preserve one-way dependencies. | Flutter calls the bridge; the bridge calls Rust; Rust does not import Flutter or platform UI concerns. | +| Keep unsupported paths visible. | Unavailable adapters return a reason and do not initialize plugins or request permissions. | +| Use fixtures as integration currency. | Every new workflow supplies a committed success fixture and a meaningful rejection case before UI success wording is added. | +| Make claims narrower than evidence. | A passing test, schema-valid report or one device observation never becomes a generic support badge. | + +## Immediate execution order + +The active implementation milestone is **Stage B preparation**. First, the project will define a small transfer-task model and fake bridge fixture for the existing text/WAV workflow. Next, the Flutter shell will be separated from its current page-local state so that widget tests can drive send, receive, cancellation, unavailable and rejection paths without native audio. The existing Rust bridge and golden fixtures stay unchanged until the shell contract identifies a concrete missing typed value. diff --git a/docs/implementation-plan_RU.md b/docs/implementation-plan_RU.md new file mode 100644 index 0000000..f6e2ec5 --- /dev/null +++ b/docs/implementation-plan_RU.md @@ -0,0 +1,81 @@ +# План реализации + +[English (canonical)](implementation-plan.md) · **Русский перевод** + +> **Translation of:** [docs/implementation-plan.md](implementation-plan.md). **Last synced:** 2026-08-21. Английский оригинал определяет implementation contract. + +Этот план превращает публичную roadmap в порядок реализации. Он построен вокруг test gates: слой может использоваться следующим слоем только после выполнения явно указанных exit criteria. План не создаёт срок поставки, device-support claim или live-audio claim. + +## Текущий baseline + +| Слой | Что подтверждено сейчас | Чего намеренно нет сейчас | +| --- | --- | --- | +| ADLP Rust crate | Versioned text и file containers, transfer profiles, manifest validation и CRC-32C tests. | Encryption, identity authentication и UI logic. | +| Audio Rust crate | Deterministic 48 kHz PCM/WAV bootstrap, text/file golden fixtures, experimental Acoustic-1 и controlled Acoustic-2 measurement harness. | Streaming encode/decode API, microphone input, speaker output и physical-channel compatibility. | +| Flutter Rust facade | Typed text/file-to-WAV и WAV-to-text/file calls; native availability reporting. | Codec reimplementation и platform-route ownership. | +| Flutter workbench | Text и bounded file selection, callsign, preset, carrier, local WAV export/import, verified text/file receipts и local verified-payload save. | Persistent history, file-size policy сверх initial 8 KiB bridge limit, live capture/playback и hardware support claim. | +| Live-audio boundary | PCM v1 contract и unavailable implementation. | Permission requests, audio session/focus, plugin, real platform adapter или route result. | + +> **Правило ownership:** Rust владеет каждым byte-level protocol и codec решением. Flutter владеет user intent, presentation, task state и platform-adapter selection. Bridge переводит только typed values; он не должен стать второй protocol implementation. + +## Последовательность поставки + +### Stage A — Зафиксировать reproducible core behavior + +Следующая application work опирается на небольшой набор Rust-owned reproducible contracts. Existing text/WAV fixtures остаются reference. Перед добавлением нового object kind или carrier его Rust API, deterministic fixture, rejection cases и CLI behavior должны попасть вместе. Flutter application может mock'ировать этот API, пока contract готовится, но не должна самостоятельно выводить bytes, checksums или decoder success. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Current ADLP и WAV tests проходят. | Typed core/bridge contract для следующего supported object workflow, deterministic fixture и invalid-input cases. | Проходят `cargo fmt`, clippy, workspace tests и fixture regression tests. | Не подразумеваются live route или cross-platform feature. | + +### Stage B — Построить Flutter transfer shell поверх fakes + +Shell разрабатывается до зависимости от новой native capability. Она будет моделировать transfer как user-visible task со states preparation, verification, export/import, completion, rejection и cancellation. Fake bridge может выдавать deterministic results для widget tests; `UnavailableWavBootstrapBridge` и `UnavailableLiveAudioAdapter` остаются обязательными negative paths. + +Сначала shell покрывает text и existing local WAV behavior. Её data model должна резервировать, но не притворяться реализующей, file-object payloads, route diagnostics и future receive queue. Callsign остаётся display metadata и не должен показываться как authentication. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Есть documented bridge contract и fake response fixture. | Testable transfer controller/state model; send и receive views; preset/callsign input; clear unavailable и rejection surfaces. | Widget tests покрывают success, cancellation, malformed input, bridge failure и unavailable route states. | Fake transfer не является encoded ADLP object и hardware test. | + +### Stage C — Подключить shell к уже протестированному Rust facade + +Когда shell state model стабилен, native integration связывает small bridge interface с Rust facade. Verification criterion — known fixture и decoded metadata, а не только successful button press. Web остаётся truthful unavailable build, пока не появится отдельно спроектированный web-compatible core. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Shell tests проходят с fake и unavailable dependencies. | Native integration tests для encode, decode, selected carrier, export/import и rejected input. | Проходят Flutter analysis/tests и native Linux build; decoded result совпадает с selected fixture. | Native fixture success не доказывает speaker-to-microphone delivery. | + +### Stage D — Осмотрительно расширить data workflows + +Text не должен молча использоваться как image или file. Первый bounded `File` payload workflow теперь существует в ADLP, Rust fixtures, CLI, thin bridge и Flutter local selection/receipt/save UI. Следующая работа — explicit size/memory policy сверх initial bridge limit и native fixture integration, сохраняющие deterministic local WAV reproduction до попытки physical route. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Text flow и integration gates пройдены. | File-object RFC, Rust validation/fixtures, thin bridge values, Flutter file-selection и receipt UI. | Rust и Flutter fixtures сохраняют name, declared media type, bytes и rejection behavior. | WAV-file transfer не доказывает live acoustic file transfer. | + +### Stage E — Добавить один live-audio platform adapter + +Только после стабилизации Flutter shell и PCM contract начинается работа на одной platform. Initial target выбирается явно в route RFC; он задаёт plugin/native APIs, session/permission policy, interruption behavior, один route, buffer boundaries, diagnostics и stop/dispose semantics. Adapter начинается как experimental и defaults to unavailable вне declared scope. + +| Entry condition | Work product | Exit gate | Explicit non-claim | +| --- | --- | --- | --- | +| Route RFC, test method и target build утверждены. | Один adapter, unit/integration tests и privacy-preserving device-acceptance template. | Adapter сообщает только declared availability; существует reviewed physical-route report для exact scope. | Одно observation не является broad compatibility, range, Bluetooth или radio claim. | + +### Stage F — Публикация evidence, compatibility и releases + +После repeated reviewed evidence compatibility documentation может описывать exact tested target, build, carrier, route и limitations. Release engineering начинается только когда supported scope имеет reproducible build и documented support policy. Trust и encryption остаются independent RFC track; они не добавляются как opportunistic UI toggle. + +## Working rules + +| Rule | Практическое следствие | +| --- | --- | +| Test the layer that owns behavior. | Rust tests устанавливают protocol/codec correctness; Flutter tests — user-flow и presentation correctness; device reports — только observed route behavior. | +| Preserve one-way dependencies. | Flutter вызывает bridge; bridge вызывает Rust; Rust не импортирует Flutter или platform UI concerns. | +| Keep unsupported paths visible. | Unavailable adapters возвращают reason и не инициализируют plugins и не запрашивают permissions. | +| Use fixtures as integration currency. | Каждый новый workflow даёт committed success fixture и meaningful rejection case до добавления UI success wording. | +| Make claims narrower than evidence. | Passing test, schema-valid report или одно device observation никогда не превращаются в generic support badge. | + +## Ближайший порядок исполнения + +Активный implementation milestone — **подготовка Stage B**. Сначала проект определит small transfer-task model и fake bridge fixture для existing text/WAV workflow. Затем Flutter shell будет отделена от текущего page-local state, чтобы widget tests могли управлять send, receive, cancellation, unavailable и rejection paths без native audio. Existing Rust bridge и golden fixtures остаются без изменений, пока shell contract не выявит конкретное отсутствующее typed value. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d9a755..f21bc99 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,8 @@ This roadmap describes intended order, not dates or guaranteed delivery. It dist | GitHub-first bootstrap | Completed | Public repository policy, issue forms, CI workflows, protected main and static-site workflow. | | ADLP v1 and WAV reference | Completed | Rust container, CRC-32C, deterministic PCM/WAV round trip, CLI and initial specification. | | Flutter ↔ Rust bridge | Completed | Native UI invokes reviewed Rust ADLP/WAV encode/decode; web reports the native codec unavailable. | -| Local WAV file workflow | Completed | User-selected save/open dialogs export/import verified WAV bytes; file-object payloads remain future work. | +| Local WAV file workflow | Completed | User-selected save/open dialogs export/import verified WAV bytes; file-object UI remains future work. | +| File-object local WAV workflow | Experimental bounded workflow | ADLP `File` constructor, bounded 8 KiB Rust facade, deterministic WAV fixture, CLI and Flutter local pick/receipt/save flow exist; no physical route is enabled. | | 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. | diff --git a/docs/roadmap_RU.md b/docs/roadmap_RU.md index 3e42dbf..71c3ddd 100644 --- a/docs/roadmap_RU.md +++ b/docs/roadmap_RU.md @@ -2,7 +2,7 @@ [English (canonical)](roadmap.md) · **Русский перевод** -> **Translation of:** [docs/roadmap.md](roadmap.md). **Last synced:** 2026-08-20. +> **Translation of:** [docs/roadmap.md](roadmap.md). **Last synced:** 2026-08-21. Эта дорожная карта описывает intended order, а не даты или гарантированные сроки. Она отделяет verified repository state от research и planned product capability. @@ -11,7 +11,8 @@ | GitHub-first bootstrap | Завершён | Public repository policy, issue forms, CI workflows, protected main и static-site workflow. | | ADLP v1 и WAV reference | Завершён | Rust container, CRC-32C, deterministic PCM/WAV round trip, CLI и initial specification. | | Flutter ↔ Rust bridge | Завершён | Native UI вызывает reviewed Rust ADLP/WAV encode/decode; web сообщает, что native codec недоступен. | -| Локальный WAV file workflow | Завершён | User-selected save/open dialogs экспортируют/импортируют проверенные WAV bytes; file-object payloads остаются будущей работой. | +| Локальный WAV file workflow | Завершён | User-selected save/open dialogs экспортируют/импортируют проверенные WAV bytes; file-object UI остаётся future work. | +| File-object local WAV workflow | Экспериментальный bounded workflow | Существуют ADLP `File` constructor, bounded 8 KiB Rust facade, deterministic WAV fixture, CLI и Flutter local pick/receipt/save flow; physical route не включён. | | 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 отсутствуют. | diff --git a/site/index.html b/site/index.html index a4bbe8d..ef2c7e2 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-to-WAV verification
✓ Local WAV import / export
native file dialogs + Rust validation
✓ 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
• 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
• Live audio and encryption
planned
See roadmap →
diff --git a/site/ru/index.html b/site/ru/index.html index 691ea40..61606b9 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-to-WAV verification
✓ Локальный WAV import / export
native file dialogs + Rust validation
✓ 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 отсутствуют
• 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 отсутствуют
• Live audio и encryption
план
Посмотреть дорожную карту →
diff --git a/tools/adlp-cli/src/main.rs b/tools/adlp-cli/src/main.rs index 24e57df..44cb322 100644 --- a/tools/adlp-cli/src/main.rs +++ b/tools/adlp-cli/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, error::Error, fs, process}; +use std::{env, error::Error, fs, path::Path, process}; use adlp_protocol::{ObjectKind, TransferProfile, WireObject}; use audio_modem_core::{acoustic1, acoustic2, decode_wav, encode_wav}; @@ -15,6 +15,8 @@ fn run() -> Result<(), Box> { match args.get(1).map(String::as_str) { Some("encode-text") => encode_text(&args, false), Some("encode-acoustic1-text") => encode_text(&args, true), + Some("encode-file") => encode_file(&args, false), + Some("encode-acoustic1-file") => encode_file(&args, true), Some("decode") => decode(&args, false), Some("decode-acoustic1") => decode(&args, true), Some("measure-acoustic1") => measure_acoustic1(&args), @@ -52,6 +54,45 @@ fn encode_text(args: &[String], acoustic1_carrier: bool) -> Result<(), Box Result<(), Box> { + let output = args.get(2).ok_or("missing output WAV path")?; + let callsign = args.get(3).ok_or("missing callsign")?; + let input = args.get(4).ok_or("missing input file path")?; + let mime_type = args + .get(5) + .map(String::as_str) + .unwrap_or("application/octet-stream"); + let profile = args + .get(6) + .map(|value| parse_profile(value)) + .transpose()? + .unwrap_or(TransferProfile::Balanced); + let file_name = Path::new(input) + .file_name() + .and_then(|value| value.to_str()) + .ok_or("input file name must be valid UTF-8")?; + let object = WireObject::file(2, callsign, file_name, mime_type, fs::read(input)?, profile)?; + let carrier = if acoustic1_carrier { + "Acoustic-1" + } else { + "bootstrap" + }; + let wav = if acoustic1_carrier { + acoustic1::encode_wav(&object)? + } else { + encode_wav(&object)? + }; + fs::write(output, wav)?; + println!( + "encoded file {} ({} bytes) as {} {} WAV transfer", + file_name, + object.payload.len(), + profile.as_str(), + carrier + ); + Ok(()) +} + fn decode(args: &[String], acoustic1_carrier: bool) -> Result<(), Box> { let input = args.get(2).ok_or("missing input WAV path")?; let (object, samples_consumed) = if acoustic1_carrier { @@ -74,10 +115,15 @@ fn decode(args: &[String], acoustic1_carrier: bool) -> Result<(), Box println!("profile: {}", manifest.profile.as_str()); println!("session: {}", manifest.session_id); println!("callsign: {}", manifest.sender_callsign); + println!("mime: {}", manifest.mime_type); println!("samples: {samples_consumed}"); match manifest.object_kind { ObjectKind::Text => println!("text: {}", String::from_utf8(object.payload)?), - ObjectKind::File => println!("file payload: {} bytes", object.payload.len()), + ObjectKind::File => println!( + "file: {} ({} bytes)", + manifest.file_name, + object.payload.len() + ), } Ok(()) } @@ -141,5 +187,5 @@ fn parse_profile(value: &str) -> Result> { } fn usage() -> &'static str { - "Usage:\n adlp-cli encode-text [reliable|balanced|fast|narrowband]\n adlp-cli decode \n adlp-cli encode-acoustic1-text [reliable|balanced|fast|narrowband]\n adlp-cli decode-acoustic1 \n adlp-cli measure-acoustic1 [leading-silence] [gain-per-mille] [noise-peak] [noise-seed] [clip-abs] [drop-every-nth]" + "Usage:\n adlp-cli encode-text [reliable|balanced|fast|narrowband]\n adlp-cli encode-file [mime-type] [reliable|balanced|fast|narrowband]\n adlp-cli decode \n adlp-cli encode-acoustic1-text [reliable|balanced|fast|narrowband]\n adlp-cli encode-acoustic1-file [mime-type] [reliable|balanced|fast|narrowband]\n adlp-cli decode-acoustic1 \n adlp-cli measure-acoustic1 [leading-silence] [gain-per-mille] [noise-peak] [noise-seed] [clip-abs] [drop-every-nth]" }