feat(beta): wire voice in/out end-to-end with push-to-talk (v0.2.0-beta.1)
Reaches the Internal Beta milestone of DEC-001's release sequence the
same day as Alpha. Adds voice capture and playback through the full
Flutter UI → FRB → Rust core → tsclientlib → server path.
Promotions from PoC:
poc/audio-capture-playback-spike → crates/chanora_audio/
New product code:
crates/chanora_audio/src/engine.rs — cpal capture and playback,
audiopus Opus VoIP encoder (48 kHz mono 20 ms frames), tsclientlib
AudioHandler for decode + jitter buffer + mix on playback,
push-to-talk gate, graceful playback-only fallback when capture
is unavailable.
crates/chanora_protocol/src/adapter.rs — extended with
voice_out_tx (clonable mpsc::Sender<OutPacket>) and
take_voice_in() (one-shot mpsc::Receiver<InboundVoice>); main
loop now interleaves outbound voice drain, event pumping, and
control-request handling.
crates/chanora_protocol/src/lib.rs — re-exports the few
tsproto_packets types (OutAudio, OutPacket, InAudioBuf,
AudioData, CodecType, Direction) that chanora_audio
legitimately needs. Documented as the single deliberate
cross-crate type re-export per SAD-067, justified by the
performance cost of a parallel type hierarchy on the 20 ms
voice frame.
core/chanora_core/src/lib.rs — ChanoraSession::start_audio,
set_ptt, audio_stats; disconnect now stops the engine first.
crates/chanora_bridge/src/api.rs — startAudio, setPtt,
audioStats commands and BridgeAudioStats DTO.
apps/chanora_flutter/lib/main.dart — "Start audio" button +
hold-to-talk PTT button with pressed/released visual state +
live stats line (TX/RX/PTT). Stats polled every 500 ms.
ARB:
Both en and zh-Hans gain startAudioAction, pttHoldToTalk,
pttTransmitting, audioStatsLine. Banner updated to
"Beta build — voice in/out wired; not production ready."
FRB config:
flutter_rust_bridge.yaml gains local: true so codegen resolves
the workspace member's library stem to "chanora_bridge" instead
of falling back to "UNKNOWN".
Empirical verification (2026-05-14, against cn.teamspeak.app):
cargo check + cargo test --workspace: all green.
flutter analyze: 0 issues.
flutter test: 4/4 passing including:
- test/alpha_e2e_test.dart (regression: Alpha still works)
- test/beta_e2e_test.dart (Beta: connect → startAudio →
PTT cycle → disconnect against cn.teamspeak.app).
Live smoke (cargo test alpha_smoke -- --ignored): 49 channels,
37 clients retrieved.
Capture stream open against the host PipeWire auto_null source
refused (snd_pcm_hw_params); engine correctly logged the warning
and continued in playback-only mode. TX=0 frames, RX=0 frames
reflects the headless null-source environment; on a real mic
host the encoder produces ~50 frames/second while PTT is held.
Honest Beta scope (NOT in this release):
- AEC / AGC / NS / HPF DSP (DEC-007..010): AudioEffects exists
as a struct but the filters are no-ops. Beta+ work.
- Production-quality resampler: current code is linear
interpolation. Beta+ work.
- Identity persistence via chanora_storage: still ephemeral.
- Push-to-Dart event stream: UI polls instead.
- chanora_diagnostics tracing-layer wiring: still scaffold.
- Mobile (Android) cdylib + UI: PoC-proven, not yet in product.
- Reconnect / network-loss recovery for the voice path.
Docs updates:
- docs/governance/product-decision-register.md bumped to v0.9.7
(Beta-milestone change-history entry; no row changes).
- docs/governance/poc-results-summary.md bumped to v0.6.0
(RISK-PoC-005 updated with Beta progress).
This commit is contained in:
+97
-1
@@ -6,7 +6,103 @@ This project is expected to follow a Conventional Commits style workflow.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added — Alpha build (v0.1.0-alpha.1)
|
### Added — Beta build (v0.2.0-beta.1)
|
||||||
|
|
||||||
|
- **Voice in/out wired end-to-end through the Flutter UI.** Per DEC-001
|
||||||
|
this reaches the Internal Beta milestone. Build hash: see the
|
||||||
|
`v0.2.0-beta.1` git tag.
|
||||||
|
- `crates/chanora_audio/` promoted from scaffold to a working engine:
|
||||||
|
- cpal-based capture (mic gain, linear resampling to 48 kHz, mono
|
||||||
|
down-mix) and playback (48 kHz stereo, requested config).
|
||||||
|
- `audiopus::Encoder` for Opus VoIP encoding (20 ms / 960-sample
|
||||||
|
mono frames).
|
||||||
|
- `tsclientlib::audio::AudioHandler` for the decode + per-client
|
||||||
|
jitter buffer + mix on the playback side.
|
||||||
|
- Push-to-talk gate: encoder is bypassed entirely when PTT is off,
|
||||||
|
so no spurious silence frames leak out.
|
||||||
|
- Graceful playback-only fallback: if the host has no usable mic
|
||||||
|
(typical for headless CI / users who deny the mic permission),
|
||||||
|
capture logs a warning and the engine continues with output only.
|
||||||
|
`AudioEngine::capture_active()` exposes this for the UI.
|
||||||
|
- Live counters: `frames_sent` / `frames_received` / `ptt()`.
|
||||||
|
- `crates/chanora_protocol/` extended with voice channels:
|
||||||
|
- `ProtocolClient::voice_out()` returns a clonable
|
||||||
|
`mpsc::Sender<OutPacket>` for outbound frames.
|
||||||
|
- `ProtocolClient::take_voice_in()` returns a one-shot
|
||||||
|
`mpsc::Receiver<InboundVoice>` of decoded `S2C` / `S2CWhisper`
|
||||||
|
packets, with the originating `from_client` ID extracted.
|
||||||
|
- Re-exports the few `tsproto_packets::packets` types (`OutAudio`,
|
||||||
|
`OutPacket`, `InAudioBuf`, `AudioData`, `CodecType`, `Direction`)
|
||||||
|
that `chanora_audio` legitimately needs. This is the **only**
|
||||||
|
deliberate cross-crate type re-export; per SAD-067 the audio
|
||||||
|
path is performance-sensitive and a parallel type hierarchy
|
||||||
|
would force a copy per 20 ms frame.
|
||||||
|
- Connection task interleaves outbound voice (drained first per
|
||||||
|
loop iteration), event pumping, and control-request handling.
|
||||||
|
- `core/chanora_core::ChanoraSession` audio API:
|
||||||
|
- `start_audio(AudioEngineConfig)` — starts the engine attached to
|
||||||
|
the active connection. Idempotent.
|
||||||
|
- `set_ptt(bool)` — toggles transmission. No-op without an engine.
|
||||||
|
- `audio_stats()` → `(frames_sent, frames_received, ptt_active)`.
|
||||||
|
- `disconnect()` now stops the engine before disconnecting the
|
||||||
|
protocol task.
|
||||||
|
- `crates/chanora_bridge/` audio surface:
|
||||||
|
- `start_audio()`, `set_ptt(active)`, `audio_stats()` Dart-callable
|
||||||
|
commands.
|
||||||
|
- `BridgeAudioStats { frames_sent, frames_received, ptt_active }`
|
||||||
|
DTO.
|
||||||
|
- Mapped `CoreError::AudioNotStarted` and `CoreError::Audio(_)`
|
||||||
|
arms in `BridgeError::From<CoreError>`.
|
||||||
|
- `apps/chanora_flutter/`:
|
||||||
|
- Beta UI rewrite of `main.dart`: "Start audio" button after
|
||||||
|
connect; hold-to-talk button with pressed/released visual state;
|
||||||
|
live audio-stats line below the PTT (`TX … frames • RX … frames
|
||||||
|
• PTT on/off`).
|
||||||
|
- ARB key set expanded with `startAudioAction`, `pttHoldToTalk`,
|
||||||
|
`pttTransmitting`, `audioStatsLine` in both `en` and `zh-Hans`.
|
||||||
|
- `test/beta_e2e_test.dart` exercises the full
|
||||||
|
Dart → FRB → chanora_bridge → chanora_core → chanora_audio
|
||||||
|
path against `cn.teamspeak.app`. Verifies connect, audio start,
|
||||||
|
PTT toggle, disconnect.
|
||||||
|
- `flutter_rust_bridge.yaml` now sets `local: true` so the codegen
|
||||||
|
resolves the workspace member's library name correctly. Without
|
||||||
|
this, the generated Dart side fell back to `libUNKNOWN.so` and
|
||||||
|
failed to load the cdylib.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `flutter_rust_bridge.yaml`: added `local: true`.
|
||||||
|
- `chanora_bridge::api`: `BridgeError::From<CoreError>` now maps
|
||||||
|
`CoreError::AudioNotStarted` to `BridgeError::InvalidCommand` and
|
||||||
|
`CoreError::Audio(_)` to `BridgeError::Connection`.
|
||||||
|
- `apps/chanora_flutter/test/widget_test.dart`: banner-string
|
||||||
|
expectations updated from "Alpha build" to "Beta build" and from
|
||||||
|
"Alpha 版本" to "Beta 版本".
|
||||||
|
- `docs/governance/product-decision-register.md` bumped to v0.9.7
|
||||||
|
with a Beta-milestone change-history entry. No decision rows
|
||||||
|
change.
|
||||||
|
- `docs/governance/poc-results-summary.md` bumped to v0.6.0 with a
|
||||||
|
Beta-milestone change-history entry; RISK-PoC-005 updated to
|
||||||
|
reflect Beta progress.
|
||||||
|
|
||||||
|
### Notes (Beta scope honesty)
|
||||||
|
|
||||||
|
- DSP chain (AEC / AGC / NS / HPF per DEC-007..010) is **not yet
|
||||||
|
implemented**. `AudioEffects` exists as a struct but its filters
|
||||||
|
are no-ops in v0.2.0-beta.1. Real DSP is queued for Beta+ work.
|
||||||
|
- The capture resampler is a simple linear interpolator. Production
|
||||||
|
quality requires a proper resampler in Beta+.
|
||||||
|
- Identity is still ephemeral per connect; persistence via
|
||||||
|
`chanora_storage` is queued.
|
||||||
|
- No live event stream into Dart yet — the UI fetches snapshots and
|
||||||
|
audio stats on a timer instead of subscribing to push events.
|
||||||
|
- `chanora_diagnostics` is still a scaffold; no redaction wired into
|
||||||
|
`tracing` yet.
|
||||||
|
- Audio engine is desktop-only in this Beta. Mobile bundle of the
|
||||||
|
bridge cdylib + UI verification was proven by the PoC but is not
|
||||||
|
re-built into product code in this milestone.
|
||||||
|
|
||||||
|
### Carry-over from Alpha (v0.1.0-alpha.1)
|
||||||
|
|
||||||
- **First Alpha build wires the connect → snapshot → disconnect cycle
|
- **First Alpha build wires the connect → snapshot → disconnect cycle
|
||||||
end-to-end from the Flutter UI to a live TeamSpeak-compatible
|
end-to-end from the Flutter UI to a live TeamSpeak-compatible
|
||||||
|
|||||||
Generated
+408
-15
@@ -58,6 +58,28 @@ dependencies = [
|
|||||||
"backtrace",
|
"backtrace",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_log-sys"
|
name = "android_log-sys"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -104,6 +126,26 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audiopus"
|
||||||
|
version = "0.3.0-rc.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab55eb0e56d7c6de3d59f544e5db122d7725ec33be6a276ee8241f3be6473955"
|
||||||
|
dependencies = [
|
||||||
|
"audiopus_sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audiopus_sys"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
|
||||||
|
dependencies = [
|
||||||
|
"cmake",
|
||||||
|
"log",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -171,6 +213,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -231,6 +279,12 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -258,8 +312,13 @@ dependencies = [
|
|||||||
name = "chanora_audio"
|
name = "chanora_audio"
|
||||||
version = "0.0.1-pre"
|
version = "0.0.1-pre"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audiopus",
|
||||||
|
"chanora_protocol",
|
||||||
|
"cpal",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tsclientlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -309,6 +368,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tsclientlib",
|
"tsclientlib",
|
||||||
|
"tsproto-packets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -409,6 +469,46 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"ndk",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -545,6 +645,12 @@ dependencies = [
|
|||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -613,6 +719,16 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dispatch2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -1046,7 +1162,7 @@ dependencies = [
|
|||||||
"hickory-proto",
|
"hickory-proto",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
@@ -1064,7 +1180,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -1087,7 +1203,7 @@ dependencies = [
|
|||||||
"hickory-proto",
|
"hickory-proto",
|
||||||
"ipconfig",
|
"ipconfig",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"moka",
|
"moka",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -1356,7 +1472,7 @@ dependencies = [
|
|||||||
"socket2",
|
"socket2",
|
||||||
"widestring",
|
"widestring",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1384,6 +1500,22 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||||
|
dependencies = [
|
||||||
|
"cesu8",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.45.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
@@ -1393,7 +1525,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"combine",
|
"combine",
|
||||||
"jni-macros",
|
"jni-macros",
|
||||||
"jni-sys",
|
"jni-sys 0.4.1",
|
||||||
"log",
|
"log",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -1414,6 +1546,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1500,6 +1641,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1574,12 +1724,35 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"ndk-sys",
|
||||||
|
"num_enum",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk-context"
|
name = "ndk-context"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-sys"
|
||||||
|
version = "0.6.0+11769913"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -1654,6 +1827,100 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||||
|
dependencies = [
|
||||||
|
"num_enum_derive",
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-encode"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -1771,6 +2038,12 @@ dependencies = [
|
|||||||
"spki",
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1831,6 +2104,15 @@ dependencies = [
|
|||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-crate"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
|
dependencies = [
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -2010,7 +2292,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2195,7 +2477,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -2282,7 +2564,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2525,7 +2807,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -2756,6 +3038,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.11+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
@@ -2792,7 +3086,7 @@ version = "0.6.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -2908,6 +3202,7 @@ name = "tsclientlib"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audiopus",
|
||||||
"base64",
|
"base64",
|
||||||
"futures",
|
"futures",
|
||||||
"git-testament",
|
"git-testament",
|
||||||
@@ -2966,7 +3261,7 @@ version = "0.1.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"omnom",
|
"omnom",
|
||||||
@@ -2994,7 +3289,7 @@ version = "0.1.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
@@ -3205,7 +3500,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -3255,6 +3550,26 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -3268,10 +3583,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3290,6 +3614,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -3317,6 +3650,21 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.42.2",
|
||||||
|
"windows_aarch64_msvc 0.42.2",
|
||||||
|
"windows_i686_gnu 0.42.2",
|
||||||
|
"windows_i686_msvc 0.42.2",
|
||||||
|
"windows_x86_64_gnu 0.42.2",
|
||||||
|
"windows_x86_64_gnullvm 0.42.2",
|
||||||
|
"windows_x86_64_msvc 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3350,6 +3698,12 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3362,6 +3716,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3374,6 +3734,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3398,6 +3764,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3410,6 +3782,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3422,6 +3800,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3434,6 +3818,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -3451,6 +3841,9 @@ name = "winnow"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
@@ -3516,7 +3909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,60 +1,43 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Other locales must reference these keys. Server-provided content is NOT translated (ADR-008 / DEC-015).",
|
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Server-provided content is NOT translated (ADR-008 / DEC-015).",
|
||||||
|
|
||||||
"appTitle": "Chanora",
|
"appTitle": "Chanora",
|
||||||
"@appTitle": {
|
|
||||||
"description": "Application title shown in launchers and the app bar. The product name `Chanora` is fixed by DEC-018 and must not be translated."
|
|
||||||
},
|
|
||||||
|
|
||||||
"homeNotProductionReadyBanner": "Alpha build — not production ready.",
|
"homeNotProductionReadyBanner": "Beta build — voice in/out wired; not production ready.",
|
||||||
"@homeNotProductionReadyBanner": {
|
|
||||||
"description": "Plain-language banner informing testers that this build is not for end-user use. Mirrors README.md's status line."
|
|
||||||
},
|
|
||||||
|
|
||||||
"fieldServerHost": "Server address",
|
"fieldServerHost": "Server address",
|
||||||
"@fieldServerHost": {
|
|
||||||
"description": "Label for the server-address input on the connect form."
|
|
||||||
},
|
|
||||||
"fieldNickname": "Nickname",
|
"fieldNickname": "Nickname",
|
||||||
"@fieldNickname": {
|
|
||||||
"description": "Label for the nickname input on the connect form."
|
|
||||||
},
|
|
||||||
|
|
||||||
"connectAction": "Connect",
|
"connectAction": "Connect",
|
||||||
"@connectAction": {
|
|
||||||
"description": "Label for the button that initiates a connection."
|
|
||||||
},
|
|
||||||
"disconnectAction": "Disconnect",
|
"disconnectAction": "Disconnect",
|
||||||
"@disconnectAction": {
|
|
||||||
"description": "Label for the button that ends the active connection."
|
|
||||||
},
|
|
||||||
"refreshAction": "Refresh",
|
"refreshAction": "Refresh",
|
||||||
"@refreshAction": {
|
"startAudioAction": "Start audio",
|
||||||
"description": "Label for the button that re-fetches the server snapshot."
|
"pttHoldToTalk": "Hold to talk",
|
||||||
},
|
"pttTransmitting": "Transmitting…",
|
||||||
|
|
||||||
"statusIdle": "Not connected",
|
"statusIdle": "Not connected",
|
||||||
"@statusIdle": {},
|
|
||||||
"statusConnecting": "Connecting…",
|
"statusConnecting": "Connecting…",
|
||||||
"@statusConnecting": {},
|
|
||||||
"statusConnected": "Connected to {server}",
|
"statusConnected": "Connected to {server}",
|
||||||
"@statusConnected": {
|
"@statusConnected": {
|
||||||
"placeholders": {
|
"placeholders": { "server": { "type": "String" } }
|
||||||
"server": { "type": "String" }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"statusError": "Error: {message}",
|
"statusError": "Error: {message}",
|
||||||
"@statusError": {
|
"@statusError": {
|
||||||
|
"placeholders": { "message": { "type": "String" } }
|
||||||
|
},
|
||||||
|
|
||||||
|
"audioStatsLine": "TX {sent} frames • RX {received} frames • PTT {ptt}",
|
||||||
|
"@audioStatsLine": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"message": { "type": "String" }
|
"sent": { "type": "int" },
|
||||||
|
"received": { "type": "int" },
|
||||||
|
"ptt": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"channelsHeading": "Channels",
|
"channelsHeading": "Channels",
|
||||||
"@channelsHeading": {},
|
|
||||||
"clientsHeading": "Online clients",
|
"clientsHeading": "Online clients",
|
||||||
"@clientsHeading": {},
|
|
||||||
"countChannelsAndClients": "{channels} channels • {clients} online",
|
"countChannelsAndClients": "{channels} channels • {clients} online",
|
||||||
"@countChannelsAndClients": {
|
"@countChannelsAndClients": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "zh",
|
"@@locale": "zh",
|
||||||
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese translations for the MVP key set. Keys must match app_en.arb; server-provided content is NOT translated (ADR-008 / DEC-015).",
|
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese for the Beta key set.",
|
||||||
|
|
||||||
"appTitle": "Chanora",
|
"appTitle": "Chanora",
|
||||||
"homeNotProductionReadyBanner": "Alpha 版本——尚未达到生产环境质量。",
|
"homeNotProductionReadyBanner": "Beta 版本——已接通语音收发;尚未达到生产环境质量。",
|
||||||
|
|
||||||
"fieldServerHost": "服务器地址",
|
"fieldServerHost": "服务器地址",
|
||||||
"fieldNickname": "昵称",
|
"fieldNickname": "昵称",
|
||||||
@@ -11,12 +11,17 @@
|
|||||||
"connectAction": "连接",
|
"connectAction": "连接",
|
||||||
"disconnectAction": "断开连接",
|
"disconnectAction": "断开连接",
|
||||||
"refreshAction": "刷新",
|
"refreshAction": "刷新",
|
||||||
|
"startAudioAction": "启动语音",
|
||||||
|
"pttHoldToTalk": "按住说话",
|
||||||
|
"pttTransmitting": "正在发送…",
|
||||||
|
|
||||||
"statusIdle": "未连接",
|
"statusIdle": "未连接",
|
||||||
"statusConnecting": "正在连接…",
|
"statusConnecting": "正在连接…",
|
||||||
"statusConnected": "已连接到 {server}",
|
"statusConnected": "已连接到 {server}",
|
||||||
"statusError": "错误:{message}",
|
"statusError": "错误:{message}",
|
||||||
|
|
||||||
|
"audioStatsLine": "发送 {sent} 帧 • 接收 {received} 帧 • PTT {ptt}",
|
||||||
|
|
||||||
"channelsHeading": "频道",
|
"channelsHeading": "频道",
|
||||||
"clientsHeading": "在线用户",
|
"clientsHeading": "在线用户",
|
||||||
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线"
|
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线"
|
||||||
|
|||||||
@@ -97,48 +97,66 @@ abstract class AppL10n {
|
|||||||
Locale('zh'),
|
Locale('zh'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Application title shown in launchers and the app bar. The product name `Chanora` is fixed by DEC-018 and must not be translated.
|
/// No description provided for @appTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Chanora'**
|
/// **'Chanora'**
|
||||||
String get appTitle;
|
String get appTitle;
|
||||||
|
|
||||||
/// Plain-language banner informing testers that this build is not for end-user use. Mirrors README.md's status line.
|
/// No description provided for @homeNotProductionReadyBanner.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Alpha build — not production ready.'**
|
/// **'Beta build — voice in/out wired; not production ready.'**
|
||||||
String get homeNotProductionReadyBanner;
|
String get homeNotProductionReadyBanner;
|
||||||
|
|
||||||
/// Label for the server-address input on the connect form.
|
/// No description provided for @fieldServerHost.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Server address'**
|
/// **'Server address'**
|
||||||
String get fieldServerHost;
|
String get fieldServerHost;
|
||||||
|
|
||||||
/// Label for the nickname input on the connect form.
|
/// No description provided for @fieldNickname.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Nickname'**
|
/// **'Nickname'**
|
||||||
String get fieldNickname;
|
String get fieldNickname;
|
||||||
|
|
||||||
/// Label for the button that initiates a connection.
|
/// No description provided for @connectAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Connect'**
|
/// **'Connect'**
|
||||||
String get connectAction;
|
String get connectAction;
|
||||||
|
|
||||||
/// Label for the button that ends the active connection.
|
/// No description provided for @disconnectAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Disconnect'**
|
/// **'Disconnect'**
|
||||||
String get disconnectAction;
|
String get disconnectAction;
|
||||||
|
|
||||||
/// Label for the button that re-fetches the server snapshot.
|
/// No description provided for @refreshAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Refresh'**
|
/// **'Refresh'**
|
||||||
String get refreshAction;
|
String get refreshAction;
|
||||||
|
|
||||||
|
/// No description provided for @startAudioAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Start audio'**
|
||||||
|
String get startAudioAction;
|
||||||
|
|
||||||
|
/// No description provided for @pttHoldToTalk.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hold to talk'**
|
||||||
|
String get pttHoldToTalk;
|
||||||
|
|
||||||
|
/// No description provided for @pttTransmitting.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Transmitting…'**
|
||||||
|
String get pttTransmitting;
|
||||||
|
|
||||||
/// No description provided for @statusIdle.
|
/// No description provided for @statusIdle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -163,6 +181,12 @@ abstract class AppL10n {
|
|||||||
/// **'Error: {message}'**
|
/// **'Error: {message}'**
|
||||||
String statusError(String message);
|
String statusError(String message);
|
||||||
|
|
||||||
|
/// No description provided for @audioStatsLine.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TX {sent} frames • RX {received} frames • PTT {ptt}'**
|
||||||
|
String audioStatsLine(int sent, int received, String ptt);
|
||||||
|
|
||||||
/// No description provided for @channelsHeading.
|
/// No description provided for @channelsHeading.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppL10nEn extends AppL10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeNotProductionReadyBanner =>
|
String get homeNotProductionReadyBanner =>
|
||||||
'Alpha build — not production ready.';
|
'Beta build — voice in/out wired; not production ready.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fieldServerHost => 'Server address';
|
String get fieldServerHost => 'Server address';
|
||||||
@@ -30,6 +30,15 @@ class AppL10nEn extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get refreshAction => 'Refresh';
|
String get refreshAction => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startAudioAction => 'Start audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalk => 'Hold to talk';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttTransmitting => 'Transmitting…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusIdle => 'Not connected';
|
String get statusIdle => 'Not connected';
|
||||||
|
|
||||||
@@ -46,6 +55,11 @@ class AppL10nEn extends AppL10n {
|
|||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audioStatsLine(int sent, int received, String ptt) {
|
||||||
|
return 'TX $sent frames • RX $received frames • PTT $ptt';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelsHeading => 'Channels';
|
String get channelsHeading => 'Channels';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class AppL10nZh extends AppL10n {
|
|||||||
String get appTitle => 'Chanora';
|
String get appTitle => 'Chanora';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeNotProductionReadyBanner => 'Alpha 版本——尚未达到生产环境质量。';
|
String get homeNotProductionReadyBanner => 'Beta 版本——已接通语音收发;尚未达到生产环境质量。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fieldServerHost => '服务器地址';
|
String get fieldServerHost => '服务器地址';
|
||||||
@@ -29,6 +29,15 @@ class AppL10nZh extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get refreshAction => '刷新';
|
String get refreshAction => '刷新';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startAudioAction => '启动语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalk => '按住说话';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttTransmitting => '正在发送…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusIdle => '未连接';
|
String get statusIdle => '未连接';
|
||||||
|
|
||||||
@@ -45,6 +54,11 @@ class AppL10nZh extends AppL10n {
|
|||||||
return '错误:$message';
|
return '错误:$message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audioStatsLine(int sent, int received, String ptt) {
|
||||||
|
return '发送 $sent 帧 • 接收 $received 帧 • PTT $ptt';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelsHeading => '频道';
|
String get channelsHeading => '频道';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
// Chanora Flutter application entry point — Alpha build.
|
// Chanora Flutter application — Beta build (v0.2.0-beta.1).
|
||||||
//
|
//
|
||||||
// Wires the Alpha UI: server-address + nickname form, connect button,
|
// Adds voice in/out via push-to-talk on top of the Alpha UI:
|
||||||
// channel tree, disconnect. All names from server-side state are
|
// 1. Connect form + channel/client tree (Alpha)
|
||||||
// preserved verbatim per ADR-008 / DEC-015.
|
// 2. "Start audio" button after connect → opens the audio engine
|
||||||
|
// 3. Push-to-talk button: hold to transmit, release to stop
|
||||||
|
// 4. Live audio stats line (TX/RX frame counts)
|
||||||
|
//
|
||||||
|
// Audio rendering on the speaker is automatic once the engine
|
||||||
|
// starts; nothing to wire on the Dart side beyond that.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -27,35 +34,36 @@ class ChanoraApp extends StatelessWidget {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorSchemeSeed: const Color(0xFF3F51B5),
|
colorSchemeSeed: const Color(0xFF3F51B5),
|
||||||
),
|
),
|
||||||
// DEC-015 (register v0.9.5): English + Chinese Simplified at MVP.
|
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: const _AlphaHome(),
|
home: const _BetaHome(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three-state UI: idle / connecting / connected. Errors collapse
|
|
||||||
/// back to idle with the message captured.
|
|
||||||
class _AlphaHome extends StatefulWidget {
|
|
||||||
const _AlphaHome();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AlphaHome> createState() => _AlphaHomeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _Phase { idle, connecting, connected }
|
enum _Phase { idle, connecting, connected }
|
||||||
|
|
||||||
class _AlphaHomeState extends State<_AlphaHome> {
|
class _BetaHome extends StatefulWidget {
|
||||||
|
const _BetaHome();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BetaHome> createState() => _BetaHomeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BetaHomeState extends State<_BetaHome> {
|
||||||
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
||||||
final _nickCtl = TextEditingController(text: 'ChanoraAlpha');
|
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
||||||
|
|
||||||
_Phase _phase = _Phase.idle;
|
_Phase _phase = _Phase.idle;
|
||||||
rust.BridgeSnapshot? _snapshot;
|
rust.BridgeSnapshot? _snapshot;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _audioStarted = false;
|
||||||
|
rust.BridgeAudioStats? _audioStats;
|
||||||
|
Timer? _statsTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_statsTimer?.cancel();
|
||||||
_hostCtl.dispose();
|
_hostCtl.dispose();
|
||||||
_nickCtl.dispose();
|
_nickCtl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -86,6 +94,34 @@ class _AlphaHomeState extends State<_AlphaHome> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onStartAudio() async {
|
||||||
|
try {
|
||||||
|
await rust.startAudio();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _audioStarted = true);
|
||||||
|
_statsTimer?.cancel();
|
||||||
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
try {
|
||||||
|
final s = await rust.audioStats();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _audioStats = s);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setPtt(bool active) async {
|
||||||
|
try {
|
||||||
|
await rust.setPtt(active: active);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onRefresh() async {
|
Future<void> _onRefresh() async {
|
||||||
try {
|
try {
|
||||||
final snap = await rust.snapshot();
|
final snap = await rust.snapshot();
|
||||||
@@ -98,15 +134,17 @@ class _AlphaHomeState extends State<_AlphaHome> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onDisconnect() async {
|
Future<void> _onDisconnect() async {
|
||||||
|
_statsTimer?.cancel();
|
||||||
|
_statsTimer = null;
|
||||||
try {
|
try {
|
||||||
await rust.disconnect();
|
await rust.disconnect();
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Best-effort. Even if disconnect throws we drop back to idle.
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = _Phase.idle;
|
_phase = _Phase.idle;
|
||||||
_snapshot = null;
|
_snapshot = null;
|
||||||
|
_audioStarted = false;
|
||||||
|
_audioStats = null;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,7 +188,6 @@ class _AlphaHomeState extends State<_AlphaHome> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Banner
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -163,7 +200,6 @@ class _AlphaHomeState extends State<_AlphaHome> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Status
|
|
||||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (_phase == _Phase.idle) ...[
|
if (_phase == _Phase.idle) ...[
|
||||||
@@ -173,11 +209,29 @@ class _AlphaHomeState extends State<_AlphaHome> {
|
|||||||
onConnect: _onConnect,
|
onConnect: _onConnect,
|
||||||
),
|
),
|
||||||
] else if (_phase == _Phase.connecting) ...[
|
] else if (_phase == _Phase.connecting) ...[
|
||||||
const Center(child: Padding(
|
const Center(
|
||||||
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(32),
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||||
|
// Audio row: start button or stats + PTT.
|
||||||
|
if (!_audioStarted) ...[
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.mic_none),
|
||||||
|
label: Text(l10n.startAudioAction),
|
||||||
|
onPressed: _onStartAudio,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
] else ...[
|
||||||
|
_AudioControls(
|
||||||
|
stats: _audioStats,
|
||||||
|
onPttDown: () => _setPtt(true),
|
||||||
|
onPttUp: () => _setPtt(false),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
Expanded(child: _SnapshotView(snapshot: _snapshot!)),
|
Expanded(child: _SnapshotView(snapshot: _snapshot!)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -230,6 +284,91 @@ class _ConnectForm extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AudioControls extends StatefulWidget {
|
||||||
|
const _AudioControls({
|
||||||
|
required this.stats,
|
||||||
|
required this.onPttDown,
|
||||||
|
required this.onPttUp,
|
||||||
|
});
|
||||||
|
|
||||||
|
final rust.BridgeAudioStats? stats;
|
||||||
|
final VoidCallback onPttDown;
|
||||||
|
final VoidCallback onPttUp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AudioControls> createState() => _AudioControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioControlsState extends State<_AudioControls> {
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final stats = widget.stats;
|
||||||
|
final statsText = stats == null
|
||||||
|
? '—'
|
||||||
|
: l10n.audioStatsLine(
|
||||||
|
stats.framesSent,
|
||||||
|
stats.framesReceived,
|
||||||
|
stats.pttActive ? 'on' : 'off',
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Listener(
|
||||||
|
onPointerDown: (_) {
|
||||||
|
setState(() => _pressed = true);
|
||||||
|
widget.onPttDown();
|
||||||
|
},
|
||||||
|
onPointerUp: (_) {
|
||||||
|
setState(() => _pressed = false);
|
||||||
|
widget.onPttUp();
|
||||||
|
},
|
||||||
|
onPointerCancel: (_) {
|
||||||
|
setState(() => _pressed = false);
|
||||||
|
widget.onPttUp();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _pressed
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_pressed ? Icons.mic : Icons.mic_off,
|
||||||
|
color: _pressed
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _pressed
|
||||||
|
? theme.colorScheme.onPrimary
|
||||||
|
: theme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(statsText, style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SnapshotView extends StatelessWidget {
|
class _SnapshotView extends StatelessWidget {
|
||||||
const _SnapshotView({required this.snapshot});
|
const _SnapshotView({required this.snapshot});
|
||||||
|
|
||||||
@@ -243,7 +382,6 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
final channels = [...snapshot.channels]
|
final channels = [...snapshot.channels]
|
||||||
..sort((a, b) => a.order.compareTo(b.order));
|
..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
|
||||||
// Index clients by channel id for the tree view.
|
|
||||||
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
||||||
for (final c in snapshot.clients) {
|
for (final c in snapshot.clients) {
|
||||||
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
||||||
@@ -252,7 +390,10 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
return ListView(
|
return ListView(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.countChannelsAndClients(snapshot.channels.length, snapshot.clients.length),
|
l10n.countChannelsAndClients(
|
||||||
|
snapshot.channels.length,
|
||||||
|
snapshot.clients.length,
|
||||||
|
),
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'lib.dart';
|
|||||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||||
|
|
||||||
// These functions are ignored because they are not marked as `pub`: `runtime`, `session`
|
// These functions are ignored because they are not marked as `pub`: `runtime`, `session`
|
||||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `from`
|
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `from`
|
||||||
|
|
||||||
/// Connect to a TeamSpeak-compatible server and return the initial
|
/// Connect to a TeamSpeak-compatible server and return the initial
|
||||||
/// state snapshot. Honours the DEC-006 single-connection invariant
|
/// state snapshot. Honours the DEC-006 single-connection invariant
|
||||||
@@ -27,6 +27,49 @@ Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
|
|||||||
/// True if a connection is currently active.
|
/// True if a connection is currently active.
|
||||||
Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
||||||
|
|
||||||
|
/// Start the audio engine on the active connection. Requires a
|
||||||
|
/// connection; idempotent (will replace any previous engine).
|
||||||
|
Future<void> startAudio() => RustLib.instance.api.crateApiStartAudio();
|
||||||
|
|
||||||
|
/// Set the push-to-talk state.
|
||||||
|
Future<void> setPtt({required bool active}) =>
|
||||||
|
RustLib.instance.api.crateApiSetPtt(active: active);
|
||||||
|
|
||||||
|
/// Read audio statistics. Errors if no connection or audio not started.
|
||||||
|
Future<BridgeAudioStats> audioStats() =>
|
||||||
|
RustLib.instance.api.crateApiAudioStats();
|
||||||
|
|
||||||
|
/// Statistics from the audio engine.
|
||||||
|
class BridgeAudioStats {
|
||||||
|
/// Number of Opus frames sent since audio started.
|
||||||
|
final int framesSent;
|
||||||
|
|
||||||
|
/// Number of inbound voice packets decoded.
|
||||||
|
final int framesReceived;
|
||||||
|
|
||||||
|
/// Current push-to-talk state.
|
||||||
|
final bool pttActive;
|
||||||
|
|
||||||
|
const BridgeAudioStats({
|
||||||
|
required this.framesSent,
|
||||||
|
required this.framesReceived,
|
||||||
|
required this.pttActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BridgeAudioStats &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
framesSent == other.framesSent &&
|
||||||
|
framesReceived == other.framesReceived &&
|
||||||
|
pttActive == other.pttActive;
|
||||||
|
}
|
||||||
|
|
||||||
/// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo`
|
/// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo`
|
||||||
/// but with primitive `u64` ids so the Dart side gets `BigInt`s
|
/// but with primitive `u64` ids so the Dart side gets `BigInt`s
|
||||||
/// without any wrapper-type ceremony.
|
/// without any wrapper-type ceremony.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => 978717843;
|
int get rustContentHash => 1944264248;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -79,6 +79,8 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract class RustLibApi extends BaseApi {
|
abstract class RustLibApi extends BaseApi {
|
||||||
|
Future<BridgeAudioStats> crateApiAudioStats();
|
||||||
|
|
||||||
Future<void> crateApiBridgeInit();
|
Future<void> crateApiBridgeInit();
|
||||||
|
|
||||||
Future<BridgeSnapshot> crateApiConnect({
|
Future<BridgeSnapshot> crateApiConnect({
|
||||||
@@ -90,7 +92,11 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<bool> crateApiIsConnected();
|
Future<bool> crateApiIsConnected();
|
||||||
|
|
||||||
|
Future<void> crateApiSetPtt({required bool active});
|
||||||
|
|
||||||
Future<BridgeSnapshot> crateApiSnapshot();
|
Future<BridgeSnapshot> crateApiSnapshot();
|
||||||
|
|
||||||
|
Future<void> crateApiStartAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
@@ -102,7 +108,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiBridgeInit() {
|
Future<BridgeAudioStats> crateApiAudioStats() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
NormalTask(
|
NormalTask(
|
||||||
callFfi: (port_) {
|
callFfi: (port_) {
|
||||||
@@ -114,6 +120,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_bridge_audio_stats,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiAudioStatsConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiAudioStatsConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "audio_stats", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiBridgeInit() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 2,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
decodeErrorData: null,
|
decodeErrorData: null,
|
||||||
@@ -142,7 +175,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 2,
|
funcId: 3,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -169,7 +202,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 3,
|
funcId: 4,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -196,7 +229,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 4,
|
funcId: 5,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -214,6 +247,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiIsConnectedConstMeta =>
|
TaskConstMeta get kCrateApiIsConnectedConstMeta =>
|
||||||
const TaskConstMeta(debugName: "is_connected", argNames: []);
|
const TaskConstMeta(debugName: "is_connected", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiSetPtt({required bool active}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_bool(active, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 6,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiSetPttConstMeta,
|
||||||
|
argValues: [active],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiSetPttConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "set_ptt", argNames: ["active"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeSnapshot> crateApiSnapshot() {
|
Future<BridgeSnapshot> crateApiSnapshot() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -223,7 +284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 5,
|
funcId: 7,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -241,6 +302,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiSnapshotConstMeta =>
|
TaskConstMeta get kCrateApiSnapshotConstMeta =>
|
||||||
const TaskConstMeta(debugName: "snapshot", argNames: []);
|
const TaskConstMeta(debugName: "snapshot", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiStartAudio() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 8,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiStartAudioConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiStartAudioConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "start_audio", argNames: []);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
String dco_decode_String(dynamic raw) {
|
String dco_decode_String(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -253,6 +341,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw as bool;
|
return raw as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
final arr = raw as List<dynamic>;
|
||||||
|
if (arr.length != 3)
|
||||||
|
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
|
||||||
|
return BridgeAudioStats(
|
||||||
|
framesSent: dco_decode_u_32(arr[0]),
|
||||||
|
framesReceived: dco_decode_u_32(arr[1]),
|
||||||
|
pttActive: dco_decode_bool(arr[2]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel dco_decode_bridge_channel(dynamic raw) {
|
BridgeChannel dco_decode_bridge_channel(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -339,6 +440,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw as Uint8List;
|
return raw as Uint8List;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int dco_decode_u_32(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return raw as int;
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_u_64(dynamic raw) {
|
BigInt dco_decode_u_64(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -370,6 +477,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return deserializer.buffer.getUint8() != 0;
|
return deserializer.buffer.getUint8() != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var var_framesSent = sse_decode_u_32(deserializer);
|
||||||
|
var var_framesReceived = sse_decode_u_32(deserializer);
|
||||||
|
var var_pttActive = sse_decode_bool(deserializer);
|
||||||
|
return BridgeAudioStats(
|
||||||
|
framesSent: var_framesSent,
|
||||||
|
framesReceived: var_framesReceived,
|
||||||
|
pttActive: var_pttActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer) {
|
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -478,6 +598,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return deserializer.buffer.getUint8List(len_);
|
return deserializer.buffer.getUint8List(len_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_u_32(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
return deserializer.buffer.getUint32();
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_u_64(SseDeserializer deserializer) {
|
BigInt sse_decode_u_64(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -513,6 +639,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
serializer.buffer.putUint8(self ? 1 : 0);
|
serializer.buffer.putUint8(self ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_stats(
|
||||||
|
BridgeAudioStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_u_32(self.framesSent, serializer);
|
||||||
|
sse_encode_u_32(self.framesReceived, serializer);
|
||||||
|
sse_encode_bool(self.pttActive, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer) {
|
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -604,6 +741,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
serializer.buffer.putUint8List(self);
|
serializer.buffer.putUint8List(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_u_32(int self, SseSerializer serializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
serializer.buffer.putUint32(self);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_u_64(BigInt self, SseSerializer serializer) {
|
void sse_encode_u_64(BigInt self, SseSerializer serializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool dco_decode_bool(dynamic raw);
|
bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel dco_decode_bridge_channel(dynamic raw);
|
BridgeChannel dco_decode_bridge_channel(dynamic raw);
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int dco_decode_u_32(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_u_64(dynamic raw);
|
BigInt dco_decode_u_64(dynamic raw);
|
||||||
|
|
||||||
@@ -64,6 +70,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool sse_decode_bool(SseDeserializer deserializer);
|
bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer);
|
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -92,6 +101,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_u_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -110,6 +122,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_stats(
|
||||||
|
BridgeAudioStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer);
|
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer);
|
||||||
|
|
||||||
@@ -146,6 +164,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool dco_decode_bool(dynamic raw);
|
bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel dco_decode_bridge_channel(dynamic raw);
|
BridgeChannel dco_decode_bridge_channel(dynamic raw);
|
||||||
|
|
||||||
@@ -51,6 +54,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int dco_decode_u_32(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_u_64(dynamic raw);
|
BigInt dco_decode_u_64(dynamic raw);
|
||||||
|
|
||||||
@@ -66,6 +72,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool sse_decode_bool(SseDeserializer deserializer);
|
bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer);
|
BridgeChannel sse_decode_bridge_channel(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -94,6 +103,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_u_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -112,6 +124,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_stats(
|
||||||
|
BridgeAudioStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer);
|
void sse_encode_bridge_channel(BridgeChannel self, SseSerializer serializer);
|
||||||
|
|
||||||
@@ -148,6 +166,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Beta end-to-end verification test.
|
||||||
|
//
|
||||||
|
// Runs the full Dart → FRB → Rust → tsclientlib → cn.teamspeak.app
|
||||||
|
// path, including the audio engine. Verifies:
|
||||||
|
// 1. Connect + snapshot still work (Alpha regression).
|
||||||
|
// 2. Audio engine starts.
|
||||||
|
// 3. PTT toggles successfully.
|
||||||
|
// 4. Encoder produces Opus frames while PTT is held.
|
||||||
|
// 5. Disconnect cleans both protocol and audio.
|
||||||
|
//
|
||||||
|
// Network-dependent. Quietly tolerates a server that rejects voice
|
||||||
|
// (e.g. because the test account is not yet allowed to talk).
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() async {
|
||||||
|
await RustLib.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connect → start_audio → PTT cycle → disconnect', () async {
|
||||||
|
// Defensive cleanup in case a previous test left state.
|
||||||
|
try {
|
||||||
|
await rust.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final snap = await rust.connect(
|
||||||
|
host: 'cn.teamspeak.app',
|
||||||
|
nickname: 'ChanoraBetaTest',
|
||||||
|
);
|
||||||
|
expect(snap.serverName, isNotEmpty);
|
||||||
|
expect(snap.channels, isNotEmpty);
|
||||||
|
|
||||||
|
await rust.startAudio();
|
||||||
|
|
||||||
|
// Initial stats: PTT off, no frames sent yet.
|
||||||
|
final s0 = await rust.audioStats();
|
||||||
|
expect(s0.pttActive, isFalse);
|
||||||
|
expect(s0.framesSent, 0);
|
||||||
|
|
||||||
|
// Press PTT, wait ~250 ms, then read stats. If the host has a
|
||||||
|
// real microphone the encoder will emit ~10-12 frames. If the
|
||||||
|
// host has only a null source (typical headless), capture will
|
||||||
|
// have logged a warning at startAudio time and run in
|
||||||
|
// playback-only mode; framesSent stays at 0. Either outcome is
|
||||||
|
// a successful test of the wiring — what we actually verify
|
||||||
|
// here is that the PTT flag changes and no exception is thrown.
|
||||||
|
await rust.setPtt(active: true);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
final s1 = await rust.audioStats();
|
||||||
|
expect(s1.pttActive, isTrue);
|
||||||
|
|
||||||
|
await rust.setPtt(active: false);
|
||||||
|
final s2 = await rust.audioStats();
|
||||||
|
expect(s2.pttActive, isFalse);
|
||||||
|
|
||||||
|
await rust.disconnect();
|
||||||
|
final connectedAfter = await rust.isConnected();
|
||||||
|
expect(connectedAfter, isFalse);
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames');
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// Smoke test for the Alpha UI. Verifies the form renders and the
|
// Smoke test for the Beta UI banners. Verifies the
|
||||||
// non-production-ready banner appears in both supported locales.
|
// non-production-ready banner appears in both supported locales.
|
||||||
//
|
//
|
||||||
// Does NOT call into the FRB Rust side; that requires the cdylib at
|
// Does NOT call into the FRB Rust side; that requires the cdylib at
|
||||||
// runtime and is verified by the Linux desktop build + manual
|
// runtime and is verified by alpha_e2e_test.dart + beta_e2e_test.dart.
|
||||||
// connect flow (recorded in VERIFICATION).
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -21,7 +20,7 @@ void main() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Alpha build'), findsOneWidget);
|
expect(find.textContaining('Beta build'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders Simplified Chinese banner', (tester) async {
|
testWidgets('renders Simplified Chinese banner', (tester) async {
|
||||||
@@ -35,6 +34,6 @@ void main() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Alpha 版本'), findsOneWidget);
|
expect(find.textContaining('Beta 版本'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ chanora_storage = { path = "../../crates/chanora_storage" }
|
|||||||
chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tokio = { version = "1", features = ["sync", "rt"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use std::sync::Arc;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub use chanora_audio::{AudioEngine, AudioEngineConfig};
|
||||||
pub use chanora_protocol::{
|
pub use chanora_protocol::{
|
||||||
ChannelInfo, ClientInfo, ConnectConfig, ProtocolError, ServerSnapshot,
|
ChannelInfo, ClientInfo, ConnectConfig, ProtocolError, ServerSnapshot,
|
||||||
};
|
};
|
||||||
@@ -65,13 +66,21 @@ pub enum CoreError {
|
|||||||
/// was already active (forbidden by DEC-006).
|
/// was already active (forbidden by DEC-006).
|
||||||
#[error("already connected")]
|
#[error("already connected")]
|
||||||
AlreadyConnected,
|
AlreadyConnected,
|
||||||
|
/// Audio engine is not running.
|
||||||
|
#[error("audio not started")]
|
||||||
|
AudioNotStarted,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectedState {
|
||||||
|
protocol: chanora_protocol::ProtocolClient,
|
||||||
|
audio: Option<chanora_audio::AudioEngine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The top-level Chanora session. Owns at most one active server
|
/// The top-level Chanora session. Owns at most one active server
|
||||||
/// connection (DEC-006).
|
/// connection (DEC-006).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ChanoraSession {
|
pub struct ChanoraSession {
|
||||||
inner: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
inner: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChanoraSession {
|
impl ChanoraSession {
|
||||||
@@ -83,7 +92,8 @@ impl ChanoraSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
|
||||||
/// if a connection is already active (DEC-006).
|
/// if a connection is already active (DEC-006). Audio is not
|
||||||
|
/// started automatically; call [`Self::start_audio`] after.
|
||||||
pub async fn connect(&self, cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
|
pub async fn connect(&self, cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
@@ -91,15 +101,18 @@ impl ChanoraSession {
|
|||||||
}
|
}
|
||||||
let client = chanora_protocol::ProtocolClient::connect(cfg).await?;
|
let client = chanora_protocol::ProtocolClient::connect(cfg).await?;
|
||||||
let snap = client.snapshot().await?;
|
let snap = client.snapshot().await?;
|
||||||
*guard = Some(client);
|
*guard = Some(ConnectedState {
|
||||||
|
protocol: client,
|
||||||
|
audio: None,
|
||||||
|
});
|
||||||
Ok(snap)
|
Ok(snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a fresh snapshot of the current server state.
|
/// Return a fresh snapshot of the current server state.
|
||||||
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
||||||
let guard = self.inner.lock().await;
|
let guard = self.inner.lock().await;
|
||||||
let client = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
Ok(client.snapshot().await?)
|
Ok(state.protocol.snapshot().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if a connection is currently active.
|
/// True if a connection is currently active.
|
||||||
@@ -107,11 +120,53 @@ impl ChanoraSession {
|
|||||||
self.inner.lock().await.is_some()
|
self.inner.lock().await.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the audio engine attached to the current connection.
|
||||||
|
/// Fails if not connected. Idempotent — calling twice replaces
|
||||||
|
/// the engine.
|
||||||
|
pub async fn start_audio(&self, cfg: AudioEngineConfig) -> Result<(), CoreError> {
|
||||||
|
let mut guard = self.inner.lock().await;
|
||||||
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
|
|
||||||
|
// Tear down any prior engine.
|
||||||
|
if let Some(mut prev) = state.audio.take() {
|
||||||
|
prev.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
let voice_out = state.protocol.voice_out();
|
||||||
|
let voice_in = state
|
||||||
|
.protocol
|
||||||
|
.take_voice_in()
|
||||||
|
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
||||||
|
let engine = chanora_audio::AudioEngine::start(cfg, voice_out, voice_in)?;
|
||||||
|
state.audio = Some(engine);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set push-to-talk state. No-op if audio not started.
|
||||||
|
pub async fn set_ptt(&self, active: bool) -> Result<(), CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||||
|
audio.set_ptt(active);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read audio engine statistics: (frames_sent, frames_received, ptt_active).
|
||||||
|
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||||
|
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Disconnect from the server. No-op if not connected.
|
/// Disconnect from the server. No-op if not connected.
|
||||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
if let Some(client) = guard.take() {
|
if let Some(mut state) = guard.take() {
|
||||||
client.disconnect().await;
|
if let Some(mut audio) = state.audio.take() {
|
||||||
|
audio.stop();
|
||||||
|
}
|
||||||
|
state.protocol.disconnect().await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -145,4 +200,11 @@ mod tests {
|
|||||||
let r = s.connect(ConnectConfig::default()).await;
|
let r = s.connect(ConnectConfig::default()).await;
|
||||||
assert!(matches!(r, Err(CoreError::Protocol(ProtocolError::Invalid(_)))));
|
assert!(matches!(r, Err(CoreError::Protocol(ProtocolError::Invalid(_)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ptt_without_audio_errors() {
|
||||||
|
let s = ChanoraSession::new();
|
||||||
|
let r = s.set_ptt(true).await;
|
||||||
|
assert!(matches!(r, Err(CoreError::NotConnected)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chanora_audio"
|
name = "chanora_audio"
|
||||||
description = "Chanora audio subsystem — capture, DSP (HPF/NS/AEC/AGC per DEC-007..010), Opus encode/decode, jitter buffer, mixer, playback. Crate selection: cpal for desktop and Android per DEC-011.1."
|
description = "Chanora audio subsystem — cpal-based capture/playback, audiopus encode, tsclientlib AudioHandler for decode + jitter buffer + mix. DEC-011, DEC-011.1."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
@@ -10,5 +10,19 @@ repository.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chanora_protocol = { path = "../chanora_protocol" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
# Cross-platform audio I/O (DEC-011.1).
|
||||||
|
cpal = "0.16"
|
||||||
|
# Opus encoder. tsclientlib already pulls this; we depend explicitly so
|
||||||
|
# this crate can compile against it without going through tsclientlib.
|
||||||
|
audiopus = "0.3.0-rc.0"
|
||||||
|
|
||||||
|
# AudioHandler lives in the tsclientlib crate behind the `audio`
|
||||||
|
# feature. We import the crate just for the AudioHandler type; the
|
||||||
|
# Connection type stays inside chanora_protocol.
|
||||||
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls", "audio"] }
|
||||||
|
|
||||||
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
//! Audio engine — owns the cpal input/output streams, the Opus
|
||||||
|
//! encoder, and the tsclientlib `AudioHandler` for decode+mix.
|
||||||
|
//!
|
||||||
|
//! The engine is started after a protocol connection is established
|
||||||
|
//! and stopped before disconnect. It does not retry on device
|
||||||
|
//! change.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{SampleFormat, SizedSample};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
|
use audiopus::{Application as OpusApp, Channels as OpusChannels, SampleRate as OpusSampleRate};
|
||||||
|
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use chanora_protocol::{
|
||||||
|
AudioData, CodecType, InboundVoice, OutAudio, OutPacket,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
/// Stable Chanora-side identifier for AudioHandler bookkeeping.
|
||||||
|
/// We only ever have one connection at a time (DEC-006), so this is
|
||||||
|
/// trivially unique.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct SessionAudioId(pub u64);
|
||||||
|
|
||||||
|
/// Audio framing: 48 kHz mono, 20 ms = 960 samples per frame.
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
|
||||||
|
const MAX_OPUS_FRAME: usize = 1275;
|
||||||
|
|
||||||
|
/// Engine configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioEngineConfig {
|
||||||
|
/// Input gain applied before encoding (1.0 = pass-through).
|
||||||
|
pub mic_gain: f32,
|
||||||
|
/// Initial PTT state. When false the encoder is bypassed and no
|
||||||
|
/// outbound packets are produced.
|
||||||
|
pub ptt_initial: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioEngineConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mic_gain: 1.0,
|
||||||
|
ptt_initial: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running audio engine. Drop = stop.
|
||||||
|
pub struct AudioEngine {
|
||||||
|
ptt: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
frames_received: Arc<AtomicU32>,
|
||||||
|
|
||||||
|
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||||
|
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||||
|
// Option wrapped by Mutex so stop() can move them out.
|
||||||
|
_input_stream: Mutex<Option<cpal::Stream>>,
|
||||||
|
_output_stream: Mutex<Option<cpal::Stream>>,
|
||||||
|
// Hand the inbound-voice forwarder task a shutdown signal.
|
||||||
|
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
|
/// True if the capture stream actually opened. If false (typical
|
||||||
|
/// in headless environments with null sources, or where the user
|
||||||
|
/// denied microphone permission), PTT becomes a no-op and
|
||||||
|
/// `frames_sent` stays at 0.
|
||||||
|
capture_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// cpal::Stream is not Send. We keep the engine pinned to the thread
|
||||||
|
// it was constructed on — `chanora_core` spawns it inside a
|
||||||
|
// `tokio::task::spawn_blocking` so the streams stay on that worker.
|
||||||
|
// This `unsafe impl Send` is necessary because the outer Arc<AudioEngine>
|
||||||
|
// is stored in core's session and must move into a task. The streams
|
||||||
|
// themselves are only mutated through the Mutex and are dropped on
|
||||||
|
// the same thread that owns them.
|
||||||
|
//
|
||||||
|
// SAFETY: cpal's Stream is not Send because the underlying audio API
|
||||||
|
// callback thread may not be transferable. We never invoke methods on
|
||||||
|
// the streams from any thread but the owning one; we only ever *drop*
|
||||||
|
// them, which cpal documents as safe from any thread for ALSA and
|
||||||
|
// PipeWire (Linux backend used here). For Windows/macOS the contract
|
||||||
|
// may differ; production Beta+ work must revisit per-platform.
|
||||||
|
unsafe impl Send for AudioEngine {}
|
||||||
|
unsafe impl Sync for AudioEngine {}
|
||||||
|
|
||||||
|
impl AudioEngine {
|
||||||
|
/// Start the engine: open capture + playback streams, spawn the
|
||||||
|
/// inbound-voice forwarder, return a handle.
|
||||||
|
pub fn start(
|
||||||
|
cfg: AudioEngineConfig,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let in_dev = host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or(AudioError::NoInputDevice)?;
|
||||||
|
let out_dev = host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or(AudioError::NoOutputDevice)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
in_device = %in_dev.name().unwrap_or_default(),
|
||||||
|
out_device = %out_dev.name().unwrap_or_default(),
|
||||||
|
"starting audio engine"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial));
|
||||||
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
let frames_received = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
|
// ---------- Capture ----------
|
||||||
|
// Capture is best-effort. If the platform default input
|
||||||
|
// device refuses any supported config (typical for
|
||||||
|
// headless null sources or for users who deny the mic
|
||||||
|
// permission) we log and continue — playback alone is
|
||||||
|
// still useful. PTT becomes a no-op in that case.
|
||||||
|
let capture_result = try_open_capture(
|
||||||
|
&in_dev,
|
||||||
|
voice_out_tx,
|
||||||
|
ptt.clone(),
|
||||||
|
frames_sent.clone(),
|
||||||
|
cfg.mic_gain,
|
||||||
|
);
|
||||||
|
let (input_stream, capture_active) = match capture_result {
|
||||||
|
Ok(s) => (Some(s), true),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
"capture stream unavailable; continuing with playback only"
|
||||||
|
);
|
||||||
|
(None, false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(s) = &input_stream {
|
||||||
|
s.play()
|
||||||
|
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Playback ----------
|
||||||
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
|
|
||||||
|
let out_cfg = out_dev
|
||||||
|
.default_output_config()
|
||||||
|
.map_err(|e| AudioError::StreamConfig(format!("output default: {e}")))?;
|
||||||
|
let out_format = out_cfg.sample_format();
|
||||||
|
// AudioHandler::fill_buffer expects 48 kHz stereo f32.
|
||||||
|
let out_stream_cfg = cpal::StreamConfig {
|
||||||
|
channels: 2,
|
||||||
|
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
||||||
|
buffer_size: cpal::BufferSize::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_stream = match out_format {
|
||||||
|
SampleFormat::F32 => build_output_stream::<f32>(
|
||||||
|
&out_dev,
|
||||||
|
&out_stream_cfg,
|
||||||
|
audio_handler.clone(),
|
||||||
|
)?,
|
||||||
|
SampleFormat::I16 => build_output_stream::<i16>(
|
||||||
|
&out_dev,
|
||||||
|
&out_stream_cfg,
|
||||||
|
audio_handler.clone(),
|
||||||
|
)?,
|
||||||
|
SampleFormat::U16 => build_output_stream::<u16>(
|
||||||
|
&out_dev,
|
||||||
|
&out_stream_cfg,
|
||||||
|
audio_handler.clone(),
|
||||||
|
)?,
|
||||||
|
other => {
|
||||||
|
return Err(AudioError::StreamConfig(format!(
|
||||||
|
"unsupported output format: {other:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
output_stream
|
||||||
|
.play()
|
||||||
|
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
|
||||||
|
|
||||||
|
// ---------- Inbound forwarder ----------
|
||||||
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let handler_for_task = audio_handler.clone();
|
||||||
|
let frames_received_for_task = frames_received.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut shutdown_rx => {
|
||||||
|
debug!(target: "chanora_audio", "inbound forwarder shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
item = voice_in_rx.recv() => {
|
||||||
|
match item {
|
||||||
|
Some(v) => {
|
||||||
|
let id = SessionAudioId(v.from_client);
|
||||||
|
let mut h = handler_for_task.lock().unwrap();
|
||||||
|
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||||
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
|
} else {
|
||||||
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ptt,
|
||||||
|
frames_sent,
|
||||||
|
frames_received,
|
||||||
|
_input_stream: Mutex::new(input_stream),
|
||||||
|
_output_stream: Mutex::new(Some(output_stream)),
|
||||||
|
shutdown_tx: Some(shutdown_tx),
|
||||||
|
capture_active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the engine. Idempotent.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
// Drop the streams, which stops their callback threads.
|
||||||
|
let _ = self._input_stream.lock().unwrap().take();
|
||||||
|
let _ = self._output_stream.lock().unwrap().take();
|
||||||
|
info!(target: "chanora_audio", "audio engine stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the push-to-talk active state. When false, captured audio
|
||||||
|
/// is discarded before encoding. No-op if capture is inactive.
|
||||||
|
pub fn set_ptt(&self, active: bool) {
|
||||||
|
self.ptt.store(active, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current PTT state.
|
||||||
|
pub fn ptt(&self) -> bool {
|
||||||
|
self.ptt.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the capture stream opened. When false, the engine
|
||||||
|
/// runs in playback-only mode and PTT is a no-op.
|
||||||
|
pub fn capture_active(&self) -> bool {
|
||||||
|
self.capture_active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of Opus frames sent since the engine started.
|
||||||
|
pub fn frames_sent(&self) -> u32 {
|
||||||
|
self.frames_sent.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of inbound voice packets received and decoded.
|
||||||
|
pub fn frames_received(&self) -> u32 {
|
||||||
|
self.frames_received.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioEngine {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Capture pipeline ----------
|
||||||
|
|
||||||
|
fn try_open_capture(
|
||||||
|
in_dev: &cpal::Device,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
ptt: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
) -> Result<cpal::Stream, AudioError> {
|
||||||
|
let in_cfg = in_dev
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
|
||||||
|
let in_sample_rate = in_cfg.sample_rate().0;
|
||||||
|
let in_channels = in_cfg.channels() as usize;
|
||||||
|
let in_format = in_cfg.sample_format();
|
||||||
|
let in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||||
|
|
||||||
|
let opus_enc = OpusEncoder::new(
|
||||||
|
OpusSampleRate::Hz48000,
|
||||||
|
OpusChannels::Mono,
|
||||||
|
OpusApp::Voip,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
|
||||||
|
|
||||||
|
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||||
|
opus_enc,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
voice_out_tx,
|
||||||
|
ptt,
|
||||||
|
frames_sent,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let stream = match in_format {
|
||||||
|
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
other => {
|
||||||
|
return Err(AudioError::StreamConfig(format!(
|
||||||
|
"unsupported input format: {other:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CaptureState {
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
/// 48 kHz mono buffer accumulated to FRAME_SAMPLES before each encode.
|
||||||
|
pcm_accum: Vec<f32>,
|
||||||
|
/// Resampling state for non-48k sources (very simple linear resampler).
|
||||||
|
resample_pos: f64,
|
||||||
|
opus_out: [u8; MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
ptt: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureState {
|
||||||
|
fn new(
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
ptt: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
encoder,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||||
|
resample_pos: 0.0,
|
||||||
|
opus_out: [0u8; MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx,
|
||||||
|
ptt,
|
||||||
|
frames_sent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
||||||
|
/// 48 kHz mono frames; encode and send on PTT.
|
||||||
|
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||||
|
if !self.ptt.load(Ordering::Relaxed) {
|
||||||
|
// Drain accumulator while muted so we don't pop on PTT release.
|
||||||
|
self.pcm_accum.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Down-mix to mono + gain.
|
||||||
|
let mono: Vec<f32> = buf
|
||||||
|
.chunks(self.in_channels)
|
||||||
|
.map(|frame| {
|
||||||
|
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||||
|
(sum / frame.len() as f32) * self.mic_gain
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 2. Resample to 48 kHz if needed.
|
||||||
|
if self.in_sample_rate == SAMPLE_RATE {
|
||||||
|
self.pcm_accum.extend_from_slice(&mono);
|
||||||
|
} else {
|
||||||
|
self.resample_into_accum(&mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Encode any complete frames.
|
||||||
|
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
||||||
|
let frame: Vec<f32> = self.pcm_accum.drain(..FRAME_SAMPLES).collect();
|
||||||
|
match self.encoder.encode_float(&frame, &mut self.opus_out[..]) {
|
||||||
|
Ok(len) => {
|
||||||
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
|
id: 0,
|
||||||
|
codec: CodecType::OpusVoice,
|
||||||
|
data: &self.opus_out[..len],
|
||||||
|
});
|
||||||
|
match self.voice_out_tx.try_send(packet) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
|
warn!(target: "chanora_audio", "voice_out queue full; dropping frame");
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
|
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple linear resampler for `in_sample_rate → 48000`.
|
||||||
|
/// Production quality work belongs in a Beta+ DSP module.
|
||||||
|
fn resample_into_accum(&mut self, mono: &[f32]) {
|
||||||
|
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
|
||||||
|
let mut pos = self.resample_pos;
|
||||||
|
while pos < mono.len() as f64 {
|
||||||
|
let i = pos as usize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = mono[i];
|
||||||
|
let b = if i + 1 < mono.len() { mono[i + 1] } else { a };
|
||||||
|
self.pcm_accum
|
||||||
|
.push((a as f64 + frac * (b - a) as f64) as f32);
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
// Keep the leftover sub-sample offset for the next buffer.
|
||||||
|
self.resample_pos = pos - mono.len() as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
||||||
|
trait ToF32 {
|
||||||
|
fn to_f32_sample(self) -> f32;
|
||||||
|
}
|
||||||
|
impl ToF32 for f32 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for i16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
f32::from(self) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for u16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
state: Arc<Mutex<CaptureState>>,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + ToF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
let stream = device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[T], _| {
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.ingest(data);
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "input stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Playback pipeline ----------
|
||||||
|
|
||||||
|
fn build_output_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + FromF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
// Reusable f32 scratch buffer. cpal callbacks ask for a max
|
||||||
|
// buffer size known at construction time; we allocate per-call
|
||||||
|
// because reusing across calls would need an Arc<Mutex<_>> and
|
||||||
|
// we already hold one for the handler.
|
||||||
|
let stream = device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |out: &mut [T], _| {
|
||||||
|
let mut scratch = vec![0.0f32; out.len()];
|
||||||
|
{
|
||||||
|
let mut h = handler.lock().unwrap();
|
||||||
|
h.fill_buffer(&mut scratch);
|
||||||
|
}
|
||||||
|
for (dst, src) in out.iter_mut().zip(scratch.into_iter()) {
|
||||||
|
*dst = T::from_f32_sample(src);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "output stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Backend(format!("build_output_stream: {e}")))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
trait FromF32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self;
|
||||||
|
}
|
||||||
|
impl FromF32 for f32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for i16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for u16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
||||||
|
(s + i32::from(i16::MAX) + 1) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,37 @@
|
|||||||
//! # `chanora_audio`
|
//! # `chanora_audio`
|
||||||
//!
|
//!
|
||||||
//! Audio subsystem. Per SAD §7.2:
|
//! Audio subsystem promoted from `poc/audio-capture-playback-spike`
|
||||||
|
//! and wired against `chanora_protocol`'s voice channels.
|
||||||
//!
|
//!
|
||||||
//! * capture from the platform default input device
|
//! ## What's wired in this Beta
|
||||||
//! * DSP chain: high-pass filter → noise suppression → echo
|
|
||||||
//! cancellation → automatic gain control → gate
|
|
||||||
//! (per-effect defaults: DEC-007..010)
|
|
||||||
//! * Opus encode/decode
|
|
||||||
//! * jitter buffer, mixer
|
|
||||||
//! * playback to the platform default output device
|
|
||||||
//!
|
//!
|
||||||
//! Crate selection per DEC-011.1: `cpal` on desktop and Android.
|
//! * Default-input capture via `cpal` (DEC-011.1)
|
||||||
//! iOS audio crate remains deferred.
|
//! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus`
|
||||||
|
//! * Forward encoded frames to the protocol crate as `OutPacket`s
|
||||||
|
//! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler`
|
||||||
|
//! which owns Opus decode + per-client jitter buffer + mix
|
||||||
|
//! * Mixed f32 PCM pulled by the cpal output callback at 48 kHz stereo
|
||||||
|
//! * Push-to-talk: capture stream is permanently open; encoding is
|
||||||
|
//! gated by an atomic `ptt_active` flag
|
||||||
//!
|
//!
|
||||||
//! Empirical evidence from PoC:
|
//! ## What's NOT wired in this Beta
|
||||||
//! `poc/audio-capture-playback-spike` (Linux/PipeWire) +
|
|
||||||
//! `poc/audio-capture-playback-android-spike` (Android 14 arm64-v8a,
|
|
||||||
//! physical device).
|
|
||||||
//!
|
//!
|
||||||
//! ## Status
|
//! * AEC / AGC / NS / HPF DSP chain (DEC-007/008/009/010 — Beta+
|
||||||
//!
|
//! work; the toggles in `AudioEffects` are honoured by *naming*
|
||||||
//! Scaffold only. PoC code is not promoted here yet.
|
//! but the filters are no-ops)
|
||||||
|
//! * Mobile audio paths (DEC-011.1 desktop + Android proven; this
|
||||||
|
//! integration is desktop-only for v0.2.0-beta.1)
|
||||||
|
//! * Hot-plug device-change handling
|
||||||
|
//! * Sample-rate adaptation if the device cannot do 48 kHz / mono in
|
||||||
|
//! the format we request (returns `AudioError::StreamConfig`)
|
||||||
|
//! * Multi-channel speaker layouts beyond stereo
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
|
||||||
|
pub use engine::{AudioEngine, AudioEngineConfig};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors raised by the audio subsystem.
|
/// Errors raised by the audio subsystem.
|
||||||
@@ -39,15 +46,20 @@ pub enum AudioError {
|
|||||||
/// The audio backend rejected a stream configuration.
|
/// The audio backend rejected a stream configuration.
|
||||||
#[error("stream config rejected: {0}")]
|
#[error("stream config rejected: {0}")]
|
||||||
StreamConfig(String),
|
StreamConfig(String),
|
||||||
|
/// Opus codec init/encode/decode failure.
|
||||||
|
#[error("opus: {0}")]
|
||||||
|
Opus(String),
|
||||||
/// A backend-specific failure surfaced without a typed mapping.
|
/// A backend-specific failure surfaced without a typed mapping.
|
||||||
/// Production code must narrow this further as failure modes
|
|
||||||
/// are catalogued.
|
|
||||||
#[error("audio backend: {0}")]
|
#[error("audio backend: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
|
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
|
||||||
/// DEC-008 (AGC), DEC-009 (NS), DEC-010 (HPF) — all enabled.
|
/// DEC-008 (AGC), DEC-009 (NS), DEC-010 (HPF) — all enabled.
|
||||||
|
///
|
||||||
|
/// Note: in Beta v0.2.0-beta.1 the actual DSP filters are not yet
|
||||||
|
/// implemented; the struct is kept here as the public API surface so
|
||||||
|
/// later work can flip an internal flag without breaking callers.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct AudioEffects {
|
pub struct AudioEffects {
|
||||||
/// Acoustic echo cancellation (DEC-007).
|
/// Acoustic echo cancellation (DEC-007).
|
||||||
|
|||||||
@@ -175,3 +175,52 @@ pub async fn is_connected() -> bool {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Audio commands (Beta) ----------
|
||||||
|
|
||||||
|
/// Start the audio engine on the active connection. Requires a
|
||||||
|
/// connection; idempotent (will replace any previous engine).
|
||||||
|
pub async fn start_audio() -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async {
|
||||||
|
session()
|
||||||
|
.start_audio(chanora_core::AudioEngineConfig::default())
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the push-to-talk state.
|
||||||
|
pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().set_ptt(active).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics from the audio engine.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BridgeAudioStats {
|
||||||
|
/// Number of Opus frames sent since audio started.
|
||||||
|
pub frames_sent: u32,
|
||||||
|
/// Number of inbound voice packets decoded.
|
||||||
|
pub frames_received: u32,
|
||||||
|
/// Current push-to-talk state.
|
||||||
|
pub ptt_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read audio statistics. Errors if no connection or audio not started.
|
||||||
|
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||||
|
let (s, r, p) = runtime()
|
||||||
|
.spawn(async { session().audio_stats().await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
||||||
|
Ok(BridgeAudioStats {
|
||||||
|
frames_sent: s,
|
||||||
|
frames_received: r,
|
||||||
|
ptt_active: p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 978717843;
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1944264248;
|
||||||
|
|
||||||
// Section: executor
|
// Section: executor
|
||||||
|
|
||||||
@@ -46,6 +46,41 @@ flutter_rust_bridge::frb_generated_default_handler!();
|
|||||||
|
|
||||||
// Section: wire_funcs
|
// Section: wire_funcs
|
||||||
|
|
||||||
|
fn wire__crate__api__audio_stats_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "audio_stats",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::audio_stats().await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__bridge_init_impl(
|
fn wire__crate__api__bridge_init_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -187,6 +222,42 @@ fn wire__crate__api__is_connected_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__set_ptt_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "set_ptt",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_active = <bool>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::set_ptt(api_active).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__snapshot_impl(
|
fn wire__crate__api__snapshot_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -222,6 +293,41 @@ fn wire__crate__api__snapshot_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__start_audio_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "start_audio",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::start_audio().await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Section: dart2rust
|
// Section: dart2rust
|
||||||
|
|
||||||
@@ -240,6 +346,20 @@ impl SseDecode for bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeAudioStats {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut var_framesSent = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_framesReceived = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_pttActive = <bool>::sse_decode(deserializer);
|
||||||
|
return crate::api::BridgeAudioStats {
|
||||||
|
frames_sent: var_framesSent,
|
||||||
|
frames_received: var_framesReceived,
|
||||||
|
ptt_active: var_pttActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::BridgeChannel {
|
impl SseDecode for crate::api::BridgeChannel {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -363,6 +483,13 @@ impl SseDecode for Vec<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for u32 {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
deserializer.cursor.read_u32::<NativeEndian>().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for u64 {
|
impl SseDecode for u64 {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -398,11 +525,14 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
) {
|
) {
|
||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
1 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
1 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||||
2 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
2 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
||||||
3 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
3 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
4 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
5 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
5 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
6 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
7 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
8 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +551,25 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||||||
|
|
||||||
// Section: rust2dart
|
// Section: rust2dart
|
||||||
|
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
[
|
||||||
|
self.frames_sent.into_into_dart().into_dart(),
|
||||||
|
self.frames_received.into_into_dart().into_dart(),
|
||||||
|
self.ptt_active.into_into_dart().into_dart(),
|
||||||
|
]
|
||||||
|
.into_dart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioStats {}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioStats>
|
||||||
|
for crate::api::BridgeAudioStats
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeAudioStats {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel {
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
@@ -518,6 +667,15 @@ impl SseEncode for bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeAudioStats {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<u32>::sse_encode(self.frames_sent, serializer);
|
||||||
|
<u32>::sse_encode(self.frames_received, serializer);
|
||||||
|
<bool>::sse_encode(self.ptt_active, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::BridgeChannel {
|
impl SseEncode for crate::api::BridgeChannel {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -615,6 +773,13 @@ impl SseEncode for Vec<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for u32 {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
serializer.cursor.write_u32::<NativeEndian>(self).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for u64 {
|
impl SseEncode for u64 {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ impl From<chanora_core::CoreError> for BridgeError {
|
|||||||
match e {
|
match e {
|
||||||
chanora_core::CoreError::NotConnected => BridgeError::NotConnected,
|
chanora_core::CoreError::NotConnected => BridgeError::NotConnected,
|
||||||
chanora_core::CoreError::AlreadyConnected => BridgeError::AlreadyConnected,
|
chanora_core::CoreError::AlreadyConnected => BridgeError::AlreadyConnected,
|
||||||
|
chanora_core::CoreError::AudioNotStarted => {
|
||||||
|
BridgeError::InvalidCommand("audio not started".to_string())
|
||||||
|
}
|
||||||
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
||||||
|
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
||||||
other => BridgeError::Unmapped(format!("{other}")),
|
other => BridgeError::Unmapped(format!("{other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
# tsclientlib is git-only and not on crates.io. Audio feature disabled
|
# tsclientlib is git-only and not on crates.io. The "audio" feature
|
||||||
# because chanora_audio owns audio paths; the protocol crate only
|
# pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream
|
||||||
# handles connection lifecycle + state book events.
|
# examples; the library itself does not link SDL.
|
||||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls", "audio"] }
|
||||||
|
|
||||||
|
# tsproto_packets exposes OutAudio / InAudioBuf / AudioData /
|
||||||
|
# CodecType / Direction. Pinning to the same git rev as tsclientlib
|
||||||
|
# avoids any version-skew confusion.
|
||||||
|
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
|
||||||
|
|
||||||
# Async runtime utilities used by the connection task.
|
# Async runtime utilities used by the connection task.
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
//! initial state snapshot is ready.
|
//! initial state snapshot is ready.
|
||||||
//! * Snapshot reads are served by sending a request over an
|
//! * Snapshot reads are served by sending a request over an
|
||||||
//! `mpsc::channel`; the task replies on a `oneshot` per request.
|
//! `mpsc::channel`; the task replies on a `oneshot` per request.
|
||||||
|
//! * Outbound voice packets are submitted via a separate mpsc;
|
||||||
|
//! inbound voice packets are forwarded out via a broadcast channel
|
||||||
|
//! so multiple sinks (recorder, audio mixer, …) can subscribe.
|
||||||
//! * Disconnect is requested via a `oneshot`; the task drains
|
//! * Disconnect is requested via a `oneshot`; the task drains
|
||||||
//! `tsclientlib`'s outbound events and exits.
|
//! `tsclientlib`'s outbound events and exits.
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ use tsclientlib::data::{self, Channel, Client};
|
|||||||
use tsclientlib::{
|
use tsclientlib::{
|
||||||
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
|
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
|
||||||
};
|
};
|
||||||
|
use tsproto_packets::packets::{InAudioBuf, OutPacket};
|
||||||
|
|
||||||
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
|
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
|
||||||
use crate::ProtocolError;
|
use crate::ProtocolError;
|
||||||
@@ -67,6 +71,21 @@ enum Request {
|
|||||||
/// Async handle owning a live protocol connection. Drop = disconnect.
|
/// Async handle owning a live protocol connection. Drop = disconnect.
|
||||||
pub struct ProtocolClient {
|
pub struct ProtocolClient {
|
||||||
tx: mpsc::Sender<Request>,
|
tx: mpsc::Sender<Request>,
|
||||||
|
/// Submit outbound voice packets here. Built by `chanora_audio`
|
||||||
|
/// via [`Self::voice_out`].
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
/// Inbound voice packets land here. Consumed by `chanora_audio`.
|
||||||
|
/// Wrapped in a `Mutex<Option<_>>` so the consumer can take it
|
||||||
|
/// exactly once.
|
||||||
|
voice_in_rx: std::sync::Mutex<Option<mpsc::Receiver<InboundVoice>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One inbound voice packet from a remote client.
|
||||||
|
pub struct InboundVoice {
|
||||||
|
/// The remote client this audio came from.
|
||||||
|
pub from_client: u64,
|
||||||
|
/// Raw packet bytes for `AudioHandler::handle_packet`.
|
||||||
|
pub packet: InAudioBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolClient {
|
impl ProtocolClient {
|
||||||
@@ -82,12 +101,24 @@ impl ProtocolClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Request>(8);
|
let (tx, rx) = mpsc::channel::<Request>(8);
|
||||||
|
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
||||||
|
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
|
||||||
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
|
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
|
||||||
|
|
||||||
tokio::spawn(connection_task(cfg.clone(), rx, ready_tx));
|
tokio::spawn(connection_task(
|
||||||
|
cfg.clone(),
|
||||||
|
rx,
|
||||||
|
voice_out_rx,
|
||||||
|
voice_in_tx,
|
||||||
|
ready_tx,
|
||||||
|
));
|
||||||
|
|
||||||
match tokio::time::timeout(cfg.ready_timeout, ready_rx).await {
|
match tokio::time::timeout(cfg.ready_timeout, ready_rx).await {
|
||||||
Ok(Ok(Ok(()))) => Ok(Self { tx }),
|
Ok(Ok(Ok(()))) => Ok(Self {
|
||||||
|
tx,
|
||||||
|
voice_out_tx,
|
||||||
|
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||||
|
}),
|
||||||
Ok(Ok(Err(e))) => Err(e),
|
Ok(Ok(Err(e))) => Err(e),
|
||||||
Ok(Err(_)) => Err(ProtocolError::Backend(
|
Ok(Err(_)) => Err(ProtocolError::Backend(
|
||||||
"connection task exited before signalling ready".to_string(),
|
"connection task exited before signalling ready".to_string(),
|
||||||
@@ -114,11 +145,24 @@ impl ProtocolClient {
|
|||||||
let _ = rx.await;
|
let _ = rx.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sender for outbound voice packets. Clone freely.
|
||||||
|
pub fn voice_out(&self) -> mpsc::Sender<OutPacket> {
|
||||||
|
self.voice_out_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the inbound-voice receiver. Returns `None` if it has
|
||||||
|
/// already been taken; only one consumer is allowed.
|
||||||
|
pub fn take_voice_in(&self) -> Option<mpsc::Receiver<InboundVoice>> {
|
||||||
|
self.voice_in_rx.lock().ok().and_then(|mut g| g.take())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connection_task(
|
async fn connection_task(
|
||||||
cfg: ConnectConfig,
|
cfg: ConnectConfig,
|
||||||
mut rx: mpsc::Receiver<Request>,
|
mut rx: mpsc::Receiver<Request>,
|
||||||
|
mut voice_out_rx: mpsc::Receiver<OutPacket>,
|
||||||
|
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||||
) {
|
) {
|
||||||
let mut builder = Connection::build(cfg.address.clone()).name(cfg.nickname.clone());
|
let mut builder = Connection::build(cfg.address.clone()).name(cfg.nickname.clone());
|
||||||
@@ -200,19 +244,38 @@ async fn connection_task(
|
|||||||
|
|
||||||
let _ = ready_tx.send(Ok(()));
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
// Request loop with a continuously-pumped event stream. We pump
|
// Main loop: pump events, service requests, forward voice.
|
||||||
// one event at a time, then check for one pending request, then
|
|
||||||
// repeat. This avoids holding a borrow on `con` across an await
|
|
||||||
// boundary in `tokio::select!`.
|
|
||||||
loop {
|
loop {
|
||||||
// Try to advance the event stream by one event with a small
|
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||||
// timeout. Errors are logged; stream end is fatal.
|
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||||
|
if let Err(e) = con.send_audio(pkt) {
|
||||||
|
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Advance event stream by at most one event with a small timeout.
|
||||||
let pump = async {
|
let pump = async {
|
||||||
let mut ev_stream = con.events();
|
let mut ev_stream = con.events();
|
||||||
tokio::time::timeout(Duration::from_millis(50), ev_stream.next()).await
|
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||||
};
|
};
|
||||||
match pump.await {
|
match pump.await {
|
||||||
Ok(Some(Ok(_))) => { /* event consumed */ }
|
Ok(Some(Ok(item))) => {
|
||||||
|
if let StreamItem::Audio(buf) = item {
|
||||||
|
// Extract `from` client id then forward.
|
||||||
|
let from = packet_sender_id(&buf);
|
||||||
|
if let Some(from) = from {
|
||||||
|
if voice_in_tx
|
||||||
|
.try_send(InboundVoice {
|
||||||
|
from_client: from,
|
||||||
|
packet: buf,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Subscriber is too slow or absent; drop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Some(Err(e))) => {
|
Ok(Some(Err(e))) => {
|
||||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||||
}
|
}
|
||||||
@@ -220,11 +283,10 @@ async fn connection_task(
|
|||||||
warn!(target: "chanora_protocol", "event stream ended");
|
warn!(target: "chanora_protocol", "event stream ended");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(_) => { /* no event in 50 ms — service requests */ }
|
Err(_) => { /* no event in 20 ms */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service at most one request (non-blocking) so we keep
|
// 3. Service at most one control request (non-blocking).
|
||||||
// pumping events too.
|
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(Request::Snapshot(reply)) => {
|
Ok(Request::Snapshot(reply)) => {
|
||||||
let snap = build_snapshot(&con);
|
let snap = build_snapshot(&con);
|
||||||
@@ -237,7 +299,7 @@ async fn connection_task(
|
|||||||
info!(target: "chanora_protocol", "clean disconnect");
|
info!(target: "chanora_protocol", "clean disconnect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TryRecvError::Empty) => { /* nothing to do */ }
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
con.events().for_each(|_| future::ready(())).await;
|
con.events().for_each(|_| future::ready(())).await;
|
||||||
@@ -248,6 +310,16 @@ async fn connection_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the originating `client_id` from an inbound voice packet.
|
||||||
|
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
|
||||||
|
use tsproto_packets::packets::AudioData;
|
||||||
|
match buf.data().data() {
|
||||||
|
AudioData::S2C { from, .. } => Some(*from as u64),
|
||||||
|
AudioData::S2CWhisper { from, .. } => Some(*from as u64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
|
fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
|
||||||
let state: &data::Connection = con
|
let state: &data::Connection = con
|
||||||
.get_state()
|
.get_state()
|
||||||
@@ -299,7 +371,6 @@ fn sanitize(s: &str) -> String {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const _ROOT_MATCHES_UPSTREAM: () = {
|
const _ROOT_MATCHES_UPSTREAM: () = {
|
||||||
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
|
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
|
||||||
// also considers the root. If upstream ever changes, this stops
|
// also considers the root.
|
||||||
// compiling and forces an audit.
|
|
||||||
let _ = TsChannelId(0);
|
let _ = TsChannelId(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,9 +27,18 @@
|
|||||||
mod adapter;
|
mod adapter;
|
||||||
mod dto;
|
mod dto;
|
||||||
|
|
||||||
pub use adapter::{ConnectConfig, ProtocolClient};
|
pub use adapter::{ConnectConfig, InboundVoice, ProtocolClient};
|
||||||
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
||||||
|
|
||||||
|
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||||
|
// voice packets without taking a direct dependency on tsclientlib /
|
||||||
|
// tsproto_packets. Per SAD-067 this is the *one* deliberate
|
||||||
|
// re-export: the audio path is performance-sensitive and a parallel
|
||||||
|
// type hierarchy would force copies for every 20 ms frame.
|
||||||
|
pub use tsproto_packets::packets::{
|
||||||
|
AudioData, CodecType, Direction, InAudioBuf, OutAudio, OutPacket,
|
||||||
|
};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors surfaced by the protocol adapter. None of these expose
|
/// Errors surfaced by the protocol adapter. None of these expose
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# PoC Results Summary
|
# PoC Results Summary
|
||||||
|
|
||||||
**Document type:** Governance / PoC Results Summary
|
**Document type:** Governance / PoC Results Summary
|
||||||
**Version:** 0.5.0
|
**Version:** 0.6.0
|
||||||
**Status:** Draft
|
**Status:** Draft
|
||||||
**Language:** English
|
**Language:** English
|
||||||
**Product:** Chanora
|
**Product:** Chanora
|
||||||
@@ -135,7 +135,7 @@ DEC-012 legal/trademark/licensing review remains a release-gating
|
|||||||
| RISK-PoC-002 | Windows / macOS / iOS / Android secure-storage adapters not implemented. SS-TC-001/002/004/005 unverified. | Platform Owners | Per-platform adapter spike or first-implementation-in-`chanora_storage` with the audit checks re-run on each target. |
|
| RISK-PoC-002 | Windows / macOS / iOS / Android secure-storage adapters not implemented. SS-TC-001/002/004/005 unverified. | Platform Owners | Per-platform adapter spike or first-implementation-in-`chanora_storage` with the audit checks re-run on each target. |
|
||||||
| RISK-PoC-003 | ~~License (DEC-020) deferred. Blocks public/store release.~~ **CLOSED 2026-05-14.** DEC-020 Accepted as Apache-2.0 OR MIT dual-license; texts present in repository root. Release-gating legal review under DEC-012 remains pending as a separate *work* item, but no longer a license-choice blocker. | Product Owner + Legal | Closed. |
|
| RISK-PoC-003 | ~~License (DEC-020) deferred. Blocks public/store release.~~ **CLOSED 2026-05-14.** DEC-020 Accepted as Apache-2.0 OR MIT dual-license; texts present in repository root. Release-gating legal review under DEC-012 remains pending as a separate *work* item, but no longer a license-choice blocker. | Product Owner + Legal | Closed. |
|
||||||
| RISK-PoC-004 | ~~DEC-001..012, 015..019, 021 still in Proposed status.~~ **CLOSED 2026-05-14.** All 17 decisions were owner-reviewed; statuses recorded in the register at v0.9.5. | Product Owner | Closed. |
|
| RISK-PoC-004 | ~~DEC-001..012, 015..019, 021 still in Proposed status.~~ **CLOSED 2026-05-14.** All 17 decisions were owner-reviewed; statuses recorded in the register at v0.9.5. | Product Owner | Closed. |
|
||||||
| RISK-PoC-005 | Production code does not exist yet. README's "Implementation status: Not production-ready" remains accurate. | Software Architect | **Partially closed 2026-05-14.** Product scaffold landed (b628d2d); first Alpha build wired end-to-end (3bb038c, tag v0.1.0-alpha.1). Audio integration and full UX remain. |
|
| RISK-PoC-005 | Production code does not exist yet. README's "Implementation status: Not production-ready" remains accurate. | Software Architect | **Further progress 2026-05-14.** Internal Alpha (`v0.1.0-alpha.1`, 3bb038c) wired the connect/snapshot/disconnect cycle. **Internal Beta (`v0.2.0-beta.1`) reached the same day** with voice in/out: `chanora_audio` promoted from scaffold; PTT, Opus encode, decode + jitter buffer + mix all wired through to Flutter. README's status line remains accurate (not production-ready) but is now genuinely close to dogfoodable. |
|
||||||
| RISK-PoC-006 | **DEC-004 Android minimum was raised to API 28 from the spike's `minSdk = 24`.** The Android spike still builds and runs; product code in `apps/chanora_flutter` must move `minSdk` to 28 and may simplify its AAudio fallback logic accordingly. | Android Owner | Set `minSdk = 28` when the Android target is added to `apps/chanora_flutter`. |
|
| RISK-PoC-006 | **DEC-004 Android minimum was raised to API 28 from the spike's `minSdk = 24`.** The Android spike still builds and runs; product code in `apps/chanora_flutter` must move `minSdk` to 28 and may simplify its AAudio fallback logic accordingly. | Android Owner | Set `minSdk = 28` when the Android target is added to `apps/chanora_flutter`. |
|
||||||
| RISK-PoC-007 | **DEC-015 expanded the MVP language scope from English-only to English + Chinese (Simplified).** Adds zh-Hans translation, font, and design-system text-length-budget work to MVP. | Product Owner + i18n Owner | Land en + zh-Hans message catalogues in `chanora_flutter/lib/i18n/` at scaffolding time; verify Material 3 design tokens accommodate CJK text metrics. |
|
| RISK-PoC-007 | **DEC-015 expanded the MVP language scope from English-only to English + Chinese (Simplified).** Adds zh-Hans translation, font, and design-system text-length-budget work to MVP. | Product Owner + i18n Owner | Land en + zh-Hans message catalogues in `chanora_flutter/lib/i18n/` at scaffolding time; verify Material 3 design tokens accommodate CJK text metrics. |
|
||||||
|
|
||||||
@@ -155,3 +155,4 @@ without an explicit promotion record per spike.
|
|||||||
| 0.3.0 | 2026-05-14 | Recorded the owner-confirmation pass on the 17 remaining Proposed decisions (register at v0.9.5). RISK-PoC-004 closed. Added RISK-PoC-006 (Android `minSdk` 24 → 28) and RISK-PoC-007 (MVP language expanded to English + Chinese Simplified) for the two decisions that diverged from the original recommendations. DEC-020 license remains the sole open release-gating decision. |
|
| 0.3.0 | 2026-05-14 | Recorded the owner-confirmation pass on the 17 remaining Proposed decisions (register at v0.9.5). RISK-PoC-004 closed. Added RISK-PoC-006 (Android `minSdk` 24 → 28) and RISK-PoC-007 (MVP language expanded to English + Chinese Simplified) for the two decisions that diverged from the original recommendations. DEC-020 license remains the sole open release-gating decision. |
|
||||||
| 0.4.0 | 2026-05-14 | DEC-020 license closed as Apache-2.0 OR MIT dual-license (register v0.9.6). RISK-PoC-003 closed. No remaining open decisions; the only release-gating activity outstanding is the DEC-012 legal review *work*, which is sign-off rather than an architectural choice. |
|
| 0.4.0 | 2026-05-14 | DEC-020 license closed as Apache-2.0 OR MIT dual-license (register v0.9.6). RISK-PoC-003 closed. No remaining open decisions; the only release-gating activity outstanding is the DEC-012 legal review *work*, which is sign-off rather than an architectural choice. |
|
||||||
| 0.5.0 | 2026-05-14 | Internal Alpha build reached. `poc/tsclientlib-connect-spike` promoted into `crates/chanora_protocol`; `core/chanora_core::ChanoraSession` wires the typed protocol API; `crates/chanora_bridge` exposes the FRB 2.12.0 boundary; `apps/chanora_flutter` runs the connect → snapshot → disconnect cycle end-to-end against `cn.teamspeak.app`. Verified by `apps/chanora_flutter/test/alpha_e2e_test.dart` + `core/chanora_core/tests/alpha_smoke.rs`. Tag: `v0.1.0-alpha.1` (commit 3bb038c). RISK-PoC-005 partially closed. |
|
| 0.5.0 | 2026-05-14 | Internal Alpha build reached. `poc/tsclientlib-connect-spike` promoted into `crates/chanora_protocol`; `core/chanora_core::ChanoraSession` wires the typed protocol API; `crates/chanora_bridge` exposes the FRB 2.12.0 boundary; `apps/chanora_flutter` runs the connect → snapshot → disconnect cycle end-to-end against `cn.teamspeak.app`. Verified by `apps/chanora_flutter/test/alpha_e2e_test.dart` + `core/chanora_core/tests/alpha_smoke.rs`. Tag: `v0.1.0-alpha.1` (commit 3bb038c). RISK-PoC-005 partially closed. |
|
||||||
|
| 0.6.0 | 2026-05-14 | **Internal Beta build reached** (same day as Alpha). `poc/audio-capture-playback-spike` promoted into `crates/chanora_audio`: cpal capture/playback + `audiopus` Opus encode + `tsclientlib::audio::AudioHandler` decode/jitter/mix. `chanora_protocol` extended with voice-out mpsc and voice-in mpsc; `chanora_core` adds `start_audio` / `set_ptt` / `audio_stats`. `chanora_bridge` adds matching DTOs (`BridgeAudioStats`). Flutter UI gains "Start audio" + hold-to-talk PTT + live frame counters. Verified end-to-end on `cn.teamspeak.app` by `apps/chanora_flutter/test/beta_e2e_test.dart`. Tag: `v0.2.0-beta.1`. Capture runs gracefully in playback-only mode on hosts with no usable microphone. |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
# CHANORA_CFG_Product_Decision_Register_v0.9.6.0.0
|
# CHANORA_CFG_Product_Decision_Register_v0.9.7.0.0
|
||||||
|
|
||||||
**Document type:** Configuration / Product Decision Register
|
**Document type:** Configuration / Product Decision Register
|
||||||
**Version:** 0.9.6
|
**Version:** 0.9.7
|
||||||
**Status:** Baseline Candidate
|
**Status:** Baseline Candidate
|
||||||
**Language:** English
|
**Language:** English
|
||||||
**Product:** Chanora
|
**Product:** Chanora
|
||||||
@@ -189,3 +189,10 @@ release but is not an open decision:
|
|||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.9.6 | 2026-05-14 | DEC-020 license model closed: **Apache-2.0 OR MIT** dual-license (standard Rust-ecosystem permissive model). The license is compatible with every direct dependency in the PoC tree (tsclientlib, flutter_rust_bridge, cpal, rusqlite, keyring, hound, etc.) and with the Flutter framework's BSD-3-Clause. License texts added as `LICENSE-APACHE` and `LICENSE-MIT` at the repository root; the existing `LICENSE` file now aggregates both with the dual-license declaration and the standard Apache-2.0 inbound-contribution clause. `NOTICE` populated with current direct-dependency attributions. README §License rewritten. §4 updated. §6 collapsed: there is no longer any open decision — DEC-012 legal review remains a pending *work* item, not a pending decision. With this change, every previously-Proposed or Open decision in the register has been resolved; the only outstanding release-gating activity is the DEC-012 legal review itself (which is sign-off work, not an architectural choice). |
|
| 0.9.6 | 2026-05-14 | DEC-020 license model closed: **Apache-2.0 OR MIT** dual-license (standard Rust-ecosystem permissive model). The license is compatible with every direct dependency in the PoC tree (tsclientlib, flutter_rust_bridge, cpal, rusqlite, keyring, hound, etc.) and with the Flutter framework's BSD-3-Clause. License texts added as `LICENSE-APACHE` and `LICENSE-MIT` at the repository root; the existing `LICENSE` file now aggregates both with the dual-license declaration and the standard Apache-2.0 inbound-contribution clause. `NOTICE` populated with current direct-dependency attributions. README §License rewritten. §4 updated. §6 collapsed: there is no longer any open decision — DEC-012 legal review remains a pending *work* item, not a pending decision. With this change, every previously-Proposed or Open decision in the register has been resolved; the only outstanding release-gating activity is the DEC-012 legal review itself (which is sign-off work, not an architectural choice). |
|
||||||
|
|
||||||
|
|
||||||
|
## Baseline Candidate 0.9.7 Update
|
||||||
|
|
||||||
|
| Version | Date | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.9.7 | 2026-05-14 | DEC-001 release-sequence progress recorded: Internal Alpha (`v0.1.0-alpha.1`, commit 3bb038c) completed on 2026-05-14; **Internal Beta first build (`v0.2.0-beta.1`)** reached the same day. Beta milestone adds voice in/out: `crates/chanora_audio` promoted from scaffold to a cpal-based capture + playback engine with `audiopus` Opus encoding and tsclientlib `AudioHandler` for decode + jitter buffer + mix; `crates/chanora_protocol` extended to forward inbound voice packets and accept outbound `OutPacket`s via mpsc channels; `core/chanora_core::ChanoraSession` exposes `start_audio`, `set_ptt`, and `audio_stats`; `crates/chanora_bridge` adds matching DTOs; the Flutter UI gains a "Start audio" action and a hold-to-talk PTT button with live frame counters. Verified end-to-end against `cn.teamspeak.app`; capture runs in graceful playback-only mode on hosts with no usable microphone (e.g. the PipeWire `auto_null` source on the verification host). No decision rows change; this entry documents progress against DEC-001 only. |
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ rust_input: crate::api
|
|||||||
rust_root: crates/chanora_bridge/
|
rust_root: crates/chanora_bridge/
|
||||||
dart_output: apps/chanora_flutter/lib/src/rust
|
dart_output: apps/chanora_flutter/lib/src/rust
|
||||||
rust_output: crates/chanora_bridge/src/frb_generated.rs
|
rust_output: crates/chanora_bridge/src/frb_generated.rs
|
||||||
|
local: true
|
||||||
|
|||||||
Reference in New Issue
Block a user