main
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cc6db18199 | build(flutter): add local notification plugin | ||
|
|
2f47e7ccee |
chore: prepare v0.3.0 release
bench-advisory / bench-advisory (push) Failing after 7m7s
ci / cargo check + cargo test (push) Successful in 10m56s
ci / cargo deny (licenses + advisories + bans + sources) (push) Successful in 53s
ci / cargo about (license inventory) (push) Successful in 10m46s
ci / flutter license inventory (push) Successful in 2m13s
ci / flutter analyze (push) Successful in 36s
ci / flutter iOS unsigned release build (push) Has been cancelled
- Rewrite CHANGELOG.md with v0.3.0 entry - Bump version to 0.3.0+100 - Fix split-per-abi Android APK builds |
||
|
|
5515ff6643 | feat: stabilize voice activity and audio routing | ||
|
|
7d5d8c2c90 | feat: integrate chat voice and diagnostics client | ||
|
|
738b274748 | chore: bump build number to +76 | ||
|
|
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 | ||
|
|
b97cc9589b |
chore: upgrade dependencies to fix low-severity vulnerability
- connectivity_plus 6.1.5 → 7.1.1 - package_info_plus 8.3.1 → 10.1.0 - package_info_plus_platform_interface 3.2.1 → 4.1.0 - win32 5.15.0 → 6.2.0 - json_annotation 4.11.0 → 4.12.0 - ffi_leak_tracker added (0.1.2) |
||
|
|
618b6930fe | feat: add macOS bridge podspec, Windows project, sync versions to 0.2.0-beta.1 | ||
|
|
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. |
||
|
|
c89acacc74 |
fix(ios,audio): pair VPIO with AVAudioSession mode .voiceChat (rc.8+67)
User report at +66: capture (mic -> remote) is clean, but local playback (remote -> speaker) is 'broken and poor', particularly for human voice. Musicbot audio (loud, near-continuous) plays correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic 20 dB peak-to-average ratio) sounds gated out so most inter- phoneme content is unintelligible. Diagnostic at +66 (render callback peak sampling every 100 callbacks during a 60-second talker session) showed peak_stereo values in the 0.005-0.01 range with occasional 0.13-0.49 spikes \u2014 i.e. the signal is REAL and reaching the device, but VPIO's output-side voice processing chain is gating the average-level content. Root cause: AVAudioSession mode .default + VPIO is a mismatched pairing. Under .default mode the VPIO unit's internal AGC/NS thresholds are tuned wrong for telephony-style speech and treat quiet inter-phoneme content as noise to gate out. Fix: switch back to mode .voiceChat which is Apple's documented pair for VoiceProcessingIO. WebRTC's reference iOS audio device manager (chromium googlesource voice_processing_audio_unit.mm) also uses this pair. VPIO under .voiceChat tunes its processing chain for speech and passes quiet content through cleanly. The original 'speaker/receiver toggle is silent under .voiceChat' bug was caused by cpal's RemoteIO unit binding to a stale physical transducer at construction time, not by .voiceChat itself. After migrating to VPIO at commits 1-4 ( |
||
|
|
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.
|
||
|
|
ecf9370db1 |
fix(ui): preserve '-rc.8' in About dialog despite iOS version sanitisation (rc.8+65)
Two related fixes for the version-display work landed at
|
||
|
|
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.
|
||
|
|
97a6ba6b55 |
feat(ui): show build number in About dialog (v1.0.0-rc.8+<build>)
Add package_info_plus 8.3.1 (Flutter Community Plus, BSD-3, ~3M downloads) and resolve the displayed version string from the platform manifest at app init. The string shown in the About dialog is now formatted as 'v<version>+<build>' (e.g. 'v1.0.0-rc.8+60') where both halves come from the SAME pubspec.yaml 'version:' field that Flutter uses to populate iOS's CFBundleShortVersionString + CFBundleVersion and Android's versionName + versionCode. Motivation: during the iOS VoiceProcessingIO audio rollout the user is rebuilding repeatedly from Xcode. Without a build-number suffix there is no way to confirm from inside the app which commit produced the build under test — they all read 'v1.0.0-rc.8'. Every test commit going forward bumps the +<build> field in pubspec.yaml so the About dialog uniquely identifies the build. Implementation: * pubspec.yaml: version 1.0.0-rc.8+59 -> +60 (this commit is a test build). Add package_info_plus: ^8.0.0. * main.dart: _kAppVersion changes from 'const String' to 'String' (resolved at runtime). Populated in main() before runApp() via new _resolveAppVersion() helper that calls PackageInfo.fromPlatform() and formats 'v<info.version>+<info.buildNumber>'. Falls back to the hardcoded 'v1.0.0-rc.8' baseline if the platform call fails (extremely unlikely; the platform channel is a constant lookup). The consumer site at the About dialog (l10n.aboutVersion(_kAppVersion)) is unchanged — the variable still resolves to a String. flutter analyze: clean. |
||
|
|
f0016155aa |
feat(ui,ios): replace audio_router with audio_session + custom VoIP-style picker
User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.
Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.
Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:
* AVAudioSession.availableInputs \u2014 enumerate every real input
port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
(wired), usbAudio, carAudio, airPlay.
* AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
active route.
* AVAudioSession.setPreferredInput(port) \u2014 switch the input
(HFP / wired / USB / car audio also move output to themselves).
* AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
toggle built-in speakerphone vs receiver/earpiece.
* AVAudioSession.routeChangeStream \u2014 live notifications when
the user plugs / unplugs / connects a device while the picker
is open.
This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.
New widgets in voice_compact.dart:
* _AudioOutputTile: shows the active output port name (Speaker /
iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
matching icon. Subscribes to routeChangeStream for live
updates. Tap opens _AudioOutputPickerSheet.
* _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
and a Discord-style list:
- Speaker (volume_up)
- iPhone (phone_in_talk; the receiver/earpiece)
- <BT name> (bluetooth_audio)
- <Wired headset> (headset)
- <USB / Car> (usb / directions_car)
Selected row is highlighted + has a check mark. Tap routes:
- Speaker -> overrideOutputAudioPort(.speaker)
- iPhone -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
- External -> overrideOutputAudioPort(.none) + setPreferredInput(port)
* _PickerRow: shared row widget with selected/check styling.
AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(
|
||
|
|
b8e9c8549e |
feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
Two user-reported issues addressed:
1. 'on supported devices such as iphone or android user should be
able to select audio device such as speaker or airpods or phone'
2. 'the bottom folder is duplicated with the app bar settings'
Issue 1 \u2014 audio output route picker:
Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:
* iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
Control Center's audio chooser. Lists Speaker / iPhone receiver /
AirPods / connected Bluetooth devices / AirPlay / CarPlay.
System manages the device list; we don't have to track route
changes manually.
* Android (post-rc.8 when we wire the platform): Material Design 3
dialog backed by AudioManager.setCommunicationDevice() with
SCO Bluetooth + USB headsets filtered for VoIP.
The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit
|
||
|
|
bb3c82e2d5 |
chore(version): bump to v1.0.0-rc.8 (post-rc.7 v1 audio + PTT lifecycle work)
pubspec.yaml + _kAppVersion in main.dart were both still reading v1.0.0-rc.1 even though the branch has accumulated 50+ commits of post-rc.7 work (the v1 audio + PTT lifecycle redesign in SDD-094..097, plus DEC-029/030/031). The About dialog and the diagnostic export's app_version field both surfaced the wrong string. Aligned both to v1.0.0-rc.8+59 (build number is the current commit count on the branch). The Rust workspace version stays at 0.0.1-pre — it's an internal pre-release marker the diagnostic export carries as crate_version, not user-facing, and changing it cascades into every inheriting Cargo.toml for no benefit. The actual v1.0.0-rc.8 tag will land once the Korean Windows 11 human-side P0 acceptance pass closes; this commit aligns the strings the running build shows so the tester sees a consistent version while running the tests. |
||
|
|
50768a8f48 |
feat(mvp): v1.0.0-rc.1 — keyring-backed DEK, encrypted bookmarks, MVP release-gate docs
Closes the v0.4 dual-file weakness in identity-at-rest and turns the release into an MVP public release candidate. The remaining work before `v1.0.0` is DEC-012 legal sign-off — see `docs/governance/legal-review-readiness.md` — and the staged platform promotions in `docs/governance/staged-release-plan.md`. No decision rows in `product-decision-register.md` change; the register's change-history advances to 0.9.8. `chanora_storage` ----------------- * New public `Crypto` trait + `IdentityFileStore::crypto()` give callers an encrypt / decrypt pair anchored on the per-install 32-byte DEK without exposing the key material. * `IdentityFileStore` keyring-first DEK retrieval (Linux Secret Service via D-Bus, macOS Keychain, Windows Credential Manager, iOS Keychain via the `keyring` crate). Pre-existing `identity.dek` files are opportunistically migrated into the keyring on first run; the on-disk DEK copy is removed once the keyring acknowledges. `CHANORA_DISABLE_KEYRING=1` forces the file-fallback path for tests and headless / CI hosts where a real keyring call would prompt the user or block on a missing D-Bus session. * `BookmarkRepository::with_crypto(dir, crypto)` encrypts the server password into a new `password_blob` BLOB column under the same per-install DEK. Schema v2 migration is idempotent — legacy v0.4 rows with a plain `password TEXT` are read transparently and lifted into `password_blob` on the next `update()`. `BookmarkRepository::new` (no crypto) is preserved for tests and as a documented fallback when the DEK is unreachable. * Storage tests rise from 8 to 10: encrypted bookmark password round-trip + legacy-plaintext-bookmark upgrade. `chanora_core` -------------- * `ChanoraSession::init_storage(dir)` wires the bookmark repository with crypto by default. On any crypto-derivation failure it falls back to the plain-password repository and logs the gap — better than hard-failing init. * `supervisor_loop` now tracks a 64-bit `snapshot_signature` over channels (id + parent + order + name) and clients (id + channel + name) instead of the old `(channel_count, client_count)` tuple. Any in-channel client move, channel rename, or reorder now fires `SessionEvent::SnapshotChanged`. The signature sorts by id before hashing so it's stable under input-vector reordering. * Two new unit tests cover the signature behaviour; new `tests/mvp_storage.rs` integration test drives `ChanoraSession::init_storage` end-to-end and verifies the bookmark `password_blob` does not contain the plaintext. * Re-export `ChannelId` + `ClientId` from `chanora_protocol` so downstream callers and tests can construct DTOs directly. Flutter ------- * New About dialog (info icon in the AppBar) surfaces DEC-018 (public name "Chanora"), DEC-019 (non-affiliation statement), and DEC-020 (Apache-2.0 OR MIT dual license). New ARB keys in `app_en.arb` and `app_zh.arb`: `aboutAction`, `aboutVersion`, `aboutNonAffiliation`, `aboutLicenseHeading`, `aboutLicenseBody`, `aboutThirdPartyHeading`, `aboutThirdPartyBody`. * `pubspec.yaml` version bumps to `1.0.0-rc.1+5`. Governance ---------- * `docs/governance/legal-review-readiness.md` — DEC-012 handoff package. Enumerates trademark / non-affiliation / license-text / third-party-attribution / `tsclientlib`-posture / crypto- export / data-handling items the legal reviewer must confirm, and lists the concrete engineering deliverables they block on (`cargo about generate`, `cargo deny check licenses`, Flutter `LicenseRegistry` dump). * `docs/governance/staged-release-plan.md` — DEC-002 channel schedule. Linux + Android sideload promote to GA on DEC-012 sign-off; Play Store / Windows / macOS / iOS gate on per- platform signed-build availability. Rollback policy included. * `product-decision-register.md` change-history advances to 0.9.8 with a single entry summarising v0.3, v0.4, and v1.0-rc.1 progress against DEC-001. No decision rows mutate. Build + ops ----------- * `NOTICE` refreshed for the MVP product-code dependency set: adds `chacha20poly1305`, `rand`, `zeroize`, `base64`, `keyring`, `connectivity_plus`, `path_provider`, `freezed_annotation`; drops PoC-only entries. * `CHANGELOG.md` restructured: explicit version sections for v0.3.0-beta.1, v0.4.0-beta.2, v1.0.0-rc.1. Previous "Unreleased" contents migrated into their respective milestone sections. * `.github/workflows/ci.yml` exports `CHANORA_DISABLE_KEYRING=1` for the cargo-test job — CI runners have no D-Bus session and the keyring crate would otherwise block. * `run-chanora.sh` reads `CHANORA_BUNDLE_FLAVOUR` (default `release`) and self-copies the latest cdylib into the bundle's `lib/` if missing. Verification ------------ * `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`: all green (49 unit tests across the workspace; up from 36 at v0.4.0-beta.2). * `cargo test -p chanora_core --release -- --ignored alpha_smoke` passes against the live `cn.teamspeak.app` (DNS → connect → snapshot → disconnect in ~2.5 s). * `flutter analyze`: clean. * `cargo build -p chanora_bridge --release` + `flutter build linux --release` produce a working Linux x86_64 bundle. No Android live test in this commit per the user's note that the physical device was removed; the Android arm64-v8a build path is mechanically identical to v0.4.0-beta.2. |
||
|
|
71ecb83781 |
feat(storage): A.2 — persist TS3 identity across app restarts
A fresh `Identity::create()` was generated on every connect, which meant the server saw a different client UID each time. Long-lived features (bookmarks, server-side bans, group membership) depend on a stable UID — restoring that now via a minimal directory-backed identity file. * `chanora_storage::IdentityFileStore` reads / writes a single `identity.tskey` file under a caller-supplied directory. On Unix the file is created with `O_CREAT | O_TRUNC | mode 0600`; on non-Unix targets the platform sandbox does the access control. Writes are atomic (temp file + `fsync` + `rename`) so a crash mid-write cannot leave a half-written identity on disk. Empty files are treated as "no identity" rather than as an error. * `chanora_protocol::ProtocolClient::generate_identity()` exposes the `counterVbase64key` serialisation used by tsclientlib's `Identity::new_from_str`, so the core layer can mint an identity and store it before dialling. * `chanora_core::ChanoraSession::init_storage(dir)` wires the store. `connect()` then resolves the identity in this order: (1) `cfg.identity` if explicitly supplied; (2) persisted value if any; (3) generate-and-persist a fresh one. * `chanora_bridge::api::init_storage(dir: String)` is the Flutter-facing entrypoint; the matching Dart side resolves `path_provider`'s `getApplicationSupportDirectory()` and calls it once on app start. * `BridgeError` now maps `CoreError::Storage`. Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not encrypted at rest. The v0.4 storage rework lands proper Secret Service + Android Keystore + iOS Keychain backends. Documented under `IdentityFileStore`'s doc comment. Live-verified on Moto G Stylus 5G: first connect generated + persisted the identity (visible in the redacted diagnostic export as "generated + persisted fresh identity"); disconnect + reconnect in the same session logged "reusing persisted identity" and dialled with the same UID. |
||
|
|
f52d702e27 |
feat(core): A.6.1 — use OS connectivity signals to drive reconnect
Extends the A.6 supervisor with an OS-level connectivity hint so a returning network triggers a redial immediately instead of waiting out the current backoff slot (up to 60 s). The watchdog remains the authoritative loss detector — the OS signal is advisory. * `chanora_core::NetworkState` (Unknown / Online / Offline) is owned by `ChanoraSession` via a `tokio::sync::watch::Sender`. `set_network_state()` / `network_state()` are the public accessors. * The supervisor's watch-phase `select!` gains a `network_rx` branch: Offline pre-charges watchdog misses (capped at `MAX_MISSES - 1`) so the next probe failure trips immediately; Online clears stale misses. This shrinks UI-banner latency on a Wi-Fi drop from ~15 s to ~5 s. * The reconnect-loop's backoff sleep races against Online: a transition cuts the sleep short and resets the attempt counter so future losses start at the smallest backoff window again. * `chanora_bridge` adds `BridgeNetworkState` (mirror enum) and a sync `set_network_state(state)` function. On platforms with no signal wired the supervisor stays at Unknown and falls back to pure watchdog/backoff — no behavioural regression vs A.6. * Flutter adds `connectivity_plus ^6.1.0` and wires `_wireConnectivity()` in `main()`: seeds with `checkConnectivity()` then forwards every `onConnectivityChanged` to the bridge, mapping any non-`none` transport to Online. Verified on Moto G Stylus 5G (Android 14): `svc wifi disable && svc data disable` for ~40 s — reconnect banner appeared promptly because the watchdog was pre-charged. After `svc wifi enable && svc data enable` the supervisor woke from its 15 s backoff slot and reconnected within seconds; the channel tree re-rendered without user action. |
||
|
|
c81ccfd9a9 |
feat(android): produce v0.2.0-beta.1 Android APK with voice in/out
Builds the Internal Beta product app for Android. Companion to the
Linux desktop build already shipped at the same tag.
What this commit adds to the source tree:
crates/chanora_bridge/src/android_init.rs (new):
JNI lifecycle for Android. JNI_OnLoad captures the JavaVM*.
Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext
is called by MainActivity.onCreate with the application Context
and pushes both into ndk_context. Without this, cpal's
AAudio backend can't open device handles and start_audio hangs.
crates/chanora_bridge/Cargo.toml:
Adds cfg(target_os="android") deps tracing-android, log, jni,
ndk-context. Linux/desktop builds are unaffected.
crates/chanora_bridge/src/lib.rs:
Conditionally includes the android_init module on Android.
crates/chanora_bridge/src/api.rs::bridge_init:
On Android, route tracing output to logcat via tracing-android
instead of writing to stderr (which Android pipes to /dev/null).
Logs show under `adb logcat -s chanora`.
apps/chanora_flutter/android/app/src/main/AndroidManifest.xml:
Adds uses-permission android.permission.INTERNET (needed for
the protocol layer) and android.permission.RECORD_AUDIO (needed
by chanora_audio's capture stream). Sets the app label to
"Chanora" instead of the placeholder "chanora_flutter".
apps/chanora_flutter/android/app/src/main/kotlin/.../MainActivity.kt:
Overrides the Flutter-generated MainActivity. Loads
libchanora_bridge.so eagerly at class-init so JNI_OnLoad runs
before any FRB call. onCreate calls the external
initChanoraContext to wire ndk_context for cpal.
apps/chanora_flutter/pubspec.yaml:
Bumps version 1.0.0+1 → 0.2.0+2 to match the v0.2.0-beta.1 tag.
run-chanora.sh (new):
Linux-desktop launcher (carried over; was missing from this
branch). Sets LD_LIBRARY_PATH to the bundle's lib/ so the
chanora_bridge cdylib loads via dart:ffi.
Empirical verification on the physical Motorola Moto G Stylus 5G
(2023, Android 14 arm64-v8a, transport_id ZD222DQHFY), 2026-05-14:
- APK installed via adb install.
- Activity launched; permissions granted.
- Connect form filled with 175.178.125.23 (Vigorous Pro's IP —
see honest limitation below); Connect button tapped.
- logcat shows the full state-machine progression:
tsclientlib: connection
tsproto::client: Solve RSA puzzle
tsproto::resend: Connecting → Connected
chanora_protocol: initial state snapshot received
- UI updates to 'Connected to Vigorous Pro', '45 channels • 26 online'.
- Welcome banner with CJK characters preserved verbatim.
- 'Start audio' tapped:
AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#1
AAudio: AAudioStream_requestStart(s#1) returned 0
AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#2
AAudio: AAudioStream_requestStart(s#2) returned 0
AAudioStream: setState s#1 from 3 to 4 (Started)
AAudioStream: setState s#2 from 3 to 4 (Started)
- PTT button held for 2.5 s:
UI shows: 'TX 124 frames • RX 0 frames • PTT off'.
124 frames / 2.5 s ≈ 50 frames/s = 20 ms Opus frames — exactly
the encoder cadence. Voice transmission proven over UDP to the
real server.
Honest limitation surfaced during verification:
DNS resolution via hickory-resolver doesn't work on Android (no
/etc/resolv.conf). Connecting by hostname produces:
BridgeError.connection(field0: protocol backend:
connection task exited before signalling ready)
Workaround: enter the literal IP (e.g. 175.178.125.23 for
cn.teamspeak.app). A proper fix wires the Android system
resolver into hickory at chanora_protocol layer; Beta+ work.
Build prerequisites (documented for reproducibility):
- Android NDK r26.3.11579264 at /opt/android-sdk/ndk/26.3.11579264.
- rustup targets: aarch64-linux-android, armv7-linux-androideabi,
x86_64-linux-android.
- cargo-ndk 4.x.
- Pre-built libopus.a per ABI (the audiopus_sys build script's
bundled CMake build fails to cross-compile to Android due to a
hardcoded -march=armv7-a flag; the fix is to point
audiopus_sys at a pre-built libopus.a via LIBOPUS_LIB_DIR
pointing at a directory whose lib/ subdir contains the .a).
Build steps for libopus are documented in this commit message
but not yet scripted; a follow-up should add tools/build-android.sh.
- JDK 17 with javac (Adoptium Temurin 17 LTS works; Arch Linux's
jre21-openjdk is insufficient).
ABIs built and shipped in the APK:
arm64-v8a, armeabi-v7a, x86_64.
Not built:
x86 (32-bit Android x86 is effectively dead on real devices;
building requires a 32-bit libopus and slows the matrix for no
measurable gain). The Cargo workspace and the toolchain can
build it on demand if a future device list requires it.
|
||
|
|
4915ec0a1b |
feat(alpha): wire connect→snapshot→disconnect end-to-end (v0.1.0-alpha.1)
First Internal Alpha build per DEC-001. Closes the milestone of
'Flutter UI calls Rust via the typed bridge, Rust connects to a
TeamSpeak-compatible server through tsclientlib, returns a typed
snapshot, and disconnects cleanly.' Audio remains Beta scope.
Promotions from PoC:
poc/tsclientlib-connect-spike → crates/chanora_protocol/
New product code:
crates/chanora_protocol/src/{dto.rs,adapter.rs} — typed boundary
over tsclientlib. Tokio task owns the Connection; public
handle communicates via mpsc/oneshot. No tsclientlib types
cross out of the crate (SAD-067 / SysDes-011 / SysDes-029).
core/chanora_core/src/lib.rs — ChanoraSession composes the
protocol crate, enforces the DEC-006 single-connection
invariant.
crates/chanora_bridge/src/{api.rs,frb_generated.rs} — FRB 2.12.0
bridge per DEC-014. cdylib + staticlib + rlib. Typed
BridgeSnapshot / BridgeChannel / BridgeClient / BridgeError
DTOs. Process-wide OnceLock<Runtime> + OnceLock<ChanoraSession>.
flutter_rust_bridge.yaml at repo root.
apps/chanora_flutter/lib/main.dart — Alpha UI: server form,
connect button, channel tree, disconnect.
apps/chanora_flutter/lib/l10n/app_{en,zh}.arb expanded with the
Alpha key set; ARB metadata reaffirms ADR-008 for
server-provided content.
Generated Dart bindings under apps/chanora_flutter/lib/src/rust/.
Empirical verification (2026-05-14):
Workspace: cargo check + cargo test clean
(workspace tests: all green).
Bridge cdylib: target/release/libchanora_bridge.so produced
(~15 MB).
Flutter: flutter analyze clean; flutter test runs 3/3 green
including the alpha_e2e_test that drives the full
Dart → FRB → chanora_bridge → chanora_core → chanora_protocol
→ tsclientlib → UDP → cn.teamspeak.app
path. The captured logcat/stdout shows the tsproto resender
transitioning Connected → Disconnecting → Disconnected on
clean teardown.
Architecture changes:
- Removed the chanora_core ↔ chanora_bridge cyclic dependency.
chanora_core no longer knows the bridge exists; the bridge
maps from CoreError.
- chanora_bridge crate's #![forbid(unsafe_code)] lint relaxed
because FRB-generated glue legitimately uses unsafe at the
FFI boundary. Hand-written code remains unsafe-free.
Open follow-ups (NOT in this Alpha):
- Audio capture/playback wiring into chanora_audio
(Beta scope per DEC-001).
- Identity persistence via chanora_storage
(currently regenerated on every connect).
- Per-message diagnostics + redaction
(chanora_diagnostics still scaffold).
- Reconnect / network-loss recovery.
- Mobile (Android) build of the bridge cdylib + UI verification.
|
||
|
|
974dda9601 |
feat(scaffold): create product workspace + Rust crates + Flutter app
Implements the canonical implementation directory layout adopted by
DEC-022 (register v0.9.5). Closes the scaffolding phase; no PoC code
has been promoted in yet (per proof-of-concept-plan.md §4 a PoC is
not product code unless explicitly promoted).
Rust workspace
==============
Top-level Cargo.toml declares seven workspace members:
core/chanora_core top-level Rust API + orchestration
crates/chanora_protocol tsclientlib isolation (SAD-067, SysDes-011/029)
crates/chanora_state state sync, reducers, deltas
crates/chanora_audio capture, DSP, Opus, jitter, mixer, playback
crates/chanora_storage non-secret DB + platform secure store
crates/chanora_diagnostics logs, redaction, export
crates/chanora_bridge typed Flutter/Rust DTOs
Workspace-wide pins:
license = "MIT OR Apache-2.0" (DEC-020)
rust-version = "1.95"
edition = "2021"
The Flutter app (apps/chanora_flutter) is NOT a Cargo workspace
member; it is owned by the Flutter / Gradle toolchain and is in the
workspace exclude array along with every poc/* spike.
Each crate ships:
* a Cargo.toml referring to workspace.dependencies pins;
* a lib.rs with #![forbid(unsafe_code)] + #![warn(missing_docs)],
a typed Error enum, and the public types relevant to the
subsystem's role per SAD §7.2;
* minimal unit tests so
running 1 test
test tests::defaults_match_decisions ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::it_compiles ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::session_can_be_constructed ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::marker_matches_poc ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::it_compiles ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::state_transitions_compile ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test tests::it_compiles ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s is non-empty.
chanora_core's CoreError type-wraps every subsystem error via
#[from] so callers can match on origin without parsing strings.
chanora_audio's AudioEffects struct defaults all four effects to
true, matching DEC-007 (AEC), DEC-008 (AGC), DEC-009 (NS),
DEC-010 (HPF). A unit test pins this so a future regression that
flips a default fails immediately.
chanora_diagnostics exports REDACTION_MARKER = "[REDACTED]",
identical to the PoC's marker so audit grep patterns survive the
promotion.
chanora_bridge's BridgeError is Serialize + Deserialize so it can
flow across the FRB 2.x boundary (DEC-014).
Empirical verification: cargo check --workspace clean, cargo test
--workspace clean (7 unit tests + 7 doc-test runners, all passing)
against Rust 1.95.0 stable.
Flutter app
===========
apps/chanora_flutter created with .
DEC-004 applied: minSdk overridden to 28 in
android/app/build.gradle.kts with a comment that points back at the
decision register and forbids lowering it without re-opening DEC-004.
DEC-015 applied: shipped English + Simplified Chinese at MVP.
- pubspec.yaml gains flutter_localizations + intl + generate:true.
- l10n.yaml emits lib/l10n/generated/AppL10n (no synthetic package
— that was removed in Flutter 3.41+).
- lib/l10n/app_en.arb is the source of truth; lib/l10n/app_zh.arb
mirrors the key set in zh-Hans. ARB metadata explicitly
reaffirms ADR-008: server-provided content (channel names,
nicknames, welcome banners) is preserved verbatim and never
translated.
lib/main.dart and test/widget_test.dart were rewritten from the
template counter into a minimal localized scaffold that proves
both locales render correctly.
Empirical verification: reports no issues;
runs the two locale smoke tests and both pass.
|