diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..47b44e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Report a reproducible defect in dart_modem. +title: "[Bug]: " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the actual and expected behavior. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Include code, modem configuration, and PCM conditions needed to reproduce it. + render: dart + validations: + required: true + - type: input + id: dart-version + attributes: + label: Dart version + placeholder: "Dart 3.x" + validations: + required: true + - type: dropdown + id: runtime + attributes: + label: Runtime + options: + - Dart VM + - Flutter + - Server-side Dart + - Web / WASM + validations: + required: true + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues for duplicates. + required: true + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..94b2f92 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/bchainhub/dart_modem/security/advisories/new + about: Report vulnerabilities privately instead of opening a public issue. + diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..d759cc1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,34 @@ +name: Feature request +description: Suggest a focused improvement or protocol extension. +title: "[Feature]: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem or use case + description: Explain the need and who benefits from it. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API, protocol, or behavior you would like. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe workarounds or alternate designs. + validations: + required: true + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues for similar requests. + required: true + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48c9cb4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: Dart CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + - run: dart pub get + - run: dart format --output=none --set-exit-if-changed . + - run: dart analyze --fatal-infos + - run: dart test + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b489340 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,12 @@ +name: Publish to pub.dev + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +jobs: + publish: + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4def04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dart and pub +.dart_tool/ +.packages +pubspec.lock +pubspec_overrides.yaml + +# Generated output +build/ +coverage/ +doc/api/ +*.dill +*.js.deps +*.js.map +*.wasm +*.wasm.map + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Editors and IDEs +.idea/ +*.iml +.vscode/ +*.code-workspace +*.swp +*.swo +*~ + +# Local environment and logs +.env +.env.* +!.env.example +*.log + +# Test and coverage tooling +.test_coverage.dart +lcov.info diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a5724bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.1.0 + +- Initial release with a pure Dart continuous-phase BFSK encoder. +- Add incremental Goertzel decoder, framing, CRC-32, and streaming APIs. +- Add audible 1200-baud and near-ultrasonic BFSK presets. +- Make the 1200-baud V.23 tone pair the phone-oriented default and retain the + original channel as `Bfsk.poorQuality()` (`Bfsk.robust()` remains an alias). +- Add the CRC-protected READY control frame, padded and repeating encoders, + duplicate-aware streaming detection, and a pure-Dart wait-state helper. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b698c1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +CORE License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), +to to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +All distribution of the Covered Software in Source Code Form, including any +Modifications and/or Contributions must be disclosed and publicly available. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY CLAIM, DAMAGES OR +OTHER LIABILITIES, WHETHER IN AN ACTION OF A CONTRACT, TORT, OR OTHERWISE, +ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE, OR +OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md index d73a003..9dda928 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,367 @@ -# dart_modem -Data to voice transmission +# DART MODEM + +`dart_modem` is a pure Dart acoustic modem. It turns arbitrary bytes into signed +16-bit PCM audio and incrementally reconstructs validated packets from PCM. The +core has no Flutter, native-code, audio-device, or runtime-specific dependency, +so it can be used on the Dart VM, servers, Flutter, and Dart-to-Wasm targets. + +## Quick start + +```dart +import 'dart:convert'; + +import 'package:dart_modem/dart_modem.dart'; + +final modem = DartModem(modulation: const Bfsk()); +final pcm = modem.encode(utf8.encode('Hello')); + +final decoder = modem.createStreamingDecoder(); +decoder.frames.listen((frame) { + print(utf8.decode(frame.payload)); +}); +decoder.feed(pcm); +``` + +The PCM output is an `Int16List`. Playing or recording it is deliberately left +to the host application, which keeps the modem platform-independent. + +## Application protocol + +`DartModem` uses Voice Transaction Protocol version 1 by default. Each input is +treated as an already-signed transaction, wrapped in a SHA-256/CRC-protected +envelope, split into Voice TX DATA packets, and reassembled by the matching +decoder. Configure the chain identifiers for production use: + +```dart +final modem = DartModem( + protocol: const VoiceTxModemProtocol( + blockchainId: 1, + networkId: 1, + ), +); +``` + +The default identifiers are zero when the application does not supply them. +Empty payloads are invalid under Voice TX. The complete Voice TX packet, +envelope, assembly, and sender/receiver session APIs are exported directly by +`dart_modem`. + +To use only the modem's basic `DMOD` transport framing, disable the application +protocol explicitly: + +```dart +final rawModem = DartModem(protocol: null); +// Equivalent when a non-null ModemProtocol value is needed: +final identityModem = DartModem(protocol: const NoModemProtocol()); +``` + +Another protocol can replace Voice TX by implementing `ModemProtocol` and its +stateful `ModemProtocolDecoder`. A protocol may turn one application payload +into multiple transport packets; the decoder emits a frame only after the +application payload is complete. + +## How BFSK works + +Binary frequency-shift keying (BFSK) represents each bit with one of two sine +frequencies. The phone-oriented default follows the forward-channel tones and +rate of ITU-T V.23 mode 2: `0` is 2100 Hz, `1` is 1300 Hz, and the channel runs +at 1200 baud. At 48000 samples per second, every bit occupies exactly 40 +samples. The encoder carries the sine phase across bit boundaries, avoiding +discontinuities caused by restarting every tone at phase zero. + +All physical settings are configurable: + +```dart +const modulation = Bfsk( + sampleRate: 48000, + baudRate: 1200, + zeroFrequency: 2100, + oneFrequency: 1300, +); + +print(modulation.samplesPerBit); // 40 +``` + +### Near-ultrasonic alternative + +For hardware with a usable high-frequency audio path, select the built-in +near-ultrasonic preset: + +```dart +final modem = DartModem( + modulation: const Bfsk.ultrasonic(), +); + +final pcm = modem.encode(payload); // 48 kHz signed Int16 PCM +final decoder = modem.createStreamingDecoder(); +decoder.frames.listen((frame) => handle(frame.payload)); +decoder.feed(recordedPcm); +``` + +This preset uses 18.5 kHz for `0`, 19.5 kHz for `1`, a 48 kHz sample rate, and +100 baud. It is near-ultrasonic rather than guaranteed inaudible: some people +can hear tones in this range, and many phones, speakers, microphones, codecs, +telephone networks, and noise-suppression systems attenuate or remove them. +Both playback and capture must preserve mono 48 kHz PCM. Test on every target +device and provide a visible disclosure when transmitting high-frequency audio. + +The current symbol clock requires `sampleRate` to be evenly divisible by +`baudRate`. Frequencies should be below the Nyquist frequency (half the sample +rate) and should be selected for the response of the real audio channel. + +### Robust low-speed mode + +For noisy, filtered, or codec-damaged calls where throughput is less important, +use the 100-baud poor-quality profile: + +```dart +final modem = DartModem( + modulation: const Bfsk.poorQuality(), +); +``` + +It uses 8 kHz PCM with 1200/2200 Hz tones and 80 samples per bit. The older +`Bfsk.robust()` name remains available as an alias. + +### Orthogonal high-speed alternative + +The `fast` preset is an alternative 1200-baud audible channel: + +```dart +final modem = DartModem( + modulation: const Bfsk.fast(), +); + +final pcm = modem.encode(payload); // 48 kHz signed Int16 PCM +final decoder = modem.createStreamingDecoder(); +``` + +It uses 1200 Hz and 2400 Hz tones at a 48 kHz sample rate. Each 40-sample +symbol contains exactly one or two tone cycles, which gives the two Goertzel +detectors clean frequency separation. Unlike the default, its tones are not the +V.23 telephone pair. + +## Goertzel decoding and synchronization + +The decoder evaluates two Goertzel filters per symbol: one at the zero +frequency and one at the one frequency. The stronger detector supplies the bit. +Goertzel computes energy at a chosen frequency directly, so no FFT is used. + +Reception is incremental. The decoder buffers only one symbol while searching +for the alternating preamble and 16-bit sync word. Once synchronized, it reads +the packet header to learn the bounded payload size, then emits only a complete +packet with a valid CRC-32. + +## Wire and packet format + +Fields are transmitted most-significant bit first. Multi-byte integers are big +endian. + +| Layer | Field | Default size | Purpose | +| --- | --- | ---: | --- | +| Frame | Carrier | 24 bits | Gives receivers time to observe a tone | +| Frame | Alternating preamble | 48 bits | Establishes the repeating bit pattern | +| Frame | Sync word | 16 bits | Marks the exact packet boundary (`0xD391`) | +| Packet | Magic | 4 bytes | ASCII `DMOD` | +| Packet | Version | 1 byte | Currently `1` | +| Packet | Payload length | 4 bytes | Unsigned byte count | +| Packet | Payload | variable | Application bytes | +| Packet | CRC-32 | 4 bytes | IEEE CRC-32 over magic through payload | + +Malformed magic, unsupported versions, oversized lengths, truncated packets, +and CRC failures are rejected. The default maximum payload is 1 MiB and can be +lowered through `Bfsk.maximumPayloadLength` for constrained receivers. + +## Streaming + +Feed chunks of any size; chunk boundaries do not need to align with symbols: + +```dart +final decoder = StreamingBfskDecoder(); +decoder.frames.listen((frame) => handle(frame.payload)); + +decoder.feed(firstPcmChunk); +decoder.feed(secondPcmChunk); +await decoder.close(); +``` + +For a synchronous pipeline, `BfskDecoder.feed` returns the frames completed by +that call. `StreamingBfskEncoder.bind` maps each incoming payload to a separate +PCM frame. + +## Telephone and acoustic use cases + +The default 1300/2100 Hz tones fit within conventional 300-3400 Hz telephone +audio and use the V.23 mode 2 bit mapping. This makes the modem useful over +telephone audio, intercoms, speakers and +microphones, audio cables, device pairing, and low-rate telemetry. Real channels +add gain control, filtering, echo, clock drift, clipping, and noise. Resample +captured audio to the configured rate and supply mono signed Int16 PCM. Test the +chosen frequencies and baud rate against the actual channel before deployment. + +The waveform uses V.23-compatible frequencies and bitrate, but dart_modem's +packet framing is its own protocol; it does not claim byte-level interoperability +with legacy V.23 terminals. Speech codecs, echo cancellation, automatic gain +control, and noise suppression can still damage modem audio. An uncompressed +G.711 path is preferable where the call stack exposes that choice. + +`dart_modem` only generates and analyzes waveforms. A Flutter application can +connect it to any suitable audio capture/playback package without coupling that +package to the modem core. + +## More examples + +Runnable programs are in [`example/`](example/): + +- `encode_hello.dart` encodes “Hello”. +- `decode_hello.dart` demonstrates incremental decoding. +- `encode_random_bytes.dart` encodes random binary data. +- `loopback.dart` verifies a full in-memory round trip. +- `fast_loopback.dart` exercises the audible 1200-baud preset. +- `ultrasonic_loopback.dart` exercises the near-ultrasonic preset. +- `ready_phone_flow.dart` demonstrates phone-side READY detection. +- `ready_server.dart` demonstrates lazy repeated READY generation. + +Hex conversion helpers are also exported: + +```dart +final bytes = hexToBytes('00 ca fe ff'); +print(bytesToHex(bytes)); // 00cafeff +``` + +## READY handshake for half-duplex calls + +READY is a compact control signal sent by the receiving side before transaction +audio. It tells the phone-side sender that the receiver has answered, is +listening, and permits transmission after a decoded guard delay. + +```text +Receiver answers +→ receiver repeats READY +→ phone microphone captures READY +→ dart_modem detects READY +→ app waits guard delay +→ app plays transaction audio +→ receiver stops READY transmission when transaction preamble is detected +``` + +The receiver repeats READY because microphone capture may begin in the middle of +a signal or the first signal may be damaged. Repetitions retain one session ID; +their sequence number increments by default. The detector remembers a bounded +number of sessions and emits only the first READY for each session unless +`emitDuplicates` is enabled. + +### READY control frame + +READY is transported as a normal CRC-32-protected modem payload and also has its +own CRC-16/CCITT-FALSE check. Its fixed 16-byte, big-endian format is: + +| Field | Size | Value or meaning | +| --- | ---: | --- | +| Magic | 2 bytes | ASCII `DM` | +| Control version | 1 byte | `1` | +| Control type | 1 byte | READY = `1` | +| Session ID | 4 bytes | Unsigned session identity | +| Flags | 1 byte | Defined optional behavior | +| Guard delay | 2 bytes | Milliseconds before sender playback | +| Receiver timeout | 1 byte | Seconds the receiver remains ready | +| Sequence | 2 bytes | Repetition sequence number | +| CRC-16 | 2 bytes | CCITT-FALSE over the preceding 14 bytes | + +Other stable control-type numbers are reserved for acknowledgement, negative +acknowledgement, accepted, rejected, and error frames. READY is the only control +behavior implemented in this release. + +### Receiver/server generation + +```dart +final modem = DartModem(); +final ready = ReadySignal( + sessionId: secureSessionId, + guardDelay: const Duration(milliseconds: 500), + receiverTimeout: const Duration(seconds: 10), +); +final repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + repeatInterval: const Duration(milliseconds: 300), + maximumRepeats: 20, +); + +for (final pcm in repeater.chunks()) { + audioOutput.send(pcm); + if (transactionPreambleDetected) break; +} +``` + +`chunks()` is synchronous and lazy. It alternates one complete padded READY PCM +block with one silence block; it does not wait in real time. `encodeAll()` creates +one complete buffer and rejects output exceeding `maximumTotalDuration`. + +### Phone-side detection + +```dart +final detector = ReadySignalDetector( + modemDecoder: modem.createStreamingDecoder(), +); + +void onMicrophonePcm(Int16List chunk) { + for (final event in detector.add(chunk)) { + if (event is ReadySignalDetected && !event.isDuplicate) { + final delay = event.signal.guardDelay; + // App stops capture, waits for delay, then plays prepared transaction PCM. + } + } +} +``` + +`ReadySignalWaiter` provides the same flow as `ReadyNotDetected`, `ReadyReceived`, +or `ReadyWaitFailed`; `ReadyReceived.recommendedTransmitDelay` is the decoded +guard delay. No timers are started by the package. + +Flutter code remains responsible for microphone capture, audio playback, call +UI, routing, permissions, lifecycle, and waiting. A server or Asterisk adapter +remains responsible for call answering and writing these PCM chunks at the +configured real-time sample rate. + +### READY security and call limitations + +CRC detects accidental corruption; it does not authenticate or encrypt READY. +Applications should generate unpredictable session IDs, bind them to expected +transactions, limit timeouts and replay windows, and authenticate sensitive +transaction payloads separately. Duplicate suppression is local state, not +replay protection across process restarts. + +Cellular and VoIP paths may apply speech codecs, resampling, voice activity +detection, automatic gain control, echo cancellation, and noise suppression. +These can delay or destroy modem tones. Tests cover deterministic chunking, +25-percent amplitude, moderate seeded white noise, symmetric clipping, and one +G.711 mu-law round trip, but every target call path still needs end-to-end +qualification. + +## Protocol evolution + +Configuration, waveform generation, detection, synchronization, and packet +framing are separate modules. This keeps room for pluggable Bell 103, Bell 202, +V.21, MFSK, and PSK implementations without putting platform audio code in the +protocol core. Wire compatibility is versioned independently through the packet +header. + +## Development and publishing + +```sh +dart pub get +dart analyze +dart test +dart pub publish --dry-run +``` + +Publishing uses pub.dev's GitHub OIDC workflow for tags such as `0.1.0`. The +first release must be published manually, then automated publishing must be +enabled in the package's pub.dev Admin tab for this repository with tag pattern +`{{version}}`. + +## License + +[CORE License](LICENSE). Distribution of covered source code, including +modifications and contributions, must remain publicly available. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0360a75 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_final_locals: true + prefer_single_quotes: true + diff --git a/example/decode_hello.dart b/example/decode_hello.dart new file mode 100644 index 0000000..fc3e2d3 --- /dev/null +++ b/example/decode_hello.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; + +Future main() async { + final Int16List pcm = BfskEncoder().encode(utf8.encode('Hello')); + final StreamingBfskDecoder decoder = StreamingBfskDecoder(); + decoder.frames.listen((ModemFrame frame) { + print(utf8.decode(frame.payload)); + }); + + for (int offset = 0; offset < pcm.length; offset += 127) { + final int end = (offset + 127).clamp(0, pcm.length); + decoder.feed(pcm.sublist(offset, end)); + } + await decoder.close(); +} diff --git a/example/encode_hello.dart b/example/encode_hello.dart new file mode 100644 index 0000000..7fdfa84 --- /dev/null +++ b/example/encode_hello.dart @@ -0,0 +1,9 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final Int16List pcm = BfskEncoder().encode(utf8.encode('Hello')); + print('Encoded ${pcm.length} signed Int16 PCM samples.'); +} diff --git a/example/encode_random_bytes.dart b/example/encode_random_bytes.dart new file mode 100644 index 0000000..3c0fdb6 --- /dev/null +++ b/example/encode_random_bytes.dart @@ -0,0 +1,13 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final Random random = Random.secure(); + final Uint8List payload = + Uint8List.fromList(List.generate(32, (_) => random.nextInt(256))); + final Int16List pcm = DartModem().encode(payload); + print('Payload: ${bytesToHex(payload)}'); + print('PCM samples: ${pcm.length}'); +} diff --git a/example/fast_loopback.dart b/example/fast_loopback.dart new file mode 100644 index 0000000..b16f28b --- /dev/null +++ b/example/fast_loopback.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final DartModem modem = DartModem(modulation: const Bfsk.fast()); + final List sent = utf8.encode('Fast acoustic modem'); + final List received = modem.createDecoder().feed( + modem.encode(sent), + ); + + if (received.length != 1 || + bytesToHex(received.single.payload) != bytesToHex(sent)) { + throw StateError('Fast loopback failed.'); + } + print(utf8.decode(received.single.payload)); +} diff --git a/example/loopback.dart b/example/loopback.dart new file mode 100644 index 0000000..a11b80d --- /dev/null +++ b/example/loopback.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final DartModem modem = DartModem(modulation: const Bfsk()); + final List sent = utf8.encode('Acoustic loopback'); + final List received = + modem.createDecoder().feed(modem.encode(sent)); + if (received.length != 1 || + bytesToHex(received.single.payload) != bytesToHex(sent)) { + throw StateError('Loopback failed.'); + } + print(utf8.decode(received.single.payload)); +} diff --git a/example/ready_phone_flow.dart b/example/ready_phone_flow.dart new file mode 100644 index 0000000..1d2ef2f --- /dev/null +++ b/example/ready_phone_flow.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; + +final DartModem modem = DartModem(); +final Int16List preparedTransactionPcm = modem.encode( + utf8.encode('Prepared transaction'), +); +final ReadySignalDetector readyDetector = ReadySignalDetector( + modemDecoder: modem.createStreamingDecoder(), +); + +void onMicrophonePcm(Int16List chunk) { + for (final ReadySignalEvent event in readyDetector.add(chunk)) { + if (event is ReadySignalDetected && !event.isDuplicate) { + final Duration delay = event.signal.guardDelay; + print( + 'READY received; stop capture, wait $delay, then play ' + '${preparedTransactionPcm.length} prepared PCM samples.', + ); + } + } +} + +void main() { + // A Flutter/native layer owns real call control, audio I/O, and wall-clock + // waiting. Here a generated READY signal stands in for microphone capture. + final Int16List receivedAudio = ReadySignalEncoder(modem: modem).encode( + const ReadySignal(sessionId: 0x12345678), + ); + for (int offset = 0; offset < receivedAudio.length; offset += 257) { + final int end = (offset + 257).clamp(0, receivedAudio.length); + onMicrophonePcm(receivedAudio.sublist(offset, end)); + } +} diff --git a/example/ready_server.dart b/example/ready_server.dart new file mode 100644 index 0000000..040b824 --- /dev/null +++ b/example/ready_server.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final DartModem modem = DartModem(); + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: const ReadySignal( + sessionId: 0x12345678, + guardDelay: Duration(milliseconds: 500), + receiverTimeout: Duration(seconds: 10), + ), + repeatInterval: const Duration(milliseconds: 300), + maximumRepeats: 20, + ); + + for (final Int16List pcm in repeater.chunks()) { + sendToAudioOutput(pcm); + if (transactionPreambleDetected()) { + break; + } + } +} + +void sendToAudioOutput(Int16List pcm) { + // A server/Asterisk adapter writes this block at the modem sample rate. + print('Send ${pcm.length} PCM samples'); +} + +bool transactionPreambleDetected() { + // The host integration supplies this state from its incoming audio path. + return false; +} diff --git a/example/ultrasonic_loopback.dart b/example/ultrasonic_loopback.dart new file mode 100644 index 0000000..6550699 --- /dev/null +++ b/example/ultrasonic_loopback.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; + +import 'package:dart_modem/dart_modem.dart'; + +void main() { + final DartModem modem = DartModem( + modulation: const Bfsk.ultrasonic(), + ); + final List sent = utf8.encode('Near-ultrasonic Hello'); + final List received = modem.createDecoder().feed( + modem.encode(sent), + ); + + if (received.length != 1 || + bytesToHex(received.single.payload) != bytesToHex(sent)) { + throw StateError('Near-ultrasonic loopback failed.'); + } + print(utf8.decode(received.single.payload)); +} diff --git a/lib/dart_modem.dart b/lib/dart_modem.dart new file mode 100644 index 0000000..0967164 --- /dev/null +++ b/lib/dart_modem.dart @@ -0,0 +1,43 @@ +library; + +export 'src/config.dart'; +export 'src/control/modem_control_frame.dart'; +export 'src/control/modem_control_type.dart'; +export 'src/control/ready_signal.dart'; +export 'src/control/ready_signal_detector.dart'; +export 'src/control/ready_signal_encoder.dart'; +export 'src/control/ready_signal_event.dart'; +export 'src/control/ready_signal_waiter.dart'; +export 'src/decoder/bfsk_decoder.dart'; +export 'src/decoder/carrier_detector.dart'; +export 'src/decoder/goertzel.dart'; +export 'src/decoder/protocol_decoder.dart'; +export 'src/decoder/synchronizer.dart'; +export 'src/encoder/bfsk_encoder.dart'; +export 'src/encoder/tone_generator.dart'; +export 'src/framing/byte_codec.dart'; +export 'src/framing/crc16.dart'; +export 'src/modem.dart'; +export 'src/protocol/crc32.dart'; +export 'src/protocol/frame.dart'; +export 'src/protocol/modem_protocol.dart'; +export 'src/protocol/packet.dart'; +export 'src/protocol/preamble.dart'; +export 'src/stream/streaming_decoder.dart'; +export 'src/stream/streaming_encoder.dart'; +export 'src/util/bits.dart'; +export 'src/util/bytes.dart'; +export 'src/util/pcm.dart'; +export 'src/voice_tx/envelope/envelope_exception.dart'; +export 'src/voice_tx/envelope/transaction_envelope.dart'; +export 'src/voice_tx/packet/packet_exception.dart'; +export 'src/voice_tx/packet/packet_type.dart'; +export 'src/voice_tx/packet/voice_tx_packet.dart'; +export 'src/voice_tx/session/receiver_session.dart'; +export 'src/voice_tx/session/sender_session.dart'; +export 'src/voice_tx/session/session_event.dart'; +export 'src/voice_tx/session/session_state.dart'; +export 'src/voice_tx/stream/assembly_event.dart'; +export 'src/voice_tx/stream/packet_assembler.dart'; +export 'src/voice_tx/stream/packetizer.dart'; +export 'src/voice_tx/stream/streaming_packet_decoder.dart'; diff --git a/lib/src/config.dart b/lib/src/config.dart new file mode 100644 index 0000000..4ddbc40 --- /dev/null +++ b/lib/src/config.dart @@ -0,0 +1,126 @@ +/// Settings shared by the BFSK encoder and decoder. +final class Bfsk { + const Bfsk({ + this.sampleRate = 48000, + this.baudRate = 1200, + this.zeroFrequency = 2100, + this.oneFrequency = 1300, + this.amplitude = 0.8, + this.carrierBits = 24, + this.preambleBits = 48, + this.maximumPayloadLength = 1024 * 1024, + }) : assert(sampleRate > 0), + assert(baudRate > 0), + assert(sampleRate % baudRate == 0), + assert(zeroFrequency > 0), + assert(oneFrequency > 0), + assert(zeroFrequency < sampleRate / 2), + assert(oneFrequency < sampleRate / 2), + assert(zeroFrequency != oneFrequency), + assert(amplitude > 0 && amplitude <= 1), + assert(carrierBits >= 0), + assert(preambleBits >= 8), + assert(maximumPayloadLength >= 0); + + /// Slow profile for noisy, filtered, or otherwise poor-quality links. + const Bfsk.poorQuality({ + int sampleRate = 8000, + int baudRate = 100, + double zeroFrequency = 1200, + double oneFrequency = 2200, + double amplitude = 0.8, + int carrierBits = 16, + int preambleBits = 32, + int maximumPayloadLength = 1024 * 1024, + }) : this( + sampleRate: sampleRate, + baudRate: baudRate, + zeroFrequency: zeroFrequency, + oneFrequency: oneFrequency, + amplitude: amplitude, + carrierBits: carrierBits, + preambleBits: preambleBits, + maximumPayloadLength: maximumPayloadLength, + ); + + /// Alias for [Bfsk.poorQuality]. + const Bfsk.robust({ + int sampleRate = 8000, + int baudRate = 100, + double zeroFrequency = 1200, + double oneFrequency = 2200, + double amplitude = 0.8, + int carrierBits = 16, + int preambleBits = 32, + int maximumPayloadLength = 1024 * 1024, + }) : this.poorQuality( + sampleRate: sampleRate, + baudRate: baudRate, + zeroFrequency: zeroFrequency, + oneFrequency: oneFrequency, + amplitude: amplitude, + carrierBits: carrierBits, + preambleBits: preambleBits, + maximumPayloadLength: maximumPayloadLength, + ); + + /// Near-ultrasonic preset for short-range communication over capable audio + /// hardware. + /// + /// The 18.5/19.5 kHz tones require a 48 kHz PCM path. Actual speaker and + /// microphone response varies considerably at these frequencies. + const Bfsk.ultrasonic({ + int sampleRate = 48000, + int baudRate = 100, + double zeroFrequency = 18500, + double oneFrequency = 19500, + double amplitude = 0.8, + int carrierBits = 16, + int preambleBits = 32, + int maximumPayloadLength = 1024 * 1024, + }) : this( + sampleRate: sampleRate, + baudRate: baudRate, + zeroFrequency: zeroFrequency, + oneFrequency: oneFrequency, + amplitude: amplitude, + carrierBits: carrierBits, + preambleBits: preambleBits, + maximumPayloadLength: maximumPayloadLength, + ); + + /// High-speed audible preset with 40 samples per bit. + /// + /// The tones complete one and two full cycles per symbol respectively, + /// improving Goertzel separation in the shorter 1200-baud window. + const Bfsk.fast({ + int sampleRate = 48000, + int baudRate = 1200, + double zeroFrequency = 1200, + double oneFrequency = 2400, + double amplitude = 0.8, + int carrierBits = 24, + int preambleBits = 48, + int maximumPayloadLength = 1024 * 1024, + }) : this( + sampleRate: sampleRate, + baudRate: baudRate, + zeroFrequency: zeroFrequency, + oneFrequency: oneFrequency, + amplitude: amplitude, + carrierBits: carrierBits, + preambleBits: preambleBits, + maximumPayloadLength: maximumPayloadLength, + ); + + final int sampleRate; + final int baudRate; + final double zeroFrequency; + final double oneFrequency; + final double amplitude; + final int carrierBits; + final int preambleBits; + final int maximumPayloadLength; + + int get samplesPerBit => sampleRate ~/ baudRate; +} diff --git a/lib/src/control/modem_control_frame.dart b/lib/src/control/modem_control_frame.dart new file mode 100644 index 0000000..6a60236 --- /dev/null +++ b/lib/src/control/modem_control_frame.dart @@ -0,0 +1,118 @@ +import 'dart:typed_data'; + +import '../framing/byte_codec.dart'; +import '../framing/crc16.dart'; +import 'modem_control_type.dart'; + +/// Error raised while validating a binary modem control frame. +final class ModemControlFrameException implements FormatException { + const ModemControlFrameException(this.message, {this.source, this.offset}); + + @override + final String message; + @override + final dynamic source; + @override + final int? offset; + + @override + String toString() => 'ModemControlFrameException: $message'; +} + +/// Generic immutable 16-byte control frame. +final class ModemControlFrame { + const ModemControlFrame({ + required this.type, + required this.sessionId, + required this.flags, + required this.guardDelayMilliseconds, + required this.receiverTimeoutSeconds, + required this.sequenceNumber, + this.version = currentVersion, + }); + + static const int length = 16; + static const int currentVersion = 1; + static const int magic0 = 0x44; + static const int magic1 = 0x4d; + + final int version; + final ModemControlType type; + final int sessionId; + final int flags; + final int guardDelayMilliseconds; + final int receiverTimeoutSeconds; + final int sequenceNumber; + + Uint8List encode() { + _checkRange(sessionId, 0xffffffff, 'sessionId'); + _checkRange(flags, 0xff, 'flags'); + _checkRange(guardDelayMilliseconds, 0xffff, 'guardDelayMilliseconds'); + _checkRange(receiverTimeoutSeconds, 0xff, 'receiverTimeoutSeconds'); + _checkRange(sequenceNumber, 0xffff, 'sequenceNumber'); + if (version != currentVersion) { + throw RangeError.value(version, 'version', 'Unsupported version.'); + } + + final Uint8List bytes = Uint8List(length); + bytes[0] = magic0; + bytes[1] = magic1; + bytes[2] = version; + bytes[3] = type.wireValue; + ByteCodec.writeUint32(bytes, 4, sessionId); + bytes[8] = flags; + ByteCodec.writeUint16(bytes, 9, guardDelayMilliseconds); + bytes[11] = receiverTimeoutSeconds; + ByteCodec.writeUint16(bytes, 12, sequenceNumber); + ByteCodec.writeUint16(bytes, 14, Crc16CcittFalse.compute(bytes.take(14))); + return bytes; + } + + static ModemControlFrame decode(Uint8List input) { + final Uint8List bytes = Uint8List.fromList(input); + if (bytes.length != length) { + throw ModemControlFrameException( + 'Control frame must contain exactly $length bytes.', + source: input, + ); + } + if (bytes[0] != magic0 || bytes[1] != magic1) { + throw ModemControlFrameException('Invalid control-frame magic.', + source: input); + } + if (bytes[2] != currentVersion) { + throw ModemControlFrameException( + 'Unsupported control protocol version: ${bytes[2]}.', + source: input, + ); + } + final ModemControlType? type = ModemControlType.fromWireValue(bytes[3]); + if (type == null) { + throw ModemControlFrameException( + 'Unknown control type: ${bytes[3]}.', + source: input, + ); + } + final int expectedCrc = ByteCodec.readUint16(bytes, 14); + final int actualCrc = Crc16CcittFalse.compute(bytes.take(14)); + if (expectedCrc != actualCrc) { + throw ModemControlFrameException('Control-frame CRC-16 failed.', + source: input); + } + return ModemControlFrame( + version: bytes[2], + type: type, + sessionId: ByteCodec.readUint32(bytes, 4), + flags: bytes[8], + guardDelayMilliseconds: ByteCodec.readUint16(bytes, 9), + receiverTimeoutSeconds: bytes[11], + sequenceNumber: ByteCodec.readUint16(bytes, 12), + ); + } + + static void _checkRange(int value, int maximum, String name) { + if (value < 0 || value > maximum) { + throw RangeError.range(value, 0, maximum, name); + } + } +} diff --git a/lib/src/control/modem_control_type.dart b/lib/src/control/modem_control_type.dart new file mode 100644 index 0000000..43100ba --- /dev/null +++ b/lib/src/control/modem_control_type.dart @@ -0,0 +1,22 @@ +/// Stable control-frame wire values. New values must only be appended. +enum ModemControlType { + ready(0x01), + acknowledgement(0x02), + negativeAcknowledgement(0x03), + accepted(0x04), + rejected(0x05), + error(0x06); + + const ModemControlType(this.wireValue); + + final int wireValue; + + static ModemControlType? fromWireValue(int value) { + for (final ModemControlType type in values) { + if (type.wireValue == value) { + return type; + } + } + return null; + } +} diff --git a/lib/src/control/ready_signal.dart b/lib/src/control/ready_signal.dart new file mode 100644 index 0000000..6787318 --- /dev/null +++ b/lib/src/control/ready_signal.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; + +import 'modem_control_frame.dart'; +import 'modem_control_type.dart'; + +final class ReadySignalDecodeException implements FormatException { + const ReadySignalDecodeException(this.message, {this.source, this.offset}); + + @override + final String message; + @override + final dynamic source; + @override + final int? offset; + + @override + String toString() => 'ReadySignalDecodeException: $message'; +} + +/// Immutable READY control message. +final class ReadySignal { + const ReadySignal({ + required this.sessionId, + this.guardDelay = const Duration(milliseconds: 500), + this.receiverTimeout = const Duration(seconds: 10), + this.sequenceNumber = 0, + this.flags = 0, + }); + + /// Requests a future acknowledgement. The READY layer only transports it. + static const int requestAcknowledgementFlag = 0x01; + static const int supportedFlagsMask = requestAcknowledgementFlag; + + final int sessionId; + final Duration guardDelay; + final Duration receiverTimeout; + final int sequenceNumber; + final int flags; + + Uint8List encodeFrame() { + _validate(); + return ModemControlFrame( + type: ModemControlType.ready, + sessionId: sessionId, + flags: flags, + guardDelayMilliseconds: guardDelay.inMilliseconds, + receiverTimeoutSeconds: receiverTimeout.inSeconds, + sequenceNumber: sequenceNumber, + ).encode(); + } + + static ReadySignal decodeFrame(Uint8List bytes) { + try { + final ModemControlFrame frame = ModemControlFrame.decode(bytes); + if (frame.type != ModemControlType.ready) { + throw ReadySignalDecodeException( + 'Expected READY, received ${frame.type.name}.', + source: bytes, + ); + } + final ReadySignal signal = ReadySignal( + sessionId: frame.sessionId, + guardDelay: Duration(milliseconds: frame.guardDelayMilliseconds), + receiverTimeout: Duration(seconds: frame.receiverTimeoutSeconds), + sequenceNumber: frame.sequenceNumber, + flags: frame.flags, + ); + signal._validateForDecode(bytes); + return signal; + } on ReadySignalDecodeException { + rethrow; + } on ModemControlFrameException catch (error) { + throw ReadySignalDecodeException(error.message, source: bytes); + } + } + + ReadySignal copyWith({ + int? sessionId, + Duration? guardDelay, + Duration? receiverTimeout, + int? sequenceNumber, + int? flags, + }) => + ReadySignal( + sessionId: sessionId ?? this.sessionId, + guardDelay: guardDelay ?? this.guardDelay, + receiverTimeout: receiverTimeout ?? this.receiverTimeout, + sequenceNumber: sequenceNumber ?? this.sequenceNumber, + flags: flags ?? this.flags, + ); + + void _validate() { + _range(sessionId, 0xffffffff, 'sessionId'); + _range(sequenceNumber, 0xffff, 'sequenceNumber'); + _range(flags, 0xff, 'flags'); + if ((flags & ~supportedFlagsMask) != 0) { + throw ArgumentError.value( + flags, 'flags', 'Contains unsupported required flags.'); + } + if (guardDelay.isNegative || + guardDelay.inMicroseconds % Duration.microsecondsPerMillisecond != 0) { + throw ArgumentError.value( + guardDelay, + 'guardDelay', + 'Must be a non-negative whole number of milliseconds.', + ); + } + _range(guardDelay.inMilliseconds, 0xffff, 'guardDelay'); + if (receiverTimeout.isNegative || + receiverTimeout.inMicroseconds % Duration.microsecondsPerSecond != 0) { + throw ArgumentError.value( + receiverTimeout, + 'receiverTimeout', + 'Must be a non-negative whole number of seconds.', + ); + } + _range(receiverTimeout.inSeconds, 0xff, 'receiverTimeout'); + } + + void _validateForDecode(Uint8List source) { + try { + _validate(); + } on Object catch (error) { + throw ReadySignalDecodeException(error.toString(), source: source); + } + } + + static void _range(int value, int maximum, String name) { + if (value < 0 || value > maximum) { + throw RangeError.range(value, 0, maximum, name); + } + } +} diff --git a/lib/src/control/ready_signal_detector.dart b/lib/src/control/ready_signal_detector.dart new file mode 100644 index 0000000..4550f93 --- /dev/null +++ b/lib/src/control/ready_signal_detector.dart @@ -0,0 +1,129 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import '../decoder/bfsk_decoder.dart'; +import '../protocol/frame.dart'; +import 'modem_control_frame.dart'; +import 'ready_signal.dart'; +import 'ready_signal_event.dart'; + +final class ReadySignalDetectorConfig { + const ReadySignalDetectorConfig({ + this.emitDuplicates = false, + this.maximumRememberedSessions = 16, + this.acceptSequenceRegression = false, + }); + + final bool emitDuplicates; + final int maximumRememberedSessions; + final bool acceptSequenceRegression; +} + +/// Converts incrementally decoded modem frames into validated READY events. +final class ReadySignalDetector { + ReadySignalDetector({ + required this.modemDecoder, + this.config = const ReadySignalDetectorConfig(), + }) { + if (config.maximumRememberedSessions <= 0) { + throw RangeError.value( + config.maximumRememberedSessions, + 'maximumRememberedSessions', + 'Must be positive.', + ); + } + } + + final ModemFrameDecoder modemDecoder; + final ReadySignalDetectorConfig config; + final LinkedHashMap _sessions = + LinkedHashMap(); + int? _lastDetectedSessionId; + + int? get lastDetectedSessionId => _lastDetectedSessionId; + + Duration? get lastGuardDelay => _lastDetectedSessionId == null + ? null + : _sessions[_lastDetectedSessionId]?.guardDelay; + + Duration? get lastReceiverTimeout => _lastDetectedSessionId == null + ? null + : _sessions[_lastDetectedSessionId]?.receiverTimeout; + + List add(Iterable pcm) { + final List events = []; + for (final ModemFrame modemFrame in modemDecoder.feed(pcm)) { + final Uint8List payload = modemFrame.payload; + if (!_looksLikeControlFrame(payload)) { + continue; + } + ReadySignal signal; + try { + signal = ReadySignal.decodeFrame(payload); + } on ReadySignalDecodeException catch (error) { + events.add(InvalidReadySignal(error)); + continue; + } + _handle(signal, events); + } + return events; + } + + void reset() { + modemDecoder.reset(); + _sessions.clear(); + _lastDetectedSessionId = null; + } + + bool _looksLikeControlFrame(Uint8List payload) => + payload.length >= 2 && + payload[0] == ModemControlFrame.magic0 && + payload[1] == ModemControlFrame.magic1; + + void _handle(ReadySignal signal, List events) { + final ReadySignal? previous = _sessions[signal.sessionId]; + if (previous == null) { + _remember(signal); + _lastDetectedSessionId = signal.sessionId; + events.add(ReadySignalDetected(signal: signal, isDuplicate: false)); + return; + } + if (!_sameSessionConfiguration(previous, signal)) { + events.add( + InvalidReadySignal( + ReadySignalDecodeException( + 'Conflicting READY parameters for session ${signal.sessionId}.', + ), + ), + ); + return; + } + + final int delta = + (signal.sequenceNumber - previous.sequenceNumber) & 0xffff; + final bool sameSequence = delta == 0; + final bool forwardSequence = delta > 0 && delta <= 0x7fff; + if (!sameSequence && !forwardSequence && !config.acceptSequenceRegression) { + return; + } + if (!sameSequence) { + _sessions[signal.sessionId] = signal; + } + _lastDetectedSessionId = signal.sessionId; + if (config.emitDuplicates) { + events.add(ReadySignalDetected(signal: signal, isDuplicate: true)); + } + } + + bool _sameSessionConfiguration(ReadySignal left, ReadySignal right) => + left.guardDelay == right.guardDelay && + left.receiverTimeout == right.receiverTimeout && + left.flags == right.flags; + + void _remember(ReadySignal signal) { + if (_sessions.length == config.maximumRememberedSessions) { + _sessions.remove(_sessions.keys.first); + } + _sessions[signal.sessionId] = signal; + } +} diff --git a/lib/src/control/ready_signal_encoder.dart b/lib/src/control/ready_signal_encoder.dart new file mode 100644 index 0000000..09436bc --- /dev/null +++ b/lib/src/control/ready_signal_encoder.dart @@ -0,0 +1,160 @@ +import 'dart:typed_data'; + +import '../modem.dart'; +import 'ready_signal.dart'; + +final class ReadySignalEncoderConfig { + const ReadySignalEncoderConfig({ + this.leadingSilence = const Duration(milliseconds: 100), + this.trailingSilence = const Duration(milliseconds: 200), + }); + + final Duration leadingSilence; + final Duration trailingSilence; +} + +/// Adds acoustic padding and encodes READY through a [DartModem]. +final class ReadySignalEncoder { + const ReadySignalEncoder({ + required this.modem, + this.config = const ReadySignalEncoderConfig(), + }); + + final DartModem modem; + final ReadySignalEncoderConfig config; + + Int16List encode(ReadySignal ready) { + final int leading = _durationSamples(config.leadingSilence); + final int trailing = _durationSamples(config.trailingSilence); + final Int16List frame = modem.encode(ready.encodeFrame()); + final Int16List result = Int16List(leading + frame.length + trailing); + result.setRange(leading, leading + frame.length, frame); + return result; + } + + int _durationSamples(Duration duration) { + if (duration.isNegative) { + throw ArgumentError.value( + duration, 'duration', 'Silence cannot be negative.'); + } + return duration.inMicroseconds * + modem.modulation.sampleRate ~/ + Duration.microsecondsPerSecond; + } +} + +final class ReadyRepeatConfig { + const ReadyRepeatConfig({ + this.repeatInterval = const Duration(milliseconds: 300), + this.maximumRepeats = 20, + this.incrementSequenceNumber = true, + this.maximumTotalDuration = const Duration(seconds: 30), + }); + + final Duration repeatInterval; + final int maximumRepeats; + final bool incrementSequenceNumber; + final Duration maximumTotalDuration; +} + +/// Lazily produces READY frame blocks alternating with inter-repeat silence. +final class RepeatingReadySignalEncoder { + RepeatingReadySignalEncoder({ + required this.modem, + required this.ready, + Duration repeatInterval = const Duration(milliseconds: 300), + int maximumRepeats = 20, + bool incrementSequenceNumber = true, + Duration maximumTotalDuration = const Duration(seconds: 30), + this.encoderConfig = const ReadySignalEncoderConfig(), + }) : config = ReadyRepeatConfig( + repeatInterval: repeatInterval, + maximumRepeats: maximumRepeats, + incrementSequenceNumber: incrementSequenceNumber, + maximumTotalDuration: maximumTotalDuration, + ) { + _validate(); + } + + RepeatingReadySignalEncoder.configured({ + required this.modem, + required this.ready, + required this.config, + this.encoderConfig = const ReadySignalEncoderConfig(), + }) { + _validate(); + } + + final DartModem modem; + final ReadySignal ready; + final ReadyRepeatConfig config; + final ReadySignalEncoderConfig encoderConfig; + + /// Yields one encoded frame, then one silence block between adjacent frames. + Iterable chunks() sync* { + final ReadySignalEncoder encoder = ReadySignalEncoder( + modem: modem, + config: encoderConfig, + ); + final int silenceSamples = _durationSamples(config.repeatInterval); + for (int index = 0; index < config.maximumRepeats; index++) { + final int sequence = config.incrementSequenceNumber + ? (ready.sequenceNumber + index) & 0xffff + : ready.sequenceNumber; + yield encoder.encode(ready.copyWith(sequenceNumber: sequence)); + if (index + 1 < config.maximumRepeats) { + yield Int16List(silenceSamples); + } + } + } + + Int16List encodeAll() { + final int maximumSamples = _durationSamples(config.maximumTotalDuration); + final List generated = []; + int total = 0; + for (final Int16List chunk in chunks()) { + total += chunk.length; + if (total > maximumSamples) { + throw StateError('Repeated READY audio exceeds maximumTotalDuration.'); + } + generated.add(chunk); + } + final Int16List result = Int16List(total); + int offset = 0; + for (final Int16List chunk in generated) { + result.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + return result; + } + + int _durationSamples(Duration duration) => + duration.inMicroseconds * + modem.modulation.sampleRate ~/ + Duration.microsecondsPerSecond; + + void _validate() { + if (config.maximumRepeats <= 0) { + throw RangeError.value( + config.maximumRepeats, + 'maximumRepeats', + 'Must be positive.', + ); + } + if (config.repeatInterval.isNegative) { + throw ArgumentError.value( + config.repeatInterval, + 'repeatInterval', + 'Cannot be negative.', + ); + } + if (config.maximumTotalDuration <= Duration.zero) { + throw ArgumentError.value( + config.maximumTotalDuration, + 'maximumTotalDuration', + 'Must be positive.', + ); + } + ready.encodeFrame(); + } +} diff --git a/lib/src/control/ready_signal_event.dart b/lib/src/control/ready_signal_event.dart new file mode 100644 index 0000000..8a2f303 --- /dev/null +++ b/lib/src/control/ready_signal_event.dart @@ -0,0 +1,18 @@ +import 'ready_signal.dart'; + +sealed class ReadySignalEvent { + const ReadySignalEvent(); +} + +final class ReadySignalDetected extends ReadySignalEvent { + const ReadySignalDetected({required this.signal, required this.isDuplicate}); + + final ReadySignal signal; + final bool isDuplicate; +} + +final class InvalidReadySignal extends ReadySignalEvent { + const InvalidReadySignal(this.error); + + final ReadySignalDecodeException error; +} diff --git a/lib/src/control/ready_signal_waiter.dart b/lib/src/control/ready_signal_waiter.dart new file mode 100644 index 0000000..36d6305 --- /dev/null +++ b/lib/src/control/ready_signal_waiter.dart @@ -0,0 +1,51 @@ +import 'ready_signal.dart'; +import 'ready_signal_detector.dart'; +import 'ready_signal_event.dart'; + +sealed class ReadyWaitResult { + const ReadyWaitResult(); +} + +final class ReadyNotDetected extends ReadyWaitResult { + const ReadyNotDetected(); +} + +final class ReadyReceived extends ReadyWaitResult { + const ReadyReceived({ + required this.signal, + required this.recommendedTransmitDelay, + }); + + final ReadySignal signal; + final Duration recommendedTransmitDelay; +} + +final class ReadyWaitFailed extends ReadyWaitResult { + const ReadyWaitFailed(this.error); + + final Object error; +} + +/// Pure-Dart state helper; callers remain responsible for wall-clock timing. +final class ReadySignalWaiter { + const ReadySignalWaiter({required this.detector}); + + final ReadySignalDetector detector; + + ReadyWaitResult add(Iterable pcm) { + for (final ReadySignalEvent event in detector.add(pcm)) { + if (event is ReadySignalDetected && !event.isDuplicate) { + return ReadyReceived( + signal: event.signal, + recommendedTransmitDelay: event.signal.guardDelay, + ); + } + if (event is InvalidReadySignal) { + return ReadyWaitFailed(event.error); + } + } + return const ReadyNotDetected(); + } + + void reset() => detector.reset(); +} diff --git a/lib/src/decoder/bfsk_decoder.dart b/lib/src/decoder/bfsk_decoder.dart new file mode 100644 index 0000000..8f79c66 --- /dev/null +++ b/lib/src/decoder/bfsk_decoder.dart @@ -0,0 +1,130 @@ +import 'dart:typed_data'; + +import '../config.dart'; +import '../protocol/frame.dart'; +import '../protocol/packet.dart'; +import '../util/bytes.dart'; +import 'goertzel.dart'; +import 'synchronizer.dart'; + +/// Synchronous interface implemented by incremental modem frame decoders. +abstract interface class ModemFrameDecoder { + List feed(Iterable pcm); + + void reset(); +} + +/// Incremental fixed-rate BFSK decoder using two Goertzel detectors. +final class BfskDecoder implements ModemFrameDecoder { + BfskDecoder({this.config = const Bfsk()}) + : _zero = Goertzel( + sampleRate: config.sampleRate, + frequency: config.zeroFrequency, + ), + _one = Goertzel( + sampleRate: config.sampleRate, + frequency: config.oneFrequency, + ), + _synchronizer = FrameSynchronizer(preambleBits: config.preambleBits), + _symbol = Int16List(config.samplesPerBit); + + final Bfsk config; + final Goertzel _zero; + final Goertzel _one; + final FrameSynchronizer _synchronizer; + final Int16List _symbol; + int _symbolOffset = 0; + bool _receivingPacket = false; + int _currentByte = 0; + int _bitOffset = 0; + final List _packetBytes = []; + int? _expectedPacketLength; + + int get sampleRate => config.sampleRate; + int get baudRate => config.baudRate; + double get zeroFrequency => config.zeroFrequency; + double get oneFrequency => config.oneFrequency; + int get samplesPerBit => config.samplesPerBit; + + /// Consumes any number of PCM samples and returns all completed valid frames. + @override + List feed(Iterable pcm) { + final List frames = []; + for (final int sample in pcm) { + _symbol[_symbolOffset++] = sample.clamp(-32768, 32767); + if (_symbolOffset == _symbol.length) { + _symbolOffset = 0; + _processBit(_detectBit(), frames); + } + } + return frames; + } + + @override + void reset() { + _symbolOffset = 0; + _synchronizer.reset(); + _resetPacket(); + } + + int _detectBit() => _one.power(_symbol) > _zero.power(_symbol) ? 1 : 0; + + void _processBit(int bit, List frames) { + if (!_receivingPacket) { + if (_synchronizer.addBit(bit)) { + _receivingPacket = true; + } + return; + } + _currentByte = (_currentByte << 1) | bit; + _bitOffset++; + if (_bitOffset != 8) { + return; + } + _packetBytes.add(_currentByte); + _currentByte = 0; + _bitOffset = 0; + + if (_packetBytes.length == Packet.headerLength) { + if (!_validHeader()) { + _resetPacket(); + return; + } + final int payloadLength = readUint32BigEndian(_packetBytes, 5); + _expectedPacketLength = + Packet.headerLength + payloadLength + Packet.crcLength; + } + if (_expectedPacketLength == _packetBytes.length) { + try { + frames.add(ModemFrame(Packet.decode( + _packetBytes, + maximumPayloadLength: config.maximumPayloadLength, + ))); + } on FormatException { + // Corrupt frames are deliberately discarded. + } + _resetPacket(); + } + } + + bool _validHeader() { + for (int index = 0; index < Packet.magic.length; index++) { + if (_packetBytes[index] != Packet.magic[index]) { + return false; + } + } + if (_packetBytes[4] != Packet.currentVersion) { + return false; + } + return readUint32BigEndian(_packetBytes, 5) <= config.maximumPayloadLength; + } + + void _resetPacket() { + _receivingPacket = false; + _currentByte = 0; + _bitOffset = 0; + _packetBytes.clear(); + _expectedPacketLength = null; + _synchronizer.reset(); + } +} diff --git a/lib/src/decoder/carrier_detector.dart b/lib/src/decoder/carrier_detector.dart new file mode 100644 index 0000000..82b3d78 --- /dev/null +++ b/lib/src/decoder/carrier_detector.dart @@ -0,0 +1,19 @@ +import 'goertzel.dart'; + +/// Detects whether a PCM window contains either configured BFSK tone. +final class CarrierDetector { + CarrierDetector({ + required int sampleRate, + required double zeroFrequency, + required double oneFrequency, + this.minimumPower = 1e8, + }) : zero = Goertzel(sampleRate: sampleRate, frequency: zeroFrequency), + one = Goertzel(sampleRate: sampleRate, frequency: oneFrequency); + + final Goertzel zero; + final Goertzel one; + final double minimumPower; + + bool detect(Iterable samples) => + zero.power(samples) >= minimumPower || one.power(samples) >= minimumPower; +} diff --git a/lib/src/decoder/goertzel.dart b/lib/src/decoder/goertzel.dart new file mode 100644 index 0000000..5152e4c --- /dev/null +++ b/lib/src/decoder/goertzel.dart @@ -0,0 +1,28 @@ +import 'dart:math' as math; + +/// Single-frequency detector using the Goertzel recurrence. +final class Goertzel { + Goertzel({required this.sampleRate, required this.frequency}) + : assert(sampleRate > 0), + assert(frequency > 0), + _coefficient = 2 * math.cos(2 * math.pi * frequency / sampleRate); + + final int sampleRate; + final double frequency; + final double _coefficient; + + /// Returns the unnormalized energy at [frequency]. + double power(Iterable samples) { + double previous = 0; + double previous2 = 0; + for (final num sample in samples) { + final double current = + sample.toDouble() + _coefficient * previous - previous2; + previous2 = previous; + previous = current; + } + return previous2 * previous2 + + previous * previous - + _coefficient * previous * previous2; + } +} diff --git a/lib/src/decoder/protocol_decoder.dart b/lib/src/decoder/protocol_decoder.dart new file mode 100644 index 0000000..1f6c08e --- /dev/null +++ b/lib/src/decoder/protocol_decoder.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import '../protocol/frame.dart'; +import '../protocol/modem_protocol.dart'; +import '../protocol/packet.dart'; +import 'bfsk_decoder.dart'; + +/// Applies an application protocol decoder to frames recovered by a modem. +final class ProtocolModemDecoder implements ModemFrameDecoder { + ProtocolModemDecoder({ + required ModemFrameDecoder modemDecoder, + required ModemProtocolDecoder protocolDecoder, + }) : _modemDecoder = modemDecoder, + _protocolDecoder = protocolDecoder; + + final ModemFrameDecoder _modemDecoder; + final ModemProtocolDecoder _protocolDecoder; + + @override + List feed(Iterable pcm) { + final List decoded = []; + for (final ModemFrame frame in _modemDecoder.feed(pcm)) { + for (final payload in _protocolDecoder.decode(frame.payload)) { + decoded.add(ModemFrame(Packet(payload))); + } + } + return decoded; + } + + @override + void reset() { + _modemDecoder.reset(); + _protocolDecoder.reset(); + } +} + +/// Push-based protocol-aware modem decoder. +final class StreamingDartModemDecoder implements ModemFrameDecoder { + StreamingDartModemDecoder(this._decoder); + + final ModemFrameDecoder _decoder; + final StreamController _controller = + StreamController.broadcast(sync: true); + bool _closed = false; + + Stream get frames => _controller.stream; + + @override + List feed(Iterable pcm) { + if (_closed) { + throw StateError('The streaming decoder is closed.'); + } + final List frames = _decoder.feed(pcm); + for (final ModemFrame frame in frames) { + _controller.add(frame); + } + return frames; + } + + @override + void reset() => _decoder.reset(); + + Future close() async { + if (!_closed) { + _closed = true; + await _controller.close(); + } + } +} diff --git a/lib/src/decoder/synchronizer.dart b/lib/src/decoder/synchronizer.dart new file mode 100644 index 0000000..d051c68 --- /dev/null +++ b/lib/src/decoder/synchronizer.dart @@ -0,0 +1,36 @@ +import '../protocol/preamble.dart'; + +/// Bit-level synchronizer for the alternating preamble and sync word. +final class FrameSynchronizer { + FrameSynchronizer({required this.preambleBits}) + : assert(preambleBits >= 8), + _pattern = [ + ...Preamble.bits(preambleBits), + for (int shift = Preamble.syncWordBits - 1; shift >= 0; shift--) + (Preamble.syncWord >> shift) & 1, + ]; + + final int preambleBits; + final List _pattern; + final List _window = []; + + void reset() => _window.clear(); + + /// Returns true exactly when the complete synchronization pattern is seen. + bool addBit(int bit) { + _window.add(bit & 1); + if (_window.length > _pattern.length) { + _window.removeAt(0); + } + if (_window.length != _pattern.length) { + return false; + } + for (int index = 0; index < _pattern.length; index++) { + if (_window[index] != _pattern[index]) { + return false; + } + } + _window.clear(); + return true; + } +} diff --git a/lib/src/encoder/bfsk_encoder.dart b/lib/src/encoder/bfsk_encoder.dart new file mode 100644 index 0000000..6ed2701 --- /dev/null +++ b/lib/src/encoder/bfsk_encoder.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import '../config.dart'; +import '../protocol/packet.dart'; +import '../protocol/preamble.dart'; +import '../util/bits.dart'; +import 'tone_generator.dart'; + +/// Encodes packets as continuous-phase binary frequency-shift keyed PCM. +final class BfskEncoder { + BfskEncoder({this.config = const Bfsk()}); + + final Bfsk config; + + int get sampleRate => config.sampleRate; + int get baudRate => config.baudRate; + double get zeroFrequency => config.zeroFrequency; + double get oneFrequency => config.oneFrequency; + int get samplesPerBit => config.samplesPerBit; + + Int16List encode(List payload) { + if (payload.length > config.maximumPayloadLength) { + throw ArgumentError.value( + payload.length, 'payload', 'Payload is too large.'); + } + final Uint8List packet = Packet(payload).encode(); + final int bitCount = config.carrierBits + + config.preambleBits + + Preamble.syncWordBits + + packet.length * 8; + final Int16List pcm = Int16List(bitCount * samplesPerBit); + final ToneGenerator generator = ToneGenerator( + sampleRate: sampleRate, + amplitude: config.amplitude, + ); + int offset = 0; + + void writeBit(int bit) { + generator.write( + pcm, + offset, + samplesPerBit, + bit == 0 ? zeroFrequency : oneFrequency, + ); + offset += samplesPerBit; + } + + for (int index = 0; index < config.carrierBits; index++) { + writeBit(1); + } + for (int index = 0; index < config.preambleBits; index++) { + writeBit(Preamble.bitAt(index)); + } + for (int shift = Preamble.syncWordBits - 1; shift >= 0; shift--) { + writeBit((Preamble.syncWord >> shift) & 1); + } + forEachBit(packet, writeBit); + return pcm; + } +} diff --git a/lib/src/encoder/tone_generator.dart b/lib/src/encoder/tone_generator.dart new file mode 100644 index 0000000..f8df82a --- /dev/null +++ b/lib/src/encoder/tone_generator.dart @@ -0,0 +1,37 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../util/pcm.dart'; + +/// Stateful sine generator whose phase is preserved between tones. +final class ToneGenerator { + ToneGenerator({required this.sampleRate, this.amplitude = 0.8}) + : assert(sampleRate > 0), + assert(amplitude > 0 && amplitude <= 1); + + final int sampleRate; + final double amplitude; + double _phase = 0; + + double get phase => _phase; + + void reset() => _phase = 0; + + void write(Int16List target, int offset, int count, double frequency) { + final double increment = 2 * math.pi * frequency / sampleRate; + final double scale = int16Maximum * amplitude; + for (int index = 0; index < count; index++) { + target[offset + index] = (math.sin(_phase) * scale).round(); + _phase += increment; + if (_phase >= 2 * math.pi) { + _phase %= 2 * math.pi; + } + } + } + + Int16List generate(double frequency, int sampleCount) { + final Int16List result = Int16List(sampleCount); + write(result, 0, sampleCount, frequency); + return result; + } +} diff --git a/lib/src/framing/byte_codec.dart b/lib/src/framing/byte_codec.dart new file mode 100644 index 0000000..3aabc0e --- /dev/null +++ b/lib/src/framing/byte_codec.dart @@ -0,0 +1,24 @@ +import 'dart:typed_data'; + +abstract final class ByteCodec { + static int readUint16(Uint8List bytes, int offset) => + (bytes[offset] << 8) | bytes[offset + 1]; + + static int readUint32(Uint8List bytes, int offset) => + bytes[offset] * 0x1000000 + + bytes[offset + 1] * 0x10000 + + bytes[offset + 2] * 0x100 + + bytes[offset + 3]; + + static void writeUint16(Uint8List bytes, int offset, int value) { + bytes[offset] = value >>> 8; + bytes[offset + 1] = value; + } + + static void writeUint32(Uint8List bytes, int offset, int value) { + bytes[offset] = value >>> 24; + bytes[offset + 1] = value >>> 16; + bytes[offset + 2] = value >>> 8; + bytes[offset + 3] = value; + } +} diff --git a/lib/src/framing/crc16.dart b/lib/src/framing/crc16.dart new file mode 100644 index 0000000..e6fd0b9 --- /dev/null +++ b/lib/src/framing/crc16.dart @@ -0,0 +1,15 @@ +/// CRC-16/CCITT-FALSE (poly 0x1021, init 0xffff, no reflection, xor-out 0). +abstract final class Crc16CcittFalse { + static int compute(Iterable bytes) { + int crc = 0xffff; + for (final int byte in bytes) { + crc ^= (byte & 0xff) << 8; + for (int bit = 0; bit < 8; bit++) { + crc = (crc & 0x8000) != 0 + ? ((crc << 1) ^ 0x1021) & 0xffff + : (crc << 1) & 0xffff; + } + } + return crc; + } +} diff --git a/lib/src/modem.dart b/lib/src/modem.dart new file mode 100644 index 0000000..fe3e559 --- /dev/null +++ b/lib/src/modem.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; + +import 'config.dart'; +import 'decoder/bfsk_decoder.dart'; +import 'decoder/protocol_decoder.dart'; +import 'encoder/bfsk_encoder.dart'; +import 'protocol/modem_protocol.dart'; + +/// Convenient facade for encoding and creating matching decoders. +final class DartModem { + DartModem({ + this.modulation = const Bfsk(), + this.protocol = const VoiceTxModemProtocol(), + }); + + final Bfsk modulation; + + /// Application protocol layered above the modem packet transport. + /// + /// Pass `null` (or [NoModemProtocol]) to transmit application bytes without + /// Voice TX framing, or supply another [ModemProtocol] implementation. + final ModemProtocol? protocol; + + Int16List encode(List bytes) { + final BfskEncoder encoder = BfskEncoder(config: modulation); + final ModemProtocol? selectedProtocol = protocol; + final Iterable> packets; + if (selectedProtocol == null) { + packets = >[bytes]; + } else { + packets = selectedProtocol.encode(bytes).cast>(); + } + final BytesBuilder pcm = BytesBuilder(copy: false); + for (final List packet in packets) { + final Int16List encoded = encoder.encode(packet); + pcm.add(encoded.buffer.asUint8List( + encoded.offsetInBytes, + encoded.lengthInBytes, + )); + } + final Uint8List bytesOut = pcm.takeBytes(); + return Int16List.view( + bytesOut.buffer, + bytesOut.offsetInBytes, + bytesOut.lengthInBytes ~/ Int16List.bytesPerElement, + ); + } + + ModemFrameDecoder createDecoder() { + final BfskDecoder decoder = BfskDecoder(config: modulation); + final ModemProtocol? selectedProtocol = protocol; + return selectedProtocol == null + ? decoder + : ProtocolModemDecoder( + modemDecoder: decoder, + protocolDecoder: selectedProtocol.createDecoder(), + ); + } + + StreamingDartModemDecoder createStreamingDecoder() => + StreamingDartModemDecoder(createDecoder()); +} diff --git a/lib/src/protocol/crc32.dart b/lib/src/protocol/crc32.dart new file mode 100644 index 0000000..455300b --- /dev/null +++ b/lib/src/protocol/crc32.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +/// IEEE CRC-32 (polynomial 0xEDB88320), as used by Ethernet and gzip. +abstract final class Crc32 { + static final Uint32List _table = _createTable(); + + static int compute(Iterable bytes) { + int crc = 0xffffffff; + for (final int byte in bytes) { + crc = _table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) & 0xffffffff; + } + + static Uint32List _createTable() { + final Uint32List table = Uint32List(256); + for (int index = 0; index < table.length; index++) { + int value = index; + for (int bit = 0; bit < 8; bit++) { + value = (value & 1) == 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value; + } + return table; + } +} diff --git a/lib/src/protocol/frame.dart b/lib/src/protocol/frame.dart new file mode 100644 index 0000000..c29022c --- /dev/null +++ b/lib/src/protocol/frame.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +import 'packet.dart'; + +/// A successfully synchronized and CRC-validated modem frame. +final class ModemFrame { + const ModemFrame(this.packet); + + final Packet packet; + + Uint8List get payload => packet.payload; + int get version => packet.version; +} diff --git a/lib/src/protocol/modem_protocol.dart b/lib/src/protocol/modem_protocol.dart new file mode 100644 index 0000000..a79dc1c --- /dev/null +++ b/lib/src/protocol/modem_protocol.dart @@ -0,0 +1,125 @@ +import 'dart:typed_data'; + +import '../voice_tx/envelope/transaction_envelope.dart'; +import '../voice_tx/packet/voice_tx_packet.dart'; +import '../voice_tx/stream/assembly_event.dart'; +import '../voice_tx/stream/packet_assembler.dart'; +import '../voice_tx/stream/packetizer.dart'; + +/// Application-level framing layered above the acoustic modem transport. +/// +/// One application payload may produce multiple independently transmitted +/// packets. Decoders are stateful so protocols can reassemble those packets. +abstract interface class ModemProtocol { + Iterable encode(List payload); + + ModemProtocolDecoder createDecoder(); +} + +/// Stateful inverse of a [ModemProtocol]. +abstract interface class ModemProtocolDecoder { + Iterable decode(List packet); + + void reset(); +} + +/// Version 1 Voice Transaction Protocol, used by [DartModem] by default. +/// +/// The input to [encode] is an already-signed, publicly broadcastable +/// transaction. It is enveloped, hashed, checksummed, and split into bounded +/// Voice TX DATA packets before acoustic encoding. +final class VoiceTxModemProtocol implements ModemProtocol { + const VoiceTxModemProtocol({ + this.blockchainId = 0, + this.networkId = 0, + this.maximumPacketPayloadSize = 24, + this.maximumTransactionSize = 1024 * 1024, + this.maximumPacketCount = 0xffff, + }); + + final int blockchainId; + final int networkId; + final int maximumPacketPayloadSize; + final int maximumTransactionSize; + final int maximumPacketCount; + + @override + Iterable encode(List payload) sync* { + final VoiceTxEnvelope envelope = VoiceTxEnvelope.create( + blockchainId: blockchainId, + networkId: networkId, + transaction: Uint8List.fromList(payload), + ); + final VoiceTxTransmission transmission = VoiceTxPacketizer( + maximumPayloadSize: maximumPacketPayloadSize, + ).packetize(envelope); + if (transmission.totalPacketCount > maximumPacketCount) { + throw StateError('Voice TX packet count exceeds the configured limit.'); + } + for (final VoiceTxPacket packet in transmission.packets) { + yield packet.encode(); + } + } + + @override + ModemProtocolDecoder createDecoder() => _VoiceTxModemProtocolDecoder(this); +} + +final class _VoiceTxModemProtocolDecoder implements ModemProtocolDecoder { + _VoiceTxModemProtocolDecoder(VoiceTxModemProtocol protocol) + : _protocol = protocol, + _assembler = _createAssembler(protocol); + + final VoiceTxModemProtocol _protocol; + VoiceTxPacketAssembler _assembler; + + static VoiceTxPacketAssembler _createAssembler( + VoiceTxModemProtocol protocol, + ) => + VoiceTxPacketAssembler( + maximumTransactionSize: protocol.maximumTransactionSize, + maximumPacketCount: protocol.maximumPacketCount, + maximumPacketPayloadSize: protocol.maximumPacketPayloadSize, + maximumBufferedBytes: protocol.maximumTransactionSize + 58, + ); + + @override + Iterable decode(List packet) sync* { + final VoiceTxPacket decoded = VoiceTxPacket.decode( + Uint8List.fromList(packet), + maximumPayloadSize: _protocol.maximumPacketPayloadSize, + ); + final VoiceTxAssemblyEvent event = _assembler.accept(decoded); + if (event case VoiceTxTransactionComplete(:final transaction)) { + yield transaction; + _assembler = _createAssembler(_protocol); + } + } + + @override + void reset() => _assembler = _createAssembler(_protocol); +} + +/// Identity application protocol. +/// +/// This is equivalent to passing `protocol: null` to [DartModem], and is +/// useful where a concrete [ModemProtocol] value is required. +final class NoModemProtocol implements ModemProtocol { + const NoModemProtocol(); + + @override + Iterable encode(List payload) => + [Uint8List.fromList(payload)]; + + @override + ModemProtocolDecoder createDecoder() => _NoModemProtocolDecoder(); +} + +final class _NoModemProtocolDecoder implements ModemProtocolDecoder { + @override + Iterable decode(List packet) => + [Uint8List.fromList(packet)]; + + @override + void reset() {} +} diff --git a/lib/src/protocol/packet.dart b/lib/src/protocol/packet.dart new file mode 100644 index 0000000..7cf62ce --- /dev/null +++ b/lib/src/protocol/packet.dart @@ -0,0 +1,73 @@ +import 'dart:typed_data'; + +import '../util/bytes.dart'; +import 'crc32.dart'; + +/// Binary packet carried inside an acoustic frame. +final class Packet { + Packet(List payload, {this.version = currentVersion}) + : payload = Uint8List.fromList(payload); + + static const List magic = [0x44, 0x4d, 0x4f, 0x44]; // DMOD + static const int currentVersion = 1; + static const int headerLength = 9; + static const int crcLength = 4; + + final int version; + final Uint8List payload; + + Uint8List encode() { + if (payload.length > 0xffffffff) { + throw const FormatException( + 'Payload is too large for the packet format.'); + } + final Uint8List result = + Uint8List(headerLength + payload.length + crcLength); + result.setRange(0, magic.length, magic); + result[4] = version; + writeUint32BigEndian(result, 5, payload.length); + result.setRange(headerLength, headerLength + payload.length, payload); + writeUint32BigEndian( + result, + headerLength + payload.length, + Crc32.compute(result.getRange(0, headerLength + payload.length)), + ); + return result; + } + + static Packet decode(List bytes, + {int maximumPayloadLength = 1024 * 1024}) { + if (bytes.length < headerLength + crcLength) { + throw const FormatException('Packet is truncated.'); + } + for (int index = 0; index < magic.length; index++) { + if (bytes[index] != magic[index]) { + throw const FormatException('Invalid packet magic.'); + } + } + final int version = bytes[4]; + if (version != currentVersion) { + throw FormatException('Unsupported packet version: $version.'); + } + final int payloadLength = readUint32BigEndian(bytes, 5); + if (payloadLength > maximumPayloadLength) { + throw const FormatException( + 'Packet payload exceeds the configured limit.'); + } + final int expectedLength = headerLength + payloadLength + crcLength; + if (bytes.length != expectedLength) { + throw const FormatException('Packet length does not match its header.'); + } + final int expectedCrc = + readUint32BigEndian(bytes, expectedLength - crcLength); + final int actualCrc = + Crc32.compute(bytes.getRange(0, expectedLength - crcLength)); + if (expectedCrc != actualCrc) { + throw const FormatException('Packet CRC-32 validation failed.'); + } + return Packet( + bytes.sublist(headerLength, headerLength + payloadLength), + version: version, + ); + } +} diff --git a/lib/src/protocol/preamble.dart b/lib/src/protocol/preamble.dart new file mode 100644 index 0000000..22ed499 --- /dev/null +++ b/lib/src/protocol/preamble.dart @@ -0,0 +1,9 @@ +abstract final class Preamble { + static const int syncWord = 0xd391; + static const int syncWordBits = 16; + + static int bitAt(int index) => index.isEven ? 1 : 0; + + static List bits(int length) => + List.generate(length, bitAt, growable: false); +} diff --git a/lib/src/stream/streaming_decoder.dart b/lib/src/stream/streaming_decoder.dart new file mode 100644 index 0000000..4b488d5 --- /dev/null +++ b/lib/src/stream/streaming_decoder.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import '../config.dart'; +import '../decoder/bfsk_decoder.dart'; +import '../protocol/frame.dart'; + +/// Push-based decoder that emits CRC-validated frames as a broadcast stream. +final class StreamingBfskDecoder implements ModemFrameDecoder { + StreamingBfskDecoder({Bfsk config = const Bfsk()}) + : _decoder = BfskDecoder(config: config); + + final BfskDecoder _decoder; + final StreamController _controller = + StreamController.broadcast(sync: true); + bool _closed = false; + + Stream get frames => _controller.stream; + + @override + List feed(Iterable pcm) { + if (_closed) { + throw StateError('The streaming decoder is closed.'); + } + final List decoded = _decoder.feed(pcm); + for (final ModemFrame frame in decoded) { + _controller.add(frame); + } + return decoded; + } + + @override + void reset() => _decoder.reset(); + + Future close() async { + if (!_closed) { + _closed = true; + await _controller.close(); + } + } +} diff --git a/lib/src/stream/streaming_encoder.dart b/lib/src/stream/streaming_encoder.dart new file mode 100644 index 0000000..0eb6939 --- /dev/null +++ b/lib/src/stream/streaming_encoder.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; + +import '../config.dart'; +import '../encoder/bfsk_encoder.dart'; + +/// Encodes each item in a byte stream as one independent modem frame. +final class StreamingBfskEncoder { + StreamingBfskEncoder({Bfsk config = const Bfsk()}) + : _encoder = BfskEncoder(config: config); + + final BfskEncoder _encoder; + + Int16List encode(List payload) => _encoder.encode(payload); + + Stream bind(Stream> payloads) => + payloads.map(_encoder.encode); +} diff --git a/lib/src/util/bits.dart b/lib/src/util/bits.dart new file mode 100644 index 0000000..2650a33 --- /dev/null +++ b/lib/src/util/bits.dart @@ -0,0 +1,22 @@ +/// Invokes [writeBit] for each byte, most-significant bit first. +void forEachBit(Iterable bytes, void Function(int bit) writeBit) { + for (final int byte in bytes) { + for (int shift = 7; shift >= 0; shift--) { + writeBit((byte >> shift) & 1); + } + } +} + +List bytesToBits(Iterable bytes) { + final List result = []; + forEachBit(bytes, result.add); + return result; +} + +int bitsToByte(List bits, int offset) { + int value = 0; + for (int index = 0; index < 8; index++) { + value = (value << 1) | (bits[offset + index] & 1); + } + return value; +} diff --git a/lib/src/util/bytes.dart b/lib/src/util/bytes.dart new file mode 100644 index 0000000..4c12945 --- /dev/null +++ b/lib/src/util/bytes.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; + +Uint8List hexToBytes(String hex) { + final String normalized = hex.replaceAll(RegExp(r'\s+'), ''); + if (normalized.length.isOdd || + !RegExp(r'^[0-9a-fA-F]*$').hasMatch(normalized)) { + throw const FormatException( + 'Expected an even number of hexadecimal digits.'); + } + final Uint8List result = Uint8List(normalized.length ~/ 2); + for (int index = 0; index < result.length; index++) { + result[index] = + int.parse(normalized.substring(index * 2, index * 2 + 2), radix: 16); + } + return result; +} + +String bytesToHex(Iterable bytes, {bool uppercase = false}) { + final String value = bytes + .map((int byte) => (byte & 0xff).toRadixString(16).padLeft(2, '0')) + .join(); + return uppercase ? value.toUpperCase() : value; +} + +int readUint32BigEndian(List bytes, int offset) => + ((bytes[offset] & 0xff) << 24) | + ((bytes[offset + 1] & 0xff) << 16) | + ((bytes[offset + 2] & 0xff) << 8) | + (bytes[offset + 3] & 0xff); + +void writeUint32BigEndian(Uint8List bytes, int offset, int value) { + bytes[offset] = value >>> 24; + bytes[offset + 1] = value >>> 16; + bytes[offset + 2] = value >>> 8; + bytes[offset + 3] = value; +} diff --git a/lib/src/util/pcm.dart b/lib/src/util/pcm.dart new file mode 100644 index 0000000..a707309 --- /dev/null +++ b/lib/src/util/pcm.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +const int int16Maximum = 32767; +const int int16Minimum = -32768; + +Int16List clampPcm(Iterable samples) => Int16List.fromList(samples + .map((num sample) => sample.round().clamp(int16Minimum, int16Maximum)) + .toList(growable: false)); + +double normalizePcmSample(int sample) => sample / 32768.0; diff --git a/lib/src/voice_tx/checksum/crc32.dart b/lib/src/voice_tx/checksum/crc32.dart new file mode 100644 index 0000000..e0f80a0 --- /dev/null +++ b/lib/src/voice_tx/checksum/crc32.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +/// IEEE 802.3 CRC-32 used by protocol version 1. +abstract final class Crc32 { + static const int _polynomial = 0xedb88320; + + /// Computes an unsigned CRC-32. + static int compute(Uint8List bytes, [int start = 0, int? end]) { + final stop = end ?? bytes.length; + var crc = 0xffffffff; + for (var index = start; index < stop; index++) { + crc ^= bytes[index]; + for (var bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) == 0 ? 0 : _polynomial); + } + } + return (crc ^ 0xffffffff) & 0xffffffff; + } +} diff --git a/lib/src/voice_tx/envelope/envelope_codec.dart b/lib/src/voice_tx/envelope/envelope_codec.dart new file mode 100644 index 0000000..a4ba2a6 --- /dev/null +++ b/lib/src/voice_tx/envelope/envelope_codec.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import '../checksum/crc32.dart'; +import '../util/byte_reader.dart'; +import '../util/byte_writer.dart'; +import 'envelope_exception.dart'; +import 'transaction_envelope.dart'; + +abstract final class EnvelopeCodec { + static const int headerLength = 54; + static const int fixedLength = 58; + static const List _magic = [0x56, 0x54, 0x58, 0x31]; + + static Uint8List encode(VoiceTxEnvelope envelope) { + final transaction = envelope.transaction; + final writer = ByteWriter(fixedLength + transaction.length - 4); + writer.writeBytes(_magic); + writer.writeUint8(envelope.protocolVersion); + writer.writeUint16(envelope.blockchainId); + writer.writeUint16(envelope.networkId); + writer.writeUint8(envelope.flags); + writer.writeUint64(envelope.messageId); + writer.writeUint32(transaction.length); + writer.writeBytes(envelope.transactionHash); + writer.writeBytes(transaction); + final withoutCrc = writer.takeBytes(); + final result = ByteWriter(fixedLength + transaction.length) + ..writeBytes(withoutCrc) + ..writeUint32(Crc32.compute(Uint8List.fromList(withoutCrc))); + return result.takeBytes(); + } + + static VoiceTxEnvelope decode(Uint8List bytes, + {required int maximumTransactionSize}) { + if (maximumTransactionSize < 1) { + throw ArgumentError.value( + maximumTransactionSize, 'maximumTransactionSize'); + } + if (bytes.length < fixedLength) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, 'Truncated envelope header'); + } + try { + final reader = ByteReader(bytes); + final magic = reader.readBytes(4); + if (!_equal(magic, _magic)) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidMagic, 'Invalid envelope magic'); + } + final version = reader.readUint8(); + if (version != VoiceTxEnvelope.currentVersion) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.unsupportedVersion, + 'Unsupported envelope version'); + } + final blockchainId = reader.readUint16(); + final networkId = reader.readUint16(); + final flags = reader.readUint8(); + if (flags != 0) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.malformedEnvelope, 'Unknown required flags'); + } + final messageId = reader.readUint64(); + final length = reader.readUint32(); + if (length == 0 || length > maximumTransactionSize) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, 'Invalid transaction length'); + } + if (bytes.length != fixedLength + length) { + throw const VoiceTxEnvelopeException(VoiceTxProtocolError.invalidLength, + 'Envelope length does not match declaration'); + } + final hash = reader.readBytes(32); + final transaction = reader.readBytes(length); + final crc = reader.readUint32(); + if (crc != Crc32.compute(bytes, 0, bytes.length - 4)) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidCrc, 'Envelope CRC mismatch'); + } + final computedHash = sha256.convert(transaction).bytes; + if (!_equal(hash, computedHash)) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidHash, 'Transaction SHA-256 mismatch'); + } + return VoiceTxEnvelope.decoded( + protocolVersion: version, + blockchainId: blockchainId, + networkId: networkId, + flags: flags, + messageId: messageId, + transactionHash: hash, + transaction: transaction); + } on VoiceTxEnvelopeException { + rethrow; + } on FormatException { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, 'Truncated envelope'); + } + } + + static bool _equal(List a, List b) { + if (a.length != b.length) return false; + var result = 0; + for (var index = 0; index < a.length; index++) { + result |= a[index] ^ b[index]; + } + return result == 0; + } +} diff --git a/lib/src/voice_tx/envelope/envelope_exception.dart b/lib/src/voice_tx/envelope/envelope_exception.dart new file mode 100644 index 0000000..bd570ea --- /dev/null +++ b/lib/src/voice_tx/envelope/envelope_exception.dart @@ -0,0 +1,44 @@ +/// Stable protocol error identifiers and wire codes. +enum VoiceTxProtocolError { + unsupportedVersion(1), + invalidMagic(2), + invalidLength(3), + invalidCrc(4), + invalidHash(5), + payloadTooLarge(6), + packetCountTooLarge(7), + invalidPacketIndex(8), + inconsistentMessageId(9), + inconsistentPacketCount(10), + conflictingDuplicate(11), + incompleteTransmission(12), + malformedEnvelope(13), + unexpectedPacketType(14), + timeout(15), + internalError(16); + + const VoiceTxProtocolError(this.wireValue); + final int wireValue; + + static VoiceTxProtocolError? fromWireValue(int value) { + for (final error in values) { + if (error.wireValue == value) return error; + } + return null; + } +} + +/// Base class for typed protocol failures. +abstract class VoiceTxException implements Exception { + const VoiceTxException(this.error, this.message); + final VoiceTxProtocolError error; + final String message; + + @override + String toString() => '$runtimeType: ${error.name}: $message'; +} + +/// A transaction-envelope validation failure. +final class VoiceTxEnvelopeException extends VoiceTxException { + const VoiceTxEnvelopeException(super.error, super.message); +} diff --git a/lib/src/voice_tx/envelope/transaction_envelope.dart b/lib/src/voice_tx/envelope/transaction_envelope.dart new file mode 100644 index 0000000..293a869 --- /dev/null +++ b/lib/src/voice_tx/envelope/transaction_envelope.dart @@ -0,0 +1,141 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import '../util/hex_codec.dart'; +import 'envelope_codec.dart'; +import 'envelope_exception.dart'; + +/// Immutable, validated envelope for an already-signed transaction. +final class VoiceTxEnvelope { + VoiceTxEnvelope._({ + required this.protocolVersion, + required this.blockchainId, + required this.networkId, + required this.flags, + required this.messageId, + required Uint8List transactionHash, + required Uint8List transaction, + }) : _transactionHash = Uint8List.fromList(transactionHash), + _transaction = Uint8List.fromList(transaction); + + static const int currentVersion = 1; + final int protocolVersion; + final int blockchainId; + final int networkId; + final int flags; + final int messageId; + final Uint8List _transactionHash; + final Uint8List _transaction; + + Uint8List get transactionHash => Uint8List.fromList(_transactionHash); + Uint8List get transaction => Uint8List.fromList(_transaction); + + /// Creates an envelope, defensively copying [transaction]. + factory VoiceTxEnvelope.create({ + required int blockchainId, + required int networkId, + required Uint8List transaction, + int? messageId, + int flags = 0, + }) { + if (transaction.isEmpty) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, + 'Transaction must not be empty', + ); + } + if (blockchainId < 0 || + blockchainId > 0xffff || + networkId < 0 || + networkId > 0xffff) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, 'Identifier is outside uint16'); + } + if (flags != 0) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.malformedEnvelope, 'Unknown required flags'); + } + final id = messageId ?? _secureMessageId(); + if (id < 0) { + throw const VoiceTxEnvelopeException( + VoiceTxProtocolError.invalidLength, 'Message ID is outside uint64'); + } + final copy = Uint8List.fromList(transaction); + return VoiceTxEnvelope._( + protocolVersion: currentVersion, + blockchainId: blockchainId, + networkId: networkId, + flags: flags, + messageId: id, + transactionHash: Uint8List.fromList(sha256.convert(copy).bytes), + transaction: copy, + ); + } + + static int _secureMessageId() { + final random = Random.secure(); + // Dart VM integers are signed 64-bit values. Keep the high bit clear while + // retaining 63 bits of CSPRNG entropy and a valid uint64 wire value. + return (random.nextInt(1 << 31) << 32) | random.nextInt(1 << 32); + } + + Uint8List encode() => EnvelopeCodec.encode(this); + + static VoiceTxEnvelope decode(Uint8List bytes, + {int maximumTransactionSize = 1024 * 1024}) => + EnvelopeCodec.decode(bytes, + maximumTransactionSize: maximumTransactionSize); + + static VoiceTxEnvelope decoded( + {required int protocolVersion, + required int blockchainId, + required int networkId, + required int flags, + required int messageId, + required Uint8List transactionHash, + required Uint8List transaction}) => + VoiceTxEnvelope._( + protocolVersion: protocolVersion, + blockchainId: blockchainId, + networkId: networkId, + flags: flags, + messageId: messageId, + transactionHash: transactionHash, + transaction: transaction); + + @override + bool operator ==(Object other) => + other is VoiceTxEnvelope && + protocolVersion == other.protocolVersion && + blockchainId == other.blockchainId && + networkId == other.networkId && + flags == other.flags && + messageId == other.messageId && + _bytesEqual(_transactionHash, other._transactionHash) && + _bytesEqual(_transaction, other._transaction); + + @override + int get hashCode => Object.hash( + protocolVersion, + blockchainId, + networkId, + flags, + messageId, + Object.hashAll(_transactionHash), + Object.hashAll(_transaction)); + + @override + String toString() => + 'VoiceTxEnvelope(messageId: $messageId, blockchainId: $blockchainId, networkId: $networkId, transactionLength: ${_transaction.length}, hash: ${bytesToHex(_transactionHash.take(6))}…)'; +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + var difference = 0; + for (var index = 0; index < a.length; index++) { + difference |= a[index] ^ b[index]; + } + return difference == 0; +} diff --git a/lib/src/voice_tx/packet/packet_codec.dart b/lib/src/voice_tx/packet/packet_codec.dart new file mode 100644 index 0000000..5bbe652 --- /dev/null +++ b/lib/src/voice_tx/packet/packet_codec.dart @@ -0,0 +1,130 @@ +import 'dart:typed_data'; + +import '../checksum/crc32.dart'; +import '../envelope/envelope_exception.dart'; +import '../util/byte_reader.dart'; +import '../util/byte_writer.dart'; +import 'packet_exception.dart'; +import 'packet_type.dart'; +import 'voice_tx_packet.dart'; + +abstract final class PacketCodec { + static const int headerLength = 19; + static const int overhead = 23; + + static Uint8List encode(VoiceTxPacket packet) { + final payload = packet.payload; + final writer = ByteWriter(overhead + payload.length) + ..writeUint8(0x56) + ..writeUint8(0x54) + ..writeUint8(packet.protocolVersion) + ..writeUint8(packet.type.wireValue) + ..writeUint8(packet.flags) + ..writeUint64(packet.messageId) + ..writeUint16(packet.packetIndex) + ..writeUint16(packet.packetCount) + ..writeUint16(payload.length) + ..writeBytes(payload); + final prefix = Uint8List.fromList( + writer.prefixForChecksum(overhead + payload.length - 4)); + writer.writeUint32(Crc32.compute(prefix)); + return writer.takeBytes(); + } + + static VoiceTxPacket decode(Uint8List bytes, + {required int maximumPayloadSize}) { + if (maximumPayloadSize < 0 || maximumPayloadSize > 0xffff) { + throw ArgumentError.value(maximumPayloadSize, 'maximumPayloadSize'); + } + if (bytes.length < overhead) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Truncated packet'); + } + try { + final reader = ByteReader(bytes); + if (reader.readUint8() != 0x56 || reader.readUint8() != 0x54) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidMagic, 'Invalid packet magic'); + } + final version = reader.readUint8(); + if (version != VoiceTxPacket.currentVersion) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.unsupportedVersion, + 'Unsupported packet version'); + } + final type = VoiceTxPacketType.fromWireValue(reader.readUint8()); + if (type == null) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.unexpectedPacketType, 'Unknown packet type'); + } + final flags = reader.readUint8(); + if (flags != 0) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.malformedEnvelope, 'Unknown required flags'); + } + final messageId = reader.readUint64(); + final index = reader.readUint16(); + final count = reader.readUint16(); + final length = reader.readUint16(); + if (length > maximumPayloadSize) { + throw const VoiceTxPacketException(VoiceTxProtocolError.payloadTooLarge, + 'Payload exceeds configured maximum'); + } + if (bytes.length != overhead + length) { + throw const VoiceTxPacketException(VoiceTxProtocolError.invalidLength, + 'Packet length does not match declaration'); + } + if (count == 0 || index >= count) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidPacketIndex, + 'Invalid packet index or count'); + } + final payload = reader.readBytes(length); + final crc = reader.readUint32(); + if (crc != Crc32.compute(bytes, 0, bytes.length - 4)) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidCrc, 'Packet CRC mismatch'); + } + _validateControl(type, payload.length); + final packet = VoiceTxPacket.decoded( + protocolVersion: version, + type: type, + flags: flags, + messageId: messageId, + packetIndex: index, + packetCount: count, + payload: payload); + if (type == VoiceTxPacketType.rejected || + type == VoiceTxPacketType.error) { + packet.protocolError; + packet.diagnostic; + } + return packet; + } on VoiceTxPacketException { + rethrow; + } on FormatException { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Truncated packet'); + } + } + + static void _validateControl(VoiceTxPacketType type, int length) { + final valid = switch (type) { + VoiceTxPacketType.hello || + VoiceTxPacketType.ready || + VoiceTxPacketType.complete => + length == 0, + VoiceTxPacketType.acknowledgement => length == 2, + VoiceTxPacketType.negativeAcknowledgement => length == 4, + VoiceTxPacketType.accepted => length == 32 || length == 34, + VoiceTxPacketType.rejected || + VoiceTxPacketType.error => + length >= 2 && length <= 2 + VoiceTxPacket.maximumDiagnosticBytes, + VoiceTxPacketType.data => true, + }; + if (!valid) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Invalid control packet payload'); + } + } +} diff --git a/lib/src/voice_tx/packet/packet_exception.dart b/lib/src/voice_tx/packet/packet_exception.dart new file mode 100644 index 0000000..c6525dd --- /dev/null +++ b/lib/src/voice_tx/packet/packet_exception.dart @@ -0,0 +1,6 @@ +import '../envelope/envelope_exception.dart'; + +/// A packet encoding or validation failure. +final class VoiceTxPacketException extends VoiceTxException { + const VoiceTxPacketException(super.error, super.message); +} diff --git a/lib/src/voice_tx/packet/packet_type.dart b/lib/src/voice_tx/packet/packet_type.dart new file mode 100644 index 0000000..9314c4e --- /dev/null +++ b/lib/src/voice_tx/packet/packet_type.dart @@ -0,0 +1,22 @@ +/// Stable packet types for protocol version 1. +enum VoiceTxPacketType { + hello(1), + ready(2), + data(3), + acknowledgement(4), + negativeAcknowledgement(5), + complete(6), + accepted(7), + rejected(8), + error(9); + + const VoiceTxPacketType(this.wireValue); + final int wireValue; + + static VoiceTxPacketType? fromWireValue(int value) { + for (final type in values) { + if (type.wireValue == value) return type; + } + return null; + } +} diff --git a/lib/src/voice_tx/packet/voice_tx_packet.dart b/lib/src/voice_tx/packet/voice_tx_packet.dart new file mode 100644 index 0000000..d344b95 --- /dev/null +++ b/lib/src/voice_tx/packet/voice_tx_packet.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../util/byte_reader.dart'; +import '../util/byte_writer.dart'; +import 'packet_codec.dart'; +import 'packet_exception.dart'; +import 'packet_type.dart'; + +/// Immutable protocol packet. +final class VoiceTxPacket { + VoiceTxPacket._( + {required this.protocolVersion, + required this.type, + required this.flags, + required this.messageId, + required this.packetIndex, + required this.packetCount, + required Uint8List payload}) + : _payload = Uint8List.fromList(payload); + + static const int currentVersion = 1; + static const int protocolMaximumPayloadSize = 0xffff; + static const int maximumDiagnosticBytes = 96; + + final int protocolVersion; + final VoiceTxPacketType type; + final int flags; + final int messageId; + final int packetIndex; + final int packetCount; + final Uint8List _payload; + Uint8List get payload => Uint8List.fromList(_payload); + + factory VoiceTxPacket( + {required VoiceTxPacketType type, + required int messageId, + int packetIndex = 0, + int packetCount = 1, + Uint8List? payload, + int flags = 0}) { + if (messageId < 0 || + packetCount < 1 || + packetCount > 0xffff || + packetIndex < 0 || + packetIndex >= packetCount || + flags != 0) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidPacketIndex, 'Invalid packet metadata'); + } + final body = Uint8List.fromList(payload ?? Uint8List(0)); + if (body.length > protocolMaximumPayloadSize) { + throw const VoiceTxPacketException(VoiceTxProtocolError.payloadTooLarge, + 'Payload exceeds protocol limit'); + } + return VoiceTxPacket._( + protocolVersion: currentVersion, + type: type, + flags: flags, + messageId: messageId, + packetIndex: packetIndex, + packetCount: packetCount, + payload: body); + } + + factory VoiceTxPacket.hello({required int messageId}) => + VoiceTxPacket(type: VoiceTxPacketType.hello, messageId: messageId); + factory VoiceTxPacket.ready({required int messageId}) => + VoiceTxPacket(type: VoiceTxPacketType.ready, messageId: messageId); + factory VoiceTxPacket.ack( + {required int messageId, required int packetIndex}) => + VoiceTxPacket( + type: VoiceTxPacketType.acknowledgement, + messageId: messageId, + payload: _uint16(packetIndex)); + factory VoiceTxPacket.nack( + {required int messageId, + required int packetIndex, + required VoiceTxProtocolError error}) { + final writer = ByteWriter(4) + ..writeUint16(packetIndex) + ..writeUint16(error.wireValue); + return VoiceTxPacket( + type: VoiceTxPacketType.negativeAcknowledgement, + messageId: messageId, + payload: writer.takeBytes()); + } + factory VoiceTxPacket.complete({required int messageId}) => + VoiceTxPacket(type: VoiceTxPacketType.complete, messageId: messageId); + factory VoiceTxPacket.accepted( + {required int messageId, + required Uint8List transactionHash, + int? statusCode}) { + if (transactionHash.length != 32 || + statusCode != null && (statusCode < 0 || statusCode > 0xffff)) { + throw ArgumentError('Invalid ACCEPTED payload'); + } + final writer = ByteWriter(statusCode == null ? 32 : 34) + ..writeBytes(transactionHash); + if (statusCode != null) writer.writeUint16(statusCode); + return VoiceTxPacket( + type: VoiceTxPacketType.accepted, + messageId: messageId, + payload: writer.takeBytes()); + } + factory VoiceTxPacket.rejected( + {required int messageId, + required VoiceTxProtocolError error, + String? diagnostic}) => + _errorPacket(VoiceTxPacketType.rejected, messageId, error, diagnostic); + factory VoiceTxPacket.error( + {required int messageId, + required VoiceTxProtocolError error, + String? diagnostic}) => + _errorPacket(VoiceTxPacketType.error, messageId, error, diagnostic); + + static VoiceTxPacket _errorPacket(VoiceTxPacketType type, int messageId, + VoiceTxProtocolError error, String? diagnostic) { + final text = diagnostic == null + ? Uint8List(0) + : Uint8List.fromList(utf8.encode(diagnostic)); + if (text.length > maximumDiagnosticBytes) { + throw ArgumentError.value(diagnostic, 'diagnostic', + 'UTF-8 form exceeds $maximumDiagnosticBytes bytes'); + } + final writer = ByteWriter(2 + text.length) + ..writeUint16(error.wireValue) + ..writeBytes(text); + return VoiceTxPacket( + type: type, messageId: messageId, payload: writer.takeBytes()); + } + + int get acknowledgedPacketIndex { + if (type != VoiceTxPacketType.acknowledgement || _payload.length != 2) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Not a valid ACK'); + } + return ByteReader(_payload).readUint16(); + } + + ({int packetIndex, VoiceTxProtocolError error}) get negativeAcknowledgement { + if (type != VoiceTxPacketType.negativeAcknowledgement || + _payload.length != 4) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Not a valid NACK'); + } + final reader = ByteReader(_payload); + final index = reader.readUint16(); + final error = VoiceTxProtocolError.fromWireValue(reader.readUint16()); + if (error == null) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.malformedEnvelope, 'Unknown error code'); + } + return (packetIndex: index, error: error); + } + + /// The defensively copied hash carried by an ACCEPTED packet. + Uint8List get acceptedTransactionHash { + if (type != VoiceTxPacketType.accepted || + (_payload.length != 32 && _payload.length != 34)) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Not a valid ACCEPTED packet'); + } + return Uint8List.fromList(_payload.sublist(0, 32)); + } + + /// Optional uint16 status carried by an ACCEPTED packet. + int? get acceptedStatusCode { + acceptedTransactionHash; + return _payload.length == 34 + ? ByteReader(Uint8List.fromList(_payload.sublist(32))).readUint16() + : null; + } + + VoiceTxProtocolError get protocolError { + if ((type != VoiceTxPacketType.rejected && + type != VoiceTxPacketType.error) || + _payload.length < 2) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Not an error packet'); + } + final error = + VoiceTxProtocolError.fromWireValue(ByteReader(_payload).readUint16()); + if (error == null) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.malformedEnvelope, 'Unknown error code'); + } + return error; + } + + /// Bounded UTF-8 diagnostic carried by REJECTED or ERROR, if present. + String? get diagnostic { + protocolError; + if (_payload.length == 2) return null; + try { + return utf8.decode(_payload.sublist(2), allowMalformed: false); + } on FormatException { + throw const VoiceTxPacketException( + VoiceTxProtocolError.invalidLength, 'Diagnostic is not valid UTF-8'); + } + } + + Uint8List encode() => PacketCodec.encode(this); + static VoiceTxPacket decode(Uint8List bytes, + {int maximumPayloadSize = 256}) => + PacketCodec.decode(bytes, maximumPayloadSize: maximumPayloadSize); + static VoiceTxPacket decoded( + {required int protocolVersion, + required VoiceTxPacketType type, + required int flags, + required int messageId, + required int packetIndex, + required int packetCount, + required Uint8List payload}) => + VoiceTxPacket._( + protocolVersion: protocolVersion, + type: type, + flags: flags, + messageId: messageId, + packetIndex: packetIndex, + packetCount: packetCount, + payload: payload); + + @override + bool operator ==(Object other) => + other is VoiceTxPacket && + protocolVersion == other.protocolVersion && + type == other.type && + flags == other.flags && + messageId == other.messageId && + packetIndex == other.packetIndex && + packetCount == other.packetCount && + _equal(_payload, other._payload); + @override + int get hashCode => Object.hash(protocolVersion, type, flags, messageId, + packetIndex, packetCount, Object.hashAll(_payload)); + @override + String toString() => + 'VoiceTxPacket(type: ${type.name}, messageId: $messageId, index: $packetIndex/$packetCount, payloadLength: ${_payload.length})'; +} + +Uint8List _uint16(int value) { + if (value < 0 || value > 0xffff) throw RangeError.range(value, 0, 0xffff); + return (ByteWriter(2)..writeUint16(value)).takeBytes(); +} + +bool _equal(List a, List b) { + if (a.length != b.length) return false; + var result = 0; + for (var index = 0; index < a.length; index++) { + result |= a[index] ^ b[index]; + } + return result == 0; +} diff --git a/lib/src/voice_tx/session/receiver_session.dart b/lib/src/voice_tx/session/receiver_session.dart new file mode 100644 index 0000000..da86ff9 --- /dev/null +++ b/lib/src/voice_tx/session/receiver_session.dart @@ -0,0 +1,102 @@ +import '../envelope/envelope_exception.dart'; +import '../envelope/transaction_envelope.dart'; +import '../packet/packet_type.dart'; +import '../packet/voice_tx_packet.dart'; +import '../stream/assembly_event.dart'; +import '../stream/packet_assembler.dart'; +import 'session_event.dart'; +import 'session_state.dart'; + +/// Deterministic receiver state machine; it performs no I/O or broadcasting. +final class VoiceTxReceiverSession { + VoiceTxReceiverSession({required this.assembler}); + final VoiceTxPacketAssembler assembler; + VoiceTxReceiverState state = VoiceTxReceiverState.waitingForHello; + int? _messageId; + VoiceTxEnvelope? _completedEnvelope; + + List accept(VoiceTxPacket packet) { + if (_messageId != null && packet.messageId != _messageId) { + return _reject( + packet.messageId, VoiceTxProtocolError.inconsistentMessageId); + } + if (state == VoiceTxReceiverState.waitingForHello) { + if (packet.type != VoiceTxPacketType.hello) { + return _reject( + packet.messageId, VoiceTxProtocolError.unexpectedPacketType); + } + _messageId = packet.messageId; + state = VoiceTxReceiverState.receiving; + return [ + SendResponsePacket(VoiceTxPacket.ready(messageId: packet.messageId)) + ]; + } + if (state == VoiceTxReceiverState.validating && + packet.type == VoiceTxPacketType.complete) { + return _complete(_completedEnvelope!); + } + if (state != VoiceTxReceiverState.receiving) { + return _reject( + packet.messageId, VoiceTxProtocolError.unexpectedPacketType); + } + if (packet.type == VoiceTxPacketType.complete) { + if (!assembler.isComplete) { + return _reject( + packet.messageId, VoiceTxProtocolError.incompleteTransmission); + } + return const []; + } + if (packet.type != VoiceTxPacketType.data) { + return _reject( + packet.messageId, VoiceTxProtocolError.unexpectedPacketType); + } + final event = assembler.accept(packet); + return switch (event) { + VoiceTxPacketAccepted() || + VoiceTxDuplicatePacket() => + [ + SendResponsePacket(VoiceTxPacket.ack( + messageId: packet.messageId, packetIndex: packet.packetIndex)) + ], + VoiceTxPacketRejected(:final error) => [ + SendResponsePacket(VoiceTxPacket.nack( + messageId: packet.messageId, + packetIndex: packet.packetIndex, + error: error)) + ], + VoiceTxTransactionComplete(:final envelope) => + _validated(packet, envelope), + }; + } + + List _validated( + VoiceTxPacket packet, VoiceTxEnvelope envelope) { + _completedEnvelope = envelope; + state = VoiceTxReceiverState.validating; + return [ + SendResponsePacket(VoiceTxPacket.ack( + messageId: packet.messageId, packetIndex: packet.packetIndex)) + ]; + } + + List _complete(VoiceTxEnvelope envelope) { + state = VoiceTxReceiverState.complete; + return [ + ReceiverTransactionComplete(envelope), + SendResponsePacket(VoiceTxPacket.accepted( + messageId: envelope.messageId, + transactionHash: envelope.transactionHash)), + const TerminateReceiverSession(null) + ]; + } + + List _reject( + int messageId, VoiceTxProtocolError error) { + state = VoiceTxReceiverState.rejected; + return [ + SendResponsePacket( + VoiceTxPacket.rejected(messageId: messageId, error: error)), + TerminateReceiverSession(error) + ]; + } +} diff --git a/lib/src/voice_tx/session/sender_session.dart b/lib/src/voice_tx/session/sender_session.dart new file mode 100644 index 0000000..26553ee --- /dev/null +++ b/lib/src/voice_tx/session/sender_session.dart @@ -0,0 +1,110 @@ +import '../envelope/envelope_exception.dart'; +import '../packet/packet_type.dart'; +import '../packet/voice_tx_packet.dart'; +import '../stream/packetizer.dart'; +import 'session_event.dart'; +import 'session_state.dart'; + +/// Deterministic half-duplex sender state machine. +final class VoiceTxSenderSession { + VoiceTxSenderSession( + {required this.transmission, this.maximumRetriesPerPacket = 3}) { + if (maximumRetriesPerPacket < 0) { + throw ArgumentError.value( + maximumRetriesPerPacket, 'maximumRetriesPerPacket'); + } + } + final VoiceTxTransmission transmission; + final int maximumRetriesPerPacket; + VoiceTxSenderState state = VoiceTxSenderState.idle; + int _index = 0; + int _retries = 0; + + VoiceTxSenderAction start() { + if (state != VoiceTxSenderState.idle) { + return _fail(VoiceTxProtocolError.unexpectedPacketType); + } + state = VoiceTxSenderState.waitingForReady; + return SendPacket(VoiceTxPacket.hello(messageId: transmission.messageId)); + } + + VoiceTxSenderAction accept(VoiceTxPacket packet) { + if (packet.messageId != transmission.messageId) { + return _fail(VoiceTxProtocolError.inconsistentMessageId); + } + if (state == VoiceTxSenderState.waitingForReady && + packet.type == VoiceTxPacketType.ready) { + return _sendCurrent(); + } + if (state == VoiceTxSenderState.waitingForAcknowledgement) { + if (packet.type == VoiceTxPacketType.acknowledgement) { + final acknowledged = packet.acknowledgedPacketIndex; + if (acknowledged < _index) return const WaitForResponse(); + if (acknowledged != _index) { + return _fail(VoiceTxProtocolError.invalidPacketIndex); + } + _retries = 0; + _index++; + if (_index < transmission.packets.length) return _sendCurrent(); + state = VoiceTxSenderState.waitingForFinalResult; + return SendPacket( + VoiceTxPacket.complete(messageId: transmission.messageId)); + } + if (packet.type == VoiceTxPacketType.negativeAcknowledgement) { + final nack = packet.negativeAcknowledgement; + if (nack.packetIndex != _index) { + return _fail(VoiceTxProtocolError.invalidPacketIndex); + } + return _retry(nack.error); + } + } + if (state == VoiceTxSenderState.waitingForFinalResult) { + if (packet.type == VoiceTxPacketType.accepted) { + final payload = packet.payload; + if (payload.length != 32 && payload.length != 34) { + return _fail(VoiceTxProtocolError.invalidLength); + } + for (var i = 0; i < 32; i++) { + if (payload[i] != transmission.transactionHash[i]) { + return _fail(VoiceTxProtocolError.invalidHash); + } + } + state = VoiceTxSenderState.accepted; + return TransmissionAccepted(transmission.transactionHash); + } + if (packet.type == VoiceTxPacketType.rejected || + packet.type == VoiceTxPacketType.error) { + state = VoiceTxSenderState.rejected; + return TransmissionRejected(packet.protocolError); + } + } + return _fail(VoiceTxProtocolError.unexpectedPacketType); + } + + VoiceTxSenderAction onTimeout() { + if (state == VoiceTxSenderState.waitingForAcknowledgement) { + return _retry(VoiceTxProtocolError.timeout); + } + if (state == VoiceTxSenderState.waitingForReady || + state == VoiceTxSenderState.waitingForFinalResult) { + return _fail(VoiceTxProtocolError.timeout); + } + return _fail(VoiceTxProtocolError.unexpectedPacketType); + } + + VoiceTxSenderAction _sendCurrent() { + state = VoiceTxSenderState.waitingForAcknowledgement; + return SendPacket(transmission.packets[_index]); + } + + VoiceTxSenderAction _retry(VoiceTxProtocolError error) { + if (_retries >= maximumRetriesPerPacket) return _fail(error); + _retries++; + return _sendCurrent(); + } + + VoiceTxSenderAction _fail(VoiceTxProtocolError error) { + state = VoiceTxSenderState.failed; + return TransmissionFailed(error); + } +} diff --git a/lib/src/voice_tx/session/session_event.dart b/lib/src/voice_tx/session/session_event.dart new file mode 100644 index 0000000..a8c37bc --- /dev/null +++ b/lib/src/voice_tx/session/session_event.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../packet/voice_tx_packet.dart'; +import '../envelope/transaction_envelope.dart'; + +/// An I/O-free action emitted by a sender session. +sealed class VoiceTxSenderAction { + const VoiceTxSenderAction(); +} + +final class SendPacket extends VoiceTxSenderAction { + const SendPacket(this.packet); + final VoiceTxPacket packet; +} + +final class WaitForResponse extends VoiceTxSenderAction { + const WaitForResponse(); +} + +final class TransmissionAccepted extends VoiceTxSenderAction { + TransmissionAccepted(Uint8List transactionHash) + : _transactionHash = Uint8List.fromList(transactionHash); + final Uint8List _transactionHash; + Uint8List get transactionHash => Uint8List.fromList(_transactionHash); +} + +final class TransmissionRejected extends VoiceTxSenderAction { + const TransmissionRejected(this.error); + final VoiceTxProtocolError error; +} + +final class TransmissionFailed extends VoiceTxSenderAction { + const TransmissionFailed(this.error); + final VoiceTxProtocolError error; +} + +/// An I/O-free action emitted by a receiver session. +sealed class VoiceTxReceiverAction { + const VoiceTxReceiverAction(); +} + +final class SendResponsePacket extends VoiceTxReceiverAction { + const SendResponsePacket(this.packet); + final VoiceTxPacket packet; +} + +final class ReceiverTransactionComplete extends VoiceTxReceiverAction { + const ReceiverTransactionComplete(this.envelope); + final VoiceTxEnvelope envelope; +} + +final class TerminateReceiverSession extends VoiceTxReceiverAction { + const TerminateReceiverSession(this.error); + final VoiceTxProtocolError? error; +} diff --git a/lib/src/voice_tx/session/session_state.dart b/lib/src/voice_tx/session/session_state.dart new file mode 100644 index 0000000..5c6b411 --- /dev/null +++ b/lib/src/voice_tx/session/session_state.dart @@ -0,0 +1,22 @@ +/// Sender lifecycle states. +enum VoiceTxSenderState { + idle, + waitingForReady, + sending, + waitingForAcknowledgement, + waitingForFinalResult, + accepted, + rejected, + failed, + completed +} + +/// Receiver lifecycle states. +enum VoiceTxReceiverState { + waitingForHello, + receiving, + validating, + complete, + rejected, + failed +} diff --git a/lib/src/voice_tx/stream/assembly_event.dart b/lib/src/voice_tx/stream/assembly_event.dart new file mode 100644 index 0000000..f8738c7 --- /dev/null +++ b/lib/src/voice_tx/stream/assembly_event.dart @@ -0,0 +1,55 @@ +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../envelope/transaction_envelope.dart'; +import '../packet/voice_tx_packet.dart'; + +/// Result of offering one packet to an assembler. +sealed class VoiceTxAssemblyEvent { + const VoiceTxAssemblyEvent(); +} + +final class VoiceTxPacketAccepted extends VoiceTxAssemblyEvent { + const VoiceTxPacketAccepted( + {required this.packetIndex, + required this.receivedPacketCount, + required this.totalPacketCount}); + final int packetIndex; + final int receivedPacketCount; + final int totalPacketCount; +} + +final class VoiceTxDuplicatePacket extends VoiceTxAssemblyEvent { + const VoiceTxDuplicatePacket(this.packetIndex); + final int packetIndex; +} + +final class VoiceTxPacketRejected extends VoiceTxAssemblyEvent { + const VoiceTxPacketRejected(this.error, {this.packetIndex}); + final VoiceTxProtocolError error; + final int? packetIndex; +} + +final class VoiceTxTransactionComplete extends VoiceTxAssemblyEvent { + VoiceTxTransactionComplete( + {required this.envelope, required Uint8List transaction}) + : _transaction = Uint8List.fromList(transaction); + final VoiceTxEnvelope envelope; + final Uint8List _transaction; + Uint8List get transaction => Uint8List.fromList(_transaction); +} + +/// Non-fatal streaming parser output. +sealed class VoiceTxStreamEvent { + const VoiceTxStreamEvent(); +} + +final class VoiceTxStreamPacket extends VoiceTxStreamEvent { + const VoiceTxStreamPacket(this.packet); + final VoiceTxPacket packet; +} + +final class VoiceTxStreamError extends VoiceTxStreamEvent { + const VoiceTxStreamError(this.error); + final VoiceTxProtocolError error; +} diff --git a/lib/src/voice_tx/stream/packet_assembler.dart b/lib/src/voice_tx/stream/packet_assembler.dart new file mode 100644 index 0000000..037ffe2 --- /dev/null +++ b/lib/src/voice_tx/stream/packet_assembler.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../envelope/transaction_envelope.dart'; +import '../packet/packet_type.dart'; +import '../packet/voice_tx_packet.dart'; +import 'assembly_event.dart'; + +/// Bounded, out-of-order DATA packet assembler. +final class VoiceTxPacketAssembler { + VoiceTxPacketAssembler( + {this.maximumTransactionSize = 4096, + this.maximumPacketCount = 256, + this.maximumPacketPayloadSize = 64, + int? maximumBufferedBytes}) + : maximumBufferedBytes = + maximumBufferedBytes ?? maximumTransactionSize + 58 { + if (maximumTransactionSize < 1 || + maximumPacketCount < 1 || + maximumPacketCount > 0xffff || + maximumPacketPayloadSize < 1 || + this.maximumBufferedBytes < 1) { + throw ArgumentError('Assembler limits must be positive and bounded'); + } + } + final int maximumTransactionSize; + final int maximumPacketCount; + final int maximumPacketPayloadSize; + final int maximumBufferedBytes; + final Map _payloads = {}; + int? _messageId; + int? _packetCount; + int _bufferedBytes = 0; + bool _complete = false; + + int get receivedPacketCount => _payloads.length; + int? get totalPacketCount => _packetCount; + int get bufferedByteCount => _bufferedBytes; + double get progress => + _packetCount == null ? 0 : _payloads.length / _packetCount!; + bool get isComplete => _complete; + + VoiceTxAssemblyEvent accept(VoiceTxPacket packet) { + if (packet.type != VoiceTxPacketType.data) { + return VoiceTxPacketRejected(VoiceTxProtocolError.unexpectedPacketType, + packetIndex: packet.packetIndex); + } + if (packet.payload.length > maximumPacketPayloadSize) { + return VoiceTxPacketRejected(VoiceTxProtocolError.payloadTooLarge, + packetIndex: packet.packetIndex); + } + if (packet.packetCount > maximumPacketCount) { + return VoiceTxPacketRejected(VoiceTxProtocolError.packetCountTooLarge, + packetIndex: packet.packetIndex); + } + if (packet.packetIndex >= packet.packetCount) { + return VoiceTxPacketRejected(VoiceTxProtocolError.invalidPacketIndex, + packetIndex: packet.packetIndex); + } + if (_messageId != null && packet.messageId != _messageId) { + return VoiceTxPacketRejected(VoiceTxProtocolError.inconsistentMessageId, + packetIndex: packet.packetIndex); + } + if (_packetCount != null && packet.packetCount != _packetCount) { + return VoiceTxPacketRejected(VoiceTxProtocolError.inconsistentPacketCount, + packetIndex: packet.packetIndex); + } + final payload = packet.payload; + final existing = _payloads[packet.packetIndex]; + if (existing != null) { + return _equal(existing, payload) + ? VoiceTxDuplicatePacket(packet.packetIndex) + : VoiceTxPacketRejected(VoiceTxProtocolError.conflictingDuplicate, + packetIndex: packet.packetIndex); + } + if (_bufferedBytes + payload.length > maximumBufferedBytes) { + return VoiceTxPacketRejected(VoiceTxProtocolError.payloadTooLarge, + packetIndex: packet.packetIndex); + } + _messageId ??= packet.messageId; + _packetCount ??= packet.packetCount; + _payloads[packet.packetIndex] = Uint8List.fromList(payload); + _bufferedBytes += payload.length; + if (_payloads.length != _packetCount) { + return VoiceTxPacketAccepted( + packetIndex: packet.packetIndex, + receivedPacketCount: _payloads.length, + totalPacketCount: _packetCount!); + } + final builder = BytesBuilder(copy: false); + for (var index = 0; index < _packetCount!; index++) { + final part = _payloads[index]; + if (part == null) { + return VoiceTxPacketRejected( + VoiceTxProtocolError.incompleteTransmission, + packetIndex: packet.packetIndex); + } + builder.add(part); + } + try { + final envelope = VoiceTxEnvelope.decode(builder.takeBytes(), + maximumTransactionSize: maximumTransactionSize); + if (envelope.messageId != _messageId) { + return VoiceTxPacketRejected(VoiceTxProtocolError.inconsistentMessageId, + packetIndex: packet.packetIndex); + } + _complete = true; + return VoiceTxTransactionComplete( + envelope: envelope, transaction: envelope.transaction); + } on VoiceTxEnvelopeException catch (error) { + return VoiceTxPacketRejected(error.error, + packetIndex: packet.packetIndex); + } + } + + void reset() { + _payloads.clear(); + _messageId = null; + _packetCount = null; + _bufferedBytes = 0; + _complete = false; + } +} + +bool _equal(List a, List b) { + if (a.length != b.length) return false; + var value = 0; + for (var index = 0; index < a.length; index++) { + value |= a[index] ^ b[index]; + } + return value == 0; +} diff --git a/lib/src/voice_tx/stream/packetizer.dart b/lib/src/voice_tx/stream/packetizer.dart new file mode 100644 index 0000000..b43e6d5 --- /dev/null +++ b/lib/src/voice_tx/stream/packetizer.dart @@ -0,0 +1,69 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../envelope/transaction_envelope.dart'; +import '../packet/packet_codec.dart'; +import '../packet/packet_exception.dart'; +import '../packet/packet_type.dart'; +import '../packet/voice_tx_packet.dart'; + +/// Immutable output of packetization. +final class VoiceTxTransmission { + VoiceTxTransmission( + {required this.messageId, + required Uint8List encodedEnvelope, + required Uint8List transactionHash, + required List packets}) + : _encodedEnvelope = Uint8List.fromList(encodedEnvelope), + _transactionHash = Uint8List.fromList(transactionHash), + packets = UnmodifiableListView( + List.unmodifiable(packets)); + final int messageId; + final Uint8List _encodedEnvelope; + final Uint8List _transactionHash; + final List packets; + Uint8List get encodedEnvelope => Uint8List.fromList(_encodedEnvelope); + Uint8List get transactionHash => Uint8List.fromList(_transactionHash); + int get totalPacketCount => packets.length; + int get totalWireByteCount => packets.fold( + 0, (sum, packet) => sum + PacketCodec.overhead + packet.payload.length); +} + +/// Splits an envelope into deterministic DATA packets. +final class VoiceTxPacketizer { + VoiceTxPacketizer({this.maximumPayloadSize = 24}) { + if (maximumPayloadSize < 1 || + maximumPayloadSize > VoiceTxPacket.protocolMaximumPayloadSize) { + throw ArgumentError.value(maximumPayloadSize, 'maximumPayloadSize'); + } + } + final int maximumPayloadSize; + + VoiceTxTransmission packetize(VoiceTxEnvelope envelope) { + final encoded = envelope.encode(); + final count = + (encoded.length + maximumPayloadSize - 1) ~/ maximumPayloadSize; + if (count > 0xffff) { + throw const VoiceTxPacketException( + VoiceTxProtocolError.packetCountTooLarge, + 'Packet count exceeds uint16'); + } + final packets = []; + for (var index = 0; index < count; index++) { + final start = index * maximumPayloadSize; + final end = (start + maximumPayloadSize).clamp(0, encoded.length); + packets.add(VoiceTxPacket( + type: VoiceTxPacketType.data, + messageId: envelope.messageId, + packetIndex: index, + packetCount: count, + payload: Uint8List.fromList(encoded.sublist(start, end)))); + } + return VoiceTxTransmission( + messageId: envelope.messageId, + encodedEnvelope: encoded, + transactionHash: envelope.transactionHash, + packets: packets); + } +} diff --git a/lib/src/voice_tx/stream/streaming_packet_decoder.dart b/lib/src/voice_tx/stream/streaming_packet_decoder.dart new file mode 100644 index 0000000..bab9f8d --- /dev/null +++ b/lib/src/voice_tx/stream/streaming_packet_decoder.dart @@ -0,0 +1,99 @@ +import 'dart:typed_data'; + +import '../envelope/envelope_exception.dart'; +import '../packet/packet_codec.dart'; +import '../packet/packet_exception.dart'; +import '../packet/voice_tx_packet.dart'; +import '../util/byte_reader.dart'; + +typedef VoiceTxStreamErrorCallback = void Function(VoiceTxProtocolError error); + +/// Recovering parser for arbitrarily chunked packet bytes. +final class VoiceTxStreamingPacketDecoder { + VoiceTxStreamingPacketDecoder( + {this.maximumPacketSize = 512, + this.maximumBufferSize = 4096, + this.onError}) { + if (maximumPacketSize < PacketCodec.overhead || + maximumBufferSize < maximumPacketSize) { + throw ArgumentError('Invalid streaming limits'); + } + } + final int maximumPacketSize; + final int maximumBufferSize; + final VoiceTxStreamErrorCallback? onError; + Uint8List _buffer = Uint8List(0); + bool _closed = false; + + List add(Uint8List chunk) { + if (_closed) throw StateError('Decoder is closed'); + if (chunk.isNotEmpty) { + _buffer = Uint8List.fromList([..._buffer, ...chunk]); + } + final packets = []; + while (true) { + final magic = _findMagic(_buffer); + if (magic < 0) { + if (_buffer.isNotEmpty && _buffer.last == 0x56) { + _buffer = Uint8List.fromList([0x56]); + } else { + _buffer = Uint8List(0); + } + break; + } + if (magic > 0) { + _report(VoiceTxProtocolError.invalidMagic); + _buffer = Uint8List.fromList(_buffer.sublist(magic)); + } + if (_buffer.length < PacketCodec.headerLength) break; + final payloadLength = + ByteReader(Uint8List.fromList(_buffer.sublist(17, 19))).readUint16(); + final total = PacketCodec.overhead + payloadLength; + if (total > maximumPacketSize) { + _report(VoiceTxProtocolError.payloadTooLarge); + _discardOne(); + continue; + } + if (_buffer.length < total) break; + final candidate = Uint8List.fromList(_buffer.sublist(0, total)); + try { + packets.add(VoiceTxPacket.decode(candidate, + maximumPayloadSize: maximumPacketSize - PacketCodec.overhead)); + _buffer = Uint8List.fromList(_buffer.sublist(total)); + } on VoiceTxPacketException catch (error) { + _report(error.error); + _discardOne(); + } + } + if (_buffer.length > maximumBufferSize) { + _report(VoiceTxProtocolError.payloadTooLarge); + _buffer = Uint8List.fromList( + _buffer.sublist(_buffer.length - maximumBufferSize)); + } + return packets; + } + + void _discardOne() => _buffer = _buffer.length <= 1 + ? Uint8List(0) + : Uint8List.fromList(_buffer.sublist(1)); + void _report(VoiceTxProtocolError error) => onError?.call(error); + void reset() { + _buffer = Uint8List(0); + _closed = false; + } + + void close() { + if (_buffer.isNotEmpty) { + _report(VoiceTxProtocolError.incompleteTransmission); + } + _buffer = Uint8List(0); + _closed = true; + } +} + +int _findMagic(Uint8List bytes) { + for (var index = 0; index + 1 < bytes.length; index++) { + if (bytes[index] == 0x56 && bytes[index + 1] == 0x54) return index; + } + return -1; +} diff --git a/lib/src/voice_tx/util/byte_reader.dart b/lib/src/voice_tx/util/byte_reader.dart new file mode 100644 index 0000000..54a423c --- /dev/null +++ b/lib/src/voice_tx/util/byte_reader.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +/// A bounds-checking big-endian binary reader. +final class ByteReader { + ByteReader(this.data); + + final Uint8List data; + int offset = 0; + + int get remaining => data.length - offset; + + void require(int count) { + if (count < 0 || remaining < count) { + throw const FormatException('Truncated binary data'); + } + } + + int readUint8() { + require(1); + return data[offset++]; + } + + int readUint16() { + require(2); + final value = ByteData.sublistView(data).getUint16(offset, Endian.big); + offset += 2; + return value; + } + + int readUint32() { + require(4); + final value = ByteData.sublistView(data).getUint32(offset, Endian.big); + offset += 4; + return value; + } + + int readUint64() { + require(8); + final value = ByteData.sublistView(data).getUint64(offset, Endian.big); + offset += 8; + return value; + } + + Uint8List readBytes(int count) { + require(count); + final value = Uint8List.fromList(data.sublist(offset, offset + count)); + offset += count; + return value; + } +} diff --git a/lib/src/voice_tx/util/byte_writer.dart b/lib/src/voice_tx/util/byte_writer.dart new file mode 100644 index 0000000..d3506bd --- /dev/null +++ b/lib/src/voice_tx/util/byte_writer.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +/// A small big-endian binary writer. +final class ByteWriter { + ByteWriter(int length) : _data = Uint8List(length); + + final Uint8List _data; + int _offset = 0; + + void writeUint8(int value) => _data[_offset++] = value; + + void writeUint16(int value) { + ByteData.sublistView(_data).setUint16(_offset, value, Endian.big); + _offset += 2; + } + + void writeUint32(int value) { + ByteData.sublistView(_data).setUint32(_offset, value, Endian.big); + _offset += 4; + } + + void writeUint64(int value) { + ByteData.sublistView(_data).setUint64(_offset, value, Endian.big); + _offset += 8; + } + + void writeBytes(List value) { + _data.setRange(_offset, _offset + value.length, value); + _offset += value.length; + } + + /// Internal codec support for calculating a checksum before its field is written. + List prefixForChecksum(int length) => _data.sublist(0, length); + + Uint8List takeBytes() { + if (_offset != _data.length) { + throw StateError('Binary buffer is not completely written'); + } + return Uint8List.fromList(_data); + } +} diff --git a/lib/src/voice_tx/util/hex_codec.dart b/lib/src/voice_tx/util/hex_codec.dart new file mode 100644 index 0000000..6c87bcc --- /dev/null +++ b/lib/src/voice_tx/util/hex_codec.dart @@ -0,0 +1,3 @@ +/// Returns lowercase hexadecimal without separators. +String bytesToHex(Iterable bytes) => + bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join(); diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..14598d1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: dart_modem +description: A pure Dart acoustic modem for encoding binary data as BFSK PCM audio. +version: 0.1.0 +repository: https://github.com/bchainhub/dart_modem +issue_tracker: https://github.com/bchainhub/dart_modem/issues +topics: + - audio + - modem + - bfsk + - signal-processing + +environment: + sdk: ^3.3.0 + +dependencies: + crypto: ^3.0.3 + +dev_dependencies: + lints: ^5.1.1 + test: ^1.25.15 diff --git a/test/bytes_test.dart b/test/bytes_test.dart new file mode 100644 index 0000000..c3ce85d --- /dev/null +++ b/test/bytes_test.dart @@ -0,0 +1,10 @@ +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('hex helpers round-trip', () { + expect(hexToBytes('00 aa FF'), [0, 170, 255]); + expect(bytesToHex([0, 170, 255]), '00aaff'); + expect(bytesToHex([0, 170, 255], uppercase: true), '00AAFF'); + }); +} diff --git a/test/crc32_test.dart b/test/crc32_test.dart new file mode 100644 index 0000000..b9a80dc --- /dev/null +++ b/test/crc32_test.dart @@ -0,0 +1,10 @@ +import 'dart:convert'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('matches the standard CRC-32 check value', () { + expect(Crc32.compute(utf8.encode('123456789')), 0xcbf43926); + }); +} diff --git a/test/decoder_test.dart b/test/decoder_test.dart new file mode 100644 index 0000000..916657c --- /dev/null +++ b/test/decoder_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('decodes a frame incrementally across arbitrary chunks', () { + final Uint8List payload = Uint8List.fromList(utf8.encode('Hello')); + final Int16List pcm = BfskEncoder().encode(payload); + final BfskDecoder decoder = BfskDecoder(); + final List frames = []; + for (int offset = 0; offset < pcm.length; offset += 37) { + final int end = (offset + 37).clamp(0, pcm.length); + frames.addAll(decoder.feed(pcm.sublist(offset, end))); + } + expect(frames, hasLength(1)); + expect(utf8.decode(frames.single.payload), 'Hello'); + }); + + test('decodes back-to-back frames', () { + final BfskEncoder encoder = BfskEncoder(); + final BfskDecoder decoder = BfskDecoder(); + final List pcm = [ + ...encoder.encode([1]), + ...encoder.encode([2]), + ]; + final List frames = decoder.feed(pcm); + expect(frames.map((ModemFrame frame) => frame.payload.single), [1, 2]); + }); + + test('rejects a frame with a corrupted CRC symbol', () { + final BfskEncoder encoder = BfskEncoder(); + final Int16List pcm = encoder.encode([1, 2, 3]); + const Bfsk config = Bfsk(); + final Uint8List packet = Packet([1, 2, 3]).encode(); + final int lastBit = packet.last & 1; + final int sourceSymbol = lastBit == 1 + ? config.carrierBits + 1 // zero bit in alternating preamble + : 0; // one bit in carrier + final int destinationSymbol = pcm.length ~/ config.samplesPerBit - 1; + pcm.setRange( + destinationSymbol * config.samplesPerBit, + (destinationSymbol + 1) * config.samplesPerBit, + pcm, + sourceSymbol * config.samplesPerBit, + ); + expect(BfskDecoder().feed(pcm), isEmpty); + }); + + test('streaming API emits frames', () async { + final StreamingBfskDecoder decoder = StreamingBfskDecoder(); + final Future first = decoder.frames.first; + decoder.feed(BfskEncoder().encode([7, 8])); + expect((await first).payload, [7, 8]); + await decoder.close(); + }); + + test('near-ultrasonic preset round-trips incrementally', () { + const Bfsk config = Bfsk.ultrasonic(); + final Int16List pcm = BfskEncoder(config: config).encode([9, 8, 7]); + final BfskDecoder decoder = BfskDecoder(config: config); + final List frames = []; + for (int offset = 0; offset < pcm.length; offset += 997) { + final int end = (offset + 997).clamp(0, pcm.length); + frames.addAll(decoder.feed(pcm.sublist(offset, end))); + } + expect(frames, hasLength(1)); + expect(frames.single.payload, [9, 8, 7]); + }); + + test('fast preset round-trips incrementally', () { + const Bfsk config = Bfsk.fast(); + final Int16List pcm = BfskEncoder(config: config).encode( + List.generate(256, (int index) => index), + ); + final BfskDecoder decoder = BfskDecoder(config: config); + final List frames = []; + for (int offset = 0; offset < pcm.length; offset += 613) { + final int end = (offset + 613).clamp(0, pcm.length); + frames.addAll(decoder.feed(pcm.sublist(offset, end))); + } + expect(frames, hasLength(1)); + expect( + frames.single.payload, + List.generate(256, (int index) => index), + ); + }); + + test('poor-quality preset round-trips incrementally', () { + const Bfsk config = Bfsk.poorQuality(); + final Int16List pcm = BfskEncoder(config: config).encode([4, 2]); + final BfskDecoder decoder = BfskDecoder(config: config); + final List frames = []; + for (int offset = 0; offset < pcm.length; offset += 53) { + final int end = (offset + 53).clamp(0, pcm.length); + frames.addAll(decoder.feed(pcm.sublist(offset, end))); + } + expect(frames, hasLength(1)); + expect(frames.single.payload, [4, 2]); + }); +} diff --git a/test/encoder_test.dart b/test/encoder_test.dart new file mode 100644 index 0000000..6ca39fd --- /dev/null +++ b/test/encoder_test.dart @@ -0,0 +1,71 @@ +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('generates signed Int16 PCM at the configured symbol rate', () { + const Bfsk config = Bfsk(sampleRate: 8000, baudRate: 100); + final Int16List pcm = BfskEncoder(config: config).encode([42]); + final int packetBits = (Packet.headerLength + 1 + Packet.crcLength) * 8; + final int expectedBits = config.carrierBits + + config.preambleBits + + Preamble.syncWordBits + + packetBits; + expect(pcm.length, expectedBits * config.samplesPerBit); + expect(pcm, contains(isNot(0))); + }); + + test('tone generator preserves phase between writes', () { + final ToneGenerator split = ToneGenerator(sampleRate: 8000); + final ToneGenerator whole = ToneGenerator(sampleRate: 8000); + final Int16List splitPcm = Int16List(80); + split.write(splitPcm, 0, 31, 1200); + split.write(splitPcm, 31, 49, 1200); + expect(splitPcm, whole.generate(1200, 80)); + }); + + test('ultrasonic preset uses a 48 kHz near-ultrasonic channel', () { + const Bfsk config = Bfsk.ultrasonic(); + expect(config.sampleRate, 48000); + expect(config.zeroFrequency, 18500); + expect(config.oneFrequency, 19500); + expect(config.samplesPerBit, 480); + }); + + test('fast preset provides a 1200-baud symbol-aligned channel', () { + const Bfsk config = Bfsk.fast(); + expect(config.sampleRate, 48000); + expect(config.baudRate, 1200); + expect(config.zeroFrequency, 1200); + expect(config.oneFrequency, 2400); + expect(config.samplesPerBit, 40); + }); + + test('default uses the V.23 phone-call channel', () { + const Bfsk config = Bfsk(); + expect(config.sampleRate, 48000); + expect(config.baudRate, 1200); + expect(config.zeroFrequency, 2100); + expect(config.oneFrequency, 1300); + expect(config.samplesPerBit, 40); + }); + + test('poor-quality preset provides the low-speed fallback channel', () { + const Bfsk config = Bfsk.poorQuality(); + expect(config.sampleRate, 8000); + expect(config.baudRate, 100); + expect(config.zeroFrequency, 1200); + expect(config.oneFrequency, 2200); + expect(config.samplesPerBit, 80); + }); + + test('robust remains an alias for the poor-quality preset', () { + const Bfsk robust = Bfsk.robust(); + const Bfsk poorQuality = Bfsk.poorQuality(); + expect(robust.sampleRate, poorQuality.sampleRate); + expect(robust.baudRate, poorQuality.baudRate); + expect(robust.zeroFrequency, poorQuality.zeroFrequency); + expect(robust.oneFrequency, poorQuality.oneFrequency); + }); +} diff --git a/test/goertzel_test.dart b/test/goertzel_test.dart new file mode 100644 index 0000000..6eb721a --- /dev/null +++ b/test/goertzel_test.dart @@ -0,0 +1,33 @@ +import 'dart:math' as math; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('selects the matching tone', () { + const int sampleRate = 8000; + final List samples = List.generate( + 80, + (int index) => math.sin(2 * math.pi * 1200 * index / sampleRate), + ); + final double matching = + Goertzel(sampleRate: sampleRate, frequency: 1200).power(samples); + final double other = + Goertzel(sampleRate: sampleRate, frequency: 2200).power(samples); + expect(matching, greaterThan(other * 100)); + }); + + test('carrier detector distinguishes tone from silence', () { + final CarrierDetector detector = CarrierDetector( + sampleRate: 8000, + zeroFrequency: 1200, + oneFrequency: 2200, + ); + final List tone = List.generate( + 80, + (int index) => 1000 * math.sin(2 * math.pi * 2200 * index / 8000), + ); + expect(detector.detect(tone), isTrue); + expect(detector.detect(List.filled(80, 0)), isFalse); + }); +} diff --git a/test/modem_protocol_test.dart b/test/modem_protocol_test.dart new file mode 100644 index 0000000..9bc9c94 --- /dev/null +++ b/test/modem_protocol_test.dart @@ -0,0 +1,63 @@ +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('Voice TX is the default protocol', () { + final DartModem modem = DartModem(); + final Uint8List payload = Uint8List.fromList([1, 2, 3, 4]); + + final List frames = + modem.createDecoder().feed(modem.encode(payload)); + + expect(modem.protocol, isA()); + expect(frames, hasLength(1)); + expect(frames.single.payload, payload); + }); + + test('null protocol sends bytes without application framing', () { + final DartModem modem = DartModem(protocol: null); + final Uint8List payload = Uint8List.fromList([5, 6, 7]); + + final List frames = + modem.createDecoder().feed(modem.encode(payload)); + + expect(frames.single.payload, payload); + }); + + test('a custom protocol can replace Voice TX', () { + final DartModem modem = DartModem(protocol: const _PrefixProtocol()); + final Uint8List payload = Uint8List.fromList([8, 9]); + + final List frames = + modem.createDecoder().feed(modem.encode(payload)); + + expect(frames.single.payload, payload); + }); +} + +final class _PrefixProtocol implements ModemProtocol { + const _PrefixProtocol(); + + @override + Iterable encode(List payload) => [ + Uint8List.fromList([0xaa, ...payload]), + ]; + + @override + ModemProtocolDecoder createDecoder() => _PrefixProtocolDecoder(); +} + +final class _PrefixProtocolDecoder implements ModemProtocolDecoder { + @override + Iterable decode(List packet) { + if (packet.isEmpty || packet.first != 0xaa) { + throw const FormatException('Missing custom protocol prefix.'); + } + return [Uint8List.fromList(packet.sublist(1))]; + } + + @override + void reset() {} +} diff --git a/test/packet_test.dart b/test/packet_test.dart new file mode 100644 index 0000000..8722f02 --- /dev/null +++ b/test/packet_test.dart @@ -0,0 +1,17 @@ +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('packet framing round-trips arbitrary bytes', () { + final Packet decoded = + Packet.decode(Packet([0, 1, 127, 255]).encode()); + expect(decoded.version, Packet.currentVersion); + expect(decoded.payload, [0, 1, 127, 255]); + }); + + test('rejects corrupt packets', () { + final List encoded = Packet([1, 2, 3]).encode(); + encoded[Packet.headerLength] ^= 1; + expect(() => Packet.decode(encoded), throwsFormatException); + }); +} diff --git a/test/ready_signal_detector_test.dart b/test/ready_signal_detector_test.dart new file mode 100644 index 0000000..dff53c6 --- /dev/null +++ b/test/ready_signal_detector_test.dart @@ -0,0 +1,266 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + final DartModem modem = DartModem(); + Int16List encode(ReadySignal signal) => + ReadySignalEncoder(modem: modem).encode(signal); + + ReadySignalDetector detector({ReadySignalDetectorConfig? config}) => + ReadySignalDetector( + modemDecoder: modem.createStreamingDecoder(), + config: config ?? const ReadySignalDetectorConfig(), + ); + + group('READY detector', () { + test('detects one READY and exposes timing metadata', () { + final ReadySignalDetector value = detector(); + final List events = value.add( + encode( + const ReadySignal( + sessionId: 10, + guardDelay: Duration(milliseconds: 750), + receiverTimeout: Duration(seconds: 12), + ), + ), + ); + final ReadySignalDetected event = events.single as ReadySignalDetected; + expect(event.isDuplicate, isFalse); + expect(event.signal.sessionId, 10); + expect(value.lastDetectedSessionId, 10); + expect(value.lastGuardDelay, const Duration(milliseconds: 750)); + expect(value.lastReceiverTimeout, const Duration(seconds: 12)); + }); + + test('accepts one sample per call', () { + final ReadySignalDetector value = detector(); + final Int16List pcm = encode(const ReadySignal(sessionId: 11)); + final List events = []; + for (final int sample in pcm) { + events.addAll(value.add([sample])); + } + expect(events, hasLength(1)); + expect((events.single as ReadySignalDetected).signal.sessionId, 11); + }); + + test('suppresses repeated sequences for one session by default', () { + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: const ReadySignal(sessionId: 12), + maximumRepeats: 3, + ); + final List events = + detector().add(repeater.encodeAll()); + expect(events, hasLength(1)); + expect((events.single as ReadySignalDetected).isDuplicate, isFalse); + }); + + test('emits exact and increasing-sequence duplicates when enabled', () { + final ReadySignalDetector value = detector( + config: const ReadySignalDetectorConfig(emitDuplicates: true), + ); + final List pcm = [ + ...encode(const ReadySignal(sessionId: 13, sequenceNumber: 4)), + ...encode(const ReadySignal(sessionId: 13, sequenceNumber: 4)), + ...encode(const ReadySignal(sessionId: 13, sequenceNumber: 5)), + ]; + final List events = value.add(pcm); + expect(events, hasLength(3)); + expect( + events.map((event) => (event as ReadySignalDetected).isDuplicate), + [false, true, true], + ); + }); + + test('rejects conflicting data for an existing session', () { + final ReadySignalDetector value = detector(); + value.add(encode(const ReadySignal(sessionId: 14))); + final List events = value.add( + encode( + const ReadySignal( + sessionId: 14, + sequenceNumber: 1, + guardDelay: Duration(milliseconds: 900), + ), + ), + ); + expect(events.single, isA()); + }); + + test('ignores sequence regression unless configured', () { + final ReadySignalDetector strict = detector( + config: const ReadySignalDetectorConfig(emitDuplicates: true), + ); + strict.add(encode(const ReadySignal(sessionId: 15, sequenceNumber: 10))); + expect( + strict.add(encode(const ReadySignal(sessionId: 15, sequenceNumber: 9))), + isEmpty, + ); + + final ReadySignalDetector permissive = detector( + config: const ReadySignalDetectorConfig( + emitDuplicates: true, + acceptSequenceRegression: true, + ), + ); + permissive + .add(encode(const ReadySignal(sessionId: 15, sequenceNumber: 10))); + expect( + permissive + .add(encode(const ReadySignal(sessionId: 15, sequenceNumber: 9))), + hasLength(1), + ); + }); + + test('ignores unrelated modem payloads', () { + final ReadySignalDetector value = detector(); + expect(value.add(modem.encode([1, 2, 3])), isEmpty); + }); + + test('emits an invalid event for a corrupt inner CRC', () { + final Uint8List corrupt = const ReadySignal(sessionId: 16).encodeFrame(); + corrupt[5] ^= 1; + final List events = + detector().add(modem.encode(corrupt)); + expect(events.single, isA()); + }); + + test('reset clears duplicate state and decoder state', () { + final ReadySignalDetector value = detector(); + final Int16List pcm = encode(const ReadySignal(sessionId: 17)); + expect(value.add(pcm), hasLength(1)); + value.reset(); + expect(value.lastDetectedSessionId, isNull); + expect(value.add(pcm), hasLength(1)); + }); + + test('bounds remembered sessions and evicts the oldest', () { + final ReadySignalDetector value = detector( + config: const ReadySignalDetectorConfig(maximumRememberedSessions: 2), + ); + value.add(encode(const ReadySignal(sessionId: 1))); + value.add(encode(const ReadySignal(sessionId: 2))); + value.add(encode(const ReadySignal(sessionId: 3))); + final List events = value.add( + encode(const ReadySignal(sessionId: 1)), + ); + expect(events, hasLength(1)); + expect((events.single as ReadySignalDetected).isDuplicate, isFalse); + }); + }); + + group('READY waiter', () { + test('returns recommended guard delay and supports reset', () { + final ReadySignalWaiter waiter = ReadySignalWaiter(detector: detector()); + expect(waiter.add(const []), isA()); + final ReadyWaitResult result = waiter.add( + encode( + const ReadySignal( + sessionId: 18, + guardDelay: Duration(milliseconds: 625), + ), + ), + ); + expect(result, isA()); + expect( + (result as ReadyReceived).recommendedTransmitDelay, + const Duration(milliseconds: 625), + ); + waiter.reset(); + }); + }); + + group('READY deterministic impairments', () { + const ReadySignal signal = ReadySignal(sessionId: 0xfeedbeef); + + void expectDetected(Iterable pcm, {Iterable? chunks}) { + final ReadySignalDetector value = detector(); + final List samples = pcm.toList(growable: false); + final List events = []; + if (chunks == null) { + events.addAll(value.add(samples)); + } else { + int offset = 0; + for (final int size in chunks) { + if (offset == samples.length) { + break; + } + final int end = math.min(offset + size, samples.length); + events.addAll(value.add(samples.sublist(offset, end))); + offset = end; + } + if (offset < samples.length) { + events.addAll(value.add(samples.sublist(offset))); + } + } + expect(events.whereType(), hasLength(1)); + } + + test('survives leading/trailing silence and arbitrary boundaries', () { + final Int16List pcm = encode(signal); + final math.Random random = math.Random(42); + expectDetected( + [ + ...List.filled(4800, 0), + ...pcm, + ...List.filled(9600, 0) + ], + chunks: List.generate(200, (_) => random.nextInt(311) + 1), + ); + }); + + test('survives amplitude reduced to 25 percent', () { + expectDetected( + encode(signal).map((int sample) => (sample * 0.25).round())); + }); + + test('survives moderate deterministic white noise', () { + final math.Random random = math.Random(1234); + expectDetected( + encode(signal).map( + (int sample) => + (sample + random.nextInt(2001) - 1000).clamp(-32768, 32767), + ), + ); + }); + + test('survives symmetric PCM clipping', () { + expectDetected( + encode(signal).map((int sample) => (sample * 2).clamp(-12000, 12000)), + ); + }); + + test('survives a G.711 mu-law encode/decode round trip', () { + expectDetected( + encode(signal).map((int sample) => _muLawDecode(_muLawEncode(sample))), + ); + }); + }); +} + +int _muLawEncode(int input) { + int sample = input.clamp(-32635, 32635); + final int sign = sample < 0 ? 0x80 : 0; + if (sample < 0) { + sample = -sample; + } + sample += 0x84; + int exponent = 7; + for (int mask = 0x4000; exponent > 0 && (sample & mask) == 0; mask >>= 1) { + exponent--; + } + final int mantissa = (sample >> (exponent + 3)) & 0x0f; + return (~(sign | (exponent << 4) | mantissa)) & 0xff; +} + +int _muLawDecode(int input) { + final int value = (~input) & 0xff; + final int sign = value & 0x80; + final int exponent = (value >> 4) & 0x07; + final int mantissa = value & 0x0f; + final int magnitude = (((mantissa << 3) + 0x84) << exponent) - 0x84; + return sign == 0 ? magnitude : -magnitude; +} diff --git a/test/ready_signal_encoder_test.dart b/test/ready_signal_encoder_test.dart new file mode 100644 index 0000000..9de2ad7 --- /dev/null +++ b/test/ready_signal_encoder_test.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + group('READY PCM encoder', () { + final DartModem modem = DartModem(); + const ReadySignal ready = ReadySignal(sessionId: 0x12345678); + + test('modem loopback recovers the exact READY frame', () { + const ReadySignalEncoderConfig config = ReadySignalEncoderConfig( + leadingSilence: Duration.zero, + trailingSilence: Duration.zero, + ); + final Int16List pcm = + ReadySignalEncoder(modem: modem, config: config).encode( + ready, + ); + final List frames = modem.createDecoder().feed(pcm); + expect(frames, hasLength(1)); + expect(frames.single.payload, ready.encodeFrame()); + }); + + test('adds exact leading and trailing silence', () { + const ReadySignalEncoderConfig config = ReadySignalEncoderConfig( + leadingSilence: Duration(milliseconds: 25), + trailingSilence: Duration(milliseconds: 50), + ); + final ReadySignalEncoder encoder = ReadySignalEncoder( + modem: modem, + config: config, + ); + final Int16List first = encoder.encode(ready); + final int leading = modem.modulation.sampleRate * 25 ~/ 1000; + final int trailing = modem.modulation.sampleRate * 50 ~/ 1000; + expect(first.length, greaterThan(leading + trailing)); + expect(first.take(leading), everyElement(0)); + expect(first.skip(first.length - trailing), everyElement(0)); + }); + }); + + group('repeating READY encoder', () { + final DartModem modem = DartModem(); + const ReadySignal ready = ReadySignal(sessionId: 55, sequenceNumber: 8); + const ReadySignalEncoderConfig noPadding = ReadySignalEncoderConfig( + leadingSilence: Duration.zero, + trailingSilence: Duration.zero, + ); + + test('yields exact frames and inter-repeat silence lazily', () { + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + repeatInterval: const Duration(milliseconds: 10), + maximumRepeats: 3, + encoderConfig: noPadding, + ); + final Iterator iterator = repeater.chunks().iterator; + expect(iterator.moveNext(), isTrue); + final Int16List firstFrame = iterator.current; + expect(firstFrame, isNotEmpty); + expect(iterator.moveNext(), isTrue); + expect(iterator.current.length, modem.modulation.sampleRate ~/ 100); + expect(iterator.current, everyElement(0)); + expect(iterator.moveNext(), isTrue); + expect(iterator.current, isNotEmpty); + expect(repeater.chunks().length, 5); + }); + + test('increments sequence while preserving the session', () { + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + repeatInterval: Duration.zero, + maximumRepeats: 3, + encoderConfig: noPadding, + ); + final List signals = repeater + .chunks() + .where((Int16List chunk) => chunk.isNotEmpty) + .map((Int16List chunk) { + final ModemFrame frame = modem.createDecoder().feed(chunk).single; + return ReadySignal.decodeFrame(frame.payload); + }).toList(); + expect(signals.map((ReadySignal value) => value.sessionId), + everyElement(55)); + expect(signals.map((ReadySignal value) => value.sequenceNumber), + [8, 9, 10]); + }); + + test('can keep sequence number constant', () { + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + repeatInterval: Duration.zero, + maximumRepeats: 2, + incrementSequenceNumber: false, + encoderConfig: noPadding, + ); + final List frames = + repeater.chunks().where((chunk) => chunk.isNotEmpty).toList(); + for (final Int16List pcm in frames) { + final ModemFrame frame = modem.createDecoder().feed(pcm).single; + expect(ReadySignal.decodeFrame(frame.payload).sequenceNumber, 8); + } + }); + + test('encodeAll enforces maximum duration', () { + final RepeatingReadySignalEncoder repeater = RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + maximumRepeats: 2, + maximumTotalDuration: const Duration(milliseconds: 1), + encoderConfig: noPadding, + ); + expect(repeater.encodeAll, throwsStateError); + }); + + test('rejects non-positive repeat counts', () { + expect( + () => RepeatingReadySignalEncoder( + modem: modem, + ready: ready, + maximumRepeats: 0, + ), + throwsRangeError, + ); + }); + }); +} diff --git a/test/ready_signal_test.dart b/test/ready_signal_test.dart new file mode 100644 index 0000000..43fc105 --- /dev/null +++ b/test/ready_signal_test.dart @@ -0,0 +1,164 @@ +import 'dart:typed_data'; + +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + group('READY frame', () { + test('round-trips all fields in exactly 16 bytes', () { + const ReadySignal original = ReadySignal( + sessionId: 0x12345678, + guardDelay: Duration(milliseconds: 65535), + receiverTimeout: Duration(seconds: 255), + sequenceNumber: 65535, + flags: ReadySignal.requestAcknowledgementFlag, + ); + final Uint8List bytes = original.encodeFrame(); + final ReadySignal decoded = ReadySignal.decodeFrame(bytes); + expect(bytes, hasLength(ModemControlFrame.length)); + expect(decoded.sessionId, 0x12345678); + expect(decoded.guardDelay, const Duration(milliseconds: 65535)); + expect(decoded.receiverTimeout, const Duration(seconds: 255)); + expect(decoded.sequenceNumber, 65535); + expect(decoded.flags, ReadySignal.requestAcknowledgementFlag); + }); + + test('supports minimum values', () { + final ReadySignal decoded = ReadySignal.decodeFrame( + const ReadySignal( + sessionId: 0, + guardDelay: Duration.zero, + receiverTimeout: Duration.zero, + sequenceNumber: 0, + ).encodeFrame(), + ); + expect(decoded.sessionId, 0); + expect(decoded.guardDelay, Duration.zero); + expect(decoded.receiverTimeout, Duration.zero); + expect(decoded.sequenceNumber, 0); + }); + + test('preserves the maximum unsigned session ID', () { + final ReadySignal decoded = ReadySignal.decodeFrame( + const ReadySignal(sessionId: 0xffffffff).encodeFrame(), + ); + expect(decoded.sessionId, 0xffffffff); + }); + + test('uses stable explicit control type values', () { + expect(ModemControlType.ready.wireValue, 1); + expect(ModemControlType.acknowledgement.wireValue, 2); + expect(ModemControlType.negativeAcknowledgement.wireValue, 3); + expect(ModemControlType.accepted.wireValue, 4); + expect(ModemControlType.rejected.wireValue, 5); + expect(ModemControlType.error.wireValue, 6); + }); + + test('CRC-16 matches the CCITT-FALSE check value', () { + expect(Crc16CcittFalse.compute('123456789'.codeUnits), 0x29b1); + }); + + test('rejects invalid magic, version, CRC, truncation, and trailing bytes', + () { + final Uint8List valid = const ReadySignal(sessionId: 7).encodeFrame(); + + Uint8List mutate(int index, int value, {bool repairCrc = false}) { + final Uint8List bytes = Uint8List.fromList(valid)..[index] = value; + if (repairCrc) { + ByteCodec.writeUint16( + bytes, 14, Crc16CcittFalse.compute(bytes.take(14))); + } + return bytes; + } + + expect( + () => ReadySignal.decodeFrame(mutate(0, 0, repairCrc: true)), + throwsA(isA()), + ); + expect( + () => ReadySignal.decodeFrame(mutate(2, 2, repairCrc: true)), + throwsA(isA()), + ); + expect( + () => ReadySignal.decodeFrame(mutate(4, valid[4] ^ 1)), + throwsA(isA()), + ); + expect( + () => ReadySignal.decodeFrame(Uint8List.fromList(valid.sublist(0, 15))), + throwsA(isA()), + ); + expect( + () => ReadySignal.decodeFrame(Uint8List.fromList([...valid, 0])), + throwsA(isA()), + ); + }); + + test('rejects the wrong control type and unsupported flags', () { + final Uint8List wrongType = const ModemControlFrame( + type: ModemControlType.acknowledgement, + sessionId: 1, + flags: 0, + guardDelayMilliseconds: 0, + receiverTimeoutSeconds: 0, + sequenceNumber: 0, + ).encode(); + final Uint8List badFlags = const ModemControlFrame( + type: ModemControlType.ready, + sessionId: 1, + flags: 0x80, + guardDelayMilliseconds: 0, + receiverTimeoutSeconds: 0, + sequenceNumber: 0, + ).encode(); + expect( + () => ReadySignal.decodeFrame(wrongType), + throwsA(isA()), + ); + expect( + () => ReadySignal.decodeFrame(badFlags), + throwsA(isA()), + ); + }); + + test('validates numeric and duration ranges', () { + expect( + () => const ReadySignal(sessionId: -1).encodeFrame(), + throwsRangeError, + ); + expect( + () => const ReadySignal(sessionId: 0x100000000).encodeFrame(), + throwsRangeError, + ); + expect( + () => const ReadySignal(sessionId: 1, sequenceNumber: 65536) + .encodeFrame(), + throwsRangeError, + ); + expect( + () => const ReadySignal( + sessionId: 1, + guardDelay: Duration(milliseconds: 65536), + ).encodeFrame(), + throwsRangeError, + ); + expect( + () => const ReadySignal( + sessionId: 1, + receiverTimeout: Duration(seconds: 256), + ).encodeFrame(), + throwsRangeError, + ); + }); + + test('does not retain mutable input or output arrays', () { + final Uint8List bytes = const ReadySignal(sessionId: 99).encodeFrame(); + final ReadySignal decoded = ReadySignal.decodeFrame(bytes); + bytes.fillRange(0, bytes.length, 0); + expect(ReadySignal.decodeFrame(decoded.encodeFrame()).sessionId, 99); + final Uint8List first = decoded.encodeFrame(); + final Uint8List second = decoded.encodeFrame(); + first[0] = 0; + expect(second[0], ModemControlFrame.magic0); + }); + }); +} diff --git a/test/synchronizer_test.dart b/test/synchronizer_test.dart new file mode 100644 index 0000000..c01a035 --- /dev/null +++ b/test/synchronizer_test.dart @@ -0,0 +1,15 @@ +import 'package:dart_modem/dart_modem.dart'; +import 'package:test/test.dart'; + +void main() { + test('finds preamble and sync after unrelated bits', () { + final FrameSynchronizer synchronizer = FrameSynchronizer(preambleBits: 16); + final List bits = [0, 0, 1, ...Preamble.bits(16)]; + for (int shift = Preamble.syncWordBits - 1; shift >= 0; shift--) { + bits.add((Preamble.syncWord >> shift) & 1); + } + final List results = bits.map(synchronizer.addBit).toList(); + expect(results.where((bool value) => value).length, 1); + expect(results.last, isTrue); + }); +}