Commit Graph
133 Commits
Author SHA1 Message Date
Edison Jwa eb9014cd81 feat: add TeamSpeak address resolver 2026-05-25 01:12:50 +09:00
Edison Jwa 7d5d8c2c90 feat: integrate chat voice and diagnostics client 2026-05-23 06:51:55 +09:00
Edison Jwa bf284018e6 feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
2026-05-22 09:29:57 +09:00
Edison Jwa 6af4ecab0f feat(voice): add iOS VAD runtime support 2026-05-21 20:51:45 +09:00
Edison Jwa b65cedfa95 fix(android): recover from input stream failures 2026-05-20 14:52:34 +09:00
Edison Jwa b21fc16ee6 fix(android): clarify channel and voice status icons 2026-05-20 14:52:34 +09:00
Edison Jwa f8b6485390 fix(android): show password action only for locked channels 2026-05-20 14:52:34 +09:00
Edison Jwa 3ae8e1ab77 fix(android): adapt fold layout and diagnostics 2026-05-20 14:52:34 +09:00
Edison Jwa eb1f4b895e fix(android): remove cpal product wording 2026-05-20 14:52:34 +09:00
Edison Jwa 210a6a0e11 fix(android): unblock mic permission startup 2026-05-20 14:52:34 +09:00
Edison Jwa 26b06ce786 fix(android): expose voice diagnostics safely 2026-05-20 14:52:33 +09:00
Edison Jwa d6763787aa feat(android): initialise Oboe runtime context 2026-05-20 14:52:33 +09:00
Edison Jwa c87b47f064 feat(audio): prefer native voice backends 2026-05-20 14:52:33 +09:00
Edison Jwa 8c253f1d4d feat(voice): harden Android audio and channel joins 2026-05-19 01:58:07 +09:00
EdisonJwa 7188a5a69d feat(perf,benchmark-infra): criterion bench harness + advisory CI workflows (SDD-120)
Implementation of SDD-120 §1-§8:

Bench harness (crates/chanora_audio/benches/):
- common.rs: deterministic synthetic audio (440 Hz sine, no RNG).
- realtime_capture.rs: bench_capture_alloc_count (dhat) +
  bench_capture_callback_wall_clock (criterion).
- opus_codec.rs: bench_opus_encode_latency + bench_opus_decode_latency
  (direct audiopus, not AudioHandler — SDD-120 §3 item 4).
- resampler.rs: bench_resampler_throughput across 44.1->48 /
  16->48 / 48->48 passthrough.

CI tooling (crates/chanora_audio/examples/):
- emit_baseline.rs: aggregates criterion estimates.json outputs
  into the SRS-217 baseline schema.
- compare_baseline.rs: applies SRS-219 tolerance, renders markdown
  table with 🟢/🟡/🔴 markers + yellow simpler-form realization per
  SDD-120 §8.

  Deviation from SDD-120 §2 / §5 / §7 placement: these tools live
  under examples/, not benches/ or src/bin/. Rationale: they must
  consume serde_json (a dev-only dep — production builds must not
  pull it). Cargo only resolves dev-dependencies for [[test]],
  [[bench]], and [[example]] targets; [[bin]] targets under
  src/bin/ see only regular [dependencies]. examples/ keeps the
  binaries out of the production dep tree while still giving them
  cargo run --example invocation. An SDD-120 amendment should
  reflect this.

Workflows (.github/workflows/):
- bench-advisory.yml: PR + push triggers; runs benches; posts a
  sticky PR comment via actions/github-script@v7; job status is
  always success (SRS-218 clause 4 — non-blocking).
- bench-baseline-update.yml: workflow_dispatch only; runs benches;
  opens PR via peter-evans/create-pull-request@v6 (sole writer of
  the SAD-089 baseline JSON).

Cargo.toml additions ([dev-dependencies] only — verified excluded
from --release builds): criterion 0.5, dhat 0.3, serde_json 1.

Source-code seam: minimal pub-but-#[doc(hidden)] bench_seam module
in chanora_audio (engine.rs + lib.rs re-export) so the criterion
bench harness can construct a CaptureState and drive
CaptureState::ingest without re-implementing the engine (SDD-120
§3). Non-iOS targets only — CaptureState itself is iOS-gated.

Initial baseline seed: crates/chanora_audio/benches/baselines/
x86_64-unknown-linux-gnu.json = {}. compare_baseline handles the
missing-baseline case gracefully and emits a 'no red markers'
report; the first manual dispatch of bench-baseline-update.yml
after merge establishes the real values.

Out of scope per SDD-120 §10: production telemetry export,
build-failing hard CI gate, multi-host benchmarking, IDE
integration, Dart-side bridge round-trip bench.

Verification:
- cargo check --workspace --all-targets: PASS.
- cargo bench --bench realtime_capture --no-run: PASS.
- cargo bench --bench opus_codec --no-run: PASS.
- cargo bench --bench resampler --no-run: PASS.
- cargo build --example emit_baseline --example compare_baseline
  -p chanora_audio: PASS.
- cargo test --workspace: 106 passed, 0 failed, 3 ignored — no
  regression from prior count.
2026-05-18 13:52:15 +08:00
EdisonJwa d13b56d379 perf(audio): pre-allocate capture scratch buffers to avoid realtime-thread Vec allocs (SDD-094)
The capture cpal callback (CaptureState::ingest) ran two heap
allocations per callback on the realtime audio thread:

  1. engine.rs:1196-1202 — fresh `mono: Vec<f32>` for the downmix
     output, once per cpal callback (50–100 Hz).
  2. engine.rs:1217-1218 — `pcm_accum.drain(..FRAME_SAMPLES).collect()`
     building a fresh Vec<f32> of 960 samples per Opus frame.

Both sites mirror the pattern already fixed for the output side at
engine.rs:1389-1397, where allocating per callback on glibc malloc
was correlated with user-perceptible audio popping. The output-side
fix replaced the per-callback allocation with a pre-allocated
`scratch` Vec that is cleared and resized in place; this commit
applies the same template to the capture side.

Changes:
- Add `mono_scratch: Vec<f32>` and `frame_scratch: Vec<f32>` to
  CaptureState. Initialised with Vec::with_capacity(4096) and
  Vec::with_capacity(FRAME_SAMPLES=960) respectively in
  CaptureState::new.
- Replace the downmix Vec construction with in-place push into
  `self.mono_scratch`; `clear()` retains capacity across callbacks.
- Replace the drain().collect() with `self.frame_scratch.extend(
  self.pcm_accum.drain(..FRAME_SAMPLES))`; same capacity-retention.
- The resampler call uses std::mem::take to swap the scratch buffer
  out for the duration of the &mut self call, then moves it back —
  the backing allocation is preserved across callbacks.

Algorithm semantics are unchanged: same downmix arithmetic, same
clamp loop, same Opus encode call sequence. Only the storage
strategy differs.

Out of scope (intentionally not touched):
- Android audio path (android_voice_unit.rs, mobile_voice_backend.rs):
  researcher constraint C-4 — the Android cpal data path is mid-
  migration and being replaced.
- Output callback at engine.rs:1409+: the only obvious per-callback
  allocation there (`scratch`) was already fixed; a fuller audit
  is a separate scope decision.
- The `scratch` buffer at engine.rs:1389-1397 — already correct.

Verification:
- cargo check --workspace --all-targets: passes.
- cargo test --workspace: 106 passed / 0 failed / 3 ignored.
- cargo clippy --workspace --all-targets: no new lints introduced;
  the one warning inside the edited region (clamp-like pattern at
  line 1266) was pre-existing on the copied clamp loop.
2026-05-18 13:01:52 +08:00
EdisonJwa 4c19410556 test+diag: SWE.4 unit tests for Rust paths, Dart service tests, diagnostics audio.android section
Verification + diagnostics:

- apps/chanora_flutter/test/services/back_intent_policy_test.dart:
  8-case truth table for the BackIntentPolicy pure function
  (SWE4-UV-042).
- apps/chanora_flutter/test/services/back_intent_service_test.dart:
  5 channel-routing tests (SWE4-UV-042, SWE5-IV-019 unit slice).
- apps/chanora_flutter/test/services/android_permissions_service_test.dart:
  12 tests covering inbound channel events, outbound requests,
  state-machine transitions, and non-Android short-circuit
  (SWE4-UV-041).
- SDD/SRS trace headers added to alpha_e2e_test.dart,
  beta_e2e_test.dart, widget_test.dart so the existing test ↔ ID
  mapping is discoverable by grep.
- chanora_diagnostics: extends DiagnosticExport with android_audio:
  Option<String> for the SDD-116 evidence schema (requested /
  achieved performance mode + sharing mode + input preset + sample
  rate + frames per burst, per-effect engagement, latency tier).
  The bridge's export_diagnostics() now embeds the Android section
  when current_android_audio_diagnostics() returns Some.

All 93 workspace Rust tests and 26 Dart test/services tests pass.

Trace: SDD-090, SDD-112, SDD-113, SDD-116, SWE4-UV-041, SWE4-UV-042,
SWE4-UV-045, SWE4-UV-047, SWE4-UV-048, SWE4-UV-049, SWE4-UV-051,
SWE4-UV-052, SWE5-IV-019.
2026-05-18 12:48:28 +08:00
EdisonJwa 7966a7c8c6 feat(bridge,android): BridgeEvent::PermissionState + JNI publish hook + c++_shared link
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).

Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.

Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.

Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
2026-05-18 12:32:01 +08:00
EdisonJwa 56222d190e feat(audio): clamp transmit selector on RECORD_AUDIO permission state (SDD-106 §6)
Per SDD-106 §6 add a permission-state clamp to TransmitModeSelector.
When RECORD_AUDIO is Denied or PermanentlyDenied the transmit gate
is forced false regardless of PTT or voice-activity state; on
Granted the clamp releases and normal transmit decisions resume.
The clamp takes precedence over PTT and hard_mute in the decision
ordering documented inline.

Three new tests cover the clamp behavior, the release-on-grant
transition, and the non-RECORD_AUDIO ignore path.

Trace: SDD-106 §6, SRS-209.
2026-05-18 10:56:17 +08:00
EdisonJwa 78190c0694 feat(audio,android): wire engine + AudioManager JNI through ModeStack (SDD-108 §2)
Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.

Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.

Trace: SDD-108, SDD-115.
2026-05-18 10:53:18 +08:00
EdisonJwa 76c6d1d40c feat(audio,android): add MobileVoiceAudioBackend + AndroidVoiceUnit (oboe-rs)
Add the cross-platform MobileVoiceAudioBackend trait, plus the
Android implementation AndroidVoiceUnit backed by oboe-rs 0.6.x.
AndroidVoiceUnit owns AAudio stream setup with VoiceCommunication
usage/preset, performance-mode LowLatency request, sharing-mode
Exclusive best-effort, hardware AEC/NS/AGC engagement via JNI, and
the diagnostics snapshot publish path used by SDD-116 evidence
collection.

Cargo.toml: adds oboe = "0.6" under the Android target.

Trace: SDD-111, SDD-112, SDD-113, SRS-210, SRS-211, SRS-212, SRS-213,
SRS-214.
2026-05-18 10:38:19 +08:00
EdisonJwa da0b208075 feat(audio): add ModeStack pure refcount helper (SDD-108)
Introduce ModeStack, a pure-Rust refcount-composable wrapper for
Android audio-mode acquire/release with prior-mode snapshot. Per
SDD-108 §1/§2 the engine snapshots the system audio mode on first
acquire and restores it on last release; composed acquires are
no-ops while the mode is held.

ModeStack is panic-free; release-on-zero returns AlreadyReleased
rather than panicking. Six SWE4-UV-045-tagged unit tests cover the
acquire/release semantics on the host target.

Trace: SDD-108, SWE4-UV-045.
2026-05-18 10:30:25 +08:00
Edison Jwa dc9c5c0a4e feat: multi-platform bug fixes, Android audio path, and build tooling
Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

Flutter Rust bridge:
- Thread is_server_query flag through protocol→bridge→Dart
- Add own_client_id to BridgeSnapshot DTO
- Add log_file_path_str() for platform log path queries

Rust protocol:
- Add ServerQuery test coverage (query_client_type_maps_to_server_query_flag)
- Split reqwest TLS: native-tls for desktop/iOS, rustls for Android

Rust audio:
- Upgrade cpal 0.16→0.17.3 with API adjustments (SampleRate, description())
- Suppress Android-only dead-code warnings (open_log_file, keyring_account)

Android build tooling:
- tools/build-opus-android.sh: NDK auto-discovery, correct CMake
  Android variables (ANDROID_ABI, ANDROID_PLATFORM), portable baseline
- tools/build-android-rust.sh: build+copy Rust cdylib for arm64-v8a,
  armeabi-v7a, x86_64 into android/app/src/main/jniLibs/
- Add jniLibs/ to .gitignore

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
2026-05-18 00:13:21 +09:00
Edison Jwa 7a59f5b9a1 feat(ios,p0): iOS P0 platform, audio fixes, channel UX 2026-05-17 22:00:00 +09:00
EdisonJwa a1fefc8ab6 fix(audio,ios): revert ring buffer back to direct fill_buffer call (rc.8+75)
The ring-buffer architecture (rc.8+73..+74) was making playback
strictly worse. Diagnostic data at +74 conclusively showed:

  * Producer task ran perfectly at 50 Hz (250 ticks per 5 s).
  * AudioHandler returned silence on 65-84% of fill_buffer calls
    even when window_peak_f32 reached 0.98 (full-scale audio).
  * Ring buffer never accumulated beyond 30 ms because consumer
    (VPIO render callback at 43.5 Hz, ~1440 samples per call)
    drained samples faster than the 50 Hz producer could push
    them, in net effect.

The producer drained AudioHandler at 50 Hz \u2014 slightly faster
than iOS VPIO actually consumes audio. Each fill_buffer call
asked for 20 ms but adjacent Opus packets hadn't arrived yet, so
fill_buffer returned mostly silence. Linux/SDL's same pattern
works because SDL calls fill_buffer at EXACTLY the device
callback rate (50 Hz = 20 ms per buffer); the rates match.

Fix: revert to direct fill_buffer call from the render callback
(the SDL pattern in tsclientlib's own reference example at
tsclientlib/examples/audio_utils/ts_to_audio.rs). The render
callback now:

  1. Resizes scratch_stereo Vec to 2 * num_frames f32 if needed
  2. Zeros the live slice (fill_buffer is additive, not clearing)
  3. Locks AudioHandler, calls fill_buffer(scratch_stereo)
  4. Downmixes L+R -> mono i16 with master gain into out[]
  5. Applies output_muted bypass
  6. Tracks peak_out + audio/silence ratios for diagnostic

The closure owns scratch_stereo across callbacks for stable
allocation. Same memory model as Linux/SDL.

Removed:
  * tokio::spawn producer task
  * rtrb dep + RingBuffer<i16> + Producer/Consumer split
  * tokio::sync::oneshot shutdown channel
  * producer_shutdown_tx field on IosVoiceUnit struct
  * RING_BUFFER_SAMPLES / PRODUCER_TICK_MS constants
  * Producer-side diagnostic counters

Diagnostic kept: cb / num_frames / frames_changes /
callbacks_with_audio / callbacks_with_silence / peak_out_i16 /
gain. Logged every 100 callbacks.

The choppy / clicks symptom is independent of the buffer
architecture \u2014 it's whatever AudioHandler is doing on iOS
that's different from Linux. Next investigation step is to
either (a) switch from VPIO to RemoteIO unit (lose Apple's
voice processing entirely), or (b) understand why AudioHandler
returns silence so often on iOS-arrival packet timing patterns.

Build counter 74 -> 75.
2026-05-17 13:05:09 +08:00
EdisonJwa a9aa19ecdd diag(audio,ios): comprehensive producer + consumer ring-buffer metrics (rc.8+74)
External reviewer correctly identified that the +73 ring-buffer
commit didn't fix the symptom but the architecture is still
right. We need to distinguish two possible causes:

  (a) Producer task isn't running (or running too rarely) so
      ring stays underfilled.
  (b) Producer IS running but fill_buffer returns zeros most of
      the time (AudioHandler stuck in buffering_samples state
      or no packets reaching it).

The +73 render-side diagnostic was insufficient: we logged
underruns + peak_out_i16 but not what the producer was
actually pushing. This commit adds producer-side metrics
rolled up every 5 s (250 ticks at 20 ms):

Producer task:
  producer_ticks           : timer firings (= ~250 per 5 s window;
                             fewer = tokio scheduler stalled)
  produced_chunks          : pushes into ring (= ticks - drops)
  fill_buffer_calls        : AudioHandler queries
  fill_buffer_zero_returns : ticks where scratch came back all
                             zeros (no decoded content to play)
  ring_full_drops          : ticks where ring was full and we
                             skipped the push
  ring_min/max_samples     : depth envelope across window
  ring_min/max_ms          : same in milliseconds
  window_peak_f32          : max scratch sample across window
  window_rms_f32           : RMS of all scratch samples across
                             window

Render callback (per-100-callback as before, plus new fields):
  ring_avail_before        : Consumer::slots() before this read
                             (= how many samples were sitting in
                              the ring at callback entry)
  read_frames              : samples successfully popped
  zero_filled              : samples zero-filled because ring
                             was empty (= num_frames - read_frames)
  underruns / underrun_samples / peak_out_i16 / clip_count_i16
                           : as before

Reading the next iteration's log:

  If producer_ticks << 250  per 5 s window:
      tokio scheduler isn't running the task fast enough.
      Move producer to its own dedicated runtime, or use
      std::thread + std::sync::mpsc + std::thread::sleep
      instead of tokio.

  If producer_ticks ~= 250 AND fill_buffer_zero_returns is
  high (most ticks return silence):
      AudioHandler isn't decoding packets fast enough OR is
      stuck buffering. Bug is upstream in protocol layer
      packet delivery or AudioHandler's jitter state machine.
      The ring buffer architecture cannot fix this.

  If producer_ticks ~= 250 AND fill_buffer_zero_returns is
  low AND ring_min_ms stays >100ms AND underruns are low BUT
  consumer's peak_out_i16 is still 0:
      Something is wrong between push and pop. Lock-free
      ring corruption, or wrong stride.

Pure diagnostic. No behavioural change beyond the logging.
Producer scratch envelope scan is O(scratch.len()) = 1920
samples per 20 ms tick = ~96k iterations/sec on the audio
producer thread \u2014 negligible CPU.

Build counter 73 -> 74.
2026-05-17 02:47:35 +08:00
EdisonJwa 99584fbc1a fix(audio,ios): decouple AudioHandler from VPIO render callback via ring buffer (rc.8+73)
User confirmed at +72 the symptom is 'voice + constant clicks +
choppy fragments'. The diagnostic data conclusively pointed to
iOS VPIO render-callback timing as the cause:

  * frames_changes=60+ per 100 callbacks at cb>=1800
    iOS keeps switching num_frames between 960 and 1104
    on roughly 60% of callbacks
  * peak_out_i16 is sensible (2500-16870, never clipping)
    when AudioHandler returns content
  * input_was_zero=true on most callbacks during active speech
    AudioHandler keeps entering buffering_samples state

The cause: previous render callback called fill_buffer
synchronously every iOS audio thread invocation. With iOS
calling at irregular rates with irregular sizes, AudioHandler's
jitter buffer (sized around 20 ms Opus frames) cannot satisfy
arbitrary-sized requests and falls back to returning silence
(&[] empty slice) on misaligned reads. The silent gaps in the
middle of the output buffer create discontinuities = audible
clicks; the missing-tail content produces choppy fragments.

Fix (architectural): decouple the AudioHandler decoder from the
VPIO render callback via a lock-free SPSC ring buffer.

  Producer (tokio task, 50 Hz):
    every 20 ms:
      fill_buffer(scratch_stereo_f32, 1920 = 20 ms stereo)
      downmix L+R -> mono i16 (960 samples)
      ring_buffer.push_slice(mono_i16)

  Consumer (VPIO render callback, iOS audio thread):
    every callback:
      pop num_frames samples from ring buffer into out
      zero-fill tail on underrun

Why it works:
  * Producer always asks AudioHandler for a stable 20 ms chunk
    (perfectly aligned with internal Opus frame size). No more
    buffering_samples false-triggers.
  * Consumer pulls whatever iOS asks for whenever iOS schedules
    it; ring buffer's 200 ms depth absorbs the callback jitter.
  * This is the standard pattern every production VoIP audio
    engine uses (WebRTC, Discord, FaceTime) to bridge bursty
    Opus decoders to bursty platform audio callbacks.

Implementation:
  * New dep: rtrb 0.3.4 (RustAudio realtime-safe SPSC ring
    buffer, 6.8M downloads, lock-free push/pop with no
    allocation on the audio thread).
  * RING_BUFFER_SAMPLES = 9600 (200 ms mono i16 at 48 kHz).
    Sized for 10x producer ticks of headroom.
  * PRODUCER_TICK_MS = 20 (matches Opus 50 Hz packet rate).
    set_missed_tick_behavior(Skip) to avoid burst catch-up on
    runtime stalls.
  * Producer task spawned in IosVoiceUnit::start, shutdown
    via tokio::oneshot when IosVoiceUnit drops.
  * Render callback is now just: pop into out, zero-fill tail,
    apply mute then gain.
  * Gain applied CONSUMER-side so user volume changes take
    effect within one callback (<= 200 ms latency).
  * Underrun diagnostics: count underrun callbacks + total
    zero-filled samples, log every 100 callbacks.

Threading + safety:
  * rtrb is lock-free SPSC. Audio thread never blocks.
  * Producer can block briefly on Arc<Mutex<AudioHandler>>
    contention with the inbound forwarder (handle_packet), but
    not with the audio thread.
  * Producer task is owned by tokio runtime; explicit shutdown
    channel ensures it exits when the engine stops.

Build verify:
  * Linux host: cargo check clean in 4.06s (downloads rtrb 0.3.4).
  * iOS Mac:   cargo check clean in 2.14s.

Build counter 72 -> 73.
2026-05-17 02:39:42 +08:00
EdisonJwa 53e09ea091 diag(audio,ios): comprehensive render-callback metrics per external review (rc.8+72)
External code review pushed back on the 'iPhone speaker hardware
distortion' hypothesis and pointed out we need more than just
peak measurements. The reviewer's checklist:

  * peak_i16
  * rms_i16
  * num_clipped_samples (abs >= 32767)
  * zero_fill_count / underrun_count
  * callback_frame_count variability
  * decoded_packet_duration_ms
  * input_was_zero
  * actual ASBD / actual sample rate

The format diagnostics at +71 already showed iOS honoured
48 kHz Int16 mono on both buses and that .default mode +
.defaultToSpeaker routed to the speaker correctly with
outputVolume=0.45. So format + route are confirmed correct.
The remaining mystery is WHY 'loud but distorted' \u2014 we need
sample-level metrics to isolate where in the pipeline the
breakage occurs.

This commit instruments the VPIO render callback with:

  * num_frames + frames_changes : detects iOS re-negotiating
                                 buffer size between callbacks
                                 (which would imply jitter the
                                 fixed scratch_stereo Vec can't
                                 absorb cleanly).
  * peak_stereo + rms_stereo  : characterises AudioHandler's
                                output BEFORE our downmix.
                                Distinguishes 'real audio
                                arriving' from 'silence'.
  * peak_out_i16 + clip_count : measures what we hand VPIO.
                                clip_count > 0 means we're
                                clipping at our boundary even
                                with gain=1.0 \u2014 indicates
                                upstream is over-driven.
  * input_was_zero            : explicit silence/no-talker
                                indicator separate from peak=0
                                which could mean tiny content
                                rounded to 0.

Reviewer's preferred diagnostic path is to dump PCM to file
and play with ffplay externally; that's iOS-impractical
without a shared filesystem path the user can extract via
Files.app. Instead we sample the same metrics in-callback at
~2 Hz which gives us the same information at run time.

Pure diagnostic. No behavioural change. Counters live in the
FnMut closure so the audio thread cost is one branch +
counter increment per callback, plus a one-pass RMS sum +
peak scan every 100 callbacks.

Build counter 71 -> 72.

Reviewer also recommended a headphone test in parallel \u2014
that will be done by the user (out-of-band) at the next
test cycle to determine whether the symptom changes when
audio leaves the speaker path.
2026-05-17 02:25:31 +08:00
EdisonJwa 2735c55c97 diag(audio,ios): log actual VPIO + AVAudioSession state post-init (rc.8+71)
Per external review (helpful checklist from ChatGPT-style analysis
pointing out we never verified that iOS actually accepted our
preferred sample rate / channels / format): preferredSampleRate
and preferredIOBufferDuration are HINTS, not guarantees. iOS may
substitute its own values if the hardware can't satisfy our
preference. If VPIO is running at 44.1 kHz Float32 stereo while
our render callback writes 48 kHz Int16 mono into the buffer,
the symptoms would match what user reports (broken playback,
pitch shifted, severe distortion) and our previous diagnostics
wouldn't catch it because they only sampled signal-level metrics.

This commit adds two diagnostic emissions to verify:

1. AppDelegate.swift::activateAudioSession: after setActive
   succeeds, log the ACTUAL session state \u2014 category, mode,
   sampleRate, ioBufferDuration, current route (inputs +
   outputs), outputVolume. Lets us see whether iOS honoured our
   .default + .defaultToSpeaker setup and which physical route
   it picked at launch.

2. ios_voice_unit.rs::IosVoiceUnit::start: after unit.start()
   succeeds, log the actual OUTPUT and INPUT stream formats
   VPIO accepted (sample_rate, channels, sample_format, flags).
   If these differ from our requested 48 kHz Int16 mono, we
   have a format-substitution problem.

Three possible outcomes from the next test:

* Both diagnostics confirm 48 kHz Int16 mono on both buses and
  the session sampleRate=48000 -> format is correct; the
  playback breakage is somewhere else (e.g. AudioHandler
  jitter buffer behaviour, route binding, or hardware mixer).

* Session sampleRate != 48000 -> we need to insert a sample
  rate converter or pin AVAudioSession's
  setPreferredSampleRate(48000) explicitly in Swift before
  setActive.

* VPIO substituted Float32 for our Int16 request -> our render
  callback is writing i16 magnitudes into a Float32 buffer
  which would explain the distortion. Fix: write Float32
  directly using data::Interleaved<f32> instead of i16.

Build counter 70 -> 71. Pure diagnostic; no behavioural
change.
2026-05-17 02:17:37 +08:00
EdisonJwa 6e0bf21295 fix(audio,ios): route playback via media channel (.default + .defaultToSpeaker) (rc.8+70)
User report after the 8x boost commit (e85a6d3): playback STILL
broken, but now the diagnostic clearly shows the actual problem.
Render-callback peak_out_i16 SATURATES at 32767 on speech peaks
(cb=600, 1100, 1200, 2400) because the 8x boost amplifies an
already-loud signal into hard clipping. Quiet content reaches
audible level but loud peaks are catastrophically distorted.

The 8x boost was treating the wrong cause.

Real root cause (researched online after user prompted: 'this is
iOS a popular platform, there must be solutions'): iOS has TWO
independent audio channels:

  In-call channel  (.voiceChat / .videoChat modes)
    * Routes through the phone-call audio path.
    * Aggressively ducks non-voice content to the earpiece.
    * Volume controlled by a separate in-call hardware
      register, not the side buttons when not actively on a
      phone call.

  Media channel  (.default mode)
    * Routes through the standard media playback path.
    * No automatic ducking.
    * Volume controlled by the side volume buttons normally.

With AVAudioSession mode .voiceChat, iOS sends our output
through the in-call channel which plays at 'earpiece-level'
loudness on the speaker too. Signal is technically present but
buried under the speaker's noise floor. With mode .default +
.defaultToSpeaker option, output routes via media channel and
plays at normal loudness.

Both Twilio (video-quickstart-ios) and Daily.co (patched WebRTC
module) document the same workaround and use VPIO for AEC while
keeping the session mode at .default for loud playback:

  github.com/twilio/video-quickstart-ios/issues/522
  stackoverflow.com/questions/79834998 (Daily.co)

The user also noticed 'tx/rx almost no changes even receiving
packages' \u2014 likely a misinterpretation of the frames counter
not advancing as fast as expected during quiet voice; AudioHandler
returns silence when its jitter buffer is in buffering_samples
state which doesn't fire 'decode failed' but also doesn't
increment frames_received. The real issue is still the playback
ducking; the counter behaviour is a downstream symptom.

Changes:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default
   with options [.defaultToSpeaker, .allowBluetoothHFP,
   .allowBluetoothA2DP]. VPIO continues to do its job (AEC, NS,
   AGC on the mic side); only the playback routing changes.
   The earlier 'speaker selector silent under .default' bug
   does NOT apply because we no longer use cpal RemoteIO \u2014
   VPIO honours overrideOutputAudioPort under any mode.

2. ios_voice_unit.rs: revert the 8x output boost from e85a6d3.
   With media-channel routing, signal levels are correct and
   no software amplification is needed. Render callback restored
   to plain (l+r)*0.5*gain downmix.

3. ios_voice_unit.rs: revert the BypassVoiceProcessing toggle
   from c16318c. The VPIO chain stays enabled so we keep
   capture-side AEC/AGC/NS for free \u2014 the playback breakage
   it was trying to fix was the wrong layer all along.

4. ios_voice_unit.rs: drop the diagnostic render-callback log
   line. Production-clean code; can be re-enabled by reverting
   the diff in the closure if future debugging needs it.

Build counter 69 -> 70.
2026-05-17 02:11:07 +08:00
EdisonJwa e85a6d36d7 fix(audio,ios): apply 8x output boost to compensate for VPIO raw playback (rc.8+69)
User-pasted log at +66 (https://pb.hit.moe/q8heratf.txt) shows
conclusive data over a 110-second continuous talker session:

  Average peak_stereo_f32: ~0.005-0.010
  Loud peak (one moment):  ~0.234
  peak_out_i16:           ~150-300 (out of 32767)

The signal arriving at our render callback from
AudioHandler::fill_buffer is consistently at -40 dB FS for
normal human speech. The Opus decode path in tsclientlib is
correct (Channels::Stereo decoder, no attenuation in fill_buffer,
queue.volume defaults to 1.0). The remote (official TS3 client)
is simply transmitting voice at the level desktop TS3 clients
typically do \u2014 well below speaker-ready amplitude.

On Linux/macOS/Windows our cpal+SDL output paths play that
signal through OS audio mixers that apply additional system-
volume amplification, reaching the user's ears at sensible
loudness. iOS's VPIO output is NOT amplified by the system
mixer \u2014 it goes nearly raw to the speaker, so the same -40
dB signal is barely audible. Musicbot (which encodes near full
scale at ~-6 dB) plays fine; human voice does not.

Fix: apply a fixed 8x (+18 dB) iOS output boost on top of the
existing user-controllable output_gain. A -40 dB signal becomes
-22 dB (normal speakerphone level). User's volume slider
continues to function in a useful 0-2x range on top.

  effective_gain = user_gain * IOS_OUTPUT_BOOST

Hard-clip at \u00b11.0 in the mono downmix prevents loud signals
(musicbot at peak 0.5 -> 4.0 -> clamped to 1.0) from
overflowing i16 wrap-around. Musicbot may distort on extreme
sustained content but voice remains intelligible at all
levels. Distortion ceiling matches the cpal-side FromF32 for
i16 conversion in engine.rs.

This is the same pattern Discord / Zoom / FaceTime iOS clients
apply: an internal output normalization on top of the user-
facing volume slider, calibrated so received voice is audible
at default settings.

Build counter 68 -> 69.
2026-05-17 01:58:15 +08:00
EdisonJwa c16318c86b fix(audio,ios): bypass VPIO voice processing for clean playback (rc.8+68)
User report at +67 (.voiceChat mode): playback still 'broken'.
Even with VPIO's Apple-documented session-mode pairing,
its output-side gating chain (echo subtraction + adaptive
noise suppression) chops quiet inter-phoneme content of human
voice. Musicbot signal (loud, ~continuous) survives because it
stays above the gating threshold; speech does not.

Fix: set kAUVoiceIOProperty_BypassVoiceProcessing = 1 on the
unit immediately after EnableIO (before stream format / callbacks
/ initialize). This disables ALL VPIO voice processing \u2014 the
unit becomes effectively a vanilla RemoteIO with mic + speaker
buses. Raw samples pass through both directions.

Trade-off:
* Lost: Apple's hardware AEC + AGC + NS on the mic path. User
  reports current capture is clean already, suggesting their
  test environment (headset? non-speakerphone?) doesn't need
  AEC. If echo loops back when speakerphone is engaged, we'll
  re-evaluate \u2014 either re-enable VPIO selectively for
  echo-prone routes or ship software AEC (DEC-007).
* Gained: playback is no longer gated. Quiet inter-phoneme
  speech content reaches the speaker.

Property setter:
* Constant: kAUVoiceIOProperty_BypassVoiceProcessing = 2100
* Scope: Global, Element: Input (1) per WebRTC's reference iOS
  ADM (voice_processing_audio_unit.mm).
* Value: u32 = 1 (= bypass).
* Soft-fail with warn log if the property is rejected on an
  exotic iOS version (the unit still works, just with VPIO
  defaults).

Build counter 67 -> 68.
2026-05-17 01:54:07 +08:00
EdisonJwa 63cbab901e diag(audio,ios): instrument VPIO render callback to isolate playback breakage (rc.8+66)
User reports capture-side audio (mic -> remote) is clean but
local playback (remote -> speaker via VPIO render callback) is
'broken and poor' at +65. The pipeline appears correct on paper:
fill_buffer -> downmix (L+R)*0.5 -> gain -> clamp -> i16 -> VPIO.
No errors logged. To stop guessing, add structured logging
inside the render callback so the next test cycle yields data
about what's actually flowing through.

Diagnostic emitted every 100th callback (~2 s at iOS's typical
20-50 Hz callback rate):

  ios VPIO render callback diagnostic sample
    cb=<counter>
    num_frames=<N>           VPIO buffer size in mono samples.
                             Expected ~960 (20ms) or ~1104 (23ms).
                             Outliers point at format mismatch.
    peak_stereo_f32=<f32>    Peak |sample| of AudioHandler's
                             output BEFORE gain + downmix.
                             0.0 = handler is producing silence
                                   (jitter underrun, no audio).
                             ~1.0 = full-scale content reaching
                                    the callback as expected.
    peak_out_i16=<i16>       Peak |sample| of the downmixed mono
                             i16 we write to VPIO. Zero with
                             non-zero peak_stereo = downmix bug.
                             Near 32767 = clipping pressure.
    gain=<f32>               Current master output gain.

What we'll be able to diagnose from a 5-second talker session:

* peak_stereo_f32 = 0 throughout
    -> AudioHandler isn't producing samples. Inbound forwarder
       may not be feeding it, or jitter buffer is stuck in
       buffering_samples state. NOT a render-callback bug.

* peak_stereo_f32 oscillating, peak_out_i16 = 0
    -> Downmix or i16 cast is broken. Math bug in the loop.

* num_frames wildly different from ~960-1104
    -> StreamFormat got rejected and VPIO is delivering a
       different rate. Format-pinning fight with the session.

* peak_stereo_f32 normal AND peak_out_i16 normal AND user
  still says 'broken'
    -> The signal reaches the device cleanly but iOS's VPIO
       output processing (AEC residual subtraction, AGC
       compression, NS gate) is mangling it after our callback
       returns. That's a VPIO-config problem, not a render-
       callback problem; fix is to disable specific VPIO
       voice-processing properties on the unit before
       initialize().

Pure diagnostic commit. No behavioural change beyond a
warn-rate-limited info log line every ~2 seconds. Cost in the
audio thread is one branch + counter increment + (every 100th)
a tracing macro invocation.

Build counter 65 -> 66.
2026-05-17 01:26:15 +08:00
EdisonJwa e7c3ffa6d2 feat(audio,ios): wire VPIO render callback to AudioHandler (commit 4/5, rc.8+64)
Replace the silence-emitting render callback from commit 1 with
real playback that drives AudioHandler::fill_buffer and downmixes
its 48 kHz stereo f32 output to the i16 mono buffer VPIO expects.

Pipeline per render callback (mirrors the cpal-output + sdl_output
contracts so the platform-neutral playback path is preserved):

1. Lock the shared Arc<Mutex<AudioHandler>>, ask fill_buffer to
   populate a stereo-f32 scratch slice of length 2*num_frames.
   AudioHandler runs Opus decode + per-client jitter buffer + mix
   internally. Same primitive every other platform calls.

2. If output_muted is true, zero the i16 output buffer and return.
   We still ran fill_buffer in step 1 so the jitter buffer drains
   while muted — preventing unbounded growth — which matches the
   cpal/SDL backend contract.

3. Downmix stereo -> mono with master gain:
       mono_f32 = (l + r) * 0.5 * gain
       i16_out  = (mono_f32.clamp(-1.0, 1.0) * i16::MAX) as i16
   The 0.5 average preserves total signal energy with 3 dB
   headroom against sum-of-correlated-peaks clipping. Multiply by
   gain after the downmix saves one mul per sample. Hard-clip on
   the i16 cast is acceptable because the upstream stereo signal
   is already in [-1.0, 1.0] from the f32 mix; only gain >1.0
   creates clipping pressure and that path is identical to every
   other backend's i16 conversion.

Closure ownership:
* scratch_stereo: Vec<f32> moved into the FnMut closure. First
  callback grows it to 2*num_frames; subsequent callbacks reuse
  the backing allocation. The audio thread never hits the
  allocator on steady-state callbacks.
* handler_for_render / output_gain_for_render / output_muted_for_render
  are Arc clones taken before the closure literal.

Public API change: AudioEngine -> IosVoiceUnit::start parameters
that were previously underscored (commit 1 placeholder) are now
all consumed by the wiring. Signature is unchanged, just the
binder names lose the leading underscore. engine.rs call-site
is unaffected.

Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 0.33s, no errors, no warnings.

Build counter 63 -> 64 — About dialog shows v1.0.0-rc.8+64.
2026-05-17 01:12:57 +08:00
EdisonJwa 1aa514df75 feat(audio,ios): wire VPIO input callback to Opus encoder (commit 3/5, rc.8+63)
Replace the no-op input callback from commit 1 with a real
capture pipeline that mirrors the cpal-side CaptureState in
engine.rs but is type-specialised for the i16 mono samples VPIO
delivers natively.

New IosCaptureState struct (private to ios_voice_unit.rs) owns:
* OpusEncoder configured for VoIP at 48 kHz mono (32 kbps,
  complexity 10, inband FEC, packet-loss-perc 5 — identical
  tuning to try_open_capture in engine.rs).
* pcm_accum: Vec<i16> with capacity 2*FRAME_SAMPLES_MONO, growing
  if a VPIO callback ever delivers more than ~40 ms.
* opus_out: [u8; MAX_OPUS_FRAME] scratch.
* Cloned Arc<AtomicBool> transmit gate + Arc<AtomicU32> frames-sent
  counter shared with AudioEngine.

ingest_i16 flow:
1. If PTT gate is off -> clear accumulator + return (matches cpal
   behaviour, no pop on PTT-release edge).
2. Apply mic_gain. Fast-path when gain==1.0 skips the multiply +
   saturate loop entirely; otherwise saturating mul-then-cast
   keeps the signal in the i16 envelope.
3. Drain complete 20 ms / 960-sample frames from the accumulator,
   encode via encoder.encode (i16 path, no float conversion
   needed since VPIO already gave us i16), build OutPacket with
   AudioData::C2S { codec: OpusVoice }, try_send on voice_out_tx.
4. Frame buffer is stack-allocated [i16; FRAME_SAMPLES_MONO] —
   no per-callback heap allocation on the realtime audio thread.

VPIO setup changes in IosVoiceUnit::start:
* NEW: explicit kAudioOutputUnitProperty_EnableIO (=2003) with
  value 1 on (Scope::Input, Element::Input) BEFORE the stream
  format setters. VPIO's input element is OFF by default; without
  this toggle no audio flows in and the input callback never
  fires. Commit 1's comment claiming set_input_callback handles
  this was wrong; coreaudio-rs's set_input_callback only installs
  the kAudioOutputUnitProperty_SetInputCallback property, not
  the EnableIO toggle.
* Apple's documented sequence (now matched):
  1. AudioComponentInstanceNew -> AudioUnit::new_uninitialized
  2. EnableIO on element 1     -> set_property(2003, ...)
  3. Stream format both elems  -> set_stream_format x2
  4. Install callbacks         -> set_input_callback + set_render_callback
  5. AudioUnitInitialize       -> unit.initialize
  6. AudioOutputUnitStart      -> unit.start
* set_input_callback closure now moves the IosCaptureState in
  by value and calls ingest_i16 with args.data.buffer (the
  &mut [i16] coreaudio-rs delivers after running AudioUnitRender
  internally to pull the mic samples into a pre-allocated
  AudioBufferList).

What this commit does NOT do:
* Output render callback is still a silence-emitting stub.
  Commit 4 lands the AudioHandler::fill_buffer + i16 downmix.
* Route-change handling — commit 5.

Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 1.18s, no errors, no warnings.

Build counter 62 -> 63 — About dialog shows v1.0.0-rc.8+63.
2026-05-17 01:11:11 +08:00
EdisonJwa 9502580b5a chore(audio,ios): silence cpal-side dead_code on iOS + regen Podfile.lock (rc.8+62)
Two follow-ups after the iOS Rust build went green at 5de6ecc:

1. cpal-side framing constants (SAMPLE_RATE / FRAME_SAMPLES /
   MAX_OPUS_FRAME) are dead code in the current iOS commit
   because the VPIO callbacks are still no-op stubs and don't
   reach the constants yet (commits 3 + 4 will). They are
   genuinely live on every other platform via the cpal capture
   pipeline. Mark each with #[allow(dead_code)] and add a
   comment pointing at the commits that will reactivate them on
   iOS, instead of cfg-gating per-platform (the constants are
   framing invariants of the engine itself, not per-backend
   details).

2. ios/Podfile.lock regenerated on the Mac via 'pod install'
   to register package_info_plus (0.4.5) which landed in
   97a6ba6. Without this regen the Xcode build fails with
   'The sandbox is not in sync with the Podfile.lock' because
   Xcode's CocoaPods integration check sees a new plugin in
   pubspec.yaml that has no matching Pod entry. Five pods now
   in the lockfile: Flutter, audio_session, chanora_bridge,
   connectivity_plus, package_info_plus.

Build counter 61 -> 62 — the About dialog will display
v1.0.0-rc.8+62 so the user can confirm the build under test
matches this commit (the previous build said +61).
2026-05-17 01:00:50 +08:00
EdisonJwa 5de6eccc0c fix(audio,ios): correct ios_voice_unit imports + iOS stop() field access (rc.8+61)
iOS build of chanora_audio (target aarch64-apple-ios) failed with
four compilation errors after commit 2 landed. Root causes were
all simple symbol-path / cfg-gating mistakes from the skeleton
commit; the underlying design is unchanged.

1. ios_voice_unit.rs: wrong import path for OutPacket. The
   chanora_protocol crate re-exports it at the crate root
   (`pub use ...::OutPacket` in lib.rs line 52), not from a
   `voice` submodule (which doesn't exist).
   - use chanora_protocol::voice::OutPacket;
   + use chanora_protocol::OutPacket;

2. ios_voice_unit.rs: LinearPcmFlags lives in
   `coreaudio::audio_unit::audio_format`, not in
   `stream_format` (the doc page lists it under StreamFormat but
   the actual module path is the upstream Apple naming).
   - use coreaudio::audio_unit::stream_format::LinearPcmFlags;
   + use coreaudio::audio_unit::audio_format::LinearPcmFlags;

3. ios_voice_unit.rs: `Ordering` import unused (commit 1
   skeleton callbacks don't load atomics yet — that comes in
   commits 3 + 4). Remove from the std::sync::atomic import to
   silence the unused_imports warning.

4. engine.rs::AudioEngine::stop(): the existing body unconditionally
   touched self._input_stream and self._output_stream, but commit
   2 cfg-gated those fields away on iOS (and added an iOS-only
   _ios_voice_unit field in their place). Split the field drop
   logic with the same target_os = "ios" cfg so each platform
   only touches the fields it actually has.

Also clean up two pre-existing warnings exposed by the iOS cfg
gating:

5. engine.rs: `tracing::{error, warn}` were imported
   unconditionally but are only used inside cpal log lines.
   Cfg-gate the import to not(target_os = "ios").
6. engine.rs: `AudioData`, `CodecType`, `OutAudio` from
   chanora_protocol are only referenced in the Opus encoder feed
   inside CaptureState — cpal-side only. Cfg-gate to
   not(target_os = "ios"); keep `InboundVoice` + `OutPacket`
   on the unconditional path because the inbound forwarder + (in
   commit 3) the iOS capture pipeline both reference them.

Also fix a stray duplicate `#[cfg(not(target_os = "ios"))]`
attribute that landed on line 18 in commit 2.

Build verify (Linux host): cargo check -p chanora_audio clean
in 0.53s. iOS-side check pending on Mac.

Build counter bumped 60 -> 61 — the About dialog will display
v1.0.0-rc.8+61 so the user can confirm the build under test
matches this commit.
2026-05-17 00:53:39 +08:00
EdisonJwa 3b1724e970 feat(audio,ios): wire IosVoiceUnit into AudioEngine, cfg-gate cpal away on iOS (commit 2/5)
Split AudioEngine::start_with_gate into two backends:

* start_with_gate_cpal  — non-iOS path, the existing cpal + (SDL on
                         Linux) flow, renamed verbatim, no
                         behavioural change.
* start_with_gate_ios   — iOS path, constructs a single
                         IosVoiceUnit (VoiceProcessingIO via
                         coreaudio-rs) for combined mic + speaker.
                         Spawns the same inbound forwarder task
                         that pumps Opus packets into AudioHandler.

The public entry point start_with_gate dispatches at the top via
cfg(target_os = "ios") so callers stay backend-agnostic.

Struct field changes:
* _input_stream  : cfg-gated to not(ios)
* _output_stream : cfg-gated to not(ios), keeps the
                   Linux=SdlOutput / else=cpal::Stream split
* _ios_voice_unit: new field, cfg-gated to ios, owns the VPIO
                   AudioUnit for the engine's lifetime.

Module-level cfg-gating:
* All cpal-only helpers (try_open_capture, build_input_stream,
  build_output_stream, CaptureState + impl, ToF32 / FromF32 traits
  and impls, PlaybackResampleState) are now wrapped with
  #[cfg(not(target_os = "ios"))]. Same for the audiopus
  encoder + cpal trait imports — iOS doesn't pull libopus into the
  engine yet (commit 3 will, once the VPIO input callback wires
  into CaptureState).

Behaviour on iOS for THIS commit:
* AudioEngine starts cleanly, IosVoiceUnit::start succeeds (VPIO
  unit allocates + initialises + starts).
* Mic capture is dropped (the input callback is a no-op stub).
* Output emits silence (the render callback fills the buffer with
  zeros).
* Inbound forwarder still runs and pushes Opus packets into
  AudioHandler — they accumulate in the jitter buffer but no
  fill_buffer drain happens (commit 4 fixes that), so the buffer
  will grow up to MAX_BUFFER_TIME (~0.5 s) and then tsclientlib
  starts dropping the oldest frames. This is fine for now — the
  point of this commit is verifying the AudioUnit constructs +
  starts cleanly on the device. Audible silence is the expected
  state until commits 3/4 land.

Build verify (Linux host): cargo check -p chanora_audio clean in
0.53s. iOS-side compile happens on the Mac via the Xcode build
the user will trigger next.
2026-05-17 00:45:50 +08:00
EdisonJwa af686ca7a6 feat(audio,ios): add coreaudio-rs dep + IosVoiceUnit skeleton (commit 1/5)
Skeleton scaffolding for the iOS VoiceProcessingIO backend that
will replace cpal on iOS. This commit lands the dependency + the
module + a constructable AudioUnit that emits silence and drops
input; nothing in engine.rs is wired up yet (that is commit 2).

Compilation contract for this commit:
* Linux / Windows / macOS / Android builds unaffected (the new
  module is target_os='ios' gated, the new dep is in
  '[target."cfg(target_os = \"ios\")"]').
* iOS build pulls in coreaudio-rs 0.14, constructs a VPIO unit,
  pins stream format to 48 kHz Int16 mono on both buses, installs
  no-op input + silence-emitting render callbacks, initializes,
  and starts. No audio is actually moved until commits 3/4.

Why VPIO and not RemoteIO via cpal: cpal's iOS backend opens
RemoteIO with no control over stream format / buffer size /
channels and produces a mono-only output element that stays bound
to the route present at construction time. End-user symptom on
iPhone 16 Pro iOS 18.7.8: tapping Speaker in the picker flips
AVAudioSession.currentRoute.outputs to Speaker (confirmed in our
diagnostic logs from commit da631a2) but audio keeps coming out
the receiver because the AudioUnit's output binding is stale.

Every production iOS VoIP client (Mumble iOS, Linphone /
mediastreamer2, Signal-iOS, Jitsi Meet iOS, the WebRTC reference
impl) avoids RemoteIO and uses VoiceProcessingIO instead. VPIO is
Apple's recommended voice unit; it ships hardware AEC + AGC + NS
and re-binds the physical transducer correctly on route changes
because it IS the canonical voice unit on iOS — FaceTime's audio
path runs through it.

coreaudio-rs 0.14 (RustAudio org, 8.6M downloads, same maintainers
as cpal) gives us a safe wrapper around the AudioUnit C API on
iOS. Uses objc2-* crates underneath so links cleanly into iOS
builds. ios_voice_unit.rs sits next to sdl_output.rs as the iOS
sibling of the Linux SDL2 output path.

Build verify (Linux host): `cargo check -p chanora_audio`
finished clean in 16.09s. iOS build verification happens in
commit 2 when the module is exercised; for this commit the module
compiles in isolation but is dead code on iOS too (no caller).
2026-05-17 00:42:47 +08:00
EdisonJwa 6a4dbad60e fix(ios,audio): use AVAudioSession mode .default + correct mono downmix
Two changes that together address the user-reported 'speaker selector
not working' AND 'audio quality bad' symptoms on iPhone:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default

   .voiceChat binds the underlying AudioUnit's output element to a
   SINGLE physical transducer (the receiver/earpiece) at session-
   configure time. overrideOutputAudioPort updates AVAudioSession's
   route metadata so currentRoute.outputs reports Speaker, but the
   AudioUnit's output binding is stale and audio keeps routing to
   the original transducer. Net: tapping Speaker in the picker
   flipped the route in our log but produced no audible change.

   .voiceChat also enables iOS's telephony processing chain (forced
   mono output, aggressive AGC, heavy noise gating) which explains
   the 'garbled / watery / metallic' quality complaints.

   .default mode uses iOS's standard audio graph: stereo output, no
   AGC, no telephony post-processing, AudioUnit re-binds live when
   the route changes. Same mode Music.app and most non-telephony
   apps use. Trade-off: we lose iOS hardware AEC. If users report
   speakerphone echo we'll add software AEC (DEC-030).

   Category options unchanged \u2014 .allowBluetoothHFP +
   .allowBluetoothA2DP still permit BT headsets in both directions.

2. engine.rs::build_output_stream: mono device downmix fix

   The mixing path at dev_channels==1 previously wrote only the L
   channel of AudioHandler's stereo output into the single mono
   device channel and discarded R entirely. Anything panned right
   in the stereo voice mix was silently lost \u2014 on .voiceChat
   speakerphone (forced mono device) this manifested as quiet
   remote speakers being inaudible. Fix: when dev_channels==1,
   output = (L + R) * 0.5 instead of just L. The dev_channels>=2
   branch is unchanged.

   With change #1 iOS will typically expose stereo so this branch
   is rarely hit, but the fix is correct for any genuinely-mono
   sink (some BT car-audio profiles, USB mono headsets).
2026-05-17 00:18:48 +08:00
EdisonJwa 54ec8dd5a8 feat(audio): tune Opus encoder for VoIP (bitrate 32k, complexity 10, inband FEC, 5% PLC)
User report: 'sound heard are too poor' on iPhone iOS \u2014 garbled/
robotic + stutters/dropouts.

The Opus encoder ran with audiopus defaults: 'auto' bitrate that
drops to ~6 kbps during silence (sounds watery on speech resume),
inband FEC disabled (single packet loss = silent gap), no packet-
loss percentage hint (encoder can't budget bits for redundancy).

On lossy mobile networks (cell, WiFi roaming) this combination
sounds noticeably worse than the same Opus stream from a desktop
client. Garbled = silence-bitrate transitions; stutters = packet
loss without FEC.

Fix: tune the encoder once at construction with values derived
from RFC 6716 \u00a77.1 (Opus VoIP recommendations), Discord's voice
client tuning, and the Mumble defaults.

  * set_bitrate(32_000)        \u2014 sweet spot for mono speech. Below
                                  24 kbps starts to sound watery;
                                  above 64 kbps wastes bandwidth.
                                  Discord uses 64 kbps; Mumble
                                  defaults to 40 kbps; we pick
                                  32 kbps as a conservative VoIP
                                  value that survives ~100 kbps
                                  uplinks comfortably.
  * set_complexity(10)         \u2014 max quality. The CPU cost on a
                                  modern iPhone (A14+) or any
                                  desktop is negligible (~0.5 % of
                                  a single core for 48 kHz mono).
  * set_inband_fec(true)       \u2014 Opus inserts a low-bitrate copy
                                  of the previous frame inside the
                                  current packet so single-packet
                                  loss can be reconstructed from
                                  the next packet. Essential on
                                  lossy mobile. The decoder side
                                  (tsclientlib's AudioHandler)
                                  auto-handles FEC frames; no
                                  receiver-side change needed.
  * set_packet_loss_perc(5)    \u2014 tells the encoder to budget
                                  bits for 5 % expected loss.
                                  Higher values trade audio
                                  quality for resilience.

Each setter is wrapped in a soft-fail: if an exotic libopus build
rejects one of these, we log + continue with the still-functional
encoder rather than aborting the audio engine. info!-log a one-
liner per-engine-start summarising the tuned values so a future
diagnostic export can correlate audio reports with the active
configuration.

Application::Voip mode was already set (engine.rs:630, unchanged);
the new calls layer on top of that mode's defaults.

cargo test -p chanora_audio --release --lib: 32 passed.
flutter build ios --release --no-codesign: 17.2 s, Runner.app
30.4 MB.
2026-05-16 21:22:52 +08:00
EdisonJwa f1f81a3d7e fix(core,bridge,ios): voice_join survives audio-engine failure; iOS log file
User report from iPhone: 'mute / continuous / PTT buttons missing'
with NO error popup. Root cause: voice_join's audio-engine startup
was failing silently, and the failure propagated out as a hard
error \u2014 which means SessionEvent::VoiceState(true) was never sent
to Dart even though the server-side channel move had already
succeeded. Dart's _inChannel stayed false; every control gated on
_inChannel disappeared while the channel tree continued to show
the user as joined.

This commit makes voice_join lenient on audio-engine failures so
the UI state matches the server-side reality, and also gives iOS
a writable log file so diagnostics from device builds are
recoverable for the first time.

core/chanora_core/src/lib.rs::voice_join
  * ensure_audio_running's error is now logged + emitted as
    SessionEvent::AudioStopped, but does NOT abort voice_join.
    The server move at step 1 already succeeded; failing the
    Dart-visible promise here would leave the UI in a phantom
    'in-channel visually but no controls' state. After this
    commit:
      - mic / headset / settings appear in the AppBar
      - PTT button appears at the bottom
      - status chip shows live audio state ('Mic on/off')
      - if audio actually failed (mic permission denied,
        no input device, CoreAudio rejecting stream config)
        the user can retry by switching modes / channels;
        BridgeEvent::AudioStopped wires _audioStarted=false
        in Dart so audio-stats poll is honest about the
        engine state.

crates/chanora_bridge/src/api.rs::log_file_path
  * iOS now writes the log to /home/milkice/Documents/chanora.log
    (Documents is the standard user-visible iOS sandbox dir).
  * Android remains None pending the bridge JNI init wiring
    a writable path (P1 follow-up).

apps/chanora_flutter/ios/Runner/Info.plist
  * Adds UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
    so the Documents directory shows up under 'On My iPhone \u2192
    Chanora' in the Files.app. The user can now copy chanora.log
    out for support without needing Xcode \u2192 Devices and
    Simulators \u2192 Download Container.

Workspace tests: 78/0/1 unchanged.
flutter build ios --release --no-codesign: 22.7 s clean
(Runner.app 29.9 MB).
2026-05-16 17:16:17 +08:00
EdisonJwa d4c04b6a72 fix(ui,audio,ios,macos): eight P0 mobile fixes
User report from sideloaded iPhone build, in order of priority:

  #4 'Could not join channel: audio: audio backend:
     build_output_stream: The requested stream configuration is
     not supported by the device.'

     Cause: we forced cpal::BufferSize::Fixed(2048) on the output
     and input streams unconditionally on non-Linux. iOS CoreAudio
     RemoteIO units reject arbitrary buffer-size requests with that
     exact error. Windows WASAPI needs the pinning for shared-mode
     jitter, but macOS / iOS do not.
     Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere
     else use BufferSize::Default and let the platform HAL pick.
     crates/chanora_audio/src/engine.rs.

  #5 'Could not join channel: invariant violated:
     voice_in already taken'

     Cause: start_audio tore down the old engine BEFORE attempting
     to construct the new one, and consumed voice_in (an mpsc
     Receiver that can only be taken once) early. When the new
     engine failed mid-construction (e.g. because of #4 above) the
     session was left with: no audio engine, voice_in consumed,
     no way to retry without reconnect. The second voice_join
     attempt surfaced the invariant message.
     Fix: build the new engine BEFORE tearing down the old. Only
     swap state.audio if construction succeeded. crates/chanora_
     core/src/lib.rs::ChanoraSession::start_audio. Additionally
     added a put_voice_in helper to the protocol adapter (
     crates/chanora_protocol/src/adapter.rs) for a future
     broadcast-channel migration; the helper is unused on the
     immediate fix path but documents the intent.

  #3 'permission request would better on first open'

     Cause: AVAudioSession only triggers the mic-permission
     prompt the first time it tries to record. We never recorded
     until voice_join, so the prompt fired then.
     Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission
     in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:).
     Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in
     macos/Runner/AppDelegate.swift::applicationDidFinishLaunching.
     Both run non-blocking; user can deny without crashing app
     launch, and voice_join then surfaces a clearer downstream
     error when the engine fails to open the input device.

  #1 + #2 'one-column upper takes too much space; Push to Talk
           button at bottom would be better'

     Layout rework for narrow-mode (single column, mobile shape):
     - Flipped the stacking order in main.dart so Voice Bar moves
       to the BOTTOM of the body and the channel tree (Expanded)
       fills above. Wide-mode (Row, >= 840 dp) layout unchanged.
     - Inside the Voice Bar on touch-only hosts, moved the
       on-screen Push to Talk button to be the LAST element of
       the Voice Bar (was Row 3). Order now: pill + mutes, mode
       badge + settings, level meter, stats line, release-tail
       caption, PTT button. The button is closest to the user's
       thumb when the Voice Bar is pinned to the bottom of a
       narrow-layout screen.

  #6 'remove right top debug badge'

     debugShowCheckedModeBanner: false on the MaterialApp.
     Release builds never showed it anyway; this only affects
     local dev / debug builds.

  #7 'what does the refresh button use for? nothing happened'

     Removed. The snapshot updates via BridgeEvent::SnapshotChanged
     are pushed from the bridge — a manual rust.snapshot() call
     was redundant. Now only the Diagnostics + Disconnect actions
     remain in the AppBar trailing row when connected.

  #8 'Bind Key related function should not be added to a mobile
     platform'

     widgets/voice_settings.dart: bind-key OutlinedButton is now
     #cfg'd out when Platform.isIOS || Platform.isAndroid. The
     release-tail slider stays because it still applies to the
     on-screen PTT button. Capability badge in voice_bar.dart
     also hidden on mobile (it would always show L0Focused which
     is redundant with the visible on-screen button).

Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1
on Linux; flutter analyze clean (6 pre-existing Radio.groupValue
infos). flutter build ios --release --no-codesign: 28.8 s clean
(Runner.app 29.9 MB).
2026-05-16 15:41:08 +08:00
EdisonJwa f0ddb160a0 fix(protocol,audio,ios): native-tls instead of rustls+aws-lc-rs
Building the bridge for `aarch64-apple-ios` failed in two ways with
the previous TLS stack:

  1. `aws-lc-sys` (transitive: rustls -> aws-lc-rs -> aws-lc-sys)
     does not cross-compile cleanly to iOS — the build produced
     undefined symbols for architecture arm64 (mldsa44, ec_GFp_mont,
     etc).
  2. `audiopus_sys` linked against the wrong iOS runtime version,
     missing `___chkstk_darwin`.

Following the rustls-platform-verifier docs and the standard Rust+
iOS+TLS pattern used by 1Password / Signal / rustup / Bitwarden,
this commit swaps the TLS provider to **native-tls** so each
platform picks its own:

  * macOS + iOS  -> Security.framework (no external C deps)
  * Windows      -> SChannel
  * Linux/BSD    -> system OpenSSL

Changes:

crates/chanora_protocol/Cargo.toml
crates/chanora_audio/Cargo.toml
  * Drop `default-tls` from tsclientlib's features. The remaining
    `audio` feature is what we actually use; default-tls was a
    reqwest convenience that picked rustls+aws-lc-rs.
  * Add a direct `reqwest` dep with `default-features = false,
    features = ["charset", "http2", "native-tls"]`. Cargo's
    workspace feature unification carries this through the
    transitive `tsclientlib -> reqwest` chain.

apps/chanora_flutter/ios/Podfile
  * Uncomment `platform :ios, '13.0'` so CocoaPods stops emitting
    the implicit-platform warning and Xcode's iOS deployment-
    target check is honored.

apps/chanora_flutter/ios/Podfile.lock
  * Generated by `pod install` after the platform pin. Committed so
    iOS builds on other developer machines pull the exact same Pod
    versions.

apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata
  * CocoaPods auto-integration: adds Pods_Runner.framework +
    Pods_RunnerTests.framework references and the Pods xcconfig
    file references. Standard `pod install` output; reviewing the
    diff shows only Pod-bookkeeping additions, no signing or
    target-config drift.

Verified end-to-end on the M1 Mac (coder@100.118.130.73):
  cargo build --release -p chanora_bridge                29.09 s
  cargo build --release --target aarch64-apple-ios       24.32 s
    (with IPHONEOS_DEPLOYMENT_TARGET=13.0 and
     CMAKE_POLICY_VERSION_MINIMUM=3.5 in the env to satisfy the
     audiopus_sys cmake invocation; documented as a P1 build-glue
     follow-up.)
  flutter build ios --release --no-codesign              ok
    Built build/ios/iphoneos/Runner.app (16.9 MB)
  Xcode GUI build of Runner.xcworkspace                  ok
    (after the user opened Runner.xcworkspace, NOT
     Runner.xcodeproj, and Clean Build Folder.)

Tests on macOS unchanged: chanora_audio 34 / 0 / 0.

DEC-025: iOS + macOS officially in scope for P0.
2026-05-16 15:32:08 +09:00
EdisonJwa f3320715ea feat(audio,ios): AVAudioSession PlayAndRecord+voiceChat in AppDelegate
iOS AVAudioSession must be configured BEFORE Flutter starts its
audio pipeline; the canonical place is application(_:didFinishLaunching\
WithOptions:) in AppDelegate.swift. This commit:

apps/chanora_flutter/ios/Runner/AppDelegate.swift:
  * import AVFoundation
  * In application(_:didFinishLaunchingWithOptions:), call
    AVAudioSession.sharedInstance().setCategory(.playAndRecord,
      mode: .voiceChat,
      options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
    followed by setActive(true). Failures are NSLogged but do not
    block app launch — cpal's CoreAudio backend will still come up
    against the default iOS routing.

  This shape:
   * routes the receiver/speaker like a phone call (.playAndRecord +
     .voiceChat),
   * engages on-device AEC / NS where supported,
   * defaults to speaker so users don't have to hold the phone to
     their ear,
   * permits Bluetooth headsets (AirPods et al. just work).

crates/chanora_audio/src/engine.rs:
  * Replace the iOS engine-start placeholder log line ('binding
    pending — Chanora iOS audio is documented-only for Beta') with
    an honest acknowledgment that the AVAudioSession configuration
    lives Swift-side. The Rust engine acknowledges the request, then
    cpal opens its CoreAudio streams against the session.

iOS-only Rust code is #[cfg(target_os = "ios")]-gated so this commit
is no-op on every other platform.

SRS-197: iOS/macOS audio routing contract. DEC-025: iOS officially
in scope for P0 (Focused PTT only — Apple's sandbox model has no
global PTT analogue).
2026-05-16 13:50:43 +08:00
EdisonJwa 383b707e7c feat(audio,macos): live CGEventTap PTT capture (SDD-085)
Replace the macOS PTT backend's worker-thread stub (which had just
slept) with a full CGEventTap implementation:

  * extern "C" bindings to CGEventTapCreate, CGEventGetIntegerValueField,
    CGEventTapEnable, CFMachPortCreateRunLoopSource, CFRunLoopGetCurrent,
    CFRunLoopAddSource/RemoveSource, CFRunLoopRun/Stop, CFRelease, plus
    the kCFRunLoopCommonModes static.
  * tap_callback: C-ABI extern fn that reads bound keycode /
    mouse-button from atomics, matches the incoming event, and toggles
    the AudioTransmitGate. Returns the event unchanged (listen-only
    tap, no event modification). Privacy-safe: never logs raw key
    codes or button numbers (DEC-027).
  * Event mask covers kCGEventKeyDown, kCGEventKeyUp,
    kCGEventOtherMouseDown, kCGEventOtherMouseUp; also handles the
    kCGEventTapDisabledBy{Timeout,UserInput} notifications by logging
    a degraded-mode warning.
  * Worker thread captures the CFRunLoopRef via a Send-marked
    RunLoopHandle newtype so stop() can call CFRunLoopStop from the
    audio engine thread.
  * Box<TapState> is leaked into the worker via a Send-marked
    TapStatePtr newtype; reclaimed on worker exit so the gate's Arc
    refcount stays correct.
  * Flutter logical-key labels are mapped to Carbon virtual keycodes
    via label_to_macos_keycode (covers letters, digits, function keys,
    navigation, common punctuation). Mouse-side-button labels resolve
    via label_to_macos_mouse_button (3 = Mouse4, 4 = Mouse5).
  * refresh_bound_atomics() rebuilds bound_keycode + bound_mouse_button
    on start() and rebind() so the tap callback sees the new binding
    without re-arming the tap.

Tests: chanora_audio 34 / 0 / 0 on macOS (was 28 before this commit).
Added: keymap_letters, keymap_function_keys, keymap_navigation,
keymap_unknown_returns_none, mouse_button_map, runloop_handle_is_send.

Verified the live IOHIDCheckAccess returns Undetermined (Unknown=2) on
a fresh M1 box where Input Monitoring has never been requested; the
1.5 s permission-watcher re-publishes the descriptor on user grant
or revoke without restart.

DEC-025: macOS desktop officially in scope. SAD-073: two-level PTT
ladder. SRS-198: honest capability advertising. DEC-027: privacy.
2026-05-16 13:50:28 +08:00
EdisonJwa ade50488d9 feat(audio,macos): live IOHIDCheckAccess for Input Monitoring permission
Replaces the macOS PTT backend's query_permission() stub (which had
returned Undetermined unconditionally) with a real IOKit call:

  extern "C" { fn IOHIDCheckAccess(request_type: u32) -> u32; }
  IOHIDCheckAccess(kIOHIDRequestTypeListenEvent = 1)

Returns Granted (0), Denied (1), or Unknown (2). The existing 1.5 s
re-query worker now drives real descriptor transitions when the user
grants or revokes Input Monitoring in System Settings: the watch
sender republishes the descriptor, ChanoraSession forwards
BridgeEvent::PttCapability, and the Flutter capability badge updates
within ~1.5 s without an app restart.

Verified live on the M1 Mac:
  rustc /tmp/check_perm.rs && ./check_perm
  IOHIDCheckAccess(ListenEvent) = 2 (Unknown)
This is the expected initial state on a fresh box where Chanora has
not yet attempted CGEventTapCreate; once the next commit lands the
event-tap worker, the macOS Input Monitoring prompt will fire on
first audio start and the value transitions to Granted/Denied.

Tests: chanora_audio 28 / 0 / 0 on macOS (Linux had 32; the 4-test
delta is the Linux-only portal probe tests). The existing 7 macOS
backend unit tests still cover the descriptor builder + state
machine purely; they don't exercise the live IOKit call (which
would need a TCC-aware test harness).

SDD-085 reference: macOS Event Tap backend / L2 / L3 capability;
SRS-198 honest capability advertising.
2026-05-16 14:09:47 +09:00
EdisonJwa d9c330c23b feat(audio,linux): output via SDL2; cpal stays on Windows/macOS
User reported persistent crackling/popping from peer audio on Linux even
after fixing the 48k->device-rate resampler boundary discontinuities,
clamping pre-Opus-encode peaks, and pre-allocating the playback scratch
buffer. Logs confirmed cpal opened raw ALSA at 44.1k native, no callback
budget violations, no underrun warnings -- yet the audio was still poor.

Root cause: cpal on Linux opens raw ALSA's 'default' PCM. On modern
PipeWire / pipewire-alsa boxes that virtual device routes through ALSA's
dmix + plug layers, whose default resampler is nearest-neighbour. cpal
also picks a small default period size (~256 frames / 5.8 ms) leaving no
headroom for kernel scheduler jitter. Both effects compound into the
crackling the user heard.

Upstream tsclientlib's own audio example
(tsclientlib/examples/audio_utils/ts_to_audio.rs) and the official Qint
client both use SDL2 with AudioSpecDesired { freq: 48000, channels: 2,
samples: 960 }. SDL2 on the same systems routes through PipeWire's PA
bridge (or PulseAudio directly), both carrying high-quality resamplers.

Fix:
  * Add sdl2 = '0.37' as a target_os=linux dependency. Links libSDL2-2.0
    .so (Arch sdl2-compat over SDL3, Debian libsdl2-2.0-0, Fedora SDL2).
  * New module crates/chanora_audio/src/sdl_output.rs implementing
    SdlOutput: opens a 48 kHz stereo 960-frame callback that zeroes the
    buffer and calls AudioHandler::fill_buffer directly (no user-side
    resampler). Master gain + hard-mute atomics wired in identically to
    the cpal callback so set_output_gain / set_output_muted keep working.
  * engine.rs cfg-gated: target_os='linux' builds SdlOutput; everywhere
    else continues with the cpal output path (including the device-native-
    rate negotiation and resampler-continuity fixes shipped earlier --
    those remain correct on Windows/macOS where cpal targets WASAPI /
    CoreAudio cleanly).
  * The cpal output helpers (build_output_stream, PlaybackResampleState,
    FromF32) are now cfg(not(target_os='linux'))-gated so the Linux
    build doesn't emit dead-code warnings.

Capture path still cpal on every platform -- outbound audio was not
reported as bad. Resampler-continuity fix on the capture side stays:
microphone -> Opus encoder still goes through the linear interpolator
with the last-sample anchor.

Tests: 32 / 0 / 0 (chanora_audio), workspace 78 / 0 / 1 unchanged.
2026-05-16 12:15:00 +08:00
EdisonJwa 8acd456af1 feat(protocol): per-platform TS3 client_version selection
ConnectOptions previously took tsclientlib's default Version. Servers that
strictly check the announced client signature could refuse or downgrade
those sessions. Add pick_client_version() that selects a stable signed
descriptor matching the runtime OS:

  Windows  -> Version::Windows_5_0_0_beta51
  Linux    -> Version::Linux_5_0_0_beta51
  macOS    -> Version::macOS_5_0_0_beta51
  Android  -> Version::Android_3_5_0__7
  iOS      -> Version::iOS_3_5_6
  other    -> Linux fallback

All five variants are guaranteed to exist in the vendored tsproto-types
enum at compile time; build fails loudly if upstream removes one.

Wired into Connection::build(...).version(pick_client_version()) on every
connect. Emits 'selected TS3 client_version' info log line so the choice
is visible in chanora.log.
2026-05-16 12:14:37 +08:00
EdisonJwa 73066749e3 fix(audio,linux): isolate zbus blocking probe on a fresh OS thread
LinuxGnomeWaylandBackend::probe() called zbus::blocking::Connection::session()
directly. The blocking facade internally constructs a current-thread tokio
runtime and block_on()s its async D-Bus client. probe() runs from
PttController::new (sync) which is called from start_audio (async on the
bridge tokio runtime). Nested runtimes panic with 'Cannot start a runtime
from within a runtime'.

Symptom on Linux: the first voice-channel join surfaced a SnackBar
'Could not join channel: join: task N panicked ...' while the channel-move
command had already succeeded server-side. User saw 'channel joined but voice
not enabled'.

Fix: run the cheap blocking probe on a dedicated std::thread (no ambient
runtime), join it synchronously, propagate the version / error. Probe is
microseconds; the join cost is negligible.
2026-05-16 12:14:28 +08:00