Compare commits

...
Author SHA1 Message Date
Edison Jwa dc52092654 docs: remove trailing whitespace from continuation design 2026-06-08 23:32:18 +09:00
Edison Jwa a644770488 docs: record Android verification evidence 2026-06-08 21:26:20 +09:00
Edison Jwa 5a1d902795 fix(audio): migrate Android JNI paths 2026-06-08 21:24:16 +09:00
Edison Jwa 901369b072 docs: align core split verification notes 2026-06-08 20:20:48 +09:00
Edison Jwa 57a4d9767b refactor: align bridge state resolver metadata 2026-06-08 20:20:18 +09:00
Edison Jwa 8c4f85ee70 refactor: reuse built-ins and shared helpers 2026-06-08 20:19:17 +09:00
Edison Jwa 7c341d42e5 fix(core,protocol): bound disconnect shutdown 2026-06-08 20:15:50 +09:00
Edison Jwa 8487acf167 docs: align review findings and verification gates 2026-06-08 19:52:16 +09:00
Edison Jwa 8606eb48c8 fix(audio): harden realtime callback paths 2026-06-08 19:40:03 +09:00
Edison Jwa d83539436e fix(voice): preserve mute owners and release touch ptt 2026-06-08 18:00:32 +09:00
Edison Jwa e4fdf8414a docs: add maintainability continuation plan 2026-06-08 17:04:29 +09:00
Edison Jwa 0f41993ed0 docs: add maintainability continuation design 2026-06-08 17:01:59 +09:00
Edison Jwa a0ff17b935 fix(voice,ios): scope AVAudioSession VoiceChat to call lifetime (#33)
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.
2026-06-08 06:02:12 +09:00
Edison Jwa 68a7892e19 fix(macos): address PR #31 review findings
- _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.
2026-06-07 23:36:31 +09:00
Edison Jwa e048b6b6bd fix(chat): propagate empty draft on target swap and dispose
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.
2026-06-07 23:30:00 +09:00
Edison Jwa dad633e381 fix(ui): address PR #30 review findings
- 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.
2026-06-07 23:30:00 +09:00
Edison Jwa e9cd832828 docs(macos): clarify network.server entitlement justification
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.
2026-06-07 23:27:46 +09:00
Edison Jwa c40705790a fix(audio,macos): address PR #27 review findings
- 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
2026-06-07 23:27:46 +09:00
Edison Jwa 76fe8faa94 fix(audio,ios): gate output_underrun on !muted in refactored render callback
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
2026-06-07 23:27:46 +09:00
Edison Jwa 5257b1e3ac chore(repo): gitignore .omo/ session directory 2026-06-07 23:27:46 +09:00
Edison Jwa 0d37550d43 chore(licenses): update inventory for windows-core 0.54 → 0.62 bump
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.
2026-06-07 23:27:46 +09:00
Edison Jwa fe5dc1cda4 feat(macos): add macOS audio lifecycle MethodChannel
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).
2026-06-07 23:27:46 +09:00
Edison Jwa 581353b6d2 docs(sdd): document VoiceActivity gate, macOS render cadence, iOS render cadence
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.
2026-06-07 23:27:46 +09:00
Edison Jwa 1cf1a8f5a6 fix(macos): add network.server entitlement to release sandbox for UDP bind
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.
2026-06-07 23:27:46 +09:00
Edison Jwa d59da05f93 refactor(audio): share AudioHandler between iOS and macOS, bump deps
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.
2026-06-07 23:27:46 +09:00
Edison Jwa eb10db5b59 docs(audio): document macOS 13 floor, render peak limiter, and VPIO ducking config
- 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).
2026-06-07 23:27:46 +09:00
Edison Jwa 74951dd7b4 fix(audio,macos): render-path peak limiter
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.
2026-06-07 23:27:46 +09:00
Edison Jwa ebae1274d6 fix(audio,ui): address PR #27 review findings
- 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
2026-06-07 23:27:46 +09:00
Edison Jwa 23bfddb6b4 feat(macos): lock-free audio event queue and channel-aware render downmix
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.
2026-06-07 23:27:46 +09:00
Edison Jwa 1bc2fccd0a fix(ios,macos): add -u force-undefined linker flags for @_cdecl symbols
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.
2026-06-07 23:12:07 +09:00
Edison Jwa 80c73f34c3 fix(ios,macos): add static @_cdecl references to defeat dead-strip
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
2026-06-07 23:12:07 +09:00
Edison Jwa 9d8a1f8fd1 fix(ios,macos): address PR #26 review findings
- 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).
2026-06-07 23:12:07 +09:00
Edison Jwa a589ac953f fix(ios,macos): preserve Silero @_cdecl exports across Xcode Archive
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.
2026-06-07 23:12:07 +09:00
Edison Jwa ad8b996376 fix(macos): reliable Local Network permission denial detection and re-check
- 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.
2026-06-07 23:12:07 +09:00
Edison Jwa ab0dc2ebc2 fix(macos): trigger Local Network permission prompt before server connect
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).
2026-06-07 23:12:07 +09:00
Edison Jwa 5e8b7915db feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix
- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024)
- Add ViewportInfo InheritedWidget for layout-aware descendants
- Add inline ChatPanel (380dp right column) for expanded desktop layout
- Add channel right-click context menu with Chat option for in-place switching
- Add per-target draft persistence via restoredDraft/onDraftChanged callbacks
- Fix header chat button to switch to current voice channel when panel open
- Fix close = dismiss (preserves last target and draft for reopen)
- Add unread dot indicator on channel tiles when chat is closed
- Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz)
  to avoid buffer underruns on macOS CoreAudio real-time thread
- Add tools/build-macos.sh release build script (7-step process)
- Add chat panel switching implementation plan and 3-panel design spec

Tests: 183 passed, 2 skipped. Flutter analyze clean.
2026-06-07 23:12:07 +09:00
Edison Jwa e7f7c55b30 docs(audio): correct iOS producer name in parseBridgeAudioRoute
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.
2026-06-07 23:11:12 +09:00
Edison Jwa bb73a94e2c docs(audio): document parseBridgeAudioRoute case-sensitivity contract
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.
2026-06-07 23:11:12 +09:00
Edison Jwa e3286f1197 fix(audio,flutter): parse Android UsbHeadset and Hdmi route strings
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.
2026-06-07 23:11:12 +09:00
Edison Jwa 957d68f39d fix(audio,ios): gate output_underrun on !muted
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.
2026-06-07 23:11:12 +09:00
Edison Jwa 8105b7af21 chore(repo): untrack macOS chanora_bridge.framework build artifacts
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.
2026-06-07 22:54:14 +09:00
Edison Jwa 5f1423c349 feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22)
* 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.
2026-06-05 20:58:16 +09:00
Edison Jwa 82441f3d97 feat(voice): real-time mic input level metering at 30 Hz (#25)
* 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.
2026-06-05 20:57:16 +09:00
Edison Jwa 2c7b68e21e chore(android): upgrade toolchain to AGP 8.13.1 / Kotlin 2.3.0 (#23)
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).
2026-06-05 20:54:51 +09:00
Edison Jwa 12e3a1f4ee feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications (#21)
* 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
2026-06-05 14:26:11 +09:00
Edison Jwa 2902a8bcd5 fix(audio): eliminate Android output stutter via Oboe config + lock-free callback (#20)
* 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
2026-06-05 13:57:53 +09:00
Edison Jwa fb2a8e0a80 docs(rust): add doc comments to delta enums and fix dead_code warnings (#24)
* 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.
2026-06-05 11:54:59 +09:00
122 changed files with 10507 additions and 2271 deletions
+17
View File
@@ -117,6 +117,23 @@ opencode.json
# iOS framework build artifacts produced by chanora_bridge.podspec # iOS framework build artifacts produced by chanora_bridge.podspec
/apps/chanora_flutter/ios/Frameworks/ /apps/chanora_flutter/ios/Frameworks/
# macOS framework build artifacts produced by chanora_bridge.podspec
# (prepare_command + script_phase rm -rf and regenerate this tree on
# every pod install AND every Xcode build, so tracking it in git is
# pure waste — the committed binary was ~40 MB per commit).
/apps/chanora_flutter/macos/Frameworks/
.opencode/ .opencode/
.omo/
AGENTS.md AGENTS.md
Screenshot 2026-05-17 at 22.23.07.png Screenshot 2026-05-17 at 22.23.07.png
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
**/Chanora */
**/*.xcarchive/
**/*.ipa
**/*.dSYM/
# macOS release zip bundles produced by local release scripts
/chanora-v*.zip
+16 -12
View File
@@ -8,8 +8,10 @@ This project follows a Conventional Commits style workflow.
The v0.3.0 milestone transitions Chanora from an internal-beta voice The v0.3.0 milestone transitions Chanora from an internal-beta voice
prototype to a cross-platform baseline client with event-driven UI, prototype to a cross-platform baseline client with event-driven UI,
per-user audio controls, non-self client info parity, and CI-hardened visible per-client audio state, non-self client info parity, and
Android / iOS / macOS / Linux builds. documented host Rust workspace plus Flutter validation gates. Android
target compile/install/smoke evidence remains blocked locally pending the
required NDK compiler and an authorized ADB target.
### Added ### Added
@@ -17,10 +19,9 @@ Android / iOS / macOS / Linux builds.
deltas (client join/leave/move/update, channel add/remove/update) deltas (client join/leave/move/update, channel add/remove/update)
flow through a typed `ProtocolDelta` enum and update the Flutter UI flow through a typed `ProtocolDelta` enum and update the Flutter UI
in real time. Channel switching is instant. in real time. Channel switching is instant.
- **Per-user volume controls.** Each client in the snapshot gets an - **Per-client audio state visibility.** Client rows surface
independent volume slider persisted in the bridge layer. Avatar muted/deafened state in avatar badges. Per-user volume UI, persistence,
badges show muted/deafened state. Volume adjustments take effect and mixer wiring remain tracked as follow-up work.
immediately on the audio mix.
- **Non-self client info parity with Qint.** The Info tab now populates - **Non-self client info parity with Qint.** The Info tab now populates
connection metadata (name, description, created, last connected, connection metadata (name, description, created, last connected,
connections, transfer, ping deviation) for other clients via an connections, transfer, ping deviation) for other clients via an
@@ -29,9 +30,10 @@ Android / iOS / macOS / Linux builds.
- **Ping deviation in client profiles.** `ping_deviation_milliseconds` - **Ping deviation in client profiles.** `ping_deviation_milliseconds`
propagated from protocol DTO through bridge API to Dart, with a propagated from protocol DTO through bridge API to Dart, with a
conditional l10n row in the client info sheet (en + zh). conditional l10n row in the client info sheet (en + zh).
- **Apple CoreML Silero VAD** as the preferred voice activity detector - **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is the private `silero-coreml` SwiftPM package is available. Product
available. WebRTC VAD remains the runtime fallback. `VoiceActivity` remains reserved/disabled per DEC-030 until a later
baseline enables and verifies it.
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV - **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
lookups and `ts3server://` URI handling. lookups and `ts3server://` URI handling.
- **Per-ABI Android APK splitting.** `flutter build apk - **Per-ABI Android APK splitting.** `flutter build apk
@@ -50,8 +52,9 @@ Android / iOS / macOS / Linux builds.
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place, - **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
audio startup. audio startup.
- **Linux native audio path promoted** with ONNX Runtime bundled for - **Linux native audio path promoted** with ONNX Runtime VAD assets
VAD. Desktop voice I/O works on PipeWire / PulseAudio. bundled for future `VoiceActivity` work. Desktop voice I/O works on
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper - **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
startup permission flow, and system back-button integration. startup permission flow, and system back-button integration.
- **`SnapshotChanged` event removed.** Replaced by the typed delta - **`SnapshotChanged` event removed.** Replaced by the typed delta
@@ -59,7 +62,8 @@ Android / iOS / macOS / Linux builds.
Flutter). Flutter).
- **Prefetch crate renamed** from the PoC-era name to - **Prefetch crate renamed** from the PoC-era name to
`chanora_prefetch`. All docs, specs, and code updated. `chanora_prefetch`. All docs, specs, and code updated.
- **Build number bumped to 76.** - **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
packages remain versioned separately at `0.2.0-beta.1`.
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0. - **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
### Fixed ### Fixed
Generated
+94 -74
View File
@@ -70,6 +70,15 @@ dependencies = [
"backtrace", "backtrace",
] ]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "alsa" name = "alsa"
version = "0.11.0" version = "0.11.0"
@@ -419,19 +428,21 @@ name = "chanora_audio"
version = "0.2.0-beta.1" version = "0.2.0-beta.1"
dependencies = [ dependencies = [
"audiopus", "audiopus",
"bytemuck",
"chanora_protocol", "chanora_protocol",
"coreaudio-rs", "coreaudio-rs",
"cpal", "cpal",
"criterion", "criterion",
"crossbeam",
"dhat", "dhat",
"dispatch2", "dispatch2",
"futures-util", "futures-util",
"jni 0.21.1", "jni 0.22.4",
"ndarray", "ndarray",
"ndk-context", "ndk-context",
"oboe", "oboe",
"ort", "ort",
"rand 0.8.6", "rand 0.10.1",
"rustfft", "rustfft",
"sdl2", "sdl2",
"serde_json", "serde_json",
@@ -442,7 +453,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"tsclientlib", "tsclientlib",
"webrtc-vad", "webrtc-vad",
"windows 0.54.0", "windows",
"zbus", "zbus",
] ]
@@ -521,7 +532,7 @@ dependencies = [
[[package]] [[package]]
name = "chanora_resolver" name = "chanora_resolver"
version = "0.1.0" version = "0.2.0-beta.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hickory-resolver", "hickory-resolver",
@@ -715,14 +726,15 @@ dependencies = [
[[package]] [[package]]
name = "cpal" name = "cpal"
version = "0.17.3" version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" checksum = "d9dd2b2151ebb4d5866c804d89fe28244bfb6b74481b9b4d406e4ec4d7f88ce5"
dependencies = [ dependencies = [
"alsa", "alsa",
"block2",
"coreaudio-rs", "coreaudio-rs",
"dasp_sample", "dasp_sample",
"jni 0.21.1", "jni 0.22.4",
"js-sys", "js-sys",
"libc", "libc",
"mach2", "mach2",
@@ -737,10 +749,9 @@ dependencies = [
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys", "web-sys",
"windows 0.62.2", "windows",
"windows-core",
] ]
[[package]] [[package]]
@@ -763,25 +774,24 @@ dependencies = [
[[package]] [[package]]
name = "criterion" name = "criterion"
version = "0.5.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [ dependencies = [
"alloca",
"anes", "anes",
"cast", "cast",
"ciborium", "ciborium",
"clap", "clap",
"criterion-plot", "criterion-plot",
"is-terminal", "itertools 0.13.0",
"itertools 0.10.5",
"num-traits", "num-traits",
"once_cell",
"oorandom", "oorandom",
"page_size",
"plotters", "plotters",
"rayon", "rayon",
"regex", "regex",
"serde", "serde",
"serde_derive",
"serde_json", "serde_json",
"tinytemplate", "tinytemplate",
"walkdir", "walkdir",
@@ -789,12 +799,12 @@ dependencies = [
[[package]] [[package]]
name = "criterion-plot" name = "criterion-plot"
version = "0.5.0" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [ dependencies = [
"cast", "cast",
"itertools 0.10.5", "itertools 0.13.0",
] ]
[[package]] [[package]]
@@ -803,6 +813,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -831,6 +852,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -1963,7 +1993,7 @@ dependencies = [
"socket2", "socket2",
"widestring", "widestring",
"windows-registry", "windows-registry",
"windows-result 0.4.1", "windows-result",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1976,22 +2006,11 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [ dependencies = [
"either", "either",
] ]
@@ -2216,12 +2235,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mach2" name = "mach2"
version = "0.5.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
@@ -2518,6 +2534,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [ dependencies = [
"bitflags 2.12.1",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2730,6 +2747,16 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -3476,9 +3503,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "sdl2" name = "sdl2"
version = "0.37.0" version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380" checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"lazy_static", "lazy_static",
@@ -3488,9 +3515,9 @@ dependencies = [
[[package]] [[package]]
name = "sdl2-sys" name = "sdl2-sys"
version = "0.37.0" version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3" checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
@@ -4746,6 +4773,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4756,14 +4799,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "windows" name = "winapi-x86_64-pc-windows-gnu"
version = "0.54.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
@@ -4772,7 +4811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [ dependencies = [
"windows-collections", "windows-collections",
"windows-core 0.62.2", "windows-core",
"windows-future", "windows-future",
"windows-numerics", "windows-numerics",
] ]
@@ -4783,17 +4822,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -4805,7 +4834,7 @@ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link", "windows-link",
"windows-result 0.4.1", "windows-result",
"windows-strings", "windows-strings",
] ]
@@ -4815,7 +4844,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
"windows-link", "windows-link",
"windows-threading", "windows-threading",
] ]
@@ -4854,7 +4883,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
"windows-link", "windows-link",
] ]
@@ -4865,19 +4894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [ dependencies = [
"windows-link", "windows-link",
"windows-result 0.4.1", "windows-result",
"windows-strings", "windows-strings",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
+14
View File
@@ -82,3 +82,17 @@ tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch =
[patch.crates-io] [patch.crates-io]
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" } cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
# Apple-only DWARF emission for archive validation lives in the iOS and
# macOS chanora_bridge podspecs (apps/chanora_flutter/{ios,macos}/
# chanora_bridge.podspec) as per-build environment overrides:
#
# CARGO_PROFILE_RELEASE_DEBUG=true
# CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off
# CARGO_PROFILE_RELEASE_STRIP=false
#
# This keeps Android, Linux, and Windows release binaries on the cargo
# default release profile (no DWARF, no extra ~10MB symbol payload).
# Apple builds need the DWARF so dsymutil can emit a usable
# chanora_bridge.framework.dSYM that the archive validator accepts.
+8 -10
View File
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
## Status ## Status
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 ```text
Current documentation baseline: v0.9.2 Current documentation baseline: v0.9.x document set
Current status: Baseline Candidate Current status: Baseline Candidate
Implementation status: Not production-ready Implementation status: Not production-ready
``` ```
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
The current engineering focus is: The current engineering focus is:
- defining the system and software architecture; - 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`; - validating TeamSpeak-compatible protocol integration through `tsclientlib`;
- defining cross-platform audio behavior; - defining cross-platform audio behavior;
- preparing release, verification, security, privacy, and legal gates. - preparing release, verification, security, privacy, and legal gates.
@@ -49,7 +49,7 @@ Current platform policy:
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked | | 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 | | 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 | | 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 | | 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. The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
@@ -230,7 +230,7 @@ docs/
aspice-swe2-swe3-integration-note.md aspice-swe2-swe3-integration-note.md
``` ```
Implementation source folders may be added later. A likely structure is: Implementation source folders are present in this workspace. The current high-level structure is:
```text ```text
apps/ apps/
@@ -248,7 +248,7 @@ crates/
chanora_bridge/ chanora_bridge/
``` ```
The exact implementation layout should be finalized when the repository scaffold is created. The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
--- ---
@@ -395,9 +395,7 @@ docs/governance/git-commit-message-convention.md
## Development ## Development
Implementation commands will be added after the repository scaffold is finalized. Common local commands include:
Expected future commands may include:
```bash ```bash
flutter pub get flutter pub get
@@ -407,7 +405,7 @@ cargo clippy
cargo fmt cargo fmt
``` ```
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created. Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
--- ---
@@ -63,8 +63,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
} }
defaultConfig { defaultConfig {
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
} }
include(":app") include(":app")
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>ad-hoc</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>teamID</key>
<string>ZNVDEVDRX3</string>
<key>provisioningProfiles</key>
<dict>
<key>app.teamspeak.chanora</key>
<string>Chanora_Ad_Hoc</string>
</dict>
</dict>
</plist>
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
// Mirror Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -1,2 +1,27 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
// Force the linker to retain Swift @_cdecl symbols that the chanora_bridge
// Rust framework resolves at runtime via dlsym(RTLD_DEFAULT). Two flags per
// symbol, intentionally redundant:
//
// -u _sym marks the symbol as force-undefined at link time,
// which keeps the object that defines it from being
// dropped and prevents dead-strip from removing the
// definition. This is the load-bearing flag.
// -exported_symbol _sym re-exports the symbol in the final binary's
// dynamic symbol table so dlsym(RTLD_DEFAULT) can
// find it from the Rust framework at runtime.
//
// Without -u, Xcode Archive's -dead_strip (WMO + LTO) can remove the
// symbol before the export list is applied, and CoreML VAD silently falls
// back to WebRTC on TestFlight / App Store. The Swift-side static
// `unsafeBitCast` references in SileroCoreMLBridge.swift are belt-and-
// suspenders defense-in-depth, NOT the primary guarantee.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// `STRIP_STYLE = all` (Xcode default for archive installs) runs `strip` without
// `-x`, which removes even the global @_cdecl symbols the linker exported above
// via -exported_symbol. `non-global` runs `strip -x`, preserving globals so the
// Rust framework's dlsym(RTLD_DEFAULT) can find them. 264-byte cost in the app.
STRIP_STYLE = non-global
+1 -1
View File
@@ -27,7 +27,7 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup flutter_ios_podfile_setup
target 'Runner' do target 'Chanora' do
use_frameworks! use_frameworks!
# Chanora Rust bridge as a vendored framework. The podspec runs # Chanora Rust bridge as a vendored framework. The podspec runs
+2 -39
View File
@@ -1,70 +1,33 @@
PODS: PODS:
- audio_session (0.0.1):
- Flutter
- chanora_bridge (1.0.0) - chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_foreground_task (0.0.1): - flutter_foreground_task (0.0.1):
- Flutter - Flutter
- haptic_kit (1.0.0): - haptic_kit (1.0.0):
- Flutter - Flutter
- package_info_plus (0.4.5):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- chanora_bridge (from `.`) - chanora_bridge (from `.`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`) - haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
chanora_bridge: chanora_bridge:
:path: "." :path: "."
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_foreground_task: flutter_foreground_task:
:path: ".symlinks/plugins/flutter_foreground_task/ios" :path: ".symlinks/plugins/flutter_foreground_task/ios"
haptic_kit: haptic_kit:
:path: ".symlinks/plugins/haptic_kit/ios" :path: ".symlinks/plugins/haptic_kit/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758 haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2 PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
@@ -8,17 +8,18 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B4754099284EEDCD859A973 /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; }; 3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; }; 8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; }; 8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */; };
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; }; FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -47,10 +48,10 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; 076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
0B4754099284EEDCD859A973 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.release.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
@@ -59,19 +60,23 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; 7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.profile.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.profile.xcconfig"; sourceTree = "<group>"; };
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chanora.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.debug.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -79,8 +84,9 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */, 8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */, C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -106,8 +112,8 @@
4351F25046559EFA4C03047A /* Frameworks */ = { 4351F25046559EFA4C03047A /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */, 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -121,6 +127,9 @@
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */, 73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */, E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */, 076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */,
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */,
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -128,6 +137,7 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -151,7 +161,7 @@
97C146EF1CF9000F007C117D /* Products */ = { 97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
97C146EE1CF9000F007C117D /* Runner.app */, 97C146EE1CF9000F007C117D /* Chanora.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
); );
name = Products; name = Products;
@@ -197,14 +207,15 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Chanora */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
buildPhases = ( buildPhases = (
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */, 7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
CA110001000000000000A100 /* Verify Silero Exports */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
@@ -214,12 +225,13 @@
); );
dependencies = ( dependencies = (
); );
name = Runner; name = Chanora;
packageProductDependencies = ( packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C5000032DD0000000000001 /* SileroCoreML */, 8C5000032DD0000000000001 /* SileroCoreML */,
); );
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -252,13 +264,14 @@
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
); );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
97C146ED1CF9000F007C117D /* Runner */, 97C146ED1CF9000F007C117D /* Chanora */,
331C8080294A63A400263BE5 /* RunnerTests */, 331C8080294A63A400263BE5 /* RunnerTests */,
); );
}; };
@@ -331,15 +344,15 @@
files = ( files = (
); );
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = { 7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
@@ -357,7 +370,7 @@
outputFileListPaths = ( outputFileListPaths = (
); );
outputPaths = ( outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", "$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
@@ -379,6 +392,21 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
CA110001000000000000A100 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -406,7 +434,7 @@
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */; target = 97C146ED1CF9000F007C117D /* Chanora */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
@@ -490,18 +518,23 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = 349G7M4TQQ; CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -522,7 +555,7 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Debug; name = Debug;
}; };
@@ -538,7 +571,7 @@
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Release; name = Release;
}; };
@@ -554,7 +587,7 @@
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Profile; name = Profile;
}; };
@@ -676,18 +709,23 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = 349G7M4TQQ; CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -702,18 +740,23 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = 349G7M4TQQ; CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -743,7 +786,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
97C147061CF9000F007C117D /* Debug */, 97C147061CF9000F007C117D /* Debug */,
@@ -756,13 +799,21 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = { 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = ../../../silero-coreml; relativePath = "../../../silero-coreml";
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
8C5000032DD0000000000001 /* SileroCoreML */ = { 8C5000032DD0000000000001 /* SileroCoreML */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */; package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
@@ -1,10 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1510" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -15,8 +33,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
@@ -32,8 +50,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
@@ -68,8 +86,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
@@ -85,8 +103,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
+130 -124
View File
@@ -6,95 +6,53 @@ import AVFoundation
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel? private var iosAudioLifecycleChannel: FlutterMethodChannel?
private var iosPlatformChannel: FlutterMethodChannel? private var iosPlatformChannel: FlutterMethodChannel?
private var iosAudioSessionChannel: FlutterMethodChannel?
/// Tracks whether a voice channel is currently active.
///
/// The AVAudioSession is intentionally not configured for VoIP at
/// app launch that would interrupt other apps' audio (Spotify,
/// Apple Music, podcasts) the moment the user opens Chanora, even
/// when they're just reading chat. Production VoIP apps (Telegram
/// group calls, Signal, Discord, Element) only switch the session
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
/// a voice channel. See `docs/architecture/sad.md` and the
/// `chanora/ios_audio_session` MethodChannel contract.
///
/// This flag gates lifecycle handlers (interruption-ended,
/// media-services-reset) so we only rebuild the VoIP session if a
/// call is actually in progress. When false, those handlers leave
/// the session in the inactive `.ambient` baseline.
private var voiceSessionActive: Bool = false
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Configure the iOS AVAudioSession **category + mode** at DispatchQueue.global(qos: .utility).async {
// app-launch time, but DEFER setActive(true) until the scene ChanoraSileroSelfTest.run()
// is foregrounded. Calling setActive in didFinishLaunching is
// racy on iOS 17+ devices: if the user launches the app from a
// cold state, the UIApplication isn't yet `.active` and
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
// (561017449) the iOS audio policy server refuses to grant
// the audio session because the app is not yet considered the
// foreground priority owner. Symptom in production builds:
// 'AVAudioSession setup failed: Error 561017449 "Session
// activation failed"' in NSLog, after which the audio engine
// is unusable until the user backgrounds + foregrounds the
// app.
//
// The category itself can be set whenever; only the active
// state needs to be deferred. We listen for
// didBecomeActiveNotification and activate then. Most
// production iOS voice apps (Discord, Zoom, FaceTime) follow
// this same shape.
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
// * https://stackoverflow.com/questions/79834998 (Daily.co)
//
// Options:
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
// .allowBluetoothA2DP : permit higher-quality A2DP
// output-only Bluetooth devices.
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
// Match VPIO / Opus frame cadence to reduce callback pressure.
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
logAudioSessionState(context: "setCategory")
} catch {
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
} }
// Activate the session once the app is actually foreground. The // AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
// notification fires immediately after the cold-launch settles, // the launch-time .playAndRecord setup):
// and again on every resume-from-background both safe //
// moments to call setActive(true). Repeated activation while // At launch we set the category to .ambient and leave the
// already-active is a no-op per the docs. // session INACTIVE matching the Telegram / Signal / Discord /
NotificationCenter.default.addObserver( // Element / Jitsi pattern and Apple's guidance that "a VoIP
self, // app's audio session should not be active" while idle.
selector: #selector(activateAudioSession), // Configuring .playAndRecord + .voiceChat at launch stops other
name: UIApplication.didBecomeActiveNotification, // apps' music (Spotify, Apple Music, podcasts) the moment the
object: nil // user opens Chanora, even when they are just reading text chat.
) //
// VoIP configuration is engaged on voice-channel join via the
// `chanora/ios_audio_session` MethodChannel, driven from Dart
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
}
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@@ -120,37 +78,60 @@ import AVFoundation
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
/// Called by `didBecomeActiveNotification` (cold-launch settle + /// Activate the VoIP audio session. Called from Dart via the
/// every resume-from-background). Activates the AVAudioSession. /// `chanora/ios_audio_session` channel when a voice channel join
/// Repeated activation is a no-op when the session is already /// reaches the `BridgeEvent::AudioStarted` stage. Configures
/// active so this is safe to call on every foreground. /// .playAndRecord + .voiceChat with .mixWithOthers so other apps
@objc private func activateAudioSession() { /// (Spotify, podcasts) can keep playing alongside the voice
/// channel matching the Telegram group-call UX. Idempotent:
/// repeated calls while already active are a no-op.
private func activateVoiceSession() {
do { do {
try AVAudioSession.sharedInstance().setActive(true, options: []) let session = AVAudioSession.sharedInstance()
NSLog("chanora_flutter: AVAudioSession activated on foreground") try session.setCategory(
// Read back the ACTUAL session state. preferredSampleRate / .playAndRecord,
// preferredIOBufferDuration are hints; iOS may pick something mode: .voiceChat,
// else depending on hardware + currently-engaged effects. options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
// Without these we can't tell whether VPIO is running at )
// 48 kHz mono (what our render callback assumes) or at e.g. try session.setPreferredIOBufferDuration(0.02)
// 44.1 kHz (which would explain the user's broken playback try session.setPreferredSampleRate(48000.0)
// \u2014 our render callback would be writing samples at the try session.setActive(true, options: [])
// wrong rate, causing pitch + timing artifacts). voiceSessionActive = true
logAudioSessionState(context: "setActive") logAudioSessionState(context: "activateVoiceSession")
let s = AVAudioSession.sharedInstance() let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog( NSLog(
"chanora_flutter: AVAudioSession actual: " + "chanora_flutter: voice session active: " +
"sampleRate=\(s.sampleRate) " + "sampleRate=\(session.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " + "ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
"inputs=[\(ins)] " + "inputs=[\(ins)] outputVolume=\(session.outputVolume)"
"outputVolume=\(s.outputVolume)"
) )
} catch { } catch {
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
} }
} }
/// Deactivate the VoIP audio session and return to the idle
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
/// (intentional leave, disconnect, or connection lost).
/// `.notifyOthersOnDeactivation` lets other audio apps know they
/// can resume best-effort: Apple Music / Podcasts resume
/// reliably, Spotify is not guaranteed.
private func deactivateVoiceSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
}
do {
try session.setCategory(.ambient, mode: .default)
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
}
voiceSessionActive = false
logAudioSessionState(context: "deactivateVoiceSession")
}
/// Reads back the actual AVAudioSession state and logs it for /// Reads back the actual AVAudioSession state and logs it for
/// SDD-098 compliance. Called after both setCategory and setActive /// SDD-098 compliance. Called after both setCategory and setActive
/// to verify that the session accepted the requested configuration. /// to verify that the session accepted the requested configuration.
@@ -215,25 +196,30 @@ import AVFoundation
} }
@objc private func handleMediaServicesReset(_ notification: Notification) { @objc private func handleMediaServicesReset(_ notification: Notification) {
NSLog("chanora_flutter: media services reset") NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
do { if voiceSessionActive {
let session = AVAudioSession.sharedInstance() do {
try session.setCategory( let session = AVAudioSession.sharedInstance()
.playAndRecord, try session.setCategory(
mode: .voiceChat, .playAndRecord,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] mode: .voiceChat,
) options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
try session.setPreferredIOBufferDuration(0.02) )
try session.setPreferredSampleRate(48000.0) try session.setPreferredIOBufferDuration(0.02)
try session.setActive(true, options: []) try session.setPreferredSampleRate(48000.0)
logAudioSessionState(context: "mediaServicesWereReset") try session.setActive(true, options: [])
} catch { logAudioSessionState(context: "mediaServicesWereReset-voip")
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)") } catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
} else {
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "mediaServicesWereReset-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
}
} }
// P1: After rebuilding the session, send the current route class to
// Rust so it can recompute the processing policy and reset the
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
// which calls ios_restart_voice_unit.
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute) let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)") NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass) iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
@@ -265,6 +251,26 @@ import AVFoundation
name: "chanora/ios_platform", name: "chanora/ios_platform",
binaryMessenger: engineBridge.applicationRegistrar.messenger() binaryMessenger: engineBridge.applicationRegistrar.messenger()
) )
iosAudioSessionChannel = FlutterMethodChannel(
name: "chanora/ios_audio_session",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
guard let self = self else {
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
return
}
switch call.method {
case "activateVoiceSession":
self.activateVoiceSession()
result(nil)
case "deactivateVoiceSession":
self.deactivateVoiceSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
iosPlatformChannel?.setMethodCallHandler { call, result in iosPlatformChannel?.setMethodCallHandler { call, result in
switch call.method { switch call.method {
case "getMicrophonePermissionState": case "getMicrophonePermissionState":
+9 -10
View File
@@ -4,9 +4,6 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
supported iPhones. Flutter's iOS embedder reads this key; no
additional Flutter package is required for dynamic refresh. -->
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -27,10 +24,16 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to your voice servers.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Chanora needs microphone access so you can talk on your TeamSpeak-compatible voice server.</string> <string>Chanora needs microphone access so you can talk on your voice server.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -58,8 +61,8 @@
<array> <array>
<string>audio</string> <string>audio</string>
</array> </array>
<key>NSLocalNetworkUsageDescription</key> <key>UIFileSharingEnabled</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -77,9 +80,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict> </dict>
</plist> </plist>
@@ -1,4 +1,5 @@
import CoreML import CoreML
import Darwin
import Foundation import Foundation
import SileroCoreML import SileroCoreML
@@ -96,3 +97,136 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return } guard let string else { return }
free(string) free(string)
} }
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
// Static linker references: keep the Swift compiler / linker from
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
// as a static reference for the dead-stripper these `_ = ` lines
// do. Without them, TestFlight builds shipped without the symbols
// even though Debug builds (no LTO) worked.
//
// The `withoutActuallyEscaping` dance prevents the optimizer from
// proving the references are unused: assigning the function value
// to a `@convention(c)` typealias forces address-taken semantics.
_ = unsafeBitCast(
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadProcess
as @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
UnsafeMutablePointer<Float>?
) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
to: UnsafeRawPointer.self,
)
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -85,6 +85,13 @@ Pod::Spec.new do |s|
} }
CARGO_BIN="$(find_cargo)" CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)" RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios" echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
cd "$REPO_ROOT" cd "$REPO_ROOT"
HOME="$USER_HOME" \\ HOME="$USER_HOME" \\
@@ -95,6 +102,9 @@ Pod::Spec.new do |s|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\ IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\ CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\ CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge "$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
if [ ! -f "$BRIDGE" ]; then if [ ! -f "$BRIDGE" ]; then
@@ -129,7 +139,21 @@ PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\ install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge" "$FW/chanora_bridge"
echo "[chanora_bridge.podspec] framework ready at $FW"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder), which is the
# failure mode that produced this prepare_command in the first
# place. dsymutil reads the DWARF that cargo emitted (enabled by
# [profile.release] debug = true at the workspace root) and writes
# chanora_bridge.framework.dSYM next to the framework. We then
# strip the in-framework binary so the shipped app stays slim —
# the symbols live exclusively in the dSYM bundle, which is the
# layout xcodebuild -exportArchive and App Store Connect expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
SCRIPT SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into # Pod CocoaPods picks this up; the framework gets embedded into
@@ -185,6 +209,13 @@ PLIST
} }
CARGO_BIN="$(find_cargo)" CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)" RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
RUST_TARGET="aarch64-apple-ios-sim" RUST_TARGET="aarch64-apple-ios-sim"
SUPPORTED_PLATFORM="iPhoneSimulator" SUPPORTED_PLATFORM="iPhoneSimulator"
@@ -203,6 +234,9 @@ PLIST
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\ IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\ CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\ CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge "$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/ios" cd "$REPO_ROOT/apps/chanora_flutter/ios"
@@ -210,15 +244,19 @@ PLIST
# Skip the wrap step if the framework's binary is already # Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental # up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). # builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
FW_UP_TO_DATE=0
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date" echo "[chanora_bridge script_phase] framework already up-to-date"
exit 0 FW_UP_TO_DATE=1
fi fi
mkdir -p "$FW" if [ "$FW_UP_TO_DATE" = 0 ]; then
cp "$BRIDGE" "$FW/chanora_bridge" mkdir -p "$FW"
cat > "$FW/Info.plist" <<PLIST cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -234,9 +272,28 @@ PLIST
</dict> </dict>
</plist> </plist>
PLIST PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\ install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge" "$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed" rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). Without this the archive validator
# fails with "archive did not include a dSYM for the
# chanora_bridge.framework with the UUIDs [<uuid>]" and the
# IPA cannot be uploaded to App Store Connect / TestFlight.
# ${DWARF_DSYM_FOLDER_PATH} resolves to <ARCHIVE>/dSYMs for
# archive builds and <BUILT_PRODUCTS_DIR> otherwise; both paths
# are the ones xcodebuild scans when collecting symbols.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
SCRIPT SCRIPT
:execution_position => :before_compile, :execution_position => :before_compile,
} }
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
// Canonical layout breakpoints for Chanora.
//
// Aligned with Material 3 adaptive layout guidance:
// compact < 600dp — phone, narrow tablet
// medium 6001023 — tablet portrait, small desktop window
// expanded ≥ 1024dp — desktop, tablet landscape
//
// 1024dp was chosen as the expanded threshold based on production app
// research: Discord (member list at 1024px), Mattermost (RHS docked at
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
/// Canonical breakpoint thresholds in logical pixels.
///
/// Use these instead of hardcoded pixel values in layout decisions.
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
/// to these named constants.
class ChanoraBreakpoints {
ChanoraBreakpoints._();
/// Width at which the layout switches from compact to medium.
/// Below this: single-column mobile layout.
/// At/above: two-panel side-by-side layout.
static const double medium = 600;
/// Width at which the layout switches from medium to expanded.
/// Below this: chat opens as a pushed route.
/// At/above: three-panel layout with inline chat panel.
static const double expanded = 1024;
// Panel sizing constants.
/// Fixed width of the left voice/control panel.
static const double voicePanelWidth = 320;
/// Fixed width of the right chat panel (expanded layout only).
static const double chatPanelWidth = 380;
/// Horizontal gap between panels.
static const double panelGap = 12;
/// Desktop snackbar width cap (used when width ≥ [medium]).
static const double snackBarDesktopCap = 560;
/// Connect form action buttons switch from row to column below this width.
static const double connectActionsStackMaxWidth = 400;
/// Modal bottom sheet max height as fraction of screen height.
static const double modalSheetHeightFraction = 0.72;
}
/// Semantic layout class derived from viewport width.
enum LayoutClass {
/// < 600dp — single-column mobile layout.
compact,
/// 6001023dp — two-panel side-by-side layout.
medium,
/// ≥ 1024dp — three-panel layout with inline chat.
expanded,
}
/// Computes the current [LayoutClass] from viewport [width].
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: Apache-2.0
// Viewport info inherited widget for Chanora.
//
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
// and provides it to the entire widget subtree. Downstream widgets read
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
// `MediaQuery.sizeOf` directly for layout-class decisions.
import 'package:flutter/widgets.dart';
import 'breakpoints.dart';
/// Inherited widget that exposes the current layout class and viewport
/// dimensions to the entire subtree.
///
/// Insert this once near the top of the widget tree (inside the Scaffold
/// body or equivalent). All descendants can then read
/// `ViewportInfo.of(context)` to determine their layout behaviour.
class ViewportInfo extends InheritedWidget {
/// Creates a [ViewportInfo].
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
/// Current layout class derived from viewport width.
final LayoutClass layoutClass;
/// Current viewport width in logical pixels.
final double width;
/// Current viewport height in logical pixels.
final double height;
/// Returns the nearest [ViewportInfo] in the widget tree.
///
/// Asserts that a [ViewportInfo] ancestor exists.
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
/// Whether the current layout is compact (< 600dp).
bool get isCompact => layoutClass == LayoutClass.compact;
/// Whether the current layout is medium (6001023dp).
bool get isMedium => layoutClass == LayoutClass.medium;
/// Whether the current layout is expanded (≥ 1024dp).
bool get isExpanded => layoutClass == LayoutClass.expanded;
/// Whether the layout has room for at least two panels (medium or expanded).
bool get isWide => !isCompact;
@override
bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass;
// NOTE: width/height changes within the same layout class do NOT trigger
// notification. Dependents who genuinely need pixel-level dimensions
// (rare — most layouts should switch on layoutClass) must use a local
// LayoutBuilder. Notifying on every pixel would rebuild every dependent
// on every resize frame, which is the exact pessimisation this
// InheritedWidget exists to avoid.
}
+3 -1
View File
@@ -46,6 +46,7 @@
"retryAction": "Retry", "retryAction": "Retry",
"chatAction": "Chat", "chatAction": "Chat",
"chatCloseAction": "Close chat", "chatCloseAction": "Close chat",
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
"chatNewPrivateAction": "New private chat", "chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...", "chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message", "chatDirectMessageAction": "Private message",
@@ -298,5 +299,6 @@
"clientVolumeMuteAction": "Mute user", "clientVolumeMuteAction": "Mute user",
"clientVolumeUnmuteAction": "Unmute user", "clientVolumeUnmuteAction": "Unmute user",
"clientVolumeResetAction": "Reset to default", "clientVolumeResetAction": "Reset to default",
"permissionDenied": "Permission Denied" "permissionDenied": "Permission Denied",
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
} }
+3 -1
View File
@@ -39,6 +39,7 @@
"retryAction": "重试", "retryAction": "重试",
"chatAction": "聊天", "chatAction": "聊天",
"chatCloseAction": "关闭聊天", "chatCloseAction": "关闭聊天",
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
"chatNewPrivateAction": "新建私聊", "chatNewPrivateAction": "新建私聊",
"chatSearchClientsHint": "搜索用户...", "chatSearchClientsHint": "搜索用户...",
"chatDirectMessageAction": "私聊", "chatDirectMessageAction": "私聊",
@@ -241,5 +242,6 @@
"clientVolumeMuteAction": "静音该用户", "clientVolumeMuteAction": "静音该用户",
"clientVolumeUnmuteAction": "取消静音", "clientVolumeUnmuteAction": "取消静音",
"clientVolumeResetAction": "恢复默认", "clientVolumeResetAction": "恢复默认",
"permissionDenied": "权限被拒绝" "permissionDenied": "权限被拒绝",
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
} }
@@ -307,6 +307,12 @@ abstract class AppL10n {
/// **'Close chat'** /// **'Close chat'**
String get chatCloseAction; String get chatCloseAction;
/// No description provided for @chatPanelCollapsedHint.
///
/// In en, this message translates to:
/// **'Tap the chat button to continue your conversation'**
String get chatPanelCollapsedHint;
/// No description provided for @chatNewPrivateAction. /// No description provided for @chatNewPrivateAction.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1246,6 +1252,12 @@ abstract class AppL10n {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Permission Denied'** /// **'Permission Denied'**
String get permissionDenied; String get permissionDenied;
/// No description provided for @voiceTalkPowerBlocked.
///
/// In en, this message translates to:
/// **'Insufficient talk power to speak in this channel'**
String get voiceTalkPowerBlocked;
} }
class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> { class _AppL10nDelegate extends LocalizationsDelegate<AppL10n> {
@@ -123,6 +123,10 @@ class AppL10nEn extends AppL10n {
@override @override
String get chatCloseAction => 'Close chat'; String get chatCloseAction => 'Close chat';
@override
String get chatPanelCollapsedHint =>
'Tap the chat button to continue your conversation';
@override @override
String get chatNewPrivateAction => 'New private chat'; String get chatNewPrivateAction => 'New private chat';
@@ -653,4 +657,8 @@ class AppL10nEn extends AppL10n {
@override @override
String get permissionDenied => 'Permission Denied'; String get permissionDenied => 'Permission Denied';
@override
String get voiceTalkPowerBlocked =>
'Insufficient talk power to speak in this channel';
} }
@@ -120,6 +120,9 @@ class AppL10nZh extends AppL10n {
@override @override
String get chatCloseAction => '关闭聊天'; String get chatCloseAction => '关闭聊天';
@override
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
@override @override
String get chatNewPrivateAction => '新建私聊'; String get chatNewPrivateAction => '新建私聊';
@@ -641,4 +644,7 @@ class AppL10nZh extends AppL10n {
@override @override
String get permissionDenied => '权限被拒绝'; String get permissionDenied => '权限被拒绝';
@override
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
} }
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,22 @@ import '../src/rust/api.dart' as rust;
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle'; const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle'; const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
const macosAudioLifecycleChannelName = 'chanora/macos_audio_lifecycle';
/// Parses a platform-channel route string into a [rust.BridgeAudioRoute].
///
/// The producer contract is:
/// - iOS: `AppDelegate.classifyAudioRoute(_:)` emits one of
/// `Earpiece`, `Speaker`, `WiredHeadset`, `BluetoothHfp`, `BluetoothA2dp`,
/// `Unknown`.
/// - Android: `AndroidAudioLifecycleController.classifyDevice` emits one of
/// `Earpiece`, `Speaker`, `WiredHeadset`, `UsbHeadset`, `BluetoothHfp`,
/// `BluetoothA2dp`, `Hdmi`, `Unknown`.
///
/// Both producers emit exact PascalCase strings. Case variants (`USB_HEADSET`,
/// `usb_headset`, `UsbHeadphone`) are NOT handled and will fall through to
/// `unknown`. If either platform classifier changes its string contract,
/// update both producers and this parser together.
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) { rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
switch (value) { switch (value) {
case 'Earpiece': case 'Earpiece':
@@ -14,11 +29,15 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
case 'Speaker': case 'Speaker':
return rust.BridgeAudioRoute.speaker; return rust.BridgeAudioRoute.speaker;
case 'WiredHeadset': case 'WiredHeadset':
case 'UsbHeadset':
return rust.BridgeAudioRoute.wiredHeadset; return rust.BridgeAudioRoute.wiredHeadset;
case 'BluetoothHfp': case 'BluetoothHfp':
return rust.BridgeAudioRoute.bluetoothHfp; return rust.BridgeAudioRoute.bluetoothHfp;
case 'BluetoothA2dp': case 'BluetoothA2dp':
return rust.BridgeAudioRoute.bluetoothA2Dp; return rust.BridgeAudioRoute.bluetoothA2Dp;
case 'Hdmi':
case 'Unknown':
return rust.BridgeAudioRoute.unknown;
default: default:
return rust.BridgeAudioRoute.unknown; return rust.BridgeAudioRoute.unknown;
} }
@@ -27,6 +46,7 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
void wireAudioLifecycle() { void wireAudioLifecycle() {
wireIosAudioLifecycle(); wireIosAudioLifecycle();
wireAndroidAudioLifecycle(); wireAndroidAudioLifecycle();
wireMacosAudioLifecycle();
} }
/// Wire the iOS AVAudioSession lifecycle MethodChannel. /// Wire the iOS AVAudioSession lifecycle MethodChannel.
@@ -105,3 +125,42 @@ void wireAndroidAudioLifecycle({
} }
}); });
} }
/// Wire the macOS audio lifecycle MethodChannel.
///
/// Swift side (`MacOSAudioLifecycle`) posts `handleDefaultDeviceChange`
/// (with `role: 'input' | 'output'`) when Core Audio HAL default-input /
/// default-output device changes, and `handleConfigurationChange` when
/// the VPIO AudioUnit reports a stream-format change. Closes the
/// iOS/macOS asymmetry noted in SysRS-051.
///
/// Current scope: events are received and logged. The FRB
/// `macosDefaultDeviceChanged` function that triggers a VPIO
/// re-bind on the engine is a follow-up; until it's exposed, the
/// macOS path mirrors the iOS `chanora/ios_audio_lifecycle` event
/// surface but does not yet trigger an engine-side restart.
void wireMacosAudioLifecycle({
bool isMacos = false,
MethodChannel channel = const MethodChannel(macosAudioLifecycleChannelName),
}) {
if (!isMacos && !Platform.isMacOS) return;
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleDefaultDeviceChange':
// TODO: call rust.macosDefaultDeviceChanged() once exposed
// via flutter_rust_bridge; until then the event is captured
// here for observability.
break;
case 'handleConfigurationChange':
// TODO: same — currently captured, no engine action yet.
break;
default:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
@@ -0,0 +1,31 @@
class HardMuteOwners {
const HardMuteOwners({
this.manual = false,
this.permission = false,
this.talkPower = false,
});
final bool manual;
final bool permission;
final bool talkPower;
bool get effective => manual || permission || talkPower;
HardMuteOwners withBridgeManualMute(bool muted) {
return copyWith(
manual: muted && (manual || !permission && !talkPower),
);
}
HardMuteOwners copyWith({
bool? manual,
bool? permission,
bool? talkPower,
}) {
return HardMuteOwners(
manual: manual ?? this.manual,
permission: permission ?? this.permission,
talkPower: talkPower ?? this.talkPower,
);
}
}
@@ -0,0 +1,66 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
///
/// The Swift `AppDelegate` configures the session to `.ambient` at
/// launch and leaves it inactive. The session is only switched to
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
/// voice channel is actually active. This controller is the Dart
/// side of that contract — call [activate] when the Rust engine
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
/// `BridgeEvent::AudioStopped`.
///
/// On non-iOS platforms both methods are no-ops; the platforms
/// handle their own session lifecycle elsewhere (Android via
/// `AndroidAudioLifecycleController`, macOS via
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
class IosAudioSessionController {
IosAudioSessionController({
MethodChannel? channel,
bool? isIos,
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
_isIos = isIos ?? Platform.isIOS;
final MethodChannel _channel;
final bool _isIos;
Future<void> activate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('activateVoiceSession');
} on PlatformException {
// Swift side logs the failure via NSLog; surfacing the
// exception to the event handler would be noise. The Rust
// engine remains alive and will produce silence until the
// next route change or a manual leave/rejoin.
} on MissingPluginException {
// Test hosts and mispackaged builds may not have registered
// the iOS channel. Keep event dispatch alive rather than
// surfacing an unhandled async error.
}
}
Future<void> deactivate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('deactivateVoiceSession');
} on PlatformException {
// Same rationale as activate(): the Swift side logs.
// Worst case the session stays in .playAndRecord until the
// app is backgrounded — at which point iOS reclaims the
// session automatically.
} on MissingPluginException {
// Same rationale as activate(): missing channel should not
// break bridge event handling.
}
}
}
/// Default singleton used by [main.dart] event dispatch. Tests
/// should construct their own [IosAudioSessionController] with a
/// mocked channel rather than mutating this instance.
final iosAudioSessionController = IosAudioSessionController();
@@ -0,0 +1,533 @@
/// macOS permission integration for Input Monitoring, Local Network,
/// and Notifications.
///
/// Trace:
/// - SRS-198 (Push-to-talk system permission acquisition).
/// - SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
/// - SysRS-166 (Desktop notifications).
/// - SDD-091 (PTT capability badge — live capability level).
///
/// Responsibilities:
/// * Subscribe to the Swift-side `MethodChannel`
/// `app.chanora/macos_permissions` for inbound state-change
/// invocations emitted by the native handler in
/// `MainFlutterWindow.swift` (Swift → Dart).
/// * Provide imperative Dart → Swift entry points for checking and
/// requesting Input Monitoring, triggering the Local Network
/// prompt, and requesting notification authorization.
/// * Expose the latest resolved states as [ValueListenable] so UI
/// surfaces (PTT capability badge, permission banners, connection
/// error messages) can react without polling.
///
/// ## Non-macOS short-circuit
///
/// On Android / iOS / Linux / Windows / web, none of these macOS-
/// specific permissions exist. The MethodChannel is therefore never
/// constructed off-macOS. Each state listenable stays at its default
/// "granted" / "not needed" value so all consumers become no-ops.
///
/// ## No global statics
///
/// Following the pattern established by `AndroidPermissionsService`
/// (SDD-106) and `BackIntentService` (SDD-028), this class is
/// constructor-injected. The host app instantiates one instance at
/// startup and passes it through the widget tree.
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
// ---------------------------------------------------------------------------
// Channel constants
// ---------------------------------------------------------------------------
/// MethodChannel name shared with Swift `MacOSPermissionsHandler`.
@visibleForTesting
const String macOSPermissionsChannelName =
'app.chanora/macos_permissions';
// Outbound (Dart → Swift) method names.
@visibleForTesting
const String methodCheckInputMonitoring = 'checkInputMonitoring';
@visibleForTesting
const String methodRequestInputMonitoring = 'requestInputMonitoring';
@visibleForTesting
const String methodOpenInputMonitoringSettings =
'openInputMonitoringSettings';
@visibleForTesting
const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
@visibleForTesting
const String methodCheckLocalNetwork = 'checkLocalNetwork';
@visibleForTesting
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
@visibleForTesting
const String methodRequestNotifications = 'requestNotifications';
@visibleForTesting
const String methodCheckNotifications = 'checkNotifications';
// Inbound (Swift → Dart) method names.
@visibleForTesting
const String methodInputMonitoringStateChanged =
'inputMonitoringStateChanged';
@visibleForTesting
const String methodLocalNetworkStateChanged = 'localNetworkStateChanged';
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/// Discrete states for macOS-specific permissions.
enum MacOSPermissionState {
/// Permission granted.
granted,
/// Permission denied by the user.
denied,
/// Permission has not yet been determined (first launch before
/// any prompt, or the system returned an unexpected value).
notDetermined,
/// No resolved state yet (cold launch before the first emission,
/// or non-macOS host before short-circuit). Consumers treat this
/// as "not yet known".
unknown,
}
/// Local Network permission states, extended to cover macOS 14 and
/// earlier where Local Network Privacy does not exist.
enum MacOSLocalNetworkState {
/// Permission granted or the Local Network prompt was satisfied.
granted,
/// Permission explicitly denied by the user (macOS 15+ only).
denied,
/// No prompt shown yet.
notDetermined,
/// Running on macOS 14 or earlier where Local Network Privacy
/// does not apply. Consumers treat this as "granted".
unsupported,
/// No resolved state yet.
unknown,
}
// ---------------------------------------------------------------------------
// State parsing helpers
// ---------------------------------------------------------------------------
MacOSPermissionState _parsePermissionState(String? raw) {
switch (raw) {
case 'Granted':
return MacOSPermissionState.granted;
case 'Denied':
return MacOSPermissionState.denied;
case 'NotDetermined':
return MacOSPermissionState.notDetermined;
default:
return MacOSPermissionState.unknown;
}
}
MacOSLocalNetworkState _parseLocalNetworkState(String? raw) {
switch (raw) {
case 'Granted':
return MacOSLocalNetworkState.granted;
case 'Denied':
return MacOSLocalNetworkState.denied;
case 'NotDetermined':
return MacOSLocalNetworkState.notDetermined;
case 'Unsupported':
return MacOSLocalNetworkState.unsupported;
default:
return MacOSLocalNetworkState.unknown;
}
}
// ---------------------------------------------------------------------------
// PTT capability level mapping
// ---------------------------------------------------------------------------
/// Maps the Input Monitoring state to the PTT capability level string
/// consumed by [PttCapabilityBadge].
///
/// Trace: SDD-091 (capability badge); desktop-ptt-architecture.md
/// (macOS Event Tap strategy).
String _pttCapabilityLevel(MacOSPermissionState inputMonitoring) {
switch (inputMonitoring) {
case MacOSPermissionState.granted:
return 'L1MacOSEventTap';
case MacOSPermissionState.denied:
case MacOSPermissionState.notDetermined:
case MacOSPermissionState.unknown:
return 'L0Focused';
}
}
// ---------------------------------------------------------------------------
// MacOSPermissionsService
// ---------------------------------------------------------------------------
/// Dart-side integration for macOS permission state.
///
/// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
class MacOSPermissionsService {
/// Construct a service bound to [channel]. Injected for testability;
/// production code uses the default channel keyed on
/// [macOSPermissionsChannelName].
MacOSPermissionsService({MethodChannel? channel})
: _channel = channel ??
(_isMacOS
? const MethodChannel(macOSPermissionsChannelName)
: null);
/// Platform-detection seam. Web counts as non-macOS.
static bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
final MethodChannel? _channel;
bool _started = false;
// -- Input Monitoring -----------------------------------------------------
final ValueNotifier<MacOSPermissionState> _inputMonitoringState =
ValueNotifier<MacOSPermissionState>(
// Non-macOS: granted so consumers are no-ops.
_isMacOS
? MacOSPermissionState.unknown
: MacOSPermissionState.granted,
);
/// Latest known Input Monitoring permission state.
///
/// On macOS this drives the PTT capability level: `granted` →
/// `L1MacOSEventTap` (global PTT via Event Tap); anything else →
/// `L0Focused` (focused-only PTT).
ValueListenable<MacOSPermissionState> get inputMonitoringState =>
_inputMonitoringState;
// -- Local Network --------------------------------------------------------
final ValueNotifier<MacOSLocalNetworkState> _localNetworkState =
ValueNotifier<MacOSLocalNetworkState>(
_isMacOS
? MacOSLocalNetworkState.unknown
: MacOSLocalNetworkState.unsupported,
);
/// Latest known Local Network permission state.
///
/// On macOS 15+ (Sequoia) this reflects the Local Network Privacy
/// TCC permission. On macOS 14 and earlier, the value is
/// [MacOSLocalNetworkState.unsupported] (no prompt needed).
ValueListenable<MacOSLocalNetworkState> get localNetworkState =>
_localNetworkState;
// -- Notifications --------------------------------------------------------
final ValueNotifier<MacOSPermissionState> _notificationState =
ValueNotifier<MacOSPermissionState>(
_isMacOS
? MacOSPermissionState.unknown
: MacOSPermissionState.granted,
);
/// Latest known notification authorization state.
ValueListenable<MacOSPermissionState> get notificationState =>
_notificationState;
// -- PTT capability (derived) ---------------------------------------------
final ValueNotifier<String> _pttCapabilityState =
ValueNotifier<String>(
_pttCapabilityLevel(
_isMacOS ? MacOSPermissionState.unknown : MacOSPermissionState.granted,
),
);
/// Derived PTT capability level string, ready for consumption by
/// [PttCapabilityBadge]. Updates automatically when Input Monitoring
/// state changes.
///
/// Returns `"L1MacOSEventTap"` when Input Monitoring is granted,
/// `"L0Focused"` otherwise.
ValueListenable<String> get pttCapabilityState => _pttCapabilityState;
// -- Lifecycle ------------------------------------------------------------
/// Start listening for state updates from Swift. Idempotent.
///
/// On non-macOS this is a no-op.
///
/// Note: this only registers the inbound handler. Call
/// [checkInitialStates] afterward to eagerly query the current
/// permission states from the native side.
void start() {
if (_started) return;
_started = true;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(_handle);
}
/// Eagerly query the current permission states from the native
/// side. Call this after [start] so the PTT capability badge
/// shows the correct level on the first frame.
///
/// On non-macOS this is a no-op.
void checkInitialStates() {
final ch = _channel;
if (ch == null) return;
unawaited(_checkInputMonitoring());
unawaited(_checkLocalNetwork());
unawaited(_checkNotifications());
}
/// Stop listening. Idempotent.
void stop() {
if (!_started) return;
_started = false;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(null);
}
// -- Inbound handler (Swift → Dart) --------------------------------------
Future<dynamic> _handle(MethodCall call) async {
switch (call.method) {
case methodInputMonitoringStateChanged:
final args = call.arguments;
if (args is Map) {
final state = _parsePermissionState(args['state'] as String?);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
}
break;
case methodLocalNetworkStateChanged:
final args = call.arguments;
if (args is Map) {
_localNetworkState.value =
_parseLocalNetworkState(args['state'] as String?);
}
break;
default:
break;
}
return null;
}
// -- Outbound: Input Monitoring -------------------------------------------
/// Check the current Input Monitoring permission state without
/// triggering a system prompt.
Future<MacOSPermissionState> _checkInputMonitoring() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw =
await ch.invokeMethod<String>(methodCheckInputMonitoring);
final state = _parsePermissionState(raw);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
return state;
} catch (e, st) {
developer.log(
'checkInputMonitoring failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _inputMonitoringState.value;
}
}
/// Request Input Monitoring permission. On macOS this calls
/// `CGRequestListenEventAccess()` which shows a system dialog
/// or opens System Settings (depending on macOS version).
///
/// Returns the new state after the request.
Future<MacOSPermissionState> requestInputMonitoring() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw = await ch.invokeMethod<String>(
methodRequestInputMonitoring,
);
final state = _parsePermissionState(raw);
_inputMonitoringState.value = state;
_pttCapabilityState.value = _pttCapabilityLevel(state);
return state;
} catch (e, st) {
developer.log(
'requestInputMonitoring failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _inputMonitoringState.value;
}
}
/// Open System Settings → Privacy & Security → Input Monitoring
/// so the user can manually grant the permission for the
/// permanently-denied case (TCC drag-based permission cannot be
/// programmatically granted).
Future<void> openInputMonitoringSettings() async {
final ch = _channel;
if (ch == null) return;
try {
await ch.invokeMethod<void>(methodOpenInputMonitoringSettings);
} catch (e, st) {
developer.log(
'openInputMonitoringSettings failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
}
}
// -- Outbound: Local Network ----------------------------------------------
Future<MacOSLocalNetworkState> _checkLocalNetwork() async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(methodCheckLocalNetwork);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkLocalNetwork failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
/// Trigger the Local Network permission prompt by starting a
/// brief `NWBrowser` scan for `_ts3._tcp`. On macOS 15+ this
/// shows the system Local Network Privacy dialog. On earlier
/// versions, this is a no-op (returns `unsupported`).
Future<MacOSLocalNetworkState> triggerLocalNetworkPrompt() async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(
methodTriggerLocalNetworkPrompt,
);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'triggerLocalNetworkPrompt failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
/// Probe whether Local Network access is currently denied for [host]:[port]
/// by creating a short-lived NWConnection and checking
/// `unsatisfiedReason == .localNetworkDenied`.
///
/// This does NOT trigger a new system prompt — it is a read-only check.
/// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms.
Future<MacOSLocalNetworkState> checkLocalNetworkAccess({
required String host,
required int port,
}) async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(
methodCheckLocalNetworkAccess,
<String, dynamic>{'host': host, 'port': port},
);
final state = _parseLocalNetworkState(raw);
_localNetworkState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkLocalNetworkAccess failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _localNetworkState.value;
}
}
// -- Outbound: Notifications ----------------------------------------------
Future<MacOSPermissionState> _checkNotifications() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw =
await ch.invokeMethod<String>(methodCheckNotifications);
final state = _parsePermissionState(raw);
_notificationState.value = state;
return state;
} catch (e, st) {
developer.log(
'checkNotifications failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _notificationState.value;
}
}
/// Request notification authorization via `UNUserNotificationCenter`.
/// Returns the new state after the system dialog resolves.
Future<MacOSPermissionState> requestNotifications() async {
final ch = _channel;
if (ch == null) return MacOSPermissionState.granted;
try {
final raw = await ch.invokeMethod<String>(
methodRequestNotifications,
);
final state = _parsePermissionState(raw);
_notificationState.value = state;
return state;
} catch (e, st) {
developer.log(
'requestNotifications failed',
name: 'MacOSPermissionsService',
error: e,
stackTrace: st,
);
return _notificationState.value;
}
}
// -- Cleanup --------------------------------------------------------------
/// Release state notifiers. Test helper; production keeps the
/// service alive for the lifetime of the app.
@visibleForTesting
void dispose() {
stop();
_inputMonitoringState.dispose();
_localNetworkState.dispose();
_notificationState.dispose();
_pttCapabilityState.dispose();
}
}
+16 -2
View File
@@ -261,6 +261,12 @@ Stream<BridgeEvent> eventsStream() =>
Future<BridgeAudioStats> audioStats() => Future<BridgeAudioStats> audioStats() =>
RustLib.instance.api.crateApiAudioStats(); RustLib.instance.api.crateApiAudioStats();
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels or the session is dropped.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
/// Apply the P1 audio-processing config. /// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({ Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config, required BridgeAudioProcessingConfig config,
@@ -681,15 +687,22 @@ class BridgeAudioStats {
/// Current push-to-talk state. /// Current push-to-talk state.
final bool pttActive; final bool pttActive;
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
final double inputLevel;
const BridgeAudioStats({ const BridgeAudioStats({
required this.framesSent, required this.framesSent,
required this.framesReceived, required this.framesReceived,
required this.pttActive, required this.pttActive,
required this.inputLevel,
}); });
@override @override
int get hashCode => int get hashCode =>
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode; framesSent.hashCode ^
framesReceived.hashCode ^
pttActive.hashCode ^
inputLevel.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -698,7 +711,8 @@ class BridgeAudioStats {
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
framesSent == other.framesSent && framesSent == other.framesSent &&
framesReceived == other.framesReceived && framesReceived == other.framesReceived &&
pttActive == other.pttActive; pttActive == other.pttActive &&
inputLevel == other.inputLevel;
} }
/// Bookmark DTO mirroring [`chanora_core::Bookmark`]. /// Bookmark DTO mirroring [`chanora_core::Bookmark`].
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => 281698435; int get rustContentHash => -20394775;
static const kDefaultExternalLibraryLoaderConfig = static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig( ExternalLibraryLoaderConfig(
@@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiInitStorage({required String dir}); Future<void> crateApiInitStorage({required String dir});
Stream<double> crateApiInputLevelStream();
Future<bool> crateApiIsConnected(); Future<bool> crateApiIsConnected();
Future<BridgeAudioDeviceList> crateApiListAudioDevices(); Future<BridgeAudioDeviceList> crateApiListAudioDevices();
@@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiInitStorageConstMeta => TaskConstMeta get kCrateApiInitStorageConstMeta =>
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]); const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
@override
Stream<double> crateApiInputLevelStream() {
final sink = RustStreamSink<double>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_f_32_Sse(sink, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiInputLevelStreamConstMeta,
argValues: [sink],
apiImpl: this,
),
),
);
return sink.stream;
}
TaskConstMeta get kCrateApiInputLevelStreamConstMeta =>
const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]);
@override @override
Future<bool> crateApiIsConnected() { Future<bool> crateApiIsConnected() {
return handler.executeNormal( return handler.executeNormal(
@@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 21, funcId: 22,
port: port_, port: port_,
); );
}, },
@@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 22, funcId: 23,
port: port_, port: port_,
); );
}, },
@@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 23, funcId: 24,
port: port_, port: port_,
); );
}, },
@@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask( SyncTask(
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_String, decodeSuccessData: sse_decode_String,
@@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 25, funcId: 26,
port: port_, port: port_,
); );
}, },
@@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 26, funcId: 27,
port: port_, port: port_,
); );
}, },
@@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 27, funcId: 28,
port: port_, port: port_,
); );
}, },
@@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer); sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 29, funcId: 30,
port: port_, port: port_,
); );
}, },
@@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer); sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 31, funcId: 32,
port: port_, port: port_,
); );
}, },
@@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 32, funcId: 33,
port: port_, port: port_,
); );
}, },
@@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 33, funcId: 34,
port: port_, port: port_,
); );
}, },
@@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 34, funcId: 35,
port: port_, port: port_,
); );
}, },
@@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 35, funcId: 36,
port: port_, port: port_,
); );
}, },
@@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 36, funcId: 37,
port: port_, port: port_,
); );
}, },
@@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () { callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer); sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
}, },
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_unit, decodeSuccessData: sse_decode_unit,
@@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 38, funcId: 39,
port: port_, port: port_,
); );
}, },
@@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 39, funcId: 40,
port: port_, port: port_,
); );
}, },
@@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 40, funcId: 41,
port: port_, port: port_,
); );
}, },
@@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 41, funcId: 42,
port: port_, port: port_,
); );
}, },
@@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 42, funcId: 43,
port: port_, port: port_,
); );
}, },
@@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 43, funcId: 44,
port: port_, port: port_,
); );
}, },
@@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 44, funcId: 45,
port: port_, port: port_,
); );
}, },
@@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 45, funcId: 46,
port: port_, port: port_,
); );
}, },
@@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 46, funcId: 47,
port: port_, port: port_,
); );
}, },
@@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 47, funcId: 48,
port: port_, port: port_,
); );
}, },
@@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 48, funcId: 49,
port: port_, port: port_,
); );
}, },
@@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 49, funcId: 50,
port: port_, port: port_,
); );
}, },
@@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError(); throw UnimplementedError();
} }
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected @protected
String dco_decode_String(dynamic raw) { String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) { BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 3) if (arr.length != 4)
throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BridgeAudioStats( return BridgeAudioStats(
framesSent: dco_decode_u_32(arr[0]), framesSent: dco_decode_u_32(arr[0]),
framesReceived: dco_decode_u_32(arr[1]), framesReceived: dco_decode_u_32(arr[1]),
pttActive: dco_decode_bool(arr[2]), pttActive: dco_decode_bool(arr[2]),
inputLevel: dco_decode_f_32(arr[3]),
); );
} }
@@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()'); throw UnimplementedError('Unreachable ()');
} }
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected @protected
String sse_decode_String(SseDeserializer deserializer) { String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_framesSent = sse_decode_u_32(deserializer); var var_framesSent = sse_decode_u_32(deserializer);
var var_framesReceived = sse_decode_u_32(deserializer); var var_framesReceived = sse_decode_u_32(deserializer);
var var_pttActive = sse_decode_bool(deserializer); var var_pttActive = sse_decode_bool(deserializer);
var var_inputLevel = sse_decode_f_32(deserializer);
return BridgeAudioStats( return BridgeAudioStats(
framesSent: var_framesSent, framesSent: var_framesSent,
framesReceived: var_framesReceived, framesReceived: var_framesReceived,
pttActive: var_pttActive, pttActive: var_pttActive,
inputLevel: var_inputLevel,
); );
} }
@@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
); );
} }
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_f_32,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected @protected
void sse_encode_String(String self, SseSerializer serializer) { void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
@@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_32(self.framesSent, serializer); sse_encode_u_32(self.framesSent, serializer);
sse_encode_u_32(self.framesReceived, serializer); sse_encode_u_32(self.framesReceived, serializer);
sse_encode_bool(self.pttActive, serializer); sse_encode_bool(self.pttActive, serializer);
sse_encode_f_32(self.inputLevel, serializer);
} }
@protected @protected
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
/// Semantic tones for lightweight, Material 3 SnackBars. /// Semantic tones for lightweight, Material 3 SnackBars.
enum AppSnackBarVariant { neutral, success, warning, error } enum AppSnackBarVariant { neutral, success, warning, error }
@@ -7,7 +9,6 @@ enum AppSnackBarVariant { neutral, success, warning, error }
class AppSnackBar { class AppSnackBar {
const AppSnackBar._(); const AppSnackBar._();
static const double _desktopMaxWidth = 560;
static const double _radius = 16; static const double _radius = 16;
static const double _elevation = 3; static const double _elevation = 3;
@@ -39,9 +40,9 @@ class AppSnackBar {
}) { }) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final viewWidth = MediaQuery.sizeOf(context).width; final viewWidth = MediaQuery.sizeOf(context).width;
final useDesktopCap = viewWidth >= 600; final useDesktopCap = viewWidth >= ChanoraBreakpoints.medium;
final snackBarWidth = useDesktopCap && margin == null final snackBarWidth = useDesktopCap && margin == null
? _desktopMaxWidth ? ChanoraBreakpoints.snackBarDesktopCap
: null; : null;
final effectiveMargin = useDesktopCap && margin != null final effectiveMargin = useDesktopCap && margin != null
? _desktopCappedMargin(context, margin) ? _desktopCappedMargin(context, margin)
@@ -76,7 +77,11 @@ class AppSnackBar {
final viewWidth = MediaQuery.sizeOf(context).width; final viewWidth = MediaQuery.sizeOf(context).width;
final resolved = margin.resolve(Directionality.of(context)); final resolved = margin.resolve(Directionality.of(context));
final extraHorizontal = final extraHorizontal =
(viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2; (viewWidth - ChanoraBreakpoints.snackBarDesktopCap).clamp(
0.0,
viewWidth,
) /
2;
return EdgeInsets.fromLTRB( return EdgeInsets.fromLTRB(
resolved.left + extraHorizontal, resolved.left + extraHorizontal,
resolved.top, resolved.top,
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'chat_views.dart';
/// Fixed-width inline chat panel for expanded desktop layouts.
class ChatPanel extends StatelessWidget {
/// Construct an inline chat panel.
const ChatPanel({
super.key,
required this.messages,
required this.snapshot,
required this.target,
required this.clientName,
required this.onClose,
this.restoredDraft,
this.onDraftChanged,
this.onTs3ServerLink,
});
/// Backing chat messages shared with the chat route.
final List<ChatEntry> messages;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Chat target shown in the panel.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Called when the user closes the inline panel.
final VoidCallback onClose;
/// External draft text to restore in the chat detail view.
final String? restoredDraft;
/// Called when the draft text changes.
final ValueChanged<String>? onDraftChanged;
@override
Widget build(BuildContext context) {
final currentChannelId = ownClientSnapshotState(snapshot)?.channelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
return SizedBox(
width: ChanoraBreakpoints.chatPanelWidth,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: BorderDirectional(
start: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: ChatDetailView(
messages: messages,
snapshot: snapshot,
target: target,
clientName: clientName,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: onTs3ServerLink,
restoredDraft: restoredDraft,
onDraftChanged: onDraftChanged,
messageMaxWidth: 500,
headerTrailing: IconButton(
tooltip: l10n.chatCloseAction,
icon: const Icon(Icons.close),
onPressed: onClose,
),
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'dart:async' show unawaited;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart'; import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart'; import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart'; import '../services/link_trust_service.dart';
@@ -13,7 +14,6 @@ import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92; const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76; const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84; const double _chatSidebarCompactHeight = 84;
const double _chatMobileBreakpoint = 600;
/// One chat/activity message shown in the chat hub. /// One chat/activity message shown in the chat hub.
class ChatEntry { class ChatEntry {
@@ -616,7 +616,7 @@ class _ChatPageState extends State<ChatPage> {
final currentChannelId = _currentChannelId; final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(snapshot, currentChannelId); final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context); final l10n = AppL10n.of(context);
final detail = _ChatDetailView( final detail = ChatDetailView(
target: _selectedTarget, target: _selectedTarget,
clientName: _selectedClientName, clientName: _selectedClientName,
snapshot: snapshot, snapshot: snapshot,
@@ -641,7 +641,7 @@ class _ChatPageState extends State<ChatPage> {
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final sidebar = _ChatSidebar( final sidebar = _ChatSidebar(
compact: constraints.maxWidth < _chatMobileBreakpoint, compact: constraints.maxWidth < ChanoraBreakpoints.medium,
selectedTarget: _selectedTarget, selectedTarget: _selectedTarget,
privateChats: _privateChats, privateChats: _privateChats,
onSelect: _selectTarget, onSelect: _selectTarget,
@@ -650,7 +650,7 @@ class _ChatPageState extends State<ChatPage> {
_selectTarget(rust.BridgeMessageTarget.client(id), name: name); _selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}), }),
); );
if (constraints.maxWidth < _chatMobileBreakpoint) { if (constraints.maxWidth < ChanoraBreakpoints.medium) {
return Column( return Column(
children: [ children: [
sidebar, sidebar,
@@ -1050,8 +1050,11 @@ class _ChannelGroup extends StatelessWidget {
} }
} }
class _ChatDetailView extends StatefulWidget { /// Detail view for a single chat target, including message history and input.
const _ChatDetailView({ class ChatDetailView extends StatefulWidget {
/// Construct a chat detail view.
const ChatDetailView({
super.key,
required this.target, required this.target,
required this.clientName, required this.clientName,
required this.snapshot, required this.snapshot,
@@ -1059,21 +1062,50 @@ class _ChatDetailView extends StatefulWidget {
required this.currentChannelId, required this.currentChannelId,
required this.channelName, required this.channelName,
this.onTs3ServerLink, this.onTs3ServerLink,
this.headerTrailing,
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
}); });
/// Chat target displayed by this detail view.
final rust.BridgeMessageTarget target; final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName; final String clientName;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot; final rust.BridgeSnapshot snapshot;
/// Backing message list. Self-sent messages are appended here.
final List<ChatEntry> messages; final List<ChatEntry> messages;
/// Current voice channel id for channel-chat send gating.
final BigInt? currentChannelId; final BigInt? currentChannelId;
/// Current voice channel name for labels and placeholders.
final String channelName; final String channelName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink; final Ts3ServerLinkHandler? onTs3ServerLink;
/// Optional widget shown at the trailing edge of the header.
final Widget? headerTrailing;
/// Optional max width for message content.
final double? messageMaxWidth;
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
@override @override
State<_ChatDetailView> createState() => _ChatDetailViewState(); State<ChatDetailView> createState() => _ChatDetailViewState();
} }
class _ChatDetailViewState extends State<_ChatDetailView> { class _ChatDetailViewState extends State<ChatDetailView> {
final _textCtl = TextEditingController(); final _textCtl = TextEditingController();
final _scrollCtl = ScrollController(); final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1; int _lastRenderedMessageCount = -1;
@@ -1100,8 +1132,35 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
clientName: widget.clientName, clientName: widget.clientName,
); );
@override
void initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
// Propagate the OUTGOING draft unconditionally, including empty
// text. The empty case is load-bearing: if the user typed text,
// saved it, restored it, then deleted everything, the parent
// map must learn the draft is now empty — otherwise the stale
// entry resurrects on the next target swap.
oldWidget.onDraftChanged?.call(_textCtl.text);
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override @override
void dispose() { void dispose() {
// Same unconditional flush on tear-down. The `isNotEmpty` guard
// here would silently drop "user cleared the field then closed
// the panel" into the same stale-entry bug class as didUpdateWidget.
widget.onDraftChanged?.call(_textCtl.text);
_textCtl.dispose(); _textCtl.dispose();
_scrollCtl.dispose(); _scrollCtl.dispose();
super.dispose(); super.dispose();
@@ -1176,7 +1235,12 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
bottom: BorderSide(color: theme.colorScheme.outlineVariant), bottom: BorderSide(color: theme.colorScheme.outlineVariant),
), ),
), ),
child: Text(_title, style: theme.textTheme.titleMedium), child: Row(
children: [
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
if (widget.headerTrailing != null) widget.headerTrailing!,
],
),
), ),
Expanded( Expanded(
child: msgs.isEmpty child: msgs.isEmpty
@@ -1216,9 +1280,16 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
controller: _scrollCtl, controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length, itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble( itemBuilder: (_, i) => Center(
entry: msgs[i], child: ConstrainedBox(
onTs3ServerLink: widget.onTs3ServerLink, constraints: BoxConstraints(
maxWidth: widget.messageMaxWidth ?? double.infinity,
),
child: _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
), ),
), ),
), ),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../design/breakpoints.dart';
import '../l10n/generated/app_localizations.dart'; import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust; import '../src/rust/api.dart' as rust;
@@ -114,7 +115,6 @@ class _ConnectFormState extends State<ConnectForm> {
const SizedBox(height: 16), const SizedBox(height: 16),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
const stackedActionsMaxWidth = 400.0;
final connectButton = FilledButton.icon( final connectButton = FilledButton.icon(
icon: const Icon(Icons.login), icon: const Icon(Icons.login),
label: Text(l10n.connectAction), label: Text(l10n.connectAction),
@@ -125,7 +125,8 @@ class _ConnectFormState extends State<ConnectForm> {
label: Text(l10n.bookmarkAddAction), label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark, onPressed: widget.onAddBookmark,
); );
if (constraints.maxWidth <= stackedActionsMaxWidth) { if (constraints.maxWidth <=
ChanoraBreakpoints.connectActionsStackMaxWidth) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -23,12 +23,14 @@ class SnapshotView extends StatefulWidget {
required this.localOutputMuted, required this.localOutputMuted,
required this.hasJoinPending, required this.hasJoinPending,
required this.canJoinVoiceChannel, required this.canJoinVoiceChannel,
required this.unreadChannelIds,
required this.onJoinChannel, required this.onJoinChannel,
required this.onJoinChannelWithPassword, required this.onJoinChannelWithPassword,
this.enableClientLongPressMenu = false, this.enableClientLongPressMenu = false,
this.onOpenClientInfo, this.onOpenClientInfo,
this.onOpenClientChat, this.onOpenClientChat,
this.onOpenClientPoke, this.onOpenClientPoke,
this.onOpenChannelChat,
this.onTs3ServerLink, this.onTs3ServerLink,
}); });
@@ -56,6 +58,9 @@ class SnapshotView extends StatefulWidget {
/// True when the local client may join voice channels. /// True when the local client may join voice channels.
final bool canJoinVoiceChannel; final bool canJoinVoiceChannel;
/// Set of channel IDs that have unread chat messages.
final Set<BigInt> unreadChannelIds;
/// Join an unlocked channel. /// Join an unlocked channel.
final ValueChanged<rust.BridgeChannel> onJoinChannel; final ValueChanged<rust.BridgeChannel> onJoinChannel;
@@ -74,6 +79,9 @@ class SnapshotView extends StatefulWidget {
/// Open a poke composer for a non-self client. /// Open a poke composer for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientPoke; final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
/// Open chat for a channel.
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
/// Handle TeamSpeak server links embedded in server-provided text. /// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink; final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -240,47 +248,64 @@ class _SnapshotViewState extends State<SnapshotView> {
); );
} }
return InkWell( return _ChannelContextMenu(
onTap: onTap, channel: channel,
child: ConstrainedBox( onChat: widget.onOpenChannelChat != null
constraints: const BoxConstraints(minHeight: 40), ? () => widget.onOpenChannelChat!(channel)
child: Row( : null,
children: [ child: InkWell(
SizedBox(width: channelIndent), onTap: onTap,
_expandButton( child: ConstrainedBox(
theme, constraints: const BoxConstraints(minHeight: 40),
hasVisibleChildren: hasVisibleChildren, child: Row(
expanded: expanded, children: [
onPressed: onToggleExpanded, SizedBox(width: channelIndent),
), _expandButton(
SizedBox( theme,
width: _channelIconColumnWidth, hasVisibleChildren: hasVisibleChildren,
child: Align( expanded: expanded,
alignment: Alignment.centerLeft, onPressed: onToggleExpanded,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
), ),
), SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.unreadChannelIds.contains(channel.id)) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
), ),
const SizedBox(width: _channelTextGap), ),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
), ),
),
); );
} }
@@ -1082,3 +1107,60 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
); );
} }
} }
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
/// long-press. Primary tap passes through to the child for voice join.
class _ChannelContextMenu extends StatelessWidget {
const _ChannelContextMenu({
required this.channel,
this.onChat,
required this.child,
});
final rust.BridgeChannel channel;
final VoidCallback? onChat;
final Widget child;
@override
Widget build(BuildContext context) {
if (onChat == null) return child;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) =>
_show(context, details.globalPosition),
onLongPressStart: (details) =>
_show(context, details.globalPosition),
child: child,
);
}
void _show(BuildContext context, Offset globalPosition) {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = RelativeRect.fromLTRB(
globalPosition.dx,
globalPosition.dy,
overlay.size.width - globalPosition.dx,
overlay.size.height - globalPosition.dy,
);
showMenu<String>(
context: context,
position: position,
items: [
PopupMenuItem(
value: 'chat',
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 18),
const SizedBox(width: 12),
Text(AppL10n.of(context).chatAction),
],
),
),
],
).then((value) {
if (value == 'chat') onChat?.call();
});
}
}
@@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget {
required this.onConfigure, required this.onConfigure,
required this.onPttHeldChanged, required this.onPttHeldChanged,
this.talkPowerBlocked = false, this.talkPowerBlocked = false,
this.inputLevel,
}); });
final bool inChannel; final bool inChannel;
@@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget {
/// level meter. Pass `null` to render an idle meter. /// level meter. Pass `null` to render an idle meter.
final rust.BridgeAudioStats? audioStats; final rust.BridgeAudioStats? audioStats;
/// Real-time input level from the 30 Hz stream (dBFS).
/// When non-null, takes precedence over `audioStats.inputLevel`.
final double? inputLevel;
/// PTT capability badge inputs — passed through to /// PTT capability badge inputs — passed through to
/// [`PttCapabilityBadge`]. /// [`PttCapabilityBadge`].
final String pttLevel; final String pttLevel;
@@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
// Row 4: level meter // Row 4: level meter
VoiceLevelMeter(active: levelActive), VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
const SizedBox(height: 4), const SizedBox(height: 4),
if (stats != null) if (stats != null)
Text( Text(
@@ -7,7 +7,7 @@
// release-tail are surfaced inline (radio buttons + slider) inside // release-tail are surfaced inline (radio buttons + slider) inside
// the modal. // the modal.
import 'dart:async' show Timer, unawaited; import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
@@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats, required this.audioStats,
required this.isTouchOnly, required this.isTouchOnly,
required this.onTap, required this.onTap,
required this.onToggleInputMute,
required this.onToggleOutputMute,
this.inputMuted = false, this.inputMuted = false,
this.outputMuted = false, this.outputMuted = false,
this.hardMuteByTalkPower = false,
this.talkPower, this.talkPower,
this.neededTalkPower, this.neededTalkPower,
this.talkPowerGranted, this.talkPowerGranted,
@@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget {
/// True when local speaker is muted. /// True when local speaker is muted.
final bool outputMuted; final bool outputMuted;
/// True when the server talk-power gate forces local hard mute.
final bool hardMuteByTalkPower;
/// Own client's talk power. /// Own client's talk power.
final int? talkPower; final int? talkPower;
@@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget {
/// Open the voice details modal. /// Open the voice details modal.
final VoidCallback onTap; final VoidCallback onTap;
/// Toggle local input hard mute.
final VoidCallback onToggleInputMute;
/// Toggle local output mute/deafen.
final VoidCallback onToggleOutputMute;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget {
); );
return Semantics( return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
hint: l10n.voiceSettingsTitle,
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: InkWell( child: Container(
onTap: () { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
HapticFeedback.lightImpact(); decoration: BoxDecoration(
onTap(); color: summary.talkPowerBlocked
}, ? Colors.amber.withValues(alpha: 0.18)
borderRadius: BorderRadius.circular(12), : summary.muted
child: ExcludeSemantics( ? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
child: Container( : theme.colorScheme.surfaceContainerHigh,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), borderRadius: BorderRadius.circular(12),
decoration: BoxDecoration( border: Border.all(
color: summary.talkPowerBlocked color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18) ? Colors.amber.shade700
: summary.muted : summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35) ? theme.colorScheme.error
: theme.colorScheme.surfaceContainerHigh, : theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(12), width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
border: Border.all( ),
color: summary.talkPowerBlocked ),
? Colors.amber.shade700 child: Row(
: summary.muted children: [
? theme.colorScheme.error Icon(
: theme.colorScheme.outlineVariant, summary.micOn
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, ? Icons.fiber_manual_record
), : Icons.fiber_manual_record_outlined,
size: 12,
color: summary.micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
), ),
child: Row( const SizedBox(width: 8),
children: [ Expanded(
Icon( child: InkWell(
summary.micOn onTap: () {
? Icons.fiber_manual_record HapticFeedback.lightImpact();
: Icons.fiber_manual_record_outlined, onTap();
size: 12, },
color: summary.micOn borderRadius: BorderRadius.circular(8),
? theme.colorScheme.primary child: Padding(
: theme.colorScheme.outline, padding: const EdgeInsets.symmetric(vertical: 2),
),
const SizedBox(width: 8),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 8), ),
Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
), ),
), const SizedBox(width: 4),
IconButton(
tooltip: hardMuteByTalkPower
? l10n.voiceTalkPowerBlocked
: l10n.voiceHardMuteLabel,
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
color: inputMuted ? theme.colorScheme.error : null,
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
color: outputMuted ? theme.colorScheme.error : null,
onPressed: onToggleOutputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceSettingsTitle,
icon: Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: onTap,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
), ),
), ),
), ),
@@ -262,6 +318,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
playVoicePttHaptic(held); playVoicePttHaptic(held);
} }
@override
void dispose() {
if (_pressed) {
_pressed = false;
widget.onHeldChanged(false);
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -455,6 +520,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
int _rateTickCount = 0; int _rateTickCount = 0;
late final AudioProcessingConfigState _audioProcessing; late final AudioProcessingConfigState _audioProcessing;
double? _streamLevel;
StreamSubscription<double>? _levelSub;
@override @override
void initState() { void initState() {
@@ -463,8 +530,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig, widget.initialAudioConfig,
); );
// Poll audio stats at 250 ms so TX/RX counters and the level meter _levelSub = rust.inputLevelStream().listen((level) {
// update in real time while the sheet is open, independent of the parent. if (mounted) setState(() => _streamLevel = level);
});
// Poll audio stats at 250 ms so TX/RX counters update in real time
// while the sheet is open.
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try { try {
final s = await rust.audioStats(); final s = await rust.audioStats();
@@ -472,7 +543,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
setState(() { setState(() {
_stats = s; _stats = s;
_rateTickCount++; _rateTickCount++;
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) { if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent; _txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived; _rxRate = s.framesReceived - _prevReceived;
@@ -487,6 +557,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
@override @override
void dispose() { void dispose() {
_levelSub?.cancel();
_statsTimer?.cancel(); _statsTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -625,7 +696,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 12), const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats. // 4) Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive), VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
const SizedBox(height: 6), const SizedBox(height: 6),
_StatsRow( _StatsRow(
txRate: _txRate, txRate: _txRate,
@@ -1,28 +1,78 @@
import 'dart:math' show max;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces. /// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget { ///
const VoiceLevelMeter({super.key, required this.active}); /// When [level] is null (no stats available yet), falls back to [active]
/// for a binary indicator. When [level] is provided it is interpreted as
/// dBFS and mapped to a 01 fill fraction via [dbfsToFraction] (floors
/// at -60 dBFS).
class VoiceLevelMeter extends StatefulWidget {
const VoiceLevelMeter({super.key, this.active = false, this.level});
/// Binary fallback when no dBFS value is available.
final bool active; final bool active;
/// Real input level in dBFS (-120 = silence, 0 = clipping).
/// Null means stats are not yet available; [active] is used instead.
final double? level;
/// Map dBFS [-60, 0] → [0.0, 1.0].
static double dbfsToFraction(double dbfs) {
const floor = -60.0;
if (dbfs <= floor) return 0.0;
if (dbfs >= 0.0) return 1.0;
return (dbfs - floor) / -floor;
}
@override
State<VoiceLevelMeter> createState() => _VoiceLevelMeterState();
}
class _VoiceLevelMeterState extends State<VoiceLevelMeter> {
double _previousFill = 0.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final double fill;
final Color color;
if (widget.level != null) {
fill = VoiceLevelMeter.dbfsToFraction(widget.level!);
color = fill > 0.0
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
} else {
fill = widget.active ? 0.75 : 0.05;
color = widget.active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
}
final begin = _previousFill;
_previousFill = fill;
return Container( return Container(
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: FractionallySizedBox( child: TweenAnimationBuilder<double>(
alignment: AlignmentDirectional.centerStart, tween: Tween<double>(begin: begin, end: fill),
widthFactor: active ? 0.75 : 0.05, duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
builder: (context, animatedFill, child) {
return FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: max(animatedFill, 0.02),
child: child,
);
},
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: active color: color,
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"
// Mirror Flutter-Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -1,2 +1,11 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig"
// macOS Release defaults to DEAD_CODE_STRIPPING = YES. See
// ios/Flutter/Release.xcconfig for the full rationale; -u is the
// load-bearing flag, -exported_symbol re-exports for dlsym, both
// are intentionally present per @_cdecl symbol.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// See ios/Flutter/Release.xcconfig for the STRIP_STYLE rationale.
STRIP_STYLE = non-global
@@ -1 +0,0 @@
Versions/Current/Resources
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>MacOSX</string></array>
<key>MinimumOSVersion</key><string>10.15</string>
</dict>
</plist>
@@ -1 +0,0 @@
Versions/Current/chanora_bridge
+1 -38
View File
@@ -1,57 +1,20 @@
PODS: PODS:
- audio_session (0.0.1):
- FlutterMacOS
- chanora_bridge (1.0.0) - chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`) - chanora_bridge (from `/Users/edison/dev/chanora/apps/chanora_flutter/macos`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
chanora_bridge: chanora_bridge:
:path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos" :path: "/Users/edison/dev/chanora/apps/chanora_flutter/macos"
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
@@ -32,6 +32,7 @@
45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; }; 45F255D1DE0134185DB5423D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */; };
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; }; 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */; };
C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; }; C2DC22E19FDCE26B9D79442E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -93,6 +94,7 @@
ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; ED6F5EE0C5FD4DA22D79188C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EFEADEEFAB54DFEAAD7A70E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FDE04BBB936AA14C2B58FA0E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C6000042DD0000000000001 /* SileroCoreML in Frameworks */, 8C6000042DD0000000000001 /* SileroCoreML in Frameworks */,
9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */, 9B86175918197ECC64969956 /* Pods_Runner.framework in Frameworks */,
); );
@@ -170,6 +173,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = { 33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@@ -244,6 +248,7 @@
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */, 4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */, 33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EA2044A3C60003C045 /* Frameworks */,
CA110002000000000000A200 /* Verify Silero Exports */,
33CC10EB2044A3C60003C045 /* Resources */, 33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */, 33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */, 3399D490228B24CF009A79C7 /* ShellScript */,
@@ -256,6 +261,7 @@
); );
name = Runner; name = Runner;
packageProductDependencies = ( packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C6000032DD0000000000001 /* SileroCoreML */, 8C6000032DD0000000000001 /* SileroCoreML */,
); );
productName = Runner; productName = Runner;
@@ -302,6 +308,7 @@
); );
mainGroup = 33CC10E42044A3C60003C045; mainGroup = 33CC10E42044A3C60003C045;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */, 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
); );
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
@@ -435,6 +442,21 @@
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";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
CA110002000000000000A200 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -865,6 +887,10 @@
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = ../../../silero-coreml; relativePath = ../../../silero-coreml;
}; };
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -873,6 +899,10 @@
package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */; package = 8C6000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML; productName = SileroCoreML;
}; };
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 33CC10E52044A3C60003C045 /* Project object */; rootObject = 33CC10E52044A3C60003C045 /* Project object */;
@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "chanora_flutter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -7,6 +7,10 @@ class AppDelegate: FlutterAppDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) { override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification) super.applicationDidFinishLaunching(notification)
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// Ask for microphone access on launch rather than on first // Ask for microphone access on launch rather than on first
// voice-channel join. Matches user expectations for a voice // voice-channel join. Matches user expectations for a voice
// chat client and saves the user from a surprising prompt // chat client and saves the user from a surprising prompt
@@ -2,8 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Flutter default: app-sandbox + JIT for the Dart VM + server <!-- Flutter default: app-sandbox + JIT for the Dart VM. network.server is
listener for `flutter run` hot-reload. --> required for the `flutter run` hot-reload listener AND, on macOS, for
every UDP bind() the app does (tsclientlib binds 0.0.0.0:0 for
outbound TS3 traffic and the sandbox treats that as a server op). -->
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
@@ -22,6 +22,8 @@
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
@@ -40,5 +42,9 @@
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string> <string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string> <string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
<key>NSBonjourServices</key>
<array>
<string>_ts3._tcp</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,106 @@
import Cocoa
import CoreAudio
import FlutterMacOS
import os.log
// ---------------------------------------------------------------------------
// MacOSAudioLifecycle
//
// Native-side MethodChannel handler for macOS audio lifecycle events.
// Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via
// AVAudioSession (no AVAudioSession equivalent on macOS). The macOS
// equivalents of the iOS lifecycle events are:
//
// * Default audio device change Core Audio HAL default-input /
// default-output device property listeners (mirrors iOS route
// change). Posted as `handleDefaultDeviceChange` with payload
// `{role: 'input' | 'output'}`.
//
// * VPIO AudioUnit configuration change observed via the VPIO
// unit's `kAudioUnitProperty_StreamFormat` property listener.
// Posted as `handleConfigurationChange` with no payload.
//
// Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the
// matching `chanora/ios_audio_lifecycle`-shaped event surface in
// `audio_lifecycle_service.dart` and currently logs the events; the
// engine-restart call (FRB `macos_default_device_changed`) is a
// follow-up.
//
// Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311
// (the deferred macOS audio-lifecycle platform-adapter allocation).
// ---------------------------------------------------------------------------
private let kLogTag = "chanora_flutter.macos_audio_lifecycle"
final class MacOSAudioLifecycle: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel?
private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:]
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "chanora/macos_audio_lifecycle",
binaryMessenger: registrar.messenger
)
let instance = MacOSAudioLifecycle()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
instance.startObserving()
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// macOS audio lifecycle is a one-way push: Swift Dart.
// The Dart side never invokes methods on this channel.
result(FlutterMethodNotImplemented)
}
// MARK: - Core Audio HAL listeners
private func startObserving() {
registerDefaultDeviceListener(role: "output")
registerDefaultDeviceListener(role: "input")
}
private func registerDefaultDeviceListener(role: String) {
let selector: AudioObjectPropertySelector = (role == "input")
? kAudioHardwarePropertyDefaultInputDevice
: kAudioHardwarePropertyDefaultOutputDevice
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject)
let channel = self.channel
let block: AudioObjectPropertyListenerBlock = { _, _ in
os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role])
}
let status = AudioObjectAddPropertyListener(objectID, &address, block, nil)
if status == noErr {
listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block
os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
} else {
os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)",
log: OSLog(subsystem: kLogTag, category: "lifecycle"),
type: .error, role, Int(status))
}
}
deinit {
// Best-effort cleanup; AudioObjectRemovePropertyListener only
// matters if the system still holds the block.
for (id, block) in listenerBlocks {
for selector in [
kAudioHardwarePropertyDefaultInputDevice,
kAudioHardwarePropertyDefaultOutputDevice,
] {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
_ = AudioObjectRemovePropertyListener(id, &address, block, nil)
}
}
}
}
@@ -1,5 +1,387 @@
import Cocoa import Cocoa
import FlutterMacOS import FlutterMacOS
import CoreGraphics
import Network
import UserNotifications
// ---------------------------------------------------------------------------
// MacOSPermissionsHandler
//
// Native-side MethodChannel handler for macOS-specific permissions
// that the Flutter `permission_handler` plugin does not cover:
//
// Input Monitoring (CGPreflightListenEventAccess /
// CGRequestListenEventAccess) for global PTT.
// Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+
// shows the Local Network Privacy prompt.
// Notifications (UNUserNotificationCenter authorization).
//
// Channel name: `app.chanora/macos_permissions`
//
// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
// ---------------------------------------------------------------------------
/// Polling interval for Input Monitoring state changes.
/// TCC does not emit a callback when the user toggles Input Monitoring
/// in System Settings, so we poll at a reasonable cadence.
private let kInputMonitoringPollInterval: TimeInterval = 2.0
class MacOSPermissionsHandler: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel?
private var inputMonitoringTimer: Timer?
private var lastInputMonitoringState: String = "Unknown"
// -- FlutterPlugin -------------------------------------------------------
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "app.chanora/macos_permissions",
binaryMessenger: registrar.messenger
)
let instance = MacOSPermissionsHandler()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
// -- Input Monitoring ---------------------------------------------------
case "checkInputMonitoring":
result(inputMonitoringStateString())
startInputMonitoringPolling()
case "requestInputMonitoring":
requestInputMonitoring(result: result)
case "openInputMonitoringSettings":
openInputMonitoringSettings(result: result)
// -- Local Network ------------------------------------------------------
case "checkLocalNetwork":
checkLocalNetwork(result: result)
case "triggerLocalNetworkPrompt":
triggerLocalNetworkPrompt(result: result)
case "checkLocalNetworkAccess":
guard let args = call.arguments as? [String: Any],
let host = args["host"] as? String,
let port = args["port"] as? Int else {
result(FlutterError(
code: "INVALID_ARGS",
message: "checkLocalNetworkAccess requires host (String) and port (Int)",
details: nil))
return
}
checkLocalNetworkAccess(host: host, port: port, result: result)
// -- Notifications ------------------------------------------------------
case "checkNotifications":
checkNotifications(result: result)
case "requestNotifications":
requestNotifications(result: result)
default:
result(FlutterMethodNotImplemented)
}
}
// =========================================================================
// Input Monitoring
// =========================================================================
/// Returns the current Input Monitoring state as a string
/// consumable by the Dart side: "Granted", "Denied", "NotDetermined".
private func inputMonitoringStateString() -> String {
// CGPreflightListenEventAccess returns true when access is already
// granted. On macOS 10.15+ it returns false when denied or not yet
// determined we cannot distinguish those two without attempting
// CGRequestListenEventAccess, so we conservatively report
// "NotDetermined" when preflight returns false. The Dart side
// treats both "Denied" and "NotDetermined" as L0Focused.
if CGPreflightListenEventAccess() {
return "Granted"
}
return "NotDetermined"
}
/// Request Input Monitoring permission via
/// `CGRequestListenEventAccess()`. On macOS 13+ this opens
/// System Settings Privacy & Security Input Monitoring.
private func requestInputMonitoring(result: @escaping FlutterResult) {
// CGRequestListenEventAccess shows the system prompt.
// It returns true if access was already granted or becomes
// granted synchronously (rare). Most of the time it returns
// false and the user must toggle the switch manually.
let granted = CGRequestListenEventAccess()
let state = granted ? "Granted" : "NotDetermined"
lastInputMonitoringState = state
result(state)
// Start polling so we detect when the user grants in Settings.
startInputMonitoringPolling()
}
/// Open System Settings Privacy & Security Input Monitoring
/// so the user can manually enable the app.
private func openInputMonitoringSettings(result: @escaping FlutterResult) {
if let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
) {
NSWorkspace.shared.open(url)
}
result(nil)
}
/// Start a periodic timer that checks Input Monitoring state and
/// notifies the Dart side when it changes.
private func startInputMonitoringPolling() {
// Don't start a second timer if one is already running.
guard inputMonitoringTimer == nil else { return }
lastInputMonitoringState = inputMonitoringStateString()
inputMonitoringTimer = Timer.scheduledTimer(
withTimeInterval: kInputMonitoringPollInterval,
repeats: true
) { [weak self] _ in
self?.pollInputMonitoring()
}
}
private func pollInputMonitoring() {
let current = inputMonitoringStateString()
guard current != lastInputMonitoringState else { return }
lastInputMonitoringState = current
// Notify the Dart side via the inbound method.
channel?.invokeMethod("inputMonitoringStateChanged", arguments: [
"state": current,
])
}
// =========================================================================
// Local Network
// =========================================================================
/// Check whether Local Network access is available.
/// On macOS 14 and earlier, Local Network Privacy does not exist,
/// so we report "Unsupported". On macOS 15+, we attempt a brief
/// NWBrowser scan and report based on the result.
private func checkLocalNetwork(result: @escaping FlutterResult) {
if #available(macOS 15.0, *) {
// We cannot synchronously determine the Local Network state
// without actually using the network. Report "NotDetermined"
// and let triggerLocalNetworkPrompt resolve the actual state.
result("NotDetermined")
} else {
result("Unsupported")
}
}
/// Trigger the Local Network Privacy prompt by starting a brief
/// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system
/// to show the Local Network permission dialog if not already
/// determined.
///
/// The browser is started and stopped after a short scan window.
/// State changes are reported back to Dart via
/// `localNetworkStateChanged`.
@available(macOS 15.0, *)
private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) {
let bonjourType = "_ts3._tcp"
let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
type: bonjourType, domain: nil)
let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp)
var resolved = false
browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in
switch browserState {
case .ready:
// Browser started successfully local network is accessible.
if !resolved {
resolved = true
result("Granted")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Granted",
])
}
browser.cancel()
case .failed(let error):
if !resolved {
resolved = true
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
// This is the canonical signal that the user denied the Local Network prompt.
if case .dns(let dnsError) = error,
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
result("Denied")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Denied",
])
} else {
// Network unreachable or other transient error
// don't assume denied.
result("NotDetermined")
}
}
browser.cancel()
case .waiting(let error):
// The browser is waiting for network. If the specific DNS error
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
// the Local Network prompt report immediately.
if case .dns(let dnsError) = error,
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
if !resolved {
resolved = true
result("Denied")
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
"state": "Denied",
])
}
browser.cancel()
}
// Otherwise the system dialog may be showing wait for
// .ready, .failed, or the timeout.
case .setup, .cancelled:
break
@unknown default:
break
}
}
browser.start(queue: DispatchQueue.main)
// Timeout: if the browser doesn't resolve within 10 seconds,
// report NotDetermined so the Dart side doesn't hang forever.
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
if !resolved {
resolved = true
result("NotDetermined")
browser.cancel()
}
}
}
private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) {
if #available(macOS 15.0, *) {
triggerLocalNetworkPromptImpl(result: result)
} else {
result("Unsupported")
}
}
/// Probe whether Local Network access is currently denied for a specific
/// host:port by creating a short-lived NWConnection and checking
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
/// new system prompt it is a read-only check.
private func checkLocalNetworkAccess(
host: String, port: Int, result: @escaping FlutterResult
) {
if #available(macOS 15.0, *) {
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
} else {
result("Unsupported")
}
}
@available(macOS 15.0, *)
private func checkLocalNetworkAccessImpl(
host: String, port: Int, result: @escaping FlutterResult
) {
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
result("NotDetermined")
return
}
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(
host: endpointHost, port: endpointPort, using: .tcp)
let queue = DispatchQueue(
label: "app.chanora.macos_permissions.local_network_check")
var didComplete = false
func finish(_ state: String) {
guard !didComplete else { return }
didComplete = true
connection.cancel()
result(state)
}
connection.stateUpdateHandler = { state in
switch state {
case .waiting, .failed:
if connection.currentPath?.unsatisfiedReason
== .localNetworkDenied {
finish("Denied")
} else {
finish("NotDetermined")
}
case .ready:
finish("Granted")
case .cancelled:
finish("NotDetermined")
case .setup, .preparing:
break
@unknown default:
break
}
}
connection.start(queue: queue)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
if !didComplete {
if connection.currentPath?.unsatisfiedReason
== .localNetworkDenied {
finish("Denied")
} else {
finish("NotDetermined")
}
}
}
}
// =========================================================================
// Notifications
// =========================================================================
private func checkNotifications(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized, .provisional:
result("Granted")
case .denied:
result("Denied")
case .notDetermined:
result("NotDetermined")
@unknown default:
result("NotDetermined")
}
}
}
private func requestNotifications(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [
.alert, .sound, .badge,
]) { granted, _ in
let state = granted ? "Granted" : "Denied"
result(state)
}
}
// =========================================================================
// Cleanup
// =========================================================================
deinit {
inputMonitoringTimer?.invalidate()
}
}
// ---------------------------------------------------------------------------
// MainFlutterWindow
// ---------------------------------------------------------------------------
class MainFlutterWindow: NSWindow { class MainFlutterWindow: NSWindow {
override func awakeFromNib() { override func awakeFromNib() {
@@ -15,6 +397,11 @@ class MainFlutterWindow: NSWindow {
RegisterGeneratedPlugins(registry: flutterViewController) RegisterGeneratedPlugins(registry: flutterViewController)
// Register the macOS permissions MethodChannel handler.
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
// Register the macOS audio-lifecycle MethodChannel handler (closes the iOS/macOS asymmetry in SysRS-051).
MacOSAudioLifecycle.register(with: flutterViewController.registrar(forPlugin: "MacOSAudioLifecycle"))
super.awakeFromNib() super.awakeFromNib()
} }
} }
@@ -2,12 +2,26 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Release builds omit the cs.allow-jit + network.server entitlements <!-- Release builds omit cs.allow-jit (Flutter hot-reload only). They keep
used only by Flutter's debug hot-reload. --> network.server because the macOS App Sandbox classifies UDP bind()
against a local port - including the ephemeral 0.0.0.0:0 that
tsclientlib's tokio::net::UdpSocket::bind() issues for outbound
voice traffic - as a server operation that requires
com.apple.security.network.server, regardless of whether the
socket is later used only to sendto() a remote peer. Without it,
bind() returns EPERM and the sandbox log records
"Sandbox: chanora(...) deny(1) network-bind". network.client
alone gates outbound connect()-style flows (TCP, connected UDP)
and is insufficient for the bind()-then-sendto() pattern Tokio's
UdpSocket uses. See Apple's App Sandbox entitlement reference:
"Network Server" covers any process that listens on, or binds
to, a network port. -->
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
</dict> </dict>
@@ -1,4 +1,5 @@
import CoreML import CoreML
import Darwin
import Foundation import Foundation
import SileroCoreML import SileroCoreML
@@ -96,3 +97,136 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return } guard let string else { return }
free(string) free(string)
} }
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
// Static linker references: keep the Swift compiler / linker from
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
// as a static reference for the dead-stripper these `_ = ` lines
// do. Without them, TestFlight builds shipped without the symbols
// even though Debug builds (no LTO) worked.
//
// The `withoutActuallyEscaping` dance prevents the optimizer from
// proving the references are unused: assigning the function value
// to a `@convention(c)` typealias forces address-taken semantics.
_ = unsafeBitCast(
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadProcess
as @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
UnsafeMutablePointer<Float>?
) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
to: UnsafeRawPointer.self,
)
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -52,19 +52,25 @@ Pod::Spec.new do |s|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin" echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT" cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:$PATH" \\ PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\ MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\ CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\ LIBOPUS_STATIC=1 \\
LIBOPUS_NO_PKG=1 \\ LIBOPUS_NO_PKG=1 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
cargo build --release --target aarch64-apple-darwin -p chanora_bridge cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin" echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:$PATH" \\ PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\ MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\ CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\ LIBOPUS_STATIC=1 \\
LIBOPUS_NO_PKG=1 \\ LIBOPUS_NO_PKG=1 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
cargo build --release --target x86_64-apple-darwin -p chanora_bridge cargo build --release --target x86_64-apple-darwin -p chanora_bridge
if [ ! -f "$BRIDGE_ARM64" ]; then if [ ! -f "$BRIDGE_ARM64" ]; then
@@ -112,7 +118,19 @@ PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\ install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
"$FW/Versions/A/chanora_bridge" "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge.podspec] framework ready at $FW"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder). dsymutil reads
# the DWARF that cargo emitted (enabled by [profile.release]
# debug = true at the workspace root) and writes the bundle next
# to the framework. We then strip the in-framework binary so the
# shipped app stays slim; symbols live in the dSYM bundle, which
# is the layout xcodebuild -exportArchive and notarisation expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
SCRIPT SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into # Pod CocoaPods picks this up; the framework gets embedded into
@@ -141,44 +159,54 @@ PLIST
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin" echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT" cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:$PATH" \ PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \ MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \ CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \ LIBOPUS_STATIC=1 \
LIBOPUS_NO_PKG=1 \ LIBOPUS_NO_PKG=1 \
CARGO_PROFILE_RELEASE_DEBUG=true \
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
CARGO_PROFILE_RELEASE_STRIP=false \
cargo build --release --target aarch64-apple-darwin -p chanora_bridge cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin" echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:$PATH" \ PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \ MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \ CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \ LIBOPUS_STATIC=1 \
LIBOPUS_NO_PKG=1 \ LIBOPUS_NO_PKG=1 \
CARGO_PROFILE_RELEASE_DEBUG=true \
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
CARGO_PROFILE_RELEASE_STRIP=false \
cargo build --release --target x86_64-apple-darwin -p chanora_bridge cargo build --release --target x86_64-apple-darwin -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/macos" cd "$REPO_ROOT/apps/chanora_flutter/macos"
FW=Frameworks/chanora_bridge.framework FW=Frameworks/chanora_bridge.framework
FW_UP_TO_DATE=0
# Skip the wrap step if the framework's binary is already # Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental # up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). # builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
if [ -f "$FW/Versions/A/chanora_bridge" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_ARM64" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_X86_64" ]; then if [ -f "$FW/Versions/A/chanora_bridge" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_ARM64" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_X86_64" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date" echo "[chanora_bridge script_phase] framework already up-to-date"
exit 0 FW_UP_TO_DATE=1
fi fi
# Create universal binary with lipo. if [ "$FW_UP_TO_DATE" = 0 ]; then
mkdir -p "$(dirname "$UNIVERSAL")" # Create universal binary with lipo.
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL" mkdir -p "$(dirname "$UNIVERSAL")"
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
rm -rf "$FW" rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources" mkdir -p "$FW/Versions/A/Resources"
ln -sfh A "$FW/Versions/Current" ln -sfh A "$FW/Versions/Current"
ln -sfh Versions/Current/Resources "$FW/Resources" ln -sfh Versions/Current/Resources "$FW/Resources"
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge" cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge" ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -195,9 +223,24 @@ PLIST
</plist> </plist>
PLIST PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \ install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
"$FW/Versions/A/chanora_bridge" "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed" rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). See the iOS podspec for the full
# rationale — same constraint applies to macOS notarisation
# and archive-based distribution.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
SCRIPT SCRIPT
:execution_position => :before_compile, :execution_position => :before_compile,
} }
+4 -4
View File
@@ -457,10 +457,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -790,10 +790,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# verify_silero_exports.sh
#
# Asserts that all six chanora_silero_vad_* C symbols that the Rust
# chanora_bridge framework resolves via dlsym(RTLD_DEFAULT) are present
# in the linked app binary's dynamic export table — verified per
# architecture slice for macOS universal builds, because `nm` on a
# universal Mach-O without -arch will succeed if a symbol exists in ANY
# slice, not every slice. A missing symbol in just the x86_64 slice
# would silently break Intel Macs.
#
# Without this check, Xcode Archive's -dead_strip can remove these
# Swift @_cdecl symbols (no Swift caller exists) and CoreML VAD falls
# back to WebRTC on TestFlight/App Store with no compile-time,
# link-time, or runtime warning. We hit that bug once; this script
# ensures we never ship it again.
#
# Runs as an Xcode build phase after Link Binary, on iOS and macOS.
set -e
if [ -z "${TARGET_BUILD_DIR}" ] || [ -z "${EXECUTABLE_PATH}" ]; then
echo "error: verify_silero_exports.sh requires TARGET_BUILD_DIR and EXECUTABLE_PATH (run from Xcode build phase)" >&2
exit 1
fi
BINARY="${TARGET_BUILD_DIR}/${EXECUTABLE_PATH}"
if [ ! -f "${BINARY}" ]; then
echo "error: app binary not found at ${BINARY}" >&2
exit 1
fi
REQUIRED_SYMBOLS="
_chanora_silero_vad_create
_chanora_silero_vad_destroy
_chanora_silero_vad_reset
_chanora_silero_vad_process
_chanora_silero_vad_last_error
_chanora_silero_vad_free_string
"
# Enumerate slices. `lipo -archs` prints arches space-separated for
# universal Mach-O; for thin binaries it prints the single arch.
ARCHS=$(xcrun lipo -archs "${BINARY}" 2>/dev/null || echo "")
if [ -z "${ARCHS}" ]; then
echo "error: xcrun lipo -archs failed for ${BINARY}; cannot enumerate slices" >&2
exit 1
fi
FAILED=0
for arch in ${ARCHS}; do
EXPORTED=$(xcrun nm -arch "${arch}" -gU "${BINARY}" 2>/dev/null | awk '{print $NF}')
if [ -z "${EXPORTED}" ]; then
echo "error: xcrun nm produced no output for arch=${arch} on ${BINARY}" >&2
FAILED=1
continue
fi
MISSING=""
for sym in ${REQUIRED_SYMBOLS}; do
if ! echo "${EXPORTED}" | grep -qx "${sym}"; then
MISSING="${MISSING} ${sym}"
fi
done
if [ -n "${MISSING}" ]; then
echo "error: chanora_silero_vad exports missing from $(basename "${BINARY}") [arch=${arch}]:${MISSING}" >&2
FAILED=1
fi
done
if [ "${FAILED}" -ne 0 ]; then
echo "error: Rust dlsym(RTLD_DEFAULT) resolution will fail and CoreML VAD will fall back to WebRTC." >&2
echo "error: Check OTHER_LDFLAGS -exported_symbol entries in Flutter/*.xcconfig and the @_cdecl exports in Runner/SileroCoreMLBridge.swift." >&2
exit 1
fi
echo "verify_silero_exports: all 6 chanora_silero_vad_* symbols present in $(basename "${BINARY}") [arches: ${ARCHS}]"
@@ -4,7 +4,7 @@ import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust; import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() { void main() {
test('parseBridgeAudioRoute maps platform route names', () { test('parseBridgeAudioRoute maps iOS-classified route strings', () {
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece); expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker); expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
expect( expect(
@@ -22,4 +22,34 @@ void main() {
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown); expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown); expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
}); });
test('parseBridgeAudioRoute maps Android UsbHeadset to wiredHeadset', () {
// AndroidAudioLifecycleController.classifyDevice emits 'UsbHeadset' for
// AudioDeviceInfo.TYPE_USB_HEADSET. USB audio is functionally a
// wired-class device — the Kotlin classifier's own preference ordering
// (line 176 of AndroidAudioLifecycleController.kt) groups it with
// WiredHeadset/BluetoothHfp/BluetoothA2dp.
expect(parseBridgeAudioRoute('UsbHeadset'),
rust.BridgeAudioRoute.wiredHeadset);
});
test('parseBridgeAudioRoute maps Android Hdmi to unknown', () {
// HDMI is a display-out transport, not a voice-call audio path; no
// existing BridgeAudioRoute variant fits. Treat as unknown rather
// than misclassify as Speaker.
expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown);
});
test('wireMacosAudioLifecycle is a no-op on non-macOS and registers on macOS', () {
// MethodChannel needs a binary messenger, which requires the test
// binding to be initialised first.
TestWidgetsFlutterBinding.ensureInitialized();
// Channel name constant matches the Swift side (MacOSAudioLifecycle.swift).
expect(macosAudioLifecycleChannelName, 'chanora/macos_audio_lifecycle');
// The wire is a no-op on the test platform (CI defaults to host OS
// which may be macOS or Linux). On Linux it returns early; on macOS
// it installs a handler. Either way, it must not throw.
expect(() => wireMacosAudioLifecycle(), returnsNormally);
});
} }
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/hard_mute_owners.dart';
void main() {
group('HardMuteOwners', () {
test('manual mute survives talk-power block and restore', () {
const owners = HardMuteOwners(manual: true);
final blocked = owners.copyWith(talkPower: true);
expect(blocked.effective, isTrue);
final restored = blocked.copyWith(talkPower: false);
expect(restored.manual, isTrue);
expect(restored.talkPower, isFalse);
expect(restored.effective, isTrue);
});
test('effective mute is the union of independent owners', () {
expect(const HardMuteOwners().effective, isFalse);
expect(const HardMuteOwners(manual: true).effective, isTrue);
expect(const HardMuteOwners(permission: true).effective, isTrue);
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
});
test('bridge mute does not convert talk-power owner into manual owner', () {
const owners = HardMuteOwners(talkPower: true);
final synced = owners.withBridgeManualMute(true);
expect(synced.manual, isFalse);
expect(synced.talkPower, isTrue);
expect(synced.effective, isTrue);
});
});
}
@@ -0,0 +1,99 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('IosAudioSessionController', () {
const channel = MethodChannel(iosAudioSessionChannelName);
final messenger = TestDefaultBinaryMessengerBinding
.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('channel name matches Swift contract', () {
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
});
test('activate invokes activateVoiceSession on iOS', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await controller.activate();
expect(calls.map((c) => c.method), ['activateVoiceSession']);
expect(calls.single.arguments, isNull);
});
test('deactivate invokes deactivateVoiceSession on iOS', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await controller.deactivate();
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
expect(calls.single.arguments, isNull);
});
test('activate is a no-op on non-iOS platforms', () async {
var invoked = false;
messenger.setMockMethodCallHandler(channel, (call) async {
invoked = true;
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: false,
);
await controller.activate();
await controller.deactivate();
expect(invoked, isFalse);
});
test('activate swallows PlatformException so engine keeps running',
() async {
messenger.setMockMethodCallHandler(channel, (call) async {
throw PlatformException(code: 'avaudiosession_failed');
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await expectLater(controller.activate(), completes);
await expectLater(controller.deactivate(), completes);
});
test('activate swallows MissingPluginException when channel is absent',
() async {
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await expectLater(controller.activate(), completes);
await expectLater(controller.deactivate(), completes);
});
});
}
@@ -0,0 +1,586 @@
// SWE.4 unit tests for MacOSPermissionsService — the Dart-side
// integration layer for the Swift `MacOSPermissionsHandler`
// (SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091).
//
// Requirement trace:
// Verification-plan row: SWE4-UV-XXX.
// SRS-198 (Push-to-talk system permission acquisition).
// SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS).
// SysRS-166 (Desktop notifications).
// SDD-091 (PTT capability badge — live capability level).
//
// Strategy: Following the pattern in
// `android_permissions_service_test.dart`, use
// `TestDefaultBinaryMessengerBinding` to (a) capture outbound
// method invocations and (b) inject inbound state-change calls as
// if Swift had emitted them.
//
// Platform note: these tests run on macOS. The static `_isMacOS`
// check inside the service evaluates to true at field-initialization
// time, so the ValueNotifiers seed to `unknown` (not `granted`).
// This is intentional — on macOS the service must query the native
// side before it knows the real state. Tests that inject a channel
// observe `unknown` as the initial value and drive transitions from
// there. The channel-null short-circuit test documents that when
// channel is null, outbound calls are suppressed and each method
// returns its safe default.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/macos_permissions_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// Whether the test host is actually macOS — the field initializers
/// inside `MacOSPermissionsService` use `Platform.isMacOS` (not
/// injectable), so the initial state values depend on this.
final bool hostIsMacOS = !kIsWeb && Platform.isMacOS;
late MethodChannel channel;
late List<MethodCall> outgoingCalls;
/// Intercept outbound calls AFTER the service's handler is installed.
/// We store a reference so outbound invokeMethod calls can be
/// captured while inbound handlePlatformMessage calls are routed
/// to the service's handler.
Future<Object?> Function(MethodCall call)? outgoingResponder;
Future<void> sendInputMonitoringStateChanged({
required String state,
}) async {
const codec = StandardMethodCodec();
final encoded = codec.encodeMethodCall(
MethodCall(methodInputMonitoringStateChanged, <String, dynamic>{
'state': state,
}),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(channel.name, encoded, (_) {});
}
Future<void> sendLocalNetworkStateChanged({
required String state,
}) async {
const codec = StandardMethodCodec();
final encoded = codec.encodeMethodCall(
MethodCall(methodLocalNetworkStateChanged, <String, dynamic>{
'state': state,
}),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(channel.name, encoded, (_) {});
}
setUp(() {
channel = const MethodChannel(macOSPermissionsChannelName);
outgoingCalls = <MethodCall>[];
outgoingResponder = null;
// The mock handler captures outbound calls AND delegates inbound
// platform messages to the service handler when set.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
outgoingCalls.add(call);
final r = outgoingResponder;
if (r != null) return r(call);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
// ===========================================================================
// Initial state
// ===========================================================================
test(
'SWE4-UV / SRS-297: a fresh service exposes a deterministic initial '
'inputMonitoringState that matches the host platform',
() {
final svc = MacOSPermissionsService(channel: channel);
if (hostIsMacOS) {
// On macOS the service hasn't queried the native side yet.
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
expect(svc.pttCapabilityState.value, 'L0Focused');
} else {
// On non-macOS the static check short-circuits to granted.
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
}
svc.dispose();
},
);
// ===========================================================================
// Input Monitoring — inbound state changes
// ===========================================================================
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=Granted transitions inputMonitoringState and PTT capability',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Drive away from the initial value first.
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(svc.pttCapabilityState.value, 'L0Focused');
var notified = 0;
void listener() => notified++;
svc.inputMonitoringState.addListener(listener);
await sendInputMonitoringStateChanged(state: 'Granted');
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
expect(notified, greaterThanOrEqualTo(1));
svc.inputMonitoringState.removeListener(listener);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=Denied transitions to denied and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'state=NotDetermined transitions to notDetermined and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'NotDetermined');
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.notDetermined,
);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with '
'malformed state parses to unknown and L0Focused',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Start from a known baseline.
await sendInputMonitoringStateChanged(state: 'Granted');
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
await sendInputMonitoringStateChanged(state: 'Bogus');
expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown);
expect(svc.pttCapabilityState.value, 'L0Focused');
svc.dispose();
},
);
// ===========================================================================
// Local Network — inbound state changes
// ===========================================================================
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Granted transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Granted');
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Denied transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Denied');
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: inbound localNetworkStateChanged with '
'state=Unsupported transitions localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendLocalNetworkStateChanged(state: 'Unsupported');
expect(
svc.localNetworkState.value,
MacOSLocalNetworkState.unsupported,
);
svc.dispose();
},
);
// ===========================================================================
// Outbound method calls
// ===========================================================================
test(
'SWE4-UV / SRS-297: requestInputMonitoring() emits outbound '
'requestInputMonitoring MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodRequestInputMonitoring) {
return 'Granted';
}
return null;
};
final result = await svc.requestInputMonitoring();
expect(
outgoingCalls.where((c) => c.method == methodRequestInputMonitoring),
hasLength(1),
reason: 'requestInputMonitoring must be called',
);
expect(result, MacOSPermissionState.granted);
expect(svc.inputMonitoringState.value, MacOSPermissionState.granted);
expect(svc.pttCapabilityState.value, 'L1MacOSEventTap');
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() emits outbound '
'triggerLocalNetworkPrompt MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodTriggerLocalNetworkPrompt) {
return 'Granted';
}
return null;
};
final result = await svc.triggerLocalNetworkPrompt();
expect(
outgoingCalls
.where((c) => c.method == methodTriggerLocalNetworkPrompt),
hasLength(1),
reason: 'triggerLocalNetworkPrompt must be called',
);
expect(result, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() emits outbound '
'checkLocalNetworkAccess MethodCall with host and port arguments',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Denied';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: '192.168.1.42',
port: 9987,
);
final calls = outgoingCalls
.where((c) => c.method == methodCheckLocalNetworkAccess)
.toList();
expect(calls, hasLength(1));
final args = calls.single.arguments as Map;
expect(args['host'], '192.168.1.42');
expect(args['port'], 9987);
expect(result, MacOSLocalNetworkState.denied);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() parses Granted and updates '
'localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Granted';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: 'ts.example.com',
port: 9987,
);
expect(result, MacOSLocalNetworkState.granted);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
'requestNotifications MethodCall; returns the platform response',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodRequestNotifications) {
return 'Granted';
}
return null;
};
final result = await svc.requestNotifications();
expect(
outgoingCalls.where((c) => c.method == methodRequestNotifications),
hasLength(1),
reason: 'requestNotifications must be called',
);
expect(result, MacOSPermissionState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: openInputMonitoringSettings() emits outbound '
'openInputMonitoringSettings MethodCall',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await svc.openInputMonitoringSettings();
final calls = outgoingCalls
.where((c) => c.method == methodOpenInputMonitoringSettings)
.toList();
expect(calls, hasLength(1));
svc.dispose();
},
);
// ===========================================================================
// Lifecycle
// ===========================================================================
test(
'SWE4-UV / SRS-297: start() is idempotent — calling it twice '
'does not double-register the handler',
() async {
final svc = MacOSPermissionsService(channel: channel)
..start()
..start();
var notified = 0;
void listener() => notified++;
svc.inputMonitoringState.addListener(listener);
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
expect(notified, 1);
svc.inputMonitoringState.removeListener(listener);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-297: stop() removes the handler — subsequent '
'inbound messages have no effect on state',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
svc.stop();
await sendInputMonitoringStateChanged(state: 'Granted');
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.denied,
reason: 'state must be frozen after stop()',
);
},
);
// ===========================================================================
// Non-macOS short-circuit (channel is null)
// ===========================================================================
test(
'SWE4-UV / SRS-297: null-channel short-circuit — on non-macOS hosts '
'the constructor seeds to safe defaults; on macOS hosts, passing '
'channel:null still creates a real channel because _isMacOS is true',
() async {
if (!hostIsMacOS) {
// On non-macOS: the constructor's `_isMacOS` branch is false,
// so `_channel` stays null. All methods return safe defaults
// without touching any channel.
final svc = MacOSPermissionsService(channel: null);
expect(
svc.inputMonitoringState.value,
MacOSPermissionState.granted,
);
expect(
svc.localNetworkState.value,
MacOSLocalNetworkState.unsupported,
);
expect(
svc.notificationState.value,
MacOSPermissionState.granted,
);
final priorOutgoing = outgoingCalls.length;
await svc.requestInputMonitoring();
await svc.triggerLocalNetworkPrompt();
await svc.requestNotifications();
await svc.openInputMonitoringSettings();
expect(
outgoingCalls.length,
priorOutgoing,
reason: 'no outbound calls when platform is non-macOS',
);
svc.start();
svc.stop();
svc.dispose();
} else {
// On macOS: even with channel: null, the constructor creates
// a MethodChannel because _isMacOS is true. This is the
// correct production behaviour — on macOS the service always
// has a channel. The short-circuit path is unreachable on
// macOS by design.
final svc = MacOSPermissionsService(channel: null);
// The service has a non-null _channel, so methods will attempt
// to invoke the channel (which has no native handler in tests).
// Verify the service doesn't throw and returns a value.
final result = await svc.requestInputMonitoring();
expect(result, isNotNull);
svc.dispose();
}
},
);
// ===========================================================================
// Channel error handling
// ===========================================================================
test(
'SWE4-UV / SRS-297: requestInputMonitoring() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
// Seed a known state via inbound.
await sendInputMonitoringStateChanged(state: 'Denied');
expect(svc.inputMonitoringState.value, MacOSPermissionState.denied);
// Make the platform stub throw.
outgoingResponder = (call) async {
if (call.method == methodRequestInputMonitoring) {
throw PlatformException(code: 'unavailable');
}
return null;
};
final result = await svc.requestInputMonitoring();
expect(
result,
MacOSPermissionState.denied,
reason:
'on channel failure requestInputMonitoring must return cached state',
);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodTriggerLocalNetworkPrompt) {
throw PlatformException(code: 'unavailable');
}
return null;
};
final result = await svc.triggerLocalNetworkPrompt();
// Returns the cached state without crashing.
expect(result, isNotNull);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() returns cached state '
'when the channel throws',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
throw PlatformException(code: 'probe-failed');
}
return null;
};
// Probe path failures (e.g. NWConnection couldn't establish a
// listener, or the Swift side threw) must not crash callers
// — they must fall back to whatever the service already cached.
final result = await svc.checkLocalNetworkAccess(
host: '127.0.0.1',
port: 9987,
);
expect(result, isNotNull);
svc.dispose();
},
);
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/chat_panel.dart';
import 'package:chanora_flutter/widgets/chat_views.dart';
void main() {
rust.BridgeSnapshot snapshot() {
final channelId = BigInt.from(10);
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: channelId,
parent: BigInt.zero,
name: 'Lobby',
order: 0,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: [
rust.BridgeClient(
id: BigInt.one,
channel: channelId,
name: 'Me',
inputMuted: false,
outputMuted: false,
isSpeaking: false,
isServerQuery: false,
talkPower: 0,
talkPowerGranted: true,
),
],
ownClientId: BigInt.one,
);
}
testWidgets('inline chat panel renders target, messages, and close action', (
tester,
) async {
var closed = false;
final messages = [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alice',
message: 'Hello from channel',
target: const rust.BridgeMessageTarget.channel(),
),
];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatPanel(
messages: messages,
snapshot: snapshot(),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
onClose: () => closed = true,
),
),
),
);
expect(find.text('# Lobby'), findsOneWidget);
expect(find.text('Hello from channel'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(closed, isTrue);
});
testWidgets('chat detail restores target drafts when the target changes', (
tester,
) async {
String? savedDraft;
final messages = <ChatEntry>[];
Widget detail({
required rust.BridgeMessageTarget target,
required String? restoredDraft,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(),
target: target,
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
restoredDraft: restoredDraft,
onDraftChanged: (text) => savedDraft = text,
),
),
);
}
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.channel(),
restoredDraft: 'channel draft',
),
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'channel draft',
);
await tester.enterText(find.byType(TextField), 'typed channel draft');
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.server(),
restoredDraft: 'server draft',
),
);
expect(savedDraft, 'typed channel draft');
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'server draft',
);
});
testWidgets(
'chat detail propagates empty draft when the user clears it before switching target',
(tester) async {
// Regression: previously, _ChatDetailViewState only emitted
// onDraftChanged when the text was non-empty. If the user
// restored a saved draft, deleted it, then switched target,
// the stale entry stayed in the parent's draft map and
// resurrected on the next target swap.
String? savedDraft = 'sentinel-unset';
final messages = <ChatEntry>[];
Widget detail({
required rust.BridgeMessageTarget target,
required String? restoredDraft,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(),
target: target,
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
restoredDraft: restoredDraft,
onDraftChanged: (text) => savedDraft = text,
),
),
);
}
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.channel(),
restoredDraft: 'previously saved channel draft',
),
);
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'previously saved channel draft',
);
// User clears the field, then switches target.
await tester.enterText(find.byType(TextField), '');
await tester.pumpWidget(
detail(
target: const rust.BridgeMessageTarget.server(),
restoredDraft: null,
),
);
// The empty string MUST reach the parent so the stale entry
// is overwritten in the draft map. With the previous guarded
// implementation, savedDraft would still hold the sentinel.
expect(
savedDraft,
'',
reason: 'empty draft must overwrite stale entry on target swap',
);
},
);
}
@@ -52,11 +52,13 @@ void main() {
String welcomeMessage = '', String welcomeMessage = '',
BigInt? ownClientId, BigInt? ownClientId,
BigInt? currentVoiceChannelId, BigInt? currentVoiceChannelId,
Set<BigInt> unreadChannelIds = const {},
rust.BridgeAudioStats? audioStats, rust.BridgeAudioStats? audioStats,
bool enableClientLongPressMenu = false, bool enableClientLongPressMenu = false,
ValueChanged<rust.BridgeClient>? onOpenClientInfo, ValueChanged<rust.BridgeClient>? onOpenClientInfo,
ValueChanged<rust.BridgeClient>? onOpenClientChat, ValueChanged<rust.BridgeClient>? onOpenClientChat,
ValueChanged<rust.BridgeClient>? onOpenClientPoke, ValueChanged<rust.BridgeClient>? onOpenClientPoke,
ValueChanged<rust.BridgeChannel>? onOpenChannelChat,
}) { }) {
return MaterialApp( return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates, localizationsDelegates: AppL10n.localizationsDelegates,
@@ -79,12 +81,14 @@ void main() {
localOutputMuted: false, localOutputMuted: false,
hasJoinPending: false, hasJoinPending: false,
canJoinVoiceChannel: true, canJoinVoiceChannel: true,
unreadChannelIds: unreadChannelIds,
onJoinChannel: (_) {}, onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {}, onJoinChannelWithPassword: (_) {},
enableClientLongPressMenu: enableClientLongPressMenu, enableClientLongPressMenu: enableClientLongPressMenu,
onOpenClientInfo: onOpenClientInfo, onOpenClientInfo: onOpenClientInfo,
onOpenClientChat: onOpenClientChat, onOpenClientChat: onOpenClientChat,
onOpenClientPoke: onOpenClientPoke, onOpenClientPoke: onOpenClientPoke,
onOpenChannelChat: onOpenChannelChat,
), ),
), ),
); );
@@ -133,6 +137,32 @@ void main() {
); );
}); });
testWidgets('renders unread dot on channels with unread chat messages', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Lobby')],
clients: const [],
unreadChannelIds: {BigInt.one},
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.constraints?.maxWidth == 8 &&
widget.constraints?.maxHeight == 8 &&
widget.decoration is BoxDecoration &&
(widget.decoration! as BoxDecoration).shape == BoxShape.circle,
),
findsOneWidget,
);
});
testWidgets('collapsing a channel hides users and child channels', ( testWidgets('collapsing a channel hides users and child channels', (
tester, tester,
) async { ) async {
@@ -493,6 +523,7 @@ void main() {
framesSent: 1, framesSent: 1,
framesReceived: 0, framesReceived: 0,
pttActive: true, pttActive: true,
inputLevel: -30.0,
), ),
), ),
); );
@@ -549,6 +580,7 @@ void main() {
localOutputMuted: false, localOutputMuted: false,
hasJoinPending: false, hasJoinPending: false,
canJoinVoiceChannel: true, canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => tapped = channel, onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {}, onJoinChannelWithPassword: (_) {},
), ),
@@ -576,6 +608,64 @@ void main() {
expect(tapped!.neededTalkPower, 12); expect(tapped!.neededTalkPower, 12);
}); });
testWidgets('channel context menu opens chat without replacing voice join', (
tester,
) async {
final lobby = channel(id: 1, name: 'Lobby');
rust.BridgeChannel? joined;
rust.BridgeChannel? openedChat;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [lobby],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (channel) => joined = channel,
onJoinChannelWithPassword: (_) {},
onOpenChannelChat: (channel) => openedChat = channel,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Lobby'));
await tester.pump();
expect(joined, lobby);
expect(openedChat, isNull);
expect(find.text('Chat'), findsNothing);
await tester.tap(find.text('Lobby'), buttons: kSecondaryMouseButton);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsOneWidget);
await tester.tap(find.text('Chat'));
await tester.pumpAndSettle();
expect(openedChat, lobby);
});
testWidgets('separator spacers render as line painters without raw text', ( testWidgets('separator spacers render as line painters without raw text', (
tester, tester,
) async { ) async {
@@ -610,6 +700,7 @@ void main() {
localOutputMuted: false, localOutputMuted: false,
hasJoinPending: false, hasJoinPending: false,
canJoinVoiceChannel: true, canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {}, onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {}, onJoinChannelWithPassword: (_) {},
), ),
@@ -664,6 +755,7 @@ void main() {
localOutputMuted: false, localOutputMuted: false,
hasJoinPending: false, hasJoinPending: false,
canJoinVoiceChannel: true, canJoinVoiceChannel: true,
unreadChannelIds: const {},
onJoinChannel: (_) {}, onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {}, onJoinChannelWithPassword: (_) {},
), ),
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('touch PTT releases when disposed while held', (tester) async {
final heldChanges = <bool>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(
active: false,
onHeldChanged: heldChanges.add,
),
),
),
);
final center = tester.getCenter(find.byType(VoicePttButton));
final gesture = await tester.startGesture(center);
await tester.pump();
expect(heldChanges, [true]);
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
expect(heldChanges, [true, false]);
await gesture.cancel();
expect(heldChanges, [true, false]);
});
}
+1 -1
View File
@@ -18,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
chanora_prefetch = { path = "../../crates/chanora_prefetch" } chanora_prefetch = { path = "../../crates/chanora_prefetch" }
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt", "macros"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[dev-dependencies] [dev-dependencies]
# Used by integration tests to inspect the bookmark DB row layout # Used by integration tests to inspect the bookmark DB row layout
+285
View File
@@ -0,0 +1,285 @@
use chanora_audio::{AudioRoute, PttBackendDescriptor};
use chanora_protocol::MessageTarget;
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
pub(crate) fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
/// High-level lifecycle event surfaced to subscribers.
///
/// This is the minimal set needed for A.6 (reconnect banner). The
/// full event catalogue lands in A.4.
#[derive(Debug, Clone)]
pub enum SessionEvent {
/// Initial connect succeeded, or reconnect attempt succeeded.
Connected {
/// Server name reported in the snapshot.
server_name: String,
},
/// Connection lost; the supervisor will retry.
Lost {
/// Reason classification from the protocol layer.
reason: String,
},
/// Supervisor is sleeping before its next reconnect attempt.
Reconnecting {
/// 1-based attempt counter for the current outage.
attempt: u32,
/// Seconds the supervisor will sleep before this attempt.
delay_secs: u32,
},
/// Supervisor gave up after `attempt` failed retries (or the
/// user explicitly disconnected mid-outage).
Disconnected {
/// Reason classification from the protocol layer.
reason: String,
},
/// Audio engine started (e.g. after a successful reconnect with
/// reattachment).
AudioStarted,
/// Audio engine stopped (e.g. before a reconnect cycle, or by
/// explicit user action).
AudioStopped,
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
/// DEC-023..028). Published when the audio engine starts or
/// when the active backend transitions (for example macOS
/// permission state change). Carries only the privacy-safe
/// descriptor — capability level, backend identifier, bound
/// input class — per SRS-202 / DEC-027.
PttCapability {
/// Stable level name from `PttCapabilityLevel::as_str()`.
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`); empty when
/// no binding is active.
bound_input_class: String,
},
/// Voice subsystem state snapshot (SDD-094). Emitted on
/// `voice_join` / `voice_leave`, transmit-mode changes,
/// hard-mute toggles, and release-tail edits.
VoiceState {
/// True when the user has joined a voice channel via
/// `voice_join` and the audio engine is running.
in_channel: bool,
/// Active transmit mode encoded as
/// [`chanora_audio::TransmitMode::as_u8`].
transmit_mode: u8,
/// True when the hard-mute clamp is engaged.
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id from the
/// `channel_join` reducer projection.
current_channel_id: Option<u64>,
/// Non-authoritative pending target channel id from the
/// reducer projection.
pending_target_channel_id: Option<u64>,
/// Whether the reducer currently allows a new join intent.
can_join: bool,
/// Whether the reducer currently allows leave intent.
can_leave: bool,
/// Join projection synchronization state.
join_sync_state: VoiceJoinSyncState,
/// Last stable sanitized join error code, if any.
join_error_code: Option<VoiceJoinErrorCode>,
},
/// iOS audio-session interruption state (SDD-101). Emitted when
/// interruption begins and when it ends (with the platform hint
/// indicating whether audio should resume).
InterruptionState {
/// True when interruption began, false when interruption ended.
began: bool,
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
/// Activity line text.
message: String,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio output route.
route: AudioRoute,
},
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel.
new_channel_id: u64,
},
/// A new client connected.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname at time of disconnect.
name: String,
},
/// Client properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Unique channel identifier.
id: u64,
/// Parent channel ID.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Unique channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is synchronizing against an initial snapshot.
SynchronizingInitialSnapshot,
/// Reducer is synchronizing after reconnect.
SynchronizingReconnect,
}
/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Coarse OS-reported network state. Populated by the Flutter side
/// via `connectivity_plus`; on platforms where no signal is wired
/// we stay at `Unknown` forever and the supervisor falls back to
/// pure watchdog/backoff behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
/// No signal seen yet — treat as ambiguous; don't change behaviour.
Unknown,
/// OS reports at least one network with internet capability.
Online,
/// OS reports no networks available.
Offline,
}
+149 -305
View File
@@ -52,6 +52,8 @@ use chanora_state::channel_join::{
ConnectionEpoch, JoinFailureKind, ConnectionEpoch, JoinFailureKind,
}; };
mod events;
mod network_diagnostics;
pub mod ptt; pub mod ptt;
pub use chanora_audio::{ pub use chanora_audio::{
@@ -69,46 +71,11 @@ pub use chanora_protocol::{
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot, MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
}; };
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore}; pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
pub use events::{
/// Privacy-safe snapshot of the active PTT capability. NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
#[derive(Debug, Clone, PartialEq, Eq)] VoiceJoinSyncState,
pub struct PttDescriptorSnapshot { };
/// Stable capability level name. use network_diagnostics::NetworkDiagnostics;
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
/// Errors that can arise during top-level orchestration. /// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -147,253 +114,11 @@ pub enum CoreError {
Ptt(#[from] ptt::PttControllerError), Ptt(#[from] ptt::PttControllerError),
} }
/// High-level lifecycle event surfaced to subscribers.
///
/// This is the minimal set needed for A.6 (reconnect banner). The
/// full event catalogue lands in A.4.
#[derive(Debug, Clone)]
pub enum SessionEvent {
/// Initial connect succeeded, or reconnect attempt succeeded.
Connected {
/// Server name reported in the snapshot.
server_name: String,
},
/// Connection lost; the supervisor will retry.
Lost {
/// Reason classification from the protocol layer.
reason: String,
},
/// Supervisor is sleeping before its next reconnect attempt.
Reconnecting {
/// 1-based attempt counter for the current outage.
attempt: u32,
/// Seconds the supervisor will sleep before this attempt.
delay_secs: u32,
},
/// Supervisor gave up after `attempt` failed retries (or the
/// user explicitly disconnected mid-outage).
Disconnected {
/// Reason classification from the protocol layer.
reason: String,
},
/// Audio engine started (e.g. after a successful reconnect with
/// reattachment).
AudioStarted,
/// Audio engine stopped (e.g. before a reconnect cycle, or by
/// explicit user action).
AudioStopped,
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
/// DEC-023..028). Published when the audio engine starts or
/// when the active backend transitions (for example macOS
/// permission state change). Carries only the privacy-safe
/// descriptor — capability level, backend identifier, bound
/// input class — per SRS-202 / DEC-027.
PttCapability {
/// Stable level name from `PttCapabilityLevel::as_str()`.
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`); empty when
/// no binding is active.
bound_input_class: String,
},
/// Voice subsystem state snapshot (SDD-094). Emitted on
/// `voice_join` / `voice_leave`, transmit-mode changes,
/// hard-mute toggles, and release-tail edits.
VoiceState {
/// True when the user has joined a voice channel via
/// `voice_join` and the audio engine is running.
in_channel: bool,
/// Active transmit mode encoded as
/// [`chanora_audio::TransmitMode::as_u8`].
transmit_mode: u8,
/// True when the hard-mute clamp is engaged.
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id from the
/// `channel_join` reducer projection.
current_channel_id: Option<u64>,
/// Non-authoritative pending target channel id from the
/// reducer projection.
pending_target_channel_id: Option<u64>,
/// Whether the reducer currently allows a new join intent.
can_join: bool,
/// Whether the reducer currently allows leave intent.
can_leave: bool,
/// Join projection synchronization state.
join_sync_state: VoiceJoinSyncState,
/// Last stable sanitized join error code, if any.
join_error_code: Option<VoiceJoinErrorCode>,
},
/// iOS audio-session interruption state (SDD-101). Emitted when
/// interruption begins and when it ends (with the platform hint
/// indicating whether audio should resume).
InterruptionState {
/// True when interruption began, false when interruption ended.
began: bool,
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
/// Activity line text.
message: String,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
route: AudioRoute,
},
ClientMoved {
client_id: u64,
new_channel_id: u64,
},
ClientJoined {
client_id: u64,
channel_id: u64,
name: String,
input_muted: bool,
output_muted: bool,
is_server_query: bool,
talk_power: i32,
talk_power_granted: bool,
},
ClientLeft {
client_id: u64,
name: String,
},
ClientUpdated {
client_id: u64,
input_muted: bool,
output_muted: bool,
is_server_query: bool,
talk_power: i32,
talk_power_granted: bool,
},
ChannelAdded {
id: u64,
parent: u64,
name: String,
order: i64,
has_password: bool,
needed_talk_power: Option<i32>,
},
ChannelRemoved {
id: u64,
},
ChannelUpdated {
id: u64,
name: String,
has_password: bool,
needed_talk_power: Option<i32>,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is synchronizing against an initial snapshot.
SynchronizingInitialSnapshot,
/// Reducer is synchronizing after reconnect.
SynchronizingReconnect,
}
/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Coarse OS-reported network state. Populated by the Flutter side
/// via `connectivity_plus`; on platforms where no signal is wired
/// we stay at `Unknown` forever and the supervisor falls back to
/// pure watchdog/backoff behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
/// No signal seen yet — treat as ambiguous; don't change behaviour.
Unknown,
/// OS reports at least one network with internet capability.
Online,
/// OS reports no networks available.
Offline,
}
/// Channel capacity for the broadcast events. Generous because /// Channel capacity for the broadcast events. Generous because
/// reconnect cycles emit several events per attempt; if subscribers /// reconnect cycles emit several events per attempt; if subscribers
/// fall behind we'd rather skip than block the supervisor. /// fall behind we'd rather skip than block the supervisor.
const EVENT_CHANNEL_CAPACITY: usize = 64; const EVENT_CHANNEL_CAPACITY: usize = 64;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: Vec<String>,
}
impl NetworkDiagnostics {
fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.remove(0);
}
self.loss_reasons.push(reason.to_string());
}
fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons.join(", ")
));
}
s
}
}
struct SupervisorInner { struct SupervisorInner {
/// Optional cached AudioEngineConfig — set when start_audio is /// Optional cached AudioEngineConfig — set when start_audio is
/// first called, used to re-create the engine after a reconnect. /// first called, used to re-create the engine after a reconnect.
@@ -439,6 +164,10 @@ struct ConnectedState {
local_output_muted: bool, local_output_muted: bool,
} }
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
inner.lock().await.take()
}
fn normalize_channel_password(password: Option<String>) -> Option<String> { fn normalize_channel_password(password: Option<String>) -> Option<String> {
password password
.map(|p| p.trim().to_string()) .map(|p| p.trim().to_string())
@@ -1307,8 +1036,8 @@ impl ChanoraSession {
Ok(()) Ok(())
} }
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active). /// Read audio engine statistics: (frames_sent, frames_received, transmit_active, input_level_dbfs).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> { pub async fn audio_stats(&self) -> Result<(u32, u32, bool, f32), CoreError> {
let guard = self.inner.lock().await; let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
@@ -1316,6 +1045,7 @@ impl ChanoraSession {
audio.frames_sent(), audio.frames_sent(),
audio.frames_received(), audio.frames_received(),
audio.transmit_active(), audio.transmit_active(),
audio.input_level(),
)) ))
} }
@@ -1839,8 +1569,7 @@ impl ChanoraSession {
/// Disconnect from the server. No-op if not connected. /// Disconnect from the server. No-op if not connected.
pub async fn disconnect(&self) -> Result<(), CoreError> { pub async fn disconnect(&self) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await; if let Some(mut state) = take_disconnect_state(&self.inner).await {
if let Some(mut state) = guard.take() {
// Signal the supervisor to exit (cancels any backoff sleep). // Signal the supervisor to exit (cancels any backoff sleep).
if let Some(tx) = state.cancel_tx.take() { if let Some(tx) = state.cancel_tx.take() {
let _ = tx.send(()); let _ = tx.send(());
@@ -1856,7 +1585,7 @@ impl ChanoraSession {
// Wait for the supervisor to wind down so we don't race // Wait for the supervisor to wind down so we don't race
// a redial against the explicit disconnect. // a redial against the explicit disconnect.
if let Some(handle) = state.supervisor.take() { if let Some(handle) = state.supervisor.take() {
let _ = handle.await; await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
} }
let _ = self.events_tx.send(SessionEvent::Disconnected { let _ = self.events_tx.send(SessionEvent::Disconnected {
reason: "user requested".to_string(), reason: "user requested".to_string(),
@@ -1895,6 +1624,22 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Number of consecutive watchdog failures before the supervisor /// Number of consecutive watchdog failures before the supervisor
/// declares the connection lost. /// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3; const WATCHDOG_MAX_MISSES: u32 = 3;
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
if tokio::time::timeout(timeout_duration, &mut handle)
.await
.is_err()
{
warn!(
target: "chanora_core",
timeout_ms = timeout_duration.as_millis() as u64,
"supervisor did not stop before shutdown timeout"
);
handle.abort();
let _ = handle.await;
}
}
struct SupervisorContext { struct SupervisorContext {
state_arc: Arc<Mutex<Option<ConnectedState>>>, state_arc: Arc<Mutex<Option<ConnectedState>>>,
@@ -1950,27 +1695,77 @@ fn spawn_event_forwarders(
let mut rx = delta_rx; let mut rx = delta_rx;
while let Some(delta) = rx.recv().await { while let Some(delta) = rx.recv().await {
let event = match delta { let event = match delta {
ProtocolDelta::ClientMoved { client_id, new_channel_id } => { ProtocolDelta::ClientMoved {
SessionEvent::ClientMoved { client_id, new_channel_id } client_id,
} new_channel_id,
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => { } => SessionEvent::ClientMoved {
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } client_id,
} new_channel_id,
},
ProtocolDelta::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => SessionEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
ProtocolDelta::ClientLeft { client_id, name } => { ProtocolDelta::ClientLeft { client_id, name } => {
SessionEvent::ClientLeft { client_id, name } SessionEvent::ClientLeft { client_id, name }
} }
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => { ProtocolDelta::ClientUpdated {
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } client_id,
} input_muted,
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => { output_muted,
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } is_server_query,
} talk_power,
ProtocolDelta::ChannelRemoved { id } => { talk_power_granted,
SessionEvent::ChannelRemoved { id } } => SessionEvent::ClientUpdated {
} client_id,
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => { input_muted,
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } output_muted,
} is_server_query,
talk_power,
talk_power_granted,
},
ProtocolDelta::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => SessionEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
},
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
ProtocolDelta::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => SessionEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
},
}; };
let _ = ev_tx.send(event); let _ = ev_tx.send(event);
} }
@@ -2399,6 +2194,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
/// the UI would render. Two snapshots with identical channel /// the UI would render. Two snapshots with identical channel
/// memberships, names, and orderings produce the same signature; /// memberships, names, and orderings produce the same signature;
/// any in-channel move, rename, or reorder produces a different one. /// any in-channel move, rename, or reorder produces a different one.
#[cfg(test)]
fn snapshot_signature(snap: &ServerSnapshot) -> u64 { fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@@ -2593,6 +2389,54 @@ mod tests {
s.disconnect().await.unwrap(); s.disconnect().await.unwrap();
} }
#[tokio::test]
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
let inner = Arc::new(Mutex::new(Some(())));
let state = super::take_disconnect_state(&inner).await;
assert_eq!(state, Some(()));
assert!(inner.try_lock().is_ok());
}
#[tokio::test]
async fn supervisor_join_returns_after_shutdown_timeout() {
let handle = tokio::spawn(async {
std::future::pending::<()>().await;
});
let start = std::time::Instant::now();
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
#[tokio::test]
async fn supervisor_shutdown_timeout_aborts_pending_task() {
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for DropNotice {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
let _notice = DropNotice(Some(dropped_tx));
std::future::pending::<()>().await;
});
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
.await
.expect("pending supervisor task should be aborted")
.expect("drop notice should be delivered");
}
#[test] #[test]
fn signature_detects_in_channel_move() { fn signature_detects_in_channel_move() {
use chanora_protocol::{ChannelInfo, ClientInfo}; use chanora_protocol::{ChannelInfo, ClientInfo};
@@ -0,0 +1,72 @@
use std::collections::VecDeque;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
pub(crate) struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: VecDeque<String>,
}
impl NetworkDiagnostics {
pub(crate) fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
pub(crate) fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.pop_front();
}
self.loss_reasons.push_back(reason.to_string());
}
pub(crate) fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ")
));
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_diagnostics_keeps_last_eight_loss_reasons() {
let mut diagnostics = NetworkDiagnostics::default();
for i in 0..10 {
diagnostics.record_loss(&format!("loss-{i}"));
}
assert_eq!(diagnostics.disconnect_count, 10);
assert_eq!(diagnostics.loss_reasons.len(), 8);
assert_eq!(
diagnostics.loss_reasons.front().map(String::as_str),
Some("loss-2")
);
assert_eq!(
diagnostics.loss_reasons.back().map(String::as_str),
Some("loss-9")
);
assert!(diagnostics.summary().contains(
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
));
}
}
+12 -6
View File
@@ -29,12 +29,13 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] } tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0" rustfft = "6.2.0"
crossbeam = { version = "0.8", default-features = false, features = ["alloc", "crossbeam-queue"] }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies] [target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture. # Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO # Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path. # AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3" cpal = "0.18.0"
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up). # Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
@@ -57,7 +58,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dyn
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION # JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised # when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim. # by the bridge crate's android_init shim.
jni = { version = "0.21", default-features = false } jni = { version = "0.22.4", default-features = false }
ndk-context = "0.1" ndk-context = "0.1"
# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback. # Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
# Primary backend for SDD-111..SDD-115. The pre-compiled static library # Primary backend for SDD-111..SDD-115. The pre-compiled static library
@@ -72,13 +73,18 @@ ndk-context = "0.1"
# - deduplicated macro impls # - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant # - PowerSavingOffloaded PerformanceMode variant
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" } oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
# Safe slice reinterpret for the oboe stereo output callback.
# bytemuck::cast_slice_mut replaces the raw-pointer cast from
# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct
# and UB-free transmute backed by `NoUninit`.
bytemuck = { version = "1", features = ["derive"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices # Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
# + WM_INPUT translation backed by a hidden message-only window, and # + WM_INPUT translation backed by a hidden message-only window, and
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both # SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
# require a per-backend OS thread that owns a message pump. # require a per-backend OS thread that owns a message pump.
windows = { version = "0.54", features = [ windows = { version = "0.62", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
@@ -98,7 +104,7 @@ tracing-subscriber = { version = "0.3", features = ["registry"] }
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec / # SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
# resampler). `harness = false` per bench entry below disables the # resampler). `harness = false` per bench entry below disables the
# default libtest harness so criterion can install its own. # default libtest harness so criterion can install its own.
criterion = "0.5" criterion = "0.8"
# SDD-120 §3 item 1 — dhat is used as the global allocator inside # SDD-120 §3 item 1 — dhat is used as the global allocator inside
# `benches/realtime_capture.rs` to count post-warmup heap allocations # `benches/realtime_capture.rs` to count post-warmup heap allocations
# on the realtime capture path. Dev-dep only — does NOT affect # on the realtime capture path. Dev-dep only — does NOT affect
@@ -141,7 +147,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# Random token bytes for the portal handle_token / session_handle_token # Random token bytes for the portal handle_token / session_handle_token
# options. The portal recommends fresh tokens to scope its own # options. The portal recommends fresh tokens to scope its own
# object paths per call. # object paths per call.
rand = "0.8" rand = "0.10"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only; # SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback. # cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the # Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
@@ -160,4 +166,4 @@ rand = "0.8"
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship # libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux # `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency. # build documentation lists this as a runtime dependency.
sdl2 = { version = "0.37", default-features = false } sdl2 = { version = "0.38", default-features = false }
@@ -0,0 +1,124 @@
use std::sync::Arc;
use crossbeam::queue::ArrayQueue;
/// Fixed-capacity PCM handoff from the Android render producer task to
/// the Oboe output callback.
pub(crate) struct AndroidRenderRing {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRing {
pub(crate) fn new(capacity: usize) -> Self {
Self {
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
}
}
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
AndroidRenderRingProducer {
frames: Arc::clone(&self.frames),
}
}
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
AndroidRenderRingConsumer {
frames: Arc::clone(&self.frames),
}
}
}
pub(crate) struct AndroidRenderRingProducer {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRingProducer {
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
for frame in samples.chunks_exact(2) {
let stereo_frame = [frame[0], frame[1]];
if self.frames.push(stereo_frame).is_err() {
let _ = self.frames.pop();
let _ = self.frames.push(stereo_frame);
}
}
}
}
pub(crate) struct AndroidRenderRingConsumer {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRingConsumer {
#[cfg(test)]
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
let mut chunks = out.chunks_exact_mut(2);
for frame_out in &mut chunks {
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
frame_out.copy_from_slice(&frame);
}
for sample in chunks.into_remainder() {
*sample = 0.0;
}
}
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
for frame_out in out {
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
*frame_out = (frame[0], frame[1]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn producer_drops_oldest_samples_when_ring_is_full() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
producer.push_frame_lossy(&[5.0, 6.0]);
let mut out = [0.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
}
#[test]
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
let mut odd_out = [9.0];
consumer.drain_into_zero_filling(&mut odd_out);
assert_eq!(odd_out, [0.0]);
producer.push_frame_lossy(&[3.0, 30.0]);
let mut out = [0.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
}
#[test]
fn consumer_zero_fills_tail_on_underrun() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[0.25, -0.25]);
let mut out = [9.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use chanora_protocol::InAudioBuf;
use crossbeam::queue::ArrayQueue;
use crate::engine::SessionAudioId;
const PACKET_QUEUE_CAPACITY: usize = 100;
const CONTROL_QUEUE_CAPACITY: usize = 32;
/// A raw inbound voice packet waiting to be inserted into AudioHandler.
#[derive(Debug)]
pub struct AudioPacket {
/// Client whose TeamSpeak audio packet this belongs to.
pub client_id: SessionAudioId,
/// Raw inbound TeamSpeak audio payload accepted by AudioHandler::handle_packet.
pub data: InAudioBuf,
}
/// Control commands from the main thread to the audio callback.
#[derive(Debug)]
pub enum AudioCommand {
/// Set a client's output volume.
SetVolume(SessionAudioId, f32),
/// Remove a client's decode queue.
// TODO: Wire to client disconnect path; handled in callback but no
// producer currently pushes this command.
#[allow(dead_code)]
RemoveClient(SessionAudioId),
}
/// Lock-free bridge between the inbound forwarder / main thread and the
/// audio callback. The callback owns the consumer halves.
pub struct AudioEventQueue {
/// Bounded lossy queue for raw voice packets. On overflow, the push
/// fails and the packet is dropped (counted via `packets_dropped`).
/// Capacity: 100 packets (~2 seconds at 50pps, far more than needed).
pub packet_queue: ArrayQueue<AudioPacket>,
/// Bounded reliable queue for control commands (volume, client removal).
/// On overflow, the caller retries. Capacity: 32 commands.
pub control_queue: ArrayQueue<AudioCommand>,
/// Atomic counter for dropped packets (for diagnostics).
pub packets_dropped: AtomicU64,
}
impl AudioEventQueue {
/// Create the Android audio event bridge with fixed queue capacities.
pub fn new() -> Arc<Self> {
Arc::new(Self {
packet_queue: ArrayQueue::new(PACKET_QUEUE_CAPACITY),
control_queue: ArrayQueue::new(CONTROL_QUEUE_CAPACITY),
packets_dropped: AtomicU64::new(0),
})
}
/// Create a producer handle sharing this queue.
pub fn producer(queue: &Arc<Self>) -> AudioEventProducer {
AudioEventProducer {
queue: Arc::clone(queue),
}
}
/// Create a consumer handle sharing this queue.
pub fn consumer(queue: &Arc<Self>) -> AudioEventConsumer {
AudioEventConsumer {
queue: Arc::clone(queue),
}
}
}
/// Producer side used by the inbound forwarder and engine control methods.
#[derive(Clone)]
pub struct AudioEventProducer {
queue: Arc<AudioEventQueue>,
}
impl AudioEventProducer {
/// Push a raw voice packet, incrementing the drop counter if full.
pub fn push_packet(&self, packet: AudioPacket) -> Result<(), AudioPacket> {
self.queue.packet_queue.push(packet).map_err(|packet| {
self.queue.packets_dropped.fetch_add(1, Ordering::Relaxed);
packet
})
}
/// Push a control command, returning it unchanged if the queue is full.
pub fn push_control(&self, cmd: AudioCommand) -> Result<(), AudioCommand> {
self.queue.control_queue.push(cmd)
}
/// Shared queue backing this producer.
pub fn queue(&self) -> Arc<AudioEventQueue> {
Arc::clone(&self.queue)
}
}
/// Consumer side used by the Android output callback.
pub struct AudioEventConsumer {
queue: Arc<AudioEventQueue>,
}
impl AudioEventConsumer {
/// Pop up to `cap` queued packets.
pub fn drain_packets(&self, cap: usize) -> impl Iterator<Item = AudioPacket> + '_ {
let mut drained = 0;
std::iter::from_fn(move || {
if drained >= cap {
return None;
}
let packet = self.queue.packet_queue.pop();
if packet.is_some() {
drained += 1;
}
packet
})
}
/// Pop all currently queued controls.
pub fn drain_controls(&self) -> impl Iterator<Item = AudioCommand> + '_ {
std::iter::from_fn(move || self.queue.control_queue.pop())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_packet(id: u64) -> AudioPacket {
let audio = chanora_protocol::AudioData::S2C {
codec: chanora_protocol::CodecType::OpusVoice,
id: 0x1234,
from: 0x5678,
data: &[1, 2, 3],
};
let out = chanora_protocol::OutAudio::new(&audio);
AudioPacket {
client_id: SessionAudioId(id),
data: InAudioBuf::try_new(chanora_protocol::Direction::S2C, out.data().to_vec())
.unwrap(),
}
}
#[test]
fn packet_overflow_increments_drop_counter() {
let queue = AudioEventQueue::new();
let producer = AudioEventQueue::producer(&queue);
for i in 0..PACKET_QUEUE_CAPACITY {
let packet = empty_packet(i as u64);
assert!(producer.push_packet(packet).is_ok());
}
let overflow = empty_packet(999);
assert!(producer.push_packet(overflow).is_err());
assert_eq!(queue.packets_dropped.load(Ordering::Relaxed), 1);
}
#[test]
fn consumer_drains_packets_and_controls() {
let queue = AudioEventQueue::new();
let producer = AudioEventQueue::producer(&queue);
let consumer = AudioEventQueue::consumer(&queue);
producer
.push_control(AudioCommand::SetVolume(SessionAudioId(7), 0.5))
.unwrap();
producer.push_packet(empty_packet(42)).unwrap();
let packets: Vec<_> = consumer.drain_packets(8).collect();
assert_eq!(packets.len(), 1);
assert_eq!(packets[0].client_id, SessionAudioId(42));
let controls: Vec<_> = consumer.drain_controls().collect();
assert_eq!(controls.len(), 1);
match controls[0] {
AudioCommand::SetVolume(id, vol) => {
assert_eq!(id, SessionAudioId(7));
assert_eq!(vol, 0.5);
}
AudioCommand::RemoveClient(_) => panic!("unexpected remove-client command"),
}
}
}
@@ -404,6 +404,19 @@ impl Default for SharedAudioProcessingStats {
} }
impl SharedAudioProcessingStats { impl SharedAudioProcessingStats {
/// Store the raw input dBFS level (desktop capture path).
/// Mobile platforms use [`Self::update_capture`] instead, which
/// also records VAD state; this lighter method is for the cpal
/// capture path that has no VAD pipeline.
pub fn set_input_dbfs(&self, dbfs: f32) {
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
}
/// Read the current input dBFS level.
pub fn input_dbfs(&self) -> f32 {
f32::from_bits(self.input_dbfs.load(Ordering::Relaxed))
}
/// Store capture levels and VAD state. /// Store capture levels and VAD state.
pub fn update_capture( pub fn update_capture(
&self, &self,
@@ -0,0 +1,118 @@
pub(crate) fn append_processed_i16_bounded(
pcm_accum: &mut Vec<i16>,
frame: &[f32],
gain: f32,
) -> bool {
if (gain - 1.0).abs() < f32::EPSILON {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
pcm_accum.push(crate::frame::f32_to_i16(src));
}
} else {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
}
}
false
}
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
pcm_accum.push(src);
}
false
}
#[cfg(test)]
mod tests {
use super::{append_i16_bounded, append_processed_i16_bounded};
#[test]
fn append_processed_i16_bounded_does_not_grow_when_full() {
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
assert!(dropped);
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
assert!(!dropped);
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_i16_bounded(&mut accum, &frame);
assert!(dropped);
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_preserves_expected_10ms_append() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_i16_bounded(&mut accum, &frame);
assert!(!dropped);
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_preserves_full_vad_preroll_window() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
for _ in 0..16 {
assert!(!append_i16_bounded(&mut accum, &frame));
}
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
assert!(append_i16_bounded(&mut accum, &frame));
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
}
@@ -0,0 +1,105 @@
pub(crate) struct CaptureResampleResult {
pub(crate) output_len: usize,
pub(crate) dropped: bool,
}
pub(crate) fn resample_capture_to_48k(
samples: &[i16],
input_sample_rate_hz: u32,
resample_pos: &mut f64,
resample_last: &mut i16,
scratch: &mut Vec<i16>,
) -> CaptureResampleResult {
scratch.clear();
if samples.is_empty() {
return CaptureResampleResult {
output_len: 0,
dropped: false,
};
}
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
let mut pos = *resample_pos;
let mut dropped = false;
while pos < samples.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
*resample_last as f64
} else {
samples[(i - 1) as usize] as f64
};
let b = if i < samples.len() as isize {
samples[i as usize] as f64
} else {
a
};
let value = (a + frac * (b - a))
.round()
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
if scratch.len() < scratch.capacity() {
scratch.push(value);
} else {
dropped = true;
}
pos += ratio;
}
*resample_pos = pos - samples.len() as f64;
*resample_last = *samples.last().unwrap_or(resample_last);
CaptureResampleResult {
output_len: scratch.len(),
dropped,
}
}
#[cfg(test)]
mod android_voice_unit_resampler_tests {
use super::resample_capture_to_48k;
#[test]
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
let mut pos = 0.0;
let mut last = 0_i16;
let mut scratch = Vec::with_capacity(960);
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
let first_len = first.output_len;
assert_eq!(first_len, 960);
assert!(!first.dropped);
assert_eq!(scratch.len(), first_len);
let warmed_capacity = scratch.capacity();
let warmed_ptr = scratch.as_ptr();
for _ in 0..8 {
let result =
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
let len = result.output_len;
assert_eq!(len, scratch.len());
assert!(len >= 959 && len <= 960);
assert!(!result.dropped);
assert_eq!(scratch.capacity(), warmed_capacity);
assert_eq!(scratch.as_ptr(), warmed_ptr);
}
}
#[test]
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
let mut pos = 0.0;
let mut last = 0_i16;
let mut scratch = Vec::with_capacity(960);
let warmed_capacity = scratch.capacity();
let warmed_ptr = scratch.as_ptr();
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
assert_eq!(result.output_len, warmed_capacity);
assert!(result.dropped);
assert_eq!(scratch.len(), warmed_capacity);
assert_eq!(scratch.capacity(), warmed_capacity);
assert_eq!(scratch.as_ptr(), warmed_ptr);
assert_eq!(pos, 0.0);
assert_eq!(last, *samples.last().unwrap());
}
}
+9
View File
@@ -314,6 +314,15 @@ mod tests {
rec.stop(); rec.stop();
} }
#[test]
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
let src = include_str!("ios_raw_unit.rs");
assert!(
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
);
}
#[test] #[test]
fn wav_header_is_44_bytes() { fn wav_header_is_44_bytes() {
// Write to a temp file to test the header. // Write to a temp file to test the header.
+281 -115
View File
@@ -52,6 +52,8 @@ use tsclientlib::audio::AudioHandler;
use chanora_protocol::{InboundVoice, OutPacket}; use chanora_protocol::{InboundVoice, OutPacket};
#[cfg(target_os = "android")]
use crate::audio_event_queue::{AudioCommand, AudioEventQueue, AudioPacket};
use crate::AudioError; use crate::AudioError;
#[cfg(all( #[cfg(all(
@@ -305,12 +307,15 @@ pub struct AudioEngine {
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>, audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>, audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(not(target_os = "android"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
audio_event_producer: crate::audio_event_queue::AudioEventProducer,
#[cfg(target_os = "android")]
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] #[cfg(target_os = "android")]
mic_gain: f32, mic_gain: f32,
// Streams must be dropped to stop audio. Both are `!Send` because // Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an // cpal's Stream isn't Send on some backends; we keep them in an
@@ -482,7 +487,7 @@ impl AudioEngine {
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate, transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, event_producer: crate::audio_event_queue::AudioEventProducer,
output_gain: Arc<AtomicU32>, output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -552,7 +557,8 @@ impl AudioEngine {
transmit_active: transmit_gate.flag_arc(), transmit_active: transmit_gate.flag_arc(),
frames_sent: frames_sent.clone(), frames_sent: frames_sent.clone(),
mic_gain, mic_gain,
handler: audio_handler.clone(), handler: AudioHandler::new(),
event_producer: event_producer.clone(),
output_gain: output_gain.clone(), output_gain: output_gain.clone(),
output_muted: output_muted.clone(), output_muted: output_muted.clone(),
voice_activity_selector: voice_activity_selector.clone(), voice_activity_selector: voice_activity_selector.clone(),
@@ -572,7 +578,7 @@ impl AudioEngine {
voice_out_tx.clone(), voice_out_tx.clone(),
transmit_gate.clone(), transmit_gate.clone(),
frames_sent.clone(), frames_sent.clone(),
audio_handler.clone(), event_producer.clone(),
output_gain.clone(), output_gain.clone(),
output_muted.clone(), output_muted.clone(),
voice_activity_selector.clone(), voice_activity_selector.clone(),
@@ -832,6 +838,7 @@ impl AudioEngine {
transmit_flag_for_capture, transmit_flag_for_capture,
frames_sent.clone(), frames_sent.clone(),
cfg.mic_gain, cfg.mic_gain,
audio_processing_stats.clone(),
); );
let (input_stream, capture_active) = match capture_result { let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true), Ok(s) => (Some(s), true),
@@ -974,7 +981,6 @@ impl AudioEngine {
Ok(Self { Ok(Self {
transmit_gate, transmit_gate,
frames_sent,
frames_received, frames_received,
output_gain, output_gain,
output_muted, output_muted,
@@ -1007,8 +1013,8 @@ impl AudioEngine {
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default())); let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default()); let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> = let event_queue = AudioEventQueue::new();
Arc::new(Mutex::new(AudioHandler::new())); let event_producer = AudioEventQueue::producer(&event_queue);
if !cfg.mobile_voice_preset { if !cfg.mobile_voice_preset {
return Err(AudioError::Backend( return Err(AudioError::Backend(
@@ -1069,7 +1075,8 @@ impl AudioEngine {
transmit_active: transmit_flag_for_capture, transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(), frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain, mic_gain: cfg.mic_gain,
handler: audio_handler.clone(), handler: AudioHandler::new(),
event_producer: event_producer.clone(),
output_gain: output_gain.clone(), output_gain: output_gain.clone(),
output_muted: output_muted.clone(), output_muted: output_muted.clone(),
voice_activity_selector: cfg.voice_activity_selector.clone(), voice_activity_selector: cfg.voice_activity_selector.clone(),
@@ -1077,11 +1084,25 @@ impl AudioEngine {
audio_processing_stats: audio_processing_stats.clone(), audio_processing_stats: audio_processing_stats.clone(),
}; };
let mut android_voice_unit = let mut android_voice_unit =
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| { match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}")) Ok(unit) => unit,
})?; Err(e) => {
Self::rollback_android_startup_resources(&mut audio_mode_stack);
return Err(AudioError::Backend(format!(
"android: failed to open Oboe voice unit: {e}"
)));
}
};
if let Err(e) = android_voice_unit.start() { if let Err(e) = android_voice_unit.start() {
if let Err(close_err) = android_voice_unit.close() {
warn!(
target: "chanora_audio",
error = %close_err,
"android: AndroidVoiceUnit::close failed during startup rollback"
);
}
Self::rollback_android_startup_resources(&mut audio_mode_stack);
return Err(AudioError::Backend(format!( return Err(AudioError::Backend(format!(
"android: failed to start Oboe voice unit: {e}" "android: failed to start Oboe voice unit: {e}"
))); )));
@@ -1103,7 +1124,7 @@ impl AudioEngine {
voice_out_tx.clone(), voice_out_tx.clone(),
transmit_gate.clone(), transmit_gate.clone(),
frames_sent.clone(), frames_sent.clone(),
audio_handler.clone(), event_producer.clone(),
output_gain.clone(), output_gain.clone(),
output_muted.clone(), output_muted.clone(),
cfg.voice_activity_selector.clone(), cfg.voice_activity_selector.clone(),
@@ -1151,7 +1172,7 @@ impl AudioEngine {
let capture_active = true; let capture_active = true;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone(); let event_producer_for_task = event_producer.clone();
let frames_received_for_task = frames_received.clone(); let frames_received_for_task = frames_received.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
@@ -1164,10 +1185,8 @@ impl AudioEngine {
match item { match item {
Some(v) => { Some(v) => {
let id = SessionAudioId(v.from_client); let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap(); let packet = AudioPacket { client_id: id, data: v.packet };
if let Err(e) = h.handle_packet(id, v.packet) { if event_producer_for_task.push_packet(packet).is_ok() {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
frames_received_for_task.fetch_add(1, Ordering::Relaxed); frames_received_for_task.fetch_add(1, Ordering::Relaxed);
} }
} }
@@ -1186,7 +1205,7 @@ impl AudioEngine {
output_muted, output_muted,
audio_processing_config, audio_processing_config,
audio_processing_stats, audio_processing_stats,
audio_handler, audio_event_producer: event_producer,
voice_out_tx, voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(), voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain, mic_gain: cfg.mic_gain,
@@ -1240,6 +1259,7 @@ impl AudioEngine {
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default())); let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default()); let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
#[cfg(any(target_os = "ios", target_os = "macos"))]
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> = let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new())); Arc::new(Mutex::new(AudioHandler::new()));
let voice_out_tx_for_backend = voice_out_tx.clone(); let voice_out_tx_for_backend = voice_out_tx.clone();
@@ -1272,8 +1292,8 @@ impl AudioEngine {
// VPIO render callback (commit 4) finds decoded frames // VPIO render callback (commit 4) finds decoded frames
// waiting. // waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let frames_received_for_task = frames_received.clone(); let frames_received_for_task = frames_received.clone();
let handler_for_task = audio_handler.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::select! { tokio::select! {
@@ -1286,10 +1306,15 @@ impl AudioEngine {
Some(v) => { Some(v) => {
let id = SessionAudioId(v.from_client); let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap(); let mut h = handler_for_task.lock().unwrap();
if let Err(e) = h.handle_packet(id, v.packet) { let res = h.handle_packet(id, v.packet);
debug!(target: "chanora_audio", error = %e, "decode failed"); drop(h);
} else { match res {
frames_received_for_task.fetch_add(1, Ordering::Relaxed); Ok(_) => {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
Err(e) => {
debug!(target: "chanora_audio", error = %e, "decode failed");
}
} }
} }
None => break, None => break,
@@ -1308,15 +1333,49 @@ impl AudioEngine {
audio_processing_config, audio_processing_config,
audio_processing_stats, audio_processing_stats,
audio_handler, audio_handler,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)), _ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
shutdown_tx: Some(shutdown_tx), shutdown_tx: Some(shutdown_tx),
capture_active, capture_active,
}) })
} }
#[cfg(target_os = "android")]
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
crate::mode_stack::ModeRelease::LastRelease { prior } => {
match android_set_audio_mode(prior) {
Ok(()) => info!(
target: "chanora_audio",
restored_mode = prior,
"android: AudioManager mode restored during startup rollback"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
restored_mode = prior,
"android: failed to restore AudioManager mode during startup rollback"
),
}
}
crate::mode_stack::ModeRelease::StillHeld => {
info!(
target: "chanora_audio",
"android: audio mode still held during startup rollback"
);
}
crate::mode_stack::ModeRelease::AlreadyReleased => {}
}
if crate::android_voice_unit::chanora_android_stop_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service stopped during startup rollback"
);
}
}
/// Stop the engine. Idempotent. /// Stop the engine. Idempotent.
pub fn stop(&mut self) { pub fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() { if let Some(tx) = self.shutdown_tx.take() {
@@ -1470,7 +1529,8 @@ impl AudioEngine {
transmit_active: self.transmit_gate.flag_arc(), transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(), frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain, mic_gain: self.mic_gain,
handler: self.audio_handler.clone(), handler: AudioHandler::new(),
event_producer: self.audio_event_producer.clone(),
output_gain: self.output_gain.clone(), output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(), output_muted: self.output_muted.clone(),
voice_activity_selector: self.voice_activity_selector.clone(), voice_activity_selector: self.voice_activity_selector.clone(),
@@ -1491,7 +1551,7 @@ impl AudioEngine {
self.voice_out_tx.clone(), self.voice_out_tx.clone(),
self.transmit_gate.clone(), self.transmit_gate.clone(),
self.frames_sent.clone(), self.frames_sent.clone(),
self.audio_handler.clone(), self.audio_event_producer.clone(),
self.output_gain.clone(), self.output_gain.clone(),
self.output_muted.clone(), self.output_muted.clone(),
self.voice_activity_selector.clone(), self.voice_activity_selector.clone(),
@@ -1594,6 +1654,11 @@ impl AudioEngine {
self.frames_received.load(Ordering::Relaxed) self.frames_received.load(Ordering::Relaxed)
} }
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub fn input_level(&self) -> f32 {
self.audio_processing_stats.input_dbfs()
}
/// Current audio-processing config snapshot. /// Current audio-processing config snapshot.
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig { pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
self.audio_processing_config.lock().unwrap().clone() self.audio_processing_config.lock().unwrap().clone()
@@ -1658,20 +1723,42 @@ impl AudioEngine {
/// `0.0..4.0`. /// `0.0..4.0`.
pub fn set_client_volume(&self, client_id: u64, volume: f32) { pub fn set_client_volume(&self, client_id: u64, volume: f32) {
let clamped = volume.clamp(0.0, 4.0); let clamped = volume.clamp(0.0, 4.0);
match self.audio_handler.lock() { #[cfg(target_os = "android")]
Ok(mut h) => { {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) { let mut cmd = AudioCommand::SetVolume(SessionAudioId(client_id), clamped);
q.volume = clamped; for _ in 0..64 {
match self.audio_event_producer.push_control(cmd) {
Ok(()) => return,
Err(returned) => {
cmd = returned;
std::thread::yield_now();
}
} }
} }
Err(e) => { tracing::warn!(
tracing::warn!( target: "chanora_audio",
target: "chanora_audio", client_id,
client_id, volume = clamped,
volume = clamped, "set_client_volume: control queue full after 64 retries — volume not applied"
error = %e, );
"set_client_volume: audio_handler lock poisoned — volume not applied" }
); #[cfg(not(target_os = "android"))]
{
match self.audio_handler.lock() {
Ok(mut h) => {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
q.volume = clamped;
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: audio_handler lock poisoned — volume not applied"
);
}
} }
} }
} }
@@ -1683,6 +1770,33 @@ impl Drop for AudioEngine {
} }
} }
#[cfg(any(test, target_os = "android"))]
fn release_android_audio_mode_for_startup_rollback(
audio_mode_stack: &mut crate::mode_stack::ModeStack,
) -> crate::mode_stack::ModeRelease {
audio_mode_stack.release()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_startup_rollback_releases_acquired_mode_snapshot() {
let mut stack = crate::mode_stack::ModeStack::new();
let _ = stack.acquire(7);
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
assert_eq!(
release,
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
);
assert_eq!(stack.refcount(), 0);
assert_eq!(stack.snapshot(), None);
}
}
// ---------- Capture pipeline ---------- // ---------- Capture pipeline ----------
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -1692,6 +1806,7 @@ fn try_open_capture(
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>, frames_sent: Arc<AtomicU32>,
mic_gain: f32, mic_gain: f32,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<cpal::Stream, AudioError> { ) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev let in_cfg = in_dev
.default_input_config() .default_input_config()
@@ -1724,9 +1839,13 @@ fn try_open_capture(
in_sample_rate, in_sample_rate,
in_channels, in_channels,
mic_gain, mic_gain,
voice_out_tx, crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"cpal-capture",
)?,
transmit_active, transmit_active,
frames_sent, audio_processing_stats,
))); )));
let stream = match in_format { let stream = match in_format {
@@ -1760,11 +1879,10 @@ struct CaptureState {
/// Linux ALSA defaults). /// Linux ALSA defaults).
resample_last: f32, resample_last: f32,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
/// The PTT transmission gate. Read once per outbound frame; the /// The PTT transmission gate. Read once per outbound frame; the
/// CaptureState never mutates this flag. /// CaptureState never mutates this flag.
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
/// Pre-allocated mono downmix buffer. Resized in-place each /// Pre-allocated mono downmix buffer. Resized in-place each
/// callback; `clear()` retains capacity. SDD-094 realtime-thread /// callback; `clear()` retains capacity. SDD-094 realtime-thread
/// invariant: this avoids the heap allocation that the prior fix /// invariant: this avoids the heap allocation that the prior fix
@@ -1778,8 +1896,27 @@ struct CaptureState {
/// capacity so the drain-into-frame path skips the allocator /// capacity so the drain-into-frame path skips the allocator
/// after warmup. Same precedent as `mono_scratch` above. /// after warmup. Same precedent as `mono_scratch` above.
frame_scratch: Vec<f32>, frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
/// Time-based decimation for the level meter. The cpal callback
/// cadence depends on platform (256 frames at 48 kHz ≈ 187 Hz,
/// 512 frames ≈ 94 Hz, 1024 frames ≈ 47 Hz) and can change at
/// runtime on device or sample-rate switch. The bridge consumer
/// (`input_level_stream`) only reads at ~30 Hz, so computing
/// `sqrt()` + `log10()` on every callback wastes real-time budget
/// and caused buffer underruns on macOS CoreAudio with small
/// buffer sizes. We only emit a new dBFS sample after at least
/// [LEVEL_METER_INTERVAL] has elapsed since the previous emit,
/// which is platform-cadence-independent.
last_level_emit: std::time::Instant,
} }
/// Minimum interval between input-level dBFS samples sent to the
/// bridge. Matches the consumer rate (`input_level_stream` at ~30 Hz).
/// Time-based gating is robust to cpal buffer-size and sample-rate
/// changes that a fixed callback-count would not be.
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState { impl CaptureState {
fn new( fn new(
@@ -1787,9 +1924,9 @@ impl CaptureState {
in_sample_rate: u32, in_sample_rate: u32,
in_channels: usize, in_channels: usize,
mic_gain: f32, mic_gain: f32,
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>, audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self { ) -> Self {
Self { Self {
encoder, encoder,
@@ -1802,13 +1939,13 @@ impl CaptureState {
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx, voice_out_tx,
transmit_active, transmit_active,
frames_sent,
// Generous upper bound for typical cpal periods
// (commonly 256..1024 frames); `clear()` retains the
// backing allocation across callbacks. See struct doc.
mono_scratch: Vec::with_capacity(4096), mono_scratch: Vec::with_capacity(4096),
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
frame_scratch: Vec::with_capacity(FRAME_SAMPLES), frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
// Start in the past so the first ingest emits immediately.
last_level_emit: std::time::Instant::now()
.checked_sub(LEVEL_METER_INTERVAL)
.unwrap_or_else(std::time::Instant::now),
} }
} }
@@ -1816,17 +1953,8 @@ impl CaptureState {
/// 48 kHz mono frames; encode and send when `transmit_active` /// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged). /// is true (PTT engaged).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) { fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) { // 1. Down-mix to mono (pre-gain). Always performed so the level
// Drain accumulator while muted so we don't pop on PTT release. // meter reflects real mic input even when PTT is released.
self.pcm_accum.clear();
return;
}
// 1. Down-mix to mono + gain.
// Reuse `self.mono_scratch` to avoid a per-callback Vec
// allocation on the realtime audio thread; see struct
// doc and the engine.rs:1389-1397 precedent for why this
// matters for user-perceptible audio popping.
let in_channels = self.in_channels; let in_channels = self.in_channels;
let mic_gain = self.mic_gain; let mic_gain = self.mic_gain;
self.mono_scratch.clear(); self.mono_scratch.clear();
@@ -1834,8 +1962,28 @@ impl CaptureState {
self.mono_scratch.reserve(frame_count); self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) { for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum(); let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch self.mono_scratch.push(sum / frame.len() as f32);
.push((sum / frame.len() as f32) * mic_gain); }
// Level meter: pay sqrt() + log10() only when at least
// LEVEL_METER_INTERVAL has elapsed, regardless of the
// platform's cpal callback cadence.
let now = std::time::Instant::now();
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
self.last_level_emit = now;
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
}
if mic_gain != 1.0 {
for s in &mut self.mono_scratch {
*s *= mic_gain;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
} }
// 2. Resample to 48 kHz if needed. We re-borrow // 2. Resample to 48 kHz if needed. We re-borrow
@@ -1887,7 +2035,6 @@ impl CaptureState {
Ok(len) => { Ok(len) => {
crate::opus_voice::send_voip_frame( crate::opus_voice::send_voip_frame(
&self.voice_out_tx, &self.voice_out_tx,
&self.frames_sent,
&self.opus_out, &self.opus_out,
len, len,
|| { || {
@@ -2301,6 +2448,13 @@ impl std::fmt::Display for AudioModeError {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
impl std::error::Error for AudioModeError {} impl std::error::Error for AudioModeError {}
#[cfg(target_os = "android")]
impl From<jni::errors::Error> for AudioModeError {
fn from(value: jni::errors::Error) -> Self {
Self::JniAttachFailed(value.to_string())
}
}
/// JNI helper shared by `android_get_audio_mode` and /// JNI helper shared by `android_get_audio_mode` and
/// `android_set_audio_mode`: attach to the current thread and return /// `android_set_audio_mode`: attach to the current thread and return
/// the `AudioManager` jobject. Centralised so SDD-108's two platform /// the `AudioManager` jobject. Centralised so SDD-108's two platform
@@ -2308,7 +2462,10 @@ impl std::error::Error for AudioModeError {}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError> fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
where where
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError> F: for<'local> FnOnce(
&mut jni::Env<'local>,
&jni::objects::JObject<'local>,
) -> Result<R, AudioModeError>
+ std::panic::UnwindSafe, + std::panic::UnwindSafe,
{ {
use jni::objects::{JObject, JString, JValue}; use jni::objects::{JObject, JString, JValue};
@@ -2325,42 +2482,40 @@ where
// at a live JavaVM* set by our bridge_init JNI hook. The // at a live JavaVM* set by our bridge_init JNI hook. The
// unsafe block contains only the cast required by // unsafe block contains only the cast required by
// `JavaVM::from_raw`. // `JavaVM::from_raw`.
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) } let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?; jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
let mut env = jvm let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
.attach_current_thread()
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; let service_name: JString =
env.new_string("audio")
let service_name: JString = .map_err(|e| AudioModeError::MethodCallFailed {
env.new_string("audio") method: "new_string",
detail: e.to_string(),
})?;
let service_name_obj = JObject::from(service_name);
let audio_manager = env
.call_method(
&context_obj,
jni::jni_str!("getSystemService"),
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
&[JValue::Object(&service_name_obj)],
)
.map_err(|e| AudioModeError::MethodCallFailed { .map_err(|e| AudioModeError::MethodCallFailed {
method: "new_string", method: "getSystemService",
detail: e.to_string(), detail: e.to_string(),
})?
.l()
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
detail: format!("obj cast: {e}"),
})?; })?;
let audio_manager = env if audio_manager.is_null() {
.call_method( return Err(AudioModeError::Other(
&context_obj, "AudioManager service is null".to_string(),
"getSystemService", ));
"(Ljava/lang/String;)Ljava/lang/Object;", }
&[JValue::Object(&service_name.into())], op(env, &audio_manager)
) })
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
detail: e.to_string(),
})?
.l()
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
detail: format!("obj cast: {e}"),
})?;
if audio_manager.is_null() {
return Err(AudioModeError::Other(
"AudioManager service is null".to_string(),
));
}
op(&mut env, &audio_manager)
}); });
match result { match result {
Ok(inner) => inner, Ok(inner) => inner,
@@ -2379,16 +2534,21 @@ where
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> { pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
android_audio_manager_call(|env, audio_manager| { android_audio_manager_call(|env, audio_manager| {
env.call_method(audio_manager, "getMode", "()I", &[]) env.call_method(
.map_err(|e| AudioModeError::MethodCallFailed { audio_manager,
method: "getMode", jni::jni_str!("getMode"),
detail: e.to_string(), jni::jni_sig!("()I"),
})? &[],
.i() )
.map_err(|e| AudioModeError::MethodCallFailed { .map_err(|e| AudioModeError::MethodCallFailed {
method: "getMode", method: "getMode",
detail: format!("int cast: {e}"), detail: e.to_string(),
}) })?
.i()
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getMode",
detail: format!("int cast: {e}"),
})
}) })
} }
@@ -2402,11 +2562,16 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
use jni::objects::JValue; use jni::objects::JValue;
android_audio_manager_call(move |env, audio_manager| { android_audio_manager_call(move |env, audio_manager| {
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)]) env.call_method(
.map_err(|e| AudioModeError::MethodCallFailed { audio_manager,
method: "setMode", jni::jni_str!("setMode"),
detail: e.to_string(), jni::jni_sig!("(I)V"),
})?; &[JValue::Int(mode)],
)
.map_err(|e| AudioModeError::MethodCallFailed {
method: "setMode",
detail: e.to_string(),
})?;
Ok(()) Ok(())
}) })
} }
@@ -2463,6 +2628,7 @@ pub mod bench_seam {
tx, tx,
transmit_active.clone(), transmit_active.clone(),
frames_sent, frames_sent,
Arc::new(crate::SharedAudioProcessingStats::default()),
); );
Self { Self {
state, state,
+82 -102
View File
@@ -42,13 +42,11 @@ mod inner {
use coreaudio::audio_unit::render_callback::{self, data}; use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType; use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::mobile_voice_backend::VoiceAudioParams; use crate::mobile_voice_backend::VoiceAudioParams;
use crate::processor::AudioProcessor; use crate::processor::AudioProcessor;
use crate::AudioError; use crate::AudioError;
use chanora_protocol::OutPacket;
const SAMPLE_RATE_HZ: f64 = 48_000.0; const SAMPLE_RATE_HZ: f64 = 48_000.0;
@@ -63,43 +61,10 @@ mod inner {
/// If the capture callback runs before the render callback has written /// If the capture callback runs before the render callback has written
/// a frame it reads zeros (silence reference), which is safe — Sonora /// a frame it reads zeros (silence reference), which is safe — Sonora
/// AEC3 simply skips cancellation for that frame. /// AEC3 simply skips cancellation for that frame.
struct RenderReferenceBuffer { type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
buf: Box<[[f32; 480]; 4]>, type RenderReferenceFrameAccumulator =
write_idx: std::sync::atomic::AtomicUsize, crate::render_reference::RenderReferenceFrameAccumulator<480>;
} const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
impl RenderReferenceBuffer {
fn new() -> Arc<Self> {
Arc::new(Self {
buf: Box::new([[0.0; 480]; 4]),
write_idx: std::sync::atomic::AtomicUsize::new(0),
})
}
/// Write one 10 ms render-reference frame. Realtime-safe.
fn write(&self, frame: &[f32; 480]) {
let idx = self.write_idx.load(Ordering::Relaxed);
// SAFETY: only one writer (render callback); torn reads
// are bounded to one frame of AEC degradation.
unsafe {
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
(*slot).copy_from_slice(frame);
}
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
}
/// Read the most recently completed render-reference frame.
fn read_latest(&self) -> [f32; 480] {
let wi = self.write_idx.load(Ordering::Relaxed);
let ri = (wi + 3) % 4;
self.buf[ri]
}
}
// SAFETY: accessed from two audio callback threads; data races are
// bounded to one frame of AEC quality degradation.
unsafe impl Send for RenderReferenceBuffer {}
unsafe impl Sync for RenderReferenceBuffer {}
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Capture pipeline state // // Capture pipeline state //
@@ -109,10 +74,9 @@ mod inner {
encoder: OpusEncoder, encoder: OpusEncoder,
pcm_accum: Vec<i16>, pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32, mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad, vad_detector: crate::vad::WebRtcFallbackVad,
@@ -128,7 +92,6 @@ mod inner {
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize, pending_10ms_len: usize,
fallback_warned_backend: Option<crate::VadBackend>, fallback_warned_backend: Option<crate::VadBackend>,
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
} }
impl RawCaptureState { impl RawCaptureState {
@@ -146,10 +109,13 @@ mod inner {
encoder, encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: params.voice_out_tx.clone(), voice_out_tx: crate::opus_voice::start_out_packet_worker(
params.voice_out_tx.clone(),
params.frames_sent.clone(),
"ios-raw",
)?,
transmit_active: params.transmit_active.clone(), transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(), output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain, mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(), voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(), vad_detector: crate::vad::WebRtcFallbackVad::default(),
@@ -166,7 +132,6 @@ mod inner {
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0, pending_10ms_len: 0,
fallback_warned_backend: None, fallback_warned_backend: None,
wav_recorder: None,
}) })
} }
@@ -204,6 +169,7 @@ mod inner {
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES { if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms; let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame); self.process_10ms_capture_frame(&frame);
self.encode_complete_20ms_frames();
self.pending_10ms_len = 0; self.pending_10ms_len = 0;
} }
} }
@@ -213,7 +179,10 @@ mod inner {
return; return;
} }
// Encode complete 20 ms Opus frames. self.encode_complete_20ms_frames();
}
fn encode_complete_20ms_frames(&mut self) {
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES { while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES]; let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]); frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
@@ -223,7 +192,6 @@ mod inner {
Ok(len) => { Ok(len) => {
crate::opus_voice::send_voip_frame( crate::opus_voice::send_voip_frame(
&self.voice_out_tx, &self.voice_out_tx,
&self.frames_sent,
&self.opus_out, &self.opus_out,
len, len,
|| { || {
@@ -253,20 +221,18 @@ mod inner {
} }
let input_dbfs = crate::frame::dbfs(&frame); let input_dbfs = crate::frame::dbfs(&frame);
// WAV tap: raw mic (before processing). // Debug WAV mic taps are intentionally unavailable on iOS raw
if let Some(ref rec) = self.wav_recorder { // realtime callbacks until WavDebugRecorder supports a
rec.push_raw_mic(&frame); // preallocated handoff path; the current recorder push path
} // allocates per frame.
// Feed render reference to WebRTC APM before capture so AEC can adapt. // Feed render reference to WebRTC APM before capture so AEC can adapt.
let render_ref = self.render_reference.read_latest(); let render_ref = self.render_reference.read_latest();
self.webrtc_apm_processor.process_render(&render_ref); self.webrtc_apm_processor.process_render(&render_ref);
self.webrtc_apm_processor.process_capture(&mut frame); self.webrtc_apm_processor.process_capture(&mut frame);
// WAV tap: processed mic (after WebRTC APM). // Processed-mic debug WAV capture is disabled for the same
if let Some(ref rec) = self.wav_recorder { // realtime allocation reason as the raw-mic tap above.
rec.push_processed_mic(&frame);
}
let voice_activity_mode = self let voice_activity_mode = self
.voice_activity_selector .voice_activity_selector
@@ -300,14 +266,9 @@ mod inner {
self.current_vad_backend = vad_backend; self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None; self.fallback_warned_backend = None;
if vad_backend == crate::VadBackend::SileroOnnx { if vad_backend == crate::VadBackend::SileroOnnx {
self.silero_coreml_worker = self.silero_coreml_worker = None;
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new(); self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
if self.silero_coreml_worker.is_none() { self.audio_processing_stats.set_vad_fallback_active(true);
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} else {
self.audio_processing_stats.set_vad_fallback_active(false);
}
} else { } else {
self.silero_coreml_worker = None; self.silero_coreml_worker = None;
self.audio_processing_stats.set_vad_fallback_active(false); self.audio_processing_stats.set_vad_fallback_active(false);
@@ -325,20 +286,38 @@ mod inner {
speech: true, speech: true,
} }
} else if vad_backend == crate::VadBackend::SileroOnnx { } else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_coreml_worker.as_ref() { match crate::vad::callback_vad_worker_policy(
let enqueued = worker.try_send(capture_seq, &frame); voice_activity_mode,
if !worker.is_stale(capture_seq) { vad_backend,
let p = worker.latest_probability(); self.silero_coreml_worker.is_some(),
crate::vad::VadOutput { ) {
probability: p, crate::vad::VadWorkerPolicy::UseWorker => {
speech: p >= 0.5, let worker = self
.silero_coreml_worker
.as_ref()
.expect("policy checked worker");
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
} }
} else if enqueued { }
crate::vad::VadOutput { crate::vad::VadWorkerPolicy::UseFallback => {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true; used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend); self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms( crate::vad::VoiceActivityDetector::process_10ms(
@@ -346,13 +325,10 @@ mod inner {
&frame, &frame,
) )
} }
} else { crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
used_fallback_vad = true; probability: 1.0,
self.mark_vad_fallback_active(vad_backend); speech: true,
crate::vad::VoiceActivityDetector::process_10ms( },
&mut self.vad_detector,
&frame,
)
} }
} else { } else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -378,15 +354,12 @@ mod inner {
return; return;
} }
let gain = self.mic_gain; if crate::capture_accumulator::append_processed_i16_bounded(
if (gain - 1.0).abs() < f32::EPSILON { &mut self.pcm_accum,
self.pcm_accum &frame,
.extend(frame.iter().copied().map(crate::frame::f32_to_i16)); self.mic_gain,
} else { ) {
self.pcm_accum.extend(frame.iter().copied().map(|s| { self.audio_processing_stats.increment_callback_xrun();
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
} }
} }
} }
@@ -446,7 +419,9 @@ mod inner {
}) })
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?; .map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
let mut scratch: Vec<f32> = Vec::with_capacity(2048); let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
let handler = params.handler.clone(); let handler = params.handler.clone();
let output_gain = params.output_gain.clone(); let output_gain = params.output_gain.clone();
let output_muted = params.output_muted.clone(); let output_muted = params.output_muted.clone();
@@ -455,9 +430,10 @@ mod inner {
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| { unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let out = args.data.buffer; let out = args.data.buffer;
let n = out.len(); let n = out.len();
let stereo_n = n * 2; let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
if scratch.len() < stereo_n { let stereo_n = process_n * 2;
scratch.resize(stereo_n, 0.0); if n > RAW_RENDER_SCRATCH_FRAMES {
stats_render.increment_callback_xrun();
} }
scratch[..stereo_n].fill(0.0); scratch[..stereo_n].fill(0.0);
@@ -475,22 +451,26 @@ mod inner {
} }
// INV_012: copy render reference BEFORE playout. // INV_012: copy render reference BEFORE playout.
let mono_n = n.min(480);
let mut ref_frame = [0.0_f32; 480];
crate::voice_render::downmix_stereo_f32_to_mono_f32( crate::voice_render::downmix_stereo_f32_to_mono_f32(
&scratch[..stereo_n], &scratch[..stereo_n],
&mut ref_frame[..mono_n], &mut mono[..process_n],
); );
render_ref_buf.write(&ref_frame); render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
render_ref_buf.write(frame);
});
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed)); let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
let muted = output_muted.load(Ordering::Relaxed); let muted = output_muted.load(Ordering::Relaxed);
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16( let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
&scratch[..stereo_n], &scratch[..stereo_n],
out, &mut out[..process_n],
1,
gain, gain,
muted, muted,
); );
if process_n < n {
out[process_n..].fill(0);
}
if mix_stats.clipped_samples > 0 { if mix_stats.clipped_samples > 0 {
stats_render.add_clipped_samples(mix_stats.clipped_samples); stats_render.add_clipped_samples(mix_stats.clipped_samples);
} }
+391 -214
View File
@@ -66,7 +66,7 @@
//! * AVAudioSession category / mode configuration — Swift owns the //! * AVAudioSession category / mode configuration — Swift owns the
//! session (it must be set up before Flutter loads). //! session (it must be set up before Flutter loads).
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder; use audiopus::coder::Encoder as OpusEncoder;
@@ -74,12 +74,11 @@ use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data}; use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType; use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc; use crossbeam::queue::ArrayQueue;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::mobile_voice_backend::VoiceAudioParams; use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError; use crate::AudioError;
use chanora_protocol::OutPacket;
/// Sample rate every layer above us assumes. Matches the Opus /// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the /// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
@@ -104,6 +103,15 @@ const INPUT_BUS: Element = Element::Input;
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160). /// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
const PRE_ROLL_FRAMES: usize = 16; const PRE_ROLL_FRAMES: usize = 16;
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
/// growing inside the input callback.
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
/// to this capacity and the remaining output is silence.
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
/// Capture pipeline state owned by the VPIO input callback. The /// Capture pipeline state owned by the VPIO input callback. The
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no /// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
/// downmix or resample needed — VPIO's hardware-side mix-down /// downmix or resample needed — VPIO's hardware-side mix-down
@@ -131,10 +139,9 @@ struct IosCaptureState {
/// jitter without reallocating. /// jitter without reallocating.
pcm_accum: Vec<i16>, pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>, voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>, transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>, output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32, mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>, voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad, vad_detector: crate::vad::WebRtcFallbackVad,
@@ -153,7 +160,6 @@ struct IosCaptureState {
pre_roll_count: usize, pre_roll_count: usize,
pre_roll_flushed: bool, pre_roll_flushed: bool,
capture_frame_seq: u64, capture_frame_seq: u64,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
} }
impl IosCaptureState { impl IosCaptureState {
@@ -161,20 +167,20 @@ impl IosCaptureState {
/// Encoder configuration is the same as cpal-side /// Encoder configuration is the same as cpal-side
/// `try_open_capture` (engine.rs) so audio quality is platform- /// `try_open_capture` (engine.rs) so audio quality is platform-
/// neutral. /// neutral.
fn new( fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
params: &VoiceAudioParams,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?; let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
Ok(Self { Ok(Self {
encoder, encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: params.voice_out_tx.clone(), voice_out_tx: crate::opus_voice::start_out_packet_worker(
params.voice_out_tx.clone(),
params.frames_sent.clone(),
"ios-vpio",
)?,
transmit_active: params.transmit_active.clone(), transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(), output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain, mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(), voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(), vad_detector: crate::vad::WebRtcFallbackVad::default(),
@@ -192,7 +198,6 @@ impl IosCaptureState {
pre_roll_count: 0, pre_roll_count: 0,
pre_roll_flushed: false, pre_roll_flushed: false,
capture_frame_seq: 0, capture_frame_seq: 0,
wav_recorder,
}) })
} }
@@ -266,7 +271,6 @@ impl IosCaptureState {
Ok(len) => { Ok(len) => {
crate::opus_voice::send_voip_frame( crate::opus_voice::send_voip_frame(
&self.voice_out_tx, &self.voice_out_tx,
&self.frames_sent,
&self.opus_out, &self.opus_out,
len, len,
|| { || {
@@ -297,25 +301,9 @@ impl IosCaptureState {
} }
let input_dbfs = crate::frame::dbfs(&frame); let input_dbfs = crate::frame::dbfs(&frame);
// WAV tap: raw mic (before processing, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_raw_mic(&frame);
}
}
// Read config once per frame (try_lock: non-blocking, falls back to // Read config once per frame (try_lock: non-blocking, falls back to
// last-known values if the lock is contended — safe to miss one frame). // last-known values if the lock is contended — safe to miss one frame).
let ( let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
run_ns,
run_agc,
run_hpf,
vad_backend,
vad_hangover,
debug_wav_dump_enabled,
route,
processing_backend,
) = self
.audio_processing_config .audio_processing_config
.try_lock() .try_lock()
.map(|cfg| { .map(|cfg| {
@@ -331,8 +319,6 @@ impl IosCaptureState {
cfg.vad_backend, cfg.vad_backend,
cfg.vad_hangover_ms, cfg.vad_hangover_ms,
cfg.debug_wav_dump_enabled, cfg.debug_wav_dump_enabled,
cfg.route,
cfg.processing_backend,
) )
}) })
.unwrap_or(( .unwrap_or((
@@ -342,10 +328,13 @@ impl IosCaptureState {
crate::VadBackend::WebrtcVad, crate::VadBackend::WebrtcVad,
crate::voice_activity::VAD_HANGOVER_MS, crate::voice_activity::VAD_HANGOVER_MS,
false, false,
crate::AudioRoute::Unknown,
crate::AudioBackend::PlatformVoiceProcessing,
)); ));
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
// path allocates per frame. Debug WAV capture is intentionally disabled
// here until the recorder can hand off preallocated frames.
let _ = debug_wav_dump_enabled;
let voice_activity_mode = self let voice_activity_mode = self
.voice_activity_selector .voice_activity_selector
.as_ref() .as_ref()
@@ -358,32 +347,13 @@ impl IosCaptureState {
self.audio_processing_stats.set_vad_fallback_active(false); self.audio_processing_stats.set_vad_fallback_active(false);
} }
// Switch VAD backend only while VoiceActivity mode is active.
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
if debug_wav_dump_enabled {
if recorder_guard.is_none() {
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
route,
processing_backend,
));
}
} else if let Some(recorder) = recorder_guard.take() {
recorder.stop();
}
}
if voice_activity_mode && vad_backend != self.current_vad_backend { if voice_activity_mode && vad_backend != self.current_vad_backend {
self.current_vad_backend = vad_backend; self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None; self.fallback_warned_backend = None;
if vad_backend == crate::VadBackend::SileroOnnx { if vad_backend == crate::VadBackend::SileroOnnx {
self.silero_coreml_worker = self.silero_coreml_worker = None;
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new(); self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
if self.silero_coreml_worker.is_none() { self.audio_processing_stats.set_vad_fallback_active(true);
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} else {
self.audio_processing_stats.set_vad_fallback_active(false);
}
} else { } else {
self.silero_coreml_worker = None; self.silero_coreml_worker = None;
self.audio_processing_stats.set_vad_fallback_active(false); self.audio_processing_stats.set_vad_fallback_active(false);
@@ -436,20 +406,38 @@ impl IosCaptureState {
speech: true, speech: true,
} }
} else if vad_backend == crate::VadBackend::SileroOnnx { } else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_coreml_worker.as_ref() { match crate::vad::callback_vad_worker_policy(
let enqueued = worker.try_send(capture_seq, &frame); voice_activity_mode,
if !worker.is_stale(capture_seq) { vad_backend,
let p = worker.latest_probability(); self.silero_coreml_worker.is_some(),
crate::vad::VadOutput { ) {
probability: p, crate::vad::VadWorkerPolicy::UseWorker => {
speech: p >= 0.5, let worker = self
.silero_coreml_worker
.as_ref()
.expect("policy checked worker");
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
} }
} else if enqueued { }
crate::vad::VadOutput { crate::vad::VadWorkerPolicy::UseFallback => {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true; used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms( crate::vad::VoiceActivityDetector::process_10ms(
@@ -457,10 +445,10 @@ impl IosCaptureState {
&frame, &frame,
) )
} }
} else { crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
used_fallback_vad = true; probability: 1.0,
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); speech: true,
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) },
} }
} else { } else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -483,13 +471,6 @@ impl IosCaptureState {
transmit_active, transmit_active,
); );
// WAV tap: processed mic (after Rust DSP, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_processed_mic(&frame);
}
}
// Convert to i16 for accumulation. // Convert to i16 for accumulation.
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES]; let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
if (self.mic_gain - 1.0).abs() < f32::EPSILON { if (self.mic_gain - 1.0).abs() < f32::EPSILON {
@@ -527,7 +508,13 @@ impl IosCaptureState {
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1); let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
for i in 0..pre_roll_to_emit { for i in 0..pre_roll_to_emit {
let idx = (oldest + i) % PRE_ROLL_FRAMES; let idx = (oldest + i) % PRE_ROLL_FRAMES;
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]); if crate::capture_accumulator::append_i16_bounded(
&mut self.pcm_accum,
&self.pre_roll_buf[idx],
) {
self.audio_processing_stats.increment_callback_xrun();
break;
}
} }
} else if !transmit_active { } else if !transmit_active {
// Gate closed — reset the flush flag so pre-roll fires again // Gate closed — reset the flush flag so pre-roll fires again
@@ -539,7 +526,9 @@ impl IosCaptureState {
return; return;
} }
self.pcm_accum.extend_from_slice(&pcm_frame); if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
self.audio_processing_stats.increment_callback_xrun();
}
} }
} }
@@ -550,6 +539,13 @@ pub struct IosVoiceUnit {
// wrapper's own Drop calls AudioComponentInstanceDispose // wrapper's own Drop calls AudioComponentInstanceDispose
// after stop returns. // after stop returns.
unit: Option<AudioUnit>, unit: Option<AudioUnit>,
// macOS-only: producer task (spawned in start_macos) polls
// this on every 20 ms tick and exits when set. Without it
// the tokio task captures `Arc<Mutex<AudioHandler>>` +
// `Arc<ArrayQueue<f32>>` and runs forever, leaking on every
// engine stop/restart cycle.
#[cfg(target_os = "macos")]
producer_shutdown: Arc<AtomicBool>,
} }
impl IosVoiceUnit { impl IosVoiceUnit {
@@ -651,6 +647,44 @@ impl IosVoiceUnit {
) )
.map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?; .map_err(|e| AudioError::Backend(format!("vpio enable input I/O: {e}")))?;
// VPIO defaults to maximum "duck others" — when our voice plays,
// every other app's audio (Music, Safari, Discord, ...) is heavily
// attenuated. That is correct for a phone call but wrong for a
// chat client running alongside music or game audio. There is no
// public "off" switch; the lowest publicly-exposed level is Min
// and disabling Advanced Ducking turns off the voice-activity-
// driven dynamic ducking. This matches what Hume, Moonshine, and
// similar open-source VoIP/voice apps configure.
// Apple ref: kAUVoiceIOProperty_OtherAudioDuckingConfiguration
// Property ID 2108, Global scope, Output element (= 0).
// Struct layout matches AUVoiceIOOtherAudioDuckingConfiguration
// from <AudioToolbox/AUVoiceIOOtherAudioDuckingConfiguration.h>:
// Boolean (u8) mEnableAdvancedDucking + AUVoiceIOOtherAudio-
// DuckingLevel (u32) mDuckingLevel, #[repr(C)] yields 8 bytes
// with the 3-byte natural alignment pad before the u32.
#[repr(C)]
struct AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: u8,
m_ducking_level: u32,
}
const K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION: u32 = 2108;
const K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN: u32 = 10;
let ducking_config = AuVoiceIoOtherAudioDuckingConfiguration {
m_enable_advanced_ducking: 0,
m_ducking_level: K_AU_VOICE_IO_OTHER_AUDIO_DUCKING_LEVEL_MIN,
};
// Apple introduced this property in iOS 17 / macOS 14. On older
// OS versions VPIO returns kAudioUnitErr_InvalidProperty (-10879)
// — log it but never fail VPIO startup over a ducking knob.
if let Err(e) = unit.set_property(
K_AU_VOICE_IO_PROPERTY_OTHER_AUDIO_DUCKING_CONFIGURATION,
Scope::Global,
Element::Output,
Some(&ducking_config),
) {
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
}
// Note: we keep VPIO's voice processing chain ENABLED // Note: we keep VPIO's voice processing chain ENABLED
// (AEC + AGC + NS on the mic path) because it gives us // (AEC + AGC + NS on the mic path) because it gives us
// clean capture for free. The historical playback // clean capture for free. The historical playback
@@ -700,18 +734,7 @@ impl IosVoiceUnit {
// scratch are owned by the closure — no Mutex needed // scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on // because the input callback is the sole writer/reader on
// the audio thread. // the audio thread.
let wav_recorder = Arc::new(Mutex::new({ let mut capture_state = IosCaptureState::new(&params)?;
let cfg = params.audio_processing_config.lock().unwrap().clone();
if cfg.debug_wav_dump_enabled {
Some(crate::debug_wav::WavDebugRecorder::start(
cfg.route,
cfg.processing_backend,
))
} else {
None
}
}));
let mut capture_state = IosCaptureState::new(&params, wav_recorder.clone())?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| { unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers // VPIO with our pinned stream format delivers
@@ -737,13 +760,14 @@ impl IosVoiceUnit {
// buffer + mix. Same primitive cpal + SDL output // buffer + mix. Same primitive cpal + SDL output
// paths use; this is the platform-neutral playback // paths use; this is the platform-neutral playback
// contract from `tsclientlib::audio::AudioHandler`. // contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to i16 mono with master gain. VPIO expects // 2. Downmix to mono i16 with master gain, then copy that
// mono int16 (the stream format we pinned above); // mono sample across every output channel the callback
// the handler produces stereo f32. We average L+R // exposes. We still request mono Int16 from VPIO, but
// to a single mono channel rather than dropping R — // the callback must respect the actual channel count it
// the cpal-side mono-output path made the same // receives. The handler itself produces stereo f32, so
// mistake briefly (commit 6a4dbad / fix) and lost // we average L+R rather than dropping R — the cpal-side
// half the spatial mix. // mono-output path made the same mistake briefly
// (commit 6a4dbad / fix) and lost half the spatial mix.
// 3. Local-mute zeroes the output but STILL drains // 3. Local-mute zeroes the output but STILL drains
// AudioHandler in step 1 so its jitter buffer // AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the // doesn't grow unbounded while muted. This is the
@@ -772,126 +796,270 @@ impl IosVoiceUnit {
// //
// Revert to direct call: render callback locks // Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and // AudioHandler, asks for `num_frames` stereo frames, and
// immediately downmixes to i16 mono into the output // immediately downmixes to mono i16 replicated across the
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16 // callback's actual output channels. Same as Linux/SDL,
// converted at the boundary. // just stereo-f32 -> interleaved-i16 converted at the
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048); // boundary.
let handler_for_render = params.handler.clone(); // macOS cadence-fix (commit-f):
let output_gain_for_render = params.output_gain.clone(); //
let output_muted_for_render = params.output_muted.clone(); // The render callback is the wrong place to call AudioHandler::fill_buffer.
let audio_processing_stats_for_render = params.audio_processing_stats.clone(); // macOS VPIO invokes us with `num_frames=512` (= 10.67 ms @ 48 kHz) which
let wav_recorder_for_render = wav_recorder.clone(); // is NOT a multiple of the 20 ms Opus frame size that AudioHandler expects
// Diagnostic counters (sampled every 100 callbacks ~= 2 s). // inside fill_buffer. The mismatch (a) leaves fill_buffer unable to
let mut cb_count: u64 = 0; // satisfy the request on most calls and (b) advances MAX_PACKET_LOSSES,
let mut last_num_frames: usize = 0; // which removes the talker and starts PLC silence/decay.
let mut num_frames_changes: u32 = 0; //
let mut callbacks_with_audio: u64 = 0; // Converged fix across Mumble (Speex jitter buffer + decoded PCM FIFO),
let mut callbacks_with_silence: u64 = 0; // WebRTC NetEQ (adaptive 80ms prebuffer + sync buffer), Songbird (Fill→
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES]; // Drain playout buffer), cpal/rodio (SPSC ring + zero-fill on underrun),
let mut render_ref_len: usize = 0; // tsclientlib's own SDL example, and the upstream tsclientlib::audio
let mut render_recorder_active = false; // contract: separate ingress quantum (20 ms decoded PCM = 1920 stereo
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| { // f32) from egress quantum (whatever VPIO asks for), connected by a
let out: &mut [i16] = args.data.buffer; // lock-free ring of decoded PCM.
let num_frames = out.len(); //
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats). // Layout:
let needed = num_frames * 2; // * macOS: spawn a tokio producer task paced at 20 ms; each tick
if scratch_stereo.len() < needed { // try-locks AudioHandler, calls fill_buffer(1920), pushes 1920
scratch_stereo.resize(needed, 0.0); // f32 into the ring. The render callback only pops — no lock,
} // no Opus decode, no allocator on the audio thread. 60 ms
// Zero the live slice. AudioHandler::fill_buffer is // prebuffer (3 × 20 ms) is held before the callback starts
// additive (does NOT clear); residual values from // draining, matching Mumble's playout margin and WebRTC's
// earlier callbacks (when scratch was bigger) would // kStartDelayMs order of magnitude. Crossbeam ArrayQueue
// leak through otherwise. // is used because we already depend on crossbeam-queue.
scratch_stereo[..needed].fill(0.0); // * iOS: keep the existing direct fill_buffer path — VPIO on iOS
// Non-blocking fill on the realtime callback thread. // requests 480-frame slices that ARE 20 ms aligned so the
// If the inbound forwarder currently owns this mutex, // cadence mismatch does not arise there.
// emit this period as silence instead of blocking and #[cfg(target_os = "macos")]
// risking an AudioUnit underrun pop/click. let producer_shutdown = Arc::new(AtomicBool::new(false));
match handler_for_render.try_lock() {
Ok(mut h) => {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats_for_render.increment_callback_xrun();
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed)); #[cfg(target_os = "macos")]
let muted = output_muted_for_render.load(Ordering::Relaxed); {
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16( // 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms
&scratch_stereo[..needed], // prebuffer plus a few jitter spikes fit without forcing the
out, // producer to drop frames. crossbeam ArrayQueue is fixed-cap
gain, // and lock-free SPSC-ish (MPMC but wait-free per end); for
muted, // single producer + single consumer it's effectively SPSC.
); const RING_CAPACITY: usize = 12000;
if mix_stats.clipped_samples > 0 { const PULL_SAMPLES: usize = 1920; // one 20 ms Opus frame, stereo
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples); const PREBUFFER_SAMPLES: usize = 9600; // 100 ms @ 48 kHz stereo
}
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
if let Ok(guard) = wav_recorder_for_render.try_lock() { let pcm_ring: Arc<ArrayQueue<f32>> = Arc::new(ArrayQueue::new(RING_CAPACITY));
if let Some(rec) = guard.as_ref() { let pcm_ring_producer = pcm_ring.clone();
if !render_recorder_active { let pcm_ring_consumer = pcm_ring.clone();
render_ref_len = 0; let handler_for_producer = params.handler.clone();
render_ref_accum.fill(0.0); let output_gain_for_render = params.output_gain.clone();
render_recorder_active = true; let output_muted_for_render = params.output_muted.clone();
let producer_shutdown_for_task = producer_shutdown.clone();
tokio::spawn(async move {
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
if producer_shutdown_for_task.load(Ordering::Relaxed) {
break;
} }
let mut idx = 0; match handler_for_producer.try_lock() {
while idx + 1 < needed { Ok(mut h) => {
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5; pull_scratch.fill(0.0);
render_ref_accum[render_ref_len] = mono; let _ = h.fill_buffer(&mut pull_scratch[..]);
render_ref_len += 1; for &s in &pull_scratch {
idx += 2; pcm_ring_producer.force_push(s);
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES { }
rec.push_render_reference(&render_ref_accum); }
render_ref_len = 0; Err(std::sync::TryLockError::WouldBlock) => {}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio",
"producer: AudioHandler mutex poisoned: {e}");
break;
} }
} }
} else {
render_recorder_active = false;
} }
} else { });
render_recorder_active = false;
}
// Track audio-vs-silence for the diagnostic. unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
if mix_stats.peak_i16 > 0 { let render_callback::Args {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1); data, num_frames, ..
} else { } = args;
audio_processing_stats_for_render.increment_output_underrun(); let out: &mut [i16] = data.buffer;
callbacks_with_silence = callbacks_with_silence.wrapping_add(1); let out_channels = data.channels;
} let needed = num_frames * out_channels;
// Diagnostic sampling. if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
if last_num_frames != 0 && last_num_frames != num_frames { for sample in &mut out[..needed] {
num_frames_changes = num_frames_changes.wrapping_add(1); *sample = 0;
} }
last_num_frames = num_frames; return Ok(());
cb_count = cb_count.wrapping_add(1); }
if cb_count.is_multiple_of(100) {
debug!( // Pop L,R as a pair per frame. The pairing is
target: "chanora_audio", // load-bearing — `written += 2` half-buffer bug.
cb = cb_count, let mut written_frames: usize = 0;
num_frames, while written_frames < num_frames {
frames_changes = num_frames_changes, let l = match pcm_ring_consumer.pop() {
callbacks_with_audio, Some(v) => v,
callbacks_with_silence, None => break,
peak_out_i16 = mix_stats.peak_i16, };
let r = pcm_ring_consumer.pop().unwrap_or(0.0);
let mut frame = [l, r];
crate::voice_render::limit_peak_inplace(&mut frame, 0.99);
let (l_lim, r_lim) = (frame[0], frame[1]);
let base = written_frames * out_channels;
if out_channels == 1 {
let mono = (l_lim + r_lim) * 0.5;
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
} else {
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
}
written_frames += 1;
}
if written_frames < num_frames {
let remaining = num_frames - written_frames;
for f in 0..remaining {
let base = (written_frames + f) * out_channels;
for c in 0..out_channels {
out[base + c] = 0;
}
}
}
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
if muted {
for sample in &mut out[..needed] {
*sample = 0;
}
} else if gain != 1.0 {
for sample in &mut out[..needed] {
*sample = (((*sample as f32) * gain)
.clamp(i16::MIN as f32, i16::MAX as f32))
as i16;
}
}
Ok(())
})
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
}
// iOS path: direct fill_buffer in callback. iOS VPIO
// requests 480-frame slices that align with tsclientlib's
// 20 ms Opus frame, so the cadence mismatch that macOS
// hits does not arise here. macOS has its own cfg-gated
// producer-task path above.
#[cfg(target_os = "ios")]
{
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
// Level meter decimation: the render callback fires ~93
// times/sec, but the bridge consumer reads at ~30 Hz.
let mut render_level_decimation: u32 = 0;
// Debug WAV render-reference capture is intentionally unavailable
// on iOS VPIO callbacks until WavDebugRecorder supports a
// preallocated handoff; its current push path allocates per frame.
// Diagnostic counters sampled every 100 callbacks.
let mut cb_count: u64 = 0;
let mut last_num_frames: usize = 0;
let mut num_frames_changes: u64 = 0;
let mut callbacks_with_audio: u64 = 0;
let mut callbacks_with_silence: u64 = 0;
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data, num_frames, ..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
if process_frames < num_frames {
audio_processing_stats_for_render.increment_callback_xrun();
}
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
let needed = process_frames * 2;
// Zero the live slice. AudioHandler::fill_buffer is
// additive (does NOT clear); residual values from
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
match handler_for_render.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats_for_render.increment_callback_xrun();
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
// Peak limiter — multi-client mixes can sum past 0 dBFS;
// without this the downmix helper would hard-clip to i16::MAX.
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
&scratch_stereo[..needed],
out,
out_channels,
gain, gain,
"ios audio unit render callback diagnostic sample (direct fill_buffer)" muted,
); );
} if mix_stats.clipped_samples > 0 {
Ok(()) audio_processing_stats_for_render
}) .add_clipped_samples(mix_stats.clipped_samples);
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?; }
render_level_decimation = render_level_decimation.wrapping_add(1);
if render_level_decimation % 3 == 0 {
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
}
// Track audio-vs-silence for the diagnostic.
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
// Muted output writes intentional silence (peak_i16 == 0 by
// design), not a starved render path. Gate on !muted to avoid
// counting deliberate silence as an output underrun.
if !muted {
audio_processing_stats_for_render.increment_output_underrun();
}
}
// Diagnostic sampling.
if last_num_frames != 0 && last_num_frames != num_frames {
num_frames_changes = num_frames_changes.wrapping_add(1);
}
last_num_frames = num_frames;
cb_count = cb_count.wrapping_add(1);
if cb_count.is_multiple_of(100) {
debug!(
target: "chanora_audio",
cb = cb_count,
num_frames,
frames_changes = num_frames_changes,
callbacks_with_audio,
callbacks_with_silence,
peak_out_i16 = mix_stats.peak_i16,
gain,
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
);
}
Ok(())
})
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
} // end #[cfg(target_os = "ios")] block
// Finalise the unit — allocates internal buffers per the // Finalise the unit — allocates internal buffers per the
// stream formats we set above. After initialize() most // stream formats we set above. After initialize() most
@@ -994,7 +1162,11 @@ impl IosVoiceUnit {
), ),
} }
Ok(Self { unit: Some(unit) }) Ok(Self {
unit: Some(unit),
#[cfg(target_os = "macos")]
producer_shutdown,
})
} }
/// Restart the audio unit after route change handling. /// Restart the audio unit after route change handling.
@@ -1038,6 +1210,11 @@ impl IosVoiceUnit {
impl Drop for IosVoiceUnit { impl Drop for IosVoiceUnit {
fn drop(&mut self) { fn drop(&mut self) {
// Signal the macOS producer task to exit on its next tick
// (up to 20 ms) so it releases its handler / ring clones.
#[cfg(target_os = "macos")]
self.producer_shutdown.store(true, Ordering::Relaxed);
// Stop the audio unit so the render callback no longer // Stop the audio unit so the render callback no longer
// fires. The coreaudio-rs wrapper's own Drop calls // fires. The coreaudio-rs wrapper's own Drop calls
// AudioComponentInstanceDispose afterwards. // AudioComponentInstanceDispose afterwards.
+15
View File
@@ -28,7 +28,17 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#[cfg(any(target_os = "android", test))]
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod android_render_ring;
#[cfg(any(target_os = "android", test))]
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod audio_event_queue;
pub mod audio_processing; pub mod audio_processing;
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod capture_accumulator;
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod capture_resampler;
pub mod debug_wav; pub mod debug_wav;
mod engine; mod engine;
pub mod frame; pub mod frame;
@@ -39,6 +49,11 @@ pub mod processor;
pub mod ptt; pub mod ptt;
pub mod ptt_backends; pub mod ptt_backends;
pub mod release_tail; pub mod release_tail;
#[cfg_attr(
not(any(target_os = "android", target_os = "ios", test)),
allow(dead_code)
)]
pub(crate) mod render_reference;
pub mod route_policy; pub mod route_policy;
pub mod transmit_mode; pub mod transmit_mode;
pub mod transmit_selector; pub mod transmit_selector;
@@ -67,7 +67,7 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
pub type AudioSessionId = i32; pub type AudioSessionId = i32;
/// Engine-owned state shared with mobile voice audio callbacks. /// Engine-owned state shared with mobile voice audio callbacks.
#[derive(Clone)] #[cfg_attr(target_os = "ios", derive(Clone))]
pub(crate) struct VoiceAudioParams { pub(crate) struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the /// Opus-encoded voice packets sent on this channel toward the
/// protocol layer. /// protocol layer.
@@ -78,8 +78,18 @@ pub(crate) struct VoiceAudioParams {
pub frames_sent: Arc<AtomicU32>, pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity). /// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32, pub mic_gain: f32,
/// AudioHandler owned by the Android output callback.
#[cfg(target_os = "android")]
pub handler: AudioHandler<SessionAudioId>,
/// Producer used by Android engine tasks to feed the output callback.
#[cfg(target_os = "android")]
pub event_producer: crate::audio_event_queue::AudioEventProducer,
/// AudioHandler that inbound decode+mix feeds into; the output /// AudioHandler that inbound decode+mix feeds into; the output
/// callback pulls mixed stereo f32 from it. /// callback pulls mixed stereo f32 from it. iOS, macOS, and desktop
/// share this `Arc<Mutex<...>>` shape; the realtime callback uses
/// `try_lock` so it never blocks on the tokio decode task (see
/// `ios_voice_unit.rs` render callback).
#[cfg(not(target_os = "android"))]
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>, pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free /// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback). /// cross-thread read from the realtime audio callback).
+198 -15
View File
@@ -3,15 +3,18 @@ use audiopus::{
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels, Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate, SampleRate as OpusSampleRate,
}; };
use std::sync::atomic::{AtomicU32, Ordering}; use crossbeam::queue::ArrayQueue;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{info, warn}; use tracing::{debug, info, warn};
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket}; use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
use crate::AudioError; use crate::AudioError;
pub(crate) const MAX_OPUS_FRAME: usize = 1275; pub(crate) const MAX_OPUS_FRAME: usize = 1275;
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
const VOIP_BITRATE_BPS: i32 = 32_000; const VOIP_BITRATE_BPS: i32 = 32_000;
const VOIP_COMPLEXITY: u8 = 10; const VOIP_COMPLEXITY: u8 = 10;
@@ -54,10 +57,129 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
); );
} }
pub(crate) struct EncodedVoiceFrame {
data: [u8; MAX_OPUS_FRAME],
len: usize,
}
pub(crate) struct EncodedVoiceFrameSender {
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
open: Arc<AtomicBool>,
}
enum EncodedVoiceFrameSendError {
Full,
Closed,
}
impl EncodedVoiceFrameSender {
fn new(capacity: usize) -> Self {
Self {
queue: Arc::new(ArrayQueue::new(capacity)),
open: Arc::new(AtomicBool::new(true)),
}
}
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
Arc::clone(&self.queue)
}
fn worker_open_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.open)
}
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
if !self.open.load(Ordering::Relaxed) {
return Err(EncodedVoiceFrameSendError::Closed);
}
self.queue
.push(frame)
.map_err(|_| EncodedVoiceFrameSendError::Full)
}
}
pub(crate) fn start_out_packet_worker(
voice_out_tx: mpsc::Sender<OutPacket>,
frames_sent: Arc<AtomicU32>,
context: &'static str,
) -> Result<EncodedVoiceFrameSender, AudioError> {
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
std::thread::Builder::new()
.name(name)
.spawn(worker)
.map(|_| ())
})
}
fn start_out_packet_worker_with_spawner<S>(
voice_out_tx: mpsc::Sender<OutPacket>,
frames_sent: Arc<AtomicU32>,
context: &'static str,
spawn: S,
) -> Result<EncodedVoiceFrameSender, AudioError>
where
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
{
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
let rx = tx.worker_queue();
let worker_open = tx.worker_open_flag();
spawn(
format!("chanora-{context}-voice-packets"),
Box::new(move || {
loop {
let Some(frame) = rx.pop() else {
if Arc::strong_count(&rx) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
};
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: frame.as_slice(),
});
match voice_out_tx.try_send(packet) {
Ok(()) => {
frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
worker_open.store(false, Ordering::Relaxed);
break;
}
}
}
}),
)
.map_err(|e| {
tx.open.store(false, Ordering::Relaxed);
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
})?;
Ok(tx)
}
impl EncodedVoiceFrame {
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
if len > opus_out.len() || len > MAX_OPUS_FRAME {
return None;
}
let mut data = [0u8; MAX_OPUS_FRAME];
data[..len].copy_from_slice(&opus_out[..len]);
Some(Self { data, len })
}
fn as_slice(&self) -> &[u8] {
&self.data[..self.len]
}
}
/// Encode-scope send helper for a freshly encoded Opus voice frame. /// Encode-scope send helper for a freshly encoded Opus voice frame.
pub(crate) fn send_voip_frame<F, G>( pub(crate) fn send_voip_frame<F, G>(
voice_out_tx: &mpsc::Sender<OutPacket>, voice_out_tx: &EncodedVoiceFrameSender,
frames_sent: &AtomicU32,
opus_out: &[u8], opus_out: &[u8],
len: usize, len: usize,
on_full: F, on_full: F,
@@ -66,16 +188,77 @@ pub(crate) fn send_voip_frame<F, G>(
F: FnOnce(), F: FnOnce(),
G: FnOnce(), G: FnOnce(),
{ {
let packet = OutAudio::new(&AudioData::C2S { let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
id: 0, on_full();
codec: CodecType::OpusVoice, return;
data: &opus_out[..len], };
}); match voice_out_tx.push(frame) {
match voice_out_tx.try_send(packet) { Ok(()) => {}
Ok(()) => { Err(EncodedVoiceFrameSendError::Full) => on_full(),
frames_sent.fetch_add(1, Ordering::Relaxed); Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
} }
Err(mpsc::error::TrySendError::Full(_)) => on_full(), }
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_voice_frame_copies_into_fixed_storage() {
let source = [7u8; MAX_OPUS_FRAME];
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
}
#[test]
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
let source = [0u8; MAX_OPUS_FRAME];
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
}
#[test]
fn encoded_voice_frame_sender_reports_full_without_blocking() {
let sender = EncodedVoiceFrameSender::new(1);
let source = [3u8; MAX_OPUS_FRAME];
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
assert!(sender.push(first).is_ok());
assert!(sender.push(second).is_err());
}
#[test]
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
let sender = EncodedVoiceFrameSender::new(1);
sender.open.store(false, Ordering::Relaxed);
let source = [3u8; MAX_OPUS_FRAME];
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
assert!(matches!(
sender.push(frame),
Err(EncodedVoiceFrameSendError::Closed)
));
assert_eq!(sender.queue.len(), 0);
}
#[test]
fn encoded_voice_frame_sender_reports_spawn_failure() {
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
let frames_sent = Arc::new(AtomicU32::new(0));
let result = start_out_packet_worker_with_spawner(
voice_out_tx,
frames_sent,
"test",
|_name, _worker| Err(std::io::Error::other("spawn failed")),
);
assert!(
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
);
} }
} }
+28 -11
View File
@@ -22,6 +22,7 @@
use core::fmt; use core::fmt;
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor}; use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
use thiserror::Error;
mod focused; mod focused;
@@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass {
} }
/// Errors raised by a desktop PTT backend. /// Errors raised by a desktop PTT backend.
#[derive(Debug)] #[derive(Debug, Error)]
pub enum PttBackendError { pub enum PttBackendError {
/// The OS rejected the backend initialisation (e.g. Raw Input /// The OS rejected the backend initialisation (e.g. Raw Input
/// registration failed, event tap creation failed). /// registration failed, event tap creation failed).
#[error("init failed: {0}")]
Init(String), Init(String),
/// The user-granted permission required for global capture is /// The user-granted permission required for global capture is
/// not granted (typically macOS Input Monitoring / Accessibility). /// not granted (typically macOS Input Monitoring / Accessibility).
#[error("permission denied")]
PermissionDenied, PermissionDenied,
/// The display server or compositor does not expose the /// The display server or compositor does not expose the
/// expected interface (typically a non-tested Linux compositor). /// expected interface (typically a non-tested Linux compositor).
#[error("unsupported environment")]
UnsupportedEnvironment, UnsupportedEnvironment,
/// Caller submitted a binding whose `platform_key` cannot be /// Caller submitted a binding whose `platform_key` cannot be
/// parsed in the active OS. /// parsed in the active OS.
#[error("invalid binding: {0}")]
InvalidBinding(String), InvalidBinding(String),
} }
impl fmt::Display for PttBackendError { #[cfg(test)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { mod tests {
match self { use super::*;
Self::Init(s) => write!(f, "init failed: {s}"),
Self::PermissionDenied => f.write_str("permission denied"), #[test]
Self::UnsupportedEnvironment => f.write_str("unsupported environment"), fn ptt_backend_error_display_strings_stay_stable() {
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"), assert_eq!(
} PttBackendError::Init("rawinput".into()).to_string(),
"init failed: rawinput"
);
assert_eq!(
PttBackendError::PermissionDenied.to_string(),
"permission denied"
);
assert_eq!(
PttBackendError::UnsupportedEnvironment.to_string(),
"unsupported environment"
);
assert_eq!(
PttBackendError::InvalidBinding("bad key".into()).to_string(),
"invalid binding: bad key"
);
} }
} }
impl std::error::Error for PttBackendError {}
/// Cross-platform desktop PTT backend (SDD-081). /// Cross-platform desktop PTT backend (SDD-081).
/// ///
/// All implementations call exactly the audio transmit gate's /// All implementations call exactly the audio transmit gate's
@@ -0,0 +1,241 @@
use std::array;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
const NO_LATEST_SLOT: usize = usize::MAX;
struct Slot<const SAMPLES: usize> {
version: AtomicUsize,
samples: [AtomicU32; SAMPLES],
#[cfg(test)]
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
}
impl<const SAMPLES: usize> Slot<SAMPLES> {
fn new() -> Self {
Self {
version: AtomicUsize::new(0),
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
#[cfg(test)]
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
}
}
}
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
pending: [f32; SAMPLES],
pending_len: usize,
}
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
pub(crate) fn new() -> Self {
assert!(
SAMPLES > 0,
"RenderReferenceFrameAccumulator requires at least one sample"
);
Self {
pending: [0.0; SAMPLES],
pending_len: 0,
}
}
pub(crate) fn push_mono_samples(
&mut self,
mut samples: &[f32],
mut publish: impl FnMut(&[f32; SAMPLES]),
) {
while !samples.is_empty() {
let needed = SAMPLES - self.pending_len;
let take = needed.min(samples.len());
self.pending[self.pending_len..self.pending_len + take]
.copy_from_slice(&samples[..take]);
self.pending_len += take;
samples = &samples[take..];
if self.pending_len == SAMPLES {
publish(&self.pending);
self.pending_len = 0;
}
}
}
#[cfg(test)]
fn pending_len(&self) -> usize {
self.pending_len
}
}
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
slots: Box<[Slot<SAMPLES>; SLOTS]>,
write_idx: AtomicUsize,
latest_slot: AtomicUsize,
}
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
pub(crate) fn new() -> Arc<Self> {
assert!(
SLOTS > 0,
"RenderReferenceBuffer requires at least one slot"
);
Arc::new(Self {
slots: Box::new(array::from_fn(|_| Slot::new())),
write_idx: AtomicUsize::new(0),
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
})
}
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
let slot = &self.slots[idx];
// The acquire half keeps payload stores after the odd in-progress marker.
let version = slot.version.fetch_add(1, Ordering::AcqRel);
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
sample.store(value.to_bits(), Ordering::Relaxed);
}
slot.version
.store(version.wrapping_add(2) & !1, Ordering::Release);
self.latest_slot.store(idx, Ordering::Release);
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
}
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
let mut out = [0.0_f32; SAMPLES];
self.read_latest_into(&mut out);
out
}
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
let idx = self.latest_slot.load(Ordering::Acquire);
if idx == NO_LATEST_SLOT {
out.fill(0.0);
return;
}
let slot = &self.slots[idx];
let before = slot.version.load(Ordering::Acquire);
if before & 1 == 1 {
out.fill(0.0);
return;
}
#[cfg(not(test))]
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
}
#[cfg(test)]
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
if idx == 0
&& slot
.bump_after_first_sample_read
.swap(false, Ordering::Relaxed)
{
slot.version.fetch_add(2, Ordering::Release);
}
}
let after = slot.version.load(Ordering::Acquire);
if before != after || after & 1 == 1 {
out.fill(0.0);
}
}
#[cfg(test)]
fn mark_latest_slot_in_progress_for_test(&self) {
let idx = self.latest_slot.load(Ordering::Acquire);
assert_ne!(idx, NO_LATEST_SLOT);
self.slots[idx].version.fetch_or(1, Ordering::Release);
}
#[cfg(test)]
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
let idx = self.latest_slot.load(Ordering::Acquire);
assert_ne!(idx, NO_LATEST_SLOT);
self.slots[idx]
.bump_after_first_sample_read
.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
#[test]
fn render_reference_reads_zero_before_first_publish() {
let buffer = RenderReferenceBuffer::<4, 2>::new();
assert_eq!(buffer.read_latest(), [0.0; 4]);
}
#[test]
fn render_reference_reader_gets_latest_complete_frame() {
let buffer = RenderReferenceBuffer::<4, 3>::new();
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
}
#[test]
fn render_reference_writes_wrap_without_returning_stale_frame() {
let buffer = RenderReferenceBuffer::<2, 2>::new();
buffer.write(&[1.0, 2.0]);
buffer.write(&[3.0, 4.0]);
buffer.write(&[5.0, 6.0]);
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
}
#[test]
fn render_reference_accumulator_publishes_only_complete_frames() {
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
let mut frames = Vec::new();
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
assert!(frames.is_empty());
assert_eq!(accum.pending_len(), 2);
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
assert_eq!(accum.pending_len(), 3);
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
assert_eq!(accum.pending_len(), 0);
}
#[test]
fn render_reference_reader_rejects_in_progress_slot() {
let buffer = RenderReferenceBuffer::<2, 1>::new();
buffer.write(&[1.0, 2.0]);
buffer.mark_latest_slot_in_progress_for_test();
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
}
#[test]
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
let buffer = RenderReferenceBuffer::<2, 1>::new();
buffer.write(&[1.0, 2.0]);
buffer.bump_latest_slot_version_after_first_sample_for_test();
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
}
#[test]
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
fn render_reference_rejects_zero_slots() {
let _ = RenderReferenceBuffer::<2, 0>::new();
}
}
+59 -1
View File
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{OnceLock, RwLock}; use std::sync::{OnceLock, RwLock};
use crate::frame::{f32_to_i16, i16_to_f32}; use crate::frame::{f32_to_i16, i16_to_f32};
use crate::AudioError; use crate::{AudioError, VadBackend};
use resampler::{Downsampler48to16, INPUT_FRAME_10MS}; use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
@@ -108,6 +108,36 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
detector.process_10ms(&frame) detector.process_10ms(&frame)
} }
/// Callback-side policy for optional model-backed VAD workers.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum VadWorkerPolicy {
/// Keep using the already-available model worker.
UseWorker,
/// No worker may be constructed on the callback thread; use WebRTC fallback.
UseFallback,
/// This backend does not need a model worker.
NotModelBacked,
}
/// Decide whether a realtime callback may use a model-backed VAD worker.
///
/// Model/worker construction is intentionally absent from this policy: if a
/// worker is not already present, callbacks must stay nonblocking and fall back.
pub(crate) fn callback_vad_worker_policy(
voice_activity_mode: bool,
backend: VadBackend,
worker_available: bool,
) -> VadWorkerPolicy {
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
return VadWorkerPolicy::NotModelBacked;
}
if worker_available {
VadWorkerPolicy::UseWorker
} else {
VadWorkerPolicy::UseFallback
}
}
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new(); static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0); static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
@@ -252,4 +282,32 @@ mod tests {
assert_eq!(silero_model_bundle_path(), path.to_string_lossy()); assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn callback_policy_uses_existing_model_worker_only() {
assert_eq!(
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
VadWorkerPolicy::UseWorker
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
VadWorkerPolicy::UseFallback
);
}
#[test]
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
assert_eq!(
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
VadWorkerPolicy::NotModelBacked
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::Disabled, false),
VadWorkerPolicy::NotModelBacked
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
VadWorkerPolicy::NotModelBacked
);
}
} }
+107 -11
View File
@@ -1,4 +1,5 @@
/// Diagnostics returned by render downmix helpers. /// Diagnostics returned by render downmix helpers.
#[cfg(any(target_os = "ios", test))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct RenderDownmixStats { pub(crate) struct RenderDownmixStats {
/// Peak absolute sample magnitude after i16 conversion. /// Peak absolute sample magnitude after i16 conversion.
@@ -7,37 +8,45 @@ pub(crate) struct RenderDownmixStats {
pub clipped_samples: u64, pub clipped_samples: u64,
} }
/// Downmix interleaved stereo f32 samples into mono i16 samples. #[cfg(any(target_os = "ios", test))]
/// pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
/// The helper is allocation-free and safe for realtime render callbacks.
/// If the stereo source is shorter than expected, the remainder of `out`
/// is filled with silence.
pub(crate) fn downmix_stereo_f32_to_mono_i16(
stereo: &[f32], stereo: &[f32],
out: &mut [i16], out: &mut [i16],
out_channels: usize,
gain: f32, gain: f32,
muted: bool, muted: bool,
) -> RenderDownmixStats { ) -> RenderDownmixStats {
if out_channels == 0 {
out.fill(0);
return RenderDownmixStats::default();
}
if muted { if muted {
out.fill(0); out.fill(0);
return RenderDownmixStats::default(); return RenderDownmixStats::default();
} }
let available_frames = stereo.len() / 2; let available_frames = stereo.len() / 2;
if available_frames < out.len() { let requested_frames = out.len() / out_channels;
if available_frames < requested_frames {
out.fill(0); out.fill(0);
} }
let mut peak = 0_u16; let mut peak = 0_u16;
let mut clipped_samples = 0_u64; let mut clipped_samples = 0_u64;
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) { for (dst_frame, lr) in out
.chunks_exact_mut(out_channels)
.zip(stereo.chunks_exact(2))
{
let mono = (lr[0] + lr[1]) * 0.5 * gain; let mono = (lr[0] + lr[1]) * 0.5 * gain;
let clamped = mono.clamp(-1.0, 1.0); let clamped = mono.clamp(-1.0, 1.0);
if (mono - clamped).abs() > f32::EPSILON { if (mono - clamped).abs() > f32::EPSILON {
clipped_samples = clipped_samples.saturating_add(1); clipped_samples = clipped_samples.saturating_add(1);
} }
let sample = (clamped * i16::MAX as f32) as i16; let sample = (clamped * i16::MAX as f32) as i16;
*dst = sample; for dst in dst_frame.iter_mut() {
*dst = sample;
}
peak = peak.max(sample.unsigned_abs()); peak = peak.max(sample.unsigned_abs());
} }
@@ -62,6 +71,29 @@ pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
} }
} }
/// In-place per-frame peak limiter. Scales the entire buffer so the
/// absolute peak equals `threshold`; returns the applied gain (1.0 =
/// no reduction). Used on the render path between `AudioHandler` and
/// the i16 downmix to prevent hard clipping when a multi-client mix
/// exceeds 0 dBFS. Per-frame scaling is sub-millisecond at 48 kHz, so
/// the pumping risk is negligible for speech; a look-ahead design was
/// rejected because it would add latency on top of the existing
/// jitter buffer.
pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
if threshold <= 0.0 || !threshold.is_finite() {
return 1.0;
}
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
if peak <= threshold {
return 1.0;
}
let gain = threshold / peak;
for s in samples.iter_mut() {
*s *= gain;
}
gain
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -71,7 +103,7 @@ mod tests {
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0]; let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
let mut out = [0_i16; 3]; let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false); let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
assert_eq!(out[0], i16::MAX); assert_eq!(out[0], i16::MAX);
assert_eq!(out[1], 0); assert_eq!(out[1], 0);
@@ -85,12 +117,35 @@ mod tests {
let stereo = [1.0_f32, 1.0, -1.0, -1.0]; let stereo = [1.0_f32, 1.0, -1.0, -1.0];
let mut out = [123_i16; 2]; let mut out = [123_i16; 2];
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true); let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
assert_eq!(out, [0, 0]); assert_eq!(out, [0, 0]);
assert_eq!(stats, RenderDownmixStats::default()); assert_eq!(stats, RenderDownmixStats::default());
} }
#[test]
fn downmix_interleaved_i16_copies_mono_to_each_channel() {
let stereo = [1.0_f32, -1.0, 0.25, 0.25];
let mut out = [0_i16; 4];
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 2, 1.0, false);
assert_eq!(out, [0, 0, 8191, 8191]);
assert_eq!(stats.peak_i16, 8191);
assert_eq!(stats.clipped_samples, 0);
}
#[test]
fn downmix_interleaved_i16_mutes_all_channels() {
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
let mut out = [123_i16; 6];
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 3, 1.0, true);
assert_eq!(out, [0, 0, 0, 0, 0, 0]);
assert_eq!(stats, RenderDownmixStats::default());
}
#[test] #[test]
fn downmix_f32_fills_missing_tail_with_silence() { fn downmix_f32_fills_missing_tail_with_silence() {
let stereo = [1.0_f32, -1.0]; let stereo = [1.0_f32, -1.0];
@@ -100,4 +155,45 @@ mod tests {
assert_eq!(out, [0.0, 0.0]); assert_eq!(out, [0.0, 0.0]);
} }
#[test]
fn limit_peak_is_noop_below_threshold() {
let mut samples = [0.1_f32, -0.2, 0.3, -0.4];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert_eq!(gain, 1.0);
assert_eq!(samples, [0.1, -0.2, 0.3, -0.4]);
}
#[test]
fn limit_peak_scales_above_threshold() {
let mut samples = [0.5_f32, 1.0, 2.0, -1.5];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert!((gain - 0.475).abs() < 1e-6, "gain = {gain}");
assert!((samples[0] - 0.2375).abs() < 1e-6);
assert!((samples[1] - 0.475).abs() < 1e-6);
assert!((samples[2] - 0.95).abs() < 1e-6);
assert!((samples[3] - (-0.7125)).abs() < 1e-6);
}
#[test]
fn limit_peak_handles_zero_and_invalid_thresholds() {
let mut samples = [0.5_f32, 1.0];
assert_eq!(limit_peak_inplace(&mut samples, 0.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, -1.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, f32::NAN), 1.0);
assert_eq!(samples, [0.5, 1.0]);
}
#[test]
fn limit_peak_then_downmix_produces_no_clipping() {
// Regression: multi-client mix previously hard-clamped to i16::MAX.
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
limit_peak_inplace(&mut scratch, 0.95);
let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
assert_eq!(stats.clipped_samples, 0);
assert!(stats.peak_i16 < i16::MAX);
}
} }
+142 -19
View File
@@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats {
pub frames_received: u32, pub frames_received: u32,
/// Current push-to-talk state. /// Current push-to-talk state.
pub ptt_active: bool, pub ptt_active: bool,
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
pub input_level: f32,
} }
/// Bridge route class for P1 audio-processing policy. /// Bridge route class for P1 audio-processing policy.
@@ -1701,49 +1703,87 @@ pub enum BridgeEvent {
}, },
/// Audio route changed (speaker/earpiece/BT/wired). /// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged { AudioRouteChanged {
/// New audio output route.
route: BridgeAudioRoute, route: BridgeAudioRoute,
}, },
/// A client moved to a different channel.
ClientMoved { ClientMoved {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Destination channel.
new_channel_id: u64, new_channel_id: u64,
}, },
/// A new client connected.
ClientJoined { ClientJoined {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Channel the client joined.
channel_id: u64, channel_id: u64,
/// Display nickname.
name: String, name: String,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A client disconnected.
ClientLeft { ClientLeft {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Display nickname at time of disconnect.
name: String, name: String,
}, },
/// Client properties changed.
ClientUpdated { ClientUpdated {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// True for server query (bot) clients.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A new channel appeared.
ChannelAdded { ChannelAdded {
/// Unique channel identifier.
id: u64, id: u64,
/// Parent channel ID.
parent: u64, parent: u64,
/// Channel name.
name: String, name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64, order: i64,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
/// A channel was deleted.
ChannelRemoved { ChannelRemoved {
/// Channel identifier.
id: u64, id: u64,
}, },
/// Channel properties changed.
ChannelUpdated { ChannelUpdated {
/// Unique channel identifier.
id: u64, id: u64,
/// Channel name.
name: String, name: String,
/// Whether the channel requires a password.
has_password: bool, has_password: bool,
/// Talk power required to speak; `None` means no restriction.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
} }
@@ -1932,27 +1972,77 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
route: route.into(), route: route.into(),
} }
} }
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => { chanora_core::SessionEvent::ClientMoved {
BridgeEvent::ClientMoved { client_id, new_channel_id } client_id,
} new_channel_id,
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => { } => BridgeEvent::ClientMoved {
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } client_id,
} new_channel_id,
},
chanora_core::SessionEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => BridgeEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
chanora_core::SessionEvent::ClientLeft { client_id, name } => { chanora_core::SessionEvent::ClientLeft { client_id, name } => {
BridgeEvent::ClientLeft { client_id, name } BridgeEvent::ClientLeft { client_id, name }
} }
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => { chanora_core::SessionEvent::ClientUpdated {
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } client_id,
} input_muted,
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => { output_muted,
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } is_server_query,
} talk_power,
chanora_core::SessionEvent::ChannelRemoved { id } => { talk_power_granted,
BridgeEvent::ChannelRemoved { id } } => BridgeEvent::ClientUpdated {
} client_id,
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => { input_muted,
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power } output_muted,
} is_server_query,
talk_power,
talk_power_granted,
},
chanora_core::SessionEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => BridgeEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
},
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
chanora_core::SessionEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => BridgeEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
},
} }
} }
} }
@@ -2058,7 +2148,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
/// Read audio statistics. Errors if no connection or audio not started. /// Read audio statistics. Errors if no connection or audio not started.
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> { pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
let (s, r, p) = runtime() let (s, r, p, lvl) = runtime()
.spawn(async { session().audio_stats().await }) .spawn(async { session().audio_stats().await })
.await .await
.map_err(|e| task_join_error("audio_stats", e))??; .map_err(|e| task_join_error("audio_stats", e))??;
@@ -2066,9 +2156,42 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
frames_sent: s, frames_sent: s,
frames_received: r, frames_received: r,
ptt_active: p, ptt_active: p,
input_level: lvl,
}) })
} }
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels, the session is dropped, or
/// the session becomes persistently unavailable.
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
runtime().spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(33));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut consecutive_errors = 0u32;
loop {
interval.tick().await;
let level = match session().audio_stats().await {
Ok((_, _, _, lvl)) => {
consecutive_errors = 0;
lvl
}
Err(_) => {
consecutive_errors += 1;
if consecutive_errors >= 10 {
return;
}
-120.0
}
};
if sink.add(level).is_err() {
return;
}
}
});
Ok(())
}
/// Apply the P1 audio-processing config. /// Apply the P1 audio-processing config.
pub async fn set_audio_processing_config( pub async fn set_audio_processing_config(
config: BridgeAudioProcessingConfig, config: BridgeAudioProcessingConfig,
+86 -30
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435; pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
// Section: executor // Section: executor
@@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl(
}, },
) )
} }
fn wire__crate__api__input_level_stream_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "input_level_stream",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink =
<StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec>>::sse_decode(
&mut deserializer,
);
deserializer.end();
move |context| {
transform_result_sse::<_, crate::BridgeError>((move || {
let output_ok = crate::api::input_level_stream(api_sink)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__is_connected_impl( fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1790,6 +1826,14 @@ impl SseDecode
} }
} }
impl SseDecode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode for String { impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats {
let mut var_framesSent = <u32>::sse_decode(deserializer); let mut var_framesSent = <u32>::sse_decode(deserializer);
let mut var_framesReceived = <u32>::sse_decode(deserializer); let mut var_framesReceived = <u32>::sse_decode(deserializer);
let mut var_pttActive = <bool>::sse_decode(deserializer); let mut var_pttActive = <bool>::sse_decode(deserializer);
let mut var_inputLevel = <f32>::sse_decode(deserializer);
return crate::api::BridgeAudioStats { return crate::api::BridgeAudioStats {
frames_sent: var_framesSent, frames_sent: var_framesSent,
frames_received: var_framesReceived, frames_received: var_framesReceived,
ptt_active: var_pttActive, ptt_active: var_pttActive,
input_level: var_inputLevel,
}; };
} }
} }
@@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl(
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), 15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), 21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len), 22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), 23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), 24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len), 26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), 27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len), 28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), 30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len), 32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), 33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len), 34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), 35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
36 => { 36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
37 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len) wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
} }
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len), 39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), 40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), 41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), 42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), 45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), 46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), 47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), 48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), 49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), 50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl(
data_len, data_len,
), ),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), 19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), 25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len), 29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len), 31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), 38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
self.frames_sent.into_into_dart().into_dart(), self.frames_sent.into_into_dart().into_dart(),
self.frames_received.into_into_dart().into_dart(), self.frames_received.into_into_dart().into_dart(),
self.ptt_active.into_into_dart().into_dart(), self.ptt_active.into_into_dart().into_dart(),
self.input_level.into_into_dart().into_dart(),
] ]
.into_dart() .into_dart()
} }
@@ -3652,6 +3700,13 @@ impl SseEncode
} }
} }
impl SseEncode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode for String { impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats {
<u32>::sse_encode(self.frames_sent, serializer); <u32>::sse_encode(self.frames_sent, serializer);
<u32>::sse_encode(self.frames_received, serializer); <u32>::sse_encode(self.frames_received, serializer);
<bool>::sse_encode(self.ptt_active, serializer); <bool>::sse_encode(self.ptt_active, serializer);
<f32>::sse_encode(self.input_level, serializer);
} }
} }
+19 -7
View File
@@ -36,7 +36,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(missing_docs)] #![warn(missing_docs)]
use std::collections::HashSet; use std::collections::{HashSet, VecDeque};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use thiserror::Error; use thiserror::Error;
@@ -712,7 +712,7 @@ impl DiagnosticExport {
/// diagnostic export and state-sync replay verification. /// diagnostic export and state-sync replay verification.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProtocolEventRecorder { pub struct ProtocolEventRecorder {
events: Vec<String>, events: VecDeque<String>,
capacity: usize, capacity: usize,
} }
@@ -720,17 +720,20 @@ impl ProtocolEventRecorder {
/// Create a recorder with the given ring-buffer capacity. /// Create a recorder with the given ring-buffer capacity.
pub fn new(capacity: usize) -> Self { pub fn new(capacity: usize) -> Self {
Self { Self {
events: Vec::with_capacity(capacity), events: VecDeque::with_capacity(capacity),
capacity, capacity,
} }
} }
fn push(&mut self, ts: &str, kind: &str, detail: &str) { fn push(&mut self, ts: &str, kind: &str, detail: &str) {
if self.capacity == 0 {
return;
}
let s = format!("[{ts}] {kind}: {detail}"); let s = format!("[{ts}] {kind}: {detail}");
if self.events.len() >= self.capacity { if self.events.len() >= self.capacity {
self.events.remove(0); self.events.pop_front();
} }
self.events.push(s); self.events.push_back(s);
} }
/// Record a successful connection. /// Record a successful connection.
@@ -777,12 +780,12 @@ impl ProtocolEventRecorder {
/// Drain all recorded events and reset the buffer. /// Drain all recorded events and reset the buffer.
pub fn drain(&mut self) -> Vec<String> { pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events) self.events.drain(..).collect()
} }
/// Snapshot all recorded events without clearing the buffer. /// Snapshot all recorded events without clearing the buffer.
pub fn snapshot(&self) -> Vec<String> { pub fn snapshot(&self) -> Vec<String> {
self.events.clone() self.events.iter().cloned().collect()
} }
} }
@@ -1103,4 +1106,13 @@ mod tests {
assert_eq!(first, second); assert_eq!(first, second);
assert_eq!(drained, first); assert_eq!(drained, first);
} }
#[test]
fn protocol_event_zero_capacity_drops_events() {
let mut recorder = ProtocolEventRecorder::new(0);
recorder.record_connected("Server");
assert!(recorder.snapshot().is_empty());
assert!(recorder.drain().is_empty());
}
} }
+215 -119
View File
@@ -46,6 +46,9 @@ use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750); const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40); const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3); const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
type PendingMoves = HashMap< type PendingMoves = HashMap<
MessageHandle, MessageHandle,
@@ -56,6 +59,13 @@ type PendingMoves = HashMap<
), ),
>; >;
struct EventChannels {
voice_in: mpsc::Sender<InboundVoice>,
chat: mpsc::Sender<ChatMessage>,
activity: mpsc::Sender<ServerActivity>,
delta: mpsc::Sender<ProtocolDelta>,
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
enum SendTimeoutError<T> { enum SendTimeoutError<T> {
Timeout(T), Timeout(T),
@@ -77,6 +87,30 @@ async fn send_with_timeout<T: Send>(
} }
} }
fn drain_voice_packets_for_tick<T, E>(
voice_out_rx: &mut mpsc::Receiver<T>,
max_packets: usize,
mut send: impl FnMut(T) -> Result<(), E>,
) -> usize {
let mut drained = 0;
for _ in 0..max_packets {
let packet = match voice_out_rx.try_recv() {
Ok(packet) => packet,
Err(_) => break,
};
let _ = send(packet);
drained += 1;
}
drained
}
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
where
S: futures::Stream,
{
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
}
/// Pick the TeamSpeak `client_version`/platform/signature triple /// Pick the TeamSpeak `client_version`/platform/signature triple
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into /// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
/// `tsproto-types` at vendor-time) that best matches the *runtime* /// `tsproto-types` at vendor-time) that best matches the *runtime*
@@ -278,10 +312,12 @@ impl ProtocolClient {
cfg.clone(), cfg.clone(),
rx, rx,
voice_out_rx, voice_out_rx,
voice_in_tx, EventChannels {
chat_tx, voice_in: voice_in_tx,
activity_tx, chat: chat_tx,
delta_tx, activity: activity_tx,
delta: delta_tx,
},
ready_tx, ready_tx,
lost_tx, lost_tx,
)); ));
@@ -332,8 +368,21 @@ impl ProtocolClient {
/// Disconnect cleanly. Blocks until the task exits. /// Disconnect cleanly. Blocks until the task exits.
pub async fn disconnect(self) { pub async fn disconnect(self) {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
if self.tx.send(Request::Disconnect(tx)).await.is_ok() { let request_path = async {
let _ = rx.await; if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
let _ = rx.await;
}
};
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
.await
.is_err()
{
warn!(
target: "chanora_protocol",
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
"disconnect request did not complete before timeout"
);
} }
} }
@@ -471,6 +520,9 @@ impl ProtocolClient {
} }
} }
/// Takes ownership of the delta receiver channel. Returns `None` if already
/// taken. Must be called exactly once during initialization to subscribe to
/// incremental state changes.
pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> { pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> {
self.delta_rx.lock().ok().and_then(|mut g| g.take()) self.delta_rx.lock().ok().and_then(|mut g| g.take())
} }
@@ -499,10 +551,7 @@ async fn connection_task(
cfg: ConnectConfig, cfg: ConnectConfig,
mut rx: mpsc::Receiver<Request>, mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>, mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>, channels: EventChannels,
chat_tx: mpsc::Sender<ChatMessage>,
activity_tx: mpsc::Sender<ServerActivity>,
delta_tx: mpsc::Sender<ProtocolDelta>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>, ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>, lost_tx: oneshot::Sender<DisconnectReason>,
) { ) {
@@ -667,12 +716,14 @@ async fn connection_task(
// Main loop: pump events, service requests, forward voice. // Main loop: pump events, service requests, forward voice.
loop { loop {
// 1. Drain any outbound voice packets first — they're time-sensitive. // 1. Send a bounded batch of outbound voice packets first — they're
while let Ok(pkt) = voice_out_rx.try_recv() { // time-sensitive, but control requests must still make progress.
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
if let Err(e) = con.send_audio(pkt) { if let Err(e) = con.send_audio(pkt) {
warn!(target: "chanora_protocol", error = %e, "send_audio failed"); warn!(target: "chanora_protocol", error = %e, "send_audio failed");
} }
} Ok::<(), ()>(())
});
// 2. Advance event stream by at most one event with a small timeout. // 2. Advance event stream by at most one event with a small timeout.
let pump = async { let pump = async {
@@ -680,21 +731,19 @@ async fn connection_task(
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
}; };
match pump.await { match pump.await {
Ok(Some(Ok(item))) => { Ok(Some(Ok(item))) => match item {
match item { StreamItem::Audio(buf) => {
StreamItem::Audio(buf) => { handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
handle_audio_stream_item(&voice_in_tx, &mut voice_activity, buf).await;
}
other => handle_non_audio_stream_item(
&con,
other,
&chat_tx,
&activity_tx,
&delta_tx,
&mut pending_moves,
),
} }
} other => handle_non_audio_stream_item(
&con,
other,
&channels.chat,
&channels.activity,
&channels.delta,
&mut pending_moves,
),
},
Ok(Some(Err(e))) => { Ok(Some(Err(e))) => {
warn!(target: "chanora_protocol", error = %e, "event error"); warn!(target: "chanora_protocol", error = %e, "event error");
// Some errors are transient; treat persistent ones // Some errors are transient; treat persistent ones
@@ -727,10 +776,8 @@ async fn connection_task(
}) })
.collect(); .collect();
for handle in expired { for handle in expired {
if let Some((_target_channel, reply, _)) = pending_moves.remove(&handle) { if let Some((_target_channel, Some(reply), _)) = pending_moves.remove(&handle) {
if let Some(reply) = reply { let _ = reply.send(Ok(()));
let _ = reply.send(Ok(()));
}
} }
} }
} }
@@ -791,10 +838,7 @@ async fn connection_task(
let r = fetch_client_profile( let r = fetch_client_profile(
&mut con, &mut con,
client_id, client_id,
&voice_in_tx, &channels,
&chat_tx,
&activity_tx,
&delta_tx,
&mut pending_moves, &mut pending_moves,
&mut voice_activity, &mut voice_activity,
) )
@@ -803,7 +847,7 @@ async fn connection_task(
} }
Ok(Request::Disconnect(reply)) => { Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new()); let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await; bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
let _ = reply.send(()); let _ = reply.send(());
info!(target: "chanora_protocol", "clean disconnect"); info!(target: "chanora_protocol", "clean disconnect");
exit!(DisconnectReason::UserRequested); exit!(DisconnectReason::UserRequested);
@@ -811,7 +855,7 @@ async fn connection_task(
Err(mpsc::error::TryRecvError::Empty) => {} Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => { Err(mpsc::error::TryRecvError::Disconnected) => {
let _ = con.disconnect(DisconnectOptions::new()); let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await; bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
info!(target: "chanora_protocol", "handle dropped; implicit disconnect"); info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
exit!(DisconnectReason::UserRequested); exit!(DisconnectReason::UserRequested);
} }
@@ -869,7 +913,7 @@ fn handle_non_audio_stream_item(
} = &ev } = &ev
{ {
let own_client = con.get_state().ok().map(|state| state.own_client); let own_client = con.get_state().ok().map(|state| state.own_client);
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved { let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -923,7 +967,9 @@ fn handle_non_audio_stream_item(
let mapped = match target { let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server, tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel, tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64), tsclientlib::MessageTarget::Client(id) => {
MessageTarget::Client(id.0 as u64)
}
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64), tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
}; };
let _ = chat_tx.try_send(ChatMessage { let _ = chat_tx.try_send(ChatMessage {
@@ -1115,16 +1161,21 @@ fn send_text_to_mode(
async fn fetch_client_profile( async fn fetch_client_profile(
con: &mut Connection, con: &mut Connection,
client_id: u64, client_id: u64,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<ClientProfile, ProtocolError> { ) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16); let target_id = TsClientId(client_id as u16);
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = { let (
database_id,
uid_b64,
has_optional,
has_connection,
is_own,
needs_server_groups,
needs_channel_groups,
) = {
let state = con let state = con
.get_state() .get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
@@ -1155,10 +1206,7 @@ async fn fetch_client_profile(
let _ = request_messages( let _ = request_messages(
con, con,
build_command("servergrouplist", &[], &[]), build_command("servergrouplist", &[], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1168,10 +1216,7 @@ async fn fetch_client_profile(
let _ = request_messages( let _ = request_messages(
con, con,
build_command("channelgrouplist", &[], &[]), build_command("channelgrouplist", &[], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1185,10 +1230,7 @@ async fn fetch_client_profile(
&[("clid", client_id.to_string())], &[("clid", client_id.to_string())],
&[], &[],
), ),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1206,10 +1248,7 @@ async fn fetch_client_profile(
if let Err(e) = request_messages( if let Err(e) = request_messages(
con, con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]), build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1225,18 +1264,9 @@ async fn fetch_client_profile(
} }
let db_info = if refresh_plan.needs_client_db_info { let db_info = if refresh_plan.needs_client_db_info {
request_client_db_info( request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
con, .await
database_id, .ok()
voice_in_tx,
chat_tx,
activity_tx,
delta_tx,
pending_moves,
voice_activity,
)
.await
.ok()
} else { } else {
None None
}; };
@@ -1303,10 +1333,18 @@ async fn fetch_client_profile(
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())), .or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
last_connected_unix_seconds: optional last_connected_unix_seconds: optional
.map(|info| info.last_connected.unix_timestamp()) .map(|info| info.last_connected.unix_timestamp())
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())), .or_else(|| {
db_info
.as_ref()
.map(|info| info.last_connected.unix_timestamp())
}),
connections_total: optional connections_total: optional
.map(|info| u64::from(info.connections_total)) .map(|info| u64::from(info.connections_total))
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))), .or_else(|| {
db_info
.as_ref()
.map(|info| u64::from(info.connections_total))
}),
online_seconds: connection online_seconds: connection
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())), .and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)), idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
@@ -1339,14 +1377,10 @@ async fn fetch_client_profile(
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)), .or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
packet_loss_client_to_server_total: net_stats packet_loss_client_to_server_total: net_stats
.map(|s| s.get_packetloss()) .map(|s| s.get_packetloss())
.or_else(|| { .or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
connection.map(|info| info.client_to_server_packetloss_total)
}),
packet_loss_server_to_client_total: net_stats packet_loss_server_to_client_total: net_stats
.map(|s| s.get_packetloss_s2c_total()) .map(|s| s.get_packetloss_s2c_total())
.or_else(|| { .or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
connection.and_then(|info| info.server_to_client_packetloss_total)
}),
}) })
} }
@@ -1389,10 +1423,7 @@ fn client_profile_refresh_plan(
async fn request_messages( async fn request_messages(
con: &mut Connection, con: &mut Connection,
command: OutCommand, command: OutCommand,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<Vec<InMessage>, ProtocolError> { ) -> Result<Vec<InMessage>, ProtocolError> {
@@ -1428,14 +1459,14 @@ async fn request_messages(
return Ok(messages); return Ok(messages);
} }
StreamItem::Audio(buf) => { StreamItem::Audio(buf) => {
handle_audio_stream_item(voice_in_tx, voice_activity, buf).await; handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
} }
other => handle_non_audio_stream_item( other => handle_non_audio_stream_item(
con, con,
other, other,
chat_tx, &channels.chat,
activity_tx, &channels.activity,
delta_tx, &channels.delta,
pending_moves, pending_moves,
), ),
} }
@@ -1445,20 +1476,14 @@ async fn request_messages(
async fn request_client_db_info( async fn request_client_db_info(
con: &mut Connection, con: &mut Connection,
dbid: tsclientlib::ClientDbId, dbid: tsclientlib::ClientDbId,
voice_in_tx: &mpsc::Sender<InboundVoice>, channels: &EventChannels,
chat_tx: &mpsc::Sender<ChatMessage>,
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves, pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>, voice_activity: &mut HashMap<u64, Instant>,
) -> Result<InClientDbInfoPart, ProtocolError> { ) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages( let messages = request_messages(
con, con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]), build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
voice_in_tx, channels,
chat_tx,
activity_tx,
delta_tx,
pending_moves, pending_moves,
voice_activity, voice_activity,
) )
@@ -1781,9 +1806,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) ->
extra, extra,
.. ..
} => { } => {
if extra.reason.is_none() { extra.reason?;
return None;
}
let client = activity_client(con, *client_id)?; let client = activity_client(con, *client_id)?;
let channel = activity_channel_name(con, client.channel)?; let channel = activity_channel_name(con, client.channel)?;
Some(format!( Some(format!(
@@ -1901,10 +1924,12 @@ const _: () = {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout, bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
server_socket_from_config, sort_channels_tree_by, std_duration_millis, is_server_query_client_type, send_with_timeout, server_socket_from_config,
ConnectConfig, SendTimeoutError, sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
}; };
use futures::stream;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tsproto_types::ClientType; use tsproto_types::ClientType;
@@ -2147,6 +2172,83 @@ mod tests {
assert_eq!(result, Err(SendTimeoutError::Timeout(2))); assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
} }
#[tokio::test]
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
let (tx, _rx) = mpsc::channel(1);
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
tx.send(Request::Snapshot(reply_tx))
.await
.expect("seed first request");
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
let result = send_with_timeout(
&tx,
Request::Disconnect(disconnect_tx),
Duration::from_millis(10),
)
.await;
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
}
#[tokio::test]
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
let (tx, _rx) = mpsc::channel(1);
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
tx.send(Request::Snapshot(snapshot_tx))
.await
.expect("seed first request");
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
let (_chat_tx, chat_rx) = mpsc::channel(1);
let (_activity_tx, activity_rx) = mpsc::channel(1);
let (_delta_tx, delta_rx) = mpsc::channel(1);
let client = ProtocolClient {
tx,
voice_out_tx,
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
};
tokio::time::timeout(
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
client.disconnect(),
)
.await
.expect("disconnect should not wait indefinitely for request channel capacity");
}
#[tokio::test]
async fn voice_drain_stops_at_per_tick_budget() {
let (tx, mut rx) = mpsc::channel(8);
for value in 0_u8..5 {
tx.send(value).await.expect("seed voice packet");
}
let mut sent = Vec::new();
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
sent.push(value);
Ok::<(), ()>(())
});
assert_eq!(drained, 2);
assert_eq!(sent, vec![0, 1]);
assert_eq!(rx.len(), 3);
}
#[tokio::test]
async fn disconnect_stream_drain_returns_after_timeout() {
let start = tokio::time::Instant::now();
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
} }
fn forward_delta( fn forward_delta(
@@ -2161,7 +2263,7 @@ fn forward_delta(
id: PropertyId::Client(client_id), id: PropertyId::Client(client_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined { let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -2178,15 +2280,13 @@ fn forward_delta(
} }
Event::PropertyRemoved { Event::PropertyRemoved {
id: PropertyId::Client(_), id: PropertyId::Client(_),
old, old: PropertyValue::Client(client),
.. ..
} => { } => {
if let PropertyValue::Client(client) = old { let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft { client_id: client.id.0 as u64,
client_id: client.id.0 as u64, name: client.name.clone(),
name: client.name.clone(), });
});
}
} }
Event::PropertyChanged { Event::PropertyChanged {
id: PropertyId::ClientChannel(_), id: PropertyId::ClientChannel(_),
@@ -2196,7 +2296,7 @@ fn forward_delta(
id: PropertyId::Client(client_id), id: PropertyId::Client(client_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(client) = state.clients.get(client_id) { if let Some(client) = state.clients.get(client_id) {
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated { let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
client_id: client_id.0 as u64, client_id: client_id.0 as u64,
@@ -2213,7 +2313,7 @@ fn forward_delta(
id: PropertyId::Channel(channel_id), id: PropertyId::Channel(channel_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) { if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded { let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
id: channel_id.0, id: channel_id.0,
@@ -2228,20 +2328,16 @@ fn forward_delta(
} }
Event::PropertyRemoved { Event::PropertyRemoved {
id: PropertyId::Channel(_), id: PropertyId::Channel(_),
old, old: PropertyValue::Channel(channel),
.. ..
} => { } => {
if let PropertyValue::Channel(channel) = old { let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
id: channel.id.0,
});
}
} }
Event::PropertyChanged { Event::PropertyChanged {
id: PropertyId::Channel(channel_id), id: PropertyId::Channel(channel_id),
.. ..
} => { } => {
if let Some(state) = con.get_state().ok() { if let Ok(state) = con.get_state() {
if let Some(channel) = state.channels.get(channel_id) { if let Some(channel) = state.channels.get(channel_id) {
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated { let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
id: channel_id.0, id: channel_id.0,
+41
View File
@@ -168,52 +168,93 @@ pub struct ServerSnapshot {
} }
impl ChannelId { impl ChannelId {
/// Root/top-level channel identifier.
pub const ROOT: ChannelId = ChannelId(0); pub const ROOT: ChannelId = ChannelId(0);
} }
/// Incremental state change emitted by the protocol adapter when the server tree changes.
///
/// Covers client joins, leaves, moves, and channel additions, removals, and updates.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProtocolDelta { pub enum ProtocolDelta {
/// A client moved to a different channel.
ClientMoved { ClientMoved {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Destination channel identifier.
new_channel_id: u64, new_channel_id: u64,
}, },
/// A new client appeared on the server.
ClientJoined { ClientJoined {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Channel the client joined.
channel_id: u64, channel_id: u64,
/// Display nickname.
name: String, name: String,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A client disconnected.
ClientLeft { ClientLeft {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Display nickname.
name: String, name: String,
}, },
/// A client's properties changed.
ClientUpdated { ClientUpdated {
/// Unique client identifier.
client_id: u64, client_id: u64,
/// Microphone muted state.
input_muted: bool, input_muted: bool,
/// Speaker muted state.
output_muted: bool, output_muted: bool,
/// Whether the client is a server query client.
is_server_query: bool, is_server_query: bool,
/// Client's talk power value.
talk_power: i32, talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool, talk_power_granted: bool,
}, },
/// A new channel appeared.
ChannelAdded { ChannelAdded {
/// Channel identifier.
id: u64, id: u64,
/// Parent channel identifier.
parent: u64, parent: u64,
/// Channel name.
name: String, name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64, order: i64,
/// Whether the channel has a password.
has_password: bool, has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
/// A channel was deleted.
ChannelRemoved { ChannelRemoved {
/// Channel identifier.
id: u64, id: u64,
}, },
/// Channel properties changed.
ChannelUpdated { ChannelUpdated {
/// Channel identifier.
id: u64, id: u64,
/// Channel name.
name: String, name: String,
/// Whether the channel has a password.
has_password: bool, has_password: bool,
/// Needed talk power, or `None` when unrestricted.
needed_talk_power: Option<i32>, needed_talk_power: Option<i32>,
}, },
} }
+4 -4
View File
@@ -1,9 +1,9 @@
[package] [package]
name = "chanora_resolver" name = "chanora_resolver"
version = "0.1.0" version.workspace = true
edition = "2021" edition.workspace = true
license = "MIT OR Apache-2.0" license.workspace = true
publish = false publish.workspace = true
build = "build.rs" build = "build.rs"
[dependencies] [dependencies]
+2 -11
View File
@@ -346,9 +346,7 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
if removed_channel { if removed_channel {
deltas.push(Delta::ChannelRemoved(id)); deltas.push(Delta::ChannelRemoved(id));
} }
Reduction { Reduction { deltas }
deltas,
}
} }
_ => Reduction { deltas: vec![] }, _ => Reduction { deltas: vec![] },
}, },
@@ -412,14 +410,7 @@ pub fn reduce_reconnect_snapshot(
state: &mut Option<ServerState>, state: &mut Option<ServerState>,
snap: ServerSnapshot, snap: ServerSnapshot,
) -> Reduction { ) -> Reduction {
let normalized = normalize_snapshot(snap); reduce(state, StateEvent::Snapshot(snap))
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
} }
#[cfg(test)] #[cfg(test)]
+6
View File
@@ -628,6 +628,12 @@ fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
/// headless / sandboxed environments force the file-fallback path /// headless / sandboxed environments force the file-fallback path
/// without poking a real OS keyring (which would either prompt the /// without poking a real OS keyring (which would either prompt the
/// user or block on a missing D-Bus session). /// user or block on a missing D-Bus session).
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_disabled() -> bool { fn keyring_disabled() -> bool {
matches!( matches!(
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(), std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),

Some files were not shown because too many files have changed in this diff Show More