Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is
not stopped while Chanora is idle and the in-call session does not get
clobbered by media-server resets unrelated to voice.
AppDelegate.swift
- Set .ambient + .mixWithOthers as the idle baseline so the app does not
hold a VoiceChat session when no call is active.
- Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers
on demand via the new chanora/ios_audio_session MethodChannel, and
revert to .ambient on deactivate.
- Gate the media-services-reset rebuild on voiceSessionActive so a
stray reset during idle no longer reactivates VoiceChat.
ios_audio_session_controller.dart (new)
- Thin Dart wrapper around chanora/ios_audio_session with activate/
deactivate; no-op on non-iOS; swallows PlatformException to keep
audio start/stop resilient to platform-side races.
main.dart
- Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate
on BridgeEvent_AudioStopped, fire-and-forget via unawaited().
ios_voice_unit.rs
- Add the 10 local bindings required by the render-callback closure
preamble (wav_recorder_for_render, render_recorder_active,
render_ref_len, render_ref_accum, cb_count, last_num_frames,
num_frames_changes, callbacks_with_audio, callbacks_with_silence)
so the iOS target compiles cleanly with the new lifecycle wiring.
Tests
- 5 unit tests in test/services/ios_audio_session_controller_test.dart
cover activate/deactivate on iOS, no-op on non-iOS, and graceful
PlatformException handling.
Docs
- SRS SRS-110 expanded to cover the call-scoped lifecycle invariant.
- SysDes mobile-voice row updated to reflect the MethodChannel and
.ambient idle baseline.
- implementation-status-2026-05-28 voiceChat row flipped to done.
Verification
- flutter analyze: No issues found (2.8s)
- flutter test: 195 passed / 2 skipped / 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
- cargo build -p chanora_audio (macOS host): clean
Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call,
revert-on-call-end, media-services-reset-during-idle) remains pending
on physical hardware.
- _showLocalNetworkDeniedSnackBar: wrap Process.run with unawaited()
and .catchError() so a rejected future (e.g. macOS sandbox refuses
fork, or 'open' is missing) cannot bubble into the Flutter zone
as an unhandled exception. The synchronous try/catch was a no-op
because Process.run only throws asynchronously.
- macos_permissions_service_test.dart: mirror the
triggerLocalNetworkPrompt error-handling test with one for
checkLocalNetworkAccess. Probe-path failures (NWConnection probe
cannot establish, or Swift side throws) must fall back to cached
state without crashing the caller.
Tests: 186 passed, 2 skipped. Dart analyze clean.
Oracle re-review on PR #30 flagged that _ChatDetailViewState only
called onDraftChanged when _textCtl.text was non-empty. The empty
case is load-bearing: if the user restored a saved draft, deleted
the text, then switched target (or closed the panel), the parent's
draft map kept the stale entry and resurrected it on the next swap.
Fix: call onDraftChanged unconditionally in both didUpdateWidget
(target change) and dispose (tear-down), so the parent map learns
when a draft is now empty.
Adds a regression test exercising the restore-clear-swap sequence.
- ViewportInfo.updateShouldNotify: compare layoutClass only
(not width/height), avoiding unnecessary rebuilds on every
resize frame within the same layout class.
- ChatPanel: use BorderDirectional(start:) for RTL support.
- ChatPanel: localize 'Close chat' tooltip via AppL10n.chatCloseAction.
- Inline panel snackbar: localize via AppL10n.chatPanelCollapsedHint.
New en/zh ARB entries added.
- _saveCurrentDraft(): removed — it was a self-assignment no-op.
Draft persistence relies on ChatDetailView's didUpdateWidget
(fires onDraftChanged on target switch) and dispose (fires on
panel tear-down), both of which already populate _chatDrafts
correctly without an explicit save call.
- _handleInlineChatViewport layout snackbar: use AppL10n.
- Audio level-meter: switch from callback-count (% 3) to time-based
gating (std::time::Duration::from_millis(33)), robust to cpal
buffer-size or sample-rate changes. Remove level_decimation_counter.
- chat_panel_test.dart: add AppL10n.localizationsDelegates so the
test resolves l10n keys.
Tests: 183 passed, 2 skipped. Dart analyze clean.
cargo test -p chanora_audio --lib: 125 passed.
Oracle re-review nit on PR #27: cite Apple's App Sandbox semantics
explicitly. The macOS sandbox classifies any UDP bind() against a
local port as a 'server' operation (covered by network.server),
even when the socket is only used to sendto() a remote peer. This
is the bind()-then-sendto() pattern tokio's UdpSocket uses
internally for tsclientlib's outbound voice traffic. Correct the
sandbox log line to the actual deny string ('Sandbox: ... deny(1)
network-bind') and reference Apple's entitlement reference wording.
- ios_voice_unit.rs: add producer_shutdown AtomicBool flag (macOS only).
The macOS start path spawns a tokio producer task that holds clones
of Arc<Mutex<AudioHandler>>, Arc<ArrayQueue<f32>>, and the output
gain/muted atomics, then loops on a 20 ms tokio interval. Without
a shutdown signal the task runs forever on engine stop/restart and
leaks all four Arcs every cycle. Drop now stores 'true' on the
flag; the producer checks it at the top of each tick and exits,
releasing its clones within at most one 20 ms tick.
- macos/Runner/Release.entitlements: strengthen the existing
justification comment for com.apple.security.network.server.
Document the specific failure mode (tokio::net::UdpSocket::bind
-> sandbox 'network-outbound deny' -> EPERM) and explain why
network.client alone does not cover bind()-then-sendto. The
entitlement is required, not over-broad.
cargo check (host + aarch64-apple-darwin): clean
cargo test -p chanora_audio --lib: 133 passed
flutter test: 186 passed, 2 skipped
dart analyze: clean
The PR #27 refactor moved the iOS render callback to a direct-fill
path with its own peak_i16 == 0 check, dropping the !muted gate
that PR #28 added to the pre-refactor callback. Without this
gate, every muted callback fires a false-positive
increment_output_underrun() because the downmix helper writes
silence (peak_i16 = 0) by design when muted.
Re-apply PR #28's gate to the refactored iOS path so this PR does
not silently reintroduce the bug PR #28 was opened to fix.
Verified:
- cargo test -p chanora_audio --lib: 133 passed, 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
Inventory regenerated after refactor(audio): share AudioHandler between iOS and macOS, bump deps (6214139), which dropped windows-core 0.54.0 (and its windows-result 0.1.2 transitive dep). Apache-2.0 crate count drops from 333 to 327. No license-class change.
apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift (new): native MethodChannel handler for chanora/macos_audio_lifecycle. Observes Core Audio HAL default-input and default-output device property changes via AudioObjectAddPropertyListener; posts handleDefaultDeviceChange events with {role: input|output} payload. Mirrors the iOS chanora/ios_audio_lifecycle event surface minus the AVAudioSession-specific events (no interruption / no media services reset equivalents on macOS — no AVAudioSession).
apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift: register MacOSAudioLifecycle next to MacOSPermissionsHandler in awakeFromNib. Closes the iOS/macOS asymmetry noted in SysRS-051.
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: add wireMacosAudioLifecycle() parallel to wireIosAudioLifecycle() / wireAndroidAudioLifecycle(). The current implementation captures and logs the events; the FRB function that triggers a VPIO re-bind on the engine is a follow-up. Event-shape mirrors the iOS side so a future caller can switch on platform without changing the dispatch shape.
apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: smoke test for wireMacosAudioLifecycle (4 tests pass, including the new one).
docs/architecture/sdd.md: three new rows in the Audio Detailed Design table. 'VoiceActivity gate (capture-side)' documents voice_activity::VoiceActivityStateMachine — the 10 ms-cadence gate for TransmitMode::VoiceActivity with open-after (40 ms) / hangover (500 ms) / min-tx (200 ms) / weak-hold (30-100 frames) timers, plus live configure() re-clamping behaviour. 'macOS render cadence (producer + ring)' documents the 20 ms tokio producer task + crossbeam ArrayQueue ring with 100 ms prebuffer, the design chosen to decouple ingress quantums (20 ms Opus frames) from egress quantums (whatever VPIO asks for). 'iOS render cadence (direct-fill)' documents the direct-fill callback path with preallocated 4096x2 f32 scratch buffer and try_lock semantics (not blocking lock).
Closes the VoiceActivity / macOS-producer-ring / iOS-direct-fill doc gaps flagged in the PR #27 'Deferred' list.
apps/chanora_flutter/macos/Runner/Release.entitlements: add com.apple.security.network.server = true. The macOS App Sandbox treats every UDP bind() — including the ephemeral 0.0.0.0:0 that tsclientlib uses for outbound TS3 traffic — as a server operation. Without this entitlement UdpSocket::bind fails with EPERM and the TS3 connect never starts. Debug builds already had this entitlement (needed for flutter run hot-reload); release builds were missing it.
apps/chanora_flutter/macos/Runner/DebugProfile.entitlements: expand the existing network.server comment to document the dual rationale (flutter hot-reload + outbound UDP bind), so the entitlement's purpose is clear without spelunking through tsclientlib.
crates/chanora_audio/src/engine.rs: drop the macOS-specific event-queue producer/consumer path; macOS now uses the iOS-style direct AudioHandler::fill_buffer in the VPIO render callback. The shared AudioHandler is an Arc<Mutex<...>>; the realtime callback uses try_lock so it never blocks on the tokio decode task (see ios_voice_unit.rs render callback).
crates/chanora_audio/src/mobile_voice_backend.rs: update VoiceAudioParams cfg gates — handler is now the iOS/macOS/desktop shape (Arc<Mutex<AudioHandler<SessionAudioId>>>), event_producer is Android-only.
crates/chanora_audio/src/lib.rs: widen the audio_event_queue module visibility to test so the macOS-specific path can be exercised by the unit test suite.
Cargo.toml: bump cpal 0.17.3 -> 0.18.0, jni 0.21 -> 0.22.4, windows 0.54 -> 0.62, criterion 0.5 -> 0.8. Cargo.lock follows.
- docs/sysrs.md: raised macOS minimum runtime in SysRS-310 from 10.15 to 13.0 to match the actual floor in apps/chanora_flutter/macos/chanora_bridge.podspec (MACOSX_DEPLOYMENT_TARGET = 13.0) and macos_deployment_target.rb; added a change-log entry for the raise. SysRS-051 gained a note documenting the iOS/macOS audio-lifecycle asymmetry (iOS has full AVAudioSession lifecycle; macOS is limited to launch-time mic permission + VPIO engine restart on default-device change + VPIO startup readback in the current baseline).
- docs/architecture/sdd.md: added two rows to the Audio Detailed Design table. 'Render peak limiter' documents voice_render::limit_peak_inplace (single-pass, allocation-free, threshold 0.99, applied in both Apple render callbacks before i16 downmix). 'VPIO ducking config (macOS 14+)' documents the 8-byte AuVoiceIoOtherAudioDuckingConfiguration struct write to selector 2108 on the VoiceProcessingIO AudioUnit at startup, with the macOS 13 silent-fallback behaviour.
- docs/governance/product-decision-register.md: added DEC-033 recording the VPIO ducking configuration decision (advanced ducking off, level = Min, macOS 14+ only).
voice_render.rs: new limit_peak_inplace helper (single-pass, allocation-free peak scaler) with 4 unit tests. Applied in both the macOS and iOS render callbacks before the i16 conversion to prevent hard clipping on multi-client mixes that sum past 0 dBFS. Threshold 0.99 keeps the limiter transparent for normal voice levels (sub-millisecond per-frame latency at 48 kHz; pumping risk negligible for speech).
ios_voice_unit.rs: limit_peak_inplace call sites added to the macOS producer/ring render callback (per-frame, 2-element stack array) and the iOS direct-fill_buffer render callback (per-callback, on the scratch_stereo buffer); the downmix helper's hard-clamp is kept as defense-in-depth and is not expected to engage in the integrated flow.
- ios_voice_unit.rs:856-868: Revert iOS render callback from blocking
lock() back to try_lock() with silence-on-contention (WouldBlock
branch increments callback_xrun stat and returns the pre-zeroed
scratch buffer). Blocking lock() inside the CoreAudio HAL render
callback can stall the realtime IO thread when the decode task on
engine.rs:1309 holds the same AudioHandler Mutex, re-introducing
the underrun pattern this codebase already fixed elsewhere.
- ios_voice_unit.rs:782: Preallocate scratch_stereo to Apple's VPIO
MaximumFramesPerSlice (4096 frames * 2 channels = 8192 f32) at
setup time, so the realtime render callback never grows the Vec
via resize(). The defensive 'len() < needed' branch is kept for
the (impossible) case that the audio unit later raises max frames.
- main.dart:52-56: Gate _showAudioDebugOverlay behind kDebugMode &&
_isMacOS so the internal audio stats panel does not ship in
release builds. kDebugMode is a Dart compile-time const, so the
overlay subtree is tree-shaken out of release/profile binaries.
- Rebuilt macOS chanora_bridge.framework binary (universal arm64 +
x86_64) from the fixed source with the new CARGO_PROFILE_RELEASE_*
env vars (DWARF preserved for dsymutil). install_name patched back
to @rpath/chanora_bridge.framework/Versions/A/chanora_bridge.
Verified:
cargo test -p chanora_audio --lib: 129 passed, 0 failed
cargo check -p chanora_audio --target aarch64-apple-ios: clean
dart analyze lib/main.dart: no issues
xcrun lipo -archs: x86_64 arm64
xcrun otool -D: @rpath install_name preserved
macOS realtime audio was suffering buffer underruns on CoreAudio's VPIO
output callback. Root cause was twofold: AudioHandler was decoded under
a Mutex held across the realtime callback, and the render path
hard-coded mono i16 output regardless of the channel count the
callback actually exposed (CoreAudio occasionally hands the callback
stereo or quad output buffers, in which case writing only every Nth
sample produced silence + clicks).
This change brings macOS in line with the lock-free Android audio
architecture introduced for output stutter elimination:
* chanora_audio: AudioPacket / AudioCommand / AudioEventQueue
(previously gated to `target_os = "android"`) are now compiled on
macOS too. The decode loop in AudioEngine pushes inbound packets
into the queue; the VPIO render callback owns AudioHandler outright
and drains the queue, so the realtime thread never blocks on a
cross-thread mutex. set_client_volume also routes through the
command queue on macOS instead of locking the handler.
* voice_render.rs: new downmix_stereo_f32_to_interleaved_i16 helper
downmixes stereo f32 from AudioHandler to mono i16 and replicates
that mono sample across every output channel the callback exposes.
The existing downmix_stereo_f32_to_mono_i16 helper is retained for
iOS, where VPIO is reliably configured for single-channel output
via the AudioUnit stream format we pin at unit-create time.
Compile-gated to ios + test so the macos build doesn't warn on
dead code.
* ios_voice_unit.rs: render callback reads data.channels from the
args struct and forwards it to the new interleaved helper, so the
macOS path tolerates whatever channel count CoreAudio assigns. A
level decimation counter avoids running sqrt+log10 on every
callback (~93 Hz) when the Flutter consumer only reads at 30 Hz;
same regression class as the capture-side fix already in engine.rs.
* mobile_voice_backend.rs: VoiceAudioParams now carries
event_producer on macOS, and the AudioHandler is no longer wrapped
in Arc<Mutex<…>> on macOS because ownership moves into the render
callback. iOS keeps Arc<Mutex<…>> because its callback design
shares the handler with the decode task.
* lib.rs: audio_event_queue module is now compiled on macOS in
addition to android.
apps/chanora_flutter/lib/main.dart wraps the home tree in a Stack and
overlays AudioDebugStatsPanel on macOS so the live engine counters
(callback rate, drift, queue depth) used to diagnose the underrun are
visible while iterating on this code. iOS and other platforms are
unaffected.
apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework binary
is rebuilt with these changes so flutter run on macOS picks up the new
realtime path without requiring developers to rebuild the Rust crate
locally. cargo check -p chanora_audio passes on macOS host.
Oracle re-review on PR #26 flagged that the Swift-side
`_ = unsafeBitCast(fn as @convention(c) ...)` static references in
ChanoraSileroSelfTest.run() are not a robust anti-dead-strip guarantee
under WMO + LTO. The optimizer can prove the discarded result has no
side effects and eliminate the address-taken reference.
The load-bearing fix is a second linker flag per symbol:
-u _sym forces the symbol as undefined at link time,
preventing the object that defines it from
being dropped and stopping -dead_strip from
removing the definition.
-exported_symbol _sym was already present; re-exports the symbol
in the binary's dynamic symbol table so the
Rust framework's dlsym(RTLD_DEFAULT) can find
it. This flag alone does NOT prevent dead-
strip; it only controls the export list
applied AFTER dead-strip.
Both flags now appear per symbol on both iOS and macOS Release
xcconfigs. The Swift-side static references stay as defense-in-depth
but are no longer the load-bearing guarantee.
PR #26 review (Oracle): dlsym(RTLD_DEFAULT, name) does NOT count as
a static linker reference, so the @_cdecl Swift functions were still
eligible for dead-stripping under Whole-Module-Optimization + LTO
in Xcode Archive builds. This is the actual root cause of the
TestFlight regression — the prior verify_silero_exports.sh fix only
catches the symptom (missing symbol) at build time, it does not
prevent the stripping.
The fix adds 6 static '_ = unsafeBitCast(<fn> as @convention(c) ...)'
references inside ChanoraSileroSelfTest.run() before the existing
dlsym probe. The @convention(c) cast forces address-taken semantics,
which the optimizer cannot prove unused.
Applied identically to ios/Runner/SileroCoreMLBridge.swift and
macos/Runner/SileroCoreMLBridge.swift (the files were and remain
byte-identical).
cargo check --workspace: clean
dart analyze: clean
- ios/Runner.xcodeproj/project.pbxproj: Update RunnerTests TEST_HOST
paths from Runner.app/Runner to Chanora.app/Chanora (target was
renamed in prior commit but test config still pointed at old paths,
breaking xcodebuild test).
- Cargo.toml: Move release DWARF flags from workspace [profile.release]
into Apple-only podspec CARGO_PROFILE_RELEASE_* env vars so Android,
Linux, Windows release builds stay lean (~10MB DWARF avoided).
- ios/Runner/Info.plist + macos/Runner/Info.plist: Flip
ITSAppUsesNonExemptEncryption from false to true (Chanora ships
ChaCha20-Poly1305 local storage + tsclientlib ECDH/AES-EAX voice
channel encryption, not exempt under Apple export-compliance rules).
- scripts/verify_silero_exports.sh: Make slice-aware via lipo -archs
loop + per-arch nm -arch invocation so universal macOS builds
verify every architecture slice, not just whichever slice nm picks.
- .gitignore: Drop .omo/ and .playwright-mcp/ entries (scope leak;
unrelated tooling state, not part of PR #26 archive-symbol concern).
The Apple CoreML Silero VAD backend resolves six @_cdecl Swift symbols
via dlsym(RTLD_DEFAULT) at runtime in the Rust audio crate. Local
flutter build paths preserved those symbols, but Xcode Archive (the
path used for TestFlight and App Store uploads) silently stripped them
through two independent mechanisms, causing Rust to fall back to
WebRTC VAD on every shipped build.
Both stripping mechanisms are now neutralised:
* ld dead-strip: OTHER_LDFLAGS now whitelists each of the six
chanora_silero_vad_* symbols via repeated `-Xlinker -exported_symbol`
pairs in ios/Flutter/{Release,Debug}.xcconfig and
macos/Flutter/Flutter-{Release,Debug}.xcconfig.
* install-time strip: STRIP_STYLE is set to `non-global` in the same
four xcconfigs so the post-link strip phase no longer drops exported
global text symbols from the Archive product. Cost: ~264 bytes per
binary; verified `xcrun strip` vs `xcrun strip -x` behaviour.
Self-test wired into both AppDelegates: at launch on a utility queue,
ChanoraSileroSelfTest resolves all six symbols through dlsym (the same
path the Rust runtime uses, not a direct call that would mask the bug
class) and exercises create → reset → process → destroy. Result is
logged via NSLog and surfaces in Console.app / idevicesyslog.
A post-link verify_silero_exports.sh build phase runs nm -gU on the
final Archive binary and fails the build if any of the six symbols are
missing. Empirically caught the original Archive regression that
flutter build --no-codesign did not.
CocoaPods bridge podspecs now emit a proper .dSYM via dsymutil so
TestFlight crash reports are symbolicated; Cargo.toml release profile
sets `debug = true` because dsymutil needs DWARF in the input dylib.
macOS chanora_bridge.podspec PATH inserts /opt/homebrew/opt/rustup/bin
ahead of /opt/homebrew/bin so rustup's cargo (which has the
x86_64-apple-darwin target installed) wins over the homebrew rust
formula that is aarch64-only.
iOS Podfile target renamed from `Runner` to `Chanora` to match the
Xcode target name shipped in the project (the workspace and scheme
already referenced Chanora; the Podfile mismatch produced lint
warnings during `pod install`).
ITSAppUsesNonExemptEncryption=false declared in both Info.plist files
so TestFlight and App Store Connect uploads skip the export-compliance
prompt; Chanora uses only platform-provided TLS.
.gitignore now covers Xcode archive bundles, IPA exports, dSYM
directories, the local macOS release zip, and agent/tooling state
directories so generated TestFlight artifacts no longer appear in
git status.
End-to-end verified by headless archive:
xcodebuild -workspace Runner.xcworkspace -scheme Runner \
-configuration Release -destination 'generic/platform=iOS' \
-archivePath /tmp/chanora.xcarchive archive CODE_SIGNING_ALLOWED=NO
nm -gU on the resulting .app/Chanora binary shows all six
chanora_silero_vad_* symbols present.
- Replace broad POSIX error checks (EACCES/EPERM/ENETDOWN) with the
canonical kDNSServiceErr_PolicyDenied DNS error in the NWBrowser
state handler, matching the pattern used by Expo, Pulse, Strongbox,
and WLED. Detect denial in both .failed and .waiting states.
- Add checkLocalNetworkAccess(host:port:) — a read-only NWConnection
probe (Sequel-Ace pattern) that checks
NWPath.unsatisfiedReason == .localNetworkDenied without triggering
a new system prompt. Useful for confirming denial against a specific
destination before attempting to connect.
- In _onConnect, after the prompt resolves to Denied, confirm with
checkLocalNetworkAccess against the target host. If confirmed,
abort the connect attempt and show a non-modal snackbar with an
'Open System Settings' action that deep-links to
Privacy_LocalNetwork. Previously the app would proceed to connect,
fail with PermissionDenied, and surface a redundant in-app modal.
- Drop the now-orphaned _openIosAppSettings helper and
_iosPlatformChannel constant (the only caller was the removed
in-app permission dialog).
- Add unit tests for checkLocalNetworkAccess covering outbound
MethodCall arguments and state parsing for Granted/Denied.
Trace: SRS-300.
The Local Network permission prompt (NWBrowser for _ts3._tcp) was
never actually triggered anywhere in the app. The service defined
triggerLocalNetworkPrompt() but no code called it.
Now _onConnect() checks the local network state before connecting.
If the state is unknown or notDetermined, it triggers the NWBrowser
scan which shows the system Local Network Privacy dialog on macOS 15+.
This ensures the prompt appears before the connection attempt so the
user can grant permission and the connection succeeds in one flow.
Non-macOS platforms are unaffected (short-circuited by the service).
Oracle re-review pass on PR #28 flagged that the doc comment named
IOSAudioLifecycleController.classifyDevice, but no such class exists
in the repo. The actual iOS classifier is
AppDelegate.classifyAudioRoute(_:) in ios/Runner/AppDelegate.swift
(line 292), invoked from the route-change and media-services-reset
handlers (lines 189, 237).
Android side is correct: AndroidAudioLifecycleController.classifyDevice
exists at android/app/src/main/kotlin/app/chanora/chanora_flutter/
AndroidAudioLifecycleController.kt.
Adds a doc comment to parseBridgeAudioRoute clarifying that both
iOS and Android producers (IOSAudioLifecycleController.classifyDevice
and AndroidAudioLifecycleController.classifyDevice) emit exact
PascalCase strings.
Case variants (USB_HEADSET, usb_headset, UsbHeadphone) fall through
to unknown by design. This is a silent failure mode worth documenting
so future changes to either platform classifier are paired with a
parser update.
Per PR #28 review feedback.
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: extend parseBridgeAudioRoute to handle 'UsbHeadset' (maps to wiredHeadset — USB audio is functionally a wired-class device, matching AndroidAudioLifecycleController.classifyCurrentRoute's own preference ordering at line 176) and 'Hdmi' (maps to unknown — HDMI is a display-out transport, not a voice-call audio path; no existing BridgeAudioRoute variant fits; safer to leave as unknown than to misclassify as Speaker). Previously these Android-emitted strings hit the default branch and silently became BridgeAudioRoute.unknown.
apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: split the existing single test into three — iOS-classified strings (preserved), Android UsbHeadset (new), Android Hdmi (new). flutter test: 3 passed, 0 failed.
ios_voice_unit.rs:1033: change the condition from `mix_stats.peak_i16 == 0` to `mix_stats.peak_i16 == 0 && !muted`. When the user mutes the channel via output_muted, the downmix helper fills the output buffer with silence (peak = 0), which previously falsely incremented the output_underrun counter. The mute toggle is intentional silence, not a real underrun.
Note: this does not address the separate false positive where peak_i16 == 0 with output unmuted but no audio incoming (e.g., just joined a channel with no remote speaking). A complete fix would require tracking whether the audio handler actually produced data; deferred to a follow-up.
The macOS chanora_bridge.framework tree at
apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework was
being tracked in git despite being a pure build artifact. Both
mechanisms in chanora_bridge.podspec rebuild the entire tree from
scratch:
* prepare_command (runs on `pod install`) — `rm -rf $FW` and
reconstructs Versions/A, the Versions/Current and Resources
symlinks, Info.plist, and copies the lipo-merged universal dylib.
* script_phase :before_compile (runs on every Xcode build) — same
rm -rf + reconstruction, gated on freshness of the cargo output.
Tracking the tree therefore added zero value and ~36 MB per binary
revision (the chanora_bridge dylib alone). The iOS counterpart at
apps/chanora_flutter/ios/Frameworks/ has been correctly ignored since
.gitignore:118-119 was added; this commit mirrors that rule for macOS.
Changes:
- git rm --cached -r the 5 tracked entries (binary, 3 symlinks,
Info.plist). Working tree is untouched, so existing local
builds keep functioning until the next `pod install` /
Xcode build refreshes them.
- Add /apps/chanora_flutter/macos/Frameworks/ to .gitignore
alongside the existing iOS entry, with a comment pointing at the
podspec mechanism so the next maintainer understands the rule.
Verified the working tree binary survives the cache untrack and
the path is now matched by .gitignore:125.
* feat(voice): unified mobile voice bar with gesture-isolated PTT row
Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:
- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode
Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.
Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)
* fix(voice): preserve current PTT button format
* feat(voice): move mute/deafen controls into VoiceStatusChip
* fix(voice): ensure consistent chip height across mute states
Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.
* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip
* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip
Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.
Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
* feat(voice): add real-time mic input level metering at 30 Hz
Expose input RMS from the audio engine through the bridge as a
dedicated Rust→Dart Stream<double>, replacing the binary on/off
indicator with a proportional dBFS level meter.
Rust side:
- chanora_audio: add set_input_dbfs/input_dbfs accessors to
SharedAudioProcessingStats; restructure CaptureState::ingest()
to compute dBFS from mono buffer before the PTT guard so the
meter shows mic activity even when not transmitting.
- chanora_core: widen audio_stats() return to include f32 input
level.
- chanora_bridge: add input_level: f32 to BridgeAudioStats and
new input_level_stream(sink: StreamSink<f32>) that pushes at
~30 Hz via tokio interval task.
- Update frb_generated.rs serialization for the new field.
Flutter side:
- VoiceLevelMeter: accept optional double level (dBFS), map
-60..0 dBFS to 0..1 fill fraction, animate with
TweenAnimationBuilder for smooth transitions.
- voice_compact.dart: subscribe to inputLevelStream in the voice
details sheet for 30 Hz meter updates, keeping 250 ms poll for
TX/RX counters.
- voice_bar.dart: accept optional inputLevel from the stream.
- main.dart: subscribe to inputLevelStream, pass to VoiceBar.
* chore: sync Flutter build config and dependency updates
- Add Flutter migrator flags to gradle.properties (builtInKotlin, newDsl)
- Add FlutterGeneratedPluginSwiftPackage to iOS/macOS Xcode projects
- Update meta 1.17→1.18, test_api 0.7.10→0.7.11
- Rebuild chanora_bridge framework for macOS
- Update Podfile.lock for iOS and macOS
* fix(voice): correct meter animation, pre-gain dBFS, stream lifecycle, and protocol warnings
B1: Convert VoiceLevelMeter to StatefulWidget tracking previous fill
as Tween begin so the meter animates smoothly instead of resetting
to zero on every frame.
B2: Compute dBFS from pre-gain mono samples in CaptureState::ingest()
so the level meter reflects raw mic input, matching mobile paths.
B4: End input_level_stream after 10 consecutive session errors instead
of emitting -120 dBFS forever when the session is gone.
Also fixes all 13 clippy warnings in chanora_protocol: collapsed
nested if-let patterns, replaced .ok() + Some matching with Ok, used
? operator, and introduced EventChannels struct to reduce the four
helper functions below the 7-argument threshold.
* fix(voice): use MissedTickBehavior::Skip for level meter stream and align dBFS doc
Set MissedTickBehavior::Skip on the input_level_stream tokio interval
so slow audio_stats() calls skip missed ticks instead of bursting,
preventing CPU spikes on the UI meter thread.
Align VoiceLevelMeter class doc: the mapping floors at -60 dBFS
(via dbfsToFraction), not the full -120 range.
Bump Android Gradle Plugin from 8.11.1 to 8.13.1 and Kotlin from
2.2.20 to 2.3.0 to align with newer plugin version requirements.
Kotlin 2.3.0 removed the kotlinOptions DSL free-string assignment.
Migrate to the compilerOptions DSL for JVM target configuration.
Gradle wrapper remains at 8.14 (compatible with AGP 8.13.1).
* feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications
Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift)
for macOS-specific permissions not covered by permission_handler:
- Input Monitoring (CGPreflightListenEventAccess /
CGRequestListenEventAccess) for global PTT via Event Tap
- Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+)
- Notifications (UNUserNotificationCenter authorization)
Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091
Changes:
- Info.plist: add NSBonjourServices array with _ts3._tcp
- macos_permissions_service.dart: Dart service with MethodChannel,
ValueNotifier states, PTT capability derivation (L0Focused /
L1MacOSEventTap), non-macOS short-circuit
- MainFlutterWindow.swift: native handler registered as FlutterPlugin,
Input Monitoring check/request/polling, NWBrowser trigger with
denial detection, UNUserNotificationCenter request
- main.dart: wire service into bootstrap lifecycle, listen for PTT
capability changes from Input Monitoring state
- macos_permissions_service_test.dart: 17 unit tests covering inbound
state changes, outbound calls, lifecycle, error handling, platform
behavior (179/179 full suite pass)
* fix(macos): keep permissions capability state live
* fix(audio): eliminate Android output stutter via Oboe config + lock-free callback
Phase 1 — Oboe configuration:
- Change output stream from Usage::VoiceCommunication to Usage::Game with
ContentType::Sonification to avoid forcing the Legacy (OpenSL ES) data
path on most devices (Oboe issue #2075)
- Switch output format from i16 Mono to f32 Stereo, matching Qint's proven
configuration and eliminating per-callback downmix conversion
- Set buffer size to 2x burst after stream open, reducing default buffer
from 8-20x burst to 2x burst for lower latency
- Remove scratch Mutex<Vec<f32>>; callback writes directly to Oboe buffer
Phase 2 — Lock-free output callback:
- Add audio_event_queue.rs: lock-free SPSC bridge using crossbeam ArrayQueue
with separate packet (lossy) and control (reliable) channels
- OutputCallback now owns AudioHandler directly (no Arc<Mutex<>> on Android)
- Inbound forwarder pushes packets via AudioEventProducer (no mutex)
- set_client_volume pushes control commands via event queue on Android
- iOS/desktop Arc<Mutex<AudioHandler>> path unchanged
* fix(audio): address PR #20 review findings
- Store AudioEventConsumer directly in OutputCallback to eliminate
per-callback Arc clone on the real-time audio thread
- Add SAFETY comment for the unsafe from_raw_parts_mut transmute
- Bound set_client_volume spin-loop to 64 retries with warn log
- Remove redundant crossbeam-utils direct dependency
- Regenerate license inventory for new crossbeam deps (CI fix)
* fix(audio): use ASCII TODO punctuation
* docs(rust): add doc comments to delta enums and fix dead_code warnings
Add missing documentation to ProtocolDelta, CoreDelta, and BridgeDelta
enum variants and their struct fields across the protocol, core, and
bridge crates. Document the ChannelId::ROOT constant and the
take_delta_rx adapter method.
Fix dead_code warnings:
- keyring_disabled: add #[cfg] gate matching its callers
- snapshot_signature: add #[cfg(test)] for future test use
* fix(rust): correct `order` field docs to predecessor channel ID, narrow audio engine cfg gates
- Correct `order` field documentation in ProtocolDelta, SessionEvent,
and BridgeEvent from 'sort order' to 'predecessor channel ID
(TeamSpeak linked-list ordering hint)' per Copilot review feedback.
- Narrow AudioEngine voice_out_tx, voice_activity_selector, and mic_gain
cfg gates from ios+macos+android to android-only, since these fields
are only read from self in android_restart_voice_unit. On iOS/macOS the
values are passed directly to the voice backend at construction time.
* fix(ui): restore speaking status indicators
Speaking state (isSpeaking) is computed from voice activity timestamps
in the protocol layer and cannot be represented as a discrete delta.
The event-driven refactor removed periodic snapshot refreshes, causing
speaking indicators to go stale.
Adds a 750ms periodic snapshot refresh (matching SPEAKING_ACTIVITY_WINDOW)
while the audio stats timer is active (in-channel only). Structural
changes (moves, joins, leaves) are still handled by instant deltas.
* fix(ui): hide server query clients from delta joins
When a ServerQuery client sends a message, a ClientJoined delta fires.
Before PR#15 the periodic snapshot rebuild would include the SQ client
but the snapshot_view filter hid it. With deltas, the client persisted
in the local snapshot. Now ClientJoined deltas skip SQ clients entirely.
* fix(proto): log getconnectioninfo errors instead of silently discarding
Ping and packet loss showing 'Unknown' in the client info sheet is
caused by getconnectioninfo failures being silently swallowed. Now
logs the error with the client_id so the root cause can be diagnosed
(e.g. missing b_client_connectioninfo_view permission on the server).
Also logs clientgetvariables failures.
* fix(proto): refresh non-self client profiles before mapping
* feat(protocol): add ping deviation to client profiles
* chore(ui): regenerate Flutter bridge bindings for ping deviation
* fix(l10n): add ping deviation labels to client info
* feat(ui): show ping deviation in client info sheet
* chore: regenerate Cargo.lock after rebase
* fix(ui): add 1s cool-down to prevent double-tap channel join
voiceJoin returns instantly (fire-and-forget protocol), so the
pending-join guard clears before a second tap lands. The cool-down
prevents the rapid channel oscillation and ClientIsFlooding (524)
that results from double-tapping.
* fix(ui): handle ChannelAlreadyIn as success, ClientIsFlooding with backoff
- ChannelAlreadyIn (0x0302): treat as silent success, update UI state
- ClientIsFlooding (0x020c): show localized snackbar, extend cooldown 5s
- Add l10n strings for flooding error (en + zh)
* fix(proto): use Windows TS3 client version for broadest compatibility
Matches Qint's default (Windows_3_X_X__1). Avoids server-side
behavioral differences with TS5 version strings.
* fix(proto): patch tsproto-types to handle short P-256 coordinates
BigInt::to_bytes_be() strips leading zeros, causing WrongPublicKeyLength
when a server's ephemeral key coordinate starts with 0x00. Patch from
EdisonJwa/tsclientlib fix/p256-short-coordinate-pad branch left-pads
coordinates to the P-256 field size instead of rejecting them.
* refactor(core): stop watchdog from emitting SnapshotChanged
The watchdog now serves only as a liveness probe (miss counting for
reconnection). UI updates are handled entirely by the event-driven
delta path (ProtocolDelta → SessionEvent → BridgeEvent → Flutter).
Removes signature tracking and SnapshotChanged emission from the
supervisor loop. The initial snapshot is still fetched via the
Connected event handler in Flutter.
* refactor(ui): remove channel-join cooldown guard
With event-driven deltas the UI updates instantly on channel moves,
so the 1-second cooldown is no longer needed. Double-taps are handled
by the server (ChannelAlreadyIn → success) and the pending-channel-id
guard prevents overlapping requests.
Also removes the _lastJoinCompletedAt field entirely.
* fix(core): reattach event forwarders after reconnect
The reconnect path swapped in a new ProtocolClient but never took
chat_rx, activity_rx, or delta_rx from it. After the first reconnect,
the event-driven UI pipeline was dead.
Fix by extracting spawn_event_forwarders() helper called on both
initial connect and reconnect. Also replaces lossy try_recv+sleep
polling with proper recv().await for push-based delivery.
* feat(protocol): enrich delta schema with all snapshot-visible fields
ClientJoined now carries input_muted, output_muted, is_server_query,
talk_power, talk_power_granted. ChannelAdded/ChannelUpdated now carry
has_password and needed_talk_power. ClientUpdated also carries
is_server_query, talk_power, talk_power_granted.
This prevents local snapshot drift where fabricated defaults could
hide password requirements, talk-power restrictions, or client type.
* refactor: remove dead SnapshotChanged variant end-to-end
SnapshotChanged is no longer emitted since the watchdog was refactored
to liveness-only. Removes the variant from SessionEvent, BridgeEvent,
and the Flutter switch statement. FRB bindings regenerated.
* fix(ci): regenerate license inventory and fix iOS submodule fetch
- Regenerate docs/security/license-inventory.md to match current lockfile
- Remove submodules: true from checkout (causes hard fail on private submodule)
- Add explicit git submodule update --init --depth=1 with || true fallback
- Check silero-coreml/Package.swift instead of directory existence
* fix(diag): record bridge events in diagnostics page
Connection lost/reconnecting/disconnected and iOS audio interruption
events now appear in the diagnostics dialog alongside existing error
snackbar entries.
* fix(diag): reduce audio callback sample verbosity to debug
The render callback diagnostic sample logged every 100 callbacks
(~2s) at INFO level, flooding the 256-entry release log buffer and
pushing out useful events. Changed to DEBUG so it only appears in
debug builds with the larger 4096-entry buffer.
* fix(diag): add timestamps to Rust diagnostic log entries
* build: add silero-coreml as git submodule
Replaces sibling-directory local package with in-repo submodule.
Updates Xcode relative paths and CI checkout to fetch submodules.
* build: add silero-coreml submodule
Merge reset-style baseline PR after local verification. GitHub Actions did not start because of the account billing/spending-limit blocker documented in the PR body.
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.
Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
Constraint: SRS-118 and SRS-119 require a Linux release package and an Android release AAB, and the current workspace also needs the sibling oboe-rs checkout for Cargo manifest loading.
Rejected: Keep release packaging as ad-hoc local knowledge | CI and contributors would still miss the required artifacts and hit the missing oboe-rs prerequisite.
Confidence: medium
Scope-risk: moderate
Directive: If the oboe-rs fork path changes or is vendored, update the helper scripts and workflow checkout steps together.
Tested: bash -n tools/build-linux-deb.sh tools/build-android-aab.sh; python3 YAML parse for .github/workflows/ci.yml, .github/workflows/bench-advisory.yml, .github/workflows/bench-baseline-update.yml; git diff --check
Not-tested: End-to-end flutter build linux --release; end-to-end flutter build appbundle --release; GitHub Actions runtime execution
Constraint: SRS-163 requires Android back to be handled by the shell/platform layer, and the Dart service existed but was not wired into the live app.
Rejected: Leave BackIntentService test-only and unwired | System back would bypass app policy on Android.
Confidence: medium
Scope-risk: narrow
Directive: Keep back-intent probe state in sync with every new dialog or pushed route added to the Flutter shell.
Tested: dart format apps/chanora_flutter/lib/main.dart; flutter analyze lib/main.dart test/services/back_intent_service_test.dart; flutter test test/services/back_intent_service_test.dart
Not-tested: Manual Android device back-navigation smoke test
Closes DEC-032. Restores the canonical Android ABI set
{arm64-v8a, armeabi-v7a, x86_64} per SDD-073 item 4 / SDD-118 item 3.
Root cause was the audiopus_sys + cmake-rs + NDK toolchain-file gap:
cargo-ndk 4.x sets ANDROID_ABI / ANDROID_PLATFORM as env vars per
invocation, but upstream cmake-rs 0.x does not forward them to the
child cmake invocation as -D variables, so armeabi-v7a and x86_64
configure steps fell through to the toolchain-file default and
failed to build.
Fix:
- Cargo.toml: add a workspace [patch.crates-io] stanza pinning the
cmake crate to fork pr2502/cmake-rs @ commit
bdad5edc569d82151922c5c6c4685b1563f12aa1 (branch android-build),
which carries cmake-rs PR #257
(https://github.com/rust-lang/cmake-rs/pull/257). The patch is a
9-line addition that forwards ANDROID_ABI and ANDROID_PLATFORM
from the env to the child cmake as -D variables.
- Cargo.lock: regenerated by 'cargo update -p cmake'; the lone
cmake entry now points at the fork rev.
- apps/chanora_flutter/android/app/build.gradle.kts: restore
abiFilters to {arm64-v8a, armeabi-v7a, x86_64}; remove the
TODO(x86_64/armv7 follow-up) comment.
- docs/governance/product-decision-register.md: mark DEC-032 as
Resolved (2026-05-18) with the resolution mechanism, update the
§3 / §7 rows, and append a 0.9.8.1 change-history entry.
Verification (host: Linux):
cargo update -p cmake -> pulled fork rev
cargo check --workspace --all-targets -> PASS
cargo test --workspace -> PASS (no regressions)
cargo ndk --platform 28 -t arm64-v8a build -p chanora_bridge -> PASS
cargo ndk --platform 28 -t armeabi-v7a build -p chanora_bridge -> PASS
cargo ndk --platform 28 -t x86_64 build -p chanora_bridge -> PASS
Upstream tracking: re-evaluate the [patch.crates-io] override once
cmake-rs PR #257 merges and a fresh cmake release lands on
crates.io; at that point switch to a plain dep bump and remove the
override.
Author SysRS-310 ratifying the macOS minimum runtime baseline at 10.15
(Catalina) at the SysRS layer, parallel to SysRS-286 (iOS 13.0) and
SysRS-288 (Android API 28).
Closes the Wave 1.5 traceability-audit deferred-but-optional follow-up.
ID allocation: SysRS-310 (not SysRS-290, which is already allocated to
MVP single-active-server-connection scope); monotonic numbering
preserved.
Cross-references SAD-087, SDD-119, SysRS-286, SysRS-288. Verification:
Review + Platform Test on a macOS 10.15 system. Raising the baseline
(e.g., to 11.0 / Big Sur) requires a DEC entry.
Trace: SysRS v0.9.11, SAD-087, SDD-119.
Resolve the macOS half of the SDD-119 item 3 single-source-of-truth follow-up. The chanora_bridge cdylib macOS deployment-target floor ('10.15') was previously hard-coded at 7 sites across Podfile and chanora_bridge.podspec; this commit collapses them to a single Ruby constant declaration in a new SoT file.
Selected Option B (Ruby constant) over Option A (.xcconfig — rejected because the podspec prepare_command runs before any xcconfig is applied) and Option C (versioned text file — rejected as overkill given both consumers are already Ruby). Realizes SAD-087(a)'s 'single macOS build-configuration location' mandate.
Out of scope: the pbxproj 'MACOSX_DEPLOYMENT_TARGET = 10.15' lines (565/666/717) belong to the PBXProject default config and are independently overridden to '11.0' at the Runner PBXNativeTarget level; they are the Runner app's floor, not the bridge cdylib's floor. The iOS half (IPHONEOS_DEPLOYMENT_TARGET=13.0) remains an open follow-up.
Files: new apps/chanora_flutter/macos/macos_deployment_target.rb (MACOS_BRIDGE_DEPLOYMENT_TARGET = '10.15'.freeze); Podfile + chanora_bridge.podspec require_relative the constant and consume it at 7 sites; docs/architecture/sdd.md SDD-119 item 3 rewritten + Notes bullet updated + v0.9.17 changelog entry.
Canonical baseline lives at crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
and is updated only via the bench-baseline-update.yml workflow.
SDD-120 amendment v0.9.15 → v0.9.16 reflecting commit 3a7750a discovery.
The two post-processor tools (emit_baseline, compare_baseline) were originally
specified in SDD-120 §2 as residing in crates/chanora_audio/benches/, with
[[bin]] declarations in Cargo.toml. The implementation discovered that Cargo's
dependency resolver only routes [dev-dependencies] to [[test]], [[bench]], and
[[example]] targets — NOT to [[bin]] targets under src/bin/ or to ad-hoc paths.
Routing serde_json (required by both binaries) as a regular [dependencies] entry
to support [[bin]] placement would force it into the production cdylib build,
contradicting SDD-120 §10 release-artifact isolation: "no production telemetry
export" and "no benchmark surface in shipping artifacts." Confirmed by the user's
question about whether benchmark crates ship in the release artifact (they must
not).
The correct Cargo-idiomatic placement is examples/. Cargo auto-discovers files
under examples/ as example targets; they receive [dev-dependencies] routing; and
they are excluded from cargo build --release and from flutter build --release
artifacts by Cargo design.
Amendment scope (SDD-120 only):
- §1 item 4: [[bin]] → [[example]]; cargo run --bin → cargo run --example.
- §2: added item 5 with the rationale clause + Cargo dependency resolver
explanation.
- §5 item 1: path benches/ → examples/; invocation flag --bin → --example.
- §5 item 4: dev-dependencies routing clause updated.
- §6 step 6/8: --bin → --example.
- §7 step 5: --bin → --example.
- §10 item 1: release-artifact-isolation bullet extended to cite the
[dev-dependencies] Cargo design mechanism that enforces it.
- §11 verification matrix unchanged.
- "Allocated to" line + "Software units" list updated to reference examples/
paths.
No semantic change to SDD-120: same harness, same metrics, same workflows, same
out-of-scope deferrals. The implementation at 3a7750a already lives at the
corrected paths; this amendment brings the SDD text into agreement with the
code.
Implementation of SDD-120 §1-§8:
Bench harness (crates/chanora_audio/benches/):
- common.rs: deterministic synthetic audio (440 Hz sine, no RNG).
- realtime_capture.rs: bench_capture_alloc_count (dhat) +
bench_capture_callback_wall_clock (criterion).
- opus_codec.rs: bench_opus_encode_latency + bench_opus_decode_latency
(direct audiopus, not AudioHandler — SDD-120 §3 item 4).
- resampler.rs: bench_resampler_throughput across 44.1->48 /
16->48 / 48->48 passthrough.
CI tooling (crates/chanora_audio/examples/):
- emit_baseline.rs: aggregates criterion estimates.json outputs
into the SRS-217 baseline schema.
- compare_baseline.rs: applies SRS-219 tolerance, renders markdown
table with 🟢/🟡/🔴 markers + yellow simpler-form realization per
SDD-120 §8.
Deviation from SDD-120 §2 / §5 / §7 placement: these tools live
under examples/, not benches/ or src/bin/. Rationale: they must
consume serde_json (a dev-only dep — production builds must not
pull it). Cargo only resolves dev-dependencies for [[test]],
[[bench]], and [[example]] targets; [[bin]] targets under
src/bin/ see only regular [dependencies]. examples/ keeps the
binaries out of the production dep tree while still giving them
cargo run --example invocation. An SDD-120 amendment should
reflect this.
Workflows (.github/workflows/):
- bench-advisory.yml: PR + push triggers; runs benches; posts a
sticky PR comment via actions/github-script@v7; job status is
always success (SRS-218 clause 4 — non-blocking).
- bench-baseline-update.yml: workflow_dispatch only; runs benches;
opens PR via peter-evans/create-pull-request@v6 (sole writer of
the SAD-089 baseline JSON).
Cargo.toml additions ([dev-dependencies] only — verified excluded
from --release builds): criterion 0.5, dhat 0.3, serde_json 1.
Source-code seam: minimal pub-but-#[doc(hidden)] bench_seam module
in chanora_audio (engine.rs + lib.rs re-export) so the criterion
bench harness can construct a CaptureState and drive
CaptureState::ingest without re-implementing the engine (SDD-120
§3). Non-iOS targets only — CaptureState itself is iOS-gated.
Initial baseline seed: crates/chanora_audio/benches/baselines/
x86_64-unknown-linux-gnu.json = {}. compare_baseline handles the
missing-baseline case gracefully and emits a 'no red markers'
report; the first manual dispatch of bench-baseline-update.yml
after merge establishes the real values.
Out of scope per SDD-120 §10: production telemetry export,
build-failing hard CI gate, multi-host benchmarking, IDE
integration, Dart-side bridge round-trip bench.
Verification:
- cargo check --workspace --all-targets: PASS.
- cargo bench --bench realtime_capture --no-run: PASS.
- cargo bench --bench opus_codec --no-run: PASS.
- cargo bench --bench resampler --no-run: PASS.
- cargo build --example emit_baseline --example compare_baseline
-p chanora_audio: PASS.
- cargo test --workspace: 106 passed, 0 failed, 3 ignored — no
regression from prior count.
The capture cpal callback (CaptureState::ingest) ran two heap
allocations per callback on the realtime audio thread:
1. engine.rs:1196-1202 — fresh `mono: Vec<f32>` for the downmix
output, once per cpal callback (50–100 Hz).
2. engine.rs:1217-1218 — `pcm_accum.drain(..FRAME_SAMPLES).collect()`
building a fresh Vec<f32> of 960 samples per Opus frame.
Both sites mirror the pattern already fixed for the output side at
engine.rs:1389-1397, where allocating per callback on glibc malloc
was correlated with user-perceptible audio popping. The output-side
fix replaced the per-callback allocation with a pre-allocated
`scratch` Vec that is cleared and resized in place; this commit
applies the same template to the capture side.
Changes:
- Add `mono_scratch: Vec<f32>` and `frame_scratch: Vec<f32>` to
CaptureState. Initialised with Vec::with_capacity(4096) and
Vec::with_capacity(FRAME_SAMPLES=960) respectively in
CaptureState::new.
- Replace the downmix Vec construction with in-place push into
`self.mono_scratch`; `clear()` retains capacity across callbacks.
- Replace the drain().collect() with `self.frame_scratch.extend(
self.pcm_accum.drain(..FRAME_SAMPLES))`; same capacity-retention.
- The resampler call uses std::mem::take to swap the scratch buffer
out for the duration of the &mut self call, then moves it back —
the backing allocation is preserved across callbacks.
Algorithm semantics are unchanged: same downmix arithmetic, same
clamp loop, same Opus encode call sequence. Only the storage
strategy differs.
Out of scope (intentionally not touched):
- Android audio path (android_voice_unit.rs, mobile_voice_backend.rs):
researcher constraint C-4 — the Android cpal data path is mid-
migration and being replaced.
- Output callback at engine.rs:1409+: the only obvious per-callback
allocation there (`scratch`) was already fixed; a fuller audit
is a separate scope decision.
- The `scratch` buffer at engine.rs:1389-1397 — already correct.
Verification:
- cargo check --workspace --all-targets: passes.
- cargo test --workspace: 106 passed / 0 failed / 3 ignored.
- cargo clippy --workspace --all-targets: no new lints introduced;
the one warning inside the edited region (clamp-like pattern at
line 1266) was pre-existing on the copied clamp loop.
Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.
Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.
Trace: SDD-028, SDD-106, SRS-163, SRS-209.
Android P0 platform shell:
- ChanoraApplication: early System.loadLibrary("c++_shared") +
System.loadLibrary("chanora_bridge") so JNI is hot before
MainActivity.onCreate.
- MainActivity: configureFlutterEngine + onResume/onDestroy wiring
for BackIntentBridge and AndroidPermissionRequester; publishes
permission-state changes through both the MethodChannel (Dart UI)
and the JNI hook (Rust audio engine).
- AndroidVoiceForegroundService: microphone-type foreground service
with notification channel chanora.voice.session per SDD-107.
- AndroidPermissionRequester: RECORD_AUDIO state machine with
persisted "has-ever-requested" flag so PermanentlyDenied is
correctly distinguished from never-asked across cold launches.
- BackIntentBridge: API 33+ OnBackInvokedCallback + pre-33
OnBackPressedDispatcher with deterministic Dart-side policy.
- MethodChannels: centralized constants for app.chanora/*.
- build.gradle.kts: SDD-118 Gradle automation that auto-builds the
Rust cdylib via cargo-ndk with per-ABI Exec tasks, minimal-env
isolation, CMAKE_TOOLCHAIN_FILE pinning, libc++_shared.so staging,
release-inspection assertion. abiFilters temporarily reduced to
arm64-v8a only per DEC-032 (multi-ABI restoration pending).
- AndroidManifest.xml: INTERNET, RECORD_AUDIO, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS,
MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT permissions; service
declaration with foregroundServiceType=microphone.
- proguard-rules.pro: keep rules for JNI native methods + Flutter
plugin entry points + FRB bindings.
Trace: SDD-073, SDD-105, SDD-106, SDD-107, SDD-108, SDD-110, SDD-118,
SRS-111, SRS-119, SRS-163, SRS-187, SRS-209, SRS-215.
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).
Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.
Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.
Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
Per SDD-106 §6 add a permission-state clamp to TransmitModeSelector.
When RECORD_AUDIO is Denied or PermanentlyDenied the transmit gate
is forced false regardless of PTT or voice-activity state; on
Granted the clamp releases and normal transmit decisions resume.
The clamp takes precedence over PTT and hard_mute in the decision
ordering documented inline.
Three new tests cover the clamp behavior, the release-on-grant
transition, and the non-RECORD_AUDIO ignore path.
Trace: SDD-106 §6, SRS-209.
Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.
Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.
Trace: SDD-108, SDD-115.
Introduce ModeStack, a pure-Rust refcount-composable wrapper for
Android audio-mode acquire/release with prior-mode snapshot. Per
SDD-108 §1/§2 the engine snapshots the system audio mode on first
acquire and restores it on last release; composed acquires are
no-ops while the mode is held.
ModeStack is panic-free; release-on-zero returns AlreadyReleased
rather than panicking. Six SWE4-UV-045-tagged unit tests cover the
acquire/release semantics on the host target.
Trace: SDD-108, SWE4-UV-045.
Debug and Profile had ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO which
blocked outbound TCP. All three configs now have:
- ENABLE_HARDENED_RUNTIME = YES
- ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES
This was the root cause of the 'Operation not permitted' connection
failure on macOS Sequoia.
macOS:
- Transparent title bar with hidden title, full-size content view
- macOS: inline Row header (no AppBar) with 56px traffic-light pad
- Other platforms: standard Material AppBar unchanged
- App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS)
- NSLocalNetworkUsageDescription added to both platforms
Linux:
- tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball
- Verifies GTK3, libopus dev headers, Rust target
- Copies libchanora_bridge.so into bundle/lib/
Required for local network privacy prompt on macOS 15+ and iOS 14+.
App appears in System Settings → Local Network after connecting to a
LAN server. Internet-hosted servers only need network.client entitlement.
When macOS denies network access (PermissionDenied), show a localized
dialog explaining how to grant permission in System Settings, with
an 'Open System Settings' button that opens directly to the Local
Network privacy pane.
Also adds author info (Edison Jwa) to About dialog and moves
diagnostics button from AppBar into About dialog.
The ring-buffer architecture (rc.8+73..+74) was making playback
strictly worse. Diagnostic data at +74 conclusively showed:
* Producer task ran perfectly at 50 Hz (250 ticks per 5 s).
* AudioHandler returned silence on 65-84% of fill_buffer calls
even when window_peak_f32 reached 0.98 (full-scale audio).
* Ring buffer never accumulated beyond 30 ms because consumer
(VPIO render callback at 43.5 Hz, ~1440 samples per call)
drained samples faster than the 50 Hz producer could push
them, in net effect.
The producer drained AudioHandler at 50 Hz \u2014 slightly faster
than iOS VPIO actually consumes audio. Each fill_buffer call
asked for 20 ms but adjacent Opus packets hadn't arrived yet, so
fill_buffer returned mostly silence. Linux/SDL's same pattern
works because SDL calls fill_buffer at EXACTLY the device
callback rate (50 Hz = 20 ms per buffer); the rates match.
Fix: revert to direct fill_buffer call from the render callback
(the SDL pattern in tsclientlib's own reference example at
tsclientlib/examples/audio_utils/ts_to_audio.rs). The render
callback now:
1. Resizes scratch_stereo Vec to 2 * num_frames f32 if needed
2. Zeros the live slice (fill_buffer is additive, not clearing)
3. Locks AudioHandler, calls fill_buffer(scratch_stereo)
4. Downmixes L+R -> mono i16 with master gain into out[]
5. Applies output_muted bypass
6. Tracks peak_out + audio/silence ratios for diagnostic
The closure owns scratch_stereo across callbacks for stable
allocation. Same memory model as Linux/SDL.
Removed:
* tokio::spawn producer task
* rtrb dep + RingBuffer<i16> + Producer/Consumer split
* tokio::sync::oneshot shutdown channel
* producer_shutdown_tx field on IosVoiceUnit struct
* RING_BUFFER_SAMPLES / PRODUCER_TICK_MS constants
* Producer-side diagnostic counters
Diagnostic kept: cb / num_frames / frames_changes /
callbacks_with_audio / callbacks_with_silence / peak_out_i16 /
gain. Logged every 100 callbacks.
The choppy / clicks symptom is independent of the buffer
architecture \u2014 it's whatever AudioHandler is doing on iOS
that's different from Linux. Next investigation step is to
either (a) switch from VPIO to RemoteIO unit (lose Apple's
voice processing entirely), or (b) understand why AudioHandler
returns silence so often on iOS-arrival packet timing patterns.
Build counter 74 -> 75.
External reviewer correctly identified that the +73 ring-buffer
commit didn't fix the symptom but the architecture is still
right. We need to distinguish two possible causes:
(a) Producer task isn't running (or running too rarely) so
ring stays underfilled.
(b) Producer IS running but fill_buffer returns zeros most of
the time (AudioHandler stuck in buffering_samples state
or no packets reaching it).
The +73 render-side diagnostic was insufficient: we logged
underruns + peak_out_i16 but not what the producer was
actually pushing. This commit adds producer-side metrics
rolled up every 5 s (250 ticks at 20 ms):
Producer task:
producer_ticks : timer firings (= ~250 per 5 s window;
fewer = tokio scheduler stalled)
produced_chunks : pushes into ring (= ticks - drops)
fill_buffer_calls : AudioHandler queries
fill_buffer_zero_returns : ticks where scratch came back all
zeros (no decoded content to play)
ring_full_drops : ticks where ring was full and we
skipped the push
ring_min/max_samples : depth envelope across window
ring_min/max_ms : same in milliseconds
window_peak_f32 : max scratch sample across window
window_rms_f32 : RMS of all scratch samples across
window
Render callback (per-100-callback as before, plus new fields):
ring_avail_before : Consumer::slots() before this read
(= how many samples were sitting in
the ring at callback entry)
read_frames : samples successfully popped
zero_filled : samples zero-filled because ring
was empty (= num_frames - read_frames)
underruns / underrun_samples / peak_out_i16 / clip_count_i16
: as before
Reading the next iteration's log:
If producer_ticks << 250 per 5 s window:
tokio scheduler isn't running the task fast enough.
Move producer to its own dedicated runtime, or use
std::thread + std::sync::mpsc + std::thread::sleep
instead of tokio.
If producer_ticks ~= 250 AND fill_buffer_zero_returns is
high (most ticks return silence):
AudioHandler isn't decoding packets fast enough OR is
stuck buffering. Bug is upstream in protocol layer
packet delivery or AudioHandler's jitter state machine.
The ring buffer architecture cannot fix this.
If producer_ticks ~= 250 AND fill_buffer_zero_returns is
low AND ring_min_ms stays >100ms AND underruns are low BUT
consumer's peak_out_i16 is still 0:
Something is wrong between push and pop. Lock-free
ring corruption, or wrong stride.
Pure diagnostic. No behavioural change beyond the logging.
Producer scratch envelope scan is O(scratch.len()) = 1920
samples per 20 ms tick = ~96k iterations/sec on the audio
producer thread \u2014 negligible CPU.
Build counter 73 -> 74.
User confirmed at +72 the symptom is 'voice + constant clicks +
choppy fragments'. The diagnostic data conclusively pointed to
iOS VPIO render-callback timing as the cause:
* frames_changes=60+ per 100 callbacks at cb>=1800
iOS keeps switching num_frames between 960 and 1104
on roughly 60% of callbacks
* peak_out_i16 is sensible (2500-16870, never clipping)
when AudioHandler returns content
* input_was_zero=true on most callbacks during active speech
AudioHandler keeps entering buffering_samples state
The cause: previous render callback called fill_buffer
synchronously every iOS audio thread invocation. With iOS
calling at irregular rates with irregular sizes, AudioHandler's
jitter buffer (sized around 20 ms Opus frames) cannot satisfy
arbitrary-sized requests and falls back to returning silence
(&[] empty slice) on misaligned reads. The silent gaps in the
middle of the output buffer create discontinuities = audible
clicks; the missing-tail content produces choppy fragments.
Fix (architectural): decouple the AudioHandler decoder from the
VPIO render callback via a lock-free SPSC ring buffer.
Producer (tokio task, 50 Hz):
every 20 ms:
fill_buffer(scratch_stereo_f32, 1920 = 20 ms stereo)
downmix L+R -> mono i16 (960 samples)
ring_buffer.push_slice(mono_i16)
Consumer (VPIO render callback, iOS audio thread):
every callback:
pop num_frames samples from ring buffer into out
zero-fill tail on underrun
Why it works:
* Producer always asks AudioHandler for a stable 20 ms chunk
(perfectly aligned with internal Opus frame size). No more
buffering_samples false-triggers.
* Consumer pulls whatever iOS asks for whenever iOS schedules
it; ring buffer's 200 ms depth absorbs the callback jitter.
* This is the standard pattern every production VoIP audio
engine uses (WebRTC, Discord, FaceTime) to bridge bursty
Opus decoders to bursty platform audio callbacks.
Implementation:
* New dep: rtrb 0.3.4 (RustAudio realtime-safe SPSC ring
buffer, 6.8M downloads, lock-free push/pop with no
allocation on the audio thread).
* RING_BUFFER_SAMPLES = 9600 (200 ms mono i16 at 48 kHz).
Sized for 10x producer ticks of headroom.
* PRODUCER_TICK_MS = 20 (matches Opus 50 Hz packet rate).
set_missed_tick_behavior(Skip) to avoid burst catch-up on
runtime stalls.
* Producer task spawned in IosVoiceUnit::start, shutdown
via tokio::oneshot when IosVoiceUnit drops.
* Render callback is now just: pop into out, zero-fill tail,
apply mute then gain.
* Gain applied CONSUMER-side so user volume changes take
effect within one callback (<= 200 ms latency).
* Underrun diagnostics: count underrun callbacks + total
zero-filled samples, log every 100 callbacks.
Threading + safety:
* rtrb is lock-free SPSC. Audio thread never blocks.
* Producer can block briefly on Arc<Mutex<AudioHandler>>
contention with the inbound forwarder (handle_packet), but
not with the audio thread.
* Producer task is owned by tokio runtime; explicit shutdown
channel ensures it exits when the engine stops.
Build verify:
* Linux host: cargo check clean in 4.06s (downloads rtrb 0.3.4).
* iOS Mac: cargo check clean in 2.14s.
Build counter 72 -> 73.
External code review pushed back on the 'iPhone speaker hardware
distortion' hypothesis and pointed out we need more than just
peak measurements. The reviewer's checklist:
* peak_i16
* rms_i16
* num_clipped_samples (abs >= 32767)
* zero_fill_count / underrun_count
* callback_frame_count variability
* decoded_packet_duration_ms
* input_was_zero
* actual ASBD / actual sample rate
The format diagnostics at +71 already showed iOS honoured
48 kHz Int16 mono on both buses and that .default mode +
.defaultToSpeaker routed to the speaker correctly with
outputVolume=0.45. So format + route are confirmed correct.
The remaining mystery is WHY 'loud but distorted' \u2014 we need
sample-level metrics to isolate where in the pipeline the
breakage occurs.
This commit instruments the VPIO render callback with:
* num_frames + frames_changes : detects iOS re-negotiating
buffer size between callbacks
(which would imply jitter the
fixed scratch_stereo Vec can't
absorb cleanly).
* peak_stereo + rms_stereo : characterises AudioHandler's
output BEFORE our downmix.
Distinguishes 'real audio
arriving' from 'silence'.
* peak_out_i16 + clip_count : measures what we hand VPIO.
clip_count > 0 means we're
clipping at our boundary even
with gain=1.0 \u2014 indicates
upstream is over-driven.
* input_was_zero : explicit silence/no-talker
indicator separate from peak=0
which could mean tiny content
rounded to 0.
Reviewer's preferred diagnostic path is to dump PCM to file
and play with ffplay externally; that's iOS-impractical
without a shared filesystem path the user can extract via
Files.app. Instead we sample the same metrics in-callback at
~2 Hz which gives us the same information at run time.
Pure diagnostic. No behavioural change. Counters live in the
FnMut closure so the audio thread cost is one branch +
counter increment per callback, plus a one-pass RMS sum +
peak scan every 100 callbacks.
Build counter 71 -> 72.
Reviewer also recommended a headphone test in parallel \u2014
that will be done by the user (out-of-band) at the next
test cycle to determine whether the symptom changes when
audio leaves the speaker path.
Per external review (helpful checklist from ChatGPT-style analysis
pointing out we never verified that iOS actually accepted our
preferred sample rate / channels / format): preferredSampleRate
and preferredIOBufferDuration are HINTS, not guarantees. iOS may
substitute its own values if the hardware can't satisfy our
preference. If VPIO is running at 44.1 kHz Float32 stereo while
our render callback writes 48 kHz Int16 mono into the buffer,
the symptoms would match what user reports (broken playback,
pitch shifted, severe distortion) and our previous diagnostics
wouldn't catch it because they only sampled signal-level metrics.
This commit adds two diagnostic emissions to verify:
1. AppDelegate.swift::activateAudioSession: after setActive
succeeds, log the ACTUAL session state \u2014 category, mode,
sampleRate, ioBufferDuration, current route (inputs +
outputs), outputVolume. Lets us see whether iOS honoured our
.default + .defaultToSpeaker setup and which physical route
it picked at launch.
2. ios_voice_unit.rs::IosVoiceUnit::start: after unit.start()
succeeds, log the actual OUTPUT and INPUT stream formats
VPIO accepted (sample_rate, channels, sample_format, flags).
If these differ from our requested 48 kHz Int16 mono, we
have a format-substitution problem.
Three possible outcomes from the next test:
* Both diagnostics confirm 48 kHz Int16 mono on both buses and
the session sampleRate=48000 -> format is correct; the
playback breakage is somewhere else (e.g. AudioHandler
jitter buffer behaviour, route binding, or hardware mixer).
* Session sampleRate != 48000 -> we need to insert a sample
rate converter or pin AVAudioSession's
setPreferredSampleRate(48000) explicitly in Swift before
setActive.
* VPIO substituted Float32 for our Int16 request -> our render
callback is writing i16 magnitudes into a Float32 buffer
which would explain the distortion. Fix: write Float32
directly using data::Interleaved<f32> instead of i16.
Build counter 70 -> 71. Pure diagnostic; no behavioural
change.
User report after the 8x boost commit (e85a6d3): playback STILL
broken, but now the diagnostic clearly shows the actual problem.
Render-callback peak_out_i16 SATURATES at 32767 on speech peaks
(cb=600, 1100, 1200, 2400) because the 8x boost amplifies an
already-loud signal into hard clipping. Quiet content reaches
audible level but loud peaks are catastrophically distorted.
The 8x boost was treating the wrong cause.
Real root cause (researched online after user prompted: 'this is
iOS a popular platform, there must be solutions'): iOS has TWO
independent audio channels:
In-call channel (.voiceChat / .videoChat modes)
* Routes through the phone-call audio path.
* Aggressively ducks non-voice content to the earpiece.
* Volume controlled by a separate in-call hardware
register, not the side buttons when not actively on a
phone call.
Media channel (.default mode)
* Routes through the standard media playback path.
* No automatic ducking.
* Volume controlled by the side volume buttons normally.
With AVAudioSession mode .voiceChat, iOS sends our output
through the in-call channel which plays at 'earpiece-level'
loudness on the speaker too. Signal is technically present but
buried under the speaker's noise floor. With mode .default +
.defaultToSpeaker option, output routes via media channel and
plays at normal loudness.
Both Twilio (video-quickstart-ios) and Daily.co (patched WebRTC
module) document the same workaround and use VPIO for AEC while
keeping the session mode at .default for loud playback:
github.com/twilio/video-quickstart-ios/issues/522
stackoverflow.com/questions/79834998 (Daily.co)
The user also noticed 'tx/rx almost no changes even receiving
packages' \u2014 likely a misinterpretation of the frames counter
not advancing as fast as expected during quiet voice; AudioHandler
returns silence when its jitter buffer is in buffering_samples
state which doesn't fire 'decode failed' but also doesn't
increment frames_received. The real issue is still the playback
ducking; the counter behaviour is a downstream symptom.
Changes:
1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default
with options [.defaultToSpeaker, .allowBluetoothHFP,
.allowBluetoothA2DP]. VPIO continues to do its job (AEC, NS,
AGC on the mic side); only the playback routing changes.
The earlier 'speaker selector silent under .default' bug
does NOT apply because we no longer use cpal RemoteIO \u2014
VPIO honours overrideOutputAudioPort under any mode.
2. ios_voice_unit.rs: revert the 8x output boost from e85a6d3.
With media-channel routing, signal levels are correct and
no software amplification is needed. Render callback restored
to plain (l+r)*0.5*gain downmix.
3. ios_voice_unit.rs: revert the BypassVoiceProcessing toggle
from c16318c. The VPIO chain stays enabled so we keep
capture-side AEC/AGC/NS for free \u2014 the playback breakage
it was trying to fix was the wrong layer all along.
4. ios_voice_unit.rs: drop the diagnostic render-callback log
line. Production-clean code; can be re-enabled by reverting
the diff in the closure if future debugging needs it.
Build counter 69 -> 70.
User-pasted log at +66 (https://pb.hit.moe/q8heratf.txt) shows
conclusive data over a 110-second continuous talker session:
Average peak_stereo_f32: ~0.005-0.010
Loud peak (one moment): ~0.234
peak_out_i16: ~150-300 (out of 32767)
The signal arriving at our render callback from
AudioHandler::fill_buffer is consistently at -40 dB FS for
normal human speech. The Opus decode path in tsclientlib is
correct (Channels::Stereo decoder, no attenuation in fill_buffer,
queue.volume defaults to 1.0). The remote (official TS3 client)
is simply transmitting voice at the level desktop TS3 clients
typically do \u2014 well below speaker-ready amplitude.
On Linux/macOS/Windows our cpal+SDL output paths play that
signal through OS audio mixers that apply additional system-
volume amplification, reaching the user's ears at sensible
loudness. iOS's VPIO output is NOT amplified by the system
mixer \u2014 it goes nearly raw to the speaker, so the same -40
dB signal is barely audible. Musicbot (which encodes near full
scale at ~-6 dB) plays fine; human voice does not.
Fix: apply a fixed 8x (+18 dB) iOS output boost on top of the
existing user-controllable output_gain. A -40 dB signal becomes
-22 dB (normal speakerphone level). User's volume slider
continues to function in a useful 0-2x range on top.
effective_gain = user_gain * IOS_OUTPUT_BOOST
Hard-clip at \u00b11.0 in the mono downmix prevents loud signals
(musicbot at peak 0.5 -> 4.0 -> clamped to 1.0) from
overflowing i16 wrap-around. Musicbot may distort on extreme
sustained content but voice remains intelligible at all
levels. Distortion ceiling matches the cpal-side FromF32 for
i16 conversion in engine.rs.
This is the same pattern Discord / Zoom / FaceTime iOS clients
apply: an internal output normalization on top of the user-
facing volume slider, calibrated so received voice is audible
at default settings.
Build counter 68 -> 69.
User report at +67 (.voiceChat mode): playback still 'broken'.
Even with VPIO's Apple-documented session-mode pairing,
its output-side gating chain (echo subtraction + adaptive
noise suppression) chops quiet inter-phoneme content of human
voice. Musicbot signal (loud, ~continuous) survives because it
stays above the gating threshold; speech does not.
Fix: set kAUVoiceIOProperty_BypassVoiceProcessing = 1 on the
unit immediately after EnableIO (before stream format / callbacks
/ initialize). This disables ALL VPIO voice processing \u2014 the
unit becomes effectively a vanilla RemoteIO with mic + speaker
buses. Raw samples pass through both directions.
Trade-off:
* Lost: Apple's hardware AEC + AGC + NS on the mic path. User
reports current capture is clean already, suggesting their
test environment (headset? non-speakerphone?) doesn't need
AEC. If echo loops back when speakerphone is engaged, we'll
re-evaluate \u2014 either re-enable VPIO selectively for
echo-prone routes or ship software AEC (DEC-007).
* Gained: playback is no longer gated. Quiet inter-phoneme
speech content reaches the speaker.
Property setter:
* Constant: kAUVoiceIOProperty_BypassVoiceProcessing = 2100
* Scope: Global, Element: Input (1) per WebRTC's reference iOS
ADM (voice_processing_audio_unit.mm).
* Value: u32 = 1 (= bypass).
* Soft-fail with warn log if the property is rejected on an
exotic iOS version (the unit still works, just with VPIO
defaults).
Build counter 67 -> 68.
User report at +66: capture (mic -> remote) is clean, but local
playback (remote -> speaker) is 'broken and poor', particularly
for human voice. Musicbot audio (loud, near-continuous) plays
correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic
20 dB peak-to-average ratio) sounds gated out so most inter-
phoneme content is unintelligible.
Diagnostic at +66 (render callback peak sampling every 100
callbacks during a 60-second talker session) showed peak_stereo
values in the 0.005-0.01 range with occasional 0.13-0.49 spikes
\u2014 i.e. the signal is REAL and reaching the device, but VPIO's
output-side voice processing chain is gating the average-level
content.
Root cause: AVAudioSession mode .default + VPIO is a mismatched
pairing. Under .default mode the VPIO unit's internal AGC/NS
thresholds are tuned wrong for telephony-style speech and treat
quiet inter-phoneme content as noise to gate out.
Fix: switch back to mode .voiceChat which is Apple's documented
pair for VoiceProcessingIO. WebRTC's reference iOS audio device
manager (chromium googlesource voice_processing_audio_unit.mm)
also uses this pair. VPIO under .voiceChat tunes its processing
chain for speech and passes quiet content through cleanly.
The original 'speaker/receiver toggle is silent under .voiceChat'
bug was caused by cpal's RemoteIO unit binding to a stale
physical transducer at construction time, not by .voiceChat
itself. After migrating to VPIO at commits 1-4 (af686ca through
e7c3ffa) the route binding is correct under either mode because
VPIO natively re-binds on overrideOutputAudioPort \u2014 it IS the
canonical voice unit. So .default lost its only benefit and we
revert to the Apple-documented pairing.
Category options unchanged: .allowBluetoothHFP +
.allowBluetoothA2DP \u2014 BT headsets still permitted in both
directions regardless of mode.
Build counter 66 -> 67.
User reports capture-side audio (mic -> remote) is clean but
local playback (remote -> speaker via VPIO render callback) is
'broken and poor' at +65. The pipeline appears correct on paper:
fill_buffer -> downmix (L+R)*0.5 -> gain -> clamp -> i16 -> VPIO.
No errors logged. To stop guessing, add structured logging
inside the render callback so the next test cycle yields data
about what's actually flowing through.
Diagnostic emitted every 100th callback (~2 s at iOS's typical
20-50 Hz callback rate):
ios VPIO render callback diagnostic sample
cb=<counter>
num_frames=<N> VPIO buffer size in mono samples.
Expected ~960 (20ms) or ~1104 (23ms).
Outliers point at format mismatch.
peak_stereo_f32=<f32> Peak |sample| of AudioHandler's
output BEFORE gain + downmix.
0.0 = handler is producing silence
(jitter underrun, no audio).
~1.0 = full-scale content reaching
the callback as expected.
peak_out_i16=<i16> Peak |sample| of the downmixed mono
i16 we write to VPIO. Zero with
non-zero peak_stereo = downmix bug.
Near 32767 = clipping pressure.
gain=<f32> Current master output gain.
What we'll be able to diagnose from a 5-second talker session:
* peak_stereo_f32 = 0 throughout
-> AudioHandler isn't producing samples. Inbound forwarder
may not be feeding it, or jitter buffer is stuck in
buffering_samples state. NOT a render-callback bug.
* peak_stereo_f32 oscillating, peak_out_i16 = 0
-> Downmix or i16 cast is broken. Math bug in the loop.
* num_frames wildly different from ~960-1104
-> StreamFormat got rejected and VPIO is delivering a
different rate. Format-pinning fight with the session.
* peak_stereo_f32 normal AND peak_out_i16 normal AND user
still says 'broken'
-> The signal reaches the device cleanly but iOS's VPIO
output processing (AEC residual subtraction, AGC
compression, NS gate) is mangling it after our callback
returns. That's a VPIO-config problem, not a render-
callback problem; fix is to disable specific VPIO
voice-processing properties on the unit before
initialize().
Pure diagnostic commit. No behavioural change beyond a
warn-rate-limited info log line every ~2 seconds. Cost in the
audio thread is one branch + counter increment + (every 100th)
a tracing macro invocation.
Build counter 65 -> 66.
Two related fixes for the version-display work landed at 97a6ba6:
PROBLEM 1: build counter stuck at +59 on the device.
The user reported the About dialog showed v1.0.0-rc.8+59 even
though pubspec.yaml has been bumping (60 -> 61 -> 62 -> 63 -> 64).
Root cause: Xcode caches ios/Flutter/Generated.xcconfig (which
holds FLUTTER_BUILD_NUMBER) and doesn't regenerate it on Cmd+R
unless inputs change. 'flutter pub get' also doesn't rewrite it.
Only 'flutter build ios' or deleting the file forces regen.
Fix (operational, not code-side): the Generated.xcconfig file on
the Mac was deleted + regenerated and is now at
FLUTTER_BUILD_NUMBER=64, so the next Xcode Run picks up the
correct value. Going forward, if the build counter ever lags
again the workaround is:
rm ios/Flutter/Generated.xcconfig && flutter pub get
before running from Xcode. We may add this to a build-doc note
or a Makefile target during the rc.8 wrap-up.
PROBLEM 2: 'v1.0.0-rc.8' was being displayed as 'v1.0.0.8'.
CFBundleShortVersionString on iOS rejects non-numeric
characters, so Flutter strips the '-rc.8' suffix to '.8' when
populating Info.plist. package_info_plus.version reflects that
mangled value. CFBundleVersion (the build counter) is passed
through intact, so the issue affects only the semver half.
Fix: split _kAppVersion resolution. Hardcode the semver baseline
as _kSemverBaseline = 'v1.0.0-rc.8' (kept in sync with the git
tag + pubspec semver portion; bumped once per release-candidate
cycle, not per test build). Use package_info_plus for the
'+<buildNumber>' suffix only, where CFBundleVersion survives the
sanitiser unchanged. Final display becomes
'v1.0.0-rc.8+<n>' (e.g. 'v1.0.0-rc.8+65').
flutter analyze: clean.
Build counter 64 -> 65. After Xcode Clean Build Folder + Run the
About dialog should now read 'v1.0.0-rc.8+65' on the iPhone.
Replace the silence-emitting render callback from commit 1 with
real playback that drives AudioHandler::fill_buffer and downmixes
its 48 kHz stereo f32 output to the i16 mono buffer VPIO expects.
Pipeline per render callback (mirrors the cpal-output + sdl_output
contracts so the platform-neutral playback path is preserved):
1. Lock the shared Arc<Mutex<AudioHandler>>, ask fill_buffer to
populate a stereo-f32 scratch slice of length 2*num_frames.
AudioHandler runs Opus decode + per-client jitter buffer + mix
internally. Same primitive every other platform calls.
2. If output_muted is true, zero the i16 output buffer and return.
We still ran fill_buffer in step 1 so the jitter buffer drains
while muted — preventing unbounded growth — which matches the
cpal/SDL backend contract.
3. Downmix stereo -> mono with master gain:
mono_f32 = (l + r) * 0.5 * gain
i16_out = (mono_f32.clamp(-1.0, 1.0) * i16::MAX) as i16
The 0.5 average preserves total signal energy with 3 dB
headroom against sum-of-correlated-peaks clipping. Multiply by
gain after the downmix saves one mul per sample. Hard-clip on
the i16 cast is acceptable because the upstream stereo signal
is already in [-1.0, 1.0] from the f32 mix; only gain >1.0
creates clipping pressure and that path is identical to every
other backend's i16 conversion.
Closure ownership:
* scratch_stereo: Vec<f32> moved into the FnMut closure. First
callback grows it to 2*num_frames; subsequent callbacks reuse
the backing allocation. The audio thread never hits the
allocator on steady-state callbacks.
* handler_for_render / output_gain_for_render / output_muted_for_render
are Arc clones taken before the closure literal.
Public API change: AudioEngine -> IosVoiceUnit::start parameters
that were previously underscored (commit 1 placeholder) are now
all consumed by the wiring. Signature is unchanged, just the
binder names lose the leading underscore. engine.rs call-site
is unaffected.
Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 0.33s, no errors, no warnings.
Build counter 63 -> 64 — About dialog shows v1.0.0-rc.8+64.
Replace the no-op input callback from commit 1 with a real
capture pipeline that mirrors the cpal-side CaptureState in
engine.rs but is type-specialised for the i16 mono samples VPIO
delivers natively.
New IosCaptureState struct (private to ios_voice_unit.rs) owns:
* OpusEncoder configured for VoIP at 48 kHz mono (32 kbps,
complexity 10, inband FEC, packet-loss-perc 5 — identical
tuning to try_open_capture in engine.rs).
* pcm_accum: Vec<i16> with capacity 2*FRAME_SAMPLES_MONO, growing
if a VPIO callback ever delivers more than ~40 ms.
* opus_out: [u8; MAX_OPUS_FRAME] scratch.
* Cloned Arc<AtomicBool> transmit gate + Arc<AtomicU32> frames-sent
counter shared with AudioEngine.
ingest_i16 flow:
1. If PTT gate is off -> clear accumulator + return (matches cpal
behaviour, no pop on PTT-release edge).
2. Apply mic_gain. Fast-path when gain==1.0 skips the multiply +
saturate loop entirely; otherwise saturating mul-then-cast
keeps the signal in the i16 envelope.
3. Drain complete 20 ms / 960-sample frames from the accumulator,
encode via encoder.encode (i16 path, no float conversion
needed since VPIO already gave us i16), build OutPacket with
AudioData::C2S { codec: OpusVoice }, try_send on voice_out_tx.
4. Frame buffer is stack-allocated [i16; FRAME_SAMPLES_MONO] —
no per-callback heap allocation on the realtime audio thread.
VPIO setup changes in IosVoiceUnit::start:
* NEW: explicit kAudioOutputUnitProperty_EnableIO (=2003) with
value 1 on (Scope::Input, Element::Input) BEFORE the stream
format setters. VPIO's input element is OFF by default; without
this toggle no audio flows in and the input callback never
fires. Commit 1's comment claiming set_input_callback handles
this was wrong; coreaudio-rs's set_input_callback only installs
the kAudioOutputUnitProperty_SetInputCallback property, not
the EnableIO toggle.
* Apple's documented sequence (now matched):
1. AudioComponentInstanceNew -> AudioUnit::new_uninitialized
2. EnableIO on element 1 -> set_property(2003, ...)
3. Stream format both elems -> set_stream_format x2
4. Install callbacks -> set_input_callback + set_render_callback
5. AudioUnitInitialize -> unit.initialize
6. AudioOutputUnitStart -> unit.start
* set_input_callback closure now moves the IosCaptureState in
by value and calls ingest_i16 with args.data.buffer (the
&mut [i16] coreaudio-rs delivers after running AudioUnitRender
internally to pull the mic samples into a pre-allocated
AudioBufferList).
What this commit does NOT do:
* Output render callback is still a silence-emitting stub.
Commit 4 lands the AudioHandler::fill_buffer + i16 downmix.
* Route-change handling — commit 5.
Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 1.18s, no errors, no warnings.
Build counter 62 -> 63 — About dialog shows v1.0.0-rc.8+63.
Two follow-ups after the iOS Rust build went green at 5de6ecc:
1. cpal-side framing constants (SAMPLE_RATE / FRAME_SAMPLES /
MAX_OPUS_FRAME) are dead code in the current iOS commit
because the VPIO callbacks are still no-op stubs and don't
reach the constants yet (commits 3 + 4 will). They are
genuinely live on every other platform via the cpal capture
pipeline. Mark each with #[allow(dead_code)] and add a
comment pointing at the commits that will reactivate them on
iOS, instead of cfg-gating per-platform (the constants are
framing invariants of the engine itself, not per-backend
details).
2. ios/Podfile.lock regenerated on the Mac via 'pod install'
to register package_info_plus (0.4.5) which landed in
97a6ba6. Without this regen the Xcode build fails with
'The sandbox is not in sync with the Podfile.lock' because
Xcode's CocoaPods integration check sees a new plugin in
pubspec.yaml that has no matching Pod entry. Five pods now
in the lockfile: Flutter, audio_session, chanora_bridge,
connectivity_plus, package_info_plus.
Build counter 61 -> 62 — the About dialog will display
v1.0.0-rc.8+62 so the user can confirm the build under test
matches this commit (the previous build said +61).
iOS build of chanora_audio (target aarch64-apple-ios) failed with
four compilation errors after commit 2 landed. Root causes were
all simple symbol-path / cfg-gating mistakes from the skeleton
commit; the underlying design is unchanged.
1. ios_voice_unit.rs: wrong import path for OutPacket. The
chanora_protocol crate re-exports it at the crate root
(`pub use ...::OutPacket` in lib.rs line 52), not from a
`voice` submodule (which doesn't exist).
- use chanora_protocol::voice::OutPacket;
+ use chanora_protocol::OutPacket;
2. ios_voice_unit.rs: LinearPcmFlags lives in
`coreaudio::audio_unit::audio_format`, not in
`stream_format` (the doc page lists it under StreamFormat but
the actual module path is the upstream Apple naming).
- use coreaudio::audio_unit::stream_format::LinearPcmFlags;
+ use coreaudio::audio_unit::audio_format::LinearPcmFlags;
3. ios_voice_unit.rs: `Ordering` import unused (commit 1
skeleton callbacks don't load atomics yet — that comes in
commits 3 + 4). Remove from the std::sync::atomic import to
silence the unused_imports warning.
4. engine.rs::AudioEngine::stop(): the existing body unconditionally
touched self._input_stream and self._output_stream, but commit
2 cfg-gated those fields away on iOS (and added an iOS-only
_ios_voice_unit field in their place). Split the field drop
logic with the same target_os = "ios" cfg so each platform
only touches the fields it actually has.
Also clean up two pre-existing warnings exposed by the iOS cfg
gating:
5. engine.rs: `tracing::{error, warn}` were imported
unconditionally but are only used inside cpal log lines.
Cfg-gate the import to not(target_os = "ios").
6. engine.rs: `AudioData`, `CodecType`, `OutAudio` from
chanora_protocol are only referenced in the Opus encoder feed
inside CaptureState — cpal-side only. Cfg-gate to
not(target_os = "ios"); keep `InboundVoice` + `OutPacket`
on the unconditional path because the inbound forwarder + (in
commit 3) the iOS capture pipeline both reference them.
Also fix a stray duplicate `#[cfg(not(target_os = "ios"))]`
attribute that landed on line 18 in commit 2.
Build verify (Linux host): cargo check -p chanora_audio clean
in 0.53s. iOS-side check pending on Mac.
Build counter bumped 60 -> 61 — the About dialog will display
v1.0.0-rc.8+61 so the user can confirm the build under test
matches this commit.
Add package_info_plus 8.3.1 (Flutter Community Plus, BSD-3, ~3M
downloads) and resolve the displayed version string from the
platform manifest at app init. The string shown in the About
dialog is now formatted as 'v<version>+<build>' (e.g.
'v1.0.0-rc.8+60') where both halves come from the SAME
pubspec.yaml 'version:' field that Flutter uses to populate iOS's
CFBundleShortVersionString + CFBundleVersion and Android's
versionName + versionCode.
Motivation: during the iOS VoiceProcessingIO audio rollout the
user is rebuilding repeatedly from Xcode. Without a build-number
suffix there is no way to confirm from inside the app which
commit produced the build under test — they all read
'v1.0.0-rc.8'. Every test commit going forward bumps the +<build>
field in pubspec.yaml so the About dialog uniquely identifies the
build.
Implementation:
* pubspec.yaml: version 1.0.0-rc.8+59 -> +60 (this commit is a
test build). Add package_info_plus: ^8.0.0.
* main.dart: _kAppVersion changes from 'const String' to
'String' (resolved at runtime). Populated in main() before
runApp() via new _resolveAppVersion() helper that calls
PackageInfo.fromPlatform() and formats 'v<info.version>+<info.buildNumber>'.
Falls back to the hardcoded 'v1.0.0-rc.8' baseline if the
platform call fails (extremely unlikely; the platform channel
is a constant lookup).
The consumer site at the About dialog (l10n.aboutVersion(_kAppVersion))
is unchanged — the variable still resolves to a String. flutter
analyze: clean.
Split AudioEngine::start_with_gate into two backends:
* start_with_gate_cpal — non-iOS path, the existing cpal + (SDL on
Linux) flow, renamed verbatim, no
behavioural change.
* start_with_gate_ios — iOS path, constructs a single
IosVoiceUnit (VoiceProcessingIO via
coreaudio-rs) for combined mic + speaker.
Spawns the same inbound forwarder task
that pumps Opus packets into AudioHandler.
The public entry point start_with_gate dispatches at the top via
cfg(target_os = "ios") so callers stay backend-agnostic.
Struct field changes:
* _input_stream : cfg-gated to not(ios)
* _output_stream : cfg-gated to not(ios), keeps the
Linux=SdlOutput / else=cpal::Stream split
* _ios_voice_unit: new field, cfg-gated to ios, owns the VPIO
AudioUnit for the engine's lifetime.
Module-level cfg-gating:
* All cpal-only helpers (try_open_capture, build_input_stream,
build_output_stream, CaptureState + impl, ToF32 / FromF32 traits
and impls, PlaybackResampleState) are now wrapped with
#[cfg(not(target_os = "ios"))]. Same for the audiopus
encoder + cpal trait imports — iOS doesn't pull libopus into the
engine yet (commit 3 will, once the VPIO input callback wires
into CaptureState).
Behaviour on iOS for THIS commit:
* AudioEngine starts cleanly, IosVoiceUnit::start succeeds (VPIO
unit allocates + initialises + starts).
* Mic capture is dropped (the input callback is a no-op stub).
* Output emits silence (the render callback fills the buffer with
zeros).
* Inbound forwarder still runs and pushes Opus packets into
AudioHandler — they accumulate in the jitter buffer but no
fill_buffer drain happens (commit 4 fixes that), so the buffer
will grow up to MAX_BUFFER_TIME (~0.5 s) and then tsclientlib
starts dropping the oldest frames. This is fine for now — the
point of this commit is verifying the AudioUnit constructs +
starts cleanly on the device. Audible silence is the expected
state until commits 3/4 land.
Build verify (Linux host): cargo check -p chanora_audio clean in
0.53s. iOS-side compile happens on the Mac via the Xcode build
the user will trigger next.
Skeleton scaffolding for the iOS VoiceProcessingIO backend that
will replace cpal on iOS. This commit lands the dependency + the
module + a constructable AudioUnit that emits silence and drops
input; nothing in engine.rs is wired up yet (that is commit 2).
Compilation contract for this commit:
* Linux / Windows / macOS / Android builds unaffected (the new
module is target_os='ios' gated, the new dep is in
'[target."cfg(target_os = \"ios\")"]').
* iOS build pulls in coreaudio-rs 0.14, constructs a VPIO unit,
pins stream format to 48 kHz Int16 mono on both buses, installs
no-op input + silence-emitting render callbacks, initializes,
and starts. No audio is actually moved until commits 3/4.
Why VPIO and not RemoteIO via cpal: cpal's iOS backend opens
RemoteIO with no control over stream format / buffer size /
channels and produces a mono-only output element that stays bound
to the route present at construction time. End-user symptom on
iPhone 16 Pro iOS 18.7.8: tapping Speaker in the picker flips
AVAudioSession.currentRoute.outputs to Speaker (confirmed in our
diagnostic logs from commit da631a2) but audio keeps coming out
the receiver because the AudioUnit's output binding is stale.
Every production iOS VoIP client (Mumble iOS, Linphone /
mediastreamer2, Signal-iOS, Jitsi Meet iOS, the WebRTC reference
impl) avoids RemoteIO and uses VoiceProcessingIO instead. VPIO is
Apple's recommended voice unit; it ships hardware AEC + AGC + NS
and re-binds the physical transducer correctly on route changes
because it IS the canonical voice unit on iOS — FaceTime's audio
path runs through it.
coreaudio-rs 0.14 (RustAudio org, 8.6M downloads, same maintainers
as cpal) gives us a safe wrapper around the AudioUnit C API on
iOS. Uses objc2-* crates underneath so links cleanly into iOS
builds. ios_voice_unit.rs sits next to sdl_output.rs as the iOS
sibling of the Linux SDL2 output path.
Build verify (Linux host): `cargo check -p chanora_audio`
finished clean in 16.09s. iOS build verification happens in
commit 2 when the module is exercised; for this commit the module
compiles in isolation but is dead code on iOS too (no caller).
Two changes that together address the user-reported 'speaker selector
not working' AND 'audio quality bad' symptoms on iPhone:
1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default
.voiceChat binds the underlying AudioUnit's output element to a
SINGLE physical transducer (the receiver/earpiece) at session-
configure time. overrideOutputAudioPort updates AVAudioSession's
route metadata so currentRoute.outputs reports Speaker, but the
AudioUnit's output binding is stale and audio keeps routing to
the original transducer. Net: tapping Speaker in the picker
flipped the route in our log but produced no audible change.
.voiceChat also enables iOS's telephony processing chain (forced
mono output, aggressive AGC, heavy noise gating) which explains
the 'garbled / watery / metallic' quality complaints.
.default mode uses iOS's standard audio graph: stereo output, no
AGC, no telephony post-processing, AudioUnit re-binds live when
the route changes. Same mode Music.app and most non-telephony
apps use. Trade-off: we lose iOS hardware AEC. If users report
speakerphone echo we'll add software AEC (DEC-030).
Category options unchanged \u2014 .allowBluetoothHFP +
.allowBluetoothA2DP still permit BT headsets in both directions.
2. engine.rs::build_output_stream: mono device downmix fix
The mixing path at dev_channels==1 previously wrote only the L
channel of AudioHandler's stereo output into the single mono
device channel and discarded R entirely. Anything panned right
in the stereo voice mix was silently lost \u2014 on .voiceChat
speakerphone (forced mono device) this manifested as quiet
remote speakers being inaudible. Fix: when dev_channels==1,
output = (L + R) * 0.5 instead of just L. The dev_channels>=2
branch is unchanged.
With change #1 iOS will typically expose stereo so this branch
is rarely hit, but the fix is correct for any genuinely-mono
sink (some BT car-audio profiles, USB mono headsets).
Add structured NSLog instrumentation to AppDelegate.swift and debugPrint
chains in voice_compact.dart::_AudioOutputPickerSheetState so we can
correlate user picker taps with what iOS actually does to the route.
Three diagnostic streams:
* 'chanora.session[<tag>]' from Swift — full session snapshot (category,
mode, sampleRate, ioBufferDuration, current route inputs+outputs,
preferredInput) emitted on every setActive and every
AVAudioSession.routeChangeNotification with the reason decoded
(override / routeConfigurationChange / newDeviceAvailable / etc).
* 'chanora.route[<tag>]' from Dart — current route's inputs+outputs
emitted before/after every overrideOutputAudioPort or
setPreferredInput call, plus a delayed re-check at +250 ms to detect
silent reverts.
* Existing 'chanora: ...' debugPrint lines from the picker now include
the OK case (override returned, setPreferredInput returned) so we see
a positive signal in the log when the API didn't throw.
Used to root-cause the 'speaker selector not working' issue: the
hypothesis is that cpal's RemoteIO AudioUnit reacts to its own format
configuration notifications by triggering routeConfigurationChange
that reverts our Dart-side override. The logs will confirm or deny
this — if we see 'chanora.session[routeChange.override] out=Speaker'
followed by 'chanora.session[routeChange.routeConfigurationChange]
out=Receiver' within a few hundred ms, that's the smoking gun.
Pure diagnostic commit. No behavioural change. Logs are NSLog +
debugPrint so they appear in Xcode console / 'flutter logs' / the
device log via Console.app or 'devicectl device log'.
User report: 'speakerphone (built-in mic input)' logs printed
successfully but audio output didn't actually switch to speaker.
No exception thrown by either AVAudioSession call.
Root cause:
Previous _selectSpeaker called:
1. await overrideOutputAudioPort(.speaker)
2. await setPreferredInput(builtInMic)
Apple-documented behavior in .voiceChat mode: when
setPreferredInput is called, iOS recalculates the entire route
based on the natural input/output pairing for the selected port.
Built-in mic's natural output pairing is the receiver/earpiece
(matches the 'I'm talking on a phone' UX of .voiceChat). So step 2
caused iOS to SILENTLY REVERT the speaker override from step 1
and route output back through the earpiece.
Net: await chain returned without exception (both calls 'succeeded'
in API terms), debugPrint logged the success message, but the user
heard the call audio coming out of the earpiece, not the speaker.
Same issue affected _selectReceiver (after the speaker bug fix
was reverted): redundant setPreferredInput(builtInMic) call risked
the same recalc race.
Fix:
* _selectSpeaker: only call overrideOutputAudioPort(.speaker).
No setPreferredInput. The override alone is sufficient \u2014 the
input stays on whatever the system was already using (built-in
mic by default, or BT/wired if connected).
* _selectReceiver: only call overrideOutputAudioPort(.none).
No setPreferredInput. Removing the speaker override naturally
returns to .voiceChat's default route (receiver).
* _selectInput (BT / wired / USB): unchanged. These inputs PAIR
their own output device, so .none + setPreferredInput is the
correct combo (user hears audio through the same device they
speak into).
flutter build ios --release --no-codesign: 21.2 s, Runner.app
30.4 MB.
User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.
Root cause:
AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'
When the user picked Speaker:
overrideOutputAudioPort(.speaker) <- already at speaker baseline; no-op
When the user picked iPhone receiver:
overrideOutputAudioPort(.none) <- removes speaker OVERRIDE,
restores baseline = .defaultToSpeaker
= speakerphone. Receiver
row silently mapped to speaker.
So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.
Fix:
1. AppDelegate.swift: drop .defaultToSpeaker from options. With
pure .voiceChat mode (no .defaultToSpeaker), the baseline is
the receiver/earpiece. overrideOutputAudioPort then works as
documented:
Default = receiver
overrideOutputAudioPort(.speaker) -> speakerphone
overrideOutputAudioPort(.none) -> back to receiver
BT/AirPods connected -> automatic
Wired headphones plugged in -> automatic
2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
swallow with debugPrint logging of (a) the actual exception
and (b) which route was selected. So if iOS rejects an
override (e.g. wired headphones plugged in), we can see WHY
in the device log instead of a silent picker no-op.
3. _selectSpeaker also now sets preferredInput to the built-in
mic so input + output stay consistent. Previously the
speakerphone override could leave the mic still routed to a
previously-selected BT input \u2014 user hears self through
speaker but server hears nothing.
flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
User report: 'sound heard are too poor' on iPhone iOS \u2014 garbled/
robotic + stutters/dropouts.
The Opus encoder ran with audiopus defaults: 'auto' bitrate that
drops to ~6 kbps during silence (sounds watery on speech resume),
inband FEC disabled (single packet loss = silent gap), no packet-
loss percentage hint (encoder can't budget bits for redundancy).
On lossy mobile networks (cell, WiFi roaming) this combination
sounds noticeably worse than the same Opus stream from a desktop
client. Garbled = silence-bitrate transitions; stutters = packet
loss without FEC.
Fix: tune the encoder once at construction with values derived
from RFC 6716 \u00a77.1 (Opus VoIP recommendations), Discord's voice
client tuning, and the Mumble defaults.
* set_bitrate(32_000) \u2014 sweet spot for mono speech. Below
24 kbps starts to sound watery;
above 64 kbps wastes bandwidth.
Discord uses 64 kbps; Mumble
defaults to 40 kbps; we pick
32 kbps as a conservative VoIP
value that survives ~100 kbps
uplinks comfortably.
* set_complexity(10) \u2014 max quality. The CPU cost on a
modern iPhone (A14+) or any
desktop is negligible (~0.5 % of
a single core for 48 kHz mono).
* set_inband_fec(true) \u2014 Opus inserts a low-bitrate copy
of the previous frame inside the
current packet so single-packet
loss can be reconstructed from
the next packet. Essential on
lossy mobile. The decoder side
(tsclientlib's AudioHandler)
auto-handles FEC frames; no
receiver-side change needed.
* set_packet_loss_perc(5) \u2014 tells the encoder to budget
bits for 5 % expected loss.
Higher values trade audio
quality for resilience.
Each setter is wrapped in a soft-fail: if an exotic libopus build
rejects one of these, we log + continue with the still-functional
encoder rather than aborting the audio engine. info!-log a one-
liner per-engine-start summarising the tuned values so a future
diagnostic export can correlate audio reports with the active
configuration.
Application::Voip mode was already set (engine.rs:630, unchanged);
the new calls layer on top of that mode's defaults.
cargo test -p chanora_audio --release --lib: 32 passed.
flutter build ios --release --no-codesign: 17.2 s, Runner.app
30.4 MB.
Two user-reported issues addressed:
1. 'when user join the server aka default channel, but there are
no ptt button also can not talk'
Root cause: TS3 servers auto-place a newly-connected client into
the server's default channel. We did not detect that. The UI
gated all voice controls (PTT button, mic/headset AppBar icons,
voice modal entry point) on _inChannel which was only flipped
true by an explicit voice_join() call from the user. So after
connect the user saw themselves in the default channel via the
channel tree but had no way to talk.
Fix: in chanora_core::Session::connect(), after the initial
snapshot resolves, call find_own_in(&snap) to determine whether
the server placed us in a channel. If yes, voice_selector
.set_in_channel(true) + emit SessionEvent::VoiceState
{ in_channel: true } + ensure_audio_running. This treats the
server-side default channel placement identically to a user-
driven voice_join: the UI receives a VoiceState(true) event and
renders all voice controls.
Tolerates audio engine startup failure the same way voice_join
does \u2014 server-side we are in the channel regardless; if mic
permission / device init fails, the UI gains the controls and
emits SessionEvent::AudioStopped so the user can resolve the
underlying issue.
Drops the inner lock before calling the public helpers because
set_in_channel + emit_voice_state + ensure_audio_running all
re-lock self.inner.
2. 'save bookmark cause a crash framework.dart line 6268
_dependents.isEmpty is not true'
Root cause: the showDialog-with-inline-TextEditingController-
dispose anti-pattern. _onAddCurrentBookmark and
_askChannelPassword both constructed a TextEditingController in
the surrounding async function, passed it to a dialog's
TextField via the dialog builder, then called ctl.dispose()
synchronously after returned.
On iOS the dialog route pop animation is still mid-flight when
showDialog's Future resolves. The inline dispose tore the
controller out from under EditableText while EditableText still
held InheritedWidget dependencies on the dialog route (theme,
localizations, default text style). When the dialog route's
InheritedElement then deactivated as part of the pop animation,
the framework's debug-only assertion _dependents.isEmpty tripped
because the disposed dialog tree had not finished detaching its
dependents yet.
Fix: hoist both dialogs into dedicated StatefulWidgets
(_BookmarkNameDialog and _ChannelPasswordDialog) that own their
own TextEditingController. The State.dispose() runs as part of
the dialog's normal unmount lifecycle, AFTER the pop animation
completes and all InheritedWidget dependencies have been cleared.
No race possible.
Bonus: dialog builders now use the dialog's own ctx for
AppL10n.of(...), Theme.of(...), and Navigator.of(...) calls
uniformly, rather than capturing the outer _HomePageState
context's l10n in a closure. That avoids a secondary leak where
the dialog widget tree held references back to the outer
route's InheritedElements through closure capture.
Added onSubmitted: -> pop(_ctl.text) on both fields so iOS
hardware-keyboard 'return' submits the dialog (small UX win
discovered while restructuring).
iOS build: flutter build ios --release --no-codesign 20.3 s,
Runner.app 30.4 MB. flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged). cargo test -p chanora_core --release
--lib: 13 passed.
User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.
Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.
Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:
* AVAudioSession.availableInputs \u2014 enumerate every real input
port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
(wired), usbAudio, carAudio, airPlay.
* AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
active route.
* AVAudioSession.setPreferredInput(port) \u2014 switch the input
(HFP / wired / USB / car audio also move output to themselves).
* AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
toggle built-in speakerphone vs receiver/earpiece.
* AVAudioSession.routeChangeStream \u2014 live notifications when
the user plugs / unplugs / connects a device while the picker
is open.
This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.
New widgets in voice_compact.dart:
* _AudioOutputTile: shows the active output port name (Speaker /
iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
matching icon. Subscribes to routeChangeStream for live
updates. Tap opens _AudioOutputPickerSheet.
* _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
and a Discord-style list:
- Speaker (volume_up)
- iPhone (phone_in_talk; the receiver/earpiece)
- <BT name> (bluetooth_audio)
- <Wired headset> (headset)
- <USB / Car> (usb / directions_car)
Selected row is highlighted + has a check mark. Tap routes:
- Speaker -> overrideOutputAudioPort(.speaker)
- iPhone -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
- External -> overrideOutputAudioPort(.none) + setPreferredInput(port)
* _PickerRow: shared row widget with selected/check styling.
AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(0466000 / 4ee2b38) is fully compatible with audio_session \u2014 the
plugin only adds Dart-side accessors over the same underlying
AVAudioSession singleton.
Removed l10n keys not used anymore (audioRouteUsb was already gone).
Kept audioRouteSpeaker / Receiver / Bluetooth / WiredHeadset /
CarAudio / Airplay / Unknown \u2014 all still used by the new picker.
flutter analyze: 6 pre-existing Radio deprecation infos (unchanged).
flutter build ios --release --no-codesign: 54.9 s, Runner.app
30.4 MB (+200 KB vs audio_router build).
User reported 'package:flutter/src/widgets/framework.dart line 6268
_dependents.isEmpty is not true' assertion AND the persistent
keyboard lag.
Root cause analysis:
framework.dart:6268 is the assertion in
InheritedElement.debugDeactivated() that fires when an
InheritedElement is being deactivated while descendants still
depend on it. This fires in debug builds; release builds skip it.
The previous tap-outside-to-dismiss implementation used:
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: Column(...),
);
The FocusScope.of(context) call subscribes this GestureDetector's
Element to the _FocusScopeMarker InheritedWidget on every build.
During the modal-sheet-pop / Navigator-deactivate sequence, the
ancestor _FocusScopeMarker InheritedElement can deactivate before
the descendant GestureDetector clears its dependency, tripping the
debug assertion.
This was the same root cause as the keyboard lag: an outer
GestureDetector in the gesture arena ahead of the TextField's own
recognizer ALWAYS interferes, either via the opaque-vs-translucent
arena race (causing lag) or via the InheritedWidget subscription
race (causing the assert).
Fix: remove the outer GestureDetector entirely. Use the built-in
TextField.onTapOutside callback added in Flutter 3.10+ instead:
TextField(
...
onTapOutside: _onTapOutside,
)
void _onTapOutside(PointerDownEvent _) {
FocusManager.instance.primaryFocus?.unfocus();
}
Why this is strictly better:
* FocusManager.instance is a global singleton with NO
BuildContext dependency. No InheritedWidget is subscribed; no
_dependents map grows; no race possible on dispose.
* TextField.onTapOutside uses the framework's TapRegion machinery
internally. Taps inside the field's TapRegion don't fire the
callback; only true outside-region taps do. Zero gesture-arena
interference with the TextField's own tap recognizer \u2014
keyboard appears synchronously on the first tap with no lag.
* iOS's default _EditableTextTapOutsideAction at
flutter/widgets/editable_text.dart:6748-6755 is intentionally a
no-op for touch on mobile (Apple convention: dismiss via Done
or swipe-down). Our explicit onTapOutside override is the
correct way to opt into tap-outside-to-dismiss on mobile
without fighting iOS conventions or the framework's gesture
arena.
Wired onTapOutside on all three connect form fields (host /
nickname / password).
flutter build ios --release --no-codesign: 20.5 s, Runner.app
30.2 MB (unchanged). flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged).
User reported on iPhone iOS 18: 'still a bit lag and stuck' after
the _kickFocus removal (1ceb47f). Web research (flutter/flutter
keyboard performance issues) and code review of GestureDetector
hit-test semantics identified the remaining race.
Root cause:
The outer GestureDetector wrapping the connect form Column was
using HitTestBehavior.translucent. Translucent semantics dispatch
the pointer event to BOTH the GestureDetector AND any descendant
hit-test target. So when the user tapped a TextField, two things
fired simultaneously:
1. GestureDetector.onTap -> FocusScope.of(context).unfocus()
This drove the keyboard *down* via the platform TextInput.hide
side effect of clearing focus.
2. TextField's own TapGestureRecognizer -> EditableText.attach
This drove the keyboard *up* via TextInput.show.
The two CAAnimations on iOS 18 raced each other inside the same
UIKit transaction, producing:
* 500-1000 ms of visible 'thinking' before the keyboard appeared
(one full slide-down + one full slide-up).
* Occasional 'stuck' state where the keyboard never came back up
because UIResponder.becomeFirstResponder was called before
resignFirstResponder finished.
Fix: switch to HitTestBehavior.opaque. With opaque:
* The GestureDetector still receives hit-test results for its
entire bounds (so taps on empty padding between fields still
reach onTap).
* But the gesture arena routes a tap that lands on a TextField
to that TextField's recognizer ONLY \u2014 the outer
GestureDetector loses the arena and its onTap does not fire.
* Net: tapping a field is exactly equivalent to having no outer
GestureDetector at all (no race, no lag, no stuck). Tapping
empty space still dismisses the keyboard cleanly.
This is the canonical pattern that several Stack Overflow answers
and the GestureDetector dartdoc recommend for 'tap-outside-to-
dismiss-keyboard'. translucent is for cases where you want both
the outer and inner to react simultaneously (rare).
flutter build ios --release --no-codesign: 29.0 s, Runner.app
30.2 MB.
User reported: 'amount need wait 500ms - 1s if i click input field
-> then keyboard popup'. The 79f8360 _kickFocus workaround was the
source of the lag.
Root cause of the lag:
void _kickFocus(FocusNode node) {
if (node.hasFocus) node.unfocus();
Future.microtask(() { // <-- this microtask
if (!mounted) return;
node.requestFocus();
});
}
The Future.microtask deferral forces EditableText's attach-to-
TextInput path to wait one frame past the user's pointer-up. iOS
26's keyboard slide-up animation then dovetails into that extra
frame in a way that adds another 200-800 ms before the keyboard
actually appears on screen. Net latency: ~500-1000 ms.
Fix: remove _kickFocus entirely. Rely on:
1. TextField's native onTap path (no onTap override = no
deferral, no microtask hop, no SystemChannels race).
2. The tap-outside-to-unfocus GestureDetector wrapping the
connect form Column (7c62d14) which already guarantees the
FocusNode is in the unfocused state when the user taps any
field, because any prior keyboard dismissal (tap outside / tap
a sibling field) goes through FocusScope.of(context).unfocus().
This means the FocusNode is always in a clean false state when a
TextField gets tapped, so EditableText's own attach path can fire
synchronously on the first frame and the keyboard appears
instantly.
The full _kickFocus implementation is retained as a code comment
above the connect-form's build() for documentation and quick
re-introduction should iOS regress again. The flutter/flutter#181474
issue (the underlying iOS 26 bug) remains open, so the comment
documents the canonical workaround if needed.
flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
(unchanged). flutter build ios --release --no-codesign: 20.4 s,
Runner.app 30.2 MB.
Three user-reported issues addressed at once.
1. Audio output displaying as Unknown on iOS
The route tile only set _device from currentDeviceStream events,
which fire on route *changes*. On first sheet open with no route
change yet, _device was null \u2192 _deviceLabel fell through to
audioRouteUnknown.
Fix: query AudioRouterPlatform.instance.getCurrentDevice() in
initState before attaching the stream listener. Plugin returns the
current AVAudioSession route synchronously (well, via Future) so
the tile renders Speaker / iPhone receiver / AirPods / etc.
immediately on first open. Errors swallowed \u2014 the stream remains
authoritative for subsequent updates.
2. 'Adjust mode & release tail' too deep (chip \u2192 modal \u2192 button \u2192 dialog)
Inlined the mode radio buttons and release-tail slider directly
into the voice modal sheet. Dropped the OutlinedButton 'Adjust'
trigger and the nested VoiceSettingsDialog dispatch entirely on
mobile.
Modal sheet is now a single-screen control panel:
Title 'Voice'
--------
Audio output: <current route> > (iOS/Android only)
--------
Transmit mode
\u25c9 PTT
\u25cb Continuous
\u25cb Voice activity (Coming soon) (disabled)
--------
Release tail 200 ms
[\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501] (0\u20131000 ms, step 50)
Bound key: F (desktop only)
--------
Level meter
TX/RX frame counts
PTT capability badge (desktop only)
VoiceSettingsDialog is retained for the wide-mode VoiceBar
'configure' button (desktop entrypoint) and the PTT-bind flow, so
desktop UX is unaffected.
New widgets: _VoiceSheetBody (StatefulWidget with local _mode +
_tail), _ModeRow (RadioListTile-shaped row with optional disabled
state for VoiceActivity). New API on showVoiceDetailsSheet:
onModeChanged + onReleaseTailChanged callbacks (replace
onAdjustVoiceSettings). Wiring in main.dart writes through to
rust.setTransmitMode / rust.setReleaseTailMs and mirrors _state.
l10n: dropped voiceAdjustSettings (en + zh). Added voiceBoundKeyLabel
(en + zh) for the desktop-only bound-key row.
3. iOS first-tap-keyboard regression (flutter/flutter#181474)
The 79f8360 _kickFocus workaround (unfocus + microtask refocus on
every TextField.onTap) was kept, but extended with a tap-outside-
to-unfocus GestureDetector wrapping the connect form Column. This
guarantees the FocusNode is in the unfocused state when the next
field tap arrives, so the focus transition is always false\u2192true
on first tap.
GestureDetector(HitTestBehavior.translucent, onTap: unfocus) is the
canonical pattern recommended in the flutter/flutter#181474 thread
+ several older iOS keyboard issues. Translucent behaviour means it
catches taps on the column padding / empty regions without
swallowing taps on the TextFields themselves (those have
onTap: _kickFocus already).
flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.9 s, Runner.app 30.2 MB (unchanged).
Awaiting iPhone retest to confirm all three fixes.
User reported: 'input field still need to click twice' on iPhone +
'a bit lag after keyboard pop up'. The previous Listener-based
approach (020be77 \u2192 23bd1c7) actively made both symptoms worse.
Root cause is a confirmed open Flutter framework bug:
flutter/flutter#181474 \u2014 [iPadOS] Keyboard is dismissed, but the
TextField keeps focus, causing subsequent taps not to trigger
keyboard presentation.
Open, P2, triaged-text-input, platform-ios, e: OS-version specific.
Reported on iPadOS 26.2 + Flutter 3.38.1 in Jan 2026 by
Crazymuyang.
Reproduction matches our symptom exactly: iOS 26 dismisses the soft
keyboard (e.g. on tap-outside, or in some fresh-launch states), but
the EditableText's FocusNode keeps hasFocus = true. Because the
node is already focused, the next user tap is a no-op from the
focus system's perspective, so the platform TextInput channel is
never re-opened and iOS keeps the soft keyboard hidden until a
second tap finally triggers an explicit re-focus path.
Why our previous attempts failed:
* 020be77 wrapped each TextField in a Listener that called
requestFocus pre-arena. That's racing the wrong layer \u2014 it
doesn't help when the bug is 'node is already focused, so
requestFocus is a no-op'.
* 23bd1c7 added SystemChannels.textInput.invokeMethod('TextInput.show')
to the same Listener. This forced the keyboard up but raced
EditableText's own attach path, producing the post-attach
typing lag the user reported.
Fix: the community-recommended workaround in the issue thread \u2014
on every TextField tap, **unfocus first, then re-request focus on
the next microtask**. This forces a real false\u2192true focus-change
transition that re-opens TextInput on the first tap. No platform
channel races, no gesture-arena fighting, no Listener wrappers.
void _kickFocus(FocusNode node) {
if (node.hasFocus) node.unfocus();
Future.microtask(() {
if (!mounted) return;
node.requestFocus();
});
}
TextField(
onTap: () => _kickFocus(_hostFocus),
...
)
Wired into all three connect-form fields (host / nickname /
password). Removed the now-redundant _focusOnTap Listener helper.
No-op on hosts where #181474 doesn't reproduce \u2014 the unfocus call
is a no-op when the node isn't focused, and the microtask
requestFocus is what TextField would have done anyway via its own
TapGestureRecognizer.
flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.6 s, Runner.app 30.2 MB.
User reported: 'input field still need to click twice' on iPhone.
The 020be77 Listener + requestFocus() approach was insufficient.
Root cause analysis:
* Flutter's EditableText opens the platform TextInput method
channel (which is what slides the iOS soft keyboard up) only
after a TapGestureRecognizer wins the gesture arena.
* Our outer Listener calls node.requestFocus() pre-arena. That
flags the FocusNode as focused in Flutter's focus tree, but
does NOT open the TextInput channel \u2014 so iOS keeps the OS
keyboard hidden until EditableText's own tap recognizer wins
on a second tap.
* requestFocus on its own is therefore a no-op for the user
visually: the cursor + caret appear briefly but the keyboard
stays down.
Fix: in the Listener.onPointerDown handler, additionally invoke
'TextInput.show' on SystemChannels.textInput. This is the same
private platform RPC EditableText calls internally on attach;
forcing it ourselves slides the keyboard up regardless of arena
state.
Belt-and-braces with the existing requestFocus() guarantees
keyboard-on-first-tap on iPhone / iPad and is harmless on:
* Android (the platform ignores the redundant show call when
the keyboard is already up),
* Linux / Windows / macOS desktop (no soft keyboard exists; the
method channel handler returns success without doing
anything).
No new imports needed \u2014 SystemChannels is already in
package:flutter/services.dart (imported for FilteringTextInputFormatter).
flutter build ios --release --no-codesign: 20.7 s, Runner.app
30.2 MB.
Two user-reported issues addressed:
1. 'on supported devices such as iphone or android user should be
able to select audio device such as speaker or airpods or phone'
2. 'the bottom folder is duplicated with the app bar settings'
Issue 1 \u2014 audio output route picker:
Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:
* iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
Control Center's audio chooser. Lists Speaker / iPhone receiver /
AirPods / connected Bluetooth devices / AirPlay / CarPlay.
System manages the device list; we don't have to track route
changes manually.
* Android (post-rc.8 when we wire the platform): Material Design 3
dialog backed by AudioManager.setCommunicationDevice() with
SCO Bluetooth + USB headsets filtered for VoIP.
The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.
The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.
Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.
Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.
Issue 2 \u2014 collapse voice controls into the single modal sheet:
The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.
The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):
1. Audio output route picker tile (iOS / Android only) \u2014 new.
2. Mode + bind / release-tail recap (display only).
3. 'Adjust mode & release tail' OutlinedButton that closes the
sheet and opens the same VoiceSettingsDialog the gear icon
used to open. One config form, not two.
4. Mic level meter.
5. TX / RX frame counts + mic state.
6. PTT capability badge (desktop only).
Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.
Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
The server-host TextField on the connect form accepts a hostname or
hostname:port pair (e.g. kr.teamspeak.app:9987). Two improvements:
1. keyboardType: TextInputType.url surfaces '.', '/', ':' on the
primary on-screen keyboard plane so the user does not have to
switch to the symbols pane mid-address. Matches iOS Safari's
URL bar.
2. textCapitalization.none + autocorrect/enableSuggestions=false
prevents iOS from auto-capitalising the first letter or
'correcting' 'kr.teamspeak.app' to something else.
3. inputFormatters belt-and-braces:
* deny whitespace (handles tab-indented paste)
* lowercase pipeline (handles uppercase paste)
4. Visual affordances: prefix dns icon + 'host[:port]' hint.
Nickname field intentionally unchanged \u2014 may contain unicode,
mixed case, spaces.
flutter build ios --release --no-codesign: 30.7 s, Runner.app
30.0 MB. flutter analyze clean (6 pre-existing deprecation
warnings on Radio.groupValue/onChanged).
Xcode warning on iOS SDK 26+:
'allowBluetooth' was deprecated in iOS 8.0: renamed to
'AVAudioSession.CategoryOptions.allowBluetoothHFP'
The flag was renamed in iOS 8 (a decade ago) but the old name has
been kept as a soft-deprecated alias. iOS 26 SDK finally emits the
warning, and -Werror builds would fail on it. Same semantics:
permit HFP-profile Bluetooth headsets as input + output. Kept
.allowBluetoothA2DP alongside for higher-quality output-only A2DP
devices.
flutter build ios --release --no-codesign: 10.7 s, Runner.app
30.0 MB.
From iPhone log:
chanora_flutter: AVAudioSession setup failed:
Error Domain=NSOSStatusErrorDomain Code=561017449
'Session activation failed'
Error code 561017449 = AVAudioSessionErrorCodeCannotStartPlaying
(ASCII '!cat' big-endian). iOS 17+ refuses setActive(true) calls
made before the app's scene is foregrounded: the audio policy
server denies the activation because the app is not yet considered
the foreground priority owner. didFinishLaunchingWithOptions runs
BEFORE the scene becomes .active, so synchronous activation there
hits this race on cold launch.
Symptom flow:
1. App cold-launch -> AppDelegate.didFinishLaunching fires
2. setActive(true) -> Error 561017449
3. Audio session is left inactive
4. cpal's later attempts to open RemoteIO see an inactive
session and reject with StreamConfigNotSupported
5. voice_join fails at ensure_audio_running
6. user sees the audio failure manifested as missing mute /
continuous / PTT buttons (now fixed in f1f81a3 to be
lenient; this commit also unblocks the underlying audio).
Fix: split the AVAudioSession configuration into two phases:
* setCategory at didFinishLaunching (always safe).
* setActive(true) deferred to UIApplication.didBecomeActive Notification, which fires after the cold-launch settle and
on every resume-from-background. Repeated setActive while
already-active is a no-op per docs.
This is the canonical iOS voice-app pattern (Discord, Zoom,
FaceTime, Flutter's package all follow it). Documented
in commit body comments.
flutter build ios --release --no-codesign: 13.2 s, Runner.app
30.0 MB.
Two distinct fixes prompted by user reports from the iPhone build:
1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug
overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to
the AppBar title because the title Row ('Chanora' + channel
pill) plus 5-6 trailing IconButton actions exceeded a typical
iPhone AppBar width. User saw the strip clipped to '...RFLOWED
BY...' since only its end fit on screen.
_AppBarTitle now drops the 'Chanora' label on narrow widths
(<840 dp). Title shows only the channel pill when in voice
channel; the user already knows they're in Chanora because
they just opened it. Wide widths (tablet/desktop, >= 840 dp)
keep the full 'Chanora \u00b7 #channel-pill' title because there's
room. Eliminates the overflow.
Note: the OVERFLOWED-BY strip only renders in debug builds
anyway; release builds suppress the overlay. But the
underlying Row overflow was a real layout bug worth fixing.
2. First-tap TextField still failed on iPhone after the earlier
FocusNode + TextField.onTap fix. Root cause: TextField.onTap
fires AFTER the gesture-arena resolves, so if the enclosing
SingleChildScrollView wins the arena (which it does on iOS
for the very first tap), the focus request never fires.
Wrap each connect-form TextField in a Listener with
HitTestBehavior.translucent and onPointerDown: requestFocus.
Listener fires synchronously on PointerDownEvent BEFORE arena
resolution, so even if the scrollable would have won the arena
we have already grabbed focus. Translucent means the pointer
ALSO propagates down to the TextField so its normal touch
handling still runs (text selection / cursor placement).
_focusOnTap helper added; wraps all three TextFields
(host, nick, password).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 18.4 s, Runner.app
30.0 MB.
The podspec's prepare_command only fires on `pod install`. Once a
framework was generated, Rust source changes were silently ignored
because Xcode kept re-bundling the stale framework into Runner.app.
Manifested today as: ran `cargo build --release --target
aarch64-apple-ios` to pick up the lenient voice_join fix, ran
`flutter build ios`, but Runner.app/Frameworks/chanora_bridge.
framework/chanora_bridge was still the framework from the previous
pod install (16:10) not the just-built 18:30 dylib.
Add an explicit `script_phase` to the podspec that re-runs:
1. cargo build --release --target aarch64-apple-ios -p chanora_bridge
2. cp dylib into Frameworks/chanora_bridge.framework/chanora_bridge
3. install_name_tool -id @rpath/...
on every Xcode 'Build', not just on pod install. The script short-
circuits when the framework's binary mtime is newer than the cargo
output (fast no-op on incremental builds where Rust didn't change).
Side effect: every Xcode build now invokes cargo, which can take
~5 s on a warm cache and ~1 min cold. This is the right trade-off
because the previous behavior silently shipped stale Rust code.
User report from iPhone: 'mute / continuous / PTT buttons missing'
with NO error popup. Root cause: voice_join's audio-engine startup
was failing silently, and the failure propagated out as a hard
error \u2014 which means SessionEvent::VoiceState(true) was never sent
to Dart even though the server-side channel move had already
succeeded. Dart's _inChannel stayed false; every control gated on
_inChannel disappeared while the channel tree continued to show
the user as joined.
This commit makes voice_join lenient on audio-engine failures so
the UI state matches the server-side reality, and also gives iOS
a writable log file so diagnostics from device builds are
recoverable for the first time.
core/chanora_core/src/lib.rs::voice_join
* ensure_audio_running's error is now logged + emitted as
SessionEvent::AudioStopped, but does NOT abort voice_join.
The server move at step 1 already succeeded; failing the
Dart-visible promise here would leave the UI in a phantom
'in-channel visually but no controls' state. After this
commit:
- mic / headset / settings appear in the AppBar
- PTT button appears at the bottom
- status chip shows live audio state ('Mic on/off')
- if audio actually failed (mic permission denied,
no input device, CoreAudio rejecting stream config)
the user can retry by switching modes / channels;
BridgeEvent::AudioStopped wires _audioStarted=false
in Dart so audio-stats poll is honest about the
engine state.
crates/chanora_bridge/src/api.rs::log_file_path
* iOS now writes the log to /home/milkice/Documents/chanora.log
(Documents is the standard user-visible iOS sandbox dir).
* Android remains None pending the bridge JNI init wiring
a writable path (P1 follow-up).
apps/chanora_flutter/ios/Runner/Info.plist
* Adds UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
so the Documents directory shows up under 'On My iPhone \u2192
Chanora' in the Files.app. The user can now copy chanora.log
out for support without needing Xcode \u2192 Devices and
Simulators \u2192 Download Container.
Workspace tests: 78/0/1 unchanged.
flutter build ios --release --no-codesign: 22.7 s clean
(Runner.app 29.9 MB).
Restructure the narrow / mobile body layout around three principles
(Hoober thumb-zone research validated, Material 3 components):
1. Channel tree gets ~85% of the screen height.
2. Live voice state is always visible in a 2-line status chip
above the PTT button.
3. PTT button is wide, bottom-anchored (thumb-natural lower zone).
4. Frequent toggles (mic mute, headset mute, voice settings) live
in the AppBar so they don't compete with the channel tree.
5. Non-essential live readouts (level meter, TX/RX, capability
badge) live in a modal sheet opened by tapping the status chip
-- progressive disclosure.
apps/chanora_flutter/lib/widgets/voice_compact.dart (new file)
* VoiceStatusChip: 2-line live readout. Line 1 = mode + bind hint;
Line 2 = release-tail + 'Mic on/off'. Tap opens
showVoiceDetailsSheet.
* VoicePttButton: 56 dp wide bottom-anchored Push to Talk button.
Same touch-and-hold gestures as the previous _PttHoldButton.
* showVoiceDetailsSheet: modal bottom sheet with mode recap +
bind/tail hint + level meter + TX/RX counts + PTT capability
badge (desktop-only).
apps/chanora_flutter/lib/main.dart
* AppBar gains mic-mute, headset-mute, voice-settings icons when
in voice channel AND MediaQuery width < 840 dp (mobile only).
Wide mode keeps these controls inside the existing VoiceBar
widget unchanged.
* AppBar title becomes a Row of 'app name + channel chip' when
in voice channel.
* Narrow-mode body restructured: Expanded(channelTree) +
VoiceStatusChip + VoicePttButton (latter only when in voice
channel AND PTT mode). The old narrow-mode VoiceBar is gone;
wide-mode VoiceBar is unchanged.
* New _onOpenVoiceDetailsSheet handler bridges the chip-tap to
showVoiceDetailsSheet.
* New top-level helper _isTouchOnlyPttHost mirrors the helpers
in widgets/voice_bar.dart and widgets/voice_settings.dart so
the AppBar + narrow-mode chip can branch consistently.
apps/chanora_flutter/lib/l10n/app_en.arb
apps/chanora_flutter/lib/l10n/app_zh.arb
apps/chanora_flutter/lib/l10n/generated/* (regenerated)
* New string voicePttHoldHint = 'Hold the button' / '按住按钮'.
Surfaced in line 1 of VoiceStatusChip on touch-only hosts and
in the modal sheet's PTT line where the desktop equivalent
would name a bound key.
Wide-mode (>= 840 dp) layout intentionally unchanged so the
signed-off rc.8 desktop verification still applies.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Local cargo + flutter analyze pass; Mac was offline at commit
time so iOS device build verification is pending the next sync.
User report: 'every first time to tap to input box nothing
happened'. Symptom is iPhone-specific: the very first tap on any
of the Host / Nickname / Password text boxes in the connect form
fails to focus + open the keyboard. The second tap on the same
field works.
Two distinct causes, both addressed:
apps/chanora_flutter/lib/main.dart
The connect form lives inside a SingleChildScrollView. Flutter
on iOS has a long-standing issue (flutter#19027) where the
enclosing Scrollable's gesture-arena participant absorbs the
first tap as a possible scroll-intent, leaving the TextField
unfocused; the second tap reaches the field because the
scrollable has already declined to handle a drag.
Fix: _ConnectForm converted from StatelessWidget to
StatefulWidget so it can own FocusNodes for the three text
fields. Each TextField gains:
* focusNode: <its own FocusNode>
* onTap: () => focusNode.requestFocus()
— forces focus on tap-down regardless of arena outcome
* textInputAction: TextInputAction.next (host, nick) /
TextInputAction.done (password) for return-key flow
* autocorrect: false, enableSuggestions: false
— these are server-host / nickname / password fields, the
iOS auto-correct + suggestion bar is wrong for all three.
Also set keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior
.onDrag on the SingleChildScrollView so the keyboard hides
when the user starts scrolling the bookmark list below.
apps/chanora_flutter/ios/Runner/AppDelegate.swift
AVAudioSession.sharedInstance().requestRecordPermission was
fired synchronously from didFinishLaunchingWithOptions. The
permission alert can race with iOS's text-input subsystem
initialisation: if the alert appears before the keyboard
layer finishes wiring up, subsequent text-field focus
requests are dropped silently.
Fix: DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) to
defer the permission request until ~1 s after the app shell
is on screen. Long enough for iOS's text-input layer to fully
initialise; short enough that the user reads the prompt
before tapping a field.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 26.8 s clean
(Runner.app 29.8 MB).
User report from sideloaded iPhone build, in order of priority:
#4 'Could not join channel: audio: audio backend:
build_output_stream: The requested stream configuration is
not supported by the device.'
Cause: we forced cpal::BufferSize::Fixed(2048) on the output
and input streams unconditionally on non-Linux. iOS CoreAudio
RemoteIO units reject arbitrary buffer-size requests with that
exact error. Windows WASAPI needs the pinning for shared-mode
jitter, but macOS / iOS do not.
Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere
else use BufferSize::Default and let the platform HAL pick.
crates/chanora_audio/src/engine.rs.
#5 'Could not join channel: invariant violated:
voice_in already taken'
Cause: start_audio tore down the old engine BEFORE attempting
to construct the new one, and consumed voice_in (an mpsc
Receiver that can only be taken once) early. When the new
engine failed mid-construction (e.g. because of #4 above) the
session was left with: no audio engine, voice_in consumed,
no way to retry without reconnect. The second voice_join
attempt surfaced the invariant message.
Fix: build the new engine BEFORE tearing down the old. Only
swap state.audio if construction succeeded. crates/chanora_
core/src/lib.rs::ChanoraSession::start_audio. Additionally
added a put_voice_in helper to the protocol adapter (
crates/chanora_protocol/src/adapter.rs) for a future
broadcast-channel migration; the helper is unused on the
immediate fix path but documents the intent.
#3 'permission request would better on first open'
Cause: AVAudioSession only triggers the mic-permission
prompt the first time it tries to record. We never recorded
until voice_join, so the prompt fired then.
Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission
in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:).
Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in
macos/Runner/AppDelegate.swift::applicationDidFinishLaunching.
Both run non-blocking; user can deny without crashing app
launch, and voice_join then surfaces a clearer downstream
error when the engine fails to open the input device.
#1 + #2 'one-column upper takes too much space; Push to Talk
button at bottom would be better'
Layout rework for narrow-mode (single column, mobile shape):
- Flipped the stacking order in main.dart so Voice Bar moves
to the BOTTOM of the body and the channel tree (Expanded)
fills above. Wide-mode (Row, >= 840 dp) layout unchanged.
- Inside the Voice Bar on touch-only hosts, moved the
on-screen Push to Talk button to be the LAST element of
the Voice Bar (was Row 3). Order now: pill + mutes, mode
badge + settings, level meter, stats line, release-tail
caption, PTT button. The button is closest to the user's
thumb when the Voice Bar is pinned to the bottom of a
narrow-layout screen.
#6 'remove right top debug badge'
debugShowCheckedModeBanner: false on the MaterialApp.
Release builds never showed it anyway; this only affects
local dev / debug builds.
#7 'what does the refresh button use for? nothing happened'
Removed. The snapshot updates via BridgeEvent::SnapshotChanged
are pushed from the bridge — a manual rust.snapshot() call
was redundant. Now only the Diagnostics + Disconnect actions
remain in the AppBar trailing row when connected.
#8 'Bind Key related function should not be added to a mobile
platform'
widgets/voice_settings.dart: bind-key OutlinedButton is now
#cfg'd out when Platform.isIOS || Platform.isAndroid. The
release-tail slider stays because it still applies to the
on-screen PTT button. Capability badge in voice_bar.dart
also hidden on mobile (it would always show L0Focused which
is redundant with the visible on-screen button).
Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1
on Linux; flutter analyze clean (6 pre-existing Radio.groupValue
infos). flutter build ios --release --no-codesign: 28.8 s clean
(Runner.app 29.9 MB).
iOS / iPadOS / Android have no hardware keyboard for the user to
bind a PTT key on. Up to now the VoiceBar showed only a
'Push to talk: bound key —' hint that didn't lead anywhere usable.
Add a touch-and-hold on-screen PTT button rendered only on
touch-only platforms (Platform.isIOS || Platform.isAndroid; web
hosts and desktop continue to use the hardware-key path
unchanged).
apps/chanora_flutter/lib/widgets/voice_bar.dart:
* New module-private `_isTouchOnlyPttHost` predicate.
* VoiceBar gains an `onPttHeldChanged: ValueChanged<bool>`
constructor param. Desktop callers wire it but never invoke it
because the button is not rendered there.
* Row 3 (the PTT-only secondary content) now branches:
- on touch-only hosts -> renders the new `_PttHoldButton` plus
a small release-tail hint underneath
- on hardware-keyboard hosts -> renders the same bound-key +
release-tail one-liner as before, unchanged.
* New `_PttHoldButton` StatefulWidget. Uses a single
GestureDetector covering onTapDown / onTapUp / onTapCancel /
onPanDown / onPanEnd / onPanCancel so the held edges fire on
finger-down and the released edge fires when the user lifts
OR drags off OR another gesture in the arena wins. Visual
feedback mirrors the level-meter active flag.
apps/chanora_flutter/lib/main.dart:
* New `_onOnscreenPttHeldChanged(bool held)` method that calls
`rust.setPtt(active: held)`. The bridge's set_ptt routes the
edge through the same release-tail timer + transmit-mode
selector that desktop hardware keys use (SDD-096 / SAD-083),
so behaviour parity is preserved.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: clean (Runner.app 29.9 MB).
DEC-025: iPhone + iPad + Android in scope; this commit makes PTT
mode actually usable on those platforms. The 'Focused' capability
badge wording in ios-p0-acceptance.md / ipad-p0-acceptance.md
already documents the on-screen button as the only PTT input;
this commit makes that documentation true.
Apple has enforced a `PrivacyInfo.xcprivacy` privacy manifest at App
Store submission since May 2024 for iOS / iPadOS / visionOS /
watchOS, and rolled the requirement out to macOS in late 2024.
Without the file, App Store Connect rejects archive uploads with
"missing required privacy manifest". This commit adds the manifest
for both iOS and macOS Runner targets.
apps/chanora_flutter/ios/Runner/PrivacyInfo.xcprivacy
apps/chanora_flutter/macos/Runner/PrivacyInfo.xcprivacy
Identical content. Declarations:
NSPrivacyCollectedDataTypes:
NSPrivacyCollectedDataTypeAudioData
Microphone audio transmitted to the user's chosen voice
server while connected and unmuted. Not linked to user
identity (no Apple ID / IDFA tied), not used for tracking.
Purpose: AppFunctionality (communications).
NSPrivacyTracking: false
NSPrivacyTrackingDomains: []
Chanora performs no cross-app / cross-website tracking.
NSPrivacyAccessedAPITypes:
FileTimestamp (C617.1)
tokio + rusqlite file I/O for identity.tskey, chanora.db,
audio_meta.json, chanora.log inside the app container.
UserDefaults (CA92.1)
Indirect via path_provider Flutter plugin querying for
Application Support / Documents directories.
SystemBootTime (35F9.1)
tracing-subscriber timestamps log records relative to boot.
DiskSpace (85F4.1)
rusqlite checks before sqlite page writes.
All four "required reason" API categories use Apple's published
allow-list reason codes; no fingerprinting / analytics usage.
apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj
Added PrivacyInfo.xcprivacy to the Runner group and to the
Runner target's "Copy Bundle Resources" build phase via the
xcodeproj Ruby gem (via a one-shot script). With this, the file
is placed at Runner.app/PrivacyInfo.xcprivacy where Apple's
validator looks for it — `find Runner.app -name
PrivacyInfo.xcprivacy` shows our manifest at the bundle root
alongside Flutter's and connectivity_plus's.
Verified on the M1 Mac (coder@100.118.130.73):
flutter build ios --release --no-codesign 4.0 s
-> Runner.app/PrivacyInfo.xcprivacy present
flutter build ipa --release --no-codesign 28.4 s
-> Runner.xcarchive built (171.4 MB)
-> archive's Runner.app/PrivacyInfo.xcprivacy present
-> archive's Runner.app/Frameworks/chanora_bridge.framework
built fresh via the chanora_bridge.podspec prepare_command
under xcodebuild's sandbox (no PATH / env weirdness).
P1 follow-ups noted by xcodebuild's validator (not blockers for
this commit but for App Store submission):
* Real app icon (currently default placeholder)
* Real launch image (currently default placeholder)
* Paid Apple Developer Program account, registered App ID, and
Distribution provisioning profile (Personal Team sideloads
still work as today).
iOS rejects loose .dylib loads (`dlopen` of any path outside the
app bundle is sandboxed), so a Flutter-Rust bridge has to ship
inside the .app as an Embed-and-Sign framework that
flutter_rust_bridge's runtime loader can dlopen via its default
`chanora_bridge.framework/chanora_bridge` lookup path.
This commit wires the bridge into the iOS build via a CocoaPods
podspec. Same role Cargokit plays for other Flutter+Rust setups,
done by hand against this repo's layout to avoid the Cargokit
vendoring footprint that was previously dropped.
apps/chanora_flutter/ios/chanora_bridge.podspec
New file. `prepare_command` invokes
`cargo build --release --target aarch64-apple-ios -p chanora_bridge`
with IPHONEOS_DEPLOYMENT_TARGET=13.0 +
CMAKE_POLICY_VERSION_MINIMUM=3.5 (satisfies audiopus_sys's
cmake invocation on modern CMake 4.x), then wraps the
produced libchanora_bridge.dylib into
chanora_bridge.framework with an Info.plist that declares
iPhoneOS / MinimumOSVersion=13.0, and rewrites LC_ID_DYLIB
to @rpath/chanora_bridge.framework/chanora_bridge.
`vendored_frameworks` exposes the result to CocoaPods, which
integrates it into Runner.xcodeproj with Embed & Sign
automatically. No Xcode UI edits required.
apps/chanora_flutter/ios/Podfile
Add `pod 'chanora_bridge', :path => '.'` to the Runner
target.
apps/chanora_flutter/ios/Podfile.lock
Generated by `pod install` after the pod was added. Pins
chanora_bridge 1.0.0 + checksum so iOS builds on other
developer machines pull the same framework version.
.gitignore
Add `/apps/chanora_flutter/ios/Frameworks/` so the
~13 MB built framework (regenerated on every pod install) is
not committed.
Verified end-to-end on the M1 Mac (coder@100.118.130.73):
pod install ok
chanora_bridge.framework generated at
apps/chanora_flutter/ios/Frameworks/chanora_bridge.framework
Install name: @rpath/chanora_bridge.framework/chanora_bridge
flutter build ios --release --no-codesign 12.7 s
-> Runner.app 29.9 MB (was 16.9 MB without the bridge)
-> Runner.app/Frameworks/chanora_bridge.framework present
alongside Flutter.framework, App.framework,
connectivity_plus.framework, objective_c.framework.
DEC-025: iOS / iPad officially in scope for P0.
Notes for owners installing on physical iOS devices:
The Personal Apple Team in Xcode requires a unique
PRODUCT_BUNDLE_IDENTIFIER (the default 'app.chanora.chanoraFlutter'
may already be claimed in the App Store registry). Set this
locally in Xcode -> Runner target -> Signing & Capabilities ->
Bundle Identifier (e.g. yourname.chanora.chanoraFlutter); do
NOT commit that change back since it is user-specific.
New controlled document docs/verification/ipad-p0-acceptance.md
extends iOS P0 coverage to iPad. The build artefact is identical
to iPhone — `TARGETED_DEVICE_FAMILY = "1,2"` in
ios/Runner.xcodeproj/project.pbxproj is the Universal family, so
the same Runner.app installs on iPad with the same personal-team
provisioning profile.
15-row checklist mirrors ios-p0-acceptance.md TC-1..TC-12 and adds
three iPad-specific rows:
TC-13 wide-mode landscape layout — iPad in landscape is well
above the 840 dp LayoutBuilder breakpoint shipped in
apps/chanora_flutter/lib/main.dart, so the connected view
splits into a 320 dp left column (banner + Voice Bar) plus
an expanding channel tree. Portrait rotation collapses
back to the stacked iPhone layout. Long channel-name pills
still ellipsize per the earlier voice_bar.dart fix.
TC-14 Split View / Slide Over no-crash — Apple iPad multitasking
is intentionally unsupported in P0. UIApplicationSupports\
MultipleScenes stays false. This row asserts the app does
not crash when iPadOS tries to host it in Split View; the
actual multi-scene wiring is P1.
TC-15 AirPlay 2 audio route — verifies AVAudioSession routing
honours an AirPlay 2 destination picked via Control
Center, and routes back cleanly when iPad is reselected.
docs/governance/document-index.md
Bumped to 0.9.9 with the change-history row noting the iPad
acceptance doc. No spec items added; DEC-025 was originally
iPhone-only for the mobile target and this row formally
extends P0 coverage to iPad within the same iOS toolchain.
python3 tools/validate_docs.py: clean (pre-existing 35-filename
[FAIL] retained, unchanged).
Building the bridge for `aarch64-apple-ios` failed in two ways with
the previous TLS stack:
1. `aws-lc-sys` (transitive: rustls -> aws-lc-rs -> aws-lc-sys)
does not cross-compile cleanly to iOS — the build produced
undefined symbols for architecture arm64 (mldsa44, ec_GFp_mont,
etc).
2. `audiopus_sys` linked against the wrong iOS runtime version,
missing `___chkstk_darwin`.
Following the rustls-platform-verifier docs and the standard Rust+
iOS+TLS pattern used by 1Password / Signal / rustup / Bitwarden,
this commit swaps the TLS provider to **native-tls** so each
platform picks its own:
* macOS + iOS -> Security.framework (no external C deps)
* Windows -> SChannel
* Linux/BSD -> system OpenSSL
Changes:
crates/chanora_protocol/Cargo.toml
crates/chanora_audio/Cargo.toml
* Drop `default-tls` from tsclientlib's features. The remaining
`audio` feature is what we actually use; default-tls was a
reqwest convenience that picked rustls+aws-lc-rs.
* Add a direct `reqwest` dep with `default-features = false,
features = ["charset", "http2", "native-tls"]`. Cargo's
workspace feature unification carries this through the
transitive `tsclientlib -> reqwest` chain.
apps/chanora_flutter/ios/Podfile
* Uncomment `platform :ios, '13.0'` so CocoaPods stops emitting
the implicit-platform warning and Xcode's iOS deployment-
target check is honored.
apps/chanora_flutter/ios/Podfile.lock
* Generated by `pod install` after the platform pin. Committed so
iOS builds on other developer machines pull the exact same Pod
versions.
apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata
* CocoaPods auto-integration: adds Pods_Runner.framework +
Pods_RunnerTests.framework references and the Pods xcconfig
file references. Standard `pod install` output; reviewing the
diff shows only Pod-bookkeeping additions, no signing or
target-config drift.
Verified end-to-end on the M1 Mac (coder@100.118.130.73):
cargo build --release -p chanora_bridge 29.09 s
cargo build --release --target aarch64-apple-ios 24.32 s
(with IPHONEOS_DEPLOYMENT_TARGET=13.0 and
CMAKE_POLICY_VERSION_MINIMUM=3.5 in the env to satisfy the
audiopus_sys cmake invocation; documented as a P1 build-glue
follow-up.)
flutter build ios --release --no-codesign ok
Built build/ios/iphoneos/Runner.app (16.9 MB)
Xcode GUI build of Runner.xcworkspace ok
(after the user opened Runner.xcworkspace, NOT
Runner.xcodeproj, and Clean Build Folder.)
Tests on macOS unchanged: chanora_audio 34 / 0 / 0.
DEC-025: iOS + macOS officially in scope for P0.
New controlled documents:
* docs/verification/macos-p0-acceptance.md
15-row human-must checklist for Apple Silicon macOS P0 sign-off.
Targets the SDD-085 CGEventTap backend (now fully live) +
SDD-094..097 audio lifecycle. Auto-test rows pre-filled from the
M1 Mac verification pass: chanora_audio 34 / 0 / 0 (Linux: 32;
macOS delta is the keymap + Send-bound + descriptor-builder
tests). Pre-flight covers the manual framework-wrap +
install_name_tool + ad-hoc codesign step via the new
tools/macos-postbuild.sh. TC-3 walks the Input Monitoring grant
+ descriptor watch transition timing (~1.5 s).
* docs/verification/ios-p0-acceptance.md
12-row human-must checklist for iOS P0 sign-off on a physical
iPhone via the developer's free Apple Personal Team. TC-3
documents the Focused-only PTT capability iOS gives us (no
global event tap analogue exists). TC-8 covers UIBackgroundModes
= audio. TC-9 covers AVAudioSession routing — phone-call
interruption, AirPods route, etc.
docs/governance/document-index.md
Bumped to 0.9.8 with a change-history row covering both new
acceptance documents. No spec items added; the existing SDD-085
(macOS) and SDD-094..097 (audio lifecycle) are what these
documents sign off.
python3 tools/validate_docs.py: clean (pre-existing 35-filename
[FAIL] retained, unchanged).
iOS AVAudioSession must be configured BEFORE Flutter starts its
audio pipeline; the canonical place is application(_:didFinishLaunching\
WithOptions:) in AppDelegate.swift. This commit:
apps/chanora_flutter/ios/Runner/AppDelegate.swift:
* import AVFoundation
* In application(_:didFinishLaunchingWithOptions:), call
AVAudioSession.sharedInstance().setCategory(.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
followed by setActive(true). Failures are NSLogged but do not
block app launch — cpal's CoreAudio backend will still come up
against the default iOS routing.
This shape:
* routes the receiver/speaker like a phone call (.playAndRecord +
.voiceChat),
* engages on-device AEC / NS where supported,
* defaults to speaker so users don't have to hold the phone to
their ear,
* permits Bluetooth headsets (AirPods et al. just work).
crates/chanora_audio/src/engine.rs:
* Replace the iOS engine-start placeholder log line ('binding
pending — Chanora iOS audio is documented-only for Beta') with
an honest acknowledgment that the AVAudioSession configuration
lives Swift-side. The Rust engine acknowledges the request, then
cpal opens its CoreAudio streams against the session.
iOS-only Rust code is #[cfg(target_os = "ios")]-gated so this commit
is no-op on every other platform.
SRS-197: iOS/macOS audio routing contract. DEC-025: iOS officially
in scope for P0 (Focused PTT only — Apple's sandbox model has no
global PTT analogue).
Replace the macOS PTT backend's worker-thread stub (which had just
slept) with a full CGEventTap implementation:
* extern "C" bindings to CGEventTapCreate, CGEventGetIntegerValueField,
CGEventTapEnable, CFMachPortCreateRunLoopSource, CFRunLoopGetCurrent,
CFRunLoopAddSource/RemoveSource, CFRunLoopRun/Stop, CFRelease, plus
the kCFRunLoopCommonModes static.
* tap_callback: C-ABI extern fn that reads bound keycode /
mouse-button from atomics, matches the incoming event, and toggles
the AudioTransmitGate. Returns the event unchanged (listen-only
tap, no event modification). Privacy-safe: never logs raw key
codes or button numbers (DEC-027).
* Event mask covers kCGEventKeyDown, kCGEventKeyUp,
kCGEventOtherMouseDown, kCGEventOtherMouseUp; also handles the
kCGEventTapDisabledBy{Timeout,UserInput} notifications by logging
a degraded-mode warning.
* Worker thread captures the CFRunLoopRef via a Send-marked
RunLoopHandle newtype so stop() can call CFRunLoopStop from the
audio engine thread.
* Box<TapState> is leaked into the worker via a Send-marked
TapStatePtr newtype; reclaimed on worker exit so the gate's Arc
refcount stays correct.
* Flutter logical-key labels are mapped to Carbon virtual keycodes
via label_to_macos_keycode (covers letters, digits, function keys,
navigation, common punctuation). Mouse-side-button labels resolve
via label_to_macos_mouse_button (3 = Mouse4, 4 = Mouse5).
* refresh_bound_atomics() rebuilds bound_keycode + bound_mouse_button
on start() and rebind() so the tap callback sees the new binding
without re-arming the tap.
Tests: chanora_audio 34 / 0 / 0 on macOS (was 28 before this commit).
Added: keymap_letters, keymap_function_keys, keymap_navigation,
keymap_unknown_returns_none, mouse_button_map, runloop_handle_is_send.
Verified the live IOHIDCheckAccess returns Undetermined (Unknown=2) on
a fresh M1 box where Input Monitoring has never been requested; the
1.5 s permission-watcher re-publishes the descriptor on user grant
or revoke without restart.
DEC-025: macOS desktop officially in scope. SAD-073: two-level PTT
ladder. SRS-198: honest capability advertising. DEC-027: privacy.
macOS:
* Runner/DebugProfile.entitlements + Release.entitlements: add
com.apple.security.network.client (outbound TS3 server connect)
and com.apple.security.device.audio-input (microphone capture).
Debug keeps com.apple.security.network.server + cs.allow-jit
(Flutter hot-reload needs both); Release drops them.
* Runner/Info.plist: add NSMicrophoneUsageDescription and
NSInputMonitoringUsageDescription so the macOS system prompts
show a sensible explanation when Chanora first needs mic or
Input Monitoring access. Input Monitoring is required by
CGEventTapCreate (SDD-085).
* Runner.xcodeproj/project.pbxproj: switch Debug/Release/Profile
code-signing from Automatic + Apple Development to Manual +
"Sign to Run Locally" (CODE_SIGN_IDENTITY = -). This lets
`flutter build macos --release` work over SSH where the login
keychain is locked. The owner re-enables the personal team
locally in Xcode for physical-device iOS testing later.
iOS:
* Runner/Info.plist: add NSMicrophoneUsageDescription and the
UIBackgroundModes = ['audio'] entry so voice traffic continues
when the app is backgrounded (TS3 servers drop clients on idle
audio streams).
tools/macos-postbuild.sh: new script. flutter build macos --release
emits build/macos/Build/Products/Release/chanora_flutter.app but
does NOT bundle libchanora_bridge.dylib. FRB on macOS dlopen()s the
bridge as chanora_bridge.framework/chanora_bridge, not a plain
dylib. This script:
1. Wraps target/release/libchanora_bridge.dylib in a proper
chanora_bridge.framework (Versions/A layout, Info.plist,
Resources, symlinks).
2. Rewrites LC_ID_DYLIB to
@rpath/chanora_bridge.framework/chanora_bridge.
3. Ad-hoc codesigns the framework and the .app bundle.
4. Verifies with codesign --verify --deep --strict.
macOS analogue of buildit.cmd on Windows. Auto-integration into
Xcode build phases via cargokit / corrosion is a P1 carryover.
Verified end-to-end on the M1 Mac:
cargo build --release -p chanora_bridge 11.76 s
flutter build macos --release ok (59.2 MB)
tools/macos-postbuild.sh Release ok
chanora_flutter.app launch via SSH bridge initialised,
identity + bookmark
store initialised
(~5 s smoke).
~/Library/Logs/app.chanora.chanora_flutter/chanora.log captures
the boot sequence cleanly.
DEC-025 reference: macOS desktop is officially in scope.
Replaces the macOS PTT backend's query_permission() stub (which had
returned Undetermined unconditionally) with a real IOKit call:
extern "C" { fn IOHIDCheckAccess(request_type: u32) -> u32; }
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent = 1)
Returns Granted (0), Denied (1), or Unknown (2). The existing 1.5 s
re-query worker now drives real descriptor transitions when the user
grants or revokes Input Monitoring in System Settings: the watch
sender republishes the descriptor, ChanoraSession forwards
BridgeEvent::PttCapability, and the Flutter capability badge updates
within ~1.5 s without an app restart.
Verified live on the M1 Mac:
rustc /tmp/check_perm.rs && ./check_perm
IOHIDCheckAccess(ListenEvent) = 2 (Unknown)
This is the expected initial state on a fresh box where Chanora has
not yet attempted CGEventTapCreate; once the next commit lands the
event-tap worker, the macOS Input Monitoring prompt will fire on
first audio start and the value transitions to Granted/Denied.
Tests: chanora_audio 28 / 0 / 0 on macOS (Linux had 32; the 4-test
delta is the Linux-only portal probe tests). The existing 7 macOS
backend unit tests still cover the descriptor builder + state
machine purely; they don't exercise the live IOKit call (which
would need a TCC-aware test harness).
SDD-085 reference: macOS Event Tap backend / L2 / L3 capability;
SRS-198 honest capability advertising.
Ran `flutter create --platforms=macos,ios --project-name=chanora_flutter
--org=app.chanora .` on the M1 Mac to generate the standard Flutter
platform-specific scaffolding (Runner.xcodeproj, Podfile, AppDelegate,
entitlements, etc.) for both macOS and iOS.
The cross-platform Dart source (lib/) and Rust workspace (crates/,
core/) carry the actual application logic; these scaffolds are
required only so flutter build macos / ios can resolve their Xcode
projects. No application code added.
macOS smoke-launch from the M1 Mac verified the bridge dylib load
path: after manually wrapping libchanora_bridge.dylib into a proper
chanora_bridge.framework bundle (FRB on macOS expects a framework,
not a plain dylib) and codesigning ad-hoc, the runner starts cleanly
through 'bridge initialised', 'identity store initialised', 'bookmark
store initialised' just like the Linux runner.
Following commits will:
* Wire the framework-bundling step into build glue (currently manual
install_name_tool + codesign).
* Replace the macOS PTT backend stub (crates/chanora_audio/src/
ptt_backends/macos.rs) with live IOHIDCheckAccess +
CGEventTapCreate so the descriptor advertises real L2/L3 capability
on a permission-granted box (SDD-085).
* Add the iOS AVAudioSession PlayAndRecord+voiceChat wiring.
* Add docs/verification/macos-p0-acceptance.md and ios-p0-acceptance.md.
New controlled document docs/verification/linux-p0-acceptance.md mirrors
docs/verification/windows-p0-acceptance.md with 15 TC rows tuned for the
GNOME-on-Wayland target environment (DEC-025). Pre-flight calibrated to
the Arch verification host (100.74.219.114): pacman queries, path
prefixes, xdg-desktop-portal-gnome version notes. Auto-test sign-off
filled with the headless verification pass run over SSH:
cargo check --workspace --release clean (30.99 s)
cargo test --workspace --lib 78 / 0 / 1
cargo test ... linux_portal_smoke -- --ignored
-> 1 / 0 (GlobalShortcuts portal reachable, version = 1)
cargo test ... ptt_privacy 1 / 0 (DEC-027 holds)
The 15 GUI rows are marked pending physical-console pass. SDD-086 portal
flow is what this doc signs off; SDD-081/094..097 are referenced.
Document index bumped to 0.9.7 with the change-history row covering the
new acceptance doc. No spec items added; pre-existing 35-filename FAIL
in validate_docs.py retained.
Two related VoiceBar / scaffold issues on wide windows:
1. Banner placement
The 'not production ready' tertiaryContainer banner sat full-width
above the body Column. In wide layouts (>=840 dp) where the connected
view splits into Voice Bar (320 dp) + channel tree (Expanded), the
banner spanned both columns and dwarfed the channel-tree pane.
Rework: wrap the body in an outer LayoutBuilder so the placement
decision can read bodyConstraints.maxWidth. When wide AND connected
AND snapshot != null, render the banner inside the left 320 dp
SizedBox above the VoiceBar. In every other state (narrow, idle,
connecting) the banner stays pinned full-width at the top.
2. Channel-name pill overflow
The Container holding the channel pill had no width constraint and
Text(channelName) had no overflow handling. Long channel names made
the pill extend past the column's 320 dp; mute icons slid under the
adjacent channel tree.
Rework: pill wrapped in Flexible(flex: 100, fit: FlexFit.loose);
inner Text gets maxLines: 1, overflow: TextOverflow.ellipsis,
softWrap: false. Spacer keeps default flex 1; the 100:1 ratio means
short names hug their intrinsic width and long names take ~99% of
the remaining space then ellipsize. Mute icons stay pinned right.
flutter analyze: clean (6 pre-existing Radio.groupValue infos only).
User reported persistent crackling/popping from peer audio on Linux even
after fixing the 48k->device-rate resampler boundary discontinuities,
clamping pre-Opus-encode peaks, and pre-allocating the playback scratch
buffer. Logs confirmed cpal opened raw ALSA at 44.1k native, no callback
budget violations, no underrun warnings -- yet the audio was still poor.
Root cause: cpal on Linux opens raw ALSA's 'default' PCM. On modern
PipeWire / pipewire-alsa boxes that virtual device routes through ALSA's
dmix + plug layers, whose default resampler is nearest-neighbour. cpal
also picks a small default period size (~256 frames / 5.8 ms) leaving no
headroom for kernel scheduler jitter. Both effects compound into the
crackling the user heard.
Upstream tsclientlib's own audio example
(tsclientlib/examples/audio_utils/ts_to_audio.rs) and the official Qint
client both use SDL2 with AudioSpecDesired { freq: 48000, channels: 2,
samples: 960 }. SDL2 on the same systems routes through PipeWire's PA
bridge (or PulseAudio directly), both carrying high-quality resamplers.
Fix:
* Add sdl2 = '0.37' as a target_os=linux dependency. Links libSDL2-2.0
.so (Arch sdl2-compat over SDL3, Debian libsdl2-2.0-0, Fedora SDL2).
* New module crates/chanora_audio/src/sdl_output.rs implementing
SdlOutput: opens a 48 kHz stereo 960-frame callback that zeroes the
buffer and calls AudioHandler::fill_buffer directly (no user-side
resampler). Master gain + hard-mute atomics wired in identically to
the cpal callback so set_output_gain / set_output_muted keep working.
* engine.rs cfg-gated: target_os='linux' builds SdlOutput; everywhere
else continues with the cpal output path (including the device-native-
rate negotiation and resampler-continuity fixes shipped earlier --
those remain correct on Windows/macOS where cpal targets WASAPI /
CoreAudio cleanly).
* The cpal output helpers (build_output_stream, PlaybackResampleState,
FromF32) are now cfg(not(target_os='linux'))-gated so the Linux
build doesn't emit dead-code warnings.
Capture path still cpal on every platform -- outbound audio was not
reported as bad. Resampler-continuity fix on the capture side stays:
microphone -> Opus encoder still goes through the linear interpolator
with the last-sample anchor.
Tests: 32 / 0 / 0 (chanora_audio), workspace 78 / 0 / 1 unchanged.
ConnectOptions previously took tsclientlib's default Version. Servers that
strictly check the announced client signature could refuse or downgrade
those sessions. Add pick_client_version() that selects a stable signed
descriptor matching the runtime OS:
Windows -> Version::Windows_5_0_0_beta51
Linux -> Version::Linux_5_0_0_beta51
macOS -> Version::macOS_5_0_0_beta51
Android -> Version::Android_3_5_0__7
iOS -> Version::iOS_3_5_6
other -> Linux fallback
All five variants are guaranteed to exist in the vendored tsproto-types
enum at compile time; build fails loudly if upstream removes one.
Wired into Connection::build(...).version(pick_client_version()) on every
connect. Emits 'selected TS3 client_version' info log line so the choice
is visible in chanora.log.
LinuxGnomeWaylandBackend::probe() called zbus::blocking::Connection::session()
directly. The blocking facade internally constructs a current-thread tokio
runtime and block_on()s its async D-Bus client. probe() runs from
PttController::new (sync) which is called from start_audio (async on the
bridge tokio runtime). Nested runtimes panic with 'Cannot start a runtime
from within a runtime'.
Symptom on Linux: the first voice-channel join surfaced a SnackBar
'Could not join channel: join: task N panicked ...' while the channel-move
command had already succeeded server-side. User saw 'channel joined but voice
not enabled'.
Fix: run the cheap blocking probe on a dedicated std::thread (no ambient
runtime), join it synchronously, propagate the version / error. Probe is
microseconds; the join cost is negligible.
- windows-p0-acceptance.md: bump auto-test row to reflect current
Windows test count (126/0/1 — was 124/0/2 before the two
new start_flips_armed_to_l2 tests landed).
- document-index.md: new 0.9.6 row marking DEC-031 (missed-key-up
watchdog disabled on P0) and the controlled status of
docs/verification/windows-p0-acceptance.md.
The original mem::replace + retain pattern worked but was opaque.
Switch to a two-pass approach: collect expired MessageHandles into
a small Vec, then remove + resolve. Behaviour-preserving; the
clippy-style readability win is worth the tiny extra allocation
(typical case: 0 or 1 expired entries per loop iteration).
Five user-reported defects + one auto-test regression-catcher.
== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.
Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.
Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.
New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.
== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.
Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.
Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
carries the canonical TS3 error code per the official catalogue
at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
The connection-task loop holds a pending_moves HashMap keyed by
MessageHandle, services StreamItem::MessageResult by looking up
and resolving the reply with either Ok or the typed
ServerRejected.
* Pending entries have a 3 s deadline so a server that never
replies doesn't leak the reply channel — expired entries fall
back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
full snapshot poll), still polls for confirmation as a
belt-and-braces fallback for legacy servers; on poll failure
emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
localised message selected by error code (channelJoinFailed*
l10n entries). 6 known codes mapped to specific messages
(insufficient permission, wrong password, channel full,
family limit, private channel, timeout); everything else
falls back to the server-supplied generic message.
== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.
Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.
== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.
Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).
== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
+2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
Captures the 15 TC rows the lead walks through on the Korean
Win 11 host before tagging v1.0.0-rc.8, plus the auto-test
sign-off matrix (Linux + Windows cargo + flutter analyze +
validator + windows-smoke).
Each TC row maps to a spec requirement (DEC / SRS / SDD) or to a
regression the rc.7 review found. Failures block the tag.
Known gaps recorded at the bottom: macOS / iOS / Android P0 are
separate documents; VAD (DEC-030) and missed-key-up watchdog
redesign (DEC-031) are P1; real RMS level meter is P1.
The Windows Raw Input backend's keymap is case-sensitive; the test
passed lowercase 'space' which works for the Focused fallback on
Linux but fails on Windows where resolve_binding returns
InvalidBinding. Use the same canonical 'Space' string the bind
dialog produces in real use.
run_raw_input_loop / run_hook_loop signatures used Sender<bool>
but the callers create the channel via sync_channel which returns
SyncSender. Linux cross-check missed this because windows.rs is
behind #[cfg(target_os = "windows")].
1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users
are always in some channel; Discord/Mumble-style leave is the
wrong model. To stop being heard / hearing, mute mic / speaker.
To physically move, tap a different channel. The voiceLeave
bridge call + _onLeaveVoice stay as dead code for now (marked
unused) so existing tests/integrations don't break.
2. voice_join now confirms the move actually applied server-side
by polling the snapshot for up to 1.5 s and matching our own
client's channel against the requested one. If the server
rejected the move (no permission, wrong password, channel
full), voice_join rolls the selector back to in_channel=false
and returns Err so the UI surfaces the failure instead of
showing a fake 'joined' state.
3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI
can identify our row without name-matching. find_own_in reads
it directly.
4. set_self_muted now also clamps the TransmitModeSelector's
hard_mute when input is muted server-side. Without this, the
Opus encoder kept producing frames after setInputMuted(true),
tsclientlib refused each one with 'Sending audio while muted',
and the log grew to 200 MB on the Korean host.
5. tsclientlib WARN spam suppressed via tracing filter
(tsclientlib=error). Belt-and-braces on top of fix 4.
6. Log file is now rotated at every launch (not just when >4 MiB).
Two generations kept: chanora.log.1 (previous) and
chanora.log.2 (the one before). The bug that produced 200 MB
files was a chatty subsystem flooding a single session; the
per-launch rotate keeps disk use bounded by what one session
can produce in its lifetime.
Bonus Windows fix (separate from the six but found in the same
log): the Raw Input + Hook backends now signal readiness BEFORE
blocking on GetMessageW. Previously init_tx.send was called after
the loop returned (i.e. on WM_QUIT, which never happens during
arming), so the main thread's 2 s readiness probe always timed
out and the backend reported L0Focused even when registration
succeeded. Both run_raw_input_loop and run_hook_loop now take an
init_tx parameter and call report!(true) right after a successful
registration, and report!(false) on every early-fail return.
cargo check --workspace: clean.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
pubspec.yaml + _kAppVersion in main.dart were both still reading
v1.0.0-rc.1 even though the branch has accumulated 50+ commits of
post-rc.7 work (the v1 audio + PTT lifecycle redesign in
SDD-094..097, plus DEC-029/030/031). The About dialog and the
diagnostic export's app_version field both surfaced the wrong
string.
Aligned both to v1.0.0-rc.8+59 (build number is the current commit
count on the branch). The Rust workspace version stays at
0.0.1-pre — it's an internal pre-release marker the diagnostic
export carries as crate_version, not user-facing, and changing it
cascades into every inheriting Cargo.toml for no benefit.
The actual v1.0.0-rc.8 tag will land once the Korean Windows 11
human-side P0 acceptance pass closes; this commit aligns the
strings the running build shows so the tester sees a consistent
version while running the tests.
The watchdog spawned by ChanoraSession::start_audio cleared
ptt_held after 30 s of continuous PTT key-down. That was correct
for the 'OS lost the key-up event' failure mode the original
SAD-079 / DEC-028 was designed to catch, but it was the wrong
shape for real human speech: anyone holding the bound key for a
long answer got cut off mid-sentence.
For P0:
- Comment out the spawn site in ChanoraSession::start_audio with
the rationale + the P1 redesign options under consideration
(raised ceiling / OS key-state polling / RMS-silence fallback).
- Leave the MissedKeyUpWatchdog Rust type, its spawn / spawn_on_signal
entry points, and all unit tests in chanora_audio::ptt unchanged
so P1 can re-enable with the chosen detection strategy without
re-implementing anything.
Spec: new DEC-031 in product-decision-register.md supersedes
DEC-028 for the v1 ship. DEC-028 stays in the register as
historical context. The §7 open-decisions log + §8 change history
get matching 0.9.12 rows.
Note: Mumble and TeamSpeak ship without a comparable watchdog —
the 30 s ceiling was stricter than industry baseline. The
underlying protection (OS-level key-up loss) is still worth
solving, just not with a fixed timeout.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(unchanged; the watchdog unit tests still run because the type
itself is unchanged).
docs validator: clean (pre-existing 35-filename warning only).
Issue 1: in Continuous transmit mode the talk indicator turned
gray-out / mic disabled after ~30 s and could only be revived by
toggling mic mute. Root cause: SAD-079 MissedKeyUpWatchdog
subscribed to AudioTransmitGate.transmit_active and force-cleared
it after 30 s of true. In PTT mode this is correct (stuck key =
bug). In Continuous mode transmit_active is *supposed* to stay
true indefinitely; the watchdog assumption doesn't hold.
Fix: the watchdog now subscribes to a new ptt_held watch on the
TransmitModeSelector (the raw key-state input, not the resolved
gate). In Continuous mode ptt_held is never set true, so the
watchdog never fires. In PTT mode it still fires on a stuck
key-down as before. The session owns the watchdog (was on the
engine) so it survives engine restarts; it's spawned lazily on the
first start_audio.
MissedKeyUpWatchdog gains spawn_on_signal(rx, on_timeout, timeout)
alongside the existing spawn(gate, timeout) — old shape preserved
for backwards compat. run_watchdog generalised to take any
watch::Receiver<bool> + Box<dyn Fn() + Send + Sync>.
Two new tests:
- watchdog_on_signal_does_not_fire_when_ptt_held_stays_false
(the Continuous-mode regression test)
- watchdog_on_signal_fires_when_signal_stays_true
(the stuck-key case still fires)
Issue 2: the Voice Bar stats line said 'PTT on/off' even when the
user was in Continuous mode where no PTT key is involved. Renamed
to 'Mic on/off' (mode-neutral) and l10n-ised the on/off literal:
- en: 'Mic on' / 'Mic off'
- zh: '麦克风 开启' / '麦克风 关闭'
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(was 78, +2 watchdog tests).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Layered test coverage for the Windows PTT subsystem ahead of the
v1.0.0-rc.8 official release sign-off.
L0 (refactor)
- Extract three pure-logic dispatchers from the existing WndProc /
LowLevelKeyboardProc / LowLevelMouseProc bodies in
crates/chanora_audio/src/ptt_backends/windows.rs:
dispatch_raw_input(ctx, &RAWINPUT)
dispatch_hook_keyboard(ctx, wparam, &KBDLLHOOKSTRUCT)
dispatch_hook_mouse(ctx, wparam, &MSLLHOOKSTRUCT)
Each takes a small Context (AtomicBinding + AudioTransmitGate +
flags) and is callable without spinning up any Win32 plumbing.
The real Win32 procs unchanged structurally; they unpack lparam
and forward to the dispatchers. AtomicBinding / RawInputContext
/ HookContext / resolve_binding are now pub(crate) so the
in-file test module can drive them.
L1 — windows_keymap full-table sweep (+13 tests)
Every key_label_to_vk arm, all A-Z + a-z, all 0-9, F1-F20,
navigation, modifiers, OEM punctuation, numpad. Exhaustive
mouse_label_to_button cases including the 0x08 / 0x10 /
unknown-bitmask fallbacks.
L2 — AtomicBinding lock-free correctness
store/read round-trip, clear(), Default = zeros, single-writer
/ single-reader concurrency, many-readers / single-writer.
L3 — resolve_binding dispatcher tests
All PttInputClass variants, well-known labels, unknown-label
fallback, mismatched class+label rejection, mouse bitmask
resolution.
L4 — Backend state-machine
Both WindowsRawInputBackend and WindowsHookBackend:
descriptor() pre-arm vs post-arm (L0Focused -> L2/L3), start()
with None binding rejection, rebind() in-place, stop()
clears + idempotent, stop() after stop() no-op.
L5 — dispatch_raw_input table
Keyboard match/non-match, key-down/key-up via Flags & 0x01,
no-binding short-circuit, mouse XBUTTON1/XBUTTON2 down/up
matching the bound button, unhandled HID type. RAWINPUT structs
built via mem::zeroed plus field-fill, owning the unsafe in
the test layer where it belongs.
L6 — dispatch_hook_keyboard + dispatch_hook_mouse
WM_KEYDOWN / WM_KEYUP / WM_SYSKEYDOWN / WM_SYSKEYUP for the
keyboard path, WM_XBUTTONDOWN / WM_XBUTTONUP for the mouse
path. Same shape as L5.
L7 — Privacy invariant (crates/chanora_audio/tests/ptt_privacy.rs)
New cross-platform integration test installs a custom
tracing_subscriber Layer that records every emitted event's
target + field names. Exercises the public PTT API plus (on
Windows) the backend factory. Asserts no field name in the
banned list (vk, scan_code, keysym, key_label, bound_key,
binding, platform_key, VKey, wVk, wScan, kbflags, mouseflags)
is ever emitted and every field belongs to the DEC-027
allow-list. Adds tracing-subscriber as a dev-dependency on
chanora_audio.
L8 — Full-chain integration in core/chanora_core/src/ptt.rs
Windows-only mod windows_full_chain_tests:
zero-tail full chain (synchronous)
default-tail full chain (200 ms wait then off)
mid-press rebind abandons in-flight press
L9/L10/L11 — tools/windows-smoke.cmd + tools/windows-smoke.md
Batch smoke script + operator doc. cargo build, flutter build,
artifact existence + size checks, headless launch with stderr
capture, bridge-initialised log assertion. Distinct exit codes
per failure step. Doc explains invocation + common failure
modes.
Verification (Linux)
- cargo check --workspace: clean.
- cargo test --workspace: 78 passed / 0 failed / 3 ignored.
76 cross-platform unit tests (unchanged) plus the new
ptt_privacy integration test plus one new ignored portal smoke
test.
The Windows-gated tests (~49 new) compile and run on the Korean
Windows 11 host where they belong; cross-compile from Linux is
not configured locally. The smoke script is the production
acceptance gate for rc.8 on Windows.
Deviations from the original plan are minor (single ignored
real-runtime test rather than per-platform attribute, L7 uses
public API rather than pub(crate) dispatchers, dispatchers live
inside windows.rs rather than a sibling module) and documented
in the subagent report.
Issue 1: tapping Space in the PTT binding capture dialog set
_captured to LogicalKeyboardKey.space.keyLabel which is ' ' (a single
space character), rendering as a blank string in the 'Captured: '
display. Same problem for Enter, Tab, Backspace, etc. — Flutter's
keyLabel returns the printable representation, not a readable name.
Added _displayLabelForKey() with a table that maps whitespace and
common special keys to canonical English labels matching the
entries in crates/chanora_audio/src/ptt_backends/windows_keymap.rs
(so the bridge resolves to the right VK_* on Windows). Pure
modifier keys (shift / ctrl / alt / meta / caps / num / scroll
lock) return null so they don't accidentally bind on their own.
Issue 2: physical key press -> 'PTT=on' in the Voice Bar lagged by
up to ~500 ms because audioStats was polled at 500 ms intervals.
The Rust-side transition is microsecond-fast; the visible delay is
purely the Flutter poll interval. Cut to 80 ms (~12 Hz), well below
the perceptual lag threshold. Adds ~12 small FFI calls per second,
trivially cheap. A push-based BridgeEvent::TransmitActiveChanged
would let us drop the poll entirely; noted as a follow-up.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Two new tests covering the user-acceptance criterion 'press → ptt
on, release → ptt off' through the full pipeline (backend press_gate
→ edge watcher → release tail → selector → real gate, identical to
what audioStats.pttActive reads in production):
* press_on_release_off_zero_tail — release_tail_ms = 0, transitions
are synchronous modulo one tokio tick.
* press_on_release_off_default_tail — release_tail_ms = 200,
transmit stays on briefly past key-up then transitions off.
cargo test --workspace --lib: 76 passed / 0 failed / 1 ignored.
Speaker (output) mute existed in the legacy _AudioControls widget
and the rust.setOutputMuted bridge call but was lost when SDD-097
replaced _AudioControls with VoiceBar. Mic mute carried over;
speaker mute did not.
Wire it back: VoiceBar gains an outputMuted prop + onToggleOutputMute
callback and renders a headset/headset_off icon next to the
existing mic mute. main.dart wires the existing _toggleOutputMute
handler (previously dead-code with // ignore: unused_element). The
bridge call setOutputMuted already does both effects together:
local engine silencer + server-broadcast ClientOutputMuted flag.
l10n: rename voiceHardMuteLabel to 'Mute microphone'/'麦克风静音'
to distinguish from the new voiceOutputMuteLabel 'Mute speakers'/
'扬声器静音'.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Previously the PttController handed its real AudioTransmitGate to
the platform backend and the backend wrote transmit_active directly
on every key edge — bypassing the 200 ms release tail and the
TransmitMode selector entirely. The tail timer was constructed and
exposed on ChanoraSession but never received any input, so SDD-096
and SRS-206 were spec-only.
Wire it: PttController now owns a synthetic 'press-edge gate' which
it hands to the backend in place of the real one. An internal
edge-watcher task subscribes to that press-gate, translating
true/false transitions into ReleaseTailTimer.key_down/key_up calls.
The release-tail timer feeds the selector's ptt_held input; the
selector recomputes transmit_active honouring mode, in_channel,
and hard_mute, and writes the real gate. Single owner of
transmit_active is preserved (SAD-083 invariant).
PttController::new now takes Arc<ReleaseTailTimer> instead of
AudioTransmitGate; ChanoraSession threads its session-scoped timer
through both the start_audio path and the supervisor reconnect
path. The legacy bridge set_ptt call (still used by the in-focus
Listener fallback and the e2e test) is rerouted through the timer
so the same tail and mute semantics apply uniformly.
ReleaseTailTimer gains force_release() — cancels any pending task
AND clears the selector's ptt_held. PttController::stop uses it so
shutdown can't leave transmit_active stuck at true.
Tests
- press_edge_drives_selector_through_release_tail: backend press
edge → real gate follows, key_up → tail keeps gate true for tail
window then clears.
- stop_clears_press_and_cancels_tail: stop() drops transmit even
with a tail in flight.
- e2e test now sets release_tail_ms=0 + waits one tick so the
pttActive=false assertion isn't racing the default 200 ms tail.
cargo test --workspace --lib: 74 passed / 0 failed / 1 ignored
(+2 new tests vs. the previous 72).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
The capability badge had its own 'Configure' TextButton that opened
the bind-key flow, while the Voice Bar's settings gear also reached
bind-key through the settings dialog. Two paths, same destination —
confusing and pointless duplication that the user flagged.
Resolution: the gear is the only configuration entry point. The
capability badge becomes information-only — it still shows the
detected PTT level + backend and (for L0Focused) the info-icon
explanation sheet, but no Configure button. The badge no longer
takes or props. The Voice Bar drops
the callback added in 6a41a0b.
Also removed the dead legacy widget class (lines
1001-1181) — it had no callers since the VoiceBar refactor in
ba444d9 but was still cluttering the file and even held a stale
reference to PttCapabilityBadge's old constructor signature.
The bound-key string is no longer duplicated either: the Voice Bar's
PTT-only secondary line ('PTT: Space · Release tail: 200ms')
remains the only place that shows the bound key, since it's also
the only PTT-mode-gated surface.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Save-binding before joining a voice channel used to return
BridgeError.invalidCommand(audio not started) because the
PttController only exists after start_audio runs and
set_ptt_binding required a live controller. Users naturally want
to bind their PTT key once on first launch, not every time they
join a channel — fix:
* chanora_storage::IdentityFileStore::set_ptt_binding /
get_ptt_binding persist the privacy-safe binding triple
(input_class, platform_key, key_label) into audio_meta.json
next to transmit_mode and release_tail_ms.
* ChanoraSession holds pending_binding: Arc<Mutex<Option<PttBinding>>>.
set_ptt_binding now (1) persists to storage best-effort, (2)
stashes into pending_binding, (3) forwards live to the
controller only if one exists. No more AudioNotStarted.
* init_storage loads the persisted binding into pending_binding
so it survives app restarts.
* start_audio applies pending_binding immediately after constructing
the PttController so the first key-press after join already works.
* supervisor_loop carries pending_binding and re-applies it after
any reconnect-driven audio engine restart, so reconnects don't
silently drop the hotkey.
* New bridge call get_ptt_binding() -> (input_class, key_label) plus
a matching Flutter _hydratePttBinding() in initState lets the
Voice Bar show the user's saved hotkey label on launch (e.g.
'PTT: Space') before any voice channel is joined.
cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
FRB bindings regenerated.
1. Hard-mute now informs the server (setInputMuted) in addition to
clamping the local TransmitGate. Without the server-side flag,
other clients keep seeing us un-muted; without the local clamp
a beat of in-flight audio leaks through. Drive both together so
the mic icon and the actual silence land at the same time.
2. Split the badge's Configure affordance from the Voice Bar's
'Voice settings' gear. The gear opens the mode + release-tail
dialog (onConfigure); the badge's configure opens the bind-key
capture flow directly (new onBindKey). Previously both routed
to the settings dialog, so 'Voice settings' and the badge's
'Configure' were the same screen — useless duplication.
3. Bind-key label is now PTT-only. The mode-badge row no longer
prints 'PTT: Space' when Continuous / Voice Activity is
selected. A new PTT-only secondary line carries the bound key
plus the release-tail value together, hidden entirely for
non-PTT modes.
4. Release-tail row is now PTT-only in BOTH the Voice Bar and the
Voice settings dialog. The dialog previously kept the slider
visible across all modes; switching to Continuous left the
user staring at a control that did nothing.
5. PTT capability badge is now PTT-only. In Continuous and Voice
Activity modes there is no key binding to surface a capability
for, so the 'L0Focused (focused)' line + its info sheet and
the Configure button disappear from the Voice Bar when the
user isn't in PTT mode.
All five fixes are pure UI; no Rust changes needed. flutter analyze
remains clean (6 pre-existing Radio.groupValue deprecation infos).
Bridge already had a tracing fmt layer writing to stderr but a Flutter
desktop app launched from Explorer / RDP has no terminal attached so
those records vanish. Add a non-ANSI file appender (best-effort,
4 MiB rotation) at the platform-conventional log path so developers
and beta testers can hand-inspect output:
* Linux: $XDG_STATE_HOME/app.chanora/chanora_flutter/chanora.log
(fallback ~/.local/state/...)
* macOS: ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
* Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log
Expose log_file_path_str() over FRB so the UI can show the path in a
'Save diagnostics' affordance later. Mobile (Android/iOS) returns an
empty string — those platforms still rely on logcat / Console.app.
No DEC-016 conflict: this is local-only, append-only, never
auto-uploaded. The in-memory log sink and export_diagnostics() path
are unchanged. The redacting layer still wraps the in-memory sink;
the new file appender consumes the same tracing events post-filter.
Trigger for this change: a ko-KR Windows 11 tester saw
'BridgeError.invalidCommand(audio not started)' with no way to find
the upstream warn record that documents which cpal call failed. Log
file is now discoverable without a terminal launch.
Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in dfa84ee.
Rust side
- chanora_audio::TransmitMode enum (Ptt/Continuous/VoiceActivity) with
serde-friendly u8 repr (SDD-095).
- chanora_audio::TransmitModeSelector: lock-free Atomic-backed selector
that is the sole writer of transmit_active (per SAD-083), applying
hard_mute as a final clamp. VoiceActivity falls through to Continuous
for v1 (DEC-030 placeholder).
- chanora_audio::ReleaseTailTimer: tokio-task-owning struct driving the
selector's ptt_held input; default 200 ms tail, configurable 0–500 ms
with AtomicU32 hot read; pending JoinHandle held in a std::sync::Mutex
touched only on PTT edge transitions (SDD-096).
- chanora_storage: AudioMeta persisted as audio_meta.json next to
identity.dek; get/set_transmit_mode + get/set_release_tail_ms with
0..=500 clamp on write.
- chanora_core::ChanoraSession: voice_join(channel, password) and
voice_leave() are the new lifecycle entry points; ensure_audio_running
and shutdown_audio_if_idle are private helpers around the existing
Option<AudioEngine> field. SessionEvent::VoiceState carries the
in_channel / transmit_mode / mute / release_tail_ms tuple. Selector
state survives reconnect; supervisor rewires it to each fresh engine
gate.
- chanora_bridge: drop start_audio; add voice_join, voice_leave,
set/get_transmit_mode, set/get_release_tail_ms, set_hard_mute.
BridgeEvent::VoiceState mirrors the core event. AudioStarted/Stopped
kept for backwards compat but Flutter ignores them in the new UI.
Flutter side
- New apps/chanora_flutter/lib/widgets/voice_bar.dart replaces the
legacy _AudioControls widget. Renders channel pill, mode badge,
mute toggle, level meter, PttCapabilityBadge, leave button. No
manual Start affordance anywhere.
- New apps/chanora_flutter/lib/widgets/voice_settings.dart dialog with
TransmitMode radio group (VoiceActivity disabled with 'Coming soon'
trailing label per DEC-030), bind-key button, release-tail slider
0–500 ms step 25.
- main.dart: state fields _inChannel, _transmitMode, _hardMute,
_releaseTailMs driven by BridgeEvent_VoiceState. Channel-tap now
calls voiceJoin instead of moveToChannel. Removed _onStartAudio,
_audioStarted-gated branch, and the FilledButton.
- l10n: 11 new strings in app_en.arb + app_zh.arb.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored
(chanora_audio: +12 new tests for TransmitMode/Selector/ReleaseTail;
chanora_storage: +2 new tests for audio_meta round-trip).
- flutter analyze: 0 errors, 0 warnings; 6 infos are the Flutter 3.32
Radio.groupValue deprecation (pre-existing API usage).
- FRB Dart/Rust bindings regenerated via flutter_rust_bridge_codegen.
Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
directly via the legacy set_ptt path; routing those key edges through
ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
Add SysRS-303/304, SysDes-149/150/151, SRS-204/205/206/207,
SAD-081/082/083, SDD-094/095/096/097, DEC-029/030.
Captures the v1 lifecycle redesign:
- Drop manual Start-audio button; audio engine is bound to voice-channel
join/leave (ensure_running on first join, shutdown_if_idle on last
leave). Output stream opens regardless of mic-permission state so
listen-only is a first-class flow.
- TransmitMode enum (Ptt / Continuous / VoiceActivity-reserved).
Default Ptt on fresh install. Persisted per identity.
- PTT release tail: 200 ms default (0-500 ms configurable) before
transmit gate closes, avoiding clipped trailing syllables.
- Hard-mute toggle overrides transmit gate regardless of mode/PTT.
- Bridge surface: drop start_audio/stop_audio; add
voice_join(channel_id) / voice_leave() and BridgeEvent::VoiceState.
DEC-029 rejects Flutter global-hotkey packages (hotkey_manager,
super_hot_key) for PTT: they wrap RegisterHotKey/RegisterEventHotKey
which consume the key and don't fire key-up, wrong primitive for PTT.
Native Rust DesktopPttBackend (SDD-083/084/085) stays authoritative.
DEC-030 defers Voice Activity Detection to P1. RMS / WebRTC VAD /
Silero VAD trade-off review (binary-size, dependency-surface, CPU
profile) postponed; TransmitMode::VoiceActivity reserved on the enum
surface so a P1 increment is non-breaking.
Validator clean: 304/151/207/83/97 IDs, strict layered sourcing
preserved, no new warnings beyond the pre-existing 35 old-package-name
filenames.
Initial Task C commit (77c2a1d) used handle constructors and import paths
that match windows-rs 0.58+, not the 0.54 version pinned via the
workspace's transitive 'windows' dep. Compile errors on the Korean
Windows 11 build:
* HWND/HHOOK/HRAWINPUT take 'pub isize' in 0.54 (became raw pointers
in 0.58). Use HWND(HWND_MESSAGE_PTR), HHOOK(0), HRAWINPUT(lparam.0)
instead of *mut _ casts.
* CreateWindowExW returns HWND directly in 0.54, not Result<HWND>.
Check hwnd.0 == 0 for null.
* RegisterClassExW + WNDCLASSEXW are gated behind Win32_Graphics_Gdi
in 0.54. Added that feature.
* XBUTTON1 / XBUTTON2 live in Win32::UI::WindowsAndMessaging not
Win32::UI::Input::KeyboardAndMouse in 0.54.
* Win32_System_Threading needed for GetCurrentThreadId.
* Unused Mutex import dropped.
No behavioural change relative to 77c2a1d; just signature alignment.
The v1.0.0-rc.7 Windows backends were thread::sleep stubs that
reported optimistic L2GlobalHoldToTalk / L3GlobalWithMouseButtons
descriptors without actually registering for any global key events.
Surfaced on Windows verification as:
* 'even press to talk key was set, still only the hold to talk
button is work for talk'
* 'and displayed as L2GlobalHoldToTalk(raw-input)'
* 'cannot continuous transmission'
This commit implements the real backends:
WindowsRawInputBackend (preferred Windows rung, SDD-083):
* Hidden message-only window via
CreateWindowExW(..., HWND_MESSAGE, ...).
* RegisterRawInputDevices with RIDEV_INPUTSINK on
Usage Page 0x01 / Usage 0x06 (keyboard) and 0x02 (mouse) so
events fire globally — including when Chanora is unfocused.
* WndProc handling WM_INPUT: GetRawInputData ->
keyboard.VKey vs bound vk, or mouse.usButtonFlags vs bound
side-button index. Down -> gate.set(true); up -> gate.set(false).
* Dedicated chanora-rawinput thread runs GetMessageW /
TranslateMessage / DispatchMessageW until stop() posts
WM_QUIT via PostThreadMessageW.
WindowsHookBackend (fallback rung, SDD-084):
* SetWindowsHookExW(WH_KEYBOARD_LL) + WH_MOUSE_LL on a
dedicated chanora-llhook thread.
* Hook procs translate KBDLLHOOKSTRUCT.vkCode and
MSLLHOOKSTRUCT.mouseData against the same shared
AtomicBinding.
* UnhookWindowsHookEx on teardown.
Both backends:
* Honest descriptor() reporting: backends start reporting
L0Focused; level upgrades to L2 / L3 only after a real
arming success (RegisterRawInputDevices or SetWindowsHookEx
returning Ok). This fixes the 'L2 reported but doesn't fire'
complaint by making the badge tell the truth — if Raw Input
registration fails at runtime the user sees the L0Focused
info-icon explanation sheet instead of being told L2 works.
* AtomicBinding (class / vk / mouse_btn) for lock-free hot
path. Translation lives in
crates/chanora_audio/src/ptt_backends/windows_keymap.rs
which maps Flutter LogicalKeyboardKey.keyLabel strings
(e.g. 'Space', 'F10', 'A') to Win32 VK_* codes; mouse
side-button bitmask strings ('mouse-side-button:8' /
':16') to RawInput button indices (4 / 5).
* Per-thread context (thread_local RefCell) carries the
gate + binding to the WndProc / hook proc without needing
raw-pointer user-data plumbing.
Diagnostic logging:
* AudioEngine::start now logs default_input_config and
default_output_config explicitly with the channels /
sample_rate / sample_format that cpal reports, so a
build_*_stream failure on locale-specific Windows hosts
(reported on ko-KR Windows 11 as 'Start Audio Button not
work') becomes diagnosable from the stderr log alone.
* build_output_stream surfaces the requested config in the
tracing::error! record on failure.
Privacy (DEC-027 / SDD-090): the windows.rs and
windows_keymap.rs hot paths NEVER log raw VKs, scan codes,
keysyms, key labels, or button identifiers. Only the
platform-neutral input class ('keyboard' /
'mouse-side-button') and the backend id appear in the tracing
stream. The SDD-090 PttSanitizer Layer is the defence-in-depth
net but this code does not rely on it.
Tests: 4 new windows-only unit tests in windows_keymap (ASCII
letters / digits / Space + Fn / unknown / mouse button index).
They compile only under cfg(target_os = "windows") so the
Linux workspace test count is unchanged at 59/0/3.
Cargo deps: adds windows = '0.54' (target_os = windows) with
the feature set needed for RawInput + hooks. 0.54 matches
the version already transitive through the workspace.
Verified on Linux: cargo check --workspace clean, cargo test
--workspace 59/0/3 (windows-gated tests skip on Linux). The
real exercise of this commit will happen on the Korean Windows
11 host (100.84.219.45) at the next build.
Surfaced on the v1.0.0-rc.7 Windows verification round as 'Bridge
Error connection failed: storage crypto decrypt aead error'.
Root cause: IdentityFileStore::ensure_dek treated the platform
keyring as the authoritative store and deleted identity.dek after
successfully promoting it. Subsequent launches whose process
context could not reach the keyring (Windows SSH session hits
ERROR_NO_SUCH_LOGON_SESSION; macOS LaunchAgent contexts hit
errSecMissingEntitlement) saw dek_path.exists() = false and
generated a fresh DEK, even though the keyring still held the
DEK that originally encrypted identity.tskey. Next ChaCha20-
Poly1305 AEAD decrypt of the identity blob then failed because
the in-process DEK was 32 fresh random bytes, not the bytes that
encrypted the stored ciphertext. The bookmark store (which
shares the DEK via crypto()) also broke for the same reason.
Manual reproduction on the rc.7 build at 100.84.219.45:
* Launch via SSH (keyring unreachable) -> file DEK_v1 created,
identity.tskey eventually encrypted under DEK_v1.
* Launch via RDP (keyring reachable) -> file DEK_v1 promoted
to keyring, identity.dek deleted.
* Launch via SSH again (keyring unreachable, file gone) -> a
fresh DEK_v2 is written to file. identity.tskey still
encrypted under DEK_v1.
* Next decrypt: DEK_v2 vs identity.tskey ciphertext -> AEAD
tag mismatch -> StorageError::Crypto('decrypt: \u2026') ->
bubble up as 'storage crypto decrypt aead error'.
Fix invariants:
* identity.dek (file) is the durable source of truth and is
never deleted by ensure_dek.
* keyring is opportunistic: we copy the DEK into it for the
UX-level convenience of platform-managed secret storage,
but its presence/absence does not affect correctness.
* ensure_dek on first install writes the DEK to BOTH places.
* ensure_dek on subsequent launches: keep using the file DEK;
re-copy into the keyring if not present (idempotent).
* load_dek prefers the file; only consults the keyring as a
legacy-migration fallback for installs that lost their file
mirror before this commit landed.
No SDD / SAD / SRS contract changes - the file fallback at
identity.dek and the in-keyring entry at app.chanora.identity::
identity-dek::<canonical-dir> were both already documented
behaviours; this commit corrects which one is authoritative.
The file lives in app-private storage where the platform
sandbox is the access-control authority (this was already
called out in the existing open_private comment on non-Unix
targets), so retaining the file mirror does not weaken the
security posture in any meaningful way relative to the prior
keyring-only durable-state design.
Verified on Linux: cargo test --workspace 59/0/3 (no regressions
from feceacf + 5c413ba).
The TeamSpeak 3 protocol's per-channel `order` field is NOT a
numeric rank — it stores the ChannelId of the channel that should
appear immediately before this one within the same parent. The
previous chanora_protocol::adapter::build_snapshot sorted by
`order.0` as if it were a sequence number, producing
stable-but-arbitrary output that did not match TS3 client display
order. Surfaced on the Windows verification round as 'channel
sort in not correct'.
Replace the numeric sort with a linked-list walk per parent
followed by a root-first depth-first emission so the bridge
consumer receives a pre-ordered tree:
fn sort_channels_tree(&[&Channel]) -> Vec<&Channel>
fn sort_channels_tree_by<T>(&[&T], extract) -> Vec<&T>
fn emit_subtree<T>(by_parent, root_id, out, extract)
Defensive behaviour:
* Per-parent cycle guard so a malformed snapshot can't infinite-loop.
* Channels whose predecessor pointer is unreachable from
order=0 are appended at the end of their parent bucket sorted
by id (channel never silently disappears from the UI).
* Channels whose `parent` is not present anywhere in the tree
are appended at the very end sorted by id (orphan defence).
Unit tests cover the four shapes that broke real users:
* Single-parent linked list out of HashMap iteration order
* Disconnected predecessor (leftover-bucket fallback)
* Two-level tree (depth-first subtree emission)
* Two-channel cycle (no infinite loop, both channels emitted)
Also removes the now-redundant Dart-side numeric sort in
_SnapshotView.build(); Flutter trusts the pre-ordered server
list and would otherwise re-introduce the bug.
Verified on Linux: cargo test --workspace 59/0/3 (was 55 + 4 new
adapter tests), flutter analyze clean.
After a user saved a PTT binding through _PttBindingCaptureDialog,
the capability badge showed the resolved level + backend (e.g.
'PTT: L2WindowsRawInput (windows-raw-input)') but never told the
user which key they had actually bound. Reported on the Windows
verification round as 'do you think we should tell user what key
they have set and then they will know what to press'.
This commit caches the captured platform-neutral key label in
_BetaHomeState whenever _onConfigurePtt succeeds, and threads it
through _AudioControls to a new boundKeyLabel prop on
PttCapabilityBadge. When the prop is non-empty the badge renders
a second line below the existing row:
PTT: L2WindowsRawInput (windows-raw-input) [ⓘ] [Configure]
Key: Space
The label uses bodySmall + monospace + onSurfaceVariant to stay
visually subordinate to the capability descriptor. Two new l10n
entries (en + zh) cover the 'Key: {key}' string.
Privacy: the displayed label is the same platform-neutral
LogicalKeyboardKey.keyLabel string the dialog already shows
during capture and that already crosses the bridge as
PttBinding.platform_key. No raw OS key code is introduced
(DEC-027 / SDD-077 compliance preserved).
State scope: display-only cache that resets on app restart. The
bridge-side PttController (SDD-088) holds the authoritative
binding; this UI cache is purely for display continuity within
a single process.
Verified on Linux: flutter analyze clean, cargo test --workspace
55/0/3.
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:
* SDD-088 PttController — backend ownership + binding mutex +
capability watch lived split between AudioEngine and
ChanoraSession. Extracted into chanora_core::ptt::PttController.
AudioEngine now owns only the cpal streams and the missed-key-up
watchdog (SDD-092); the platform input backend, the active
PttBinding, and the capability watch::Sender live in the
controller. ChanoraSession::start_audio constructs the controller
against the engine's gate; disconnect/reconnect/restart paths
tear it down through stop().await before the engine.
* SDD-090 PttSanitizer — banned-field check was inlined as
PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
into a generic PttSanitizer<L> tracing_subscriber::Layer that
decorates an inner Layer (canonical pairing:
RedactingLogLayer::with_sanitizer). The inner layer keeps its
own structural ban check as defence-in-depth for bare-install
callers.
* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
into a public PttCapabilityBadge widget and added the
SDD-091-specified per-platform explanation sheet that opens on
the info-icon tap when the resolved capability is L0Focused.
New l10n strings (en + zh) cover the sheet copy.
Tests:
* 2 new unit tests for PttController (arm + descriptor watch)
* 1 new unit test for PttSanitizer (end-to-end through a real
tracing subscriber proving banned drop + safe forward)
cargo test --workspace: 55 passed / 0 failed / 3 ignored
cargo deny check: advisories ok, bans ok, licenses ok, sources ok
flutter analyze: no issues
tools/validate_docs.py: zero undefined refs, zero direct-layer
violations (pre-existing 35 old-package-name warning unchanged)
No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
A P0 traceability audit per the project's compliance workflow
found exactly one gap across the 162 P0 SRS items:
* SRS-200 (mouse side buttons as bindable inputs for desktop
Global PTT) had no dedicated SAD item. The earlier baseline
matrix folded SRS-200 under the narrative
`SRS-195..203 -> SAD-071..079` umbrella, which technically
covered the requirement at the table level but did not give
SRS-200 a one-to-one SAD source the validator's strict
discipline expects.
Per the project's gate-check workflow (Case C —
BLOCKED_MISSING_SAD) this commit closes the documentation chain
*before* claiming compliance for the already-merged mouse-side-
button code path:
* `docs/architecture/sad.md` v0.9.4 — new `SAD-080` sources
SRS-200, allocates `Audio (Windows / macOS / Linux), Bridge,
Flutter UI`, and records the cross-platform mouse-side-button
surface as a software-architecture item. The §27 coverage
matrix gains a dedicated SRS-200 -> SAD-080 row.
* `docs/architecture/sdd.md` v0.9.4 — new `SDD-093` sources
SAD-080. Specifies the `PttInputClass` enum surface
(`None` / `Keyboard` / `MouseSideButton`), the rebind
contract on the Windows + macOS backends, the Linux portal's
pass-through behaviour, and the Flutter
`PointerEvent.buttons` bitmask capture (back = `0x08`,
forward = `0x10`). The §11 coverage matrix gains the
SAD-080 -> SDD-093 row.
* `docs/governance/traceability-matrix.md` v0.9.4 — the
`(DEC-026 mouse buttons)` row moves from
`SAD-072..074 / SDD-082..086` to the dedicated
`SAD-080 / SDD-093`.
* `docs/governance/baseline-candidate-validation-report.md`
v0.9.4 — ID totals advance to 302 / 148 / 203 / 80 / 93;
direct-layer-rule and undefined-reference counts remain
zero.
* `docs/governance/repo-format-validation-report.md` v0.9.4 —
same ID totals update.
Audit summary
-------------
* 162 P0 SRS items audited.
* 1 SAD-coverage gap (SRS-200) — closed by this commit.
* 0 SDD-coverage gaps (every PTT SAD has explicit SDD coverage;
the inherited baseline `SAD-032/033` are covered through the
documented range row, not individually).
* All Priority: P0 software requirements now have a strict
SRS -> SAD -> SDD chain on file.
Validator output:
```
[OK] SysRS: 302 defined, 0 undefined references
[OK] SysDes: 148 defined, 0 undefined references
[OK] SRS: 203 defined, 0 undefined references
[OK] SAD: 80 defined, 0 undefined references
[OK] SDD: 93 defined, 0 undefined references
[OK] SRS direct SysRS references: 0
[OK] SAD direct SysRS references: 0
[OK] SAD direct SysDes references: 0
[OK] SDD direct SysRS references: 0
[OK] SDD direct SysDes references: 0
[OK] SDD direct SRS references: 0
```
Implementation status
---------------------
The mouse-side-button code was implemented in v1.0.0-rc.4 and
v1.0.0-rc.5 under the (then-implicit) PTT umbrella; the code
already matches the new SDD-093's contract verbatim. This
commit ships **documentation only** — it adds the SAD/SDD/matrix
rows that retroactively justify the existing implementation
under the strict traceability discipline. No code edits, no test
edits.
* `cargo test --workspace`: 67/67 green (unchanged).
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate`: zero new warnings (no Cargo.lock
delta).
* `flutter analyze`: clean.
* `tools/validate_docs.py`: all SRS/SAD/SDD coverage and
direct-layer-rule checks pass.
Outstanding open items remain live verification per
`docs/release/release-readiness-go-nogo-record.md`
(RR-PTT-001..006/008) and the DEC-012 legal review; both are
non-engineering work.
Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md`
against the live code. Three items needed work; this commit closes
all three.
Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible
labels for the PTT control)
-------------------------------------------------------------------
The Flutter PTT control is a custom `Listener` over a `Container`
— not a built-in `Button`, so the platform accessibility tree had
no idea it was an interactive control. Screen readers
(VoiceOver, TalkBack, NVDA, Orca) would have read the visible text
without announcing the control role or its toggled state.
Wrap the Listener in a `Semantics(button: true, toggled: _pressed,
label: …, hint: …, excludeSemantics: true)` so the platform
accessibility tree carries the right role, the current state
("Hold to talk" / "Transmitting"), and a usage hint. The
`excludeSemantics: true` argument suppresses the duplicate child
nodes the Container + Row + Icon + Text would otherwise generate
on top of our explicit label.
SysRS-263 (no colour-only state) is preserved: the visible label
and the mic icon already differentiate the two states without
relying on the colour transition.
New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and
`app_zh.arb`.
Gap B — SRS-198 (macOS async permission re-check)
-------------------------------------------------
The macOS backend queried `query_permission()` once at
construction and never re-checked. That violates SRS-198's
"upgrade to the appropriate Global level only after the user
grants the required permission" — once Chanora is running, a
runtime grant must lift the descriptor from `L0Focused` to a
Global level without an app restart.
Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`:
* `permission: PermissionState` becomes `permission: Arc<AtomicU8>`,
enabling cross-thread updates without a Mutex.
`PermissionState::{to_u8, from_u8}` carry the encoding.
* The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
and overrides `DesktopPttBackend::descriptor_watch()` to hand
out subscribers; `chanora_core::ChanoraSession::start_audio`
already forwards transitions to `SessionEvent::PttCapability`.
* `start()` spawns a `chanora-perm-watch` OS thread that polls
`query_permission()` every 1.5 s and republishes the
descriptor on every transition. Polling rather than KVO /
notifications because Input-Monitoring has no public
change-notification API on macOS; 1.5 s is sufficient for a
user grant + return-to-Chanora cycle.
* `rebind()` also republishes the descriptor so a
`keyboard → mouse-side-button` change updates the badge.
* Six new unit tests on the platform-independent
`build_descriptor` and the atomic encoding contract. They
only compile under `target_os = "macos"` (consistent with
the rest of the module), so the Linux dev-host workspace
test count is unchanged.
`query_permission()` itself still returns `Undetermined` until
the IOKit live link lands in the macOS platform-verification
commit; the re-query loop will engage the upgrade path
automatically the moment that function returns real values.
Gap C — SRS-200 (Linux mouse-side-button portal-dependence)
-----------------------------------------------------------
`desktop-ptt-architecture.md` §5.3 already described the
heuristic classifier. Added one explicit sentence stating that
Linux mouse-side-button support is *portal-dependent*: Chanora
never claims a fixed Mouse4/Mouse5 binding on Linux; the portal
decides what inputs it accepts in the current session, and the
classifier degrades to `keyboard` whenever the portal's
description does not contain "mouse". This matches the SRS-200
text verbatim and removes the ambiguity over what "Linux
support follows the portal" means in practice.
Verification
------------
* `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`):
all 67 Linux-side tests green (unchanged). The new macOS
unit tests count under `target_os = "macos"` only — they
will report once the macOS reference host runs `cargo test`.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `flutter analyze`: clean (no new accessibility warnings).
* Linux release bundle builds clean.
P0 audit summary
----------------
After this commit every Priority: P0 row in `sysrs.md` and
`srs.md` has a concrete implementation. The remaining open items
are all live verification, not code:
* Per-platform live PTT traces on Windows / macOS reference
hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable
locally; queued for platform owners.
* Linux GNOME-Wayland live trace (RR-PTT-004) — implemented
in rc.5; awaiting live host trace.
* Linux non-tested compositor fallback trace (RR-PTT-005) —
Open.
* Diagnostic-export key-leak inspection (RR-PTT-006) — Open
but trivially testable on any host with PTT bound.
* DEC-012 legal review — engineering hand-off complete since
rc.2.
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:
* Stop now closes the portal session through the dedicated
`org.freedesktop.portal.Session` interface (not the
request-cancel `Request` interface — that would only abort a
pending Request, not release the bound shortcuts).
* Ten new unit tests cover `classify_shortcuts_value`,
`publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability
contract using synthesised `OwnedValue` payloads. Live D-Bus
coverage stays in the `linux_portal_smoke` ignored
integration test (RR-PTT-004).
Live session lifecycle (gen2 Q5b — lazy, single backend instance):
1. `start(gate, binding)` spawns one `tokio::spawn` worker that
owns an async `zbus::Connection` (sharing the bridge's
tokio runtime per Q4a).
2. `CreateSession` with fresh random `handle_token` /
`session_handle_token` tokens. The worker awaits the portal
`Response` signal via a `RequestProxy` subscription and
extracts `session_handle` from the results dict.
3. `BindShortcuts(session_handle, [("chanora-ptt", { description
= "Chanora push-to-talk" })], "", {})`. The portal opens its
own system-managed dialog asking the user to choose a key
— Chanora itself never reads raw key events. The audio
engine continues at `L0Focused` while the dialog is open;
the descriptor watch publishes the transition once the
portal returns.
4. On `response_code == 0`: classify the `trigger_description`
substring (heuristic: contains "mouse" -> MouseSideButton,
else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for
mouse) through the watch sender. The raw trigger_description
string is never logged (DEC-027 / SRS-202).
5. On `response_code == 1` (cancelled) or `>= 2` (failure):
publish `L0Focused` through the watch sender. The user can
retry via the UI "Configure" button (gen2 Q6a).
6. The worker enters a `tokio::select!` loop multiplexing the
`cmd_rx` channel (Rebind / Stop) and the `Activated` /
`Deactivated` signals. Matching signals scoped to this
session handle and `chanora-ptt` shortcut id drive
`gate.set(true/false)`.
7. `Rebind` re-runs `BindShortcuts` on the same session.
8. `Stop` calls `org.freedesktop.portal.Session.Close()` on
the session-handle object path, clears the gate, exits.
UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`,
the Flutter "Configure" button skips the in-app
`_PttBindingCaptureDialog` and shows a SnackBar telling the user
their desktop environment will open its own shortcut dialog.
The button delegates to `setPttBinding(keyboard, "portal")`
which nudges the backend; the portal handles the rest. New ARB
key `pttConfigurePortalRedirect` in en + zh-Hans.
Trait surface (cross-cutting):
* `DesktopPttBackend::descriptor_watch()` is a new trait method
with a default impl returning a never-firing receiver.
Backends with async capability transitions (only the Linux
portal backend today) override it to return the live watch
sender's receiver.
* `chanora_core::ChanoraSession::start_audio` subscribes to the
active backend's `descriptor_watch()` and spawns a forwarder
task that re-emits `SessionEvent::PttCapability` on every
transition. The initial value is emitted synchronously.
`Cargo.toml` (Linux-only):
* `futures-util` (std features, no executor) for stream
consumption on the portal signal subscriptions.
* `rand 0.8` for fresh per-process portal tokens.
* `zbus` continues at v5 with the `tokio` + `blocking-api`
features.
Tests
-----
* `chanora_audio` rises from 8 to 18 unit tests. New
coverage on the Linux module:
- `classify_returns_none_when_shortcut_id_missing`
- `classify_returns_keyboard_for_typical_trigger_description`
- `classify_returns_keyboard_when_trigger_description_missing`
- `classify_detects_mouse_substring`
- `classify_is_case_insensitive_on_mouse_substring`
- `publish_bound_keyboard_publishes_L2_with_keyboard_class`
- `publish_bound_mouse_publishes_L3`
- `publish_bound_none_publishes_L2_keyboard_default`
- `publish_l0_clears_descriptor`
- `shortcut_id_is_stable`
* Workspace total: 67 unit + integration tests, all green with
`CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4).
* New `crates/chanora_audio/tests/linux_portal_smoke.rs`
ignored integration test (RR-PTT-004 evidence path). Run on
a GNOME-on-Wayland host with
`cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`.
Documentation
-------------
* `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten
to describe the realised lifecycle; v0.9.4 change-history
entry added.
* `docs/governance/product-decision-register.md` v0.9.10
change-history entry recording the code-side promotion. No
decision rows mutate.
* `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004
flipped from `Open` to `Implemented (live trace pending)`;
v0.9.5 change-history entry.
Verification
------------
* `cargo test --workspace`: 67/67 green.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate --offline`: zero new warnings.
* `tools/dump_flutter_licenses.sh`: 94 packages, 0 without
LICENSE.
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` +
`flutter build linux --release`: clean Linux x86_64 bundle.
* Live portal trace (RR-PTT-004) — **not run**. The dev shell
is a TTY without a Wayland session. The user will run the
ignored smoke test from inside a GNOME-on-Wayland session
when available.
No Windows / macOS / iOS live verification in this commit (hosts
unavailable). The Windows + macOS backend scaffolds remain in
place reporting their target capability honestly; live OS-call
wiring is queued for their respective platform owners'
reference hosts per `docs/governance/staged-release-plan.md`.
Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).
`chanora_audio::ptt`
--------------------
* `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
`tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
encoder feed reads the atomic on the hot path; the watchdog
subscribes to the watch channel.
* `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
transitions and self-clears `transmit_active` if the
`false -> true` lifetime exceeds the configured ceiling
(DEC-028, default 30s). Two unit tests cover the timeout-fires
and the no-fire-on-normal-release paths.
`chanora_audio::ptt_backends`
-----------------------------
* `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
carries only `input_class` and an opaque `platform_key`
string; raw key codes never appear in the type surface.
* `select()` factory (SAD-071): runtime ladder evaluation per
OS. Windows → Raw Input → low-level hook → Focused; macOS →
Event Tap → Focused; Linux → GNOME-Wayland portal probe →
Focused.
* `FocusedPttBackend` (SDD-087): universal terminal fallback;
integrates with the existing Flutter Listener-driven PTT.
* `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
SDD-084): three-rung ladder evaluated once at engine start.
Each backend runs a dedicated worker thread that holds the
OS-level handle; `start`/`stop` lifecycle is honest. Live
`RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
platform-verification work — the scaffolding lets the
descriptor + watchdog + capability event be exercised
end-to-end now.
* `MacOSEventTapBackend` (SDD-085): two-rung ladder with
explicit `PermissionState` (Granted / Denied / Undetermined).
`Undetermined` resolves to `L0Focused` so capability
advertising matches actual runtime behaviour even before
Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
wiring is platform-verification work.
* `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
is reachable by reading the `version` property over a
blocking zbus session. Reports `gnome-wayland-portal` /
`L2GlobalHoldToTalk`. Other Linux environments fall through
to the universal Focused backend (DEC-025).
`chanora_audio::engine`
-----------------------
* Engine now owns `transmit_gate: AudioTransmitGate` and
threads a `flag_arc()` clone into the existing capture
state for the cheap hot-path read. `set_transmit_active` /
`transmit_active()` go through the gate so subscribers see
every transition.
* `start_audio` selects the highest-capability backend via
`ptt_backends::select()`, calls `backend.start(gate, none())`,
and spawns the watchdog. Both are released in `stop()` and
on Drop.
* New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
drives the binding-capture flow without restarting the engine.
* New `engine.ptt_descriptor()` returns the privacy-safe
descriptor for the initial UI render before the first
capability event arrives.
`chanora_core`
--------------
* Re-exports `PttBinding` + `PttInputClass`.
* New `ChanoraSession::set_ptt_binding(binding)` — calls
`audio.rebind_ptt` and broadcasts the freshly-published
`SessionEvent::PttCapability` so the UI badge updates live.
* New `ChanoraSession::ptt_descriptor()` for the initial render.
`chanora_bridge`
----------------
* New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
platform_key)` async function. The `platform_key` string is
opaque to the bridge and never logged.
* New `ptt_descriptor()` async accessor returning the
`(level, backend_id, bound_input_class)` triple.
Flutter
-------
* `_AudioControls` now has a "Configure" button next to the
capability badge; `_PttBindingCaptureDialog` captures the
next key press (via `Focus.onKeyEvent`) or mouse side button
(via `Listener.onPointerDown` filtered to button bitmasks
`0x08` / `0x10`). The captured value is the platform-neutral
`LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
* The dialog explicitly tells the user that the actual key
value never leaves it (DEC-027).
* New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
`pttConfigurePrompt`, `pttConfigureWaiting`,
`pttConfigureCaptured`, `pttConfigurePrivacyNote`,
`pttConfigureSaveAction` (en + zh-Hans).
Dependencies
------------
* `chanora_audio` adds (Linux only) `zbus = "5"` with the
`tokio` runtime selector + `blocking-api` feature for the
GlobalShortcuts portal probe.
* `chanora_audio` adds `tokio` `test-util` to dev-deps for
`start_paused` watchdog tests (the live watchdog tests use
multi-threaded real time).
Verification
------------
* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
57 tests green (was 53). chanora_audio rises from 4 to 8.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate --offline`: regenerates
`docs/security/license-inventory.{md,html}`. The crate count
rises from 364 to 383 with the addition of the zbus tree.
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
LICENSE (unchanged).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
linux --release`: clean Linux x86_64 bundle.
Documentation
-------------
* `docs/release/release-readiness-go-nogo-record.md` flips
RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
to the two passing unit tests; bumps to v0.9.4. Live
per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
open and are blocked only on platform reference hosts.
Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
Implements the gen2 v0.9.3 doc baseline's first slice of code work:
* SRS-201: split the audio engine's `ptt` AtomicBool into the
authoritative `transmit_active` flag. The legacy `set_ptt` /
`ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
so the existing bridge command and the existing Flutter
hold-to-talk UI keep compiling.
* SAD-075 / SDD-089 acknowledged at the type level: only
`AudioEngine::set_transmit_active` (or its legacy alias)
mutates the flag; the encoder feed reads it once per outbound
frame and never writes.
* SDD-082: new `chanora_audio::ptt` module ships the
`PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
`L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
`L4DeviceAware` reserved) with a stable `as_str` mapping and
an `is_global` classifier.
* SDD-087: `PttBackendDescriptor::focused()` constant value for
the universal Focused-PTT fallback. The struct shape carries
only privacy-safe fields (`level`, `backend_id`,
`bound_input_class`) — a key code cannot fit through this
surface by construction (DEC-027).
* SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
`PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
(`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
`keysym_string`, `key_sequence`, `key_press_history`,
`key_timing`). Any record whose field set names a banned key
is dropped before reaching the in-memory log sink or the
user-initiated diagnostic export. The check is structural and
runs ahead of formatting / redaction.
* `SessionEvent::PttCapability` carries the diagnostics-safe
descriptor through the broadcast event stream;
`chanora_core::ChanoraSession::start_audio` publishes the
Focused-PTT descriptor when the audio engine starts (SRS-196
/ SDD-091).
* `BridgeEvent::PttCapability` mirrors the event across the
FFI boundary. flutter_rust_bridge codegen regenerated.
* Flutter `_AudioControls` renders a capability badge above the
PTT button: a globe icon for Global levels, a focus-frame
icon for `L0Focused`, plus a Tooltip exposing the bound input
class. New ARB key `pttCapabilityBadge(level, backend)` in
`app_en.arb` and `app_zh.arb`.
Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.
Tests
-----
* `chanora_audio` rises from 1 to 4 unit tests covering
`PttCapabilityLevel::as_str`, `is_global`, and the
`PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
the new `PttBanCheckVisitor` over every banned field name and
the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
`CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate`: zero warnings (license inventory
regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
LICENSE.
* Linux x86_64 release bundle builds clean.
No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
Applies the gen2 desktop-PTT review summary
(`gen2/chanora-desktop-ptt-review-summary-v0.9.2.md`) to our doc set
with the owner rulings PTT-OPEN-001 through PTT-OPEN-006 resolved as
accepted decisions DEC-023 through DEC-028:
* DEC-023 Windows Global PTT P0 / MVP
* DEC-024 macOS Global PTT P0 / MVP with permission UX
* DEC-025 Linux officially-tested env: GNOME on Wayland only
* DEC-026 Mouse side buttons supported (Win + macOS; Linux portal)
* DEC-027 PTT diagnostics: capability + availability only, no
raw key codes ever
* DEC-028 Missed-key-up watchdog: P0
Requirements (SysRS / SRS) and architecture (SysDes / SAD / SDD)
gain the desktop-PTT ID set the gen2 summary describes:
SysRS-296..302 -> SysDes-142..148
-> SRS-195..203
-> SAD-071..079
-> SDD-081..092
ID totals advance from 295 / 141 / 194 / 70 / 80 to 302 / 148 / 203
/ 79 / 92. The strict layered sourcing rule (`SRS -> SysDes` only,
`SAD -> SRS` only, `SDD -> SAD` only) is preserved; the
`tools/validate_docs.py` validator reports zero undefined refs and
zero direct-layer-rule violations.
New document:
* `docs/architecture/desktop-ptt-architecture.md` — capability
ladder (L0Focused, L1GlobalShortcut, L2GlobalHoldToTalk,
L3GlobalWithMouseButtons, L4DeviceAware reserved), Windows /
macOS / Linux strategies, privacy rule, audio-gate rule,
missed-key-up watchdog, release-readiness evidence requirement,
traceability summary.
Doc addenda (Baseline Candidate 0.9.3):
* `privacy/privacy-policy.md` — no raw key history, capability-
dependent Global PTT, UI reflects actual runtime capability
* `security/threat-model.md` — THREAT-PTT-001..006
* `security/diagnostic-redaction-audit-report.md` —
REDACT-PTT-001..006 banned field list enforced by `PttSanitizer`
* `release/platform-release-policy.md` — per-platform evidence
fields, no over-claim on untested Linux compositors
* `release/release-readiness-go-nogo-record.md` — RR-PTT-001..008
release-readiness items
* `verification/swe4-unit-verification-plan.md` —
SWE4-UV-035..039
* `verification/swe5-software-integration-verification-plan.md` —
SWE5-IV-015
* `verification/swe6-software-verification-plan.md` — SWE6-SV-017
* `verification/sys4-system-integration-verification-plan.md` —
SYS4-SIV-016
* `governance/traceability-matrix.md` — full PTT trace rows +
verification map
* `governance/decision-impact-assessment.md` — DEC-023..028
impact matrix
* `governance/product-decision-register.md` v0.9.9 entry
recording DEC-023..028 in the decision table and the status
table at §7
* `governance/document-index.md` — adds
`desktop-ptt-architecture.md` to the controlled set
* `architecture/proof-of-concept-plan.md` —
PoC-PTT-001..005 platform items
* `references/external-references.md` — Windows Raw Input,
macOS event-tap, Linux GlobalShortcuts portal references
* Both validation reports
(`baseline-candidate-validation-report.md`,
`repo-format-validation-report.md`) bumped to v0.9.3 with the
new ID totals (302 / 148 / 203 / 79 / 92).
README §"Desktop Push-to-Talk" added between Architecture Overview
and Repository Layout: capability levels, per-platform strategy,
privacy posture, missed-key-up watchdog.
Tooling:
* `tools/validate_docs.py` copied from the gen2 zip into the
repo tree (was previously available only inside the zip).
Reports zero undefined refs, zero direct-layer-rule violations,
English-only CJK check passes. The 35 "old package-style
filename" hits are pre-existing and identical to the gen2
baseline (they live in `path-migration-map.md` and config-ID
headers of governance docs and are intentional per the path
migration policy).
* `.gitignore` adds `/gen2/` so the externally-provided review
package does not enter the repo.
No code changes in this commit; B (the implementation split into
`transmit_active` / `capture_active`, `PttCapabilityLevel`
reporting, `PttSanitizer` diagnostics rule, and the UI capability
badge) follows in a separate commit.
Closes engineering deliverables 1–3 from the open-work table in
`docs/governance/legal-review-readiness.md` so the DEC-012 legal
review can actually run. With this commit, the only remaining
engineering item blocking sign-off is signed Windows / macOS / iOS
build artefacts, deferrable per the DEC-002 staged release plan.
Tooling
-------
* `about.toml` + `about.hbs` + `about-md.hbs` configure cargo-about
with the DEC-020 license posture and the five-target matrix
(Linux, Android, Windows, macOS, iOS). One per-crate clarification
for `allo-isolate` (`flutter_rust_bridge` transitive that ships
Apache-2.0 via `license-file` rather than an SPDX `license`
field). `cargo about generate` runs with zero warnings.
* `deny.toml` mirrors the cargo-about allow-list and adds minimal
bans / sources / advisories config. `cargo deny check` reports
`advisories ok, bans ok, licenses ok, sources ok` for the
workspace; multiple-versions of `windows_x86_64_msvc` produce
advisory `warn` (no fail) because three windows-targets versions
reach the graph via `jni`, `cpal`, and `keyring` respectively.
* `tools/dump_flutter_licenses.sh` + `tools/dump_flutter_licenses.dart`
walk `apps/chanora_flutter/pubspec.lock`, resolve each dependency
to its local pub-cache directory, read the LICENSE file, and emit
`docs/security/flutter-license-inventory.md`. SDK-sourced
packages (`flutter`, `flutter_localizations`, `flutter_test`,
`flutter_web_plugins`, `sky_engine`) resolve to the Flutter
framework BSD-3-Clause LICENSE under `$FLUTTER_ROOT` (or
`$HOME/sdks/flutter`).
Artefacts
---------
* `docs/security/license-inventory.md` — 364 transitive Rust
crates with full license texts. Apache-2.0 (276), MIT (55),
Unicode-3.0 (19), BSD-3-Clause (7), ISC (7). Zero copyleft.
* `docs/security/license-inventory.html` — same data rendered as
styled HTML for reviewer convenience.
* `docs/security/flutter-license-inventory.md` — 94 Dart / Flutter
packages with their LICENSE texts. Zero packages without a
resolvable LICENSE in this RC.
CI
--
* New `supply-chain` job runs `cargo deny check --workspace
--all-features` via `EmbarkStudios/cargo-deny-action@v2`. Fails
the build on any GPL / LGPL / AGPL / commercial-source license
surfacing transitively.
* New `license-inventory` job installs `cargo-about --features cli`
and regenerates `docs/security/license-inventory.md`; diffs
against the committed copy and fails on drift. Forces
contributors who touch the Cargo.lock to refresh the inventory.
* New `flutter-license-inventory` job runs
`tools/dump_flutter_licenses.sh` against the just-resolved pub
cache; same diff-on-drift semantics.
Governance
----------
* `docs/governance/legal-review-readiness.md` §5 cross-links the
three new artefacts in a "Reviewer artefacts" subsection.
* The open-work table at the bottom of the doc is rewritten as a
status grid: items 1–3 now read **Done**; item 4 (signed iOS /
macOS builds) remains the only open engineering blocker, with a
pointer back to `staged-release-plan.md`.
Verification
------------
* `CHANORA_DISABLE_KEYRING=1 cargo test --workspace`: all 49 unit
+ integration tests green (unchanged from v1.0.0-rc.1).
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate --output-file …`: zero warnings.
* `tools/dump_flutter_licenses.sh`: 94 packages, 0 without LICENSE.
* `flutter analyze`: clean.
No code changes touch the runtime; this is governance-tooling only.
Closes the v0.4 dual-file weakness in identity-at-rest and turns the
release into an MVP public release candidate. The remaining work
before `v1.0.0` is DEC-012 legal sign-off — see
`docs/governance/legal-review-readiness.md` — and the staged
platform promotions in `docs/governance/staged-release-plan.md`.
No decision rows in `product-decision-register.md` change; the
register's change-history advances to 0.9.8.
`chanora_storage`
-----------------
* New public `Crypto` trait + `IdentityFileStore::crypto()` give
callers an encrypt / decrypt pair anchored on the per-install
32-byte DEK without exposing the key material.
* `IdentityFileStore` keyring-first DEK retrieval (Linux Secret
Service via D-Bus, macOS Keychain, Windows Credential Manager,
iOS Keychain via the `keyring` crate). Pre-existing
`identity.dek` files are opportunistically migrated into the
keyring on first run; the on-disk DEK copy is removed once the
keyring acknowledges. `CHANORA_DISABLE_KEYRING=1` forces the
file-fallback path for tests and headless / CI hosts where a
real keyring call would prompt the user or block on a missing
D-Bus session.
* `BookmarkRepository::with_crypto(dir, crypto)` encrypts the
server password into a new `password_blob` BLOB column under
the same per-install DEK. Schema v2 migration is idempotent —
legacy v0.4 rows with a plain `password TEXT` are read
transparently and lifted into `password_blob` on the next
`update()`. `BookmarkRepository::new` (no crypto) is preserved
for tests and as a documented fallback when the DEK is
unreachable.
* Storage tests rise from 8 to 10: encrypted bookmark password
round-trip + legacy-plaintext-bookmark upgrade.
`chanora_core`
--------------
* `ChanoraSession::init_storage(dir)` wires the bookmark
repository with crypto by default. On any crypto-derivation
failure it falls back to the plain-password repository and
logs the gap — better than hard-failing init.
* `supervisor_loop` now tracks a 64-bit `snapshot_signature` over
channels (id + parent + order + name) and clients (id + channel
+ name) instead of the old `(channel_count, client_count)`
tuple. Any in-channel client move, channel rename, or reorder
now fires `SessionEvent::SnapshotChanged`. The signature sorts
by id before hashing so it's stable under input-vector
reordering.
* Two new unit tests cover the signature behaviour; new
`tests/mvp_storage.rs` integration test drives
`ChanoraSession::init_storage` end-to-end and verifies the
bookmark `password_blob` does not contain the plaintext.
* Re-export `ChannelId` + `ClientId` from `chanora_protocol` so
downstream callers and tests can construct DTOs directly.
Flutter
-------
* New About dialog (info icon in the AppBar) surfaces DEC-018
(public name "Chanora"), DEC-019 (non-affiliation statement),
and DEC-020 (Apache-2.0 OR MIT dual license). New ARB keys in
`app_en.arb` and `app_zh.arb`: `aboutAction`, `aboutVersion`,
`aboutNonAffiliation`, `aboutLicenseHeading`, `aboutLicenseBody`,
`aboutThirdPartyHeading`, `aboutThirdPartyBody`.
* `pubspec.yaml` version bumps to `1.0.0-rc.1+5`.
Governance
----------
* `docs/governance/legal-review-readiness.md` — DEC-012 handoff
package. Enumerates trademark / non-affiliation / license-text
/ third-party-attribution / `tsclientlib`-posture / crypto-
export / data-handling items the legal reviewer must confirm,
and lists the concrete engineering deliverables they block on
(`cargo about generate`, `cargo deny check licenses`,
Flutter `LicenseRegistry` dump).
* `docs/governance/staged-release-plan.md` — DEC-002 channel
schedule. Linux + Android sideload promote to GA on DEC-012
sign-off; Play Store / Windows / macOS / iOS gate on per-
platform signed-build availability. Rollback policy included.
* `product-decision-register.md` change-history advances to
0.9.8 with a single entry summarising v0.3, v0.4, and v1.0-rc.1
progress against DEC-001. No decision rows mutate.
Build + ops
-----------
* `NOTICE` refreshed for the MVP product-code dependency set:
adds `chacha20poly1305`, `rand`, `zeroize`, `base64`,
`keyring`, `connectivity_plus`, `path_provider`,
`freezed_annotation`; drops PoC-only entries.
* `CHANGELOG.md` restructured: explicit version sections for
v0.3.0-beta.1, v0.4.0-beta.2, v1.0.0-rc.1. Previous "Unreleased"
contents migrated into their respective milestone sections.
* `.github/workflows/ci.yml` exports `CHANORA_DISABLE_KEYRING=1`
for the cargo-test job — CI runners have no D-Bus session and
the keyring crate would otherwise block.
* `run-chanora.sh` reads `CHANORA_BUNDLE_FLAVOUR` (default
`release`) and self-copies the latest cdylib into the bundle's
`lib/` if missing.
Verification
------------
* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`: all
green (49 unit tests across the workspace; up from 36 at
v0.4.0-beta.2).
* `cargo test -p chanora_core --release -- --ignored alpha_smoke`
passes against the live `cn.teamspeak.app` (DNS → connect →
snapshot → disconnect in ~2.5 s).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
linux --release` produce a working Linux x86_64 bundle.
No Android live test in this commit per the user's note that the
physical device was removed; the Android arm64-v8a build path is
mechanically identical to v0.4.0-beta.2.
The v0.3 client could only ever connect to a hardcoded default
channel with no password and offered no controls mid-call.
External Beta closes those gaps and tightens identity-at-rest.
User-facing additions
---------------------
* **Server password** on the connect form. Plumbed through
`BridgeError`-aware `connect(host, nickname, password)`. Empty
string means "no password" — no behaviour change for open
servers.
* **Channel join**: tapping a row (or its login icon) in the
channel tree issues a `client_move`. Names containing "🔒" or
"password" prompt for a channel password first.
* **Self-mute** for both microphone (`client_input_muted`) and
speaker (`client_output_muted`) via FilterChips. Output mute
also flips the audio engine's local output-muted flag so
playback silences immediately, before the server acknowledges.
* **Master output gain** slider (0–200%). Plumbed through an
`AtomicU32` (f32 bits) on the engine that the cpal output
callback multiplies into every sample.
* **Bookmarks**: SQLite-backed list with Save / Connect / Delete
actions. Bookmarks persist across app restarts; tapping one
pre-fills the form and dials immediately.
Hardening
---------
* **Encrypted identity at rest** (RISK-PoC-002 closure for the
file-only threat model). ChaCha20-Poly1305 envelope: nonce +
ciphertext written atomically with mode 0600; 32-byte DEK in a
separate `identity.dek` file. Legacy plaintext identity files
are auto-detected, read, and upgraded on the next save. Full OS-
keyring integration is still v0.4 work — documented in the
store's doc comment.
* **Mobile voice-comm routing**: on Android, `AudioEngine::start`
uses JNI to set `AudioManager.setMode(MODE_IN_COMMUNICATION)`
when `cfg.mobile_voice_preset` is true (default). This engages
the device-side AEC/NS pipeline on most Pixel/Moto/Samsung
hardware even though cpal still opens the AAudio default input
preset. Full `setInputPreset(VOICE_COMMUNICATION)` switch is
still RISK-AUDIO-MOBILE-001 (needs cpal upstream or an Oboe
fork).
* **Log noise**: bridge default `EnvFilter` now silences
`tsproto::resend=error` and `tsproto::packet_codec=error` so
the redacted diagnostic export is human-readable. Still
overridable via `RUST_LOG=...`.
Engineering
-----------
* **`chanora_storage`** gains `BookmarkRepository` (rusqlite
bundled) with `add` / `update` / `delete` / `list`. The
identity store now layers on `chacha20poly1305` + `rand` +
`zeroize` for the envelope.
* **`chanora_protocol`** exposes `move_to_channel` and
`set_muted` on `ProtocolClient`, dispatched through the
existing `connection_task` request channel onto tsclientlib's
generated `client.client_move(...)` and
`state.client_update().set_input_muted/set_output_muted(...)`
paths.
* **`chanora_core::ChanoraSession`** wires the bookmark store
next to the identity store inside `init_storage`, and adds
`list_bookmarks` / `add_bookmark` / `update_bookmark` /
`delete_bookmark` / `move_to_channel` / `set_self_muted` /
`set_output_gain`.
* **`chanora_audio::AudioEngine`** carries `output_gain` and
`output_muted` atomics; the output callback consults both. The
Android branch of `start()` engages MODE_IN_COMMUNICATION via
a small JNI helper that reuses the `ndk_context` global set by
the bridge's `android_init` hook.
* **`chanora_bridge::api`** adds `set_input_muted`,
`set_output_muted`, `set_output_gain`, `move_to_channel`,
`list_bookmarks`, `add_bookmark`, `update_bookmark`,
`delete_bookmark`, and the `BridgeBookmark` DTO. FRB v2.12
codegen regenerated.
Tests + CI
----------
* `chanora_storage` test count rises from 3 to 8 — bookmark CRUD
round-trip, missing-row → `NotFound`, encrypted round-trip
(verifies ciphertext is not the plaintext on disk), and the
legacy plaintext upgrade path.
* New `.github/workflows/ci.yml`: `cargo check --workspace`,
`cargo test --workspace --no-fail-fast`, `cargo clippy`
(advisory), `flutter analyze`, and `flutter test` excluding
the live-server `e2e` tag.
Live-verified on Moto G Stylus 5G against cn.teamspeak.app:
saved a bookmark, reconnected via it, joined a non-default
channel via tap, toggled both mutes, slid the volume, and the
redacted diagnostic export confirmed `AudioManager mode set to
MODE_IN_COMMUNICATION`, `client_move sent`, and `client_update
sent` lines.
The Beta scope for mobile DSP is OS-source-driven (Android
`MediaRecorder.AudioSource.VOICE_COMMUNICATION`, iOS
`AVAudioSession.Mode.voiceChat`) — letting the platform's built-in
AEC / NS engage instead of shipping our own DSP chain on
constrained devices. Linux desktop stays a deliberate no-op:
PipeWire / ALSA's default source is correct for desktop voice and
adding a software AEC there would regress against an already-good
baseline.
This commit lands the *config surface* through every layer:
* `AudioEngineConfig` gains `effects: AudioEffects` (mirrors the
DEC-007/008/009/010 toggles) and `mobile_voice_preset: bool`
(default `true`).
* On Android, `AudioEngine::start` logs the preset + effects
requests so a future cpal / Oboe upstream switch can be observed
via the redacted diagnostic export.
* On iOS, the same log line documents the binding gap — Chanora
iOS audio is documented-only for Beta per the release notes.
* On Linux desktop, the flags are honoured by name but the engine
continues to use the default ALSA / PipeWire source. No
behaviour change.
RISK-AUDIO-MOBILE-001 (new) tracks the actual preset switch. The
follow-up work either pulls in an Oboe-based input host or waits
for cpal upstream to expose `set_input_preset`. Either way the
config flag is forward-compatible — callers do not need to change
when the binding lands.
Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.
* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
fires the event only when the count actually changes. `last_counts`
is reset to `None` on a successful reconnect so the freshly
dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
`_onRefresh()` to repopulate the snapshot view.
The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
Replaces the diagnostics scaffold with the production redaction
policy + a user-initiated export path that satisfies DEC-016 (no
automatic uploads).
* `chanora_diagnostics::Redactor` applies the six policy rules to
every captured log line: `$HOME` paths → `[home]`; IPv4 + IPv6
literals → `[ip]`; email-shaped strings → `[email]`; long
base64-ish tokens → `[token]`; substrings registered with
`KnownSecretRegistry` → `[REDACTED]`. The registry implements
SS-AUD-003 defence-in-depth: storage adapters can register
secrets as they cross out of the keyring so an accidental
`Debug` print is still scrubbed at write time.
* `InMemoryLogSink` is a bounded ring buffer (cap 500 lines in the
bridge) that always passes lines through the redactor before
storing them. `RedactingLogLayer` plugs it into `tracing-
subscriber` alongside the existing logcat / fmt layers.
* `DiagnosticExport::from_sink` builds a plaintext blob — already
redacted — combining free-form metadata (crate version, target
os/arch) with the retained log tail. `bridge::api::
export_diagnostics()` is the Flutter-facing entrypoint
(`#[frb(sync)]`).
* `bridge_init` now installs the redaction layer on both Android
and desktop hosts, switching from the global `fmt::init()`
shortcut to a layered `Registry` so the in-memory sink can sit
side-by-side with the platform sink.
* Flutter adds a bug-report icon to the AppBar; tapping it opens a
scrollable monospace dialog with Copy and Close actions. New
`diagnosticsAction` / `copyAction` / `closeAction` strings land
in `app_en.arb` + `app_zh.arb`.
Tests cover the redaction matrix (IPv4, IPv6, email, long tokens,
known secret), the ring buffer capacity, and the full
`DiagnosticExport::to_text()` round-trip — 9/9 green.
Live-verified on Moto G: the dialog rendered a multi-line transcript
with `[ip]`, `[token]`, `[home]` substitutions, the metadata block
showed `target_os=android` `target_arch=aarch64`, and Copy placed
the same text on the clipboard.
A fresh `Identity::create()` was generated on every connect, which
meant the server saw a different client UID each time. Long-lived
features (bookmarks, server-side bans, group membership) depend on a
stable UID — restoring that now via a minimal directory-backed
identity file.
* `chanora_storage::IdentityFileStore` reads / writes a single
`identity.tskey` file under a caller-supplied directory. On Unix
the file is created with `O_CREAT | O_TRUNC | mode 0600`; on
non-Unix targets the platform sandbox does the access control.
Writes are atomic (temp file + `fsync` + `rename`) so a crash
mid-write cannot leave a half-written identity on disk. Empty
files are treated as "no identity" rather than as an error.
* `chanora_protocol::ProtocolClient::generate_identity()` exposes
the `counterVbase64key` serialisation used by tsclientlib's
`Identity::new_from_str`, so the core layer can mint an identity
and store it before dialling.
* `chanora_core::ChanoraSession::init_storage(dir)` wires the
store. `connect()` then resolves the identity in this order:
(1) `cfg.identity` if explicitly supplied; (2) persisted value if
any; (3) generate-and-persist a fresh one.
* `chanora_bridge::api::init_storage(dir: String)` is the
Flutter-facing entrypoint; the matching Dart side resolves
`path_provider`'s `getApplicationSupportDirectory()` and calls
it once on app start.
* `BridgeError` now maps `CoreError::Storage`.
Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not
encrypted at rest. The v0.4 storage rework lands proper Secret
Service + Android Keystore + iOS Keychain backends. Documented
under `IdentityFileStore`'s doc comment.
Live-verified on Moto G Stylus 5G: first connect generated +
persisted the identity (visible in the redacted diagnostic export
as "generated + persisted fresh identity"); disconnect + reconnect
in the same session logged "reusing persisted identity" and dialled
with the same UID.
Extends the A.6 supervisor with an OS-level connectivity hint so a
returning network triggers a redial immediately instead of waiting
out the current backoff slot (up to 60 s). The watchdog remains the
authoritative loss detector — the OS signal is advisory.
* `chanora_core::NetworkState` (Unknown / Online / Offline) is owned
by `ChanoraSession` via a `tokio::sync::watch::Sender`.
`set_network_state()` / `network_state()` are the public accessors.
* The supervisor's watch-phase `select!` gains a `network_rx`
branch: Offline pre-charges watchdog misses (capped at
`MAX_MISSES - 1`) so the next probe failure trips immediately;
Online clears stale misses. This shrinks UI-banner latency on a
Wi-Fi drop from ~15 s to ~5 s.
* The reconnect-loop's backoff sleep races against Online: a
transition cuts the sleep short and resets the attempt counter so
future losses start at the smallest backoff window again.
* `chanora_bridge` adds `BridgeNetworkState` (mirror enum) and a
sync `set_network_state(state)` function. On platforms with no
signal wired the supervisor stays at Unknown and falls back to
pure watchdog/backoff — no behavioural regression vs A.6.
* Flutter adds `connectivity_plus ^6.1.0` and wires
`_wireConnectivity()` in `main()`: seeds with `checkConnectivity()`
then forwards every `onConnectivityChanged` to the bridge,
mapping any non-`none` transport to Online.
Verified on Moto G Stylus 5G (Android 14): `svc wifi disable && svc
data disable` for ~40 s — reconnect banner appeared promptly
because the watchdog was pre-charged. After `svc wifi enable && svc
data enable` the supervisor woke from its 15 s backoff slot and
reconnected within seconds; the channel tree re-rendered without
user action.
Adds an end-to-end auto-reconnect path so a brief network outage no
longer leaves the client wedged in a half-dead state. The flow has
three layers, each motivated by a real failure mode observed on the
Moto G live test:
* `chanora_protocol::DisconnectReason` (`UserRequested` /
`StreamEnded` / `Error(String)`) is reported on a `oneshot` when
the per-connection task exits, so the supervisor can tell user
intent apart from a real loss.
* `chanora_core` spawns a supervisor task per `ChanoraSession`. It
listens for the loss notifier AND runs a watchdog that issues
`snapshot()` probes every 5s with a 4s timeout — three consecutive
misses synthesise a `DisconnectReason::Error(...)` and trigger the
reconnect path. The watchdog catches the "ghost connected" case
where tsclientlib silently resets internal state but the event
stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s
(capped). On success the supervisor swaps the dead `ProtocolClient`
for the new one in place and, if audio was running, restarts the
audio engine bound to the new `voice_in`/`voice_out` channels.
* `SessionEvent` (Connected / Lost / Reconnecting / Disconnected /
AudioStarted / AudioStopped) is broadcast on a 64-slot channel.
`chanora_bridge` re-exports it as `BridgeEvent` and exposes
`events_stream(StreamSink)`; the Flutter side subscribes from
`initState` and renders a reconnect banner with attempt count and
delay. New `SnapshotProbe` exposes a clone-friendly snapshot path
so the watchdog can probe without holding `&self` across awaits.
Localization adds `statusReconnecting` and `statusConnectionLost`
keys to `app_en.arb` and `app_zh.arb`.
Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app:
killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three
misses, supervisor walked the backoff schedule, and the UI
reconnected automatically once the radios came back. Snapshot tree
re-rendered without user action.
Resolves the Beta-blocking issue surfaced during Android v0.2.0-beta.1
verification: hostnames could not be used, only literal IPs.
Root cause:
tsclientlib's built-in resolver uses hickory-resolver, which reads
/etc/resolv.conf. That file does not exist on Android or iOS, so
any connect by hostname exited the connection task before
signalling ready and surfaced the cryptic error
BridgeError.connection(field0: protocol backend:
connection task exited before signalling ready)
Fix:
crates/chanora_protocol/src/resolver.rs (new):
Resolves hostnames via tokio::net::lookup_host, which uses the
platform's getaddrinfo. Works on every platform Chanora targets.
Tiny in-process positive-result cache (5 min TTL) keeps
reconnects cheap. IPv4 sorted ahead of IPv6 in the returned list
to favour the more reliable path on dual-stack networks.
crates/chanora_protocol/src/adapter.rs:
connection_task now resolves the hostname itself and passes the
resulting SocketAddr (not the hostname String) to
tsclientlib::Connection::build. tsclientlib's ServerAddress enum
accepts SocketAddr via its From impl, so the upstream resolver
is skipped entirely.
crates/chanora_protocol/src/lib.rs:
New typed error arm ProtocolError::DnsFailed { host, reason }
so the UI can distinguish 'server not found' from 'server
refused our packets'.
crates/chanora_bridge/src/lib.rs:
Matching BridgeError::DnsFailed { host, reason } DTO surfaced
to Dart, with explicit From<CoreError::Protocol(DnsFailed)>
mapping so the UI gets the structured fields rather than a
stringified mess.
Tests added (crates/chanora_protocol/src/resolver.rs::tests):
- rejects_empty
- literal_ipv4_short_circuits
- literal_ipv4_default_port_path
- unresolvable_returns_dns_failed
- resolves_known_hostname (#[ignore], --ignored to run; hits net)
Empirical verification (2026-05-14):
Workspace: cargo check + cargo test --workspace clean.
Live resolver test: cn.teamspeak.app → 175.178.125.23:9987 (passes).
cargo test -p chanora_core --test alpha_smoke -- --ignored:
server='Vigorous Pro' channels=42 clients=20 (passes by hostname).
flutter test: alpha_e2e_test + beta_e2e_test both green.
Physical Moto G Stylus 5G (Android 14 arm64-v8a):
APK rebuilt (48.9 MB). adb install + launch.
Connect form left at default 'cn.teamspeak.app'.
logcat shows:
chanora_protocol: dns resolved input=cn.teamspeak.app
resolved=175.178.125.23:9987
tsclientlib: starting connection to 175.178.125.23:9987
tsproto::resend: Connecting → Connected
chanora_protocol: initial state snapshot received
UI shows 'Connected to Vigorous Pro' / '42 channels • 20 online'.
This is the first item in Category A (post-Beta polish bundle).
Pause point: review before A.6 (full reconnect).
The development host is Linux x86_64; the iOS toolchain (Xcode, xcrun,
codesign, iPhoneOS SDK) is macOS-only under Apple licence and cannot
be cross-compiled from Linux. This commit adds the instructions for
producing the iOS v0.2.0-beta.1 build on a macOS host plus a bash
helper that automates the build itself.
docs/release/ios-build.md (v0.1.0):
- Toolchain pin table (macOS 14+, Xcode 26+ per DEC-021, iOS SDK
26+, iOS deployment target 13.0 per DEC-003, Flutter 3.41.9,
Rust 1.95 stable with aarch64-apple-ios / aarch64-apple-ios-sim /
x86_64-apple-ios targets, CocoaPods 1.16+, FRB 2.12.0).
- macOS host options: owned hardware vs rental (MacStadium,
MacinCloud, Scaleway Apple silicon, AWS EC2 Mac) vs borrowed
Mac. Realistic cost ranges per option.
- Step-by-step Homebrew + Rust + Flutter + CocoaPods install.
- Pre-built libopus.a per arch via a CMake invocation that
targets the iOS SDK explicitly. Mirrors the Android build's
LIBOPUS_LIB_DIR wrap-dir trick.
- flutter create --platforms=ios to scaffold the ios/ folder
(the product Flutter app was created with only linux + android).
- Edits required to ios/Podfile and ios/Runner/Info.plist:
iOS 13 deployment target (DEC-003), NSMicrophoneUsageDescription
for the audio engine, UIBackgroundModes=audio for screen-locked
playback.
- Three cargo build --target invocations for device + both
simulator slices.
- lipo merge of the two simulator slices into one .a.
- xcodebuild -create-xcframework to produce
target/ChanoraBridge.xcframework with the right slices.
- flutter build ios --release --no-codesign or
flutter build ipa --release --export-method development for
a signed .ipa.
- Install paths: xcrun devicectl for wired install, altool for
TestFlight upload.
- Smoke-test instructions with the same hostname-resolution
caveat that affects the Android Beta (hickory-resolver does
not work on iOS; use the literal IP).
- Packaging into chanora-v0.2.0-beta.1-ios.ipa.
- Known-issue table covering: audiopus_sys cmake build failures
on iOS, microphone permission prompt prerequisites,
AVAudioSession category quirks for voice transmission, code-
signing failure modes, TestFlight rejection causes.
- Reproducibility note (build is not bit-reproducible).
tools/build-ios.sh:
- Parameter switches: --version, --no-codesign,
--regenerate-bindings, --export-method.
- Verifies xcodebuild, xcrun, cargo, rustc, flutter, pod, lipo
on PATH.
- Adds the three rustup iOS targets if missing.
- Verifies each pre-built libopus.a exists at the expected wrap
dir before starting.
- Optionally regenerates FRB bindings.
- Three cargo build runs (device + Apple-silicon sim + Intel
sim), each with LIBOPUS_LIB_DIR pointed at its arch's wrap dir
and the audiopus_sys build-cache wiped per target.
- lipo + xcodebuild -create-xcframework.
- flutter pub get + pod install + flutter build {ios,ipa}.
- Copies the .ipa to a versioned path under $HOME and prints
SHA-256.
This is documentation + helper only; no actual iOS binaries are
produced by this commit. The Linux development host cannot run
Xcode. To produce the binaries, follow §3-§14 of
docs/release/ios-build.md on a macOS host, or run
tools/build-ios.sh there.
DEC-011.1 iOS audio status remains Deferred; the doc notes that
cpal's iOS backend has not been empirically verified and the
AVAudioSession category likely needs configuration for voice
transmission. Both are Beta+ items.
Builds the Internal Beta product app for Android. Companion to the
Linux desktop build already shipped at the same tag.
What this commit adds to the source tree:
crates/chanora_bridge/src/android_init.rs (new):
JNI lifecycle for Android. JNI_OnLoad captures the JavaVM*.
Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext
is called by MainActivity.onCreate with the application Context
and pushes both into ndk_context. Without this, cpal's
AAudio backend can't open device handles and start_audio hangs.
crates/chanora_bridge/Cargo.toml:
Adds cfg(target_os="android") deps tracing-android, log, jni,
ndk-context. Linux/desktop builds are unaffected.
crates/chanora_bridge/src/lib.rs:
Conditionally includes the android_init module on Android.
crates/chanora_bridge/src/api.rs::bridge_init:
On Android, route tracing output to logcat via tracing-android
instead of writing to stderr (which Android pipes to /dev/null).
Logs show under `adb logcat -s chanora`.
apps/chanora_flutter/android/app/src/main/AndroidManifest.xml:
Adds uses-permission android.permission.INTERNET (needed for
the protocol layer) and android.permission.RECORD_AUDIO (needed
by chanora_audio's capture stream). Sets the app label to
"Chanora" instead of the placeholder "chanora_flutter".
apps/chanora_flutter/android/app/src/main/kotlin/.../MainActivity.kt:
Overrides the Flutter-generated MainActivity. Loads
libchanora_bridge.so eagerly at class-init so JNI_OnLoad runs
before any FRB call. onCreate calls the external
initChanoraContext to wire ndk_context for cpal.
apps/chanora_flutter/pubspec.yaml:
Bumps version 1.0.0+1 → 0.2.0+2 to match the v0.2.0-beta.1 tag.
run-chanora.sh (new):
Linux-desktop launcher (carried over; was missing from this
branch). Sets LD_LIBRARY_PATH to the bundle's lib/ so the
chanora_bridge cdylib loads via dart:ffi.
Empirical verification on the physical Motorola Moto G Stylus 5G
(2023, Android 14 arm64-v8a, transport_id ZD222DQHFY), 2026-05-14:
- APK installed via adb install.
- Activity launched; permissions granted.
- Connect form filled with 175.178.125.23 (Vigorous Pro's IP —
see honest limitation below); Connect button tapped.
- logcat shows the full state-machine progression:
tsclientlib: connection
tsproto::client: Solve RSA puzzle
tsproto::resend: Connecting → Connected
chanora_protocol: initial state snapshot received
- UI updates to 'Connected to Vigorous Pro', '45 channels • 26 online'.
- Welcome banner with CJK characters preserved verbatim.
- 'Start audio' tapped:
AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#1
AAudio: AAudioStream_requestStart(s#1) returned 0
AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#2
AAudio: AAudioStream_requestStart(s#2) returned 0
AAudioStream: setState s#1 from 3 to 4 (Started)
AAudioStream: setState s#2 from 3 to 4 (Started)
- PTT button held for 2.5 s:
UI shows: 'TX 124 frames • RX 0 frames • PTT off'.
124 frames / 2.5 s ≈ 50 frames/s = 20 ms Opus frames — exactly
the encoder cadence. Voice transmission proven over UDP to the
real server.
Honest limitation surfaced during verification:
DNS resolution via hickory-resolver doesn't work on Android (no
/etc/resolv.conf). Connecting by hostname produces:
BridgeError.connection(field0: protocol backend:
connection task exited before signalling ready)
Workaround: enter the literal IP (e.g. 175.178.125.23 for
cn.teamspeak.app). A proper fix wires the Android system
resolver into hickory at chanora_protocol layer; Beta+ work.
Build prerequisites (documented for reproducibility):
- Android NDK r26.3.11579264 at /opt/android-sdk/ndk/26.3.11579264.
- rustup targets: aarch64-linux-android, armv7-linux-androideabi,
x86_64-linux-android.
- cargo-ndk 4.x.
- Pre-built libopus.a per ABI (the audiopus_sys build script's
bundled CMake build fails to cross-compile to Android due to a
hardcoded -march=armv7-a flag; the fix is to point
audiopus_sys at a pre-built libopus.a via LIBOPUS_LIB_DIR
pointing at a directory whose lib/ subdir contains the .a).
Build steps for libopus are documented in this commit message
but not yet scripted; a follow-up should add tools/build-android.sh.
- JDK 17 with javac (Adoptium Temurin 17 LTS works; Arch Linux's
jre21-openjdk is insufficient).
ABIs built and shipped in the APK:
arm64-v8a, armeabi-v7a, x86_64.
Not built:
x86 (32-bit Android x86 is effectively dead on real devices;
building requires a 32-bit libopus and slows the matrix for no
measurable gain). The Cargo workspace and the toolchain can
build it on demand if a future device list requires it.
The development host is Linux x86_64; `flutter build windows` cannot
be cross-compiled and requires a Windows host with Visual Studio
2022's C++ Desktop workload. This commit adds the instructions for
producing the Windows v0.2.0-beta.1 Internal Beta build on an Azure
VM, plus a PowerShell helper that automates the build itself.
docs/release/windows-build.md (v0.1.0):
- Toolchain pin table (Windows Server 2022, VS 2022 Build Tools
+ C++ workload, Flutter 3.41.9, Rust 1.95 stable, FRB 2.12.0,
CMake, audiopus build dependency).
- Azure VM provisioning recipe: Standard_D4s_v5 (4 vCPU / 16 GiB),
Premium SSD 128 GiB, RDP locked to caller IP, auto-shutdown,
cost estimate (<USD 1 per build session).
- Step-by-step PowerShell to install VS 2022 Build Tools with the
required components, Git for Windows, rustup, Flutter SDK,
flutter_rust_bridge_codegen, and CMake.
- Two upload paths for source: temporary git remote OR zip archive
over RDP clipboard.
- flutter create --platforms=windows to scaffold the
windows/ platform folder (the product Flutter app was created
with only linux + android).
- cargo build -p chanora_bridge to produce chanora_bridge.dll.
- flutter build windows --release to produce chanora_flutter.exe
and the bundle.
- Drop the DLL next to the EXE so dart:ffi loads it.
- Smoke-test instructions including the cn.teamspeak.app UDP 9987
egress gotcha for some Azure regions.
- Packaging into chanora-v0.2.0-beta.1-windows-x64.zip.
- Known-issue / caveat table.
- Reproducibility note (build is not bit-reproducible in this Beta).
tools/build-windows.ps1:
- Parameter switches: -SkipRustBuild, -RegenerateBindings, -Version.
- Verifies flutter, cargo, rustc, cmake, git on PATH.
- Adds the x86_64-pc-windows-msvc target via rustup if missing.
- Runs flutter create --platforms=windows if the windows/ folder
is absent in apps/chanora_flutter/.
- Optionally regenerates FRB bindings.
- cargo build --release -p chanora_bridge --target x86_64-pc-windows-msvc.
- flutter pub get + flutter build windows --release.
- Copies the DLL into the Release bundle.
- Compress-Archive into chanora-<version>-windows-x64.zip.
- Prints final artefact paths.
This is documentation + helper only; no actual Windows binaries are
produced by this commit. To produce the binaries, follow §3-§13 of
docs/release/windows-build.md on a Windows host.
2026-05-14 23:02:56 +08:00
587 changed files with 110381 additions and 34958 deletions
MERGE_BASE=$(git merge-base "$BASE_SHA" HEAD || true)
else
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD || true)
fi
if [ -n "$MERGE_BASE" ] && git show "${MERGE_BASE}:crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json" > baseline.json 2>/dev/null; then
echo "BASELINE_FOUND=1" >> "$GITHUB_ENV"
else
echo "BASELINE_FOUND=0" >> "$GITHUB_ENV"
echo "{}" > baseline.json
fi
- name:Compare against baseline
run:|
cargo run --example compare_baseline -p chanora_audio -- \
--current current.json \
--baseline baseline.json \
--output report.md
- name:Post PR comment
if:github.event_name == 'pull_request'
uses:actions/github-script@v7
with:
script:|
const fs = require('fs');
const body = fs.readFileSync('report.md', 'utf8');
Chanora is currently in early planning and baseline-candidate design.
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
```text
Current documentation baseline: v0.9.2
Current documentation baseline: v0.9.x document set
Current status: Baseline Candidate
Implementation status: Not production-ready
```
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
The current engineering focus is:
- defining the system and software architecture;
-preparing the Flutter + Rust application structure;
-hardening the Flutter + Rust application structure;
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
- defining cross-platform audio behavior;
- preparing release, verification, security, privacy, and legal gates.
@@ -46,13 +46,24 @@ Current platform policy:
| Platform | Baseline |
|---|---|
| iOS / iPadOS runtime target | iOS 13+ unless Flutter, plugin, audio, or product constraints require raising it |
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
| Google Play target API | Target the Google Play-required API level on upload date |
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
Apple CoreML VAD development requires the private `silero-coreml` SwiftPM package checked out as a sibling of this repository, so the app checkout and package checkout share the same parent directory:
```text
workspace/
chanora/
silero-coreml/
```
The iOS and macOS Xcode projects reference that package via `../../../../silero-coreml` from their project files. GitHub CI skips the unsigned iOS build when the sibling package is unavailable, but local Apple builds need that checkout.
---
## Architecture Overview
@@ -113,6 +124,46 @@ The current recommended MVP scope is:
---
## Desktop Push-to-Talk
Chanora's desktop Push-to-Talk (PTT) follows a **capability-based** design (see
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
"aboutNonAffiliation": "Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.",
"aboutLicenseHeading": "License",
"aboutLicenseBody": "Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.",
"aboutThirdPartyHeading": "Third-party software",
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
"copyAction": "Copy",
"closeAction": "Close",
"openAction": "Open",
"cancelAction": "Cancel",
"retryAction": "Retry",
"chatAction": "Chat",
"chatCloseAction": "Close chat",
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
"chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message",
"chatPokeAction": "Poke",
"clientInfoAction": "Info",
"startAudioAction": "Start audio",
"pttHoldToTalk": "Hold to talk",
"pttTransmitting": "Transmitting…",
"pttHoldToTalkSemanticsHint": "Press and hold to transmit voice; release to stop.",
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
"pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.",
"pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.",
"pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.",
"pttCapabilityExplainGoGlobalIos": "On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.",
"pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.",
"pttConfigurePrompt": "Press the key or mouse side button you want to use for Push-to-Talk.",
"pttConfigureWaiting": "(waiting for input…)",
"pttConfigureCaptured": "Captured",
"pttConfigurePrivacyNote": "Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.",
"pttConfigureSaveAction": "Save",
"pttConfigurePortalRedirect": "Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.",
"inputMuteAction": "Mute mic",
"inputUnmuteAction": "Unmute mic",
"outputMuteAction": "Mute speaker",
"outputUnmuteAction": "Unmute speaker",
"joinChannelAction": "Join channel",
"leaveChannelAction": "Leave channel",
"channelPasswordTitle": "Channel password",
"bookmarksHeading": "Bookmarks",
"bookmarksEmpty": "No bookmarks yet. Enter a server above and tap \"Save bookmark\".",
"networkPermissionBody": "Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.",
"networkPermissionOpenSettings": "Open System Settings",
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
"permissionGrantAction": "Grant",
"startupPermissionsTitle": "Permissions",
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
'Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.';
@override
StringgetaboutLicenseHeading=>'License';
@override
StringgetaboutLicenseBody=>
'Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.';
'Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.';
@override
StringgetcopyAction=>'Copy';
@override
StringgetcloseAction=>'Close';
@override
StringgetopenAction=>'Open';
@override
StringgetcancelAction=>'Cancel';
@override
StringgetretryAction=>'Retry';
@override
StringgetchatAction=>'Chat';
@override
StringgetchatCloseAction=>'Close chat';
@override
StringgetchatPanelCollapsedHint=>
'Tap the chat button to continue your conversation';
'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.';
@override
StringgetpttCapabilityExplainGoGlobalWindows=>
'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.';
@override
StringgetpttCapabilityExplainGoGlobalMacos=>
'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.';
@override
StringgetpttCapabilityExplainGoGlobalLinux=>
'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.';
@override
StringgetpttCapabilityExplainGoGlobalIos=>
'On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.';
@override
StringgetpttCapabilityExplainGoGlobalGeneric=>
'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.';
'Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.';
@override
StringgetnetworkPermissionOpenSettings=>'Open System Settings';
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
@override
StringgetmicrophonePermissionRequiredForVoice=>
'Microphone permission is required for voice transmission.';
@override
StringgetpermissionGrantAction=>'Grant';
@override
StringgetstartupPermissionsTitle=>'Permissions';
@override
StringgetstartupPermissionsBody=>
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.