Three real bugs, one smoke-test session's worth of progress.
1. RELAY: wrong advertised addr in CallSetup
The direct-call CallSetup computed `relay_addr = addr.ip()` where
`addr = connection.remote_address()` — i.e. the CLIENT'S IP, not the
relay's. So the relay was telling both parties "the call room is at
the answerer's IP:4433", which meant each client dialed either the
other client (no server listening) or themselves. Both endpoint.connect
calls hung forever and the call never happened.
Fix: compute the relay's own advertised IP once at startup. If the
listen addr is 0.0.0.0, probe the primary outbound interface via the
classic UDP-bind-and-connect(8.8.8.8:80) trick to discover the LAN
IP the OS would use to reach external hosts. Thread the resulting
advertised_addr_str into the CallSetup sender for both parties.
2. RELAY: accept loop serialized QUIC handshakes
Previously the main accept loop called `wzp_transport::accept` which
did both `endpoint.accept().await` AND `incoming.await` (the server-
side QUIC handshake). A single slow handshake therefore blocked every
subsequent client from being accepted. Unroll the helper here and
move `incoming.await` into the per-connection spawned task, so every
handshake runs in parallel. Also log "accept queue: new Incoming",
"QUIC handshake complete", and "QUIC handshake failed" so we can tell
immediately whether a client's packets are reaching the relay at all.
3. ANDROID: playout was routed to the silent in-call stream
The Oboe playout stream was configured with Usage::VoiceCommunication,
which routes to the Android in-call earpiece stream. That stream is
silent unless the Activity has called AudioManager.setMode(
IN_COMMUNICATION) and, even then, only the earpiece/BT headset get
audio (not the loud speaker). Result: android→mac calls worked
because mac had a normal media output, but mac→android calls were
silent even though packets flowed through the relay just fine.
Switch to Usage::Media + ContentType::Speech so Oboe routes to the
loud speaker and uses the media volume slider. A later polish step
will wire setMode + setSpeakerphoneOn from MainActivity.kt so we can
go back to VoiceCommunication for AEC and proximity-sensor routing.
Plus: heartbeat tracing every 2s in the send/recv tasks — frames_sent,
last_rms, last_pkt_bytes, short_reads on the send side; decoded_frames,
last_decode_n, last_written, decode_errs on the recv side. Will make the
next "no sound" regression trivial to localize.
PlayoutCallback::onAudioReady crashed with SIGSEGV(SEGV_ACCERR) on the
first AAudio callback because g_rings was a `const WzpOboeRings*` pointing
at the caller's stack frame. wzp_native_audio_start() constructs the
rings struct as a stack local in Rust, passes &rings to wzp_oboe_start
(which stored the raw pointer), and returns — at which point the stack
frame unwinds and g_rings becomes a dangling reference. The first audio
callback then read from freed memory and died.
- g_rings is now a static WzpOboeRings value (was `const WzpOboeRings*`).
The raw int16 buffer + atomic index pointers inside the struct still
point into the Rust-owned AudioBackend singleton, which is leaked for
the lifetime of the process, so deep-copying the struct by value is
safe and keeps the inner pointers valid forever.
- g_rings_valid atomic bool gates the audio-callback reads: set to true
after the value copy in wzp_oboe_start, cleared in wzp_oboe_stop BEFORE
the streams are torn down so any in-flight callback sees "no backend"
and returns Stop instead of racing on g_rings.
- All g_rings->x accesses in the capture + playout callbacks switched to
g_rings.x (member-of-value).
Reproduced on Pixel 6 / Android 15 with build 0105b0f:
F libc: Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR),
fault addr 0x71aa717eb0 in tid 11822 (AudioTrack)
#00 PlayoutCallback::onAudioReady(oboe::AudioStream*, void*, int)+120
#01 oboe::AudioStream::fireDataCallback(void*, int)+136
...
Now that Phase 1 proved the split-cdylib pipeline (build #37 launched
cleanly with 'wzp-native dlopen OK: version=42 msg=...' in logcat),
this commit brings the real audio code into wzp-native without ever
touching the Tauri crate:
- cpp/oboe_bridge.{h,cpp}, oboe_stub.cpp, getauxval_fix.c copied
verbatim from crates/wzp-android/cpp/ (same files that work in the
legacy wzp-android .so on this phone)
- build.rs near-identical to crates/wzp-android/build.rs: clones
google/oboe@1.8.1 into OUT_DIR, compiles oboe_bridge.cpp + all
oboe source files as a single static lib with c++_shared linkage,
emits -llog + -lOpenSLES. On non-android hosts it compiles just
oboe_stub.cpp so `cargo check` works locally without an NDK.
- Cargo.toml gets cc = "1" in [build-dependencies]. This is SAFE
because wzp-native is a single-cdylib crate — crate-type is only
["cdylib"], no staticlib, so rust-lang/rust#104707 does not apply.
- src/lib.rs extends the FFI surface with the real audio API:
wzp_native_audio_start() -> i32
wzp_native_audio_stop()
wzp_native_audio_read_capture(*mut i16, usize) -> usize
wzp_native_audio_write_playout(*const i16, usize) -> usize
wzp_native_audio_capture_latency_ms() -> f32
wzp_native_audio_playout_latency_ms() -> f32
wzp_native_audio_is_running() -> i32
Plus a static AudioBackend singleton holding the two SPSC ring
buffers (capture + playout) that are shared with the C++ Oboe
callbacks via AtomicI32 cursors. The wzp_native_version() and
wzp_native_hello() smoke tests from Phase 1 are preserved.
Compiles cleanly on macOS host with the stub oboe .cpp. Next build
will exercise the full cargo-ndk path inside docker to verify the
whole Oboe compile still works standalone.
Phase 3 (next commit): wzp-desktop engine.rs on Android calls
wzp-native's audio FFI via the already-wired libloading handle, and
the real CallEngine::start() is implemented for Android using the
same codec/handshake/send/recv pipeline as desktop but with Oboe
rings instead of CPAL rings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>