d7556cd39f77df5ad00616ff1daabcb165ffa711
82
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d5d8c2c90 | feat: integrate chat voice and diagnostics client | ||
|
|
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) |
||
|
|
6af4ecab0f | feat(voice): add iOS VAD runtime support | ||
|
|
b65cedfa95 | fix(android): recover from input stream failures | ||
|
|
b21fc16ee6 | fix(android): clarify channel and voice status icons | ||
|
|
f8b6485390 | fix(android): show password action only for locked channels | ||
|
|
3ae8e1ab77 | fix(android): adapt fold layout and diagnostics | ||
|
|
eb1f4b895e | fix(android): remove cpal product wording | ||
|
|
210a6a0e11 | fix(android): unblock mic permission startup | ||
|
|
26b06ce786 | fix(android): expose voice diagnostics safely | ||
|
|
d6763787aa | feat(android): initialise Oboe runtime context | ||
|
|
c87b47f064 | feat(audio): prefer native voice backends | ||
|
|
8c253f1d4d | feat(voice): harden Android audio and channel joins | ||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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) |
||
|
|
7a59f5b9a1 | feat(ios,p0): iOS P0 platform, audio fixes, channel UX | ||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
6e0bf21295 |
fix(audio,ios): route playback via media channel (.default + .defaultToSpeaker) (rc.8+70)
User report after the 8x boost commit ( |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
01003b3448 |
refactor(protocol): clarify pending_moves expiry sweep
The original mem::replace + retain pattern worked but was opaque. Switch to a two-pass approach: collect expired MessageHandles into a small Vec, then remove + resolve. Behaviour-preserving; the clippy-style readability win is worth the tiny extra allocation (typical case: 0 or 1 expired entries per loop iteration). |