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