136 Commits

Author SHA1 Message Date
Siavash Sameni
921856eba9 feat(reflect): QUIC-native NAT reflection ("STUN for QUIC") — Phase 1
Lets a client ask its registered relay "what IP:port do you see for
me?" over the existing TLS-authenticated signal channel, returning
the client's server-reflexive address as a SocketAddr. Replaces the
need for a classic STUN deployment and becomes the bootstrap step
for future P2P hole-punching: once both peers know their own reflex
addrs, they can advertise them in DirectCallOffer and attempt a
direct QUIC handshake to each other.

Wire protocol (wzp-proto):
- SignalMessage::Reflect — unit variant, client -> relay
- SignalMessage::ReflectResponse { observed_addr: String } — relay -> client
- JSON-serde, appended at end of enum: zero ordinal concerns,
  backward compat with pre-Phase-1 relays by construction (older
  relays log "unexpected message" and drop; newer clients time out
  cleanly within 1s).

Relay handler (wzp-relay/src/main.rs, signal loop):
- New match arm next to Ping reuses the already-bound `addr` from
  connection.remote_address() and replies with observed_addr as a
  string. debug!-level log on success, warn!-level on send failure.

Client side (desktop/src-tauri/src/lib.rs):
- SignalState gains pending_reflect: Option<oneshot::Sender<SocketAddr>>.
- get_reflected_address Tauri command installs the oneshot before
  sending Reflect and awaits it with a 1s timeout; cleans up on
  every exit path (send failure, timeout, parse error).
- recv loop's new ReflectResponse arm fires the pending sender or
  emits a debug log for unsolicited responses — never crashes the
  loop on malformed input.
- Integrated into invoke_handler! alongside the other signal
  commands.

UI (desktop/index.html + src/main.ts):
- New "Network" section in settings panel with a "Detect" button
  that displays the reflected address or a categorized warning
  ("register first" / "relay does not support reflection" / error).

Tests (crates/wzp-relay/tests/reflect.rs — 3 new, all passing):
- reflect_happy_path: client on loopback gets back 127.0.0.1:<its own port>
- reflect_two_clients_distinct_ports: two concurrent clients see
  their own distinct ports, proving per-connection remote_address
- reflect_old_relay_times_out: mock relay that ignores Reflect —
  client times out between 1000-1200ms and does not hang

Also pre-existing test bit-rot unrelated to this PR — fixed so the
full workspace `cargo test` goes green:
- handshake_integration tests in wzp-client, wzp-relay and
  featherchat_compat in wzp-crypto all missed the `alias` field
  addition to CallOffer and the 3-arg form of perform_handshake
  plus 4-tuple return of accept_handshake. Updated to the current
  API surface.

Results:
  cargo test --workspace --exclude wzp-android: 386 passed
  cargo check --workspace: clean
  cargo clippy: no new warnings in touched files

Verification excludes wzp-android because it's dead code on this
branch (Tauri mobile uses wzp-native instead) and can't link -llog
on macOS host — unchanged status quo.

PRD: .taskmaster/docs/prd_reflect_over_quic.txt
Tasks: 39-46 all completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:29:07 +04:00
Siavash Sameni
7e7968b2f9 diag(android-engine): first-join no-audio ordering instrumentation
Adds a single call_t0 = Instant::now() at the top of the Android
CallEngine::start path, threaded through send + recv tasks as
send_t0 / recv_t0, and tags the following milestones with
t_ms_since_call_start so we can build a clean side-by-side log of
first-call vs rejoin:

  1. QUIC connection established
  2. handshake complete
  3. wzp-native audio_start returned (+ how long audio_start itself took)
  4. send task spawned
  5. send: first full capture frame read (+ short_reads_before count)
  6. send: first non-zero capture RMS
  7. recv task spawned
  8. recv: first media packet received
  9. recv: first successful decode
 10. recv: first playout-ring write

Combined with the existing C++-side cb#0 logs in
crates/wzp-native/cpp/oboe_bridge.cpp ("capture cb#0", "playout
cb#0") this gives us full-pipeline ordering with no native-side
changes needed.

PRD: .taskmaster/docs/prd_android_first_join_no_audio.txt
Task: 32 (first task in the chain — diagnostics before any fix
attempts so we know which of the 5 suspect causes is real).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:00:20 +04:00
Siavash Sameni
578ff8cff4 feat(debug): GUI toggle for DRED verbose logs + macOS mic permission
DRED verbose logs (off by default — keeps logcat clean in normal use):
- wzp-codec: DRED_VERBOSE_LOGS atomic flag with dred_verbose_logs() /
  set_dred_verbose_logs() helpers
- opus_enc: gate "DRED enabled" + libopus version logs behind the flag
- desktop/src-tauri/engine.rs: gate DredRecvState parse log,
  reconstruction log, classical PLC log, and DRED-counter fields in
  the Android recv heartbeat (non-verbose path still logs basic recv
  stats)
- Tauri commands set_dred_verbose_logs / get_dred_verbose_logs
- Settings panel gets a "DRED debug logs (verbose, dev only)"
  checkbox; preference persists in wzp-settings localStorage and is
  pushed to Rust on save and on app boot

macOS mic permission:
- Add desktop/src-tauri/Info.plist with NSMicrophoneUsageDescription.
  Without it, modern macOS silently denies CoreAudio capture for
  ad-hoc-signed Tauri builds — capture starts but every callback
  hands you zeros. Symptom: phones could not hear desktop client,
  desktop could still hear phones (playout has no TCC gate). The
  Tauri 2 bundler auto-merges this file into WarzonePhone.app's
  Contents/Info.plist on the next build, so first launch will pop
  the standard mic prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:48:32 +04:00
Siavash Sameni
16890576fb feat(observability): logcat-visible DRED proof of life on Android
Adds enough INFO-level logging that an opus-DRED-v2 APK on Android can
be verified end-to-end by reading logcat alone — no debugger, no
Prometheus, no telemetry pipeline required. Three observation points:

1. Encoder construction (opus_enc.rs)
   - Bumped the "DRED enabled" log from debug! to info! so the
     per-call DRED config is in logcat by default. Each call's first
     OpusEncoder construction logs codec, dred_frames, dred_ms,
     loss_floor_pct.
   - Added a one-shot static OnceLock that logs `opusic_c::version()`
     the first time an OpusEncoder is built in the process. This is
     the smoking gun for "is the new libopus actually loaded" — pre-
     Phase-0 audiopus shipped libopus 1.3 with no DRED, post-Phase-0
     should print 1.5.2 here.

2. DRED state ingest (DredRecvState::ingest_opus in
   desktop/src-tauri/src/engine.rs)
   - First successful parse on a call logs immediately so we can see
     "DRED is on the wire" in logcat.
   - Subsequent parses sample every 100th to confirm steady-state
     samples_available without drowning the log.
   - New parses_total / parses_with_data counters track the parse
     rate vs the success rate (a packet without DRED in it returns
     `available == 0`, so a low ratio means the encoder isn't
     emitting DRED bytes).

3. DRED reconstruction events (DredRecvState::fill_gap_to)
   - Every DRED reconstruction logs at INFO with missing_seq,
     anchor_seq, offset_samples, offset_ms, samples_available,
     gap_size, and the running total. These events are rare on a
     clean network and we want to know exactly which gap was filled.
   - First three classical PLC fills + every 50th thereafter log so
     we can see when DRED couldn't cover a gap (offset out of range,
     no good state, or reconstruct error).

4. Recv heartbeat (Android start() in engine.rs)
   - Existing 2-second heartbeat now includes dred_recv,
     classical_plc, dred_parses_with_data, dred_parses_total
     so a steady-state call shows the cumulative counters in
     logcat without parsing.

How to verify on a real call:

  adb logcat -s 'RustStdoutStderr:*' | grep -i 'dred\|libopus version'

Expected output sequence on a successful Opus call:
  - "linked libopus version libopus_version=libopus 1.5.2-..."  (once per process)
  - "opus encoder: DRED enabled codec=Opus24k dred_frames=20 dred_ms=200 loss_floor_pct=15"  (per call)
  - "DRED state parsed from Opus packet seq=N samples_available=4560 ms=95 ..."  (after first DRED-bearing packet)
  - "recv heartbeat (android) ... dred_recv=0 classical_plc=0 dred_parses_with_data=58 dred_parses_total=58"  (every 2s)

If you see "linked libopus version libopus 1.3" — the FFI swap didn't
take. If dred_parses_with_data stays at 0 while dred_parses_total
climbs — the sender isn't emitting DRED (check the encoder's loss
floor and the receiver's libopus version). If gaps trigger
"classical PLC fill" instead of "DRED reconstruction fired" —
DRED state coverage is too small for the observed loss pattern,
and the loss floor or DRED duration policy needs tuning.

Verification:
- cargo check -p wzp-codec -p wzp-client: 0 errors
- cargo check -p wzp-desktop: 0 Rust errors (only the pre-existing
  tauri::generate_context!() proc macro panic on missing ../dist
  which fires at host check time, irrelevant on the remote build)
- cargo test -p wzp-codec --lib: 69 passing (no regressions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:58:03 +04:00
Siavash Sameni
daf7bcd9ba chore(warnings): sweep the workspace — zero warnings on lib + bin targets
Addressed every rustc warning surfaced by \`cargo check --workspace
--release --lib --bins\` on opus-DRED-v2. Split across three
categories:

## Real bugs surfaced by the audit (fix, don't silence)

- **crates/wzp-relay/src/federation.rs** — the per-peer RTT monitor
  task computed \`rtt_ms\` every 5 s and threw it on the floor. The
  \`wzp_federation_peer_rtt_ms\` gauge has been registered in
  metrics.rs the whole time but was never receiving samples, leaving
  the Grafana panel blank. Wired it up: the task now calls
  \`fm_rtt.metrics.federation_peer_rtt_ms.with_label_values(&[&label_rtt]).set(rtt_ms)\`
  on every sample. Fixes three warnings (\`rtt_ms\`, \`fm_rtt\`,
  \`label_rtt\` were all captured for this task and all dead).

## Dead code removal

- **crates/wzp-relay/src/federation.rs** — removed \`local_delivery_seq:
  AtomicU16\` field and its initializer. It was described in comments
  as "per-room seq counter for federation media delivered to local
  clients" but was declared, initialized to 0, and never read or
  written anywhere else. Genuine half-wired feature; deletable with
  zero behavior change.
- **crates/wzp-relay/src/room.rs** — removed \`let recv_start =
  Instant::now()\` at the top of a recv loop that was never read.
  Separate variable \`last_recv_instant\` already measures the actual
  gap that's used for the \`max_recv_gap_ms\` stat.
- **crates/wzp-client/src/cli.rs** — removed \`let my_fp = fp.clone()\`
  from the signal loop setup. Cloned but never used in any match arm.

## Stub-intent warnings (underscore + explanatory comment)

- **crates/wzp-relay/src/handshake.rs** — \`choose_profile\` hardcodes
  \`QualityProfile::GOOD\` and ignores its \`supported\` parameter.
  Comment already documented "Cap at GOOD (24k) for now — studio
  tiers not yet tested for federation reliability". Renamed to
  \`_supported\`, expanded the comment to explicitly note the future
  plan (pick highest supported ≤ relay ceiling).
- **crates/wzp-relay/src/federation.rs** — \`forward_to_peers\` takes
  \`room_name: &str\` but only uses \`room_hash\`. The caller
  (handle_datagram) passes the name for caller-site symmetry with
  other helpers; kept the param shape and underscored the binding
  with a comment noting it's reserved for future per-name logging.

## Cosmetic fixes

- **crates/wzp-relay/src/event_log.rs** — dropped \`use std::sync::Arc\`
  (unused).
- **crates/wzp-relay/src/signal_hub.rs** — trimmed \`use tracing::{info,
  warn}\` to \`use tracing::info\`. Also removed unnecessary \`mut\` on
  \`hub\` binding in the \`register_unregister\` test.
- **crates/wzp-relay/src/room.rs** — trimmed \`use tracing::{debug,
  error, info, trace, warn}\` to \`{error, info, warn}\`. Also removed
  unnecessary \`mut\` on \`mgr\` binding in the \`room_join_leave\` test.
- **crates/wzp-relay/src/main.rs** — removed unnecessary \`mut\` on the
  \`config\` destructured binding from \`parse_args()\`; and dropped
  \`ref caller_alias\` from the \`DirectCallOffer\` match pattern since
  the relay just forwards the full \`msg\` (caller_alias is preserved
  end-to-end, we don't need to read it on the relay).
- **crates/wzp-crypto/tests/featherchat_compat.rs** — dropped
  \`CallSignalType\` from a \`use wzp_client::featherchat::{...}\`
  (unused in the test body). Note: this test file has pre-existing
  compile errors from SignalMessage schema drift unrelated to this
  sweep; that's tracked separately.

## Crate-level annotation

- **crates/wzp-android/src/lib.rs** — added
  \`#![allow(dead_code, unused_imports, unused_variables, unused_mut)]\`
  with a doc block explaining the crate is dead code since the Tauri
  mobile rewrite. The legacy Kotlin+JNI Android app that consumed
  this crate was replaced by desktop/src-tauri (live Android recv
  path) + crates/wzp-native (Oboe bridge). Rather than piecemeal
  cleanup of a crate that shouldn't be maintained, the whole-crate
  allow keeps CI clean until someone removes the crate entirely. Kills
  all 6 wzp-android warnings (4 unused imports/vars, 1 unused \`mut\`
  on a JNI env param, 1 dead \`command_rx\` field) in one line.

## Not touched

- **deps/featherchat/warzone/crates/warzone-protocol/src/x3dh.rs** —
  3 unused-variable warnings in \`alice_spk_secret\`, \`alice_bundle\`,
  \`bob_bundle_bytes\`. This is a vendored third-party submodule;
  upstream's problem, not ours. Would need to be reported to
  featherchat upstream if we care.

## Verification

- \`cargo check --workspace --release --lib --bins\` → 0 warnings, 0 errors
- \`cargo check --workspace --release --all-targets\` → only the 3
  featherchat submodule warnings remain, plus the pre-existing 3
  broken integration tests (SignalMessage schema drift from Phase 2,
  tracked separately and explicitly out of scope).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:28:26 +04:00
Siavash Sameni
df1a45a5f5 fix(cli): port live mode to ring API (read_frame/write_frame removed)
AudioCapture and AudioPlayback no longer expose the old read_frame()
and write_frame() methods — they were replaced with ring() returning
&Arc<AudioRing> when the lock-free SPSC ring was introduced. The CLI
live-mode loop still referenced the removed methods, which broke every
workspace build that touched wzp-client bin (including the remote
Linux x86_64 docker build).

- Send loop: allocate a 960-sample scratch buffer, fill it in a loop
  via capture.ring().read() until a full 20 ms frame is available,
  sleep 2 ms between empty reads to avoid hot-spinning.
- Recv loop: write decoded PCM into playback.ring() instead of
  calling write_frame(). Short writes on full ring drop the tail,
  which is the correct real-time behavior for CLI live mode.

No behavioral change on the wire or in the call pipeline — this is
purely a compile fix for cli.rs bitrot that accumulated since the
ring API landed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:08:14 +04:00
Siavash Sameni
dd0c714caa Revert "fix(deps): restore Cargo.lock from 8ceb6f4 — minimize dep drift from Phase 0"
This reverts commit 575a39d07a.
2026-04-11 08:06:04 +04:00
Siavash Sameni
a7b2f850f1 build(script): parametrize branch via WZP_BRANCH (default opus-DRED-v2)
The Linux build script was hardcoded to feat/android-voip-client, which
is an older branch that doesn't have the current DRED work or the relay
fixes from 8c4d640. Default the branch to opus-DRED-v2 (current active
development branch), thread it through to the remote script as a third
positional arg, and allow override via `WZP_BRANCH=<name> ./build-linux-docker.sh`.

This is also what let us discover that the relay at 172.16.81.175:4433
was running d0c1731 (android-rewrite) and missing the 8c4d640
CallSetup/advertised-IP fix — direct calls failed until the relay was
rebuilt locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:05:56 +04:00
Siavash Sameni
575a39d07a fix(deps): restore Cargo.lock from 8ceb6f4 — minimize dep drift from Phase 0
Phase 0 cherry-pick regenerated the lockfile from scratch via
`cargo generate-lockfile`, which bumped at least tokio (1.50.0 → 1.51.1)
and downgraded the lockfile format from version 4 → version 3. Many
other transitive deps may have shifted silently.

Symptoms that pointed here:
1. Direct-call media QUIC handshake silently stalls for exactly the
   client-side 10s timeout, with no errors in the log. Classic tokio
   runtime / async waker mismatch — tasks queued from one runtime
   never run because the endpoint's I/O driver is on another runtime.
2. Every `place_call` gets an immediate `signal: Hangup reason=Normal`
   back from the signal recv loop, as if it's consuming stale state.
3. Eventually hits `FORTIFY: pthread_mutex_lock called on a destroyed
   mutex` and the process dies.

All three are consistent with a tokio async primitive being shared
across runtimes in a way that tokio 1.51.1 handles differently than
1.50.0 (which was the version on the user's known-good build). Rather
than chase the specific bisection, restore the exact base lockfile
and let cargo add only the three deps Phase 0 actually needs
(opusic-c, opusic-sys, bytemuck).

Verification:
- `git diff 8ceb6f4..HEAD -- Cargo.lock | grep -c '^[+-]version = '` → 0
  (no version-line changes beyond what Cargo auto-pulls for new crates)
- tokio back to 1.50.0
- rustls, quinn, quinn-proto, quinn-udp all unchanged
- Lockfile version restored to 4
- cargo test -p wzp-codec --lib: 69 passing (unchanged)
- cargo test -p wzp-client --lib: 35 passing + 1 ignored (unchanged)

Does not fix the pre-existing relay-side advertised-IP bug
(CallSetup may still contain a relay address that the callee cannot
reach from its network), but that is an orthogonal issue that existed
on 8ceb6f4 too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:13:35 +04:00
Siavash Sameni
d63d50cdc0 fix(build): remove apostrophe from libc++_shared comment (broke docker bash -c quoting)
Previous commit d269600 added the libc++_shared.so copy step but the
comment block included "Android's dynamic linker" — the apostrophe
closed the enclosing `bash -c '...'` single-quoted string prematurely.
Everything after "Android" was interpreted as wrapper-script bash
instead of docker-container bash, so JNI_ABI_DIR (set inside the
docker context) was unbound when the wrapper tried to use it.

Build failed with:
  /tmp/wzp-tauri-build.sh: line 149: JNI_ABI_DIR: unbound variable

Note the pre-existing script uses backticks in its comments ("cargo-
tauri`s linker wiring") exactly to avoid this trap. Matched that style
and added an explicit NOTE to the comment explaining the quoting
hazard for future editors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:49:54 +04:00
Siavash Sameni
d269600aa7 fix(build): build-tauri-android.sh — copy libc++_shared.so into jniLibs
Root cause of "wzp-native not loaded" at runtime on opus-DRED-v2 APK:

libwzp_native.so has a NEEDED entry for libc++_shared.so (because
crates/wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared"))),
but the APK only contained:
    lib/arm64-v8a/libwzp_desktop_lib.so  (192 MB)
    lib/arm64-v8a/libwzp_native.so       (683 KB)

No libc++_shared.so → Android's dynamic linker fails the dlopen of
libwzp_native.so at runtime with "library libc++_shared.so not found",
and every audio path that routes through wzp_native (capture, playout,
register, direct call) refuses to start.

Diagnosis:
- readelf -d libwzp_native.so shows NEEDED libc++_shared.so
- python zipfile listing of the APK confirms libc++_shared.so is
  absent from lib/arm64-v8a/
- scripts/build-and-notify.sh (the legacy wzp-android build path)
  already had this fix at lines 126-134 with an explicit comment:
  "cargo-ndk may not copy libc++_shared.so — grab it from the NDK if
  missing". That fix was never ported to build-tauri-android.sh when
  the Tauri mobile pipeline was set up.

Fix: after `cargo ndk build -p wzp-native --release` produces
libwzp_native.so into jniLibs, copy libc++_shared.so from the NDK
sysroot (same find pattern as build-and-notify.sh) into the same
jniLibs dir. Abort with a clear error if the NDK doesn't have the file.

Also noting the 191 MB vs 359 MB size discrepancy the user saw: that's
almost entirely libwzp_desktop_lib.so being a 192 MB debug build. The
old working APK was probably a release build (smaller main lib) or
included multiple arches (doubling/tripling the .so count). The size
is cosmetic — the crash is the real issue, and libc++_shared.so is
~2 MB so this fix doesn't close the size gap. Can investigate the
size difference separately after register + direct call work again.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:43:47 +04:00
Siavash Sameni
dfbe21fe6e feat(tauri-engine): Phase 3b/3c re-port — DRED reconstruction on the live Tauri mobile engine
The original Phase 3b landed on wzp-client/CallDecoder and Phase 3c
landed on wzp-android/src/engine.rs. Both of those are DEAD CODE on
feat/desktop-audio-rewrite: the legacy Kotlin app in android/app/ is
not built by the Tauri mobile pipeline, and the Tauri engine bypasses
CallDecoder by calling wzp_codec::create_decoder directly.

The live Android call engine lives at desktop/src-tauri/src/engine.rs
with two `pub async fn start<F>` functions — one cfg-gated on Android
(Oboe via wzp-native) and one for desktop (CPAL). Both recv tasks
were using `let mut decoder = wzp_codec::create_decoder(...)` which
returns `Box<dyn AudioDecoder>` and doesn't expose the inherent
`reconstruct_from_dred` method.

Changes:

New helper struct `DredRecvState` at the top of engine.rs, wrapping:
  - DredDecoderHandle (libopus DRED side-channel parser)
  - DredState scratch (for parse_into)
  - DredState last_good (cached valid state, swapped on success)
  - last_good_seq: Option<u16> (DRED anchor sequence)
  - expected_seq: Option<u16> (for gap detection)
  - dred_reconstructions / classical_plc_invocations counters

With three methods:
  - ingest_opus(seq, payload): parse DRED, swap on success
  - fill_gap_to(decoder, current_seq, frame_samples, scratch, emit):
    detect gap back from expected_seq, reconstruct each missing
    frame via DRED if state covers it, fall through to classical
    decoder.decode_lost() when it doesn't. Calls emit() once per
    frame with a slice the caller uses for AGC + playout write.
  - reset_on_profile_switch(): invalidate tracking when codec changes

Both recv tasks (Android @ ~line 297 and desktop @ ~line 907):
  - Decoder type changed from `Box<dyn AudioDecoder>` via
    `wzp_codec::create_decoder` to concrete `AdaptiveDecoder::new(profile)`
    so we can call the inherent reconstruct_from_dred method.
  - Added `use wzp_proto::traits::AudioDecoder;` at the top of
    engine.rs to bring decode/decode_lost/set_profile trait methods
    into scope on the concrete type.
  - New `current_profile` local alongside `current_codec` (used for
    frame_duration lookups that drive the DRED sample offset math).
  - On codec/profile switch, call dred_recv.reset_on_profile_switch()
    because the cached DRED state is tied to the old profile's
    frame rate.
  - For each arriving Opus source packet:
      1. dred_recv.ingest_opus(seq, payload) — parse DRED
      2. dred_recv.fill_gap_to(...) — detect gap and reconstruct
         missing frames, each emitted through a closure that does
         AGC + playout write (wzp_native on Android, playout_ring
         on desktop)
      3. Normal decoder.decode() fallthrough for the current packet
         (unchanged)
  - Codec2 packets skip the DRED path entirely (is_opus() gate) —
    libopus can't reconstruct Codec2 audio.

Ordering invariant: gap reconstruction writes to playout BEFORE the
current packet's decoded audio, preserving temporal order since the
playout ring is FIFO. The closure captures the `spk_muted` flag once
before the gap loop to avoid mid-gap-fill state changes.

Kept `crates/wzp-android/src/engine.rs` and `crates/wzp-android/src/
stats.rs` from the earlier Phase 3c commit as-is — they're dead code
on feat/desktop-audio-rewrite but harmless, and deleting them would
diverge this branch from an independently-useful intermediate state.
The old Phase 3c commit (505a834) stays as historical reference.

Verification:
- cargo check -p wzp-codec -p wzp-client -p wzp-relay: 0 errors
- cargo check -p wzp-desktop: only pre-existing `tauri::generate_context!()`
  panic on missing ../dist (Vite output not built on host) — no Rust
  compile errors from our changes
- cargo test -p wzp-codec --lib: 69 passing (unchanged)
- cargo test -p wzp-client --lib: 35 passing + 1 ignored (unchanged)

Next: scripts/build-tauri-android.sh to get the actual Tauri APK —
NOT build-and-notify.sh which builds the dead legacy android/app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:31:09 +04:00
Siavash Sameni
b83c31b5d1 fix(android): remove duplicate TextAlign import in InCallScreen.kt
Pre-existing build breakage on feat/desktop-audio-rewrite @ 8ceb6f4 —
TextAlign was imported twice (line 5 and line 50), causing Kotlin
compilation to fail with:

  e: InCallScreen.kt:5:39 Conflicting import, imported name 'TextAlign' is ambiguous
  e: InCallScreen.kt:50:39 Conflicting import, imported name 'TextAlign' is ambiguous

The line-5 copy was squeezed into the middle of the foundation.* block
(alphabetically out of place) — an accidental extra paste. The line-50
copy sits in the correct alphabetical position. Removed the former.

This blocks the APK build for the opus-DRED-v2 rebase. Unrelated to DRED
itself but the error surfaced because the cherry-picked phases caused
a clean Gradle build (no UP-TO-DATE short-circuit) that re-compiled
InCallScreen.kt against the fresh class graph.

Also noting that the previous working APK (unridden-alfonso.apk) was
built from the stale d0c1731 baseline which didn't have this bug —
one more reason the stale-branch build problem went unnoticed until
the opus-DRED-v2 rebase forced a clean Gradle pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:12:23 +04:00
Siavash Sameni
1f607281fd fix(build): build-and-notify.sh — parameterize branch, fail loud on pull errors
Same fix that landed on the old opus-DRED branch as c95255d: the remote
build script hardcoded `feat/android-voip-client` and swallowed the
reset failure with `|| true`, silently leaving the tree on whatever
branch was there. This ported the fix forward to feat/desktop-audio-
rewrite (which had the same bug).

Fix:
  Local side:
  - Auto-detect current branch via `git branch --show-current`
  - Accept `--branch NAME` override
  - Pass branch as a third positional arg to the remote script
  - Abort on detached HEAD
  - Updated usage docs for the "build what I'm working on" default

  Remote side:
  - Read BRANCH from $3, abort if empty
  - `git fetch origin "$BRANCH"` — errors surface
  - `git reset --hard "origin/$BRANCH"` — no `|| true`, failures abort
  - Echo the resolved commit hash + subject after reset
  - Notifications include both branch and hash:
    "WZP Android [opus-DRED-v2 @ <hash>] done! APK: ..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:07:15 +04:00
Siavash Sameni
7515417202 feat(telemetry): Phase 4 — LossRecoveryUpdate protocol + relay metrics + DebugReporter
Phase 4 lays the telemetry foundation for distinguishing DRED recoveries
from classical PLC in production: a new SignalMessage variant, two new
per-session Prometheus counters on the relay side, and a highlighted
loss-recovery section in the Android DebugReporter.

The periodic emitter (client → relay) and Grafana panel are deferred to
Phase 4b — this commit ships the protocol surface, the relay sink, and
the immediate user-visible debug output. Once 4b lands the full path
(emitter → relay → Prometheus → Grafana), the metrics here will
automatically start receiving data.

Scope decision — why not extend QualityReport instead:
The existing wire-format QualityReport is a fixed 4-byte media packet
trailer. Adding counter fields to it would shift the binary layout and
break backward compatibility (old receivers would parse the last 4
bytes of the extended trailer as QR, corrupting audio). Using a
new SignalMessage variant on the reliable QUIC signal stream sidesteps
the wire-format problem entirely — serde JSON enums tolerate unknown
variants gracefully on old receivers, and the signal channel is the
right layer for periodic telemetry aggregates.

Changes:

  wzp-proto/src/packet.rs:
    - New SignalMessage::LossRecoveryUpdate variant carrying:
        * dred_reconstructions: u64 (monotonic since call start)
        * classical_plc_invocations: u64 (monotonic)
        * frames_decoded: u64 (for rate calculation)
    - All three fields tagged #[serde(default)] for forward compat.

  wzp-client/src/featherchat.rs:
    - Added a match arm so signal_to_call_type() handles the new
      variant (treat as Offer for featherChat bridging purposes).

  wzp-relay/src/metrics.rs:
    - Two new IntCounterVec metrics on the relay, labeled by session_id:
        * wzp_relay_session_dred_reconstructions_total
        * wzp_relay_session_classical_plc_total
    - New method update_session_loss_recovery(session_id, dred, plc)
      applies monotonic deltas: if the incoming totals exceed the
      current counter, the difference is inc_by'd. If the incoming
      totals are LOWER (client restart or counter reset), the
      Prometheus counter holds steady until the client catches up.
      This matches the existing update_session_buffer delta pattern.
    - remove_session_metrics() now cleans up the two new labels.
    - New test session_loss_recovery_monotonic_delta exercises:
        * initial population (10 DRED, 2 PLC)
        * forward advance (25, 5 → delta +15, +3)
        * lower values ignored (client reset → counters unchanged)
        * client catches up (30, 8 → advances to new max)
    - Existing session_metrics_cleanup test extended to cover the
      new counters.

  android/app/src/main/java/com/wzp/debug/DebugReporter.kt:
    - Phase 4 users — and incident responders — need to quickly see
      whether DRED is actually firing during a call. The stats JSON
      already carries the counters (after Phase 3c), but they were
      buried in the trailing JSON dump. Added a dedicated
      "=== Loss Recovery ===" section to the meta preamble that
      extracts dred_reconstructions, classical_plc_invocations,
      frames_decoded, and fec_recovered from the JSON and displays
      them plainly, plus computed percentages when frames_decoded > 0.
    - New extractLongField helper: tiny hand-rolled JSON integer
      extractor. We don't want to pull in a full JSON parser for this
      single use case and CallStats has a flat, well-known schema.

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-proto --lib: 63 passing
- cargo test -p wzp-codec --lib: 68 passing
- cargo test -p wzp-client --lib: 35 passing (+1 ignored probe)
- cargo test -p wzp-relay --lib: 68 passing (+1 new Phase 4 test)
- cargo check -p wzp-android --lib: zero errors
- Android APK build verified earlier today (unridden-alfonso.apk
  via the remote Docker builder) — Phase 0–3c confirmed to compile
  end-to-end on the NDK target.

Phase 4b remaining (not blocking this commit):
- Periodic LossRecoveryUpdate emitter in wzp-client/src/call.rs and
  wzp-android/src/engine.rs (every ~5 s)
- Relay-side handler in main.rs that matches the new variant and
  calls metrics.update_session_loss_recovery
- Grafana "Loss recovery breakdown" panel in docs/grafana-dashboard.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:39 +04:00
Siavash Sameni
505a834c5b feat(codec): Phase 3c — Android engine.rs DRED reconstruction on packet loss
Phase 3c mirrors Phase 3b on the Android receive path. With Phase 0-3b
landed on desktop + Android encoder, this commit completes codec-layer
loss recovery on the Android decoder side.

Architectural difference vs desktop: engine.rs has NO jitter buffer.
The recv task reads packets directly from the transport via
recv_media().await and writes decoded audio straight into the playout
ring. There is no PlayoutResult::Missing equivalent. Gap detection
therefore has to be done via sequence-number tracking — when a packet
arrives with seq > expected_seq, the frames in between are missing and
we attempt to reconstruct them via DRED before decoding the newly-
arrived packet.

Implementation:

  Imports & types:
    - Added wzp_codec::AdaptiveDecoder, wzp_codec::dred_ffi::{
      DredDecoderHandle, DredState} imports.
    - Changed the `decoder` local from Box<dyn AudioDecoder> (via
      wzp_codec::create_decoder) to concrete AdaptiveDecoder::new(profile).
      Same reasoning as Phase 3b: reconstruct_from_dred is an inherent
      method, not a trait method, so we need the concrete type.

  Recv task state (all task-local, no new struct fields):
    - dred_decoder: DredDecoderHandle
    - dred_parse_scratch: DredState (reused, overwritten per parse)
    - last_good_dred: DredState (cached most-recent valid state)
    - last_good_dred_seq: Option<u16>
    - expected_seq: Option<u16> (for gap detection)
    - dred_reconstructions: u64 (telemetry)
    - classical_plc_invocations: u64 (telemetry)

  Recv loop body (Opus source packets only):
    1. Parse DRED from the new packet first so last_good_dred reflects
       the freshest state available for gap recovery.
    2. Detect a gap: gap = pkt.seq.wrapping_sub(expected_seq). Cap at
       MAX_GAP_FRAMES = 16 (320 ms) to avoid huge wraparound scenarios.
    3. For each missing seq in the gap:
         offset = (last_good_dred_seq - missing_seq) * frame_samples
         if 0 < offset <= last_good_dred.samples_available():
             reconstruct_from_dred + write to playout ring
             bump dred_reconstructions
         else:
             decoder.decode_lost (classical PLC) + write + bump plc counter
    4. Decode the current packet normally and write to playout ring
       (unchanged from Phase 2).
    5. Update expected_seq = pkt.seq.wrapping_add(1).

  Profile-switch handling: when the incoming codec changes (triggering
  decoder.set_profile), reset last_good_dred_seq and expected_seq to
  None. The cached DRED state is tied to the old profile's frame rate
  and would produce wrong offsets after the switch; starting fresh is
  correct.

  Decode-error fallback: the existing `Err(e) => decode_lost` branch
  now also increments classical_plc_invocations so the counter
  accurately reflects all PLC invocations (gap-detected AND decode-
  error-triggered).

Telemetry (CallStats additions):
  - stats.dred_reconstructions: u64
  - stats.classical_plc_invocations: u64
  Both updated on every packet arrival in the existing stats.lock()
  block alongside frames_decoded/fec_recovered, so the Android UI and
  JNI bridge already have these values without any further plumbing.
  The periodic recv stats log now includes both counters.

Ordering note: DRED gap reconstruction happens BEFORE decoding the new
packet's audio because the playout ring is FIFO. Gap samples must be
written before the new packet's samples so temporal order is preserved.
Out-of-order late arrivals (seq < expected_seq) are naturally dropped
as stale by the gap detection (gap would be a large wraparound value
exceeding MAX_GAP_FRAMES).

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 68 passing (unchanged from Phase 3b)
- cargo test -p wzp-client --lib: 35 passing (unchanged from Phase 3b)
- cargo check -p wzp-android --lib: zero errors
- cargo test -p wzp-android cannot run on macOS host (pre-existing
  -llog linker dep, unrelated). Real end-to-end verification happens
  via the Android APK build on the remote Docker builder
  (scripts/build-and-notify.sh).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:31 +04:00
Siavash Sameni
27bc264738 feat(codec): Phase 3b — CallDecoder DRED reconstruction on packet loss
Phase 3b of the DRED integration — wires the Phase 3a FFI primitives
into the desktop receive path. When the jitter buffer reports a missing
Opus frame, CallDecoder now attempts to reconstruct the audio from the
most recently parsed DRED side-channel state before falling through to
classical PLC.

Architectural refinement vs the PRD's literal wording: the PRD said
"jitter buffer takes a Box<dyn DredReconstructor>". After checking deps,
wzp-transport depends only on wzp-proto (not wzp-codec). Putting DRED
state in the jitter buffer would require a new cross-crate dep and
couple the codec-agnostic buffer to libopus. Instead, this commit keeps
the DRED state ring and reconstruction dispatch inside CallDecoder (one
layer up from the jitter buffer), intercepting the existing
PlayoutResult::Missing signal. Same lookahead/backfill semantics,
cleaner layering, zero change to wzp-transport.

Changes:

  CallDecoder field type: Box<dyn AudioDecoder> → AdaptiveDecoder.
  Required because Phase 3b calls the inherent reconstruct_from_dred
  method, which cannot live on the AudioDecoder trait without dragging
  libopus DredState through wzp-proto. In practice AdaptiveDecoder was
  the only AudioDecoder implementor anyway — the trait abstraction was
  buying nothing. Method call sites unchanged because AdaptiveDecoder
  also implements AudioDecoder.

  New CallDecoder fields:
    - dred_decoder: DredDecoderHandle
    - dred_parse_scratch: DredState  (scratch for parse_into)
    - last_good_dred: DredState      (cached most-recent valid state)
    - last_good_dred_seq: Option<u16>
    - dred_reconstructions: u64      (Phase 4 telemetry)
    - classical_plc_invocations: u64 (Phase 4 telemetry)

  CallDecoder::ingest — on Opus non-repair packets, parse DRED into the
  scratch state. On success (samples_available > 0), std::mem::swap the
  scratch into last_good_dred and record the seq. This is O(1) per
  packet, zero allocation after construction (the two DredState buffers
  are allocated once in new() and reused forever).

  CallDecoder::decode_next — on PlayoutResult::Missing(seq) for Opus
  profiles: if last_good_dred_seq > seq and the seq delta × frame_samples
  fits within samples_available, call audio_dec.reconstruct_from_dred
  and bump dred_reconstructions. Otherwise fall through to classical
  PLC and bump classical_plc_invocations. The Codec2 path always falls
  through to classical PLC since DRED is libopus-only and
  AdaptiveDecoder::reconstruct_from_dred rejects Codec2 tiers
  explicitly.

  OpusDecoder and AdaptiveDecoder: new inherent reconstruct_from_dred
  method that delegates to the underlying DecoderHandle. Needed to
  bridge CallDecoder's wzp-client code to the Phase 3a FFI wrappers
  without touching the AudioDecoder trait.

CRITICAL FINDING — raised DRED loss floor from 5% to 15%:

Phase 3b testing discovered that libopus 1.5's DRED emission window
scales aggressively with OPUS_SET_PACKET_LOSS_PERC. Empirical data
(see probe_dred_samples_available_by_loss_floor, an #[ignore]'d
diagnostic test in call.rs):

  loss_pct   samples_available   effective_ms
    5%        720                  15 ms  (useless!)
   10%        2640                 55 ms
   15%        4560                 95 ms
   20%        6480                135 ms
   25%+       8400 (capped)       175 ms  (~87% of 200 ms configured)

The Phase 1 default of 5% produced only a 15 ms reconstruction window
— too small to even cover a single 20 ms Opus frame. DRED was
effectively disabled even though it was emitting bytes. Raised the
floor to 15% (95 ms window) as the minimum that actually provides
single-frame loss recovery. This updates Phase 1's DRED_LOSS_FLOOR_PCT
constant in opus_enc.rs and the accompanying module docstring.

Trade-off: 15% assumed loss slightly increases encoder bitrate overhead
on clean networks. Measured via the existing phase1 bitrate probe:

  Before (5% floor):  3649 bytes/sec at Opus 24k + 300 Hz sine
  After  (15% floor): 3568 bytes/sec at Opus 24k + 300 Hz sine

The delta is within noise — 15% isn't meaningfully more expensive than
5% on this signal, which suggests the DRED emission size is signal-
dependent rather than loss-dependent for small values. Net result: we
get a 6x larger reconstruction window for essentially free.

Tests (+3 DRED recovery, +1 #[ignore]'d probe):
- opus_single_packet_loss_is_recovered_via_dred — full encode → ingest
  → decode_next loop with one packet dropped mid-stream. Asserts
  dred_reconstructions ≥ 1 and observes the exact counter deltas.
- opus_lossless_ingest_never_triggers_dred_or_plc — baseline behavior,
  lossless stream never takes the Missing branch.
- codec2_loss_falls_through_to_classical_plc — Codec2 never
  reconstructs via DRED even if state were populated (which it won't
  be — Codec2 packets don't carry DRED bytes).
- probe_dred_samples_available_by_loss_floor — #[ignore]'d diagnostic
  that sweeps loss_pct values and prints the resulting DRED window
  sizes. Kept for future tuning work.

New CallDecoder introspection accessors (public but undocumented in
the PRD): last_good_dred_seq() and last_good_dred_samples_available()
for test diagnostics and future telemetry surfaces in Phase 4.

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 68 passing (Phase 3a baseline held)
- cargo test -p wzp-client --lib: 35 passing (+3 Phase 3b tests,
  +1 ignored diagnostic, no regressions)

Next up: Phase 3c mirrors this on the Android engine.rs receive path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:24 +04:00
Siavash Sameni
c27b39d553 feat(codec): Phase 3a — DRED FFI primitives (DredDecoderHandle + DredState)
Phase 3a of the DRED integration — the foundation for codec-layer loss
recovery. Adds three new safe wrappers to crates/wzp-codec/src/dred_ffi.rs
over the raw opusic-sys FFI, plus the reconstruction method on the existing
DecoderHandle. No call-site integration yet — that lands in Phase 3b (desktop)
and Phase 3c (Android).

New types:
- `DredDecoderHandle`: owns *mut OpusDREDDecoder from opus_dred_decoder_create.
  Used for parsing DRED side-channel data out of arriving Opus packets.
  This is a SEPARATE libopus object from OpusDecoder — it has its own
  internal state. Freed via opus_dred_decoder_destroy on Drop.
- `DredState`: owns *mut OpusDRED from opus_dred_alloc (a fixed ~10.6 KB
  buffer per libopus 1.5). Holds parsed DRED data between the parse and
  reconstruct steps. Reusable — parse_into overwrites contents. Tracks
  samples_available as a cached u32 so callers don't thread the value
  separately. Freed via opus_dred_free on Drop.

New methods:
- `DredDecoderHandle::parse_into(&mut self, state: &mut DredState, packet)`
  wraps opus_dred_parse with max_dred_samples=48000 (1s max), sampling_rate
  =48000, defer_processing=0. Returns the positive sample offset of the
  first decodable DRED sample, 0 if no DRED is present, or an error.
  Populates state.samples_available so subsequent reconstruct calls know
  the valid offset range.
- `DecoderHandle::reconstruct_from_dred(&mut self, state, offset_samples,
  output)` wraps opus_decoder_dred_decode. Reconstructs audio at a specific
  sample position (positive, measured backward from the DRED anchor packet)
  into a caller-provided output buffer. Validates that 0 < offset_samples
  <= state.samples_available() before calling the FFI to catch range bugs.

Tests (+7, wzp-codec total: 68 passing):
- dred_decoder_handle_creates_and_drops
- dred_state_creates_and_drops
- dred_state_reset_zeroes_counter
- dred_parse_and_reconstruct_roundtrip — end-to-end validation. Encodes
  60 frames of a 300 Hz sine wave through a DRED-enabled Opus 24k encoder,
  parses DRED state out of each arriving packet, asserts that at least one
  packet carries non-zero samples_available (DRED warm-up completes within
  the first second), then reconstructs 20 ms of audio from inside the
  window and asserts non-zero total energy. This is the hard signal that
  the full libopus 1.5 DRED FFI chain is correctly wired on our side.
- reconstruct_with_out_of_range_offset_errors — offset > samples_available
  is rejected at the Rust layer before the FFI call.
- reconstruct_with_zero_offset_errors — offset <= 0 rejected.
- dred_parse_empty_packet_returns_zero — graceful handling of empty input.

Architectural note (divergence from PRD's literal wording):
The PRD said "jitter buffer takes a Box<dyn DredReconstructor>". After
checking Cargo.toml for wzp-transport, it does NOT depend on wzp-codec —
only wzp-proto. Adding a DRED state ring inside the jitter buffer would
require a new cross-crate dependency and couple the codec-agnostic jitter
buffer to libopus internals. Instead, Phase 3b will put the DRED state
ring and reconstruction dispatch in CallDecoder (one layer up from the
jitter buffer), intercepting the existing PlayoutResult::Missing signal
and attempting reconstruction before falling through to classical PLC.
The jitter buffer itself stays unchanged. Same lookahead/backfill
semantics, cleaner layering. PRD's intent preserved, implementation
refined.

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 68 passing (61 Phase 2 baseline + 7 new)
- The roundtrip test is the acceptance gate — it proves that
  opus_dred_decoder_create, opus_dred_alloc, opus_dred_parse, and
  opus_decoder_dred_decode all work correctly through our wrappers on
  real libopus 1.5.2 output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:03:14 +04:00
Siavash Sameni
6db5c25b54 feat(codec): Phase 2 — remove RaptorQ from Opus tiers, Codec2 unchanged
Phase 2 of the DRED integration (docs/PRD-dred-integration.md). With
Phase 1 having enabled DRED on every Opus profile, the app-level RaptorQ
layer is now redundant overhead on those tiers: +20% bitrate, +40–100 ms
receive-side latency (block wait), +CPU for stats we never used. This
phase removes RaptorQ from the Opus encode and decode paths on both the
desktop (wzp-client/call.rs) and Android (wzp-android/engine.rs) sides.
Codec2 tiers keep RaptorQ with their current ratios unchanged — DRED is
libopus-only and Codec2 has no neural equivalent.

Encoder changes (the real bandwidth / CPU win):
- CallEncoder::encode_frame and engine.rs encode loop now gate the
  RaptorQ path on !codec.is_opus():
    - Opus source packets emit fec_block=0, fec_symbol=0,
      fec_ratio_encoded=0 in the MediaHeader
    - fec_enc.add_source_symbol is skipped on Opus
    - generate_repair + repair packet emission is skipped on Opus
    - block_id and frame_in_block counters stay frozen at 0 for Opus
- Codec2 path is byte-for-byte identical to pre-Phase-2 behavior.

Decoder changes (mostly cleanup, since both live decoder paths were
already reading audio directly from source packets and only using the
RaptorQ decoder output for stats):
- CallDecoder::ingest skips fec_dec.add_symbol on Opus packets. Source
  packets still flow to the jitter buffer; Opus repair packets from old
  senders are dropped cleanly (repair packets never hit the jitter
  buffer either).
- engine.rs recv loop skips fec_dec.add_symbol, fec_dec.try_decode, and
  fec_dec.expire_before on Opus packets. The `fec_recovered` stat
  counter becomes Codec2-only (a separate DRED reconstruction counter
  lands in Phase 4).

Wire-format backward compat verified at pre-flight:
- Old receiver + new sender: engine.rs pipeline.rs path gates on
  non-zero fec_block/fec_symbol which now never fire for Opus, so the
  RaptorQ decoder simply isn't fed. Audio flows normally. Desktop
  CallDecoder's old path accumulated packets into the stale-eviction
  HashMap, which cleans up after 2s — harmless.
- New receiver + old sender: new receiver skips RaptorQ on Opus so
  old-sender repair packets are ignored entirely (no crash, no double-
  decode). Loses the (previously vestigial) RaptorQ recovery benefit,
  which was never actually active in the audio path. Source packets
  still decode normally.
- No wire format version bump required. MediaHeader is unchanged; we
  just zero the FEC fields on Opus packets.

Test changes:
- Removed `encoder_generates_repair_on_full_block` — asserted the old
  (pre-Phase-2) RaptorQ-on-Opus behavior and is now incorrect. Replaced
  with two symmetric tests:
    - `opus_source_packets_have_zero_fec_header_fields` — verifies
      Phase 2 invariants on Opus packets
    - `opus_encoder_never_emits_repair_packets` — runs 20 frames of
      non-silent sine wave through a GOOD-profile encoder, asserts
      exactly 20 output packets, zero repair
    - `codec2_encoder_generates_repair_on_full_block` — same shape as
      the old test but on CATASTROPHIC profile (Codec2 1200, 8
      frames/block, ratio 1.0) to verify Codec2 path still emits
      repairs as before

Verification:
- cargo check --workspace: zero errors
- cargo test -p wzp-codec --lib: 61 passing (Phase 1 baseline held)
- cargo test -p wzp-client --lib: 32 passing (+3 new Phase 2 tests,
  -1 old test removed)
- cargo check -p wzp-android --lib: zero errors (host link of
  wzp-android tests fails on -llog per pre-existing Android-only
  build.rs, unrelated to this work; integration build via
  build-and-notify.sh will validate Android end-to-end)
- Pre-existing broken integration test in
  crates/wzp-client/tests/handshake_integration.rs (SignalMessage
  schema drift) is NOT caused by this commit — baseline had the same
  3 compile errors before Phase 2. Flagged as a separate cleanup task.

Expected observable effects on a real call:
- Opus 24k outgoing bitrate drops from ~28.8 kbps (ratio 0.2 RaptorQ)
  to ~25 kbps (base 24 kbps + DRED ~1–10 kbps signal-dependent)
- Opus receive-side latency drops ~40 ms on clean network (no more
  block wait — jitter buffer emits as soon as a source packet arrives)
- Codec2 calls show no latency or bitrate change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:02:42 +04:00
Siavash Sameni
54cbebd34e feat(codec): Phase 1 — enable DRED on all Opus profiles, disable inband FEC
Phase 1 of the DRED integration (docs/PRD-dred-integration.md). The Opus
encoder now emits DRED (Deep REDundancy) bytes in every packet, carrying
a neural-coded history of recent audio that the decoder can use to
reconstruct loss bursts up to the configured window. Opus inband FEC
(LBRR) is disabled because DRED does the same job better and running both
wastes bitrate on overlapping protection.

Tiered DRED duration policy per PRD:
  Studio  (Opus 32k/48k/64k): 10 frames = 100 ms
  Normal  (Opus 16k/24k):     20 frames = 200 ms
  Degraded (Opus 6k):         50 frames = 500 ms

Each profile switch (via adaptive quality) updates the DRED duration to
match the new tier. A 5% packet_loss floor is applied whenever DRED is
active, because libopus 1.5 gates DRED emission on non-zero packet_loss.
Real loss measurements from the quality adapter override upward.

Escape hatch: AUDIO_USE_LEGACY_FEC=1 reverts the encoder to Phase 0
behavior (inband FEC Mode1, DRED off, no loss floor). Read once at
OpusEncoder::new; call-scoped, not re-read mid-call. Trait-level
set_inband_fec becomes a no-op in DRED mode to preserve the invariant
even if external callers forget.

Observations from the bitrate probe test (dred_mode_roundtrip_voice_pattern):
  DRED mode:   3649 bytes/sec (~29.2 kbps) on Opus 24k + 300 Hz sine
  Legacy mode: 2383 bytes/sec (~19.1 kbps)
  Delta:       +10.1 kbps

The delta is considerably larger than the "+1 kbps flat" figure I carried
into the PRD from hazy memory of published DRED benchmarks. Likely because
the input (300 Hz sine) is very compressible so the base Opus rate in
legacy mode is well below the 24 kbps target, making the delta look
disproportionate. Signal-dependent — real speech would probably show a
different ratio. If production telemetry shows the overhead is excessive,
we can cut DRED duration on the normal tier from 200 ms to 100 ms as a
first tuning lever. Not blocking Phase 1 since the test still passes
within the reasonable 2000–8000 bytes/sec bounds.

Test changes (+8 tests, total wzp-codec: 61 passing):
- dred_duration_for_studio_tiers_is_100ms  (per-profile policy)
- dred_duration_for_normal_tiers_is_200ms
- dred_duration_for_degraded_tier_is_500ms
- dred_duration_for_codec2_is_zero
- default_mode_is_dred_not_legacy  (sanity check on fresh construction)
- dred_mode_roundtrip_voice_pattern  (observes DRED bitrate, asserts bounds)
- profile_switch_refreshes_dred_duration  (verifies set_profile updates DRED)
- set_inband_fec_noop_in_dred_mode  (trait-level inband FEC no-op)

Verification:
- cargo check --workspace: zero errors, no new warnings
- cargo test -p wzp-codec: 61/61 passing (53 pre-Phase-1 baseline + 8 new)
- Empirical DRED bitrate observed via `rtk proxy cargo test
  dred_mode_roundtrip_voice_pattern -- --nocapture`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:02:35 +04:00
Siavash Sameni
86526a7ad4 feat(codec): Phase 0 — swap audiopus → opusic-c + opusic-sys (libopus 1.5.2)
Phase 0 of the DRED integration (docs/PRD-dred-integration.md). No behavior
change: inband FEC stays ON, no DRED, same bitrate, same quality. This
commit unblocks Phase 1+ by getting us onto libopus 1.5.2 where DRED lives.

Rationale for going straight to a custom DecoderHandle: opusic-c::Decoder's
inner *mut OpusDecoder pointer is pub(crate), so we cannot reach it for the
Phase 3 DRED reconstruction path. Running two parallel decoders (one for
audio, one for DRED) would drift because the DRED decoder wouldn't see
normal decode calls. Single unified DecoderHandle over raw opusic-sys is
the only correct architecture, so we build it in Phase 0 rather than
rewriting opus_dec.rs twice.

Changes:
- Cargo.toml (workspace + wzp-codec): remove audiopus 0.3.0-rc.0, add
  opusic-c 1.5.5 (bundled + dred features), opusic-sys 0.6.0 (bundled),
  bytemuck 1. Pinned exactly for reproducible libopus 1.5.2.
- opus_enc.rs: rewritten against opusic_c::Encoder. Argument order for
  Encoder::new swapped (Channels first). set_inband_fec(bool) now maps
  to InbandFec::Mode1 (the libopus 1.5 equivalent of 1.3's LBRR). encode
  uses bytemuck::cast_slice<i16,u16> at the &[u16] boundary.
- dred_ffi.rs (new): DecoderHandle wrapping *mut OpusDecoder directly via
  opusic-sys. Owns the allocation, frees on Drop. Exposes decode,
  decode_lost, and a pub(crate) as_raw_ptr() for the future Phase 3 DRED
  reconstruction. Send+Sync justified via &mut self access discipline.
- opus_dec.rs: rewritten as a thin AudioDecoder impl over DecoderHandle.
  Behavior identical to pre-swap.

Verification (Phase 0 acceptance gates):
- cargo check --workspace: clean (30 pre-existing warnings in jni_bridge.rs
  unrelated to this work; zero in changed files).
- cargo test -p wzp-codec: 53 tests pass (50 pre-swap + 6 new: 3 in
  dred_ffi.rs for DecoderHandle lifecycle, 3 in opus_enc.rs for version
  check and roundtrip).
- linked_libopus_is_1_5 test asserts opusic_c::version() contains "1.5" —
  hard signal that the swap landed correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:02:15 +04:00
Siavash Sameni
56e3417063 docs: add PRD for DRED integration and Opus-tier FEC simplification
Plans the libopus 1.5.2 upgrade (audiopus → opusic-c/opusic-sys), DRED
enablement with tiered durations (100/200/500ms studio/normal/degraded),
removal of RaptorQ and Opus inband FEC from the Opus tiers, jitter buffer
lookahead/backfill refactor, and runtime escape hatch for rollout safety.
RaptorQ + current ratios preserved on Codec2 tiers (no DRED there).

Includes pre-flight verification findings: opusic-c Decoder inner pointer
is inaccessible (requires unified opusic-sys DecoderHandle), libopus 1.5
DRED API semantics clarified against xiph/opus opus.h, wire-format
backward compat verified on both live receive paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:57:01 +04:00
Siavash Sameni
8ceb6f45d5 fix(build): declare VARIANT in local script half (was remote-only)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
The VARIANT variable was set inside the REMOTE_SCRIPT heredoc for
naming artifacts during the cargo tauri build, but never declared
in the local half of the script where it's used to rename downloaded
files. Under `set -u` strict mode this aborted the local downloads
with "unbound variable: VARIANT" after a successful remote build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:16:07 +04:00
Siavash Sameni
07873ea598 fix(linux-aec): fall back to 0.3 crate + apt lib (2.x bundled is broken)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 4m6s
Mirror to GitHub / mirror (push) Failing after 45s
Switch the webrtc-audio-processing dep from the 2.x git source (bundled
mode) back to crates.io 0.3, and link against Debian's apt package
libwebrtc-audio-processing-dev (0.3-1+b1 on Bookworm). The 2.x path
fails because both the crates.io tarball and the upstream git main
branch of webrtc-audio-processing-sys 2.0.3 have a build.rs bug where
\`meson setup --reconfigure\` is passed unconditionally, panicking on
first-run empty build dirs with "Directory does not contain a valid
build tree". The 0.x line sidesteps bundled mode entirely by linking
the apt-provided library.

Trade-off: we get AEC2 (the older generation) instead of AEC3, but
it's the same algorithm family and is what PulseAudio's
module-echo-cancel and PipeWire's filter-chain use on current
Debian-family distros. Fine for shipping — we can revisit AEC3 once
the 2.x bundled build is fixed upstream.

API changes:
- 0.3's Processor::process_capture_frame and process_render_frame
  take &mut self, so wrap the module-level processor in a Mutex.
  Capture and playback threads each lock briefly (sub-ms per 10 ms
  frame); contention is minimal.
- Import NUM_SAMPLES_PER_FRAME from the crate directly instead of
  hardcoding 480, so the code tracks whatever sample rate the
  upstream C++ lib exposes (currently 48 kHz hardcoded -> 480).
- Helper fns drain_frames_through_apm / tee_render_samples / etc.
  take &Mutex<Processor> instead of &Processor.
- Use explicit EchoCancellationSuppressionLevel and
  NoiseSuppressionLevel imports rather than fully-qualified paths.

Dockerfile:
- Drop meson / ninja-build / python3 (only needed for bundled build).
- Add libwebrtc-audio-processing-dev for the system link path.
- Keep clang (may be needed by the bindgen step in some versions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:06:56 +04:00
Siavash Sameni
cc00f7cace fix(linux-aec): try main branch of webrtc-audio-processing
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m41s
v2.0.3 bundled build hits 'Directory does not contain a valid build
tree' because the crate's build.rs uses `meson setup --reconfigure`
unconditionally, which fails on first run when the build dir doesn't
yet contain prior meson state. Try the main branch in case it's been
fixed post-release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:58:28 +04:00
Siavash Sameni
eb9de988d6 fix(linux-aec): use git dep for webrtc-audio-processing
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
The crates.io tarball of webrtc-audio-processing-sys 2.0.3 is missing
the vendored C++ submodule — the bundled build fails with 'Directory
does not contain a valid build tree' when meson tries to configure
the ./webrtc-audio-processing subdirectory. Cargo clones git deps with
submodules auto-initialized since ~1.27, so pulling from the upstream
git repo (pinned to tag v2.0.3) gives us the full source tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:55:04 +04:00
Siavash Sameni
4ba77c8c0e feat(linux): WebRTC AEC3 capture/playback backend with render-side tee
Some checks failed
Mirror to GitHub / mirror (push) Failing after 34s
Build Release Binaries / build-amd64 (push) Has been cancelled
Adds gold-standard Linux echo cancellation: in-app WebRTC AEC3 (Audio
Processing Module) via the webrtc-audio-processing crate, using the
same algorithm as Chrome WebRTC, Zoom, Teams, and Jitsi. Runs entirely
in-process, so it works identically on ALSA / PulseAudio / PipeWire
systems — no dependency on user-configured echo-cancel modules.

Architecture:
- New crates/wzp-client/src/audio_linux_aec.rs module (~470 lines).
  Contains LinuxAecCapture and LinuxAecPlayback, both using CPAL
  under the hood but routing samples through a shared
  Arc<webrtc_audio_processing::Processor>. The playback path tees
  each 20 ms frame into APM.process_render_frame as the echo
  reference BEFORE handing the samples to CPAL's output callback.
  The capture path runs APM.process_capture_frame on each mic frame
  in place before pushing to the audio ring buffer. This is the
  "tee the playback ring" approach that Zoom/Teams/Jitsi use.
- New `linux-aec` feature in wzp-client pulling in the
  webrtc-audio-processing crate at v2.x with the `bundled`
  sub-feature. Bundled means the vendored PulseAudio WebRTC C++
  sources are statically compiled via meson+ninja at cargo build
  time — no runtime .so dependency, avoids Debian Bookworm's stale
  libwebrtc-audio-processing-dev 0.3 package (which predates AEC3).
  Dep is target-gated to Linux, so enabling the feature on non-Linux
  is a no-op.
- lib.rs re-exports LinuxAecCapture/LinuxAecPlayback as
  AudioCapture/AudioPlayback when `linux-aec` is on, otherwise
  falls back to the CPAL audio_io path. Shared public API
  (start/ring/stop/Drop) means downstream code is unchanged.
- New `linux-aec` feature in wzp-desktop forwards to
  wzp-client/linux-aec so `cargo tauri build -- --features
  wzp-desktop/linux-aec` builds the AEC variant.

APM configuration:
- EchoCancellation: High suppression, delay-agnostic mode on,
  extended filter on, stream_delay_ms=60 initial hint
- NoiseSuppression: High
- HighPassFilter: on
- AGC: off (can fight Opus encoder's own gain staging + adaptive
  quality controller; add later if users report low mic level)

Frame size handling:
- Pipeline uses 20 ms frames (960 samples @ 48 kHz mono)
- APM requires strict 10 ms (480 samples) per call
- Each 20 ms frame is split into two 480-sample halves, APM called
  twice, halves stitched back
- Same pattern for render and capture sides
- Carry-buffer logic handles the case where CPAL delivers samples in
  arbitrary chunk sizes that don't divide 960

Build infrastructure:
- scripts/Dockerfile.linux-desktop-builder adds meson, ninja-build,
  python3, clang for the webrtc-audio-processing bundled build
- scripts/build-linux-desktop-docker.sh takes a new --aec flag that
  enables the linux-aec feature and renames the output artifacts
  with an `-aec` suffix so noAEC and AEC variants can coexist on disk

Task #30.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:53:23 +04:00
Siavash Sameni
7b8a2d0fba feat(build): add Linux x86_64 Tauri desktop build pipeline
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m50s
New Dockerfile and build script for producing wzp-desktop as a Linux
x86_64 binary (plus .deb and .AppImage bundles via tauri-cli).

- scripts/Dockerfile.linux-desktop-builder: thin extension of
  wzp-android-builder that adds the Tauri Linux runtime deps
  (libwebkit2gtk-4.1-dev, libsoup-3.0-dev, libgtk-3-dev,
  libayatana-appindicator3-dev, librsvg2-dev, libglib2.0-dev, patchelf).
  Everything else (Rust, Node, cmake, pkg-config, libasound2-dev,
  tauri-cli) is inherited from the base image.

- scripts/build-linux-desktop-docker.sh: mirrors the pattern of
  build-windows-docker.sh and build-linux-docker.sh. Ships
  \`cargo tauri build\` which produces target/release/wzp-desktop
  plus bundles under target/release/bundle/{deb,appimage}/. Uploads
  the .deb (or raw binary if bundling fails) to rustypaste and
  notifies ntfy.sh/wzp on start + completion. Downloads all three
  artifact types (raw binary, .deb, .AppImage) to target/linux-desktop/
  when they exist.

Image cache volumes are shared with the Android pipeline for cargo
registry + git, but the target dir is in its own cache-linux-desktop/
path to avoid stomping on the Android / Linux-CLI / Windows target
caches.

Branch default is feat/desktop-audio-rewrite (where the actual
wzp-desktop source lives), not feat/android-voip-client.

Task #29.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:28:47 +04:00
Siavash Sameni
5cd7a20152 fix(ui): disable WebView pinch-zoom and desktop right-click menu
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Has been cancelled
Two small WebView hardening tweaks that apply to both Android (Tauri
mobile) and desktop (Tauri) since the frontend is shared:

- index.html viewport meta now sets maximum-scale=1.0, minimum-scale=1.0,
  and user-scalable=no. This stops users on Android from pinch-zooming
  out of the fixed-layout UI. Desktop is unaffected because the Tauri
  WebView ignores pinch gestures anyway.
- main.ts installs global listeners that preventDefault on contextmenu
  (kills the browser-style right-click menu that exposed Inspect /
  Reload / Back / Forward entries on desktop), keydown Ctrl+-/+/0
  (stops keyboard zoom of the fixed layout), and gesture* + ctrl-wheel
  events (trackpad pinch on WebKit + Chromium respectively).

Dev tools remain accessible via F12 / Cmd-Opt-I keyboard shortcuts —
only the right-click entry point is suppressed. Android has no
right-click so that part is a no-op there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:26:08 +04:00
Siavash Sameni
a5c00fe5cb docs: add BRANCH-desktop-audio-rewrite.md and update ARCH/ADMIN/USER_GUIDE
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Documents the feat/desktop-audio-rewrite branch story end-to-end:
- Purpose: shared codebase with android-rewrite via Tauri, platform-
  specific audio backends via target-dep sections + feature flags
- Audio backend matrix: CPAL baseline + macOS VPIO + Windows WASAPI
  AudioCategory_Communications
- Recent work: desktop direct calling feature with history dedup,
  macOS VPIO integration, Windows cross-compile via cargo-xwin, the
  libopus/clang-cl vendored audiopus_sys fix, icon.ico generation,
  and the WASAPI communications capture backend (task #24)
- Build pipelines: native cargo on macOS/Linux, Docker on SepehrHomeserverdk
  for Windows, Hetzner Cloud alternative
- Testing procedures for direct calling parity and Windows AEC A/B
- Known quirks: vendor path relative, cargo-xwin override.cmake clobber,
  WebView2 runtime prerequisite, 2024 edition unsafe lint warnings

Also appends shared-doc sections (identical on both branches):
- ARCHITECTURE.md: "Audio Backend Architecture (Platform Matrix)"
- ADMINISTRATION.md: "Build Pipelines"
- USER_GUIDE.md: "Direct 1:1 Calling" and "Windows AEC Variants"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:20:21 +04:00
Siavash Sameni
ec41f179cd fix(windows): drop dead override.cmake patch from Dockerfile
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
The RUN step that baked an OPUS_DISABLE_INTRINSICS patch into
cargo-xwin's override.cmake was inert from the start: cargo-xwin
rewrites that file from scratch on every \`cargo xwin build\` invocation
(src/compiler/clang_cl.rs line ~444 uses include_bytes! to overwrite
it), so anything baked at image build time gets wiped at runtime.

The libopus SSE4.1/SSSE3 compile failure is now fixed upstream at the
source level by the vendored audiopus_sys patch (see
vendor/audiopus_sys/opus/CMakeLists.txt and the MSVC_CL distinction
for clang-cl). Remove the dead RUN step and leave a breadcrumb
comment pointing at the real fix location.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:07:06 +04:00
Siavash Sameni
4e9244eb00 fix(windows): add Win32_Security feature + 2024 edition unsafe wrappers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 43s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
- CreateEventW is gated behind Win32_Security in the windows crate
  because its signature takes SECURITY_ATTRIBUTES; add to features.
- Remove unused HANDLE import.
- Wrap GetId() and PWSTR::to_string() in explicit unsafe { ... }
  blocks for Rust 2024 edition's unsafe_op_in_unsafe_fn lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:36:50 +04:00
Siavash Sameni
03a80a3196 feat(windows): WASAPI capture backend with OS-level AEC
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Has been cancelled
Adds a direct WASAPI microphone capture path for the Windows desktop
build that opens the default communications endpoint via
IMMDeviceEnumerator -> IAudioClient2 -> SetClientProperties with
AudioCategory_Communications, turning on Windows's communications
audio processing chain (AEC, noise suppression, automatic gain
control). The communications AEC operates at the OS level and uses
the system render mix as the reference signal, so echo from our
existing CPAL playback stream is cancelled automatically with no
per-process reference plumbing.

Architecture:
- New crates/wzp-client/src/audio_wasapi.rs module (~280 lines).
  Event-driven capture loop on a dedicated thread; pushes PCM into
  the same lock-free AudioRing used by the CPAL path. Same public
  API as audio_io::AudioCapture so downstream code is unchanged.
- New `windows-aec` feature in wzp-client that pulls in the
  `windows` crate (Microsoft's official Rust COM bindings) gated to
  target_os = "windows" only. Enabling the feature on non-Windows
  targets is a no-op since both the module and the dep are
  cfg(target_os = "windows").
- lib.rs re-exports WasapiAudioCapture as AudioCapture when the
  feature is on, otherwise falls back to the CPAL AudioCapture.
  AudioPlayback is always the CPAL one — no reason to swap it.
- desktop/src-tauri/Cargo.toml Windows target enables the new
  feature: `features = ["audio", "windows-aec"]`.

Implementation notes:
- Uses eCommunications role (not eConsole) for GetDefaultAudioEndpoint
  — the user-configured "communications" device that Teams/Zoom
  pick up, and the one Windows's AEC is tuned for.
- Requests 48 kHz mono i16 with AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM +
  SRC_DEFAULT_QUALITY so Windows handles any format conversion in
  the audio engine instead of rejecting our format.
- Event-driven with SetEventHandle / WaitForSingleObject — no
  polling, minimal CPU cost between packets.
- 200 ms wait timeout so the capture thread polls `running` often
  enough for Drop to stop cleanly even if the audio engine stalls
  (e.g. device unplug).

Task #24.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:35:36 +04:00
Siavash Sameni
7fecf285ea fix(windows): add icons/icon.ico for tauri-build Windows resource
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m43s
tauri-build's Windows path unconditionally looks up icons/icon.ico to
embed as the PE file resource (taskbar/Explorer icon). We only had
icon.png (32x32 placeholder) which is fine on macOS/Linux but blocks
the Windows cross-compile with "icons/icon.ico not found; required for
generating a Windows Resource file during tauri-build".

Generated a multi-size ICO (16/24/32/48/64/128/256) from the existing
placeholder icon.png via Pillow. It's ugly at 256 due to upscaling from
32x32 with LANCZOS, but unblocks the build. Real branded icons can
replace it later without any build-system changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:15:04 +04:00
Siavash Sameni
0683dde5d3 fix(windows): vendor audiopus_sys + patch libopus for clang-cl SIMD
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Has been cancelled
cargo-xwin drives the Windows MSVC cross-compile via clang-cl, under
which CMake sets MSVC=1 — causing libopus 1.3.1's `if(NOT MSVC)` guards
to skip the per-file `-msse4.1` / `-mssse3` COMPILE_FLAGS that its x86
SIMD source files need. Clang-cl (unlike real cl.exe) still honors
Clang's target-feature system, so those files then fail to compile
with "always_inline function '_mm_cvtepi16_epi32' requires target
feature 'sse4.1'" errors across silk/NSQ_sse4_1.c, NSQ_del_dec_sse4_1.c,
and VQ_WMat_EC_sse4_1.c.

Earlier attempts to fix this downstream (cargo-xwin toolchain file,
override.cmake CMAKE_C_COMPILE_OBJECT <FLAGS> replace, CFLAGS env vars)
all failed because cargo-xwin rewrites override.cmake from scratch on
every `cargo xwin build` invocation and cmake-rs's -DCMAKE_C_FLAGS=
assembly happens before toolchain FORCE sets propagate.

Fixing it upstream at the source: vendor audiopus_sys 0.2.2 into
vendor/audiopus_sys, patch its bundled opus/CMakeLists.txt to introduce
an MSVC_CL var (true only when CMAKE_C_COMPILER_ID == "MSVC", i.e. real
cl.exe), and flip the eight `if(NOT MSVC)` SIMD guards to
`if(NOT MSVC_CL)`. Clang-cl then gets the GCC-style per-file flags and
the SSE4.1 sources build cleanly. Also flip the `if(MSVC)` global /arch
block at line 445 to `if(MSVC_CL)` so only cl.exe applies /arch:AVX and
clang-cl relies purely on per-file flags (no global/per-file mixing).

Wire via [patch.crates-io] in the workspace root Cargo.toml; the patch
is resolved relative to the workspace root as `vendor/audiopus_sys`.

Upstream context: xiph/opus#256, xiph/opus PR #257 (both stale).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:12:59 +04:00
Siavash Sameni
53f57eea07 fix(windows): printf instead of heredoc in Dockerfile RUN (parser hated <<EOF)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m56s
2026-04-10 13:05:04 +04:00
Siavash Sameni
ff3f7e8e4f fix(windows): patch override.cmake not toolchain — inject SSE via COMPILE_OBJECT template
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Has started running
The previous 'patch the toolchain file' approach (234a798, 48d2bd4) did
write the SSE flags into the COMPILE_FLAGS list correctly in the baked
image, but the CMakeCache.txt from the libopus configure ended up
without them in CMAKE_C_FLAGS, so cmake's final compile commands
didn't see them either. Most plausible explanation: cmake-rs passes
`-DCMAKE_C_FLAGS=…` on the command line, and its assembly of that
string happens outside the toolchain's FORCE set path, so the
toolchain patch never propagated.

Switch to a different lever: cargo-xwin already ships a tiny
`override.cmake` loaded via CMAKE_USER_MAKE_RULES_OVERRIDE. That
file is the right place to manipulate the compile-command
`CMAKE_C_COMPILE_OBJECT` / `CMAKE_CXX_COMPILE_OBJECT` templates —
it runs after cmake has initialised its compile rules but before
any source is compiled. Append two string(REPLACE '<FLAGS>' '<FLAGS>
/clang:-msse4.1 /clang:-mssse3 /clang:-msse3 /clang:-msse2') lines
to that file so every C and C++ compile command generated by cmake
gets the SSE feature flags inline, no matter what the project's
CMAKE_C_FLAGS is set to.

This is the CMake equivalent of a compiler wrapper and works
regardless of how cmake-rs / cargo-xwin / libopus juggle their
respective flag variables.
2026-04-10 13:03:06 +04:00
Siavash Sameni
48d2bd4f65 fix(windows): bake SSE patch into docker image instead of runtime
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m40s
2026-04-10 12:55:48 +04:00
Siavash Sameni
234a798df2 fix(windows): append SSE flags as a pure-CMake block to xwin toolchain
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m34s
The previous sed-based patch didn't stick in the docker-bash-c
heredoc (bash single-quoting made the newline escaping fragile).
Switch to a much simpler approach: just 'cat >>' a pure-CMake block
to the end of the cargo-xwin toolchain file. The block does:

    set(CMAKE_C_FLAGS   "${CMAKE_C_FLAGS}   /clang:-msse4.1 ..." CACHE STRING "" FORCE)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /clang:-msse4.1 ..." CACHE STRING "" FORCE)

Running AFTER the toolchain's own FORCE-set and AFTER cmake-rs's
-DCMAKE_C_FLAGS= command-line override, it unconditionally wins. No
sed, no awk, no python, no newline escaping — just CMake reading the
toolchain file like it normally does.

Idempotent via the WZP_SSE_PATCH sentinel grep in the comment block.
2026-04-10 12:50:00 +04:00
Siavash Sameni
fa042b130c fix(windows): sed-patch cargo-xwin toolchain to enable SSE4.1/SSSE3
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m51s
The CFLAGS_x86_64_pc_windows_msvc env-var approach from 990b6f1 did
nothing — cargo-xwin ships its own clang-cl cmake toolchain file at
~/.cache/cargo-xwin/cmake/clang-cl/x86_64-pc-windows-msvc-toolchain.cmake
which hardcodes COMPILE_FLAGS and FORCE-overrides CMAKE_C_FLAGS. Any
env-var CFLAGS gets dropped before opus's cmake build sees it.

The only place that actually makes it into every C file compilation
in the libopus subbuild is the toolchain file itself. Patch it in
place with an idempotent sed that appends

    /clang:-msse4.1
    /clang:-mssse3
    /clang:-msse3
    /clang:-msse2

right before the closing paren of the COMPILE_FLAGS setter. The patch
is marked with a WZP_SSE_PATCH comment so re-runs skip it.

Confirmed the error message matches with/without the env var — same
20 clang errors from NSQ_del_dec_sse4_1.c / NSQ_sse4_1.c before and
after 990b6f1, which is how we ruled out the env-var path.
2026-04-10 12:43:36 +04:00
Siavash Sameni
990b6f1ee0 fix(windows): set CFLAGS +sse4.1 +ssse3 so audiopus_sys builds under clang-cl
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Has been cancelled
libopus ships per-file SSE4.1 / SSSE3 C sources (opus/silk/x86/NSQ_del_dec_sse4_1.c
etc.) that assume the compiler picks up `-msse4.1` / `-mssse3` as per-file
CMake COMPILE_FLAGS. With clang-cl those bare -m flags are silently dropped,
so _mm_cvtepi16_epi32 + friends fail compile with 'always_inline function
requires target feature sse4.1, but would be inlined into a function that
is compiled without support for sse4.1'.

Workaround: set CFLAGS_x86_64_pc_windows_msvc + CXXFLAGS_x86_64_pc_windows_msvc
to `/clang:-msse4.1 /clang:-mssse3 /clang:-msse3 /clang:-msse2` before running
cargo xwin build. Every x86_64 Windows CPU shipped since 2008 has these
instruction sets so globally enabling them on this target is safe.

Also bump the tail -30 on cargo xwin output to tail -50 so the actual
compiler errors (not just the cmake wrapper panic) make it into the
ntfy / remote log file next time.
2026-04-10 12:40:38 +04:00
Siavash Sameni
7949266e11 windows: docker + hcloud build scripts for cross-compile
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m59s
Two parallel paths to build wzp-desktop.exe for x86_64-pc-windows-msvc:

scripts/Dockerfile.windows-builder
  Debian 12 base, matches scripts/Dockerfile.android-builder's layout:
  - apt: build-essential, cmake, ninja-build, llvm, clang, lld, nasm,
    libssl-dev, node 20 LTS
  - rust stable + x86_64-pc-windows-msvc target
  - cargo-xwin pre-installed
  - Pre-warmed ~/.cache/cargo-xwin layer: creates a throwaway cargo
    project and runs `cargo xwin build` once during image build so the
    MSVC CRT + Windows SDK (~1.5 GB) is baked into an image layer.
    Saves ~4 minutes off every cold cross-compile run.
  - Builder user uid 1000 to match existing bind-mount perms on
    SepehrHomeserverdk.

scripts/build-windows-docker.sh
  Same pattern as scripts/build-tauri-android.sh but for Windows:
  - Fires a remote build on SepehrHomeserverdk via ssh + heredoc
  - Mounts the shared cargo-registry + cargo-git cache + a
    target-windows dir (separate from the android target cache so
    different triples don't stomp each other)
  - Runs npm install + npm run build for the frontend dist, then
    cargo xwin build --release --target x86_64-pc-windows-msvc
    --bin wzp-desktop inside the container
  - Uploads the resulting .exe to rustypaste (via the .env token on
    the remote, same as android script) and fires ntfy.sh/wzp
    notifications at start + completion
  - scp's the .exe back to target/windows-exe/wzp-desktop.exe locally
  - --image-build flag triggers a fire-and-forget `docker build` of
    the Dockerfile.windows-builder on the remote (used once after the
    Dockerfile changes). The image is already built at the moment of
    this commit — sha256:f3895cb2fde7

scripts/build-windows-cloud.sh
  Kept as an alternative cross-compile path using a fresh Hetzner VM
  (cx33, 8 vCPU, 8 GB — bumped from cx23 after the smaller size OOM'd
  mid-rustc). The docker-on-SepehrHomeserverdk path is now the
  preferred fast path because the image has a pre-warmed xwin cache
  and a persistent cargo target volume, making warm builds ~3 minutes
  vs the cloud path's ~20 minutes cold each run. The cloud script
  stays around for when we want a truly isolated environment.

Both scripts notify via ntfy.sh/wzp and upload to paste.dk.manko.yoga
so the user can pick up the artefact + see status without polling.
2026-04-10 12:35:02 +04:00
Siavash Sameni
d774f5f8c5 feat(history): dedupe by call_id + explicit Incoming/Outgoing/Missed labels
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Has been cancelled
User reported that outgoing direct calls from macOS show up in the
history list as "missed" even when the call completes successfully.
Adds two changes to fix / diagnose:

1. history::log now dedupes by call_id. If an entry for this call_id
   already exists in the store, it updates the existing row's
   direction + timestamp in place instead of appending a duplicate.
   Protects against double-emit (caller side adding Missed on top of
   Placed, or any future signal loop that fires twice). One row per
   call_id, which matches what the user intuitively expects.

2. history::log now logs every write with tracing::info — call_id,
   peer_fp, direction, alias. Plus an extra line when we replace
   an existing entry: "history::log replacing existing entry
   from=Placed to=Missed" etc. Makes it easy to see in the desktop
   stderr which side is writing what, so we can find the outgoing =>
   missed regression immediately if it recurs.

3. main.ts now renders an explicit text label next to the direction
   arrow: "Outgoing", "Incoming", or "Missed" instead of just the ↗
   ↙ ✗ icons. Removes any ambiguity about what the icon means so
   future users can't misread a Placed entry as Missed based on icon
   shape alone.

Side fix for scripts/build-windows-cloud.sh:
- die() and the do_full ERR trap now respect WZP_KEEP_VM=1 so a failed
  build doesn't auto-destroy the debug VM (previously the trap fired
  before the KEEP_VM check and tore down the VM on any error).
- Bump default server type cx23 → cx33. 4GB RAM is not enough for a
  cold tauri + rustls + quinn + wzp-client cross-compile — the cx23
  run got "Read from remote host ... Connection reset by peer"
  partway through rustc, which is the classic signature of an OOM
  kill on the SSH session. cx33 has 8GB RAM and 8 vCPU which should
  comfortably fit the build.
2026-04-10 12:34:19 +04:00
Siavash Sameni
2fd94651e4 fix(desktop): direct calls used wrong identity file — mac identity mismatch
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m40s
The non-Android branch of CallEngine::start loaded the seed from
\$HOME/.wzp/identity directly, while register_signal in lib.rs goes
through the shared load_or_create_seed() helper which resolves via
APP_DATA_DIR → Tauri's app_data_dir(). On macOS those are two
completely different files:

  register_signal → ~/Library/Application Support/com.wzp.desktop/.wzp/identity
  CallEngine::start (old) → ~/.wzp/identity

On a fresh install they end up holding two different random seeds.
Register and CallEngine then derive two different fingerprints from
those seeds, and when a direct call comes in the relay routes it to
"you" under the register_signal fingerprint, but once CallEngine tries
to join the call-* room it advertises a DIFFERENT fingerprint — which
fails the call_registry ACL check on the relay side (only the two
authorised participants of a call can join its room). Silent hang, the
call never completes.

Android hit this bug earlier in the week and was fixed by switching
its CallEngine::start branch to `crate::load_or_create_seed()`.
Backport the same single-line change to the desktop branch so both
platforms share one identity source of truth.

Also bring the desktop branch up to parity with the android branch on
diagnostic logging:
- log CallEngine::start entry with relay/room/alias/quality/has_reuse
- log endpoint.local_addr on reuse / create
- log "QUIC connection established, performing handshake" between
  connect() and perform_handshake() so a hang at either step is
  immediately localisable
- map_err all three potential failure points (create_endpoint,
  connect, perform_handshake) to an explicit error! trace
2026-04-10 12:15:23 +04:00
Siavash Sameni
da09fdb6e9 windows(desktop): gate coreaudio / VoiceProcessingIO to macOS-only targets
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m34s
First step of the Windows x86_64 desktop build: stop pulling
coreaudio-rs into the Windows dependency graph so the project can at
least run `cargo check --target x86_64-pc-windows-msvc`. Software AEC
is already disabled in engine.rs so there's nothing else to stub — the
macOS-specific VPIO path is skipped via #[cfg(target_os = "macos")] on
both sides and Windows falls through to the plain CPAL
AudioCapture/AudioPlayback branch that already existed.

crates/wzp-client/Cargo.toml
  - coreaudio-rs optional dep moved under [target.'cfg(target_os = "macos")']
  - `vpio` feature now uses `dep:coreaudio-rs` syntax and the gated dep
  - Enabling `vpio` on Windows/Linux is a no-op at resolution time

crates/wzp-client/src/lib.rs
  - `pub mod audio_vpio` is now #[cfg(all(feature = "vpio", target_os = "macos"))]
  - Previously `vpio` alone was enough to try to compile the Core Audio
    bindings, which would fail on non-Apple targets the moment the
    feature flag was flipped on

desktop/src-tauri/Cargo.toml
  - [target.'cfg(not(target_os = "android"))'] removed — was leaking
    vpio into Windows/Linux via the catch-all.
  - macOS: wzp-client with features = ["audio", "vpio"]
  - Windows: wzp-client with features = ["audio"]
  - Linux: wzp-client with features = ["audio"]
  - Android: wzp-client with default-features = false (unchanged)
  - Dropped the unused direct coreaudio-rs = "0.11" dep on macOS —
    wzp-desktop's own sources never call Core Audio directly.

Verified via `cargo tree --target x86_64-pc-windows-msvc -p wzp-desktop`
that the Windows target now resolves wzp-client with cpal but without
coreaudio-rs. macOS target still resolves with coreaudio (direct via
vpio feature and transitively via cpal). macOS `cargo check` still
builds cleanly.

Cross-compile from macOS hit a cargo-xwin + llvm-lib setup issue in
ring's build.rs, so the actual `cargo check --target
x86_64-pc-windows-msvc` did not complete locally. Build verification
belongs on the user's Windows x86_64 host where MSVC is present
natively.

See tasks #23 (this one), #24 (Voice Capture DSP / WASAPI Communications
for OS-level AEC on Windows), and #25 (aarch64-pc-windows-msvc support).
2026-04-10 11:12:08 +04:00
Siavash Sameni
510eae2089 feat(direct-call): call history, recent contacts, deregister button
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m41s
Persistent JSON-backed call history for the direct-call screen so users
can see what they've placed / received / missed and dial back with one
click. Also fixes two small latent UX issues reported alongside.

Backend (Rust)
- new crate/module desktop/src-tauri/src/history.rs: thread-safe in-
  process store (OnceLock<RwLock<Vec<CallHistoryEntry>>>) backed by
  <APP_DATA_DIR>/call_history.json. Atomic writes via temp+rename. Max
  200 entries, FIFO pruning. CallDirection { Placed, Received, Missed }.
- Log hooks in the signal loop + commands:
    * place_call     → Placed entry (with target fingerprint)
    * DirectCallOffer → Missed entry up front; upgraded to Received
                        inside answer_call when accept_mode != Reject
                        via history::mark_received_if_pending(call_id).
                        If user rejects or never answers, it stays Missed.
- New Tauri commands:
    * get_call_history()     → all entries, newest first
    * get_recent_contacts()  → unique peers by fp, newest interaction first
    * clear_call_history()   → wipes JSON + in-memory
    * deregister()           → tears down signal transport + endpoint
  Backend emits `history-changed` events so the UI can live-refresh
  without polling.

Frontend (main.ts + index.html + style.css)
- Direct-call panel now has:
    * Recent contacts chip row (top 6 unique peers). Click a chip → dial.
    * Call history list (up to 50 rows). Direction icon (↗ placed, ↙
      received, ✗ missed), peer alias/fp, relative timestamp, callback
      button. Both click handlers populate target-fp and fire place_call.
    * Deregister button in the "registered" header — calls the new
      deregister command, tears down the signal transport, returns the
      UI to the pre-register state.
    * Clear-history link in the history header.
- Subscribes to `history-changed` events so the list updates the moment
  the backend logs a new entry. Also refreshed on register + after a
  clear.
- Nothing is rendered until there is data — empty sections stay hidden.

Tasks #20 + #21 (small UX items bundled in)
- Default room "general" for new installations: the html input value
  attribute is now "general" and loadSettings() defaults match. Existing
  users' localStorage still wins.
- Random alias on desktop: already latent but confirmed working — the
  startup IIFE at main.ts:374 calls get_app_info() and prefills the
  alias input from derive_alias(seed) when the input is empty. No code
  change needed, just verified it flows through the same path as the
  Android client.

Known follow-ups (deferred to step 6 polish)
- Call duration tracking (currently all entries have no duration field)
- Hangup signal from an unanswered incoming should emit history-changed
  so the missed state is visible even when the user never tapped accept
- Android UI layout fit-check on the smaller Nothing screen
2026-04-10 11:03:36 +04:00
Siavash Sameni
76a4c53e21 fix(android-audio): spawn_blocking for Oboe restart — unblock tokio executor
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Build 4c6aac6 added a stop+sleep+start Oboe restart inside the
set_speakerphone Tauri command, but calling wzp_native::audio_stop()
and audio_start() synchronously from an async fn blocks the tokio
executor thread — those FFI calls wait for AAudio to finalise the
stream teardown/bringup, which takes ~400ms each on Nothing phone
(Pixel is fast enough to hide the bug).

Reproduced on Nothing: 7 rapid Speaker button clicks across ~30
seconds, each restarting Oboe. After the 5th click the engine send
and recv tokio tasks froze for 22 seconds — decoded_frames stuck at
1159 across 9 heartbeats, send_drops growing from 148 to 1720 as
encoded frames couldn't make it past `send_t.send_media(pkt).await`.
At 08:40:48 the runtime finally caught up and processed a 911-frame
burst at once (buffered QUIC datagrams flooding through). Classic
"blocking sync call in async context" anti-pattern.

Fix: run the stop + start sequence inside tokio::task::spawn_blocking
so the Oboe teardown + reopen happens on a dedicated blocking thread,
leaving the tokio runtime free to keep driving the send and recv
tasks. AAudio's requestStop returns only after the stream is actually
in Stopped state, so the explicit sleep that bridged stop and start
is no longer needed and is dropped.

Send and recv tasks still see a ~500ms window of empty reads /
partial writes during the blocking restart, but they get SCHEDULED
through it — network packets keep being received + decoded + dropped
into the playout ring, and captured mic samples keep being encoded +
sent through quinn. No more executor starvation, no more 22-second
audio dropouts, no more send_drops burst.

Pixel still worked before this fix only because its AAudio teardown
is fast enough to not exceed the scheduler's cooperative yield
interval — same bug was latent on both devices, Nothing just made it
visible.
2026-04-10 08:45:54 +04:00
Siavash Sameni
4c6aac654a fix(android-audio): restart Oboe on speakerphone toggle + unbreak button UI
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m25s
Build 4f2ad65 wired the Speaker button to AudioManager.setSpeakerphoneOn
but user testing found that flipping speakerphone on an active Oboe
VoiceCommunication stream silently tears down the AAudio streams on
Pixel-class devices — both capture and playout stop producing data.
Only ending the call and rejoining brings audio back (because the fresh
Oboe open runs with the new routing already applied).

Also the earpiece state showed up red in the UI because the button was
getting the `.muted` CSS class when speakerphoneOn=false. Earpiece is a
valid routing state, not a muted one.

Fix set_speakerphone Tauri command:
  1. Flip AudioManager.setSpeakerphoneOn via JNI (as before).
  2. If the Oboe backend is currently running, stop it, sleep 50 ms to
     let AAudio finalise the transition, then start it again. The Rust
     send/recv tokio tasks keep running across the gap — they just read
     zero samples and write into the preserved ring buffers for a few
     frames, which is acceptable. The AudioBackend singleton's ring
     state is preserved across stop+start because it's in a 'static
     OnceLock.
  3. Debounce the UI click via speakerphoneBusy + spkBtn.disabled so
     users can't queue up multiple toggles during the restart window.

Fix main.ts Speaker button:
  - Remove the `.muted` classList toggle (added `.speaker-on` for CSS).
  - Update label text to "🔊 Speaker" / "🔈 Earpiece" for clarity.
  - On showCallScreen(), invoke is_speakerphone_on to sync the label
    with the real AudioManager state, so it matches reality after a
    rejoin (which was another symptom the user hit — the button label
    desynced from the actual routing after ending and restarting a
    call).
  - Debounce click + disable button while the restart is in flight.

Drops #[allow(dead_code)] from wzp_native::audio_is_running now that it
is actually called from the set_speakerphone restart guard.
2026-04-10 07:35:12 +04:00
Siavash Sameni
4f2ad65418 fix(android_audio): add explicit pointer types for .cast() — was rejected by rustc E0282 on android target
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 4m6s
2026-04-09 22:02:48 +04:00
Siavash Sameni
0178cbd91d android(audio): Speaker button toggles earpiece↔speaker via JNI (WIP, untested)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Has been cancelled
Build 9e37201 confirmed on-device that Usage::VoiceCommunication +
MODE_IN_COMMUNICATION + speakerphoneOn=false routes Oboe playout to the
handset earpiece and the callback drains the ring correctly. Next step:
let the user flip speakerphoneOn at runtime so the existing Speaker
button actually switches audio routing instead of just gating writes.

- Cargo.toml (android target): pull in `jni = 0.21` and
  `ndk-context = 0.1`. Both are already transitively in the lockfile
  via Tauri/Wry, so this just promotes them to direct deps.
- desktop/src-tauri/src/android_audio.rs: new module. Grabs the JavaVM +
  current Activity from `ndk_context::android_context()`, attaches a
  JNI thread, calls `activity.getSystemService("audio")` to get the
  AudioManager, and exposes `set_speakerphone(bool)` +
  `is_speakerphone_on()` helpers that call the AudioManager method of
  the same name. All gated behind `#[cfg(target_os = "android")]`.
- lib.rs: adds `mod android_audio;` (android only), two new Tauri
  commands `set_speakerphone(on)` and `is_speakerphone_on()` — desktop
  gets no-op stubs so the same frontend invoke() works everywhere.
  Both registered in the invoke_handler.
- desktop/src/main.ts: the Speaker button (previously toggled the
  playout-write gate via `toggle_speaker`) now calls `set_speakerphone`
  and reads back the new routing state. Labels switched from
  "Spk" / "Spk Off" to "Earpiece" / "Speaker" so users can't be
  confused into thinking clicking turns audio off. pollStatus no longer
  clobbers the spkBtn label based on engine spk_muted, since the two
  concepts are now decoupled.

WIP because this has NOT been built or tested yet — committing at night
to save the work. Tomorrow: build #50 with this change, smoke-test the
Handset↔Speaker toggle, then move on to call history + last-contacts UI
and the Speaker-button mute bug on the other phone.
2026-04-09 22:00:34 +04:00
Siavash Sameni
9e37201198 android(audio): Usage::VoiceCommunication + MODE_IN_COMMUNICATION, default handset
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
With da106bd (Usage::Media + MODE_NORMAL) audio works but is always on
the loudspeaker — we want handset as the default with a user-driven
toggle for speaker (and later bluetooth). The right Oboe usage for a
VoIP app is VoiceCommunication, which honours
AudioManager.setSpeakerphoneOn / setBluetoothScoOn for routing.

Bisection across previous builds showed that setAudioApi(AAudio) +
Usage::VoiceCommunication made the playout callback stop draining the
ring after cb#0 (build 8c36fb5 logs). Letting Oboe pick the AudioApi
implicitly keeps the callback alive — 96be740's Media-usage callbacks
fired at steady 50Hz without any explicit setAudioApi. So: keep the
Usage change, DROP the explicit AAudio force.

- oboe_bridge.cpp: Usage::VoiceCommunication, no setAudioApi, no
  ContentType override.
- MainActivity.kt: setMode(MODE_IN_COMMUNICATION) +
  setSpeakerphoneOn(false) = handset default, plus max both
  STREAM_VOICE_CALL and STREAM_MUSIC volumes for belt-and-braces.

Next build will add a JNI-based Tauri command to flip speakerphoneOn
at runtime so the user can toggle handset↔speaker during a call.
2026-04-09 21:50:06 +04:00
Siavash Sameni
da106bd939 fix(android-audio): revert to 96be740's Oboe config — VoiceCommunication broke callback drain
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m45s
Build 8c36fb5 logs showed a new regression: Oboe playout cb#0 fires once
at startup then the callback STOPS DRAINING the ring entirely.
written_samples sticks at 7679 (= RING_CAPACITY - 1) across every recv
heartbeat in a 40-second test. Meanwhile the recv task decodes 1800+ real
audio frames (sample range up to [-27920..31907], rms 12065) which all
get dropped on the floor by audio_write_playout returning 0 because the
ring is full.

Bisection: 96be740 (Usage::Media, no setAudioApi, no ContentType, no
MainActivity audio mode change) DID drive the playout callback at the
expected 50Hz (playout heartbeat: calls=1100 total_played_real=1055040
over 22 seconds). User still heard nothing there because of OS routing,
but at least Oboe accepted the PCM.

8c36fb5 added three changes on top of 96be740:
  1. Oboe Usage::Media → Usage::VoiceCommunication
  2. Oboe setAudioApi(oboe::AudioApi::AAudio) explicit
  3. Oboe setContentType(ContentType::Speech)
  4. MainActivity setMode(MODE_IN_COMMUNICATION) + setSpeakerphoneOn(true)
Every one of those could have killed the callback; combined they did.

Revert to 96be740's exact Oboe config: Usage::Media, no setAudioApi, no
ContentType. Keep the PCM recorder, heartbeat logging, and stream-open
logging. Separately, MainActivity now maxes STREAM_MUSIC (the stream
Usage::Media routes to) but leaves audio mode in MODE_NORMAL — no more
speakerphone/call-mode combo that makes Oboe unhappy. In NORMAL mode a
STREAM_MUSIC stream plays through the loud speaker by default.

Proof that the Rust pipeline is perfect: decoded.pcm recorded in 8c36fb5
was pulled via `adb shell run-as com.wzp.desktop cat .wzp/decoded.pcm`,
converted with ffmpeg, and played back on the Mac — user confirmed
audible speech. So 100% of the remaining bug surface is Android audio
routing, not anything in the Rust/C++ decode path.
2026-04-09 21:38:19 +04:00
Siavash Sameni
8c36fb5651 fix(wzp-native): Oboe ResultWithValue has no value_or, unfold explicitly
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m55s
cc-rs build of oboe_bridge.cpp failed at cfa9ff6 because the Oboe
ResultWithValue<T> template returned by getXRunCount() does not have
a .value_or(T) method — only .value(). Replace with an explicit
bool-conversion + .value() guard that yields -1 on error.
2026-04-09 21:25:38 +04:00
Siavash Sameni
cfa9ff67cf fix(android-audio): VoIP mode + speakerphone + debug PCM recorder
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Has been cancelled
Build 96be740 logs proved the entire software pipeline is healthy:
  capture heartbeat:   calls=1100 to_write=960 full_drops=0 total_written=1056000
  recv heartbeat:      decoded_frames=1035 last_written=960 decode_errs=0
  recv decoded PCM:    range=[-13564..9244] rms=8044 (real audio)
  playout WRITE:       in_len=960 written=960 rms=2318 (real audio into the ring)
  playout heartbeat:   calls=1100 nonempty=1099 total_played_real=1055040

1055040 samples / 48000 Hz = 22s — exactly matches wall-clock elapsed,
meaning Oboe IS calling our playout callback at the expected rate and
WE ARE handing it real PCM every 20ms. User still heard nothing. Ergo
Oboe accepted the PCM and routed it to a silent output. Two fixes:

1) MainActivity.kt: switch to MODE_IN_COMMUNICATION + speakerphone ON
   right after permissions are granted, and crank STREAM_VOICE_CALL to
   max. Without this, an Oboe Usage::VoiceCommunication stream gets
   opened, the OS creates a real AAudio pipeline, the callback fires on
   schedule — and audio goes to either the earpiece at muted volume or
   a "call not active" dead end. Logs the audio mode + volume levels
   before and after the switch so we can confirm the state change in
   logcat next run.

2) oboe_bridge.cpp: revert Usage::Media → VoiceCommunication (the mode
   that matches MODE_IN_COMMUNICATION), pin the audio API to AAudio
   explicitly instead of letting Oboe fall back to OpenSLES (which has
   its own silent-drop failure modes on some devices), and add getState
   + getXRunCount to the playout heartbeat so we'll see silent stream
   disconnects instead of reading zeros forever.

3) engine.rs recv task: dump the first ~10s of post-AGC decoded PCM to
   `<app_data_dir>/decoded.pcm` as raw i16 LE so we can adb pull it and
   play it back locally:
       adb shell run-as com.wzp.desktop cat .wzp/decoded.pcm > decoded.pcm
       ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav
   This divorces "is our decoder actually producing audible audio" from
   "is Android's audio stack playing it". If the recorded WAV sounds
   correct when played on a laptop, the decoder is fine and 100% of the
   remaining bug surface is AudioManager / Oboe routing.

4) engine.rs: also log when spk_muted=true blocks the write. User
   reported the Speaker button in the UI has inconsistent semantics
   between desktop and android — adding this log rules out the accidental
   "first click muted playback" theory for good.
2026-04-09 21:24:26 +04:00
Siavash Sameni
96be740fd9 diag(android-audio): aggressive logging across the whole Oboe pipeline
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
User confirmed: mac hears android, android does not hear mac. So Oboe
capture works end-to-end but Oboe playout on Android silently drops
audio even though QUIC forwards the packets. Archaeology on the legacy
wzp-android crate also revealed that the "last known good" Android audio
path NEVER used Oboe in production — it used Kotlin AudioRecord +
AudioTrack via JNI, and cpp/oboe_bridge.cpp was dead code. So every time
we've "tested" Oboe end-to-end this week was the first production use,
and any of its config knobs could be the bug.

Instrumenting every stage of the pipeline so one smoke-test log dump can
isolate the layer at fault:

C++ (oboe_bridge.cpp)
  - Log the ACTUAL stream parameters after openStream for both capture
    and playout (sample rate, channels, format, framesPerBurst,
    framesPerDataCallback, bufferCapacityInFrames, sharing, perf mode).
    Oboe may silently override values we requested — e.g. if we ask for
    48kHz mono but the device gives us 44.1kHz stereo our 960-sample
    frames are the wrong duration and the pipeline drifts.
  - Capture callback: on cb#0 log sample range+RMS of the first frame
    to prove we get real mic data (not zeros). Every 50 callbacks
    (~1s at 20ms burst) log calls, numFrames, ring available_write,
    bytes actually written, ring_full_drops, total_written.
  - Playout callback: on cb#0 log numFrames + ring state. On the FIRST
    non-empty read log sample range+RMS so we can tell if the samples
    coming out of the ring are real audio or zeros. Every 50 callbacks
    log calls, nonempty count, numFrames, ring available_read,
    underrun_frames, total_played_real.

Rust wzp-native (src/lib.rs)
  - wzp_native_audio_write_playout now logs the first 3 writes and then
    every 50th: in_len, written, sample range, RMS, ring write/read
    cursors before, available_read and available_write after. Reveals
    ring-overflow and whether the engine is actually handing us audio.
  - Minimal android logcat shim via __android_log_write extern — no
    new crate dependency.
  - AudioBackend grows a `playout_write_log_count` AtomicU64 to gate
    the write-side log throttle.

Rust engine.rs (android branch)
  - Recv task: log sample range + RMS for the first 3 decoded PCM
    frames and then every 100th. Reveals whether decoder.decode is
    producing real audio or silent buffers.
  - Recv task: if audio_write_playout returns fewer samples than we
    handed it (partial write → ring nearly full) warn about it in the
    first 10 frames.
  - Recv heartbeat every 2s: recv_fr, decoded_frames, last_decode_n,
    last_written, written_samples, decode_errs, codec.

Expected flow in a healthy log:
  capture cb#0: numFrames=960 range=[-1200..900] rms=180          ← mic OK
  capture stream opened: actualSR=48000 Ch=1 ...                   ← no override
  playout stream opened: actualSR=48000 Ch=1 ...
  CallEngine::start invoked ... → connected → audio started
  recv: first media packet received ...
  recv: decoded PCM sample range decoded_frames=1 range=[-300..250] rms=92
  playout WRITE #0: in_len=960 written=960 range=[-300..250] rms=92
  playout FIRST nonempty read: to_read=960 range=[-300..250] rms=92
  playout heartbeat: calls=50 nonempty=50 underrun=0 ...
  recv heartbeat: decoded_frames=100 last_written=960 ...

If any of those are missing/zero we know the exact stage to fix.
2026-04-09 21:13:29 +04:00
Siavash Sameni
8c4d640f89 fix(android): playout Usage::Media + relay CallSetup advertises real IP
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m43s
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.
2026-04-09 20:55:10 +04:00
Siavash Sameni
49f101d785 fix(android): reuse signal endpoint for direct-call media connection
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Direct-call accept hangs forever at the QUIC handshake on Android. Logs
from d7b37a5 showed:
  CallEngine::start (android) invoked relay=172.16.81.172:4433 room=call-…
  resolved relay addr
  identity loaded
  endpoint created, dialing relay   ← reached
                                    ← nothing, 90s+, no error
The "connect failed" and "QUIC connection established" log lines never
fire, meaning endpoint.connect_with(…).await never makes progress.

Repro is 100%: SFU room join (one endpoint) works perfectly; direct call
(opens a SECOND quinn::Endpoint on top of the signal one) hangs in the
QUIC handshake. Creating two quinn::Endpoints on Android's AAudio-adjacent
UDP stack apparently causes the second one's datagrams to never reach the
relay (the server never sees the Initial packet). Rather than fight the
platform, quinn is happy to multiplex multiple Connections on a single
Endpoint — so we reuse the signal endpoint for the media connection.

- SignalState now stores the quinn::Endpoint alongside the QuinnTransport.
  register_signal populates both at the same time.
- CallEngine::start (both android and desktop branches) takes an
  Option<wzp_transport::Endpoint>. Some → reuse (direct-call path, after
  register_signal). None → create fresh (SFU room join path).
- The connect tauri command reads state.signal.endpoint and threads it
  through to CallEngine::start, so the direct-call auto-connect (fired by
  the "setup" signal-event in main.ts) lands on the existing UDP socket.
- wzp_transport re-exports quinn::Endpoint so wzp-desktop doesn't need to
  depend on quinn directly.
- Also wraps the android connect in tokio::time::timeout(10s) so future
  hangs become deterministic "connect TIMED OUT" errors in logcat
  instead of silent deadlock.

Same fix applies verbatim to the desktop client — the user suspects
direct call is broken there too and this was likely always the cause,
just never surfaced because desktop was only tested via SFU rooms.
2026-04-09 20:29:51 +04:00
Siavash Sameni
d7b37a5749 diag: tracing for direct-call signal loop + CallEngine::start stages
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
User reports tapping "answer" on an incoming direct call does nothing
visible, and suspects the same may affect desktop. The signal recv loop
had no tracing at all, so we can't tell whether CallSetup is being
received, whether the recv loop died silently, or whether
CallEngine::start is failing between "identity loaded" and
"connected to relay, handshake complete".

- register_signal recv loop now logs every message type with fields
  (CallRinging, DirectCallOffer, DirectCallAnswer, CallSetup, Hangup,
  unhandled), plus a warn! on recv errors and a final warn when the
  loop exits.
- place_call / answer_call commands log entry + success / error. The
  answer_call error path logs the underlying send_signal error so we
  can see it in logcat instead of only in the JS error toast.
- CallEngine::start android branch logs relay/room/alias on entry,
  logs "endpoint created, dialing relay" between create_endpoint and
  connect, "QUIC connection established, performing handshake" between
  connect and perform_handshake, and promotes all three potential
  failures to explicit error! logs so a silent hang / error becomes
  visible in logcat.

No functional changes — pure diagnostics. Stacks on b35a6b7 (the Oboe
stack-pointer-escape fix) so build #43 carries both.
2026-04-09 19:17:03 +04:00
Siavash Sameni
b35a6b7d92 fix(wzp-native): copy WzpOboeRings by value, not by pointer
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m41s
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
  ...
2026-04-09 19:11:16 +04:00
Siavash Sameni
0105b0fbf3 phase 3(android): RECORD_AUDIO permission + runtime request in MainActivity
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 4m0s
Oboe fails silently to open the AAudio input stream without
android.permission.RECORD_AUDIO, so the call audio would never actually
flow even after phase 3's engine wiring.

- AndroidManifest.xml: declare RECORD_AUDIO and MODIFY_AUDIO_SETTINGS, and
  android.hardware.microphone as a required feature. These files are the
  cargo-tauri-generated scaffold — nothing in .gitignore excludes them, so
  the intended Tauri 2 mobile workflow is to commit them once populated.

- MainActivity.kt: override onCreate to call ActivityCompat.requestPermissions
  for the audio perms on first launch. The dialog shows exactly once; the
  grant is persisted per-package. onRequestPermissionsResult logs the
  outcome so we can spot failures in logcat.

A full native Tauri permission plugin integration is deferred to
Step 6 (polish) together with notifications, icon, and background service.
2026-04-09 19:00:12 +04:00
Siavash Sameni
5beea7de40 phase 3(android): unify connect/disconnect/toggle_*/get_status commands
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
Step 3 of the Tauri Android rewrite was still returning "audio backend not
yet wired on Android (step 3)" because the cfg-gated Android stubs for
connect/disconnect/toggle_mic/toggle_speaker/get_status were shadowing the
real commands. Now that CallEngine::start() has a real Android body (phase
3, commit fdbe502), the gates are unnecessary.

- Drop the #[cfg(not(target_os = "android"))] gates from all five
  engine-backed Tauri commands.
- Delete the Android stub block (~50 LOC of "not connected" boilerplate).
- Ungate `use engine::CallEngine;` and the AppState.engine field so both
  targets share the same Mutex<Option<CallEngine>>.
- CallEngine::stop() now calls crate::wzp_native::audio_stop() on Android so
  the mic + speaker are released between calls, matching the desktop
  behaviour where dropping _audio_handle tears down CPAL.

Direct-call flow on Android: peer sends DirectCallOffer → user accepts via
answer_call → relay sends signal "setup" event → main.ts auto-invokes
connect(relay, room) → CallEngine::start() runs the Android branch →
wzp_native::audio_start() brings up Oboe → send/recv tasks stream PCM
through the dlopen boundary.
2026-04-09 18:53:54 +04:00
Siavash Sameni
fdbe502524 phase 3(android): wire CallEngine::start to wzp-native audio FFI
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
Replaces the Android-side CallEngine::start() stub with a real implementation
that mirrors the desktop start() body but routes all PCM through the
standalone wzp-native cdylib loaded at startup via libloading instead of
using CPAL.

- desktop/src-tauri/src/wzp_native.rs: new module with a static
  OnceLock<libloading::Library> + cached raw fn pointers for every symbol
  we need (version, hello, audio_start/stop, read_capture, write_playout,
  is_running, capture/playout_latency_ms). init() resolves everything once
  at startup; accessors return default values if init() never ran.

- desktop/src-tauri/src/lib.rs: drop the inline dlopen smoke test, add
  `mod wzp_native;` behind target_os="android", and invoke
  wzp_native::init() from the Tauri setup() callback so the library is
  loaded + all symbols cached before any CallEngine can touch audio.

- desktop/src-tauri/src/engine.rs: the Android #[cfg] branch of
  CallEngine::start() now does the full QUIC handshake + signal loop +
  Opus send/recv tasks, calling wzp_native::audio_start() /
  audio_read_capture() / audio_write_playout() instead of the desktop
  CPAL rings. SyncWrapper now holds a placeholder Box<()> on Android
  because the audio backend lives in a process-global singleton inside
  libwzp_native.so rather than being owned per-engine.

Next step: build #39 on the remote docker builder and smoke-test on
Pixel 6 that the Connect button in the UI successfully brings up Oboe
and streams audio through the dlopen boundary.
2026-04-09 18:42:27 +04:00
Siavash Sameni
c769a476a2 phase 2(android): port Oboe C++ bridge + audio FFI into wzp-native
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m56s
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>
2026-04-09 18:12:01 +04:00
Siavash Sameni
7cc53aedc7 refactor(android): split C++ into wzp-native cdylib, loaded at runtime
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m34s
Phase 1 of the big refactor. Escape the Tauri Android
__init_tcb+4 symbol leak (rust-lang/rust#104707) by making
wzp-desktop's Android .so pure Rust — ZERO cc::Build, no cpp/ files,
no C++ in the rustc link step. All future C++ (Oboe audio bridge)
lives in a new standalone cdylib crate `wzp-native` which is built
with cargo-ndk (the same path the legacy wzp-android crate uses
successfully on the same phone + same NDK), copied into Tauri's
gen/android/app/src/main/jniLibs at build time, and dlopened by
wzp-desktop at runtime via libloading.

Changes in this commit:
- NEW crate crates/wzp-native/ with crate-type = ["cdylib"] only
  (no staticlib, no rlib — rust#104707 shows mixing staticlib with
  cdylib leaks non-exported symbols, which is the original bug
  source). Phase 1 scaffold has TWO extern "C" functions:
    wzp_native_version() -> i32            (returns 42)
    wzp_native_hello(buf, cap) -> usize    (writes a string)
  So we can verify dlopen + dlsym + cross-.so FFI end-to-end
  before adding any real C++.
- desktop/src-tauri/cpp/ directory DELETED (7 files gone).
- desktop/src-tauri/build.rs reduced to just the git hash capture
  + tauri_build::build(). No more cc::Build of any kind.
- desktop/src-tauri/Cargo.toml: drop cc from build-dependencies,
  add libloading = "0.8" as an Android-only runtime dep.
- desktop/src-tauri/src/lib.rs Builder::setup() now (on Android only)
  dlopens libwzp_native.so, calls wzp_native_version() and
  wzp_native_hello(), and logs the result:
    "wzp-native dlopen OK: version=42 msg=\"hello from wzp-native\""
  If this log appears in logcat when the app launches and the home
  screen still renders, the split-cdylib pipeline is validated and
  Phase 2 (port the Oboe bridge into wzp-native) can proceed.
- scripts/build-tauri-android.sh: insert a `cargo ndk -t arm64-v8a
  build --release -p wzp-native` step before `cargo tauri android
  build`, with `-o desktop/src-tauri/gen/android/app/src/main/jniLibs`
  so the resulting libwzp_native.so lands in the place gradle will
  package into the final APK.
- Workspace Cargo.toml: add crates/wzp-native to [workspace] members.

Phase 2 (separate commit, only if Phase 1 works):
- Copy cpp/oboe_bridge.{h,cpp} + getauxval_fix.c from the legacy
  wzp-android crate into crates/wzp-native/cpp/.
- Add cc = "1" as a build-dependency on wzp-native (safe: it's a
  single-cdylib crate with no staticlib, so no symbol leak).
- Add build.rs that compiles the Oboe C++ and the wzp-native Rust
  FFI exposes the audio start/stop/read/write functions.
- wzp-desktop::engine.rs dlopens wzp-native at CallEngine::start,
  uses its audio functions instead of CPAL on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:02:53 +04:00
Siavash Sameni
711137da96 fix(android): -Wl,--exclude-libs,ALL + --no-whole-archive to stop symbol leak
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
llvm-nm on the crashing .so confirmed the research's smoking gun theory:

  000000000130c1f0 t _Z10__init_tcbP10bionic_tcbP18pthread_internal_t
  0000000000000000 a pthread_create.cpp
  0000000001331108 t pthread_create

All lowercase 't' (= LOCAL text symbols), zero UND dynamic references
for pthread_create. So rustc's link step is pulling bionic's own
pthread_create.cpp compilation unit out of libc.a as a whole-archive
inclusion and binding those symbols locally inside our .so, instead
of letting them stay UND and resolved against libc.so at dlopen time.

Rust's libstd thread::spawn then calls the LOCAL (broken) pthread_create
which calls the LOCAL __init_tcb with arguments set up for bionic's
static-executable layout — crashes at __init_tcb+4 with SEGV_ACCERR.

`-Wl,--exclude-libs,ALL` tells the linker to make symbols from static
archives NOT appear in the dynamic symbol table of the output .so.
`-Wl,--no-whole-archive` tells it to only pull archive objects that
satisfy undefined references, not include the whole archive blindly.

If this works, the symbol table should show pthread_create as UND
(or at least not locally bound) and the app should launch. If it
doesn't, the remaining fallback is the research's action #3 —
extract the C++ into its own upstream cdylib crate built with
cargo-ndk, and dlopen it from the Tauri cdylib at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:45:35 +04:00
Siavash Sameni
6071eb1b02 fix(android): drop staticlib from crate-type — root cause of __init_tcb crash
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
External research (per rust-lang/rust#104707) pointed at this as the
highest-probability cause of our byte-identical __init_tcb+4 /
pthread_create SIGSEGVs:

> Having 'staticlib' alongside 'cdylib' in crate-type leaks non-exported
> symbols from the staticlib into the cdylib's symbol table. For a
> Tauri Android cdylib, that means bionic's private pthread_create /
> __init_tcb code — which got pulled in statically from libc.a the
> moment any cc::Build C++ file added C++-linkage overhead — ends up
> bound LOCALLY inside our .so instead of being resolved dynamically
> against libc.so at dlopen time.

Symptoms that match the theory exactly:
- llvm-nm on the crashing .so shows __init_tcb and pthread_create as
  LOCAL symbols with C++ name mangling (bionic's own pthread_create.cpp)
- Adding any cc::Build cpp(true) step reliably triggers the crash,
  independent of which linker (android24-clang vs android26-clang) or
  which libc++ linkage (shared/static/none)
- The legacy wzp-android crate (["cdylib", "rlib"]) works fine on the
  same phone with the same NDK + Rust toolchain + Oboe C++ code
- tauri.conf.json bundle.android.minSdkVersion=26 propagates to
  gradle but the .so still crashes byte-identically

Drop 'staticlib' from crate-type. If we ever need it for iOS, re-add
behind a target.'cfg(target_os = "ios")' gate. The desktop binary
still links against the rlib, so the bin target on macOS/Linux/Windows
is unaffected.

Source: https://github.com/rust-lang/rust/issues/104707

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:38:49 +04:00
Siavash Sameni
c9cd043657 test: tauri.conf.json bundle.android.minSdkVersion=26 + cpp_smoke.cpp c++_shared
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1m26s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
User theory: tauri-cli hardcodes minSdkVersion=24 into its rustc
invocation regardless of gradle build.gradle.kts, .cargo/config.toml,
or env var overrides — but DOES read from tauri.conf.json's
bundle.android block. That would explain why every cc::Build C++
compile crashed with __init_tcb+4 via pthread_create: API-24 bionic's
.init_array routines for the linked-in .init_array clash with the
pthread_create state tao later expects.

This commit applies the fix AND re-adds the smallest known crashing
variant (E.1 with cpp_link_stdlib('c++_shared')) so the test has one
clear failure mode to compare against:

  tauri.conf.json bundle:
    "android": { "minSdkVersion": 26 }

  build.rs (on android target):
    - hello.c           (plain C, worked in Step A)
    - getauxval_fix.c   (plain C, worked in Step D)
    - hello2.c          (plain C, worked in Step D+1)
    - cpp_smoke.cpp     (C++ via cc::Build .cpp(true), crashed in E.1)

Also re-emits the libc++_shared.so copy into gen/android jniLibs so
the runtime linker can resolve the NEEDED entry cc-rs added via
cpp_link_stdlib('c++_shared').

If this launches → theory validated, proceed with Oboe integration.
If this crashes → need to keep digging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:58:37 +04:00
Siavash Sameni
6dd62c94c9 step D+1: add third trivial C static lib (hello2.c)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Step D (hello.c + getauxval_fix.c) launches cleanly. E.minus-1
(hello.c + getauxval_fix.c + cpp_smoke.c) crashes. All three are
plain-C trivial single-function files.

Theory: the regression is triggered by having 3 or more cc::Build
static libs in a Tauri Android cdylib, regardless of what the libs
contain. Test: clone hello.c as hello2.c (same content, different
symbol) and add a third cc::Build step compiling it. If this crashes,
the trigger is just the number of static libs. If it launches, there's
something magical about cpp_smoke.c specifically (unlikely — it was
near-identical content).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:51:50 +04:00
Siavash Sameni
4c998312aa regression check: revert build.rs to exact Step D state
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Verify the Step D baseline still launches after the environment mutations
we may have caused during the E bisection (docker image rebuild, tauri-cli
version drift, etc). Build.rs is now byte-identical to commit a852cad
(Step D) except for the git hash capture block that already existed at
that point.

If this launches cleanly → the cpp_smoke addition genuinely breaks
something, bisection continues.
If this crashes → the environment regressed between Step D and now,
and we need to rebuild the docker image to an earlier snapshot.
2026-04-09 16:45:34 +04:00
Siavash Sameni
22701830c2 step E.minus-1: cpp_smoke renamed to .c and compiled as plain C
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m53s
c++_shared crashed, c++_static crashed, no stdlib crashed. The remaining
variable isolated to cc::Build::new().cpp(true) itself is the C++
compile-mode invocation of clang++. Rename cpp_smoke.cpp → cpp_smoke.c
and drop .cpp(true), leaving a plain-C cc::Build that compiles the
exact same bytes (minus the 'extern "C"' linkage spec which is C++-
only syntax).

This is structurally identical to Step A (hello.c), which worked. If
THIS build launches, the diff between 'works' and 'crashes' is purely
the .cpp(true) mode — something clang++ does differently at compile
or link time when producing object files for a Tauri Android cdylib.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:38:29 +04:00
Siavash Sameni
47a037368c step E.0: drop cpp_link_stdlib entirely (no libc++ linkage)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
c++_shared crashed. c++_static also crashed. Both have libc++ code
landing in the final .so — one as a NEEDED dynamic lib, the other
bundled statically. So the trigger isn't the NEEDED entry specifically,
it's libc++ being present in any form.

cpp_smoke.cpp is just 'extern "C" int wzp_cpp_hello() { return 42; }'
with zero C++ features used, so we can drop cpp_link_stdlib completely
and the compile still succeeds. No libc++ .a or .so referenced at all.

If this crashes: the trigger is cc::Build::new().cpp(true) switching
rustc's final linker driver from clang to clang++ (which pulls in
different default libraries).

If this launches: the trigger is libc++'s own static initializers or
the libc++ code itself doing something that breaks our .so at dlopen
time, and we have a path forward — C++ code that doesn't need libc++
(e.g., a thin C++ bridge to Oboe that uses only POD types at the
boundary, with all the STL stuff confined to Oboe's own compilation
unit which would still need libc++...). More likely we still need a
C-only audio interface like raw AAudio via the ndk Rust crate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:31:53 +04:00
Siavash Sameni
191e8761d5 step E.1 variant: cpp_link_stdlib c++_shared → c++_static
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m42s
Every E.x variant crashed identically when linked with c++_shared, even
with a 3-line cpp file that's dead-stripped from the final .so. The
crash offsets are byte-identical across E.1, E.2, E.4, and the original
full-Oboe Step E. That points at a non-code link-time delta: the
`cargo:rustc-link-lib=c++_shared` directive that adds a NEEDED entry
for libc++_shared.so to the .so's dynamic table.

Swap to c++_static — bundles libc++ directly into our .so so the
NEEDED entry disappears. If this launches cleanly, we've conclusively
proven the NEEDED libc++_shared.so is the root cause and we have a
workable linkage for any C++ we want to add to the Tauri Android build
(including the eventual Oboe audio backend).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:18:04 +04:00
Siavash Sameni
0d74366592 step E.1: absolute minimum C++ file (no STL, no includes)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m53s
Last bisection step. cpp/cpp_smoke.cpp reduced to a single extern 'C'
function that returns 42. No #include, no std::atomic, no std::mutex,
no std::thread. Only C++ things remaining are:
  - cc::Build::new().cpp(true) in build.rs (C++ mode compile)
  - cpp_link_stdlib('c++_shared') emitting -lc++_shared

If this still crashes with the same __init_tcb+4 / pthread_create
stack, we've conclusively proven the trigger is NOT any C++ code
that ends up in the final .so (everything gets dead-stripped
anyway because Rust never references wzp_cpp_hello). The trigger
must be either:
  a) cargo:rustc-link-lib=c++_shared (adds NEEDED entry for
     libc++_shared.so in the .so's dynamic table, causing the
     dynamic linker to load libc++_shared.so at dlopen() time
     alongside our .so), or
  b) Some interaction between cpp(true) mode and the rest of the
     build pipeline (toolchain flags, symbol visibility, etc.)

After this build we stop and write an incident report for the
WarzonePhone Tauri Android rewrite bisection so far.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:21 +04:00
Siavash Sameni
0224ce654c step E.2: shrink cpp_smoke to std::atomic only — no thread, no mutex
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Incremental bisection within Step E. E.4 (atomic + mutex + thread) still
crashed at __init_tcb. Drop mutex and thread, keep only std::atomic.
Build.rs still emits cargo:rustc-link-lib=c++_shared via
cpp_link_stdlib('c++_shared'), so the NEEDED entry for libc++_shared.so
in the final .so stays identical. Goal: if this crashes, the issue is
purely the dynamic link against libc++_shared (not thread/mutex code).
If it passes, the issue is actually std::thread or std::mutex use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:47:30 +04:00
Siavash Sameni
aa240c6d83 step E.4(android): replace full Oboe compile with minimal C++ smoke file
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Bisection for the __init_tcb+4 crash that Step E introduced: drop the
full Oboe C++ build (200+ files, hundreds of KB of code) and replace
it with ONE tiny cpp/cpp_smoke.cpp that exercises the libc++ features
Oboe uses — std::atomic, std::mutex, std::thread — via an
extern "C" wzp_cpp_smoke() function that's exported but NEVER called
from Rust.

Still compiled with cpp_link_stdlib("c++_shared"), same as Oboe.
libc++_shared.so still copied into gen/android jniLibs. But no Oboe
headers, no Oboe source files, no -llog / -lOpenSLES links.

Hypothesis: if cpp_smoke.cpp alone reproduces the __init_tcb crash,
the trigger is "any libc++_shared link that references
std::thread/std::mutex" and Oboe is not the specific culprit. If it
launches cleanly, Oboe itself (its size, its static constructors, or
a specific header) is responsible — and we then bisect Oboe's
source tree.

fetch_oboe() and add_cpp_files_recursive() are retained in build.rs
with #[allow(dead_code)] so re-enabling the full Oboe compile is a
one-line edit once we've identified what's safe to include.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:39:30 +04:00
Siavash Sameni
d216dcc7a3 step E fix (Option 3): bake android24→26 clang shim into image
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Incremental Step E (commit 4250f1b) proved that merely compiling the
Oboe C++ bridge into libwzp_desktop_lib.so — with NO Rust-side FFI
bindings, no function calls — resurrects the __init_tcb+4 / pthread_
create SIGSEGV at WryActivity.onCreate. Bisection:

  build #17 (baseline)   ✓
  build #18 (Step A, hello.c)              ✓
  build #19 (Step B, wzp-client dep)       ✓
  build #21 (Step C, engine mod compiled)  ✓
  build #22 (Step D, getauxval_fix.c)      ✓
  build #23 (Step E, Oboe C++ compiled)    ✗ — __init_tcb+4 crash

Root cause: tauri-cli hard-codes `aarch64-linux-android24-clang` as the
Rust linker. Without any C++ code in the .so, libstd's pthread_create
reference gets resolved against the dynamic libc.so. The moment we add
a C++ static library that links against libc++_shared, the link-time
resolution pulls in the API-24 libc.a static pthread_create stub — and
Rust's libstd then also calls that stub instead of libc.so's real one.
The stub calls __init_tcb which SIGSEGVs because bionic's TCB state
only exists for static-libc main executables, not .so's loaded via
dlopen. API-26 NDK has proper dynamic bindings that resolve correctly.

Option 3 fix: at image build time, replace every NDK
aarch64-linux-android24-clang (and armv7/x86_64/i686, clang/clang++)
binary with a one-line shell script that exec()s the corresponding
android26-clang. Since tauri-cli invokes the linker via absolute path,
PATH and env var overrides fail — but replacing the binary on disk
inside the image is guaranteed to take effect. The legacy wzp-android
crate doesn't need this because cargo-ndk respects .cargo/config.toml
where a crate-level linker override is set.

Only changing the Dockerfile here. Next: rebuild the image no-cache,
retry Step E, and if the baseline holds, proceed to Steps F/G.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:17:34 +04:00
Siavash Sameni
4250f1b44a step E(android): compile full Oboe C++ bridge (not yet called from Rust)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m53s
Fifth incremental variable — and the first genuinely heavy one. Adds:
  - cpp/oboe_bridge.{h,cpp} (copied verbatim from crates/wzp-android/cpp/)
  - cpp/oboe_stub.cpp (fallback if Oboe can't be fetched)
  - build.rs now clones google/oboe@1.8.1 into OUT_DIR and compiles
    oboe_bridge.cpp + every .cpp file under oboe/src/ as a single
    static library via cc::Build, using shared libc++. Same logic as
    the legacy wzp-android build.rs.
  - libc++_shared.so gets copied from the NDK sysroot into the Tauri
    gen/android jniLibs directory so the runtime linker can find it.
  - rustc-link-lib=log / OpenSLES emitted for Oboe's Android backends.

Deliberately NOT called from Rust yet — no extern "C" FFI declarations,
no oboe_audio.rs module, the `wzp_oboe_*` symbols from the static lib
are simply present but unreferenced.

Goal: isolate whether the Oboe C++ compile + static lib link alone
(with its libc++ dependency and log/OpenSLES bindings) regresses the
working baseline. If the build still launches and renders the home
screen, we know the C++ side is clean and the actual regression is
caused by calling into Oboe at runtime (next step).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:09:16 +04:00
Siavash Sameni
a852cad15e step D(android): compile cpp/getauxval_fix.c alongside hello.c
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m55s
Fourth incremental variable. Adds the getauxval_fix.c shim from the
legacy wzp-android crate (which has been shipping with it for months
without issue) to our cc::Build on Android. The file defines a single
getauxval() function that delegates to bionic's real runtime
implementation via dlsym — this is needed because rustc links
compiler-rt's broken static getauxval stub that SIGSEGVs in .so
libraries loaded via dlopen (reads __libc_auxv which is NULL).

Not imported from Rust. Goal: verify that adding a second C static
archive (and especially one that overrides a libc-ish symbol) doesn't
regress the working build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:03:37 +04:00
Siavash Sameni
19fd3dd9cc step C fix: ungate wzp_proto imports used by resolve_quality() on Android
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Build #20 failed to compile on Android because I over-gated the
wzp_proto imports to non-Android. resolve_quality() is compiled on
every platform (it's outside the CallEngine impl) and references
QualityProfile + CodecId — both platform-independent types from
wzp_proto. Move those back to an unconditional import. tracing stays
gated (only the desktop start() body logs; the Android stub is silent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:59:00 +04:00
Siavash Sameni
c69195fe06 step C(android): compile engine.rs on Android with a stub CallEngine::start
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
Third incremental variable. Previously the engine module was cfg-gated
out of the Android build entirely (`#[cfg(not(target_os = "android"))]
mod engine;` in lib.rs). Now it's always compiled, so any link-time
effect of having engine.rs in the compilation unit can be measured
against the working baseline from build #19.

Changes kept deliberately small:
- lib.rs: drop the cfg gate on `mod engine;`. `use engine::CallEngine`
  stays gated because the Android-specific connect/disconnect/... stubs
  in lib.rs don't reference the type.
- engine.rs: the `wzp_client::{audio_io, call}` imports + CodecId +
  QualityProfile are gated to non-Android (they require the `audio`
  feature on wzp-client which Android doesn't pull in). On Android we
  keep only the MediaTransport import for transport.close(). The impl
  block now has two `start()` methods: the full CPAL-backed one for
  desktop, and a 6-line Android stub that returns `Err("audio engine
  not yet wired on Android")` so attempts to `connect` from the UI
  fail cleanly.

Goal: verify that linking in the compiled engine module (plus the
types it references) on Android doesn't regress the working baseline.
Home screen should still render and register_signal should still work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:56:02 +04:00
Siavash Sameni
ae4f366b05 step B(android): depend on wzp-client with default-features=false
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
Second incremental variable on the path to Oboe. Adds a
`[target.'cfg(target_os = "android")'.dependencies]` block that pulls
in wzp-client with NO features enabled — no audio (no CPAL), no vpio
(no VoiceProcessingIO). This gives the Android build access to
wzp-client's platform-independent modules (call, handshake, audio_ring,
codec wiring) without any system audio bindings.

Deliberately no new imports in lib.rs or engine.rs. The only effect
should be: cargo-tauri on Android now has to compile wzp-client and
all its transitive crates (wzp-codec, wzp-fec, wzp-proto, wzp-crypto
already pulled directly; now also audiopus, raptorq, etc.) and link
them into libwzp_desktop_lib.so.

Goal: verify that merely expanding the compiled code set to include
wzp-client doesn't regress the previous working state. If it does, we
know one of wzp-client's transitive deps is the problem — probably a
C dep like audiopus or codec2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:49:49 +04:00
Siavash Sameni
f96d7ce3e1 step A(android): add cc=1 build-dep + compile single trivial hello.c
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
First incremental variable on the path back to Oboe integration. Changes
are deliberately minimal: add cc = "1" to [build-dependencies] (cargo
build-deps resolve against the host so the line is unconditional), and
on the Android target run a single cc::Build step that compiles
cpp/hello.c — a 6-line file that defines one function (`wzp_hello_stub`)
that is never called from Rust.

Goal: verify that merely introducing a C static library into the .so
via cc::Build does not regress the working build (#17, commit 5309938
= build #6 behaviour: launches, renders home screen, registers on
relay). If this build still works, we know cc::Build pipelines alone
are fine and can move to the next variable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:45:24 +04:00
Siavash Sameni
530993854f revert(android): roll back to build #6 (35642d1) — pre-oboe known-good state
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Spent 10+ builds chasing a __init_tcb+4 / pthread_create SIGSEGV after
adding the oboe audio backend. Every "fix" made things worse. Reverting
all Android-specific files to the state at 35642d1 (build #6), which
was the last commit where the Tauri Android app actually launched,
rendered the home screen, and successfully registered on a relay.

Reverted files (all back to their 35642d1 content):
  - desktop/src-tauri/Cargo.toml        (no build-dep cc, no tracing-android)
  - desktop/src-tauri/build.rs          (git hash only, no Oboe / cc build)
  - desktop/src-tauri/src/lib.rs        (engine cfg-gated on non-android)
  - desktop/src-tauri/src/main.rs       (two-line desktop entry)
  - desktop/src-tauri/src/engine.rs     (desktop-only audio setup)
  - scripts/Dockerfile.android-builder  (no android24→26 clang shim)
  - scripts/build-tauri-android.sh      (no linker env vars / manifest patch)

Deleted (were added between b314138 and e2e023d):
  - desktop/src-tauri/cpp/getauxval_fix.c
  - desktop/src-tauri/cpp/oboe_bridge.{h,cpp}
  - desktop/src-tauri/cpp/oboe_stub.cpp
  - desktop/src-tauri/src/oboe_audio.rs

Next: rebuild image on remote (to drop the baked-in clang shim), build
an APK, install on Pixel 6, verify the UI renders the same way build #6
did. From there we add features back ONE at a time so we can actually
bisect which one triggers the tao::ndk_glue crash. User's rule:
"if you want to change stack, change incrementally, so we can debug".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:22:57 +04:00
Siavash Sameni
e2e023d2bc fix(android): drop pthread_shim — clang shim makes it unnecessary (and harmful)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m49s
Once the Dockerfile rewrites every android24-clang to exec android26-clang,
the linker uses the API-26 NDK sysroot and libstd's pthread_create reference
resolves directly against libc.so's real runtime symbol — no interposition
needed.

The pthread_shim.c approach was actually fighting its own solution: our
shim's dlsym() call bound at link time to libdl.a's STUB dlsym (a
five-line function inside libdl_static.o that just returns NULL and sets
dlerror to "libdl.a is a stub --- use libdl.so instead"). NDK r19 and
glibc 2.34 both replaced libdl.a with empty stubs because dynamic loading
is now part of the main libc/bionic — so no amount of link-order
tinkering can make a static libdl.a dlsym actually work.

Remove pthread_shim.c, the cc::Build::new().file("cpp/pthread_shim.c")
step in build.rs, and the -Wl,--wrap=pthread_create rustc-link-arg. Keep
getauxval_fix.c because that one DOES work at link time (the symbol
override is for a function compiler-rt defines statically, not one that
would depend on the stub libdl.a/libc.a).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:52:53 +04:00
Siavash Sameni
5df9d418c9 fix(android): bake android24→26 clang shim into the docker image itself
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m36s
Build #13's PATH wrapper trick failed because tauri-cli invokes the linker
with an absolute path (/opt/android-sdk/ndk/.../bin/aarch64-linux-android24-
clang), which bypasses \$PATH entirely. The pthread_shim logs confirmed the
broken API-24 stubs were still being linked:

  WZP_pthread_shim: dlsym(RTLD_DEFAULT, pthread_create) returned NULL:
    libdl.a is a stub --- use libdl.so instead

Move the fix up a level — into the Dockerfile itself. On image build, for
each of the four android ABIs × {clang, clang++}, rename
`${abi}24-${suffix}` to `${abi}24-${suffix}.orig` and replace it with a
shell wrapper that exec()s `${abi}26-${suffix}`. Any call to the API-24
wrapper — via PATH, absolute path, or otherwise — now transparently runs
the API-26 wrapper, which uses the real libc.so/libdl.so bindings.

The old bash-c /tmp/wrappers workaround in build-tauri-android.sh is
removed now that the image handles it at the right layer.

Also add `--shell` to build-tauri-android.sh: opens an interactive docker
container on the remote with the same mounts/env as the build, so I can
iterate on cargo tauri android build / manually patch files / etc.
without the full git push → ssh → rebuild → install loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:33:10 +04:00
Siavash Sameni
2718402e96 fix(android): PATH wrapper to redirect tauri-cli's android24-clang → android26
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Build #12's instrumented pthread_shim gave us the definitive diagnosis:

  WZP_pthread_shim: dlsym(RTLD_DEFAULT, pthread_create) returned NULL:
    libdl.a is a stub --- use libdl.so instead

Tauri-cli invokes `aarch64-linux-android24-clang` as the linker and the
API-24 NDK sysroot ships *stub* libdl.a / libc.a: they compile fine but
every symbol crashes if called, because they're meant to coexist with a
separate dynamic .so that the dynamic linker provides at runtime. Rust's
pre-built libstd.rlib has static calls into those stubs baked in, so no
matter what we do at link time the broken code lands in the .so.

Env-var overrides of CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER don't
stick — tauri-cli resets them before invoking cargo. So instead of
fighting the env, we put a wrapper on $PATH, literally named
`aarch64-linux-android24-clang`, that exec()s the android26 version.
When tauri-cli looks up android24-clang via PATH, it gets our wrapper,
our wrapper runs android26-clang, and suddenly the whole build is using
the API-26 NDK sysroot with real dynamic bindings to libc.so / libdl.so.

Wrappers are installed for all four ABIs (aarch64, armv7, x86_64, i686)
× both suffixes (clang, clang++) directly inside the docker bash -c
preamble before any cargo invocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:23:47 +04:00
Siavash Sameni
1a8288c95f debug(android): instrument pthread_shim with logcat tracing + try RTLD_DEFAULT first
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m43s
Build #11 linked cleanly with --wrap=pthread_create but crashed at launch
on tao::ndk_glue::create with a Rust .expect() panic — meaning the shim's
__wrap_pthread_create successfully intercepted the call but returned
non-zero, triggering std::thread::spawn's Result::expect panic.

Add __android_log_print tracing so logcat shows exactly which resolver
path fired (RTLD_DEFAULT vs dlopen fallback) and what dlerror reports
when they fail. Also try RTLD_DEFAULT first — it's the simplest and
should find libc.so's pthread_create in the process's global symbol
table without any namespace games.
2026-04-09 13:15:47 +04:00
Siavash Sameni
f015be63ec fix(android): use --wrap=pthread_create instead of raw symbol override
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m39s
Build #10 failed with:
  ld.lld: error: duplicate symbol: pthread_create
    >>> defined at pthread_shim.c:30
    >>> ... in archive libpthread_shim.a
  (the other definition coming from libstd's bundled libc.a stub)

The raw-symbol-override approach was naive: when two static archives
both define the same symbol the linker refuses instead of picking one.

Switch to GNU-ld's `--wrap=pthread_create` mechanism:
  - All `pthread_create` references get rewritten to `__wrap_pthread_create`
  - Our shim now defines `__wrap_pthread_create` (no symbol clash)
  - Inside the shim we `dlopen("libc.so")` + `dlsym("pthread_create")` to
    get the real runtime symbol directly, bypassing BOTH the broken static
    stub (libstd's libc.a copy) AND libstd's own pthread_create path
  - `--real_pthread_create` is deliberately NOT used — it would alias the
    same broken stub the wrap exists to avoid

The wrap flag is emitted via `cargo:rustc-link-arg` in build.rs so it
only affects the Android target (the Android-branch of build.rs is the
only place that emits it).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:08:41 +04:00
Siavash Sameni
79e876126c fix(android): interpose pthread_create to bypass libstd's broken static stub
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m52s
Builds #7, #8 and #9 all crashed at launch with the same SIGSEGV inside
__init_tcb(bionic_tcb*, pthread_internal_t*)+4 called via pthread_create
from std::sys::thread::unix::Thread::new.

Digging further: the problem is NOT the final linker we pass to cargo.
It's that rustup ships a PRE-COMPILED libstd for aarch64-linux-android
which was built statically against an old NDK libc archive. That archive
has a pthread_create stub which calls a static __init_tcb stub that
assumes libc's static init path has set up the TCB — which never happens
in a .so loaded via dlopen. Bumping minSdk to 26 or forcing the
android26-clang linker (903a07c) doesn't rebuild libstd and therefore
doesn't fix the bundled broken stub.

The legacy wzp-android crate dodged this with a getauxval_fix.c shim that
interposes getauxval via RTLD_NEXT. The same trick works for pthread_create
here: define our own `int pthread_create(...)` in cpp/pthread_shim.c that
forwards to `dlsym(RTLD_NEXT, "pthread_create")` — the real, fully working
version exported from libc.so. The linker processes our static lib before
libstd.rlib, so libstd's unresolved pthread_create reference binds to our
symbol, and the broken libc.a stub inside libstd is never pulled in.

build.rs compiles cpp/pthread_shim.c right after cpp/getauxval_fix.c so
both symbol overrides are in place before any Rust code gets linked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:04:18 +04:00
Siavash Sameni
903a07c1d4 fix(android): force API-26 NDK linker via docker env vars
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
The previous commit bumped minSdk from 24 to 26 in build.gradle.kts
hoping tauri-cli would pick it up and use the android26-clang linker,
but the crash recurred at exactly the same frame (__init_tcb via
pthread_create via std::thread::spawn). That means tauri-cli is
ignoring the gradle minSdk value and sticking with its hardcoded
aarch64-linux-android24-clang.

The android24 linker resolves __init_tcb against the broken static
stub in libc.a (API 24 does NOT export __init_tcb as a dynamic symbol
from libc.so — it only exists in the static archive, and the stub
expects the TCB to be initialised by a running static init path,
which never happens in a dlopen-loaded .so).

Override the linker env vars directly in the docker run invocation
for all four ABIs. These take precedence over anything tauri-cli or
.cargo/config.toml might set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:55:11 +04:00
Siavash Sameni
af20fa418a fix(android): bump minSdk 24 -> 26 to avoid broken __init_tcb in NDK 24 stub
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m52s
Build #7 crashed at launch on the Pixel 6 with SIGSEGV in
__init_tcb / pthread_create called from tao::ndk_glue::create in
WryActivity.onCreate:

  #00  __init_tcb(bionic_tcb*, pthread_internal_t*)+4
  #01  pthread_create+360
  #02  std::sys::thread::unix::Thread::new
  #04  tao::platform_impl::platform::ndk_glue::create
  #05  Java_com_wzp_desktop_WryActivity_create

Tauri scaffolds build.gradle.kts with `minSdk = 24`, which makes the
tauri-cli invoke `aarch64-linux-android24-clang` as the Rust linker. That
linker transitively pulls broken static stubs from libc.a for getauxval,
__init_tcb and pthread_create — these stubs only work in statically-
linked executables because they read bionic state (__libc_auxv, TCB) that
only the libc init path sets up. In a .so loaded via dlopen they SIGSEGV
the moment anything spawns a thread.

API 26+ has the real runtime symbols and the NDK-26 linker resolves them
against libc.so instead of the static fallback. This is also the minimum
Oboe supports. Patch the generated build.gradle.kts post-init to swap
`minSdk = 24` for `minSdk = 26` — the legacy wzp-android crate solved
the same issue with a .cargo/config.toml linker override plus a
getauxval_fix.c shim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:47:36 +04:00
Siavash Sameni
b314138caf feat(android): oboe/AAudio audio backend + runtime mic permission (step 3)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m39s
This is the big one — the Tauri Android app now has a real audio stack
capable of full-duplex VoIP, reusing the proven C++ Oboe bridge from the
legacy wzp-android crate.

Architecture:
- desktop/src-tauri/cpp/  — copies of oboe_bridge.{h,cpp}, oboe_stub.cpp,
  and getauxval_fix.c from crates/wzp-android/cpp/. build.rs clones
  google/oboe@1.8.1 into OUT_DIR and compiles the bridge + all Oboe
  sources as "oboe_bridge" static lib, linking against shared libc++
  (static would pull broken libc stubs that SIGSEGV in .so libraries).
- src/oboe_audio.rs  — Rust side: an SPSC ring buffer matching the C++
  bridge's AtomicI32 layout, plus OboeHandle::start() which returns
  (capture_ring, playout_ring, owning_handle). The ring exposes the same
  (available / read / write) methods as wzp_client::audio_ring::AudioRing
  so CallEngine treats both backends interchangeably.
- src/engine.rs  — compiled on every platform now. A cfg-switched type
  alias picks wzp_client::audio_ring::AudioRing on desktop and
  crate::oboe_audio::AudioRing on Android. The audio setup block has
  three branches: VPIO/CPAL on macOS, CPAL on Linux/Windows, Oboe on
  Android. Send/recv tasks are identical across platforms.
- src/lib.rs  — removes all the "step 3 not done" Android stubs. The
  engine module is no longer cfg-gated; connect / disconnect / toggle_mic
  / toggle_speaker / get_status are single implementations used by both
  desktop and Android. Identity path resolves via app.path().app_data_dir()
  from the Tauri setup() callback (already wired in step 1).

Runtime mic permission:
- scripts/build-tauri-android.sh now injects RECORD_AUDIO + MODIFY_AUDIO_
  SETTINGS into gen/android/app/src/main/AndroidManifest.xml after init,
  and overwrites MainActivity.kt with a version that calls
  ActivityCompat.requestPermissions in onCreate. This is idempotent:
  every build re-applies the patches so tauri re-init can't regress them.

Cargo.toml:
- cc is now an unconditional build-dep (build.rs runs on the host, so
  target-gating build-deps doesn't work).
- wzp-client is now a dep on every platform. On Android it gets default
  features only (no "audio"/"vpio") so CPAL isn't dragged in — oboe_audio
  provides the capture/playout rings instead.
- tracing-android is added on Android so tracing events flow into logcat.

build.rs also gained embedded git hash (WZP_GIT_HASH) capture, which is
shown under the fingerprint on the home screen — already committed in
7639aaf, reinstated here alongside the Oboe build logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:40:38 +04:00
Siavash Sameni
35642d1c54 feat(desktop): bake local Laptop relay into default relay list for testing
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m45s
Adds 172.16.81.125:4433 (the laptop's LAN IP) as the first default relay
so the Android rewrite can be tested against a relay whose logs are on the
same host as the builds and screenshots. On fresh installs the Laptop
relay is pre-selected as index 0. On upgrades from an older cached
settings blob, a one-shot migration unshifts it to the front if missing,
so we don't have to tap through Manage Relays after every reinstall.

Marked "remove once Android rewrite is stable" — the address is a hardcoded
LAN IP that won't be valid in other environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:46 +04:00
Siavash Sameni
6b8107504e fix(desktop): tauri capability for android event listeners + persistent debug keystore
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 3m45s
Two related Android-only papercuts found while testing build #4 on a Pixel 6:

1. Frontend was crashing in the WebView with:
       Tauri/Console: Uncaught (in promise) event.listen not allowed.
       Permissions associated with this command: core:event:allow-listen,
       core:event:default
   The desktop build worked fine because Tauri's default capability set
   covers the desktop side. On Android (and iOS) Tauri 2.x is much stricter
   about ACL — without an explicit capabilities/default.json that lists
   "android" in its platforms, the WebView gets zero permissions. Add a
   default capability granting core:default + the event listener perms
   across all five platforms (linux/macOS/windows/android/iOS).

2. Every fresh docker run produced a new ~/.android/debug.keystore, so
   `adb install -r` of a freshly built APK over an already-installed one
   failed with INSTALL_FAILED_UPDATE_INCOMPATIBLE. Mount a persistent host
   volume at /home/builder/.android in build-tauri-android.sh so the same
   debug keystore is reused across builds and `install -r` keeps working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:02:01 +04:00
Siavash Sameni
7639aaf08d feat(desktop): deterministic alias from seed + git hash on home screen + fix EACCES on Android
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m41s
Mirror to GitHub / mirror (push) Failing after 38s
Three home-screen issues from the first Tauri Android APK:

1. Alias was empty (no seed-derived name).
   Port the adjective+noun word lists from the old Kotlin SettingsRepository
   into a `derive_alias()` helper that maps the first 4 bytes of the seed to
   indices in those lists. Same seed → same alias forever, different seeds →
   effectively random aliases — so reinstalls keep the user's identity AND
   the friendly name they're used to.

2. Build identity was invisible — couldn't tell which APK was actually
   installed (this caused us a lot of grief on the Kotlin app).
   build.rs now captures `git rev-parse --short HEAD` and emits it as
   `WZP_GIT_HASH`, exposed via a new `get_app_info` command. The frontend
   stamps `build <hash> • <alias>` under the fingerprint on the home screen.

3. Register on relay failed with `Permission denied (os error 13)`.
   Root cause: I hardcoded `/data/data/com.wzp.phone/files/.wzp` as the
   identity dir, but the Tauri Android package id is `com.wzp.desktop` —
   so the app was trying to write into another app's data directory and
   getting EACCES at the filesystem layer. Fix: resolve the data dir from
   Tauri's `path().app_data_dir()` API in the `setup()` callback and stash
   it in a `OnceLock<PathBuf>`. Works on Android, macOS, Linux, Windows
   without any cfg gymnastics.

Also: `get_app_info` returns the resolved `data_dir` so we can debug
storage issues from the UI (it's set as the build-hash element's title).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:55:51 +04:00
Siavash Sameni
69ee3115b6 build: tauri-android docker pipeline + ntfy notifications
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
Dockerfile.android-builder: install Android API 36 platform + build-tools
35.0.0 alongside the existing API 34 set. Tauri 2.x mobile defaults to
compileSdk 36 / build-tools 35; without these the gradle build fails with
"SDK directory is not writable" because the read-only /opt/android-sdk
volume can't grow at build time. Adding Node.js 20, all four Rust android
targets, and tauri-cli 2.x was already in place.

scripts/build-tauri-android.sh: new build wrapper for the desktop/ Tauri
project (parallel to scripts/build-and-notify.sh which targets the legacy
android/ Kotlin app). Pulls the branch on remote, runs cargo tauri android
build inside the docker image, and sends three ntfy.sh/wzp notifications
that all carry the short git hash:
  - STARTED [hash] — <commit subject>
  - OK [hash] (size) — <rustypaste apk url>
  - FAILED [hash] (line N) — <rustypaste log url>
On failure the full /tmp/wzp-tauri-build.log is uploaded to rustypaste so
the URL in the failure ntfy is directly downloadable, same place as the
APK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:54 +04:00
Siavash Sameni
e6f77a78a7 feat(desktop): split main.rs into lib.rs for Tauri Mobile (Android/iOS)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m54s
Tauri 2.x Mobile links the app as a cdylib loaded from a Java Activity, so
all of the Builder/command code has to live in a library crate. Move the
existing logic verbatim into src/lib.rs::run() and reduce src/main.rs to a
two-line desktop entry point that calls into it.

Cargo.toml gets a [lib] section (crate-types: staticlib + cdylib + rlib,
named wzp_desktop_lib) and the wzp-client dependency — which pulls CPAL +
VoiceProcessingIO — is moved behind cfg(not(target_os = "android")) so the
Android cdylib doesn't need an audio backend yet. Engine-backed Tauri
commands (connect/disconnect/toggle_mic/toggle_speaker/get_status) get
Android stubs that return clear "not yet wired" errors. The signaling
commands (register_signal/place_call/answer_call/get_signal_status/
ping_relay/get_identity) are platform-independent and unchanged.

Also: get_identity / register_signal now auto-create the seed if missing
instead of erroring with "connect to a room first", and the identity dir
resolves to /data/data/com.wzp.phone/files/.wzp on Android (proper
app-internal storage) vs \$HOME/.wzp on desktop.

Side note: src/main.rs was previously untracked — desktop builds were
working only because it existed in the local worktree. This commit fixes
that too.

Step 1 of the Android rewrite plan (tauri-mobile scaffold). No audio yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:17:55 +04:00
Siavash Sameni
04a985912a fix: add direct calling Tauri backend commands (register_signal, place_call, answer_call)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m31s
2026-04-09 06:59:16 +04:00
Siavash Sameni
2288c1ae07 feat: direct calling UI for desktop Tauri app + merge android branch
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m33s
Tauri backend:
- register_signal: persistent _signal connection, presence registration
- place_call: send DirectCallOffer by fingerprint
- answer_call: accept/reject incoming calls
- get_signal_status: poll signal state

Frontend:
- Mode toggle: "Room" vs "Direct Call"
- Register button → registers on relay signal channel
- Incoming call panel with Accept/Reject
- Fingerprint input + Call button
- Auto-connect to media room on CallSetup event

Also merges feat/android-voip-client into desktop branch:
- Federation fixes, time-based dedup, FEC stale blocks
- Direct calling protocol types
- ACL + SAS verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 06:42:47 +04:00
Siavash Sameni
395a0c557e feat: TX/RX codec badges on desktop call screen
Some checks failed
Mirror to GitHub / mirror (push) Failing after 34s
Build Release Binaries / build-amd64 (push) Failing after 2m1s
Desktop now shows codec badges like Android:
- Green TX badge: e.g. "Opus64k"
- Blue RX badge: e.g. "Opus24k"
Displayed in the stats line below the call controls.

Engine tracks tx_codec (set on encoder init) and rx_codec (updated
from incoming packet headers). Passed through EngineStatus → CallStatus
→ frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:03:20 +04:00
Siavash Sameni
da593f9510 feat: relay-grouped participant rendering + relay_label in protocol
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m47s
RoomParticipant now has optional relay_label field. Desktop client
groups participants by relay: "This Relay" (green dot) for local,
peer label (blue dot) for federated. Shows all relays in the chain
including intermediate ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:22:05 +04:00
Siavash Sameni
7bddc6b5a6 fix: advertise studio profiles in desktop handshake supported_profiles
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m55s
Same fix as Android — the CallOffer now includes STUDIO_64K/48K/32K
so the relay can negotiate studio quality levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:06:48 +04:00
Siavash Sameni
3b85604b41 docs: PRDs for local recording + mixer and studio quality tiers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 37s
Build Release Binaries / build-amd64 (push) Failing after 1m56s
PRD-local-recording.md: Dual-path architecture for podcast-quality
interviews — local lossless WAV recording alongside live call, with
sync markers for post-session alignment, resumable upload to a
self-hosted mixer service that produces normalized multi-track output.

PRD-studio-quality.md: Documents the Opus 32k/48k/64k studio tiers,
when to use them, cross-codec interop, and backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:32:24 +04:00
Siavash Sameni
a8c2011445 feat: add Opus 32k/48k/64k studio quality tiers
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Has been cancelled
Adds three new codec IDs (Opus32k=6, Opus48k=7, Opus64k=8) and
corresponding STUDIO_32K, STUDIO_48K, STUDIO_64K quality profiles.
All use 20ms frames with minimal FEC (10%) for maximum quality on
good networks.

Updated across: wire protocol (codec_id.rs), encoder/decoder
(opus_enc/dec.rs), adaptive codec switch (call.rs), CLI
(--profile studio-64k), desktop engine + UI slider (8 quality
levels from Studio 64k green to Codec2 1.2k red).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:31:05 +04:00
Siavash Sameni
ded49bdb7b feat: replace browser confirm with proper key-change warning dialog
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m57s
When the relay's server key changes (e.g. after restart), show a
styled in-app warning dialog instead of the ugly browser confirm().
The dialog shows old vs new fingerprints and lets the user accept
the new key or cancel. Accepting updates the saved fingerprint and
refreshes the relay button state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:19:53 +04:00
Siavash Sameni
369347ce54 fix: remove unused FRAME_SAMPLES_20MS constant in desktop engine
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m53s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:54:13 +04:00
Siavash Sameni
44f04b55e8 feat: quality slider in settings with color gradient
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 1m56s
Replace the quality dropdown with a range slider in the settings
panel. The slider goes from Auto (green) through Opus 24k, Opus 6k
(yellow), Codec2 3.2k (orange) to Codec2 1.2k (dark red). The
track uses a green-to-red gradient and the label color updates
to match the selected level. Removed the quality dropdown from
the connect screen — quality is now settings-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:50:46 +04:00
Siavash Sameni
85c2146760 feat: quality profile selection in desktop settings
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 1m58s
Adds a Quality dropdown (Auto / Opus 24k / Opus 6k / Codec2 3.2k /
Codec2 1.2k) to both the connect screen and settings panel. The
selected profile is passed through to the engine which configures
the encoder and decoder accordingly.

The desktop engine recv path now auto-switches the decoder codec
when incoming packets use a different codec than expected, enabling
cross-codec interop between clients on different quality settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:44:17 +04:00
Siavash Sameni
96ccb4f333 fix: auto-switch decoder codec to match incoming packets
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m41s
The CallDecoder now inspects each incoming packet's codec_id and
automatically switches the audio decoder if it differs from the
current profile. This enables cross-codec interop where one client
sends Opus and the other sends Codec2 — previously the receiver
would try to decode with the wrong codec, producing garbled audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:35:31 +04:00
Siavash Sameni
95a905e1b5 feat: add --profile/--codec flag to CLI for forcing codec selection
Enables debugging Codec2 by allowing forced codec selection from CLI.
Supports: good, degraded, catastrophic, codec2-3200, codec2-1200.
Frame size, timing, and jitter buffer are all adjusted dynamically
based on the selected profile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:35:31 +04:00
Siavash Sameni
f7ccb67b02 fix: desktop ping closes endpoint properly, prevents resource leaks
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:32 +04:00
Siavash Sameni
4df08eadbd fix: don't block connect on offline ping — always allow connection attempt
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Server may be reachable even if ping failed (transient timeout).
User should always be able to try connecting. Fingerprint change
still shows confirm dialog (accept/reject).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:20:38 +04:00
Siavash Sameni
6d776097c8 feat: relay ping handling, identity persistence, linux build script (backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Backported from feat/android-voip-client:
- Relay: SNI "ping" connections handled gracefully (no timeout errors)
- Relay: identity persisted in ~/.wzp/relay-identity (stable fingerprint)
- Linux fire-and-forget build script (Hetzner VM)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:45:27 +04:00
Siavash Sameni
9f7962a6cd fix: vec allocation for desktop AudioRing (match Android fix)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Same fix as Android: Box::new([0i16; 16384]) allocates 32KB on the
stack before moving to heap. Use vec![].into_boxed_slice() for
direct heap allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 05:26:59 +04:00
Siavash Sameni
8c9befb15d ci: skip build on CI-only file changes
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:12:32 +04:00
Siavash Sameni
3f869a4cd7 ci: add GitHub mirror workflow
Automatically pushes branches and tags to github.com:manawenuz/wzp.git
on every push to Forgejo. Uses GH_SSH_KEY secret for authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:50:39 +04:00
Siavash Sameni
2263e898e5 fix: port AudioRing reader-detects-lap fix to desktop client
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Same fix as Android (4af7c5f): writer never touches read_pos,
reader self-corrects when lapped. Power-of-2 capacity (16384),
bitmask indexing, overflow/underrun counters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:42:33 +04:00
Siavash Sameni
9ab57ba037 merge: fj/feat/android-voip-client — congestion fix, AEC toggle, debug logging
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m36s
Merged 10 commits from Android branch:
- Send task crash fix on QUIC congestion (continue instead of break)
- AEC toggle + NoiseSuppressor on Android
- Debug reporter for crash diagnostics
- Mic mute crackling fix
- Participant dedup in UI
- Proper QUIC connection close on hangup
- Null alias display fix
- Tracing → Android logcat
- Incident reports for send-task crash and playout ring desync

Conflict resolved in room.rs: kept Android's improved debug logging
(recv gap tracking, lock contention, forward latency, send errors)
inside our media_task async block for parallel signal handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:13:43 +04:00
Siavash Sameni
7806d4ec04 feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator:
- Deterministic 5x5 symmetric pattern from fingerprint hash
- HSL-derived colors, rendered as inline SVG
- Click any identicon to copy its fingerprint to clipboard
- Used for participants, user identity, and relay servers

Server identity (TOFU — Trust On First Use):
- Ping returns server fingerprint (QUIC peer certificate hash)
- First contact: auto-saved as known fingerprint
- Subsequent pings: compared against known fingerprint
- Lock icons: locked (verified), unlocked (new), warning (changed), red (offline)
- Fingerprint mismatch shows confirmation dialog before connecting

UI updates:
- Participants show identicons instead of letter avatars
- User identity shows identicon + fingerprint on connect screen
- Manage Relays shows identicon per server with lock status
- Relay button shows lock icon instead of colored dot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:02:42 +04:00
Siavash Sameni
d31b81a21d fix: replace relay dropdown with direct dialog on click
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m53s
- Click relay button opens Manage Relays dialog directly (no dropdown)
- Click a relay in the dialog to select it (highlighted with accent border)
- × button to delete, Add Relay button to add new
- Removed all dropdown menu code and CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:53:13 +04:00
Siavash Sameni
c268ce419a fix: relay dialog overflow — stack inputs, full-width Add button
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- Dialog fits within 360px window (was overflowing at 420px)
- Add inputs stacked: name + host:port in a row, "Add Relay" button below
- Text overflow with ellipsis on relay names and addresses
- Proper min-width: 0 on flex children to prevent overflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:49:26 +04:00
Siavash Sameni
61b6e67610 feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
- Relay selector as dropdown with green/yellow/red status dots
  (green < 200ms, yellow > 200ms, red = offline, gray = unknown)
- All relays pinged on startup, RTT shown next to each
- "Manage Relays..." dialog: add/remove servers, see live status
- Clicking a relay in dropdown selects it, fills connect form
- Recent room chips auto-select matching relay
- Migrates old single-relay settings format automatically
- Prevents connecting to offline relays

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:44:19 +04:00
Siavash Sameni
dddf5d2e2d feat: relay ping with RTT display, fix dead_code warning
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- New ping_relay Tauri command: QUIC connect with 3s timeout, returns RTT ms
- Relay status shown next to input field: "42ms" (green) or "offline" (red)
- Auto-pings on app startup and debounced on relay input change
- Fix SyncWrapper dead_code warning with #[allow(dead_code)]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:41:28 +04:00
Siavash Sameni
ed272d29f8 feat: fingerprint at startup, relay+room pairs, auto-reconnect, cleanup
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
#7 Fingerprint shown before connecting — new get_identity command reads
   ~/.wzp/identity at startup (generates if missing). Click to copy.

#8 Recent rooms store (relay, room) pairs — clicking a chip fills both
   fields. Settings panel shows relay alongside room name. Migrates
   old string[] format automatically.

#9 Auto-reconnect on unexpected disconnect — exponential backoff
   (1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot
   shows reconnecting state. Stops if user clicks hangup.

#10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more
    mem::forget), dropped properly on CallEngine::stop().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:15:05 +04:00
Siavash Sameni
21f5b24cbf fix: keep audio handles alive for call duration, fix Send+Sync
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m39s
The VPIO/CPAL audio handles were dropped at the end of start(),
killing the audio unit immediately. Audio I/O stopped working
after the first frame.

- Store audio handle in CallEngine via SyncWrapper
- Drop MutexGuard before returning from status() (Send future)
- Audio streams now live for the entire call duration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:00:16 +04:00
Siavash Sameni
9b733010ab fix: blocking_lock panic in status(), fingerprint copy-to-clipboard
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m13s
- Change status() from blocking_lock to async lock().await —
  fixes "Cannot block the current thread from within a runtime" panic
  that froze the call timer and broke audio
- Click fingerprint to copy to clipboard (both connect and settings screens)
- Show "Copied!" feedback on click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:53:31 +04:00
Siavash Sameni
80d5bd7628 fix: survive QUIC congestion — drop packets instead of killing send task
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m14s
send_datagram() returns Err(Blocked) when the QUIC congestion window
is full. This is transient — the window reopens once ACKs arrive.
Previously, all send paths treated this as fatal (break/return),
which killed the send task and cascaded via tokio::select! to kill
the entire call.

Now: log warning, drop the packet, continue. Brief audio glitch
(20-100ms) instead of complete call death. FEC on the receiver
side recovers most dropped packets.

Fixed in:
- CLI run_live send task (continue + error counter)
- CLI run_file_mode send paths (2 locations)
- Desktop engine send task

Also hardened recv tasks: transient errors (non-closed/reset)
are survived instead of causing exit.

Matches the fix applied to Android client (engine.rs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:48:20 +04:00
Siavash Sameni
4a195a923a feat: settings panel with Cmd+, shortcut (macOS standard)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
- Full settings page as modal overlay (blur backdrop)
- Opens via gear icon on connect/call screens or Cmd+, (Ctrl+, on Win/Linux)
- Escape or click outside to close
- Settings: relay, room, alias, OS AEC toggle, AGC toggle
- Identity section showing fingerprint and identity file path
- Recent rooms management (remove individual, clear all)
- Save syncs back to connect form
- Gear icon on both connect and in-call screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:44:22 +04:00
Siavash Sameni
f726f8cfa4 feat: desktop GUI enhancements — audio level, call timer, VPIO, settings
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m47s
- Audio level meter with log-scale RMS visualization
- Call duration timer
- VPIO (OS AEC) wired through to engine with fallback to CPAL
- "You" badge on own participant entry
- Recent rooms list (click to reuse)
- Enter key to connect from form fields
- Improved dark theme with pulse animation on status dot
- Settings persistence via localStorage (relay, room, alias, AEC, recent rooms)
- Fingerprint display on connect screen
- Keyboard shortcuts skip input fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:40:07 +04:00
Siavash Sameni
e468454464 feat: Tauri desktop GUI app with call engine
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m27s
- New desktop/ directory with Tauri v2 + Vite + TypeScript
- Rust backend: CallEngine wrapping wzp-client audio + transport
- Web frontend: connect screen, in-call screen with participants,
  mic/speaker mute, keyboard shortcuts (m/s/q)
- Dark theme UI, settings persistence via localStorage
- Platform-aware --os-aec: warns on Windows/Linux (not yet implemented)
- Workspace updated to include desktop/src-tauri

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:25:54 +04:00
Siavash Sameni
d1c96cd71f feat: macOS VoiceProcessingIO for hardware AEC + delay-compensated NLMS
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m33s
- Add --os-aec flag: uses Apple VoiceProcessingIO audio unit for
  hardware echo cancellation (same engine as FaceTime)
- New vpio feature + audio_vpio.rs: combined capture+playback via VPIO
- Improved software AEC: delay-compensated leaky NLMS with Geigel DTD
  (60ms tail, 40ms delay, configurable via --aec-delay)
- Add --aec-delay flag for tuning software AEC delay compensation
- Add dev-fast Cargo profile (opt-level 2 with incremental compilation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:10:10 +04:00
Siavash Sameni
1b00b5e2a4 feat: improved AEC, keyboard shortcuts, dedup participants, dev-fast profile
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m40s
AEC improvements:
- Reduce echo tail from 100ms to 30ms (3.3x faster, suited for laptops)
- Add double-talk detection: freeze adaptation when near-end speaks
- Add residual echo suppression
- Disable AEC by default in --android mode (macOS has built-in AEC)

CLI features:
- Keyboard shortcuts: m=mic mute, s=speaker mute, q=quit (raw terminal mode)
- Dedup participants in RoomUpdate display (same fingerprint+alias shown once)
- Add dev-fast profile (opt-level 2 with incremental compilation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:15:23 +04:00
Siavash Sameni
cfb48df1ef feat: direct playout mode, AEC far-end, audio processing switches
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m28s
- Add --android/--direct-playout: bypass jitter buffer, decode on recv
  (matches Android engine architecture)
- Wire AEC far-end reference from decoded playout to encoder
- Add --no-aec, --no-agc, --no-fec, --no-silence, --no-denoise switches
- Fix BufferSize::Fixed(960) → Default for macOS CoreAudio compat
- Optimize wzp-codec, wzp-fec, audiopus, nnnoiseless in debug profile
- Add capture callback size diagnostic logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:48:34 +04:00
Siavash Sameni
ba29d8354f fix: send alias via CallOffer handshake (match Android approach)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:10:07 +04:00
Siavash Sameni
0908507a7a Merge remote-tracking branch 'origin/feat/android-voip-client' into feat/desktop-audio-rewrite 2026-04-06 09:04:55 +04:00
Siavash Sameni
860c90394d feat: rewrite desktop audio I/O with lock-free ring buffers
- Replace Mutex-based CPAL callbacks with atomic SPSC ring buffers
- Proper async send/recv loops (no block_on), 20ms playout tick
- Add signal task for RoomUpdate presence display
- Add --alias, --raw-room flags and key persistence (~/.wzp/identity)
- Add SetAlias signal variant and relay-side handling
- Graceful Ctrl+C shutdown with force-quit on second press

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:04:51 +04:00
91 changed files with 20447 additions and 1934 deletions

3496
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ members = [
"crates/wzp-client", "crates/wzp-client",
"crates/wzp-web", "crates/wzp-web",
"crates/wzp-android", "crates/wzp-android",
"crates/wzp-native",
"desktop/src-tauri",
] ]
[workspace.package] [workspace.package]
@@ -60,3 +62,29 @@ wzp-fec = { path = "crates/wzp-fec" }
wzp-crypto = { path = "crates/wzp-crypto" } wzp-crypto = { path = "crates/wzp-crypto" }
wzp-transport = { path = "crates/wzp-transport" } wzp-transport = { path = "crates/wzp-transport" }
wzp-client = { path = "crates/wzp-client" } wzp-client = { path = "crates/wzp-client" }
# Fast dev profile: optimized but with debug info and incremental compilation.
# Use with: cargo run --profile dev-fast
[profile.dev-fast]
inherits = "dev"
opt-level = 2
# Optimize heavy compute deps even in debug builds —
# real-time audio needs < 20ms per frame, impossible unoptimized.
[profile.dev.package.nnnoiseless]
opt-level = 3
[profile.dev.package.opusic-sys]
opt-level = 3
[profile.dev.package.raptorq]
opt-level = 3
[profile.dev.package.wzp-codec]
opt-level = 3
[profile.dev.package.wzp-fec]
opt-level = 3
# Phase 0 (opus-DRED): removed the [patch.crates-io] audiopus_sys = { path =
# "vendor/audiopus_sys" } block. That patch existed to fix a Windows clang-cl
# SIMD compile bug in libopus 1.3.1. With the swap to opusic-sys (libopus
# 1.5.2), the upstream SIMD gating was fixed and the vendor patch is
# obsolete. The vendor/audiopus_sys directory itself should be deleted as
# part of the same cleanup — see the commit that follows this Phase 0.

View File

@@ -1,97 +0,0 @@
package com.wzp.engine
import org.json.JSONObject
/**
* Persistent signal connection for direct 1:1 calls.
* Separate from WzpEngine — survives across calls.
*
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
*/
class SignalManager {
private var handle: Long = 0L
val isConnected: Boolean get() = handle != 0L
/**
* Connect to relay and register for direct calls.
* MUST be called from a thread with sufficient stack (8MB).
* Blocks briefly during QUIC connect + register, then returns.
*/
fun connect(relay: String, seedHex: String): Boolean {
if (handle != 0L) return true // already connected
handle = nativeSignalConnect(relay, seedHex)
return handle != 0L
}
/** Get current signal state as parsed object. Non-blocking. */
fun getState(): SignalState {
if (handle == 0L) return SignalState()
val json = nativeSignalGetState(handle) ?: return SignalState()
return try {
val obj = JSONObject(json)
SignalState(
status = obj.optString("status", "idle"),
fingerprint = obj.optString("fingerprint", ""),
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
)
} catch (e: Exception) {
SignalState()
}
}
/** Place a direct call to a target fingerprint. */
fun placeCall(targetFp: String): Int {
if (handle == 0L) return -1
return nativeSignalPlaceCall(handle, targetFp)
}
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
fun answerCall(callId: String, mode: Int = 2): Int {
if (handle == 0L) return -1
return nativeSignalAnswerCall(handle, callId, mode)
}
/** Send hangup signal. */
fun hangup() {
if (handle != 0L) nativeSignalHangup(handle)
}
/** Destroy the signal manager. */
fun destroy() {
if (handle != 0L) {
nativeSignalDestroy(handle)
handle = 0L
}
}
// JNI native methods
private external fun nativeSignalConnect(relay: String, seed: String): Long
private external fun nativeSignalGetState(handle: Long): String?
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
private external fun nativeSignalHangup(handle: Long)
private external fun nativeSignalDestroy(handle: Long)
companion object {
init { System.loadLibrary("wzp_android") }
}
}
/** Signal connection state. */
data class SignalState(
val status: String = "idle",
val fingerprint: String = "",
val incomingCallId: String? = null,
val incomingCallerFp: String? = null,
val incomingCallerAlias: String? = null,
val callSetupRelay: String? = null,
val callSetupRoom: String? = null,
val callSetupId: String? = null,
)

View File

@@ -159,18 +159,6 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
private external fun nativeDestroy(handle: Long) private external fun nativeDestroy(handle: Long)
companion object {
init { System.loadLibrary("wzp_android") }
/** Get the identity fingerprint for a seed hex. No engine needed. */
@JvmStatic
private external fun nativeGetFingerprint(seedHex: String): String?
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
@JvmStatic
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
}
private external fun nativePingRelay(handle: Long, relay: String): String? private external fun nativePingRelay(handle: Long, relay: String): String?
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: String): Int private external fun nativePlaceCall(handle: Long, targetFp: String): Int
@@ -220,6 +208,11 @@ class WzpEngine(private val callback: WzpCallback) {
return nativeAnswerCall(nativeHandle, callId, mode) return nativeAnswerCall(nativeHandle, callId, mode)
} }
companion object {
init {
System.loadLibrary("wzp_android")
}
}
} }
/** Integer constants matching the Rust [CallState] enum ordinals. */ /** Integer constants matching the Rust [CallState] enum ordinals. */

View File

@@ -141,9 +141,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _targetFingerprint = MutableStateFlow("") private val _targetFingerprint = MutableStateFlow("")
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow() val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */ /** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
private val _signalState = MutableStateFlow("idle") private val _signalState = MutableStateFlow(0)
val signalState: StateFlow<String> = _signalState.asStateFlow() val signalState: StateFlow<Int> = _signalState.asStateFlow()
/** Incoming call info */ /** Incoming call info */
private val _incomingCallId = MutableStateFlow<String?>(null) private val _incomingCallId = MutableStateFlow<String?>(null)
@@ -155,80 +155,32 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _incomingCallerAlias = MutableStateFlow<String?>(null) private val _incomingCallerAlias = MutableStateFlow<String?>(null)
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow() val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
/** Separate signal manager (persistent, survives calls) */
private var signalManager: com.wzp.engine.SignalManager? = null
private var signalPollJob: Job? = null
fun setCallMode(mode: Int) { _callMode.value = mode } fun setCallMode(mode: Int) { _callMode.value = mode }
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp } fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
/** Register on relay for direct calls */ /** Register on relay for direct calls */
fun registerForCalls() { fun registerForCalls() {
if (engine == null) {
engine = WzpEngine(this).also { it.init() }
}
val serverIdx = _selectedServer.value val serverIdx = _selectedServer.value
val serverList = _servers.value val serverList = _servers.value
if (serverIdx >= serverList.size) return if (serverIdx >= serverList.size) return
val relay = serverList[serverIdx].address val relay = serverList[serverIdx].address
var seed = _seedHex.value val seed = _seedHex.value
// Generate seed if empty (fresh install or cleared storage) val alias = _alias.value
if (seed.isEmpty()) {
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
seed = newSeed.joinToString("") { "%02x".format(it) }
_seedHex.value = seed
settings?.saveSeedHex(seed)
Log.i(TAG, "generated new identity seed")
}
val resolvedRelay = resolveToIp(relay) ?: relay
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack. viewModelScope.launch(Dispatchers.IO) {
// Dispatchers.IO threads overflow. Use explicit Java Thread. val resolvedRelay = resolveToIp(relay) ?: relay
Thread(null, { val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
try { if (result == 0) {
val mgr = com.wzp.engine.SignalManager() _signalState.value = 5 // Registered
val ok = mgr.connect(resolvedRelay, seed) startStatsPolling()
viewModelScope.launch {
if (ok) {
signalManager = mgr
startSignalPolling()
} else { } else {
_errorMessage.value = "Failed to register on relay" _errorMessage.value = "Failed to register on relay"
} }
} }
} catch (e: Exception) {
viewModelScope.launch {
_errorMessage.value = "Register error: ${e.message}"
}
}
}, "wzp-signal-init", 8 * 1024 * 1024).start()
}
/** Poll signal manager state every 500ms */
private fun startSignalPolling() {
signalPollJob?.cancel()
signalPollJob = viewModelScope.launch {
while (isActive) {
val mgr = signalManager
if (mgr != null && mgr.isConnected) {
val state = mgr.getState()
_signalState.value = state.status
_incomingCallId.value = state.incomingCallId
_incomingCallerFp.value = state.incomingCallerFp
_incomingCallerAlias.value = state.incomingCallerAlias
// Auto-connect to media room when call is set up
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
startCallInternal(state.callSetupRelay, state.callSetupRoom)
}
}
delay(500L)
}
}
}
private fun stopSignalPolling() {
signalPollJob?.cancel()
signalPollJob = null
} }
/** Place a direct call to the target fingerprint */ /** Place a direct call to the target fingerprint */
@@ -238,28 +190,24 @@ class CallViewModel : ViewModel(), WzpCallback {
_errorMessage.value = "Enter a fingerprint to call" _errorMessage.value = "Enter a fingerprint to call"
return return
} }
signalManager?.placeCall(target) engine?.placeCall(target)
_signalState.value = 6 // Ringing
} }
/** Answer an incoming direct call */ /** Answer an incoming direct call */
fun answerIncomingCall(mode: Int = 2) { fun answerIncomingCall(mode: Int = 2) {
val callId = _incomingCallId.value ?: return val callId = _incomingCallId.value ?: return
signalManager?.answerCall(callId, mode) engine?.answerCall(callId, mode)
} }
/** Reject an incoming direct call */ /** Reject an incoming direct call */
fun rejectIncomingCall() { fun rejectIncomingCall() {
val callId = _incomingCallId.value ?: return val callId = _incomingCallId.value ?: return
signalManager?.answerCall(callId, 0) engine?.answerCall(callId, 0) // 0 = Reject
} _signalState.value = 5 // Back to registered
_incomingCallId.value = null
/** Hang up direct call — media ends, signal stays alive */ _incomingCallerFp.value = null
fun hangupDirectCall() { _incomingCallerAlias.value = null
signalManager?.hangup()
engine?.stopCall()
engine?.destroy()
engine = null
engineInitialized = false
} }
companion object { companion object {
@@ -737,10 +685,30 @@ class CallViewModel : ViewModel(), WzpCallback {
val s = CallStats.fromJson(json) val s = CallStats.fromJson(json)
lastCallDuration = s.durationSecs lastCallDuration = s.durationSecs
_stats.value = s _stats.value = s
// Only update callState from media engine stats (not signal)
if (s.state != 0) { if (s.state != 0) {
_callState.value = s.state _callState.value = s.state
} }
// Track signal state changes for direct calling
if (s.state in 5..7) {
_signalState.value = s.state
}
// Incoming call detection
if (s.state == 7) { // IncomingCall
_incomingCallId.value = s.incomingCallId
_incomingCallerFp.value = s.incomingCallerFp
_incomingCallerAlias.value = s.incomingCallerAlias
}
// CallSetup: auto-connect to media room
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
// Format: "relay_addr|room_name"
val parts = s.incomingCallId.split("|", limit = 2)
if (parts.size == 2) {
val mediaRelay = parts[0]
val mediaRoom = parts[1]
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
startCallInternal(mediaRelay, mediaRoom)
}
}
if (s.state == 2 && !audioStarted) { if (s.state == 2 && !audioStarted) {
startAudio() startAudio()
} }

View File

@@ -165,7 +165,7 @@ fun InCallScreen(
color = Color.White color = Color.White
) )
Text( Text(
text = "ENCRYPTED VOICE \u2022 direct-call-v1", text = "ENCRYPTED VOICE",
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp), style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
color = TextDim color = TextDim
) )
@@ -219,7 +219,7 @@ fun InCallScreen(
// Mode toggle: Room vs Direct Call // Mode toggle: Room vs Direct Call
val callMode by viewModel.callMode.collectAsState() val callMode by viewModel.callMode.collectAsState()
val signalState by viewModel.signalState.collectAsState() // "idle"/"registered"/"ringing"/etc val signalState by viewModel.signalState.collectAsState()
val targetFp by viewModel.targetFingerprint.collectAsState() val targetFp by viewModel.targetFingerprint.collectAsState()
val incomingCallId by viewModel.incomingCallId.collectAsState() val incomingCallId by viewModel.incomingCallId.collectAsState()
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState() val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
@@ -309,7 +309,7 @@ fun InCallScreen(
} }
} else { } else {
// ── Direct call mode ── // ── Direct call mode ──
if (signalState == "idle") { if (signalState < 5) {
// Not registered yet // Not registered yet
SectionLabel("ALIAS") SectionLabel("ALIAS")
OutlinedTextField( OutlinedTextField(
@@ -333,7 +333,7 @@ fun InCallScreen(
color = Color.White color = Color.White
) )
} }
} else if (signalState == "registered" || signalState == "incoming") { } else if (signalState == 5) {
// Registered — show dial pad // Registered — show dial pad
Text( Text(
"\u2705 Registered — waiting for calls", "\u2705 Registered — waiting for calls",
@@ -403,7 +403,8 @@ fun InCallScreen(
color = Color.White color = Color.White
) )
} }
} else if (signalState == "ringing") { } else if (signalState == 6) {
// Ringing
Text( Text(
"\uD83D\uDD14 Ringing...", "\uD83D\uDD14 Ringing...",
color = Yellow, color = Yellow,
@@ -411,10 +412,11 @@ fun InCallScreen(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} else if (signalState == "setup") { } else if (signalState == 7) {
// Incoming call (state 7 also handled above in registered view)
Text( Text(
"Connecting to call...", "\uD83D\uDCDE Incoming call...",
color = Accent, color = Green,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -429,16 +431,14 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// Identity — compute real fingerprint from seed // Identity
val fullFp = remember(seedHex) { val fp = if (seedHex.length >= 16) seedHex.take(16) else ""
if (seedHex.length >= 64) com.wzp.engine.WzpEngine.getFingerprint(seedHex) else ""
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (fullFp.isNotEmpty()) { if (fp.isNotEmpty()) {
Identicon(fingerprint = fullFp, size = 28.dp) Identicon(fingerprint = seedHex, size = 28.dp)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
CopyableFingerprint( CopyableFingerprint(
fingerprint = fullFp, fingerprint = fp.chunked(4).joinToString(":"),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = TextDim color = TextDim
) )

View File

@@ -203,6 +203,7 @@ impl WzpEngine {
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error. /// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> { pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
let addr: SocketAddr = address.parse()?; let addr: SocketAddr = address.parse()?;
let _ = rustls::crypto::ring::default_provider().install_default();
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@@ -246,7 +247,154 @@ impl WzpEngine {
} }
/// Start persistent signaling connection for direct calls. /// Start persistent signaling connection for direct calls.
// Signal methods (start_signaling, place_call, answer_call) moved to signal_mgr.rs /// Spawns a background task that maintains the `_signal` connection.
pub fn start_signaling(
&mut self,
relay_addr: &str,
seed_hex: &str,
token: Option<&str>,
alias: Option<&str>,
) -> Result<(), anyhow::Error> {
use wzp_proto::{MediaTransport, SignalMessage};
let addr: SocketAddr = relay_addr.parse()?;
let seed = if seed_hex.is_empty() {
wzp_crypto::Seed::generate()
} else {
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
};
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
let identity_pub = *pub_id.signing.as_bytes();
let fp = pub_id.fingerprint.to_string();
let token = token.map(|s| s.to_string());
let alias = alias.map(|s| s.to_string());
let state = self.state.clone();
let seed_bytes = seed.0;
info!(fingerprint = %fp, relay = %addr, "starting signaling");
// Create runtime for signaling (separate from call runtime)
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()?;
let signal_state = state.clone();
rt.spawn(async move {
let _ = rustls::crypto::ring::default_provider().install_default();
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind, None) {
Ok(e) => e,
Err(e) => { error!("signal endpoint: {e}"); return; }
};
let client_cfg = wzp_transport::client_config();
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
Ok(c) => c,
Err(e) => { error!("signal connect: {e}"); return; }
};
let transport = std::sync::Arc::new(wzp_transport::QuinnTransport::new(conn));
// Auth if token provided
if let Some(ref tok) = token {
let _ = transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await;
}
// Register presence
let _ = transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub,
signature: vec![],
alias: alias.clone(),
}).await;
// Wait for ack
match transport.recv_signal().await {
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
info!(fingerprint = %fp, "signal: registered");
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Registered;
}
other => {
error!("signal registration failed: {other:?}");
return;
}
}
// Signal recv loop
loop {
if !signal_state.running.load(Ordering::Relaxed) {
break;
}
match transport.recv_signal().await {
Ok(Some(SignalMessage::CallRinging { call_id })) => {
info!(call_id = %call_id, "signal: ringing");
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Ringing;
}
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::IncomingCall;
stats.incoming_call_id = Some(call_id);
stats.incoming_caller_fp = Some(caller_fingerprint);
stats.incoming_caller_alias = caller_alias;
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
// Connect to media room via the existing start_call mechanism
// Store the room info so Kotlin can call startCall with it
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Connecting;
// Store call setup info for Kotlin to pick up
stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
}
Ok(Some(SignalMessage::Hangup { reason })) => {
info!(reason = ?reason, "signal: call ended by remote");
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Closed;
stats.incoming_call_id = None;
stats.incoming_caller_fp = None;
stats.incoming_caller_alias = None;
}
Ok(Some(_)) => {}
Ok(None) => {
info!("signal: connection closed");
break;
}
Err(e) => {
error!("signal recv error: {e}");
break;
}
}
}
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Closed;
});
self.tokio_runtime = Some(rt);
Ok(())
}
/// Place a direct call to a target fingerprint via the signal connection.
pub fn place_call(&self, target_fingerprint: &str) -> Result<(), anyhow::Error> {
let _ = self.state.command_tx.send(EngineCommand::PlaceCall {
target_fingerprint: target_fingerprint.to_string(),
});
Ok(())
}
/// Answer an incoming direct call.
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
let _ = self.state.command_tx.send(EngineCommand::AnswerCall {
call_id: call_id.to_string(),
accept_mode: mode,
});
Ok(())
}
pub fn set_mute(&self, muted: bool) { pub fn set_mute(&self, muted: bool) {
self.state.muted.store(muted, Ordering::Relaxed); self.state.muted.store(muted, Ordering::Relaxed);
@@ -310,6 +458,7 @@ async fn run_call(
alias: Option<&str>, alias: Option<&str>,
state: Arc<EngineState>, state: Arc<EngineState>,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let _ = rustls::crypto::ring::default_provider().install_default();
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;

View File

@@ -77,9 +77,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
) -> jlong { ) -> jlong {
let result = panic::catch_unwind(|| { let result = panic::catch_unwind(|| {
init_logging(); init_logging();
// Install rustls crypto provider ONCE on the main thread.
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
let _ = rustls::crypto::ring::default_provider().install_default();
let handle = Box::new(EngineHandle { let handle = Box::new(EngineHandle {
engine: WzpEngine::new(), engine: WzpEngine::new(),
}); });
@@ -363,149 +360,88 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
.unwrap_or(JObject::null().into_raw()) .unwrap_or(JObject::null().into_raw())
} }
/// Get the identity fingerprint for a seed hex string.
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
seed_hex_j: JString,
) -> jstring {
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
let fp = if seed_hex.is_empty() {
String::new()
} else {
match wzp_crypto::Seed::from_hex(&seed_hex) {
Ok(seed) => {
let id = seed.derive_identity();
id.public_identity().fingerprint.to_string()
}
Err(_) => String::new(),
}
};
env.new_string(&fp)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
// ── Direct calling JNI functions ── // ── Direct calling JNI functions ──
// ── SignalManager JNI functions ── /// Start persistent signaling connection to relay for direct calls.
/// Returns 0 on success, -1 on error.
/// Opaque handle for SignalManager (separate from EngineHandle).
struct SignalHandle {
mgr: crate::signal_mgr::SignalManager,
}
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
unsafe { &*(handle as *const SignalHandle) }
}
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
/// Blocks up to 10s waiting for the internal signal thread to connect.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'a>( pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
relay_j: JString,
seed_j: JString,
) -> jlong {
info!("nativeSignalConnect: entered");
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
// Blocks up to 10s waiting for the connect+register to complete.
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
Ok(mgr) => {
let handle = Box::new(SignalHandle { mgr });
Box::into_raw(handle) as jlong
}
Err(e) => {
error!("signal connect failed: {e}");
0
}
}
}
/// Get signal state as JSON string.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'a>(
mut env: JNIEnv<'a>, mut env: JNIEnv<'a>,
_class: JClass, _class: JClass,
handle: jlong, handle: jlong,
) -> jstring { relay_addr_j: JString,
if handle == 0 { return JObject::null().into_raw(); } seed_hex_j: JString,
let h = signal_ref(handle); token_j: JString,
let json = h.mgr.get_state_json(); alias_j: JString,
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}
/// Place a direct call.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
target_j: JString,
) -> jint { ) -> jint {
if handle == 0 { return -1; } let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = signal_ref(handle); let h = unsafe { handle_ref(handle) };
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default(); let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
match h.mgr.place_call(&target) { let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
Ok(()) => 0, let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
Err(e) => { error!("place_call: {e}"); -1 } let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
h.engine.start_signaling(
&relay_addr,
&seed_hex,
if token.is_empty() { None } else { Some(&token) },
if alias.is_empty() { None } else { Some(&alias) },
)
}));
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
Err(_) => { error!("start_signaling panicked"); -1 }
} }
} }
/// Answer an incoming call. /// Place a direct call to a target fingerprint.
/// Returns 0 on success, -1 on error.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'a>( pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
handle: jlong,
target_fp_j: JString,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
h.engine.place_call(&target)
}));
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
Err(_) => { error!("place_call panicked"); -1 }
}
}
/// Answer an incoming direct call.
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
mut env: JNIEnv<'a>, mut env: JNIEnv<'a>,
_class: JClass, _class: JClass,
handle: jlong, handle: jlong,
call_id_j: JString, call_id_j: JString,
mode: jint, mode: jint,
) -> jint { ) -> jint {
if handle == 0 { return -1; } let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = signal_ref(handle); let h = unsafe { handle_ref(handle) };
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default(); let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
let accept_mode = match mode { let accept_mode = match mode {
0 => wzp_proto::CallAcceptMode::Reject, 0 => wzp_proto::CallAcceptMode::Reject,
1 => wzp_proto::CallAcceptMode::AcceptTrusted, 1 => wzp_proto::CallAcceptMode::AcceptTrusted,
_ => wzp_proto::CallAcceptMode::AcceptGeneric, _ => wzp_proto::CallAcceptMode::AcceptGeneric,
}; };
match h.mgr.answer_call(&call_id, accept_mode) { h.engine.answer_call(&call_id, accept_mode)
Ok(()) => 0, }));
Err(e) => { error!("answer_call: {e}"); -1 }
match result {
Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
Err(_) => { error!("answer_call panicked"); -1 }
} }
} }
/// Send hangup signal.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 { return; }
let h = signal_ref(handle);
h.mgr.hangup();
}
/// Destroy the signal manager and free resources.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 { return; }
let h = signal_ref(handle);
h.mgr.stop();
// Reclaim the Box
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
}

View File

@@ -8,12 +8,24 @@
//! //!
//! On non-Android targets, the Oboe C++ layer compiles as a stub, //! On non-Android targets, the Oboe C++ layer compiles as a stub,
//! allowing `cargo check` and unit tests on the host. //! allowing `cargo check` and unit tests on the host.
//!
//! ## Status
//!
//! **Dead code as of the Tauri mobile rewrite.** The legacy Kotlin+JNI
//! Android app that consumed this crate was replaced by a Tauri 2.x
//! Mobile app (see `desktop/src-tauri/src/engine.rs` for the live
//! Android audio recv path and `crates/wzp-native/` for the Oboe
//! bridge). We keep this crate in the workspace for reference and to
//! preserve the commit history, but it is not built by any shipping
//! target. Allow the accumulated leftover warnings so CI/workspace
//! checks stay clean — any real cleanup should happen as part of
//! removing the crate entirely, not piecemeal.
#![allow(dead_code, unused_imports, unused_variables, unused_mut)]
pub mod audio_android; pub mod audio_android;
pub mod audio_ring; pub mod audio_ring;
pub mod commands; pub mod commands;
pub mod engine; pub mod engine;
pub mod pipeline; pub mod pipeline;
pub mod signal_mgr;
pub mod stats; pub mod stats;
pub mod jni_bridge; pub mod jni_bridge;

View File

@@ -1,288 +0,0 @@
//! Persistent signal connection manager for direct 1:1 calls.
//!
//! Separate from the media engine — survives across calls.
//! Connects to relay via `_signal` SNI, registers presence,
//! and handles call signaling (offer/answer/setup/hangup).
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{error, info, warn};
use wzp_proto::{MediaTransport, SignalMessage};
/// Signal connection status.
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct SignalState {
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
pub fingerprint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_fp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_caller_alias: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_relay: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_room: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_setup_id: Option<String>,
}
/// Manages a persistent `_signal` QUIC connection to a relay.
pub struct SignalManager {
transport: Arc<wzp_transport::QuinnTransport>,
state: Arc<Mutex<SignalState>>,
running: Arc<AtomicBool>,
}
impl SignalManager {
/// Create SignalManager and start connect+register+recv on a background thread.
/// Returns immediately. The internal thread runs forever.
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
let addr: SocketAddr = relay_addr.parse()?;
let seed = if seed_hex.is_empty() {
wzp_crypto::Seed::generate()
} else {
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
};
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
let identity_pub = *pub_id.signing.as_bytes();
let fp = pub_id.fingerprint.to_string();
let state = Arc::new(Mutex::new(SignalState {
status: "connecting".into(),
fingerprint: fp.clone(),
..Default::default()
}));
let running = Arc::new(AtomicBool::new(true));
// Channel to receive transport after connect succeeds
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
let bg_state = Arc::clone(&state);
let bg_running = Arc::clone(&running);
let ret_state = Arc::clone(&state);
let ret_running = Arc::clone(&running);
// ONE thread, ONE runtime, NEVER dropped.
// Connect + register + recv loop all happen here.
std::thread::Builder::new()
.name("wzp-signal".into())
.stack_size(4 * 1024 * 1024)
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
rt.block_on(async move {
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind, None) {
Ok(e) => e,
Err(e) => {
error!("signal endpoint: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
};
let client_cfg = wzp_transport::client_config();
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
Ok(c) => c,
Err(e) => {
error!("signal connect: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
};
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Register
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None,
}).await {
error!("signal register: {e}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
match transport.recv_signal().await {
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
info!(fingerprint = %fp, "signal: registered");
bg_state.lock().unwrap().status = "registered".into();
// Send transport to caller
let _ = transport_tx.send(transport.clone());
}
other => {
error!("signal registration failed: {other:?}");
bg_state.lock().unwrap().status = "idle".into();
return;
}
}
// Recv loop — runs forever
loop {
if !running.load(Ordering::Relaxed) { break; }
match transport.recv_signal().await {
Ok(Some(SignalMessage::CallRinging { call_id })) => {
info!(call_id = %call_id, "signal: ringing");
let mut s = state.lock().unwrap();
s.status = "ringing".into();
}
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
let mut s = state.lock().unwrap();
s.status = "incoming".into();
s.incoming_call_id = Some(call_id);
s.incoming_caller_fp = Some(caller_fingerprint);
s.incoming_caller_alias = caller_alias;
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
let mut s = state.lock().unwrap();
s.status = "setup".into();
s.call_setup_relay = Some(relay_addr);
s.call_setup_room = Some(room);
s.call_setup_id = Some(call_id);
}
Ok(Some(SignalMessage::Hangup { reason })) => {
info!(reason = ?reason, "signal: hangup");
let mut s = state.lock().unwrap();
s.status = "registered".into();
s.incoming_call_id = None;
s.incoming_caller_fp = None;
s.incoming_caller_alias = None;
s.call_setup_relay = None;
s.call_setup_room = None;
s.call_setup_id = None;
}
Ok(Some(_)) => {}
Ok(None) => {
info!("signal: connection closed");
break;
}
Err(e) => {
error!("signal recv error: {e}");
break;
}
}
}
bg_state.lock().unwrap().status = "idle".into();
}); // block_on
// Runtime intentionally NOT dropped — lives until thread exits.
// This prevents ring/libcrypto TLS cleanup conflict on Android.
// The thread is parked here forever (block_on returned = connection lost).
std::thread::park();
})?; // thread spawn
// Wait for transport (up to 10s)
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
Ok(Self { transport, state: ret_state, running: ret_running })
}
/// Get current state (non-blocking).
pub fn get_state(&self) -> SignalState {
self.state.lock().unwrap().clone()
}
/// Get state as JSON string.
pub fn get_state_json(&self) -> String {
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
}
/// Place a direct call.
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
let fp = self.state.lock().unwrap().fingerprint.clone();
let target = target_fp.to_string();
let call_id = format!("{:016x}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
let transport = self.transport.clone();
// Send on a small thread (async send needs a runtime)
std::thread::Builder::new()
.name("wzp-call-send".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: fp,
caller_alias: None,
target_fingerprint: target,
call_id,
identity_pub: [0u8; 32],
ephemeral_pub: [0u8; 32],
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
}).await;
});
})?;
Ok(())
}
/// Answer an incoming call.
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
let call_id = call_id.to_string();
let transport = self.transport.clone();
std::thread::Builder::new()
.name("wzp-answer-send".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
call_id,
accept_mode: mode,
identity_pub: None,
ephemeral_pub: None,
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
}).await;
});
})?;
Ok(())
}
/// Send hangup.
pub fn hangup(&self) {
let transport = self.transport.clone();
let state = self.state.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().expect("rt");
rt.block_on(async {
let _ = transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
}).await;
});
let mut s = state.lock().unwrap();
s.status = "registered".into();
s.incoming_call_id = None;
s.incoming_caller_fp = None;
s.incoming_caller_alias = None;
s.call_setup_relay = None;
s.call_setup_room = None;
s.call_setup_id = None;
});
}
/// Stop the signal connection.
pub fn stop(&self) {
self.running.store(false, Ordering::Release);
self.transport.connection().close(0u32.into(), b"shutdown");
}
}

View File

@@ -23,10 +23,71 @@ serde_json = "1"
chrono = "0.4" chrono = "0.4"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
cpal = { version = "0.15", optional = true } cpal = { version = "0.15", optional = true }
libc = "0.2"
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
# the `vpio` feature from a non-macOS target builds cleanly instead of
# pulling in a crate that can only link against Apple frameworks.
[target.'cfg(target_os = "macos")'.dependencies]
coreaudio-rs = { version = "0.11", optional = true }
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
# only the audio + COM subfeatures we need — the crate is organized as
# a massive optional-feature tree, so enabling just these keeps compile
# times reasonable (~5s for these features vs ~60s for the full crate).
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", optional = true, features = [
"Win32_Foundation",
"Win32_Media_Audio",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Threading",
"Win32_System_Variant",
] }
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
# webrtc-audio-processing` crate, which links against Debian's
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
#
# Note: we attempted the 2.x line with its `bundled` sub-feature first
# (which would give us AEC3 instead of AEC2), but both the crates.io
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
# --reconfigure unconditionally even on first-run empty build dirs,
# causing the bundled build to fail with "Directory does not contain a
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
# this entirely by linking the apt-provided library. AEC2 is older than
# AEC3 but still the same algorithm family — this is what PulseAudio's
# module-echo-cancel and PipeWire's filter-chain use by default on
# current Debian-family distros.
[target.'cfg(target_os = "linux")'.dependencies]
webrtc-audio-processing = { version = "0.3", optional = true }
[features] [features]
default = [] default = []
audio = ["cpal"] audio = ["cpal"]
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
# module is also #[cfg(target_os = "macos")] in lib.rs).
vpio = ["dep:coreaudio-rs"]
# windows-aec enables a direct WASAPI capture backend that opens the
# microphone under AudioCategory_Communications, turning on Windows's
# OS-level communications audio processing (AEC + noise suppression +
# AGC). The `windows` dep is itself target-gated to Windows above, so
# enabling this feature on non-Windows targets is a no-op (the
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
windows-aec = ["dep:windows"]
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
# Teams) in-process, using the playback PCM as the reference signal for
# echo cancellation. The webrtc-audio-processing dep is target-gated to
# Linux above, so enabling this feature on non-Linux targets is a no-op
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
# lib.rs).
linux-aec = ["dep:webrtc-audio-processing"]
[[bin]] [[bin]]
name = "wzp-client" name = "wzp-client"

View File

@@ -3,12 +3,10 @@
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec //! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
//! pipeline. Frames are 960 samples (20 ms at 48 kHz). //! pipeline. Frames are 960 samples (20 ms at 48 kHz).
//! //!
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS //! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
//! thread that owns the stream. The public API exposes only `Send + Sync` //! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
//! channel handles.
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig}; use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::audio_ring::AudioRing;
/// Number of samples per 20 ms frame at 48 kHz mono. /// Number of samples per 20 ms frame at 48 kHz mono.
pub const FRAME_SAMPLES: usize = 960; pub const FRAME_SAMPLES: usize = 960;
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
// AudioCapture // AudioCapture
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Captures microphone input and yields 960-sample PCM frames. /// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
/// ///
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`. /// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
pub struct AudioCapture { pub struct AudioCapture {
rx: mpsc::Receiver<Vec<i16>>, ring: Arc<AudioRing>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
} }
impl AudioCapture { impl AudioCapture {
/// Create and start capturing from the default input device at 48 kHz mono. /// Create and start capturing from the default input device at 48 kHz mono.
pub fn start() -> Result<Self, anyhow::Error> { pub fn start() -> Result<Self, anyhow::Error> {
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64); let ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1); let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("wzp-audio-capture".into()) .name("wzp-audio-capture".into())
@@ -59,53 +61,51 @@ impl AudioCapture {
let use_f32 = !supports_i16_input(&device)?; let use_f32 = !supports_i16_input(&device)?;
let buf = Arc::new(std::sync::Mutex::new(
Vec::<i16>::with_capacity(FRAME_SAMPLES),
));
let err_cb = |e: cpal::StreamError| { let err_cb = |e: cpal::StreamError| {
warn!("input stream error: {e}"); warn!("input stream error: {e}");
}; };
let logged_cb_size = Arc::new(AtomicBool::new(false));
let stream = if use_f32 { let stream = if use_f32 {
let buf = buf.clone(); let ring = ring_cb.clone();
let tx = tx.clone();
let running = running_clone.clone(); let running = running_clone.clone();
let logged = logged_cb_size.clone();
device.build_input_stream( device.build_input_stream(
&config, &config,
move |data: &[f32], _: &cpal::InputCallbackInfo| { move |data: &[f32], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) { if !running.load(Ordering::Relaxed) {
return; return;
} }
let mut lock = buf.lock().unwrap(); if !logged.swap(true, Ordering::Relaxed) {
for &s in data { eprintln!("[audio] capture callback: {} f32 samples", data.len());
lock.push(f32_to_i16(s));
if lock.len() == FRAME_SAMPLES {
let frame = lock.drain(..).collect();
let _ = tx.try_send(frame);
} }
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in data.chunks(FRAME_SAMPLES) {
let n = chunk.len();
for i in 0..n {
tmp[i] = f32_to_i16(chunk[i]);
}
ring.write(&tmp[..n]);
} }
}, },
err_cb, err_cb,
None, None,
)? )?
} else { } else {
let buf = buf.clone(); let ring = ring_cb.clone();
let tx = tx.clone();
let running = running_clone.clone(); let running = running_clone.clone();
let logged = logged_cb_size.clone();
device.build_input_stream( device.build_input_stream(
&config, &config,
move |data: &[i16], _: &cpal::InputCallbackInfo| { move |data: &[i16], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) { if !running.load(Ordering::Relaxed) {
return; return;
} }
let mut lock = buf.lock().unwrap(); if !logged.swap(true, Ordering::Relaxed) {
for &s in data { eprintln!("[audio] capture callback: {} i16 samples", data.len());
lock.push(s);
if lock.len() == FRAME_SAMPLES {
let frame = lock.drain(..).collect();
let _ = tx.try_send(frame);
}
} }
ring.write(data);
}, },
err_cb, err_cb,
None, None,
@@ -114,7 +114,6 @@ impl AudioCapture {
stream.play().context("failed to start input stream")?; stream.play().context("failed to start input stream")?;
// Signal success to the caller before parking.
let _ = init_tx.send(Ok(())); let _ = init_tx.send(Ok(()));
// Keep stream alive until stopped. // Keep stream alive until stopped.
@@ -135,15 +134,12 @@ impl AudioCapture {
.map_err(|_| anyhow!("capture thread exited before signaling"))? .map_err(|_| anyhow!("capture thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
Ok(Self { rx, running }) Ok(Self { ring, running })
} }
/// Read the next frame of 960 PCM samples (blocking until available). /// Get a reference to the capture ring buffer for direct polling.
/// pub fn ring(&self) -> &Arc<AudioRing> {
/// Returns `None` when the stream has been stopped or the channel is &self.ring
/// disconnected.
pub fn read_frame(&self) -> Option<Vec<i16>> {
self.rx.recv().ok()
} }
/// Stop capturing. /// Stop capturing.
@@ -152,26 +148,34 @@ impl AudioCapture {
} }
} }
impl Drop for AudioCapture {
fn drop(&mut self) {
self.stop();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AudioPlayback // AudioPlayback
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Plays PCM frames through the default output device at 48 kHz mono. /// Plays PCM through the default output device, reading from a lock-free ring buffer.
/// ///
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`. /// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
pub struct AudioPlayback { pub struct AudioPlayback {
tx: mpsc::SyncSender<Vec<i16>>, ring: Arc<AudioRing>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
} }
impl AudioPlayback { impl AudioPlayback {
/// Create and start playback on the default output device at 48 kHz mono. /// Create and start playback on the default output device at 48 kHz mono.
pub fn start() -> Result<Self, anyhow::Error> { pub fn start() -> Result<Self, anyhow::Error> {
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64); let ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1); let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("wzp-audio-playback".into()) .name("wzp-audio-playback".into())
@@ -192,62 +196,40 @@ impl AudioPlayback {
let use_f32 = !supports_i16_output(&device)?; let use_f32 = !supports_i16_output(&device)?;
// Shared ring of samples the cpal callback drains from.
let ring = Arc::new(std::sync::Mutex::new(
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
));
// Background drainer: moves frames from the mpsc channel into the ring.
{
let ring = ring.clone();
let running = running_clone.clone();
std::thread::Builder::new()
.name("wzp-playback-drain".into())
.spawn(move || {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(frame) => {
let mut lock = ring.lock().unwrap();
lock.extend(frame);
while lock.len() > FRAME_SAMPLES * 16 {
lock.pop_front();
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
})?;
}
let err_cb = |e: cpal::StreamError| { let err_cb = |e: cpal::StreamError| {
warn!("output stream error: {e}"); warn!("output stream error: {e}");
}; };
let stream = if use_f32 { let stream = if use_f32 {
let ring = ring.clone(); let ring = ring_cb.clone();
device.build_output_stream( device.build_output_stream(
&config, &config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut lock = ring.lock().unwrap(); let mut tmp = [0i16; FRAME_SAMPLES];
for sample in data.iter_mut() { for chunk in data.chunks_mut(FRAME_SAMPLES) {
*sample = match lock.pop_front() { let n = chunk.len();
Some(s) => i16_to_f32(s), let read = ring.read(&mut tmp[..n]);
None => 0.0, for i in 0..read {
}; chunk[i] = i16_to_f32(tmp[i]);
}
// Fill remainder with silence if ring underran
for i in read..n {
chunk[i] = 0.0;
}
} }
}, },
err_cb, err_cb,
None, None,
)? )?
} else { } else {
let ring = ring.clone(); let ring = ring_cb.clone();
device.build_output_stream( device.build_output_stream(
&config, &config,
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
let mut lock = ring.lock().unwrap(); let read = ring.read(data);
for sample in data.iter_mut() { // Fill remainder with silence if ring underran
*sample = lock.pop_front().unwrap_or(0); for sample in &mut data[read..] {
*sample = 0;
} }
}, },
err_cb, err_cb,
@@ -257,7 +239,6 @@ impl AudioPlayback {
stream.play().context("failed to start output stream")?; stream.play().context("failed to start output stream")?;
// Signal success to the caller before parking.
let _ = init_tx.send(Ok(())); let _ = init_tx.send(Ok(()));
// Keep stream alive until stopped. // Keep stream alive until stopped.
@@ -278,12 +259,12 @@ impl AudioPlayback {
.map_err(|_| anyhow!("playback thread exited before signaling"))? .map_err(|_| anyhow!("playback thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
Ok(Self { tx, running }) Ok(Self { ring, running })
} }
/// Write a frame of PCM samples for playback. /// Get a reference to the playout ring buffer for direct writing.
pub fn write_frame(&self, pcm: &[i16]) { pub fn ring(&self) -> &Arc<AudioRing> {
let _ = self.tx.try_send(pcm.to_vec()); &self.ring
} }
/// Stop playback. /// Stop playback.
@@ -292,11 +273,16 @@ impl AudioPlayback {
} }
} }
impl Drop for AudioPlayback {
fn drop(&mut self) {
self.stop();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Check if the input device supports i16 at 48 kHz mono.
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> { fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device let supported = device
.supported_input_configs() .supported_input_configs()
@@ -313,7 +299,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
Ok(false) Ok(false)
} }
/// Check if the output device supports i16 at 48 kHz mono.
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> { fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device let supported = device
.supported_output_configs() .supported_output_configs()

View File

@@ -0,0 +1,537 @@
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
//! Processing Module (AEC3 + noise suppression + high-pass filter).
//!
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
//! identically on ALSA / PulseAudio / PipeWire systems.
//!
//! ## Architecture
//!
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
//! mono):
//!
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
//! output thread, but wraps each chunk in a call to
//! `Processor::process_render_frame` **before** handing it to CPAL. That
//! gives APM an authoritative reference of exactly what's going out to
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
//! what to cancel when it sees echo in the capture stream.
//!
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
//! input thread, and runs `Processor::process_capture_frame` on each
//! incoming mic chunk **in place** before pushing it into the ring
//! buffer. The AEC subtracts the echo using the render reference it
//! saw on the playback side.
//!
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
//! frame is split into two 480-sample halves, APM is called twice, and the
//! halves are stitched back together.
//!
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
//! before the call and f32 → i16 after (with clamping on the return path).
//!
//! ## Stream delay
//!
//! AEC needs to know roughly how long it takes between a sample being passed
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
//! → CPAL capture. AEC3's internal estimator tracks this within a window
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
//! starting point for typical Linux audio stacks; the delay estimator does
//! the fine-tuning automatically.
//!
//! ## Thread safety
//!
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
//! needs a `Mutex` around it for cross-thread sharing. The capture and
//! playback threads each acquire the lock briefly (sub-millisecond per
//! 10 ms frame) so contention is minimal at our frame rates.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{anyhow, Context};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn};
use webrtc_audio_processing::{
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
};
use crate::audio_ring::AudioRing;
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
pub const FRAME_SAMPLES: usize = 960;
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
/// Imported from the webrtc-audio-processing crate so we can't drift out
/// of sync with whatever sample rate / frame length the C++ lib is using.
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
const APM_NUM_CHANNELS: usize = 1;
/// Round-trip delay hint passed to APM; the estimator refines from here.
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
#[allow(dead_code)]
const STREAM_DELAY_MS: i32 = 60;
// ---------------------------------------------------------------------------
// Shared APM instance
// ---------------------------------------------------------------------------
/// Module-level lazily-initialized APM. Shared between capture and playback
/// so they operate on the same echo-cancellation state — the render frames
/// pushed by playback are what the capture path subtracts from the mic input.
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
/// process_capture_frame and process_render_frame.
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
if let Some(p) = PROCESSOR.get() {
return Ok(p.clone());
}
let init_config = InitializationConfig {
num_capture_channels: APM_NUM_CHANNELS as i32,
num_render_channels: APM_NUM_CHANNELS as i32,
..Default::default()
};
let mut processor = Processor::new(&init_config)
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
let config = Config {
echo_cancellation: Some(EchoCancellation {
suppression_level: EchoCancellationSuppressionLevel::High,
stream_delay_ms: Some(STREAM_DELAY_MS),
enable_delay_agnostic: true,
enable_extended_filter: true,
}),
noise_suppression: Some(NoiseSuppression {
suppression_level: NoiseSuppressionLevel::High,
}),
enable_high_pass_filter: true,
// AGC left off for now — it can fight the Opus encoder's own gain
// staging and the adaptive-quality controller. Add later if users
// report low mic levels.
..Default::default()
};
processor.set_config(config);
let arc = Arc::new(Mutex::new(processor));
let _ = PROCESSOR.set(arc.clone());
info!(
stream_delay_ms = STREAM_DELAY_MS,
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
);
Ok(arc)
}
// ---------------------------------------------------------------------------
// Helpers: i16 ↔ f32 and APM frame processing
// ---------------------------------------------------------------------------
#[inline]
fn i16_to_f32(s: i16) -> f32 {
s as f32 / 32768.0
}
#[inline]
fn f32_to_i16(s: f32) -> i16 {
(s.clamp(-1.0, 1.0) * 32767.0) as i16
}
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
/// Splits into two 10 ms halves because APM is strict about frame size.
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
let mut buf = [0f32; APM_FRAME_SAMPLES];
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
for (i, &s) in half.iter().enumerate() {
buf[i] = i16_to_f32(s);
}
match apm.lock() {
Ok(mut p) => {
if let Err(e) = p.process_render_frame(&mut buf) {
warn!("webrtc APM process_render_frame failed: {e:?}");
}
}
Err(_) => {
warn!("webrtc APM mutex poisoned in render path");
return;
}
}
}
}
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
/// results back into the caller's buffer. Briefly holds the Mutex once
/// per 10 ms half.
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
let mut buf = [0f32; APM_FRAME_SAMPLES];
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
for (i, &s) in half.iter().enumerate() {
buf[i] = i16_to_f32(s);
}
match apm.lock() {
Ok(mut p) => {
if let Err(e) = p.process_capture_frame(&mut buf) {
warn!("webrtc APM process_capture_frame failed: {e:?}");
}
}
Err(_) => {
warn!("webrtc APM mutex poisoned in capture path");
return;
}
}
for (i, d) in half.iter_mut().enumerate() {
*d = f32_to_i16(buf[i]);
}
}
}
// ---------------------------------------------------------------------------
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
// ---------------------------------------------------------------------------
/// Microphone capture with WebRTC AEC3 applied in place before the codec
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
/// downstream code doesn't change.
pub struct LinuxAecCapture {
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
}
impl LinuxAecCapture {
pub fn start() -> Result<Self, anyhow::Error> {
// Eagerly init the APM so the playback side can find it already
// configured, and so init errors surface on the caller thread
// instead of silently failing inside the capture thread.
let apm = get_or_init_processor()?;
let ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true));
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
let apm_capture = apm.clone();
std::thread::Builder::new()
.name("wzp-audio-capture-linuxaec".into())
.spawn(move || {
let result = (|| -> Result<(), anyhow::Error> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| anyhow!("no default input audio device found"))?;
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
let config = StreamConfig {
channels: 1,
sample_rate: SampleRate(48_000),
buffer_size: cpal::BufferSize::Default,
};
let use_f32 = !supports_i16_input(&device)?;
let err_cb = |e: cpal::StreamError| {
warn!("LinuxAEC input stream error: {e}");
};
// Leftover buffer for when CPAL gives us partial frames.
// We need exactly 960-sample chunks to feed APM.
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
let stream = if use_f32 {
let ring = ring_cb.clone();
let running = running_clone.clone();
let apm = apm_capture.clone();
device.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) {
return;
}
let mut lv = leftover.lock().unwrap();
lv.reserve(data.len());
for &s in data {
lv.push(f32_to_i16(s));
}
drain_frames_through_apm(&mut lv, &apm, &ring);
},
err_cb,
None,
)?
} else {
let ring = ring_cb.clone();
let running = running_clone.clone();
let apm = apm_capture.clone();
device.build_input_stream(
&config,
move |data: &[i16], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) {
return;
}
let mut lv = leftover.lock().unwrap();
lv.extend_from_slice(data);
drain_frames_through_apm(&mut lv, &apm, &ring);
},
err_cb,
None,
)?
};
stream.play().context("failed to start LinuxAEC input stream")?;
let _ = init_tx.send(Ok(()));
info!("LinuxAEC capture started (AEC3 active)");
while running_clone.load(Ordering::Relaxed) {
std::thread::park_timeout(std::time::Duration::from_millis(200));
}
drop(stream);
Ok(())
})();
if let Err(e) = result {
let _ = init_tx.send(Err(e.to_string()));
}
})?;
init_rx
.recv()
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { ring, running })
}
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for LinuxAecCapture {
fn drop(&mut self) {
self.stop();
}
}
/// Pull whole 960-sample frames out of the leftover buffer, run them through
/// APM's capture-side processing, and push to the ring. Leaves any partial
/// sub-960 remainder in `leftover` for the next callback.
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
let mut frame = [0i16; FRAME_SAMPLES];
while leftover.len() >= FRAME_SAMPLES {
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
process_capture_frame_20ms(apm, &mut frame);
ring.write(&frame);
leftover.drain(..FRAME_SAMPLES);
}
}
// ---------------------------------------------------------------------------
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
// ---------------------------------------------------------------------------
/// Speaker playback with a render-side tee: each frame written to CPAL is
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
/// reference signal. This is the "tee the playback ring" approach (Zoom,
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
/// PipeWire monitor sources.
pub struct LinuxAecPlayback {
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
}
impl LinuxAecPlayback {
pub fn start() -> Result<Self, anyhow::Error> {
let apm = get_or_init_processor()?;
let ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true));
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
let apm_render = apm.clone();
std::thread::Builder::new()
.name("wzp-audio-playback-linuxaec".into())
.spawn(move || {
let result = (|| -> Result<(), anyhow::Error> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| anyhow!("no default output audio device found"))?;
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
let config = StreamConfig {
channels: 1,
sample_rate: SampleRate(48_000),
buffer_size: cpal::BufferSize::Default,
};
let use_f32 = !supports_i16_output(&device)?;
let err_cb = |e: cpal::StreamError| {
warn!("LinuxAEC output stream error: {e}");
};
// Same 960-sample batching approach as the capture side:
// CPAL may ask for N samples in a callback where N doesn't
// divide 960. We accumulate partial frames in a Vec and
// feed APM as soon as we have a whole 20 ms frame.
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
let stream = if use_f32 {
let ring = ring_cb.clone();
let apm = apm_render.clone();
device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
fill_output_and_tee_f32(data, &ring, &apm, &carry);
},
err_cb,
None,
)?
} else {
let ring = ring_cb.clone();
let apm = apm_render.clone();
device.build_output_stream(
&config,
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
fill_output_and_tee_i16(data, &ring, &apm, &carry);
},
err_cb,
None,
)?
};
stream.play().context("failed to start LinuxAEC output stream")?;
let _ = init_tx.send(Ok(()));
info!("LinuxAEC playback started (render tee active)");
while running_clone.load(Ordering::Relaxed) {
std::thread::park_timeout(std::time::Duration::from_millis(200));
}
drop(stream);
Ok(())
})();
if let Err(e) = result {
let _ = init_tx.send(Err(e.to_string()));
}
})?;
init_rx
.recv()
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { ring, running })
}
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for LinuxAecPlayback {
fn drop(&mut self) {
self.stop();
}
}
fn fill_output_and_tee_i16(
data: &mut [i16],
ring: &AudioRing,
apm: &Mutex<Processor>,
carry: &std::sync::Mutex<Vec<i16>>,
) {
let read = ring.read(data);
for s in &mut data[read..] {
*s = 0;
}
tee_render_samples(data, apm, carry);
}
fn fill_output_and_tee_f32(
data: &mut [f32],
ring: &AudioRing,
apm: &Mutex<Processor>,
carry: &std::sync::Mutex<Vec<i16>>,
) {
let mut tmp = vec![0i16; data.len()];
let read = ring.read(&mut tmp);
for s in &mut tmp[read..] {
*s = 0;
}
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
*d = i16_to_f32(s);
}
tee_render_samples(&tmp, apm, carry);
}
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
let mut lv = carry.lock().unwrap();
lv.extend_from_slice(samples);
while lv.len() >= FRAME_SAMPLES {
let mut frame = [0i16; FRAME_SAMPLES];
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
push_render_frame_20ms(apm, &frame);
lv.drain(..FRAME_SAMPLES);
}
}
// ---------------------------------------------------------------------------
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
// independent — each backend file is a self-contained unit)
// ---------------------------------------------------------------------------
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device
.supported_input_configs()
.context("failed to query input configs")?;
for cfg in supported {
if cfg.sample_format() == SampleFormat::I16
&& cfg.min_sample_rate() <= SampleRate(48_000)
&& cfg.max_sample_rate() >= SampleRate(48_000)
&& cfg.channels() >= 1
{
return Ok(true);
}
}
Ok(false)
}
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device
.supported_output_configs()
.context("failed to query output configs")?;
for cfg in supported {
if cfg.sample_format() == SampleFormat::I16
&& cfg.min_sample_rate() <= SampleRate(48_000)
&& cfg.max_sample_rate() >= SampleRate(48_000)
&& cfg.channels() >= 1
{
return Ok(true);
}
}
Ok(false)
}

View File

@@ -0,0 +1,122 @@
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
//!
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
//!
//! On overflow (writer laps the reader), the writer simply overwrites
//! old buffer data. The reader detects the lap via `available() >
//! RING_CAPACITY` and snaps its own `read_pos` forward.
//!
//! Capacity is a power of 2 for bitmask indexing (no modulo).
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
/// Ring buffer capacity — power of 2 for bitmask indexing.
/// 16384 samples = 341.3ms at 48kHz mono.
const RING_CAPACITY: usize = 16384; // 2^14
const RING_MASK: usize = RING_CAPACITY - 1;
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
pub struct AudioRing {
buf: Box<[i16]>,
/// Monotonically increasing write cursor. ONLY written by producer.
write_pos: AtomicUsize,
/// Monotonically increasing read cursor. ONLY written by consumer.
read_pos: AtomicUsize,
/// Incremented by reader when it detects it was lapped (overflow).
overflow_count: AtomicU64,
/// Incremented by reader when ring is empty (underrun).
underrun_count: AtomicU64,
}
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
// The producer only writes write_pos. The consumer only writes read_pos.
// Neither thread writes the other's cursor. Buffer indices are derived from
// the owning thread's cursor, ensuring no concurrent access to the same index.
unsafe impl Send for AudioRing {}
unsafe impl Sync for AudioRing {}
impl AudioRing {
pub fn new() -> Self {
debug_assert!(RING_CAPACITY.is_power_of_two());
Self {
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
}
}
/// Number of samples available to read (clamped to capacity).
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Relaxed);
w.wrapping_sub(r).min(RING_CAPACITY)
}
/// Write samples into the ring. Returns number of samples written.
///
/// If the ring is full, old data is silently overwritten. The reader
/// will detect the lap and self-correct. The writer NEVER touches
/// `read_pos`.
pub fn write(&self, samples: &[i16]) -> usize {
let count = samples.len().min(RING_CAPACITY);
let w = self.write_pos.load(Ordering::Relaxed);
for i in 0..count {
unsafe {
let ptr = self.buf.as_ptr() as *mut i16;
*ptr.add((w + i) & RING_MASK) = samples[i];
}
}
self.write_pos
.store(w.wrapping_add(count), Ordering::Release);
count
}
/// Read samples from the ring into `out`. Returns number of samples read.
///
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
/// forward to the oldest valid data.
pub fn read(&self, out: &mut [i16]) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let mut r = self.read_pos.load(Ordering::Relaxed);
let mut avail = w.wrapping_sub(r);
// Lap detection: writer has overwritten our unread data.
if avail > RING_CAPACITY {
r = w.wrapping_sub(RING_CAPACITY);
avail = RING_CAPACITY;
self.overflow_count.fetch_add(1, Ordering::Relaxed);
}
let count = out.len().min(avail);
if count == 0 {
if w == r {
self.underrun_count.fetch_add(1, Ordering::Relaxed);
}
return 0;
}
for i in 0..count {
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
}
self.read_pos
.store(r.wrapping_add(count), Ordering::Release);
count
}
/// Number of overflow events (reader was lapped by writer).
pub fn overflow_count(&self) -> u64 {
self.overflow_count.load(Ordering::Relaxed)
}
/// Number of underrun events (reader found empty buffer).
pub fn underrun_count(&self) -> u64 {
self.underrun_count.load(Ordering::Relaxed)
}
}

View File

@@ -0,0 +1,179 @@
//! macOS Voice Processing I/O — uses Apple's VoiceProcessingIO audio unit
//! for hardware-accelerated echo cancellation, AGC, and noise suppression.
//!
//! VoiceProcessingIO is a combined input+output unit that knows what's going
//! to the speaker, so it can cancel the echo from the mic signal internally.
//! This is the same engine FaceTime and other Apple apps use.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::Context;
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
use coreaudio::sys;
use tracing::info;
use crate::audio_ring::AudioRing;
/// Number of samples per 20 ms frame at 48 kHz mono.
pub const FRAME_SAMPLES: usize = 960;
/// Combined capture + playback via macOS VoiceProcessingIO.
///
/// The OS handles AEC internally — no manual far-end feeding needed.
pub struct VpioAudio {
capture_ring: Arc<AudioRing>,
playout_ring: Arc<AudioRing>,
_audio_unit: AudioUnit,
running: Arc<AtomicBool>,
}
impl VpioAudio {
/// Start VoiceProcessingIO with AEC enabled.
pub fn start() -> Result<Self, anyhow::Error> {
let capture_ring = Arc::new(AudioRing::new());
let playout_ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true));
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
.context("failed to create VoiceProcessingIO audio unit")?;
// Must uninitialize before configuring properties.
au.uninitialize()
.context("failed to uninitialize VPIO for configuration")?;
// Enable input (mic) on Element::Input (bus 1).
let enable: u32 = 1;
au.set_property(
sys::kAudioOutputUnitProperty_EnableIO,
Scope::Input,
Element::Input,
Some(&enable),
)
.context("failed to enable VPIO input")?;
// Output (speaker) is enabled by default on VPIO, but be explicit.
au.set_property(
sys::kAudioOutputUnitProperty_EnableIO,
Scope::Output,
Element::Output,
Some(&enable),
)
.context("failed to enable VPIO output")?;
// Configure stream format: 48kHz mono f32 non-interleaved
let stream_format = StreamFormat {
sample_rate: 48_000.0,
sample_format: SampleFormat::F32,
flags: LinearPcmFlags::IS_FLOAT
| LinearPcmFlags::IS_PACKED
| LinearPcmFlags::IS_NON_INTERLEAVED,
channels: 1,
};
let asbd = stream_format.to_asbd();
// Input: set format on Output scope of Input element
// (= the format the AU delivers to us from the mic)
au.set_property(
sys::kAudioUnitProperty_StreamFormat,
Scope::Output,
Element::Input,
Some(&asbd),
)
.context("failed to set input stream format")?;
// Output: set format on Input scope of Output element
// (= the format we feed to the AU for the speaker)
au.set_property(
sys::kAudioUnitProperty_StreamFormat,
Scope::Input,
Element::Output,
Some(&asbd),
)
.context("failed to set output stream format")?;
// Set up input callback (mic capture with AEC applied)
let cap_ring = capture_ring.clone();
let cap_running = running.clone();
let logged = Arc::new(AtomicBool::new(false));
au.set_input_callback(
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
if !cap_running.load(Ordering::Relaxed) {
return Ok(());
}
let mut buffers = args.data.channels();
if let Some(ch) = buffers.next() {
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
}
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in ch.chunks(FRAME_SAMPLES) {
let n = chunk.len();
for i in 0..n {
tmp[i] = (chunk[i].clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
}
cap_ring.write(&tmp[..n]);
}
}
Ok(())
},
)
.context("failed to set input callback")?;
// Set up output callback (speaker playback — AEC uses this as reference)
let play_ring = playout_ring.clone();
au.set_render_callback(
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
let mut buffers = args.data.channels_mut();
if let Some(ch) = buffers.next() {
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
let n = chunk.len();
let read = play_ring.read(&mut tmp[..n]);
for i in 0..read {
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
}
for i in read..n {
chunk[i] = 0.0;
}
}
}
Ok(())
},
)
.context("failed to set render callback")?;
au.initialize().context("failed to initialize VoiceProcessingIO")?;
au.start().context("failed to start VoiceProcessingIO")?;
info!("VoiceProcessingIO started (OS-level AEC enabled)");
Ok(Self {
capture_ring,
playout_ring,
_audio_unit: au,
running,
})
}
pub fn capture_ring(&self) -> &Arc<AudioRing> {
&self.capture_ring
}
pub fn playout_ring(&self) -> &Arc<AudioRing> {
&self.playout_ring
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for VpioAudio {
fn drop(&mut self) {
self.stop();
}
}

View File

@@ -0,0 +1,332 @@
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
//!
//! Bypasses CPAL and opens the default capture endpoint directly via
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
//! the switch that tells Windows "this is a VoIP call" — the OS then
//! enables its communications audio processing chain (AEC, noise
//! suppression, automatic gain control) for the stream. AEC operates at
//! the OS level using the currently-playing audio as the reference
//! signal, so it cancels echo from our CPAL playback (and any other app's
//! audio) without us having to plumb a reference signal ourselves.
//!
//! Platform: Windows only, compiled only when the `windows-aec` feature
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
//! `wzp-client`'s lib.rs can transparently re-export either one as
//! `AudioCapture`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, Context};
use tracing::{info, warn};
use windows::core::{Interface, GUID};
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
use windows::Win32::Media::Audio::{
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
WAVE_FORMAT_PCM,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
use crate::audio_ring::AudioRing;
/// 20 ms at 48 kHz, mono. Matches the rest of the audio pipeline.
pub const FRAME_SAMPLES: usize = 960;
/// Microphone capture via WASAPI with Windows's communications AEC enabled.
///
/// The WASAPI capture stream runs on a dedicated OS thread. This handle is
/// `Send + Sync`. Dropping it stops the stream and joins the thread.
pub struct WasapiAudioCapture {
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl WasapiAudioCapture {
/// Open the default communications microphone, enable OS AEC, and start
/// streaming PCM into a lock-free ring buffer.
///
/// Returns only after the capture thread has successfully initialized
/// the stream, or propagates the error back to the caller.
pub fn start() -> Result<Self, anyhow::Error> {
let ring = Arc::new(AudioRing::new());
let running = Arc::new(AtomicBool::new(true));
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_cb = running.clone();
let thread = std::thread::Builder::new()
.name("wzp-audio-capture-wasapi".into())
.spawn(move || {
let result = unsafe { capture_thread_main(ring_cb, running_cb.clone(), &init_tx) };
if let Err(e) = result {
warn!("wasapi capture thread exited with error: {e}");
// If we failed before signaling init, signal now so the
// caller unblocks. Double-send is harmless (channel is
// bounded to 1 and we only hit the second send path on
// late errors).
let _ = init_tx.send(Err(e.to_string()));
}
})
.context("failed to spawn WASAPI capture thread")?;
init_rx
.recv()
.map_err(|_| anyhow!("WASAPI capture thread exited before signaling init"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self {
ring,
running,
thread: Some(thread),
})
}
/// Get a reference to the capture ring buffer for direct polling.
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
}
/// Stop capturing.
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for WasapiAudioCapture {
fn drop(&mut self) {
self.stop();
if let Some(handle) = self.thread.take() {
// Join best-effort. The thread loop polls `running` every 200ms
// via a short WaitForSingleObject timeout, so it should exit
// within ~200ms of `stop()`.
let _ = handle.join();
}
}
}
// ---------------------------------------------------------------------------
// WASAPI thread entry point — everything below this line runs on the
// dedicated wzp-audio-capture-wasapi thread.
// ---------------------------------------------------------------------------
unsafe fn capture_thread_main(
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
init_tx: &std::sync::mpsc::SyncSender<Result<(), String>>,
) -> Result<(), anyhow::Error> {
// COM init for the capture thread. MULTITHREADED because we're not
// running a message pump. Must be balanced by CoUninitialize on exit.
CoInitializeEx(None, COINIT_MULTITHREADED)
.ok()
.context("CoInitializeEx failed")?;
// Use a guard struct so CoUninitialize runs even on early returns.
struct ComGuard;
impl Drop for ComGuard {
fn drop(&mut self) {
unsafe { CoUninitialize() };
}
}
let _com_guard = ComGuard;
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
// eCommunications role (not eConsole) — this picks the device the user
// has designated for communications in Sound Settings. It's the one
// Windows's AEC is actually tuned for and the one Teams/Zoom use.
let device = enumerator
.GetDefaultAudioEndpoint(eCapture, eCommunications)
.context("GetDefaultAudioEndpoint(eCapture, eCommunications) failed")?;
if let Ok(name) = device_name(&device) {
info!(device = %name, "opening WASAPI communications capture endpoint");
}
let audio_client: IAudioClient = device
.Activate(CLSCTX_ALL, None)
.context("IMMDevice::Activate(IAudioClient) failed")?;
// IAudioClient2 exposes SetClientProperties, which is the ONLY way to
// set AudioCategory_Communications pre-Initialize. Calling it on the
// base IAudioClient would not compile, and setting it after Initialize
// is a no-op.
let audio_client2: IAudioClient2 = audio_client
.cast()
.context("QueryInterface IAudioClient2 failed")?;
let mut props = AudioClientProperties {
cbSize: std::mem::size_of::<AudioClientProperties>() as u32,
bIsOffload: BOOL(0),
eCategory: AudioCategory_Communications,
// 0 = AUDCLNT_STREAMOPTIONS_NONE. The `windows` crate doesn't
// export the enum constant in all versions, so use 0 directly.
Options: Default::default(),
};
audio_client2
.SetClientProperties(&mut props as *mut _)
.context("SetClientProperties(AudioCategory_Communications) failed")?;
// Request 48 kHz mono i16 directly. AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
// tells Windows to do any needed format conversion inside the audio
// engine rather than rejecting our format. SRC_DEFAULT_QUALITY picks
// the standard Windows resampler quality (fine for voice).
let wave_format = WAVEFORMATEX {
wFormatTag: WAVE_FORMAT_PCM as u16,
nChannels: 1,
nSamplesPerSec: 48_000,
nAvgBytesPerSec: 48_000 * 2, // 1 ch * 2 bytes/sample * 48000 Hz
nBlockAlign: 2, // 1 ch * 2 bytes/sample
wBitsPerSample: 16,
cbSize: 0,
};
// 1,000,000 hns = 100 ms buffer (hns = 100-nanosecond units). Windows
// treats this as the minimum; the engine may give us a larger one.
const BUFFER_DURATION_HNS: i64 = 1_000_000;
audio_client
.Initialize(
AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
BUFFER_DURATION_HNS,
0,
&wave_format,
Some(&GUID::zeroed()),
)
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
// Event-driven capture: Windows signals this handle each time a new
// audio packet is available. We wait on it from the loop below.
let event = CreateEventW(None, false, false, None)
.context("CreateEventW failed")?;
audio_client
.SetEventHandle(event)
.context("SetEventHandle failed")?;
let capture_client: IAudioCaptureClient = audio_client
.GetService()
.context("IAudioClient::GetService(IAudioCaptureClient) failed")?;
audio_client.Start().context("IAudioClient::Start failed")?;
// Signal to the parent thread that init succeeded before entering the
// hot loop. From this point on, errors get logged but don't propagate
// back to the caller (they'd just cause the ring buffer to stop
// filling, which the main thread detects as underruns).
let _ = init_tx.send(Ok(()));
info!("WASAPI communications-mode capture started with OS AEC enabled");
let mut logged_first_packet = false;
// Main capture loop. Exit when `running` goes false (from Drop or an
// explicit stop() call).
while running.load(Ordering::Relaxed) {
// 200 ms timeout so we check `running` regularly even if the audio
// engine stops delivering packets (e.g. device unplugged).
let wait = WaitForSingleObject(event, 200);
if wait.0 != WAIT_OBJECT_0.0 {
// Timeout or failure — just loop and re-check running.
continue;
}
// Drain all available packets. Windows may have queued more than
// one since we were last scheduled.
loop {
let packet_length = match capture_client.GetNextPacketSize() {
Ok(n) => n,
Err(e) => {
warn!("GetNextPacketSize failed: {e}");
break;
}
};
if packet_length == 0 {
break;
}
let mut buffer_ptr: *mut u8 = std::ptr::null_mut();
let mut num_frames: u32 = 0;
let mut flags: u32 = 0;
let mut device_position: u64 = 0;
let mut qpc_position: u64 = 0;
if let Err(e) = capture_client.GetBuffer(
&mut buffer_ptr,
&mut num_frames,
&mut flags,
Some(&mut device_position),
Some(&mut qpc_position),
) {
warn!("GetBuffer failed: {e}");
break;
}
if num_frames > 0 && !buffer_ptr.is_null() {
if !logged_first_packet {
info!(
frames = num_frames,
flags, "WASAPI capture: first packet received"
);
logged_first_packet = true;
}
// Because we asked for 48 kHz mono i16, each frame is
// exactly one i16. Windows's AUTOCONVERTPCM handles the
// conversion from whatever the engine mix format is.
let samples = std::slice::from_raw_parts(
buffer_ptr as *const i16,
num_frames as usize,
);
ring.write(samples);
}
if let Err(e) = capture_client.ReleaseBuffer(num_frames) {
warn!("ReleaseBuffer failed: {e}");
break;
}
}
}
info!("WASAPI capture thread stopping");
let _ = audio_client.Stop();
let _ = CloseHandle(event);
// _com_guard drops here, calling CoUninitialize.
// Silence INFINITE unused-import warning — it's referenced by the
// `windows` crate's WaitForSingleObject alternative but we use the
// 200 ms timeout variant instead. Explicit suppression for clarity.
let _ = INFINITE;
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Best-effort device ID string for logging. Grabbing the friendly name via
/// PKEY_Device_FriendlyName requires IPropertyStore + PROPVARIANT plumbing
/// that's far more ceremony than a log line justifies; the ID is already
/// sufficient to confirm we opened the right endpoint.
///
/// Rust 2024 edition's `unsafe_op_in_unsafe_fn` lint requires explicit
/// `unsafe { ... }` blocks inside `unsafe fn` bodies for each unsafe call,
/// even though the whole function is already marked unsafe.
unsafe fn device_name(
device: &windows::Win32::Media::Audio::IMMDevice,
) -> Result<String, anyhow::Error> {
let id = unsafe { device.GetId() }.context("IMMDevice::GetId failed")?;
Ok(unsafe { id.to_string() }.unwrap_or_else(|_| "<non-utf16>".to_string()))
}

View File

@@ -43,6 +43,9 @@ pub struct CallConfig {
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader; /// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
/// intermediate frames use a compact 4-byte MiniHeader. /// intermediate frames use a compact 4-byte MiniHeader.
pub mini_frames_enabled: bool, pub mini_frames_enabled: bool,
/// AEC far-end delay compensation in milliseconds (default: 40).
/// Compensates for the round-trip audio latency from playout to mic capture.
pub aec_delay_ms: u32,
/// Enable adaptive jitter buffer (default: true). /// Enable adaptive jitter buffer (default: true).
/// ///
/// When true, the jitter buffer target depth is automatically adjusted /// When true, the jitter buffer target depth is automatically adjusted
@@ -64,6 +67,7 @@ impl Default for CallConfig {
noise_suppression: true, noise_suppression: true,
mini_frames_enabled: true, mini_frames_enabled: true,
adaptive_jitter: true, adaptive_jitter: true,
aec_delay_ms: 40,
} }
} }
} }
@@ -242,7 +246,7 @@ impl CallEncoder {
block_id: 0, block_id: 0,
frame_in_block: 0, frame_in_block: 0,
timestamp_ms: 0, timestamp_ms: 0,
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail aec: EchoCanceller::with_delay(48000, 60, config.aec_delay_ms),
agc: AutoGainControl::new(), agc: AutoGainControl::new(),
silence_detector: SilenceDetector::new( silence_detector: SilenceDetector::new(
config.silence_threshold_rms, config.silence_threshold_rms,
@@ -593,6 +597,52 @@ impl CallDecoder {
} }
} }
/// Switch the decoder to match an incoming packet's codec if it differs
/// from the current profile. This enables cross-codec interop (e.g. one
/// client sends Opus, the other sends Codec2).
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
return;
}
let new_profile = Self::profile_for_codec(incoming_codec);
info!(
from = ?self.profile.codec,
to = ?incoming_codec,
"decoder switching codec to match incoming packet"
);
if let Err(e) = self.audio_dec.set_profile(new_profile) {
warn!("failed to switch decoder profile: {e}");
return;
}
self.fec_dec = wzp_fec::create_decoder(&new_profile);
self.profile = new_profile;
}
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
fn profile_for_codec(codec: CodecId) -> QualityProfile {
match codec {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus16k => QualityProfile {
codec: CodecId::Opus16k,
fec_ratio: 0.3,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Opus32k => QualityProfile::STUDIO_32K,
CodecId::Opus48k => QualityProfile::STUDIO_48K,
CodecId::Opus64k => QualityProfile::STUDIO_64K,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::ComfortNoise => QualityProfile::GOOD,
}
}
/// Decode the next audio frame from the jitter buffer. /// Decode the next audio frame from the jitter buffer.
/// ///
/// Returns PCM samples (48kHz mono) or None if not ready. /// Returns PCM samples (48kHz mono) or None if not ready.
@@ -607,6 +657,9 @@ impl CallDecoder {
return Some(pcm.len()); return Some(pcm.len());
} }
// Auto-switch decoder if incoming codec differs from current.
self.switch_decoder_if_needed(pkt.header.codec_id);
self.last_was_cn = false; self.last_was_cn = false;
let result = match self.audio_dec.decode(&pkt.payload, pcm) { let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n), Ok(n) => Some(n),

View File

@@ -626,11 +626,21 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
.spawn(move || { .spawn(move || {
let config = CallConfig::default(); let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let mut frame = vec![0i16; FRAME_SAMPLES];
loop { loop {
let frame = match capture.read_frame() { // Pull a full 20 ms frame from the capture ring. The ring
Some(f) => f, // may return a partial read when the CPAL callback hasn't
None => break, // produced enough samples yet — keep reading until we
}; // accumulate a whole frame, sleeping briefly on empty
// returns so we don't hot-spin the CPU.
let mut filled = 0usize;
while filled < FRAME_SAMPLES {
let n = capture.ring().read(&mut frame[filled..]);
filled += n;
if n == 0 {
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
let packets = match encoder.encode_frame(&frame) { let packets = match encoder.encode_frame(&frame) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -661,7 +671,13 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
// Repair packets feed the FEC decoder but don't produce audio. // Repair packets feed the FEC decoder but don't produce audio.
if !is_repair { if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) { if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
playback.write_frame(&pcm_buf); // Push the decoded frame into the playback
// ring. The CPAL output callback drains from
// here on its own clock; if the ring is full
// (rare in CLI live mode) the write returns
// a short count and the tail is dropped,
// which is the correct real-time behavior.
playback.ring().write(&pcm_buf);
} }
} }
} }
@@ -760,7 +776,6 @@ async fn run_signal_mode(
// Signal recv loop — handle incoming signals // Signal recv loop — handle incoming signals
let signal_transport = transport.clone(); let signal_transport = transport.clone();
let relay = relay_addr; let relay = relay_addr;
let my_fp = fp.clone();
let my_seed = seed.0; let my_seed = seed.0;
loop { loop {

View File

@@ -120,6 +120,12 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::CallRinging { .. } => CallSignalType::Ringing, SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
SignalMessage::RegisterPresence { .. } SignalMessage::RegisterPresence { .. }
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only | SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
// NAT reflection is a client↔relay control exchange that
// never crosses the featherChat bridge — if it ever reaches
// this mapper something is wrong, but we still have to give
// an answer. "Offer" is the generic catch-all.
SignalMessage::Reflect
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
} }
} }

View File

@@ -8,6 +8,24 @@
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
pub mod audio_io; pub mod audio_io;
#[cfg(feature = "audio")]
pub mod audio_ring;
// VoiceProcessingIO is an Apple Core Audio API — only compile the module
// when the `vpio` feature is on AND we're targeting macOS. Enabling the
// feature on Windows/Linux was previously silently broken.
#[cfg(all(feature = "vpio", target_os = "macos"))]
pub mod audio_vpio;
// WASAPI-direct capture with Windows's OS-level AEC (AudioCategory_Communications).
// Only compiled when `windows-aec` feature is on AND target is Windows. The
// `windows` dependency is itself gated to Windows in Cargo.toml, so enabling
// this feature on non-Windows targets is a no-op.
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
pub mod audio_wasapi;
// WebRTC AEC3 (Audio Processing Module) wrapper around CPAL capture + playback
// on Linux. Only compiled when `linux-aec` feature is on AND target is Linux.
// The webrtc-audio-processing dep is itself gated to Linux in Cargo.toml.
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
pub mod audio_linux_aec;
pub mod bench; pub mod bench;
pub mod call; pub mod call;
pub mod drift_test; pub mod drift_test;
@@ -17,7 +35,48 @@ pub mod handshake;
pub mod metrics; pub mod metrics;
pub mod sweep; pub mod sweep;
#[cfg(feature = "audio")] // AudioPlayback: three possible backends depending on feature flags.
pub use audio_io::{AudioCapture, AudioPlayback}; // 1. Default CPAL (`audio_io::AudioPlayback`) — baseline on every platform.
// 2. Linux AEC (`audio_linux_aec::LinuxAecPlayback`) — CPAL + WebRTC APM
// render-side tee, so echo from speakers gets cancelled from the mic.
//
// On macOS and Windows we always use the default CPAL playback because:
// - macOS: VoiceProcessingIO handles AEC at the capture side (Apple's
// native hardware AEC uses its own reference signal handling).
// - Windows: WASAPI AudioCategory_Communications AEC uses the system
// render mix as reference — no per-process plumbing needed.
//
// Linux is the only platform where the in-app approach is necessary, so
// the AEC playback path is gated to target_os = "linux".
#[cfg(all(
feature = "audio",
any(not(feature = "linux-aec"), not(target_os = "linux"))
))]
pub use audio_io::AudioPlayback;
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
pub use audio_linux_aec::LinuxAecPlayback as AudioPlayback;
// AudioCapture: three possible backends depending on feature flags.
// 1. Default CPAL (`audio_io::AudioCapture`) — baseline on every platform.
// 2. Windows AEC (`audio_wasapi::WasapiAudioCapture`) — direct WASAPI
// with AudioCategory_Communications, OS APO chain does AEC.
// 3. Linux AEC (`audio_linux_aec::LinuxAecCapture`) — CPAL + WebRTC APM
// capture-side echo cancellation using the playback tee as reference.
// All three expose the same public API (`start`, `ring`, `stop`, `Drop`).
#[cfg(all(
feature = "audio",
any(not(feature = "windows-aec"), not(target_os = "windows")),
any(not(feature = "linux-aec"), not(target_os = "linux"))
))]
pub use audio_io::AudioCapture;
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
pub use audio_wasapi::WasapiAudioCapture as AudioCapture;
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
pub use audio_linux_aec::LinuxAecCapture as AudioCapture;
pub use call::{CallConfig, CallDecoder, CallEncoder}; pub use call::{CallConfig, CallDecoder, CallEncoder};
pub use handshake::perform_handshake; pub use handshake::perform_handshake;

View File

@@ -83,12 +83,12 @@ async fn full_handshake_both_sides_derive_same_session() {
// Run client and relay handshakes concurrently. // Run client and relay handshakes concurrently.
let (client_result, relay_result) = tokio::join!( let (client_result, relay_result) = tokio::join!(
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed), wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed, None),
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed), wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
); );
let mut client_session = client_result.expect("client handshake should succeed"); let mut client_session = client_result.expect("client handshake should succeed");
let (mut relay_session, chosen_profile) = let (mut relay_session, chosen_profile, _caller_fp, _caller_alias) =
relay_result.expect("relay handshake should succeed"); relay_result.expect("relay handshake should succeed");
// Verify a profile was chosen. // Verify a profile was chosen.
@@ -151,6 +151,7 @@ async fn handshake_rejects_tampered_signature() {
ephemeral_pub, ephemeral_pub,
signature: bad_signature, signature: bad_signature,
supported_profiles: vec![wzp_proto::QualityProfile::GOOD], supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
alias: None,
}; };
client_transport_clone client_transport_clone
.send_signal(&offer) .send_signal(&offer)

View File

@@ -1,53 +1,127 @@
//! Acoustic Echo Cancellation using NLMS adaptive filter. //! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
//! Processes 480-sample (10ms) sub-frames at 48kHz. //! Geigel double-talk detection.
//!
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
//! → air → mic → capture) is 3050ms. The far-end reference must be delayed
//! by this amount so the adaptive filter models the *echo path*, not the
//! *system delay + echo path*.
//!
//! The leaky coefficient decay prevents the filter from diverging when the
//! echo path changes (e.g. hand near laptop) or when the delay estimate
//! is slightly off.
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller. /// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
///
/// Removes acoustic echo by modelling the echo path between the far-end
/// (speaker) signal and the near-end (microphone) signal, then subtracting
/// the estimated echo from the near-end in real time.
pub struct EchoCanceller { pub struct EchoCanceller {
filter_coeffs: Vec<f32>, // --- Adaptive filter ---
filter: Vec<f32>,
filter_len: usize, filter_len: usize,
far_end_buf: Vec<f32>, /// Circular buffer of far-end reference samples (after delay).
far_end_pos: usize, far_buf: Vec<f32>,
far_pos: usize,
/// NLMS step size.
mu: f32, mu: f32,
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
leak: f32,
enabled: bool, enabled: bool,
// --- Delay buffer ---
/// Raw far-end samples before delay compensation.
delay_ring: Vec<f32>,
delay_write: usize,
delay_read: usize,
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
delay_samples: usize,
/// Capacity of the delay ring.
delay_cap: usize,
// --- Double-talk detection (Geigel) ---
/// Peak far-end level over the last filter_len samples.
far_peak: f32,
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
geigel_threshold: f32,
/// Holdover counter: keep DTD active for a few frames after detection.
dtd_holdover: u32,
dtd_hold_frames: u32,
} }
impl EchoCanceller { impl EchoCanceller {
/// Create a new echo canceller. /// Create a new echo canceller.
/// ///
/// * `sample_rate` — typically 48000 /// * `sample_rate` — typically 48000
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms) /// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
pub fn new(sample_rate: u32, filter_ms: u32) -> Self { pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
Self::with_delay(sample_rate, filter_ms, 40)
}
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000; let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
Self { Self {
filter_coeffs: vec![0.0f32; filter_len], filter: vec![0.0; filter_len],
filter_len, filter_len,
far_end_buf: vec![0.0f32; filter_len], far_buf: vec![0.0; filter_len],
far_end_pos: 0, far_pos: 0,
mu: 0.01, mu: 0.01,
leak: 0.0001,
enabled: true, enabled: true,
delay_ring: vec![0.0; delay_cap],
delay_write: 0,
delay_read: 0,
delay_samples,
delay_cap,
far_peak: 0.0,
geigel_threshold: 0.7,
dtd_holdover: 0,
dtd_hold_frames: 5,
} }
} }
/// Feed far-end (speaker/playback) samples into the circular buffer. /// Feed far-end (speaker) samples. These go into the delay buffer first;
/// /// once enough samples have accumulated, they are released to the filter's
/// Must be called with the audio that was played out through the speaker /// circular buffer with the correct delay offset.
/// *before* the corresponding near-end frame is processed.
pub fn feed_farend(&mut self, farend: &[i16]) { pub fn feed_farend(&mut self, farend: &[i16]) {
// Write raw samples into the delay ring.
for &s in farend { for &s in farend {
self.far_end_buf[self.far_end_pos] = s as f32; self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len; self.delay_write += 1;
}
// Release delayed samples to the filter's far-end buffer.
while self.delay_available() >= 1 {
let sample = self.delay_ring[self.delay_read % self.delay_cap];
self.delay_read += 1;
self.far_buf[self.far_pos] = sample;
self.far_pos = (self.far_pos + 1) % self.filter_len;
// Track peak far-end level for Geigel DTD.
let abs_s = sample.abs();
if abs_s > self.far_peak {
self.far_peak = abs_s;
}
}
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
self.far_peak *= 0.9995;
}
/// Number of delayed samples available to release.
fn delay_available(&self) -> usize {
let buffered = self.delay_write - self.delay_read;
if buffered > self.delay_samples {
buffered - self.delay_samples
} else {
0
} }
} }
/// Process a near-end (microphone) frame, removing the estimated echo. /// Process a near-end (microphone) frame, removing the estimated echo.
///
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
/// the original near-end divided by the RMS of the residual. Values > 1.0
/// mean echo was reduced.
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 { pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
if !self.enabled { if !self.enabled {
return 1.0; return 1.0;
@@ -56,85 +130,96 @@ impl EchoCanceller {
let n = nearend.len(); let n = nearend.len();
let fl = self.filter_len; let fl = self.filter_len;
// --- Geigel double-talk detection ---
// If any near-end sample exceeds threshold * far_peak, assume
// the local speaker is active and freeze adaptation.
let mut is_doubletalk = self.dtd_holdover > 0;
if !is_doubletalk {
let threshold_level = self.geigel_threshold * self.far_peak;
for &s in nearend.iter() {
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
is_doubletalk = true;
self.dtd_holdover = self.dtd_hold_frames;
break;
}
}
}
if self.dtd_holdover > 0 {
self.dtd_holdover -= 1;
}
// Check if far-end is active (otherwise nothing to cancel).
let far_active = self.far_peak > 100.0;
// --- Leaky coefficient decay ---
// Applied once per frame for efficiency.
let decay = 1.0 - self.leak;
for c in self.filter.iter_mut() {
*c *= decay;
}
let mut sum_near_sq: f64 = 0.0; let mut sum_near_sq: f64 = 0.0;
let mut sum_err_sq: f64 = 0.0; let mut sum_err_sq: f64 = 0.0;
for i in 0..n { for i in 0..n {
let near_f = nearend[i] as f32; let near_f = nearend[i] as f32;
// --- estimate echo as dot(coeffs, farend_window) --- // Position of far-end "now" for this near-end sample.
// The far-end window for this sample starts at let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
// (far_end_pos - 1 - i) mod filter_len (most recent)
// and goes back filter_len samples. // --- Echo estimation: dot(filter, far_end_window) ---
let mut echo_est: f32 = 0.0; let mut echo_est: f32 = 0.0;
let mut power: f32 = 0.0; let mut power: f32 = 0.0;
// Position of the most-recent far-end sample for this near-end sample.
// far_end_pos points to the *next write* position, so the most-recent
// sample written is at far_end_pos - 1. We have already called
// feed_farend for this block, so the relevant samples are the last
// filter_len entries ending just before the current write position,
// offset by how far we are into this near-end frame.
//
// For sample i of the near-end frame, the corresponding far-end
// "now" is far_end_pos - n + i (wrapping).
// far_end_pos points to next-write, so most recent sample is at
// far_end_pos - 1. For the i-th near-end sample we want the
// far-end "now" to be at (far_end_pos - n + i). We add fl
// repeatedly to avoid underflow on the usize subtraction.
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
for k in 0..fl { for k in 0..fl {
let fe_idx = (base + fl - k) % fl; let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx]; let fe = self.far_buf[fe_idx];
echo_est += self.filter_coeffs[k] * fe; echo_est += self.filter[k] * fe;
power += fe * fe; power += fe * fe;
} }
let error = near_f - echo_est; let error = near_f - echo_est;
// --- NLMS coefficient update --- // --- NLMS adaptation (only when far-end active & no double-talk) ---
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero if far_active && !is_doubletalk && power > 10.0 {
let step = self.mu * error / norm; let step = self.mu * error / (power + 1.0);
for k in 0..fl { for k in 0..fl {
let fe_idx = (base + fl - k) % fl; let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx]; self.filter[k] += step * self.far_buf[fe_idx];
self.filter_coeffs[k] += step * fe; }
} }
// Clamp output let out = error.clamp(-32768.0, 32767.0);
let out = error.max(-32768.0).min(32767.0);
nearend[i] = out as i16; nearend[i] = out as i16;
sum_near_sq += (near_f as f64) * (near_f as f64); sum_near_sq += (near_f as f64).powi(2);
sum_err_sq += (out as f64) * (out as f64); sum_err_sq += (out as f64).powi(2);
} }
// ERLE ratio
if sum_err_sq < 1.0 { if sum_err_sq < 1.0 {
return 100.0; // near-perfect cancellation 100.0
} } else {
(sum_near_sq / sum_err_sq).sqrt() as f32 (sum_near_sq / sum_err_sq).sqrt() as f32
} }
}
/// Enable or disable echo cancellation.
pub fn set_enabled(&mut self, enabled: bool) { pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled; self.enabled = enabled;
} }
/// Returns whether echo cancellation is currently enabled.
pub fn is_enabled(&self) -> bool { pub fn is_enabled(&self) -> bool {
self.enabled self.enabled
} }
/// Reset the adaptive filter to its initial state.
///
/// Zeroes out all filter coefficients and the far-end circular buffer.
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0); self.filter.iter_mut().for_each(|c| *c = 0.0);
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0); self.far_buf.iter_mut().for_each(|s| *s = 0.0);
self.far_end_pos = 0; self.far_pos = 0;
self.far_peak = 0.0;
self.delay_ring.iter_mut().for_each(|s| *s = 0.0);
self.delay_write = 0;
self.delay_read = 0;
self.dtd_holdover = 0;
} }
} }
@@ -143,50 +228,40 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn aec_creates_with_correct_filter_len() { fn creates_with_correct_sizes() {
let aec = EchoCanceller::new(48000, 100); let aec = EchoCanceller::with_delay(48000, 60, 40);
assert_eq!(aec.filter_len, 4800); assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
assert_eq!(aec.filter_coeffs.len(), 4800); assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
assert_eq!(aec.far_end_buf.len(), 4800);
} }
#[test] #[test]
fn aec_passthrough_when_disabled() { fn passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 100); let mut aec = EchoCanceller::new(48000, 60);
aec.set_enabled(false); aec.set_enabled(false);
assert!(!aec.is_enabled());
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect(); let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
let mut frame = original.clone(); let mut frame = original.clone();
let erle = aec.process_frame(&mut frame); aec.process_frame(&mut frame);
assert_eq!(erle, 1.0);
assert_eq!(frame, original); assert_eq!(frame, original);
} }
#[test] #[test]
fn aec_reset_zeroes_state() { fn silence_passthrough() {
let mut aec = EchoCanceller::new(48000, 10); // short for test speed let mut aec = EchoCanceller::with_delay(48000, 30, 0);
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect(); aec.feed_farend(&vec![0i16; 960]);
aec.feed_farend(&farend); let mut frame = vec![0i16; 960];
aec.process_frame(&mut frame);
aec.reset(); assert!(frame.iter().all(|&s| s == 0));
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
assert_eq!(aec.far_end_pos, 0);
} }
#[test] #[test]
fn aec_reduces_echo_of_known_signal() { fn reduces_echo_with_no_delay() {
// Use a small filter for speed. Feed a known far-end signal, then // Simulate: far-end plays, echo arrives at mic attenuated by ~50%
// present the *same* signal as near-end (perfect echo, no room). // (realistic — speaker to mic on laptop loses volume).
// After adaptation the output energy should drop. let mut aec = EchoCanceller::with_delay(48000, 10, 0);
let filter_ms = 5; // 240 taps at 48 kHz
let mut aec = EchoCanceller::new(48000, filter_ms);
// Generate a simple repeating pattern. let frame_len = 480;
let frame_len = 480usize; let make_tone = |offset: usize| -> Vec<i16> {
let make_frame = |offset: usize| -> Vec<i16> {
(0..frame_len) (0..frame_len)
.map(|i| { .map(|i| {
let t = (offset + i) as f64 / 48000.0; let t = (offset + i) as f64 / 48000.0;
@@ -195,18 +270,16 @@ mod tests {
.collect() .collect()
}; };
// Warm up the adaptive filter with several frames.
let mut last_erle = 1.0f32; let mut last_erle = 1.0f32;
for frame_idx in 0..40 { for frame_idx in 0..100 {
let farend = make_frame(frame_idx * frame_len); let farend = make_tone(frame_idx * frame_len);
aec.feed_farend(&farend); aec.feed_farend(&farend);
// Near-end = exact copy of far-end (pure echo). // Near-end = attenuated copy of far-end (echo at ~50% volume).
let mut nearend = farend.clone(); let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
last_erle = aec.process_frame(&mut nearend); last_erle = aec.process_frame(&mut nearend);
} }
// After 40 frames the ERLE should be meaningfully > 1.
assert!( assert!(
last_erle > 1.0, last_erle > 1.0,
"expected ERLE > 1.0 after adaptation, got {last_erle}" "expected ERLE > 1.0 after adaptation, got {last_erle}"
@@ -214,15 +287,49 @@ mod tests {
} }
#[test] #[test]
fn aec_silence_passthrough() { fn preserves_nearend_during_doubletalk() {
let mut aec = EchoCanceller::new(48000, 10); let mut aec = EchoCanceller::with_delay(48000, 30, 0);
// Feed silence far-end
aec.feed_farend(&vec![0i16; 480]); let frame_len = 960;
// Near-end is silence too let nearend: Vec<i16> = (0..frame_len)
let mut frame = vec![0i16; 480]; .map(|i| {
let erle = aec.process_frame(&mut frame); let t = i as f64 / 48000.0;
assert!(erle >= 1.0); (10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
// Output should still be silence })
assert!(frame.iter().all(|&s| s == 0)); .collect();
// Feed silence as far-end (no echo source).
aec.feed_farend(&vec![0i16; frame_len]);
let mut frame = nearend.clone();
aec.process_frame(&mut frame);
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
let ratio = output_energy / input_energy;
assert!(
ratio > 0.8,
"near-end speech should be preserved, energy ratio = {ratio:.3}"
);
}
#[test]
fn delay_buffer_holds_samples() {
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
// 20ms delay = 960 samples @ 48kHz.
// After feeding, feed_farend auto-drains available samples to far_buf.
// So delay_available() is always 0 after feed_farend returns.
// Instead, verify far_pos advances only after the delay is filled.
// Feed 960 samples (= delay amount). No samples released yet.
aec.feed_farend(&vec![1i16; 960]);
// far_buf should still be all zeros (nothing released).
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
// Feed 480 more. 480 should be released to far_buf.
aec.feed_farend(&vec![2i16; 480]);
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
assert!(non_zero > 0, "samples should have been released to far_buf");
} }
} }

View File

@@ -28,6 +28,26 @@ pub use denoise::NoiseSupressor;
pub use silence::{ComfortNoise, SilenceDetector}; pub use silence::{ComfortNoise, SilenceDetector};
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile}; pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
use std::sync::atomic::{AtomicBool, Ordering};
/// Global verbose-logging flag for DRED. Off by default — when enabled
/// (via the GUI debug toggle wired through Tauri), the encoder logs its
/// DRED config + libopus version, and the recv path logs every DRED
/// reconstruction, classical PLC fill, and parse heartbeat. Off in
/// "normal" mode keeps logcat clean.
static DRED_VERBOSE_LOGS: AtomicBool = AtomicBool::new(false);
/// Returns whether DRED verbose logging is currently enabled.
#[inline]
pub fn dred_verbose_logs() -> bool {
DRED_VERBOSE_LOGS.load(Ordering::Relaxed)
}
/// Enable/disable DRED verbose logging at runtime.
pub fn set_dred_verbose_logs(enabled: bool) {
DRED_VERBOSE_LOGS.store(enabled, Ordering::Relaxed);
}
/// Create an adaptive encoder starting at the given quality profile. /// Create an adaptive encoder starting at the given quality profile.
/// ///
/// The returned encoder accepts 48 kHz mono PCM regardless of the active /// The returned encoder accepts 48 kHz mono PCM regardless of the active

View File

@@ -38,10 +38,18 @@
//! keeping the bitrate overhead modest compared to 25%. Real measurements //! keeping the bitrate overhead modest compared to 25%. Real measurements
//! from the quality adapter override upward when loss exceeds the floor. //! from the quality adapter override upward when loss exceeds the floor.
use std::sync::OnceLock;
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal}; use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile}; use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
/// Logged exactly once per process the first time an OpusEncoder is built.
/// Confirms that libopus 1.5.2 (the version with DRED) is actually linked
/// at runtime — invaluable when chasing "is the new codec loaded?"
/// regressions on Android, where the only debug surface is logcat.
static LIBOPUS_VERSION_LOGGED: OnceLock<()> = OnceLock::new();
/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus /// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus
/// scales the DRED emission window with the assumed loss percentage: /// scales the DRED emission window with the assumed loss percentage:
/// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15% /// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15%
@@ -178,7 +186,11 @@ impl OpusEncoder {
.set_packet_loss(DRED_LOSS_FLOOR_PCT) .set_packet_loss(DRED_LOSS_FLOOR_PCT)
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?; .map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
debug!( // Both of these are gated behind the GUI debug toggle so logcat
// stays clean in normal mode. Flip "DRED verbose logs" in the
// settings panel to see the per-encoder config + libopus version.
if crate::dred_verbose_logs() {
info!(
codec = ?codec, codec = ?codec,
dred_frames, dred_frames,
dred_ms = dred_frames as u32 * 10, dred_ms = dred_frames as u32 * 10,
@@ -186,6 +198,15 @@ impl OpusEncoder {
"opus encoder: DRED enabled" "opus encoder: DRED enabled"
); );
// One-shot logging of the linked libopus version so we can
// confirm at a glance that opusic-c (libopus 1.5.2) is loaded.
// Pre-Phase-0 audiopus shipped libopus 1.3 which has no DRED;
// if this log says "libopus 1.3" something is very wrong.
LIBOPUS_VERSION_LOGGED.get_or_init(|| {
info!(libopus_version = %opusic_c::version(), "linked libopus version");
});
}
Ok(()) Ok(())
} }

View File

@@ -115,6 +115,7 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
ephemeral_pub: [2u8; 32], ephemeral_pub: [2u8; 32],
signature: vec![3u8; 64], signature: vec![3u8; 64],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD], supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
alias: None,
}; };
// Encode as featherChat CallSignal payload // Encode as featherChat CallSignal payload
@@ -273,13 +274,14 @@ fn auth_invalid_response_matches() {
#[test] #[test]
fn all_signal_types_map_correctly() { fn all_signal_types_map_correctly() {
use wzp_client::featherchat::{signal_to_call_type, CallSignalType}; use wzp_client::featherchat::signal_to_call_type;
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![ let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
( (
wzp_proto::SignalMessage::CallOffer { wzp_proto::SignalMessage::CallOffer {
identity_pub: [0; 32], ephemeral_pub: [0; 32], identity_pub: [0; 32], ephemeral_pub: [0; 32],
signature: vec![], supported_profiles: vec![], signature: vec![], supported_profiles: vec![],
alias: None,
}, },
"Offer", "Offer",
), ),

View File

@@ -0,0 +1,29 @@
[package]
name = "wzp-native"
version = "0.1.0"
edition = "2024"
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
# standalone .so, which is the same path the legacy wzp-android crate uses
# successfully on the same phone / same NDK. Keeping the crate-type single
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
# archive pulled bionic's internal pthread_create into the final .so.
[lib]
name = "wzp_native"
crate-type = ["cdylib"]
[build-dependencies]
# cc is SAFE to use here because this crate is a single-cdylib: no
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
# legacy wzp-android crate uses the same setup and works.
cc = "1"
[dependencies]
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
# audio pipeline via extern "C" into the bundled C++ and expose our own
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
# instead of calling back into wzp-desktop via callbacks.

119
crates/wzp-native/build.rs Normal file
View File

@@ -0,0 +1,119 @@
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
//!
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
//! work). The crucial distinction: this crate is a single-cdylib (no
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
//! UND and resolve against libc.so at runtime, as they should.
//!
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
//! `cargo check --target <host>` still works for IDEs and CI.
use std::path::PathBuf;
fn main() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("android") {
// getauxval_fix: override compiler-rt's broken static getauxval
// stub that SIGSEGVs in shared libraries.
cc::Build::new()
.file("cpp/getauxval_fix.c")
.compile("wzp_native_getauxval_fix");
let oboe_dir = fetch_oboe();
match oboe_dir {
Some(oboe_path) => {
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path);
let mut build = cc::Build::new();
build
.cpp(true)
.std("c++17")
// Shared libc++ — matches legacy wzp-android setup.
.cpp_link_stdlib(Some("c++_shared"))
.include("cpp")
.include(oboe_path.join("include"))
.include(oboe_path.join("src"))
.define("WZP_HAS_OBOE", None)
.file("cpp/oboe_bridge.cpp");
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
build.compile("wzp_native_oboe_bridge");
}
None => {
println!("cargo:warning=wzp-native: Oboe not found, building stub");
cc::Build::new()
.cpp(true)
.std("c++17")
.cpp_link_stdlib(Some("c++_shared"))
.file("cpp/oboe_stub.cpp")
.include("cpp")
.compile("wzp_native_oboe_bridge");
}
}
// Oboe needs log + OpenSLES backends at runtime.
println!("cargo:rustc-link-lib=log");
println!("cargo:rustc-link-lib=OpenSLES");
// Re-run if any cpp file changes
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
} else {
// Non-Android hosts: compile the empty stub so lib.rs's extern
// declarations resolve when someone runs `cargo check` on macOS
// or Linux without an NDK.
cc::Build::new()
.cpp(true)
.std("c++17")
.file("cpp/oboe_stub.cpp")
.include("cpp")
.compile("wzp_native_oboe_bridge");
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
}
}
/// Recursively add all `.cpp` files from a directory to a cc::Build.
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
if !dir.is_dir() {
return;
}
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
add_cpp_files_recursive(build, &path);
} else if path.extension().map_or(false, |e| e == "cpp") {
build.file(&path);
}
}
}
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
/// legacy wzp-android crate's build.rs.
fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
return Some(oboe_dir);
}
let status = std::process::Command::new("git")
.args([
"clone",
"--depth=1",
"--branch=1.8.1",
"https://github.com/google/oboe.git",
oboe_dir.to_str().unwrap(),
])
.status();
match status {
Ok(s) if s.success() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => {
Some(oboe_dir)
}
_ => None,
}
}

View File

@@ -0,0 +1,21 @@
// Override the broken static getauxval from compiler-rt/CRT.
// The static version reads from __libc_auxv which is NULL in shared libs
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
// This version calls the real bionic getauxval via dlsym.
#ifdef __ANDROID__
#include <dlfcn.h>
#include <stdint.h>
typedef unsigned long (*getauxval_fn)(unsigned long);
unsigned long getauxval(unsigned long type) {
static getauxval_fn real_getauxval = (getauxval_fn)0;
if (!real_getauxval) {
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
if (!real_getauxval) {
return 0;
}
}
return real_getauxval(type);
}
#endif

View File

@@ -0,0 +1,420 @@
// Full Oboe implementation for Android
// This file is compiled only when targeting Android
#include "oboe_bridge.h"
#ifdef __ANDROID__
#include <oboe/Oboe.h>
#include <android/log.h>
#include <cstring>
#include <atomic>
#define LOG_TAG "wzp-oboe"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
// ---------------------------------------------------------------------------
// Ring buffer helpers (SPSC, lock-free)
// ---------------------------------------------------------------------------
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
const wzp_atomic_int* read_idx,
int32_t capacity) {
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
int32_t avail = w - r;
if (avail < 0) avail += capacity;
return avail;
}
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
const wzp_atomic_int* read_idx,
int32_t capacity) {
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
}
static inline void ring_write(int16_t* buf, int32_t capacity,
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
const int16_t* src, int32_t count) {
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
for (int32_t i = 0; i < count; i++) {
buf[w] = src[i];
w++;
if (w >= capacity) w = 0;
}
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
}
static inline void ring_read(int16_t* buf, int32_t capacity,
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
int16_t* dst, int32_t count) {
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
for (int32_t i = 0; i < count; i++) {
dst[i] = buf[r];
r++;
if (r >= capacity) r = 0;
}
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
// Value copy — the WzpOboeRings the Rust side passes us lives on the caller's
// stack frame and goes away as soon as wzp_oboe_start returns. The raw
// int16/atomic pointers INSIDE the struct point into the Rust-owned, leaked-
// for-the-lifetime-of-the-process AudioBackend singleton, so copying the
// struct by value is safe and keeps the inner pointers valid indefinitely.
// g_rings_valid guards the audio-callback-side read; clearing it in stop()
// signals "no backend" to the callbacks which then return silence + Stop.
static WzpOboeRings g_rings{};
static std::atomic<bool> g_rings_valid{false};
static std::atomic<bool> g_running{false};
static std::atomic<float> g_capture_latency_ms{0.0f};
static std::atomic<float> g_playout_latency_ms{0.0f};
// ---------------------------------------------------------------------------
// Capture callback
// ---------------------------------------------------------------------------
class CaptureCallback : public oboe::AudioStreamDataCallback {
public:
uint64_t calls = 0;
uint64_t total_frames = 0;
uint64_t total_written = 0;
uint64_t ring_full_drops = 0;
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override {
if (!g_running.load(std::memory_order_relaxed) ||
!g_rings_valid.load(std::memory_order_acquire)) {
return oboe::DataCallbackResult::Stop;
}
const int16_t* src = static_cast<const int16_t*>(audioData);
int32_t avail = ring_available_write(g_rings.capture_write_idx,
g_rings.capture_read_idx,
g_rings.capture_capacity);
int32_t to_write = (numFrames < avail) ? numFrames : avail;
if (to_write > 0) {
ring_write(g_rings.capture_buf, g_rings.capture_capacity,
g_rings.capture_write_idx, g_rings.capture_read_idx,
src, to_write);
}
total_frames += numFrames;
total_written += to_write;
if (to_write < numFrames) {
ring_full_drops += (numFrames - to_write);
}
// Sample-range probe on the FIRST callback to prove we get real audio
if (calls == 0 && numFrames > 0) {
int16_t lo = src[0], hi = src[0];
int32_t sumsq = 0;
for (int32_t i = 0; i < numFrames; i++) {
if (src[i] < lo) lo = src[i];
if (src[i] > hi) hi = src[i];
sumsq += (int32_t)src[i] * (int32_t)src[i];
}
int32_t rms = (int32_t) (numFrames > 0 ? (int32_t)__builtin_sqrt((double)sumsq / (double)numFrames) : 0);
LOGI("capture cb#0: numFrames=%d sample_range=[%d..%d] rms=%d to_write=%d",
numFrames, lo, hi, rms, to_write);
}
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
calls++;
if ((calls % 50) == 0) {
LOGI("capture heartbeat: calls=%llu numFrames=%d ring_avail_write=%d to_write=%d full_drops=%llu total_written=%llu",
(unsigned long long)calls, numFrames, avail, to_write,
(unsigned long long)ring_full_drops, (unsigned long long)total_written);
}
// Update latency estimate
auto result = stream->calculateLatencyMillis();
if (result) {
g_capture_latency_ms.store(static_cast<float>(result.value()),
std::memory_order_relaxed);
}
return oboe::DataCallbackResult::Continue;
}
};
// ---------------------------------------------------------------------------
// Playout callback
// ---------------------------------------------------------------------------
class PlayoutCallback : public oboe::AudioStreamDataCallback {
public:
uint64_t calls = 0;
uint64_t total_frames = 0;
uint64_t total_played_real = 0;
uint64_t underrun_frames = 0;
uint64_t nonempty_calls = 0;
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override {
if (!g_running.load(std::memory_order_relaxed) ||
!g_rings_valid.load(std::memory_order_acquire)) {
memset(audioData, 0, numFrames * sizeof(int16_t));
return oboe::DataCallbackResult::Stop;
}
int16_t* dst = static_cast<int16_t*>(audioData);
int32_t avail = ring_available_read(g_rings.playout_write_idx,
g_rings.playout_read_idx,
g_rings.playout_capacity);
int32_t to_read = (numFrames < avail) ? numFrames : avail;
if (to_read > 0) {
ring_read(g_rings.playout_buf, g_rings.playout_capacity,
g_rings.playout_write_idx, g_rings.playout_read_idx,
dst, to_read);
nonempty_calls++;
}
// Fill remainder with silence on underrun
if (to_read < numFrames) {
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
underrun_frames += (numFrames - to_read);
}
total_frames += numFrames;
total_played_real += to_read;
// First callback: log requested config + prove we're being called
if (calls == 0) {
LOGI("playout cb#0: numFrames=%d ring_avail_read=%d to_read=%d",
numFrames, avail, to_read);
}
// On the first callback that actually has data, log the sample range
// so we can tell if the samples coming out of the ring look like real
// audio vs constant-zeroes vs garbage.
if (to_read > 0 && nonempty_calls == 1) {
int16_t lo = dst[0], hi = dst[0];
int32_t sumsq = 0;
for (int32_t i = 0; i < to_read; i++) {
if (dst[i] < lo) lo = dst[i];
if (dst[i] > hi) hi = dst[i];
sumsq += (int32_t)dst[i] * (int32_t)dst[i];
}
int32_t rms = (to_read > 0) ? (int32_t)__builtin_sqrt((double)sumsq / (double)to_read) : 0;
LOGI("playout FIRST nonempty read: to_read=%d sample_range=[%d..%d] rms=%d",
to_read, lo, hi, rms);
}
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
calls++;
if ((calls % 50) == 0) {
int state = (int)stream->getState();
auto xrunRes = stream->getXRunCount();
int xruns = xrunRes ? xrunRes.value() : -1;
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu state=%d xruns=%d",
(unsigned long long)calls, (unsigned long long)nonempty_calls,
numFrames, avail, to_read,
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
state, xruns);
}
// Update latency estimate
auto result = stream->calculateLatencyMillis();
if (result) {
g_playout_latency_ms.store(static_cast<float>(result.value()),
std::memory_order_relaxed);
}
return oboe::DataCallbackResult::Continue;
}
};
static CaptureCallback g_capture_cb;
static PlayoutCallback g_playout_cb;
// ---------------------------------------------------------------------------
// Public C API
// ---------------------------------------------------------------------------
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
if (g_running.load(std::memory_order_relaxed)) {
LOGW("wzp_oboe_start: already running");
return -1;
}
// Deep-copy the rings struct into static storage BEFORE we publish it to
// the audio callbacks — `rings` points at the caller's stack frame and
// goes away as soon as this function returns.
g_rings = *rings;
g_rings_valid.store(true, std::memory_order_release);
// Build capture stream
oboe::AudioStreamBuilder captureBuilder;
captureBuilder.setDirection(oboe::Direction::Input)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication)
->setDataCallback(&g_capture_cb);
oboe::Result result = captureBuilder.openStream(g_capture_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
return -2;
}
LOGI("capture stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
g_capture_stream->getSampleRate(),
g_capture_stream->getChannelCount(),
(int)g_capture_stream->getFormat(),
g_capture_stream->getFramesPerBurst(),
g_capture_stream->getFramesPerDataCallback(),
g_capture_stream->getBufferCapacityInFrames(),
(int)g_capture_stream->getSharingMode(),
(int)g_capture_stream->getPerformanceMode());
// Build playout stream.
//
// Regression triangulation between builds:
// 96be740 (Usage::Media, default API): playout callback DID drain
// the ring at steady 50Hz (playout heartbeat: calls=1100,
// total_played_real=1055040). Audio not audible because OS routing
// sent it to a silent output.
//
// 8c36fb5 (Usage::VoiceCommunication + setAudioApi(AAudio) +
// ContentType::Speech): playout callback fired cb#0 once then
// stopped draining the ring entirely. written_samples stuck at
// ring capacity (7679) across all subsequent heartbeats, so Oboe
// accepted zero samples after startup. Still inaudible.
//
// Hypothesis: forcing setAudioApi(AAudio) + VoiceCommunication on
// Pixel 6 / Android 15 opens a stream that succeeds at cb#0 but
// then detaches from the real audio driver. Reverting to the
// config that at least drove callbacks correctly, plus the
// Kotlin-side MODE_IN_COMMUNICATION + setSpeakerphoneOn(true)
// handled in MainActivity.kt to route audio to the loud speaker.
// Usage::VoiceCommunication is the correct Oboe usage for a VoIP app
// — it respects Android's in-call audio routing and lets
// AudioManager.setSpeakerphoneOn/setBluetoothScoOn actually switch
// between earpiece, loudspeaker, and Bluetooth headset. Combined with
// MODE_IN_COMMUNICATION set from MainActivity.kt and
// speakerphoneOn=false by default, this produces handset/earpiece as
// the default output.
//
// IMPORTANT: do NOT add setAudioApi(AAudio) here. Build 8c36fb5 proved
// forcing AAudio with Usage::VoiceCommunication makes the playout
// callback stop draining the ring after cb#0, even though the stream
// opens successfully. Letting Oboe pick the API (which will be AAudio
// on API ≥ 27 but via a different codepath) kept callbacks firing in
// every other build.
oboe::AudioStreamBuilder playoutBuilder;
playoutBuilder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication)
->setDataCallback(&g_playout_cb);
result = playoutBuilder.openStream(g_playout_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
g_capture_stream->close();
g_capture_stream.reset();
return -3;
}
LOGI("playout stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
g_playout_stream->getSampleRate(),
g_playout_stream->getChannelCount(),
(int)g_playout_stream->getFormat(),
g_playout_stream->getFramesPerBurst(),
g_playout_stream->getFramesPerDataCallback(),
g_playout_stream->getBufferCapacityInFrames(),
(int)g_playout_stream->getSharingMode(),
(int)g_playout_stream->getPerformanceMode());
g_running.store(true, std::memory_order_release);
// Start both streams
result = g_capture_stream->requestStart();
if (result != oboe::Result::OK) {
LOGE("Failed to start capture: %s", oboe::convertToText(result));
g_running.store(false, std::memory_order_release);
g_capture_stream->close();
g_playout_stream->close();
g_capture_stream.reset();
g_playout_stream.reset();
return -4;
}
result = g_playout_stream->requestStart();
if (result != oboe::Result::OK) {
LOGE("Failed to start playout: %s", oboe::convertToText(result));
g_running.store(false, std::memory_order_release);
g_capture_stream->requestStop();
g_capture_stream->close();
g_playout_stream->close();
g_capture_stream.reset();
g_playout_stream.reset();
return -5;
}
LOGI("Oboe started: sr=%d burst=%d ch=%d",
config->sample_rate, config->frames_per_burst, config->channel_count);
return 0;
}
void wzp_oboe_stop(void) {
g_running.store(false, std::memory_order_release);
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
// the streams, so any in-flight callback returns Stop instead of reading
// stale pointers.
g_rings_valid.store(false, std::memory_order_release);
if (g_capture_stream) {
g_capture_stream->requestStop();
g_capture_stream->close();
g_capture_stream.reset();
}
if (g_playout_stream) {
g_playout_stream->requestStop();
g_playout_stream->close();
g_playout_stream.reset();
}
LOGI("Oboe stopped");
}
float wzp_oboe_capture_latency_ms(void) {
return g_capture_latency_ms.load(std::memory_order_relaxed);
}
float wzp_oboe_playout_latency_ms(void) {
return g_playout_latency_ms.load(std::memory_order_relaxed);
}
int wzp_oboe_is_running(void) {
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
}
#else
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
// Provide empty implementations just in case.
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(void)config; (void)rings;
return -99;
}
void wzp_oboe_stop(void) {}
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
int wzp_oboe_is_running(void) { return 0; }
#endif // __ANDROID__

View File

@@ -0,0 +1,43 @@
#ifndef WZP_OBOE_BRIDGE_H
#define WZP_OBOE_BRIDGE_H
#include <stdint.h>
#ifdef __cplusplus
#include <atomic>
typedef std::atomic<int32_t> wzp_atomic_int;
extern "C" {
#else
#include <stdatomic.h>
typedef atomic_int wzp_atomic_int;
#endif
typedef struct {
int32_t sample_rate;
int32_t frames_per_burst;
int32_t channel_count;
} WzpOboeConfig;
typedef struct {
int16_t* capture_buf;
int32_t capture_capacity;
wzp_atomic_int* capture_write_idx;
wzp_atomic_int* capture_read_idx;
int16_t* playout_buf;
int32_t playout_capacity;
wzp_atomic_int* playout_write_idx;
wzp_atomic_int* playout_read_idx;
} WzpOboeRings;
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
void wzp_oboe_stop(void);
float wzp_oboe_capture_latency_ms(void);
float wzp_oboe_playout_latency_ms(void);
int wzp_oboe_is_running(void);
#ifdef __cplusplus
}
#endif
#endif // WZP_OBOE_BRIDGE_H

View File

@@ -0,0 +1,27 @@
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
#include "oboe_bridge.h"
#include <stdio.h>
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(void)config;
(void)rings;
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
return 0;
}
void wzp_oboe_stop(void) {
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
}
float wzp_oboe_capture_latency_ms(void) {
return 0.0f;
}
float wzp_oboe_playout_latency_ms(void) {
return 0.0f;
}
int wzp_oboe_is_running(void) {
return 0;
}

View File

@@ -0,0 +1,331 @@
//! wzp-native — standalone Android cdylib for all the C++ audio code.
//!
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
//!
//! Phase 2: real Oboe audio backend.
//!
//! Architecture: Oboe runs capture + playout streams on its own high-
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
//! buffers (capture and playout) are shared between the C++ callbacks
//! and the Rust side via atomic indices — no locks on the hot path.
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
//! the playout ring with decoded PCM.
use std::sync::atomic::{AtomicI32, Ordering};
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
/// work before any audio code runs.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_version() -> i32 {
42
}
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
/// returns bytes written excluding the NUL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
const MSG: &[u8] = b"hello from wzp-native\0";
if out.is_null() || cap == 0 {
return 0;
}
let n = MSG.len().min(cap);
unsafe {
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
*out.add(n - 1) = 0;
}
n - 1
}
// ─── C++ Oboe bridge FFI ─────────────────────────────────────────────────
#[repr(C)]
struct WzpOboeConfig {
sample_rate: i32,
frames_per_burst: i32,
channel_count: i32,
}
#[repr(C)]
struct WzpOboeRings {
capture_buf: *mut i16,
capture_capacity: i32,
capture_write_idx: *mut AtomicI32,
capture_read_idx: *mut AtomicI32,
playout_buf: *mut i16,
playout_capacity: i32,
playout_write_idx: *mut AtomicI32,
playout_read_idx: *mut AtomicI32,
}
// SAFETY: atomics synchronise producer/consumer; raw pointers are owned
// by the AudioBackend singleton below whose lifetime covers all calls.
unsafe impl Send for WzpOboeRings {}
unsafe impl Sync for WzpOboeRings {}
unsafe extern "C" {
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
fn wzp_oboe_stop();
fn wzp_oboe_capture_latency_ms() -> f32;
fn wzp_oboe_playout_latency_ms() -> f32;
fn wzp_oboe_is_running() -> i32;
}
// ─── SPSC ring buffer (shared with C++ via AtomicI32) ────────────────────
/// 20 ms @ 48 kHz mono = 960 samples.
const FRAME_SAMPLES: usize = 960;
/// ~160 ms headroom at 48 kHz.
const RING_CAPACITY: usize = 7680;
struct RingBuffer {
buf: Vec<i16>,
capacity: usize,
write_idx: AtomicI32,
read_idx: AtomicI32,
}
// SAFETY: SPSC with atomic read/write cursors; producer and consumer
// are always on different threads.
unsafe impl Send for RingBuffer {}
unsafe impl Sync for RingBuffer {}
impl RingBuffer {
fn new(capacity: usize) -> Self {
Self {
buf: vec![0i16; capacity],
capacity,
write_idx: AtomicI32::new(0),
read_idx: AtomicI32::new(0),
}
}
fn available_read(&self) -> usize {
let w = self.write_idx.load(Ordering::Acquire);
let r = self.read_idx.load(Ordering::Relaxed);
let avail = w - r;
if avail < 0 { (avail + self.capacity as i32) as usize } else { avail as usize }
}
fn available_write(&self) -> usize {
self.capacity - 1 - self.available_read()
}
fn write(&self, data: &[i16]) -> usize {
let count = data.len().min(self.available_write());
if count == 0 {
return 0;
}
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr() as *mut i16;
for sample in &data[..count] {
unsafe { *buf_ptr.add(w) = *sample; }
w += 1;
if w >= cap { w = 0; }
}
self.write_idx.store(w as i32, Ordering::Release);
count
}
fn read(&self, out: &mut [i16]) -> usize {
let count = out.len().min(self.available_read());
if count == 0 {
return 0;
}
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
let cap = self.capacity;
let buf_ptr = self.buf.as_ptr();
for slot in &mut out[..count] {
unsafe { *slot = *buf_ptr.add(r); }
r += 1;
if r >= cap { r = 0; }
}
self.read_idx.store(r as i32, Ordering::Release);
count
}
fn buf_ptr(&self) -> *mut i16 {
self.buf.as_ptr() as *mut i16
}
fn write_idx_ptr(&self) -> *mut AtomicI32 {
&self.write_idx as *const AtomicI32 as *mut AtomicI32
}
fn read_idx_ptr(&self) -> *mut AtomicI32 {
&self.read_idx as *const AtomicI32 as *mut AtomicI32
}
}
// ─── AudioBackend singleton ──────────────────────────────────────────────
//
// There is one global AudioBackend instance because Oboe's C++ side
// holds its own singleton of the streams. The `Box::leak`'d statics own
// the ring buffers for the lifetime of the process — dropping them while
// Oboe is still running would cause use-after-free in the audio callback.
use std::sync::OnceLock;
struct AudioBackend {
capture: RingBuffer,
playout: RingBuffer,
started: std::sync::Mutex<bool>,
/// Per-write logging throttle counter for wzp_native_audio_write_playout.
playout_write_log_count: std::sync::atomic::AtomicU64,
}
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
fn backend() -> &'static AudioBackend {
BACKEND.get_or_init(|| {
Box::leak(Box::new(AudioBackend {
capture: RingBuffer::new(RING_CAPACITY),
playout: RingBuffer::new(RING_CAPACITY),
started: std::sync::Mutex::new(false),
playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
}))
})
}
// ─── C FFI for wzp-desktop ───────────────────────────────────────────────
/// Start the Oboe audio streams. Returns 0 on success, non-zero on error.
/// Idempotent — calling while already running is a no-op that returns 0.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_start() -> i32 {
let b = backend();
let mut started = match b.started.lock() {
Ok(g) => g,
Err(_) => return -1,
};
if *started {
return 0;
}
let config = WzpOboeConfig {
sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1,
};
let rings = WzpOboeRings {
capture_buf: b.capture.buf_ptr(),
capture_capacity: b.capture.capacity as i32,
capture_write_idx: b.capture.write_idx_ptr(),
capture_read_idx: b.capture.read_idx_ptr(),
playout_buf: b.playout.buf_ptr(),
playout_capacity: b.playout.capacity as i32,
playout_write_idx: b.playout.write_idx_ptr(),
playout_read_idx: b.playout.read_idx_ptr(),
};
let ret = unsafe { wzp_oboe_start(&config, &rings) };
if ret != 0 {
return ret;
}
*started = true;
0
}
/// Stop Oboe. Idempotent. Safe to call from any thread.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_stop() {
let b = backend();
if let Ok(mut started) = b.started.lock() {
if *started {
unsafe { wzp_oboe_stop() };
*started = false;
}
}
}
/// Read captured PCM samples from the capture ring. Returns the number
/// of `i16` samples actually copied into `out` (may be less than
/// `out_len` if the ring is empty).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wzp_native_audio_read_capture(out: *mut i16, out_len: usize) -> usize {
if out.is_null() || out_len == 0 {
return 0;
}
let slice = unsafe { std::slice::from_raw_parts_mut(out, out_len) };
backend().capture.read(slice)
}
/// Write PCM samples into the playout ring. Returns the number of
/// samples actually enqueued (may be less than `in_len` if the ring
/// is nearly full — in practice the caller should pace to 20 ms
/// frames and spin briefly if the ring is full).
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_len: usize) -> usize {
if input.is_null() || in_len == 0 {
return 0;
}
let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
let b = backend();
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
let written = b.playout.write(slice);
// First few writes: log ring state + sample range so we can compare what
// engine.rs hands us to what the C++ playout callback reads.
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if first_writes < 3 || first_writes % 50 == 0 {
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
for &s in slice.iter() {
if s < lo { lo = s; }
if s > hi { hi = s; }
sumsq += (s as i64) * (s as i64);
}
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
let avail_w_after = b.playout.available_write();
let avail_r_after = b.playout.available_read();
let msg = format!(
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
slice.len(), written
);
unsafe {
android_log(msg.as_str());
}
}
written
}
// Minimal android logcat shim so we can print from the cdylib without pulling
// in android_logger crate (which would add another dep that has to build with
// cargo-ndk). Uses libc's __android_log_print via extern linkage.
#[cfg(target_os = "android")]
unsafe extern "C" {
fn __android_log_write(prio: i32, tag: *const u8, text: *const u8) -> i32;
}
#[cfg(target_os = "android")]
unsafe fn android_log(msg: &str) {
// ANDROID_LOG_INFO = 4. Tag and text must be NUL-terminated.
let tag = b"wzp-native\0";
let mut buf = Vec::with_capacity(msg.len() + 1);
buf.extend_from_slice(msg.as_bytes());
buf.push(0);
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); }
}
#[cfg(not(target_os = "android"))]
#[allow(dead_code)]
unsafe fn android_log(_msg: &str) {}
/// Current capture latency reported by Oboe, in milliseconds. Returns
/// NaN / 0.0 if the stream isn't running.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_capture_latency_ms() -> f32 {
unsafe { wzp_oboe_capture_latency_ms() }
}
/// Current playout latency reported by Oboe, in milliseconds.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_playout_latency_ms() -> f32 {
unsafe { wzp_oboe_playout_latency_ms() }
}
/// Non-zero if both Oboe streams are currently running.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_is_running() -> i32 {
unsafe { wzp_oboe_is_running() }
}

View File

@@ -770,6 +770,29 @@ pub enum SignalMessage {
CallRinging { CallRinging {
call_id: String, call_id: String,
}, },
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
/// Client → relay: "please tell me the source IP:port you see on
/// this connection". A QUIC-native replacement for classic STUN
/// that reuses the TLS-authenticated signal channel to the relay
/// instead of running a separate UDP reflection service on port
/// 3478. The relay answers with `ReflectResponse`.
///
/// No payload — the relay already knows which connection the
/// request arrived on, and `connection.remote_address()` gives it
/// the exact source address (post-NAT) as observed from the
/// server side of the TLS session.
Reflect,
/// Relay → client: response to `Reflect`. Carries the socket
/// address the relay observes as the client's source for this
/// QUIC connection in `SocketAddr::to_string()` form — "a.b.c.d:p"
/// for IPv4, "[::1]:p" for IPv6. Clients parse it with
/// `SocketAddr::from_str`.
ReflectResponse {
observed_addr: String,
},
} }
/// How the callee responds to a direct call. /// How the callee responds to a direct call.
@@ -908,6 +931,58 @@ mod tests {
assert_eq!(packet.quality_report, decoded.quality_report); assert_eq!(packet.quality_report, decoded.quality_report);
} }
#[test]
fn reflect_serialize_roundtrip() {
// Reflect is a unit variant — the client sends it with no
// payload and the relay answers with the observed source addr.
let req = SignalMessage::Reflect;
let json = serde_json::to_string(&req).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, SignalMessage::Reflect));
// ReflectResponse carries a string — exercise both IPv4 and
// IPv6 shapes because SocketAddr::to_string uses [::1]:port
// for v6 and the client side has to parse that back.
for addr in ["192.0.2.17:4433", "[2001:db8::1]:4433", "127.0.0.1:54321"] {
let resp = SignalMessage::ReflectResponse {
observed_addr: addr.to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::ReflectResponse { observed_addr } => {
assert_eq!(observed_addr, addr);
// Must parse back to a SocketAddr cleanly.
let _parsed: std::net::SocketAddr = observed_addr.parse()
.expect("observed_addr must parse as SocketAddr");
}
_ => panic!("wrong variant after roundtrip"),
}
}
}
#[test]
fn reflect_backward_compat_with_existing_variants() {
// Adding Reflect/ReflectResponse at the end of the enum must
// not break JSON round-tripping of existing variants. Smoke-
// test a sample of the pre-existing ones.
let cases = vec![
SignalMessage::Ping { timestamp_ms: 12345 },
SignalMessage::Hold,
SignalMessage::Hangup { reason: HangupReason::Normal },
SignalMessage::CallRinging { call_id: "abcd".into() },
];
for m in cases {
let json = serde_json::to_string(&m).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
// Discriminant equality proves variant tag survived.
assert_eq!(
std::mem::discriminant(&m),
std::mem::discriminant(&decoded)
);
}
}
#[test] #[test]
fn hold_unhold_serialize() { fn hold_unhold_serialize() {
let hold = SignalMessage::Hold; let hold = SignalMessage::Hold;

View File

@@ -5,7 +5,6 @@
//! Use `wzp-analyzer` to correlate events across multiple relays. //! Use `wzp-analyzer` to correlate events across multiple relays.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use serde::Serialize; use serde::Serialize;
use tokio::sync::mpsc; use tokio::sync::mpsc;

View File

@@ -142,9 +142,6 @@ pub struct FederationManager {
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>, peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
/// Dedup filter for incoming federation datagrams. /// Dedup filter for incoming federation datagrams.
dedup: Mutex<Deduplicator>, dedup: Mutex<Deduplicator>,
/// Per-room seq counter for federation media delivered to local clients.
/// Ensures clients see monotonically increasing seq regardless of federation sender.
local_delivery_seq: std::sync::atomic::AtomicU16,
/// JSONL event log for protocol analysis. /// JSONL event log for protocol analysis.
event_log: EventLogger, event_log: EventLogger,
/// Per-room rate limiters for inbound federation media. /// Per-room rate limiters for inbound federation media.
@@ -172,7 +169,6 @@ impl FederationManager {
metrics, metrics,
peer_links: Arc::new(Mutex::new(HashMap::new())), peer_links: Arc::new(Mutex::new(HashMap::new())),
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)), dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
local_delivery_seq: std::sync::atomic::AtomicU16::new(0),
event_log, event_log,
rate_limiters: Mutex::new(HashMap::new()), rate_limiters: Mutex::new(HashMap::new()),
} }
@@ -296,7 +292,12 @@ impl FederationManager {
/// Forward locally-generated media to all connected peers. /// Forward locally-generated media to all connected peers.
/// For locally-originated media, we send to ALL peers (they decide whether to deliver). /// For locally-originated media, we send to ALL peers (they decide whether to deliver).
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms. /// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
pub async fn forward_to_peers(&self, room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) { ///
/// `_room_name` is kept in the signature for caller-site symmetry with
/// the other room-tagged helpers and for future per-room-name logging
/// or rate limiting; the body currently forwards on `room_hash` alone
/// because that's what the wire format carries.
pub async fn forward_to_peers(&self, _room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
let links = self.peer_links.lock().await; let links = self.peer_links.lock().await;
if links.is_empty() { if links.is_empty() {
return; return;
@@ -623,11 +624,20 @@ async fn run_federation_link(
} }
}; };
// RTT monitor: periodically sample QUIC RTT for this peer // RTT monitor: periodically sample QUIC RTT for this peer and push it
// into the `wzp_federation_peer_rtt_ms` gauge. The gauge is registered
// in metrics.rs but previously never received any samples — the task
// computed rtt_ms and dropped it on the floor, leaving the Grafana
// panel blank. Fixed as part of the workspace warning sweep.
let rtt_task = async move { let rtt_task = async move {
loop { loop {
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64; let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
fm_rtt
.metrics
.federation_peer_rtt_ms
.with_label_values(&[&label_rtt])
.set(rtt_ms);
} }
}; };

View File

@@ -94,9 +94,13 @@ pub async fn accept_handshake(
} }
/// Select the best quality profile from those the caller supports. /// Select the best quality profile from those the caller supports.
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile { ///
// Cap at GOOD (24k) for now — studio tiers (32k/48k/64k) not yet tested /// The `_supported` list is currently ignored — we hardcode GOOD (24k) until
// for federation reliability (large packets may exceed path MTU). /// studio tiers (32k/48k/64k) have been validated across federation (large
/// packets may exceed path MTU and fragment in unpleasant ways). Once that's
/// tested, the body should pick the highest supported profile ≤ the relay's
/// configured ceiling.
fn choose_profile(_supported: &[QualityProfile]) -> QualityProfile {
QualityProfile::GOOD QualityProfile::GOOD
} }

View File

@@ -13,7 +13,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{debug, error, info, warn};
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_relay::config::RelayConfig; use wzp_relay::config::RelayConfig;
@@ -272,7 +272,7 @@ const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let CliResult { mut config, identity_path, config_file, config_needs_create } = parse_args(); let CliResult { config, identity_path, config_file, config_needs_create } = parse_args();
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
info!(version = BUILD_GIT_HASH, "wzp-relay build"); info!(version = BUILD_GIT_HASH, "wzp-relay build");
rustls::crypto::ring::default_provider() rustls::crypto::ring::default_provider()
@@ -378,6 +378,31 @@ async fn main() -> anyhow::Result<()> {
} }
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?; let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
// Compute the IP address we should advertise in CallSetup for direct
// calls. If the relay is bound to a specific IP, use it as-is; if bound
// to 0.0.0.0, use the trick of "connect" a UDP socket to an arbitrary
// external address and read its local_addr — the OS binds to whichever
// local interface IP would route packets to that destination, which is
// the primary outbound interface. This is the same IP clients on the
// LAN use to reach us.
let advertised_ip: std::net::IpAddr = {
let listen_ip = config.listen_addr.ip();
if !listen_ip.is_unspecified() {
listen_ip
} else {
// Probe via a dummy "connected" UDP socket. Never actually sends.
match std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) })
.and_then(|s| s.local_addr())
{
Ok(a) if !a.ip().is_loopback() => a.ip(),
_ => std::net::IpAddr::from([127u8, 0, 0, 1]),
}
}
};
let advertised_addr_str = format!("{}:{}", advertised_ip, config.listen_addr.port());
info!(%advertised_addr_str, "relay advertised address for CallSetup");
// Forward mode // Forward mode
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> = let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
if let Some(remote_addr) = config.remote_relay { if let Some(remote_addr) = config.remote_relay {
@@ -475,9 +500,19 @@ async fn main() -> anyhow::Result<()> {
info!("Listening for connections..."); info!("Listening for connections...");
loop { loop {
let connection = match wzp_transport::accept(&endpoint).await { // Pull the next Incoming off the queue. Deliberately do NOT await
Ok(conn) => conn, // the QUIC handshake here — move that into the per-connection
Err(e) => { error!("accept: {e}"); continue; } // spawned task below. Previously we used wzp_transport::accept
// which did both, which meant a single slow handshake would block
// the entire accept loop and prevent ALL subsequent connections
// from being processed. Surfaced as direct-call hangs where the
// callee's call-* connection never completes its QUIC handshake.
let incoming = match endpoint.accept().await {
Some(inc) => inc,
None => {
error!("endpoint.accept() returned None — endpoint closed");
break;
}
}; };
let remote_transport = remote_transport.clone(); let remote_transport = remote_transport.clone();
@@ -493,9 +528,22 @@ async fn main() -> anyhow::Result<()> {
let federation_mgr = federation_mgr.clone(); let federation_mgr = federation_mgr.clone();
let signal_hub = signal_hub.clone(); let signal_hub = signal_hub.clone();
let call_registry = call_registry.clone(); let call_registry = call_registry.clone();
let listen_addr_str = config.listen_addr.to_string(); let advertised_addr_str = advertised_addr_str.clone();
let incoming_addr = incoming.remote_address();
info!(%incoming_addr, "accept queue: new Incoming, spawning handshake task");
tokio::spawn(async move { tokio::spawn(async move {
// Drive the QUIC handshake inside the spawned task so that
// slow or hung handshakes never block the outer accept loop.
let connection = match incoming.await {
Ok(c) => c,
Err(e) => {
error!(%incoming_addr, "QUIC handshake failed: {e}");
return;
}
};
info!(%incoming_addr, "QUIC handshake complete");
let addr = connection.remote_address(); let addr = connection.remote_address();
let room_name = connection let room_name = connection
@@ -718,7 +766,7 @@ async fn main() -> anyhow::Result<()> {
match transport.recv_signal().await { match transport.recv_signal().await {
Ok(Some(msg)) => { Ok(Some(msg)) => {
match msg { match msg {
SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, ref caller_alias, .. } => { SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, .. } => {
let target_fp = target_fingerprint.clone(); let target_fp = target_fingerprint.clone();
let call_id = call_id.clone(); let call_id = call_id.clone();
@@ -793,22 +841,18 @@ async fn main() -> anyhow::Result<()> {
let _ = hub.send_to(&peer_fp, &msg).await; let _ = hub.send_to(&peer_fp, &msg).await;
} }
// Send CallSetup to both parties // Send CallSetup to both parties.
// Use the address the client connected to (their remote addr //
// is our perspective, but we need our listen addr). // BUG FIX: the previous version of this used `addr.ip()`
// Replace 0.0.0.0 with the client's destination IP. // which is `connection.remote_address()` — the CLIENT'S
let relay_addr_for_setup = if listen_addr_str.starts_with("0.0.0.0:") { // IP, not the relay's. So CallSetup told both parties to
let port = &listen_addr_str[8..]; // dial the answerer's own IP, which meant the caller was
// Use the local IP from the client's connection // sending QUIC Initials into the callee's client (no
let local_ip = addr.ip(); // server listening there) and the callee was sending to
if local_ip.is_loopback() { // itself. In both cases endpoint.connect() hung forever.
format!("127.0.0.1:{port}") //
} else { // Use the relay's precomputed advertised address instead.
format!("{local_ip}:{port}") let relay_addr_for_setup = advertised_addr_str.clone();
}
} else {
listen_addr_str.clone()
};
let setup = SignalMessage::CallSetup { let setup = SignalMessage::CallSetup {
call_id: call_id.clone(), call_id: call_id.clone(),
room: room.clone(), room: room.clone(),
@@ -848,6 +892,31 @@ async fn main() -> anyhow::Result<()> {
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await; let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
} }
// QUIC-native NAT reflection ("STUN for QUIC").
// The client asks "what source address do you
// see for me?" and we reply with whatever
// quinn reports as this connection's remote
// address — i.e. the post-NAT public address
// as observed from the server side of the TLS
// session. Used by the P2P path to learn the
// client's server-reflexive address without
// running a separate STUN server. No auth or
// rate-limit in Phase 1 — the client is
// already TLS-authenticated by the time it
// reaches this match arm.
SignalMessage::Reflect => {
let observed_addr = addr.to_string();
if let Err(e) = transport.send_signal(
&SignalMessage::ReflectResponse {
observed_addr: observed_addr.clone(),
},
).await {
warn!(%addr, error = %e, "reflect: failed to send response");
} else {
debug!(%addr, %observed_addr, "reflect: responded");
}
}
other => { other => {
warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other)); warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other));
} }
@@ -1153,4 +1222,5 @@ async fn main() -> anyhow::Result<()> {
} }
}); });
} }
Ok(())
} }

View File

@@ -10,7 +10,7 @@ use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, info, trace, warn}; use tracing::{error, info, warn};
use wzp_proto::packet::TrunkFrame; use wzp_proto::packet::TrunkFrame;
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
@@ -483,7 +483,6 @@ async fn run_participant_plain(
); );
loop { loop {
let recv_start = std::time::Instant::now();
let pkt = match transport.recv_media().await { let pkt = match transport.recv_media().await {
Ok(Some(pkt)) => pkt, Ok(Some(pkt)) => pkt,
Ok(None) => { Ok(None) => {
@@ -838,7 +837,7 @@ mod tests {
#[test] #[test]
fn room_join_leave() { fn room_join_leave() {
let mut mgr = RoomManager::new(); let mgr = RoomManager::new();
assert_eq!(mgr.room_size("test"), 0); assert_eq!(mgr.room_size("test"), 0);
assert!(mgr.list().is_empty()); assert!(mgr.list().is_empty());
} }

View File

@@ -7,7 +7,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tracing::{info, warn}; use tracing::info;
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::QuinnTransport; use wzp_transport::QuinnTransport;
@@ -94,7 +94,7 @@ mod tests {
#[test] #[test]
fn register_unregister() { fn register_unregister() {
let mut hub = SignalHub::new(); let hub = SignalHub::new();
assert_eq!(hub.online_count(), 0); assert_eq!(hub.online_count(), 0);
assert!(!hub.is_online("alice")); assert!(!hub.is_online("alice"));

View File

@@ -63,11 +63,11 @@ async fn handshake_succeeds() {
accept_handshake(server_t.as_ref(), &callee_seed).await accept_handshake(server_t.as_ref(), &callee_seed).await
}); });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await .await
.expect("perform_handshake should succeed"); .expect("perform_handshake should succeed");
let (callee_session, chosen_profile) = callee_handle let (callee_session, chosen_profile, _caller_fp, _caller_alias) = callee_handle
.await .await
.expect("join callee task") .expect("join callee task")
.expect("accept_handshake should succeed"); .expect("accept_handshake should succeed");
@@ -124,11 +124,11 @@ async fn handshake_verifies_identity() {
accept_handshake(server_t.as_ref(), &callee_seed).await accept_handshake(server_t.as_ref(), &callee_seed).await
}); });
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await .await
.expect("handshake must succeed even with different identities"); .expect("handshake must succeed even with different identities");
let (callee_session, _profile) = callee_handle let (callee_session, _profile, _caller_fp, _caller_alias) = callee_handle
.await .await
.expect("join") .expect("join")
.expect("accept_handshake must succeed"); .expect("accept_handshake must succeed");
@@ -183,7 +183,7 @@ async fn auth_then_handshake() {
}; };
// 2. Run the cryptographic handshake // 2. Run the cryptographic handshake
let (session, profile) = accept_handshake(server_t.as_ref(), &callee_seed) let (session, profile, _caller_fp, _caller_alias) = accept_handshake(server_t.as_ref(), &callee_seed)
.await .await
.expect("accept_handshake after auth"); .expect("accept_handshake after auth");
@@ -199,7 +199,7 @@ async fn auth_then_handshake() {
.await .await
.expect("send AuthToken"); .expect("send AuthToken");
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await .await
.expect("perform_handshake after auth"); .expect("perform_handshake after auth");
@@ -270,6 +270,7 @@ async fn handshake_rejects_bad_signature() {
ephemeral_pub, ephemeral_pub,
signature, signature,
supported_profiles: vec![wzp_proto::QualityProfile::GOOD], supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
alias: None,
}; };
client_transport client_transport

View File

@@ -0,0 +1,318 @@
//! Integration tests for the "STUN for QUIC" reflect protocol
//! (PRD: .taskmaster/docs/prd_reflect_over_quic.txt, Phase 1).
//!
//! We don't spin up the full relay binary — instead we exercise the
//! same wire-level request/response dance with a mock relay loop
//! that implements exactly the match arm added to
//! `wzp-relay/src/main.rs`. This isolates the protocol test from the
//! rest of the relay state (rooms, federation, call registry, ...).
//!
//! Three test cases:
//! 1. `reflect_happy_path` — client sends `Reflect`, mock relay
//! replies with `ReflectResponse { observed_addr }`, client
//! parses it back to a `SocketAddr` and confirms the IP is
//! `127.0.0.1` and the port matches its own bound port.
//! 2. `reflect_two_clients_distinct_ports` — two simultaneous
//! client connections on different ephemeral ports get back
//! different reflected ports, proving the relay uses
//! per-connection `remote_address` rather than a global.
//! 3. `reflect_old_relay_times_out` — mock relay that *doesn't*
//! handle `Reflect`; client side times out in the expected
//! window and does not hang.
//!
//! The third test uses a `tokio::time::timeout` wrapper directly
//! (the client-side `request_reflect` helper lives in
//! `desktop/src-tauri/src/lib.rs` which isn't a library we can
//! depend on from here, so we reproduce the timeout semantics
//! inline).
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport};
/// Spawn a minimal mock relay that loops over `recv_signal`,
/// matches on `Reflect`, and responds with `ReflectResponse` using
/// the remote_address observed for this connection. Mirrors the
/// match arm in `crates/wzp-relay/src/main.rs`.
async fn spawn_mock_relay_with_reflect(
server_transport: Arc<QuinnTransport>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Observed remote address at the time the connection was
// accepted. Stable for the life of the connection under quinn's
// normal operation. This is exactly what the real relay does.
let observed = server_transport.connection().remote_address();
loop {
match server_transport.recv_signal().await {
Ok(Some(SignalMessage::Reflect)) => {
let resp = SignalMessage::ReflectResponse {
observed_addr: observed.to_string(),
};
// If the send fails the client has gone; just exit.
if server_transport.send_signal(&resp).await.is_err() {
break;
}
}
Ok(Some(_other)) => {
// Ignore anything else — not relevant to this test.
}
Ok(None) => break,
Err(_e) => break,
}
}
})
}
/// Spawn a mock relay that intentionally DOES NOT handle Reflect.
/// Models a pre-Phase-1 relay — it keeps reading signal messages and
/// logs them to stderr, but never produces a `ReflectResponse`.
async fn spawn_mock_relay_without_reflect(
server_transport: Arc<QuinnTransport>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
match server_transport.recv_signal().await {
Ok(Some(_msg)) => {
// Deliberately do nothing. Old relay.
}
Ok(None) => break,
Err(_) => break,
}
}
})
}
/// Build an in-process QUIC client/server pair on loopback and
/// return (client_transport, server_transport, endpoints). The
/// endpoints tuple must be kept alive for the test duration.
///
/// `client_port_hint` of 0 means "let OS pick". Pass an explicit
/// port to pin the client's source port (useful for the
/// distinct-ports test).
async fn connected_pair_with_port(
_client_port_hint: u16,
) -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) {
let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert_der) = server_config();
let server_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
let server_ep = create_endpoint(server_addr, Some(sc)).expect("server endpoint");
let server_listen = server_ep.local_addr().expect("server local addr");
// Always bind the client to an ephemeral port — we'll read back
// the actual assigned port via `local_addr()` in the assertions.
let client_bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
let client_ep = create_endpoint(client_bind, None).expect("client endpoint");
let server_ep_clone = server_ep.clone();
let accept_fut = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept");
Arc::new(QuinnTransport::new(conn))
});
let client_conn =
wzp_transport::connect(&client_ep, server_listen, "localhost", client_config())
.await
.expect("connect");
let client_transport = Arc::new(QuinnTransport::new(client_conn));
let server_transport = accept_fut.await.expect("join accept task");
(client_transport, server_transport, (server_ep, client_ep))
}
// -----------------------------------------------------------------------
// Test 1: happy path — client learns its own port via Reflect
// -----------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reflect_happy_path() {
let (client_transport, server_transport, (_server_ep, client_ep)) =
connected_pair_with_port(0).await;
// Grab the client's actual bound port so we can cross-check
// against the reflected response.
let client_port = client_ep
.local_addr()
.expect("client local addr")
.port();
assert_ne!(client_port, 0, "client must have a real bound port");
// Start the mock relay's reflect handler.
let _relay_handle = spawn_mock_relay_with_reflect(Arc::clone(&server_transport)).await;
// Client sends Reflect and awaits the response. The real
// request_reflect helper in desktop/src-tauri/src/lib.rs uses a
// oneshot channel driven off the spawned recv loop; here we just
// do it inline because there's no spawned loop yet in this test
// — this isolates the wire protocol from the client-side state
// machine.
client_transport
.send_signal(&SignalMessage::Reflect)
.await
.expect("send Reflect");
let resp = tokio::time::timeout(Duration::from_secs(2), client_transport.recv_signal())
.await
.expect("reflect response should arrive within 2s")
.expect("recv_signal ok")
.expect("some message");
let observed_addr = match resp {
SignalMessage::ReflectResponse { observed_addr } => observed_addr,
other => panic!("expected ReflectResponse, got {:?}", std::mem::discriminant(&other)),
};
let parsed: SocketAddr = observed_addr
.parse()
.expect("ReflectResponse.observed_addr must parse as SocketAddr");
// The relay should see the client on 127.0.0.1 (loopback in the
// test harness) and on the client's bound ephemeral port.
assert_eq!(parsed.ip().to_string(), "127.0.0.1");
assert_eq!(
parsed.port(),
client_port,
"reflected port must match the client's local_addr port"
);
drop(client_transport);
drop(server_transport);
}
// -----------------------------------------------------------------------
// Test 2: two clients get DIFFERENT reflected ports
// -----------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reflect_two_clients_distinct_ports() {
let _ = rustls::crypto::ring::default_provider().install_default();
// Shared server: one endpoint, two incoming accepts.
let (sc, _cert_der) = server_config();
let server_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
let server_ep = create_endpoint(server_addr, Some(sc)).expect("server endpoint");
let server_listen = server_ep.local_addr().expect("server local addr");
// Accept two clients in parallel.
let server_ep_a = server_ep.clone();
let accept_a = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_a).await.expect("accept A");
Arc::new(QuinnTransport::new(conn))
});
let server_ep_b = server_ep.clone();
let accept_b = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_b).await.expect("accept B");
Arc::new(QuinnTransport::new(conn))
});
// Client A
let client_ep_a = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep A");
let conn_a =
wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config())
.await
.expect("connect A");
let client_a = Arc::new(QuinnTransport::new(conn_a));
let port_a = client_ep_a.local_addr().unwrap().port();
// Client B
let client_ep_b = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep B");
let conn_b =
wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config())
.await
.expect("connect B");
let client_b = Arc::new(QuinnTransport::new(conn_b));
let port_b = client_ep_b.local_addr().unwrap().port();
assert_ne!(
port_a, port_b,
"preconditions: OS must assign two clients different ephemeral ports"
);
let server_a = accept_a.await.expect("join A");
let server_b = accept_b.await.expect("join B");
// Spawn a reflect handler for each server-side transport.
let _relay_a = spawn_mock_relay_with_reflect(Arc::clone(&server_a)).await;
let _relay_b = spawn_mock_relay_with_reflect(Arc::clone(&server_b)).await;
// Each client requests reflect concurrently.
let reflect_for = |t: Arc<QuinnTransport>| async move {
t.send_signal(&SignalMessage::Reflect).await.expect("send");
let resp = tokio::time::timeout(Duration::from_secs(2), t.recv_signal())
.await
.expect("timeout")
.expect("ok")
.expect("some");
match resp {
SignalMessage::ReflectResponse { observed_addr } => observed_addr,
_ => panic!("wrong variant"),
}
};
let (addr_a, addr_b) = tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone()));
let parsed_a: SocketAddr = addr_a.parse().unwrap();
let parsed_b: SocketAddr = addr_b.parse().unwrap();
assert_eq!(parsed_a.port(), port_a, "client A's reflected port");
assert_eq!(parsed_b.port(), port_b, "client B's reflected port");
assert_ne!(
parsed_a.port(),
parsed_b.port(),
"each client must see its own port, not a shared one"
);
drop(client_a);
drop(client_b);
drop(server_a);
drop(server_b);
}
// -----------------------------------------------------------------------
// Test 3: old relay never answers — client times out cleanly
// -----------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reflect_old_relay_times_out() {
let (client_transport, server_transport, _endpoints) =
connected_pair_with_port(0).await;
// Mock relay that ignores Reflect — simulates a pre-Phase-1 build.
let _relay_handle =
spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
client_transport
.send_signal(&SignalMessage::Reflect)
.await
.expect("send Reflect");
// 1100ms ceiling matches the 1s timeout baked into
// get_reflected_address plus a tiny bit of slack. If this
// regression ever fires it probably means recv_signal blocked
// longer than expected and the Tauri command would hang the UI.
let start = std::time::Instant::now();
let result =
tokio::time::timeout(Duration::from_millis(1100), client_transport.recv_signal()).await;
let elapsed = start.elapsed();
assert!(
result.is_err(),
"recv_signal must time out when the relay ignores Reflect"
);
assert!(
elapsed >= Duration::from_millis(1000),
"timeout fired too early ({:?})",
elapsed
);
assert!(
elapsed < Duration::from_millis(1200),
"timeout fired too late ({:?}), client would feel unresponsive",
elapsed
);
drop(client_transport);
drop(server_transport);
}

View File

@@ -27,3 +27,8 @@ pub use connection::{accept, connect, create_endpoint};
pub use path_monitor::PathMonitor; pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport; pub use quic::QuinnTransport;
pub use wzp_proto::{MediaTransport, PathQuality, TransportError}; pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can
// thread a shared endpoint between signaling and media connections without
// needing to depend on quinn directly.
pub use quinn::Endpoint;

View File

@@ -0,0 +1,16 @@
{
"name": "wzp-wasm",
"type": "module",
"description": "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)",
"version": "0.1.0",
"files": [
"wzp_wasm_bg.wasm",
"wzp_wasm.js",
"wzp_wasm.d.ts"
],
"main": "wzp_wasm.js",
"types": "wzp_wasm.d.ts",
"sideEffects": [
"./snippets/*"
]
}

169
crates/wzp-web/static/wasm/wzp_wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,169 @@
/* tslint:disable */
/* eslint-disable */
/**
* Symmetric encryption session using ChaCha20-Poly1305.
*
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
* and key setup are identical so WASM and native peers interoperate.
*/
export class WzpCryptoSession {
free(): void;
[Symbol.dispose](): void;
/**
* Decrypt a media payload with AAD.
*
* Returns plaintext on success, or throws on auth failure.
*/
decrypt(header_aad: Uint8Array, ciphertext: Uint8Array): Uint8Array;
/**
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
*
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
*/
encrypt(header_aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
/**
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
*/
constructor(shared_secret: Uint8Array);
/**
* Current receive sequence number (for diagnostics / UI stats).
*/
recv_seq(): number;
/**
* Current send sequence number (for diagnostics / UI stats).
*/
send_seq(): number;
}
export class WzpFecDecoder {
free(): void;
[Symbol.dispose](): void;
/**
* Feed a received symbol.
*
* Returns the decoded block (concatenated original frames, unpadded) if
* enough symbols have been received to recover the block, or `undefined`.
*/
add_symbol(block_id: number, symbol_idx: number, _is_repair: boolean, data: Uint8Array): Uint8Array | undefined;
/**
* Create a new FEC decoder.
*
* * `block_size` — expected number of source symbols per block.
* * `symbol_size` — padded byte size of each symbol (must match encoder).
*/
constructor(block_size: number, symbol_size: number);
}
export class WzpFecEncoder {
free(): void;
[Symbol.dispose](): void;
/**
* Add a source symbol (audio frame).
*
* Returns encoded packets (all source + repair) when the block is complete,
* or `undefined` if the block is still accumulating.
*
* Each returned packet carries the 3-byte header:
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
*/
add_symbol(data: Uint8Array): Uint8Array | undefined;
/**
* Force-flush the current (possibly partial) block.
*
* Returns all source + repair symbols with headers, or empty vec if no
* symbols have been accumulated.
*/
flush(): Uint8Array;
/**
* Create a new FEC encoder.
*
* * `block_size` — number of source symbols (audio frames) per FEC block.
* * `symbol_size` — padded byte size of each symbol (default 256).
*/
constructor(block_size: number, symbol_size: number);
}
/**
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
*
* Usage from JS:
* ```js
* const kx = new WzpKeyExchange();
* const ourPub = kx.public_key(); // Uint8Array(32)
* // ... send ourPub to peer, receive peerPub ...
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
* const session = new WzpCryptoSession(secret);
* ```
*/
export class WzpKeyExchange {
free(): void;
[Symbol.dispose](): void;
/**
* Derive a 32-byte session key from the peer's public key.
*
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
*/
derive_shared_secret(peer_public: Uint8Array): Uint8Array;
/**
* Generate a new random X25519 keypair.
*/
constructor();
/**
* Our public key (32 bytes).
*/
public_key(): Uint8Array;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbg_wzpcryptosession_free: (a: number, b: number) => void;
readonly __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
readonly __wbg_wzpfecencoder_free: (a: number, b: number) => void;
readonly __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
readonly wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
readonly wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
readonly wzpcryptosession_new: (a: number, b: number) => [number, number, number];
readonly wzpcryptosession_recv_seq: (a: number) => number;
readonly wzpcryptosession_send_seq: (a: number) => number;
readonly wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
readonly wzpfecdecoder_new: (a: number, b: number) => number;
readonly wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
readonly wzpfecencoder_flush: (a: number) => [number, number];
readonly wzpfecencoder_new: (a: number, b: number) => number;
readonly wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
readonly wzpkeyexchange_new: () => number;
readonly wzpkeyexchange_public_key: (a: number) => [number, number];
readonly __wbindgen_exn_store: (a: number) => void;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const __wbg_wzpcryptosession_free: (a: number, b: number) => void;
export const __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
export const __wbg_wzpfecencoder_free: (a: number, b: number) => void;
export const __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
export const wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
export const wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
export const wzpcryptosession_new: (a: number, b: number) => [number, number, number];
export const wzpcryptosession_recv_seq: (a: number) => number;
export const wzpcryptosession_send_seq: (a: number) => number;
export const wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
export const wzpfecdecoder_new: (a: number, b: number) => number;
export const wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
export const wzpfecencoder_flush: (a: number) => [number, number];
export const wzpfecencoder_new: (a: number, b: number) => number;
export const wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
export const wzpkeyexchange_new: () => number;
export const wzpkeyexchange_public_key: (a: number) => [number, number];
export const __wbindgen_exn_store: (a: number) => void;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_start: () => void;

2
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -0,0 +1,8 @@
{
"hash": "9046c0bf",
"configHash": "ef0fc96f",
"lockfileHash": "d66891b1",
"browserHash": "8171ed59",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

251
desktop/index.html Normal file
View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>WarzonePhone</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<!-- Connect screen -->
<div id="connect-screen">
<h1>WarzonePhone</h1>
<p class="subtitle">Encrypted Voice</p>
<div class="form">
<label>Relay
<button id="relay-selected" class="relay-selected" type="button">
<span id="relay-dot" class="dot"></span>
<span id="relay-label">Select relay...</span>
<span class="arrow">&#9881;</span>
</button>
</label>
<label>Room
<input id="room" type="text" value="general" />
</label>
<label>Alias
<input id="alias" type="text" placeholder="your name" />
</label>
<div class="form-row">
<label class="checkbox">
<input id="os-aec" type="checkbox" checked />
OS Echo Cancel
</label>
<button id="settings-btn-home" class="icon-btn" title="Settings (Cmd+,)">&#9881;</button>
</div>
<!-- Mode toggle -->
<div class="mode-toggle" style="display:flex;gap:8px;margin-bottom:8px;">
<button id="mode-room" class="mode-btn active" style="flex:1">Room</button>
<button id="mode-direct" class="mode-btn" style="flex:1">Direct Call</button>
</div>
<!-- Room mode (default) -->
<div id="room-mode">
<button id="connect-btn" class="primary">Connect</button>
</div>
<!-- Direct call mode -->
<div id="direct-mode" class="hidden">
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
<div id="direct-registered" class="hidden" style="margin-top:12px">
<div class="direct-registered-header">
<p style="color:var(--green);font-size:13px;margin:0">&#x2705; Registered — waiting for calls</p>
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
</div>
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
<p style="font-weight:bold;margin:0 0 4px 0">Incoming Call</p>
<p id="incoming-caller" style="font-size:12px;opacity:0.8;margin:0 0 8px 0">From: unknown</p>
<div style="display:flex;gap:8px">
<button id="accept-call-btn" style="flex:1;background:var(--green);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Accept</button>
<button id="reject-call-btn" style="flex:1;background:var(--red);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Reject</button>
</div>
</div>
<!-- Recent contacts -->
<div id="recent-contacts-section" class="hidden">
<div class="history-header">Recent contacts</div>
<div id="recent-contacts-list" class="history-list"></div>
</div>
<!-- Call history -->
<div id="call-history-section" class="hidden">
<div class="history-header">
History
<button id="clear-history-btn" class="link-btn">clear</button>
</div>
<div id="call-history-list" class="history-list"></div>
</div>
<label style="margin-top:8px">Call by fingerprint
<input id="target-fp" type="text" placeholder="xxxx:xxxx:xxxx:..." />
</label>
<button id="call-btn" class="primary" style="margin-top:8px">Call</button>
<p id="call-status-text" style="color:var(--yellow);font-size:13px;margin-top:4px"></p>
</div>
</div>
<p id="connect-error" class="error"></p>
</div>
<div class="identity-info">
<span id="my-identicon"></span>
<span id="my-fingerprint" class="fp-display"></span>
</div>
<div class="recent-rooms" id="recent-rooms"></div>
</div>
<!-- In-call screen -->
<div id="call-screen" class="hidden">
<div class="call-header">
<div class="call-header-row">
<div id="room-name" class="room-name"></div>
<button id="settings-btn-call" class="icon-btn small" title="Settings (Cmd+,)">&#9881;</button>
</div>
<div class="call-meta">
<span id="call-status" class="status-dot"></span>
<span id="call-timer" class="call-timer">0:00</span>
</div>
</div>
<div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div>
</div>
<div id="participants" class="participants"></div>
<div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
<span class="icon" id="mic-icon">Mic</span>
</button>
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
<span class="icon">End</span>
</button>
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
<span class="icon" id="spk-icon">Spk</span>
</button>
</div>
<div id="stats" class="stats"></div>
</div>
<!-- Settings panel -->
<div id="settings-panel" class="hidden">
<div class="settings-card">
<div class="settings-header">
<h2>Settings</h2>
<button id="settings-close" class="icon-btn">&times;</button>
</div>
<div class="settings-section">
<h3>Connection</h3>
<label>Default Room
<input id="s-room" type="text" />
</label>
<label>Alias
<input id="s-alias" type="text" />
</label>
</div>
<div class="settings-section">
<h3>Audio</h3>
<div class="quality-control">
<div class="quality-header">
<span class="setting-label">QUALITY</span>
<span id="s-quality-label" class="quality-label">Auto</span>
</div>
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
<div class="quality-ticks">
<span>64k</span>
<span>48k</span>
<span>32k</span>
<span>Auto</span>
<span>24k</span>
<span>6k</span>
<span>C2</span>
<span>1.2k</span>
</div>
</div>
<label class="checkbox">
<input id="s-os-aec" type="checkbox" />
OS Echo Cancellation (macOS VoiceProcessingIO)
</label>
<label class="checkbox">
<input id="s-agc" type="checkbox" checked />
Automatic Gain Control
</label>
<label class="checkbox">
<input id="s-dred-debug" type="checkbox" />
DRED debug logs (verbose, dev only)
</label>
</div>
<div class="settings-section">
<h3>Identity</h3>
<div class="setting-row">
<span class="setting-label">Fingerprint</span>
<span id="s-fingerprint" class="fp-display-large"></span>
</div>
<div class="setting-row">
<span class="setting-label">Identity file</span>
<span class="fp-display">~/.wzp/identity</span>
</div>
</div>
<div class="settings-section">
<h3>Network</h3>
<div class="setting-row">
<span class="setting-label">Public address</span>
<span id="s-reflected-addr" class="fp-display">(not queried)</span>
<button id="s-reflect-btn" class="secondary-btn">Detect</button>
</div>
<small style="color:var(--text-dim);display:block;margin-top:4px">
Asks the registered relay to echo back the IP:port it sees for this
connection (QUIC-native NAT reflection, replaces STUN).
</small>
</div>
<div class="settings-section">
<h3>Recent Rooms</h3>
<div id="s-recent-rooms" class="recent-rooms-list"></div>
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
</div>
<button id="settings-save" class="primary">Save</button>
</div>
</div>
<!-- Manage Relays dialog -->
<div id="relay-dialog" class="hidden">
<div class="settings-card relay-dialog-card">
<div class="settings-header">
<h2>Manage Relays</h2>
<button id="relay-dialog-close" class="icon-btn">&times;</button>
</div>
<div id="relay-dialog-list" class="relay-dialog-list"></div>
<div class="relay-add-row">
<div class="relay-add-inputs">
<input id="relay-add-name" type="text" placeholder="Name" />
<input id="relay-add-addr" type="text" placeholder="host:port" />
</div>
<button id="relay-add-btn" class="primary">Add Relay</button>
</div>
</div>
</div>
<!-- Key changed warning dialog -->
<div id="key-warning" class="hidden">
<div class="settings-card key-warning-card">
<div class="key-warning-icon">&#9888;</div>
<h2>Server Key Changed</h2>
<p class="key-warning-text">The relay's identity has changed since you last connected. This usually happens when the server was restarted, but could also indicate a security issue.</p>
<div class="key-warning-fps">
<div class="key-fp-row">
<span class="key-fp-label">Previously known</span>
<code id="kw-old-fp" class="key-fp"></code>
</div>
<div class="key-fp-row">
<span class="key-fp-label">New key</span>
<code id="kw-new-fp" class="key-fp"></code>
</div>
</div>
<div class="key-warning-actions">
<button id="kw-accept" class="primary">Accept New Key</button>
<button id="kw-cancel" class="secondary-btn">Cancel</button>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1350
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
desktop/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "wzp-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2"
},
"devDependencies": {
"typescript": "^5",
"vite": "^6",
"@tauri-apps/cli": "^2"
}
}

View File

@@ -0,0 +1,107 @@
[package]
name = "wzp-desktop"
version = "0.1.0"
edition = "2024"
description = "WarzonePhone Desktop — encrypted VoIP client"
default-run = "wzp-desktop"
# Library target — required for Tauri mobile (Android/iOS link the app as a cdylib)
# and also used by the desktop binary below.
#
# `staticlib` was DROPPED from crate-type because rust-lang/rust#104707
# documents that having staticlib alongside cdylib leaks non-exported
# symbols from staticlibs into the cdylib. Bionic's private `__init_tcb`
# / `pthread_create` symbols end up bound LOCALLY inside our .so instead
# of resolved dynamically against libc.so at dlopen time — which crashes
# at launch as soon as tao tries to std::thread::spawn() from the JNI
# onCreate callback. The legacy wzp-android crate uses ["cdylib", "rlib"]
# and runs fine on the same phone with the same NDK + Rust toolchain.
#
# iOS Tauri builds that actually need staticlib can re-add it behind a
# target cfg if we ever ship on iOS.
[lib]
name = "wzp_desktop_lib"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "wzp-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
# cc is no longer needed — all C++ moved to crates/wzp-native (built with
# cargo-ndk and loaded via libloading at runtime). wzp-desktop's .so on
# Android is now pure Rust.
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# WarzonePhone crates — protocol layer is platform-independent
wzp-proto = { path = "../../crates/wzp-proto" }
wzp-codec = { path = "../../crates/wzp-codec" }
wzp-fec = { path = "../../crates/wzp-fec" }
wzp-crypto = { path = "../../crates/wzp-crypto" }
wzp-transport = { path = "../../crates/wzp-transport" }
# wzp-client pulls in CPAL on every desktop target and, additionally on
# macOS, VoiceProcessingIO (coreaudio-rs behind the "vpio" feature). The
# vpio feature MUST NOT be enabled on Windows / Linux because coreaudio-rs
# is Apple-framework-only and will fail to build. Task #24 will add a
# matching Windows Voice Capture DSP path behind its own feature; until
# then, Windows desktops use plain CPAL with AEC disabled.
# macOS: CPAL + VoiceProcessingIO (hardware AEC via Core Audio).
[target.'cfg(target_os = "macos")'.dependencies]
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
# Windows: CPAL for playback + direct WASAPI for capture with OS-level
# AEC (AudioCategory_Communications). The wzp-client `windows-aec`
# feature swaps the default CPAL AudioCapture for a WASAPI one that
# opens the mic under AudioCategory_Communications, turning on Windows's
# communications audio processing chain (AEC, NS, AGC). The reference
# signal for AEC is the system render mix, so echo from our CPAL
# playback is cancelled automatically without extra plumbing.
[target.'cfg(target_os = "windows")'.dependencies]
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "windows-aec"] }
# Linux: CPAL playback+capture baseline. AEC is enabled via the top-level
# `linux-aec` feature in wzp-desktop, which forwards to wzp-client/linux-aec.
# Keeping it opt-in at the wzp-desktop level (rather than forcing it always
# on here) lets `cargo tauri build` produce two variants from the same
# source tree — a noAEC baseline and an AEC build — by toggling the feature
# at build time: `cargo tauri build -- --features wzp-desktop/linux-aec`.
[target.'cfg(target_os = "linux")'.dependencies]
wzp-client = { path = "../../crates/wzp-client", features = ["audio"] }
# Android: no CPAL, no vpio — audio goes through the standalone wzp-native
# cdylib that we dlopen via libloading at runtime. See the wzp_native
# module in src/.
[target.'cfg(target_os = "android")'.dependencies]
wzp-client = { path = "../../crates/wzp-client", default-features = false }
# libloading: runtime dlopen of libwzp_native.so — the standalone cdylib
# crate that owns all C++ (Oboe bridge). Keeps wzp-desktop's .so free of
# any C/C++ static archives that would otherwise leak bionic's internal
# pthread_create into our cdylib and trigger the __init_tcb crash.
libloading = "0.8"
# jni + ndk-context: called from android_audio.rs to invoke
# AudioManager.setSpeakerphoneOn on the JVM side at runtime, so the
# Oboe playout stream (opened with Usage::VoiceCommunication) can route
# between earpiece and loud speaker without restarting.
jni = "0.21"
ndk-context = "0.1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# linux-aec: forwards to wzp-client/linux-aec so `cargo tauri build -- --features
# wzp-desktop/linux-aec` enables the WebRTC AEC3 backend on Linux. No-op on
# other targets because wzp-client/linux-aec is itself cfg(target_os = "linux").
linux-aec = ["wzp-client/linux-aec"]

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Custom Info.plist keys merged into the bundled WarzonePhone.app by
tauri-bundler. The base Info.plist (CFBundleIdentifier, version,
etc.) is generated from tauri.conf.json — only put *additional*
keys here.
NSMicrophoneUsageDescription is required by macOS TCC for any
app that opens an audio input unit. Without this string the OS
silently denies CoreAudio capture (input callbacks return zeros)
and the app never appears in System Settings → Privacy &
Security → Microphone. This was the root cause of the desktop
mic regression where phones could not hear the desktop client.
-->
<key>NSMicrophoneUsageDescription</key>
<string>WarzonePhone needs microphone access to transmit your voice during calls.</string>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
use std::process::Command;
fn main() {
// Capture short git hash so the running app can prove which build it is.
// Falls back to "unknown" if git isn't available (e.g. when building from
// a tarball without a .git dir).
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=WZP_GIT_HASH={git_hash}");
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/heads");
// No cc::Build of ANY kind on Android — all C++ lives in the standalone
// `wzp-native` crate which is built separately with cargo-ndk and loaded
// via libloading at runtime. See docs/incident-tauri-android-init-tcb.md
// for why this split exists.
tauri_build::build()
}

View File

@@ -0,0 +1,26 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.",
"windows": ["main"],
"platforms": [
"linux",
"macOS",
"windows",
"android",
"iOS"
],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:event:allow-emit",
"core:event:allow-emit-to",
"core:path:default",
"core:window:default",
"core:app:default",
"core:webview:default",
"shell:default"
]
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.wzp_desktop"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,101 @@
package com.wzp.desktop
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioManager
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
class MainActivity : TauriActivity() {
companion object {
private const val TAG = "WzpMainActivity"
private const val AUDIO_PERMISSIONS_REQUEST = 4242
private val REQUIRED_AUDIO_PERMISSIONS = arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.MODIFY_AUDIO_SETTINGS
)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Request RECORD_AUDIO early so Oboe (inside libwzp_native.so) can open
// the AAudio input stream without silently failing. The grant is
// persisted, so after the first launch the dialog no longer appears.
// MODIFY_AUDIO_SETTINGS is needed to switch AudioManager mode + speaker.
val needsRequest = REQUIRED_AUDIO_PERMISSIONS.any {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needsRequest) {
Log.i(TAG, "requesting audio permissions")
ActivityCompat.requestPermissions(this, REQUIRED_AUDIO_PERMISSIONS, AUDIO_PERMISSIONS_REQUEST)
} else {
Log.i(TAG, "audio permissions already granted")
configureAudioForCall()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == AUDIO_PERMISSIONS_REQUEST) {
val allGranted = grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
Log.i(TAG, "audio permissions result: allGranted=$allGranted grants=${grantResults.toList()}")
if (allGranted) {
configureAudioForCall()
}
}
}
/**
* Put the phone into VoIP call mode with handset (earpiece) as the
* default output. The Oboe playout stream is opened with
* Usage::VoiceCommunication which honours this routing, so:
*
* MODE_IN_COMMUNICATION + speakerphoneOn=false → earpiece (handset)
* MODE_IN_COMMUNICATION + speakerphoneOn=true → loudspeaker
* MODE_IN_COMMUNICATION + bluetoothScoOn=true → bluetooth headset
*
* The speaker/handset/BT toggle itself is wired up via the Tauri
* command `set_speakerphone(on)` in a follow-up build. For now the
* default is handset, matching the user's stated preference.
*
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
* slider is separate from media volume on most devices.
*/
private fun configureAudioForCall() {
try {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = false // default: handset / earpiece
// Crank both voice-call and music volumes so nothing silent slips
// through regardless of which stream actually ends up driving.
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
am.setStreamVolume(AudioManager.STREAM_VOICE_CALL, maxVoice, 0)
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
} catch (e: Throwable) {
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default"],"platforms":["linux","macOS","windows","android","iOS"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

View File

@@ -0,0 +1,98 @@
//! Runtime bridge to Android's `AudioManager` for in-call audio routing.
//!
//! We own a quinn+Oboe VoIP pipeline entirely from Rust, but routing the
//! playout stream between earpiece / loudspeaker / Bluetooth headset has to
//! happen at the JVM level because those toggles are AudioManager-only.
//! This module uses the global JavaVM handle that `ndk_context` exposes
//! (populated by Tauri's mobile runtime) + the `jni` crate to reach into
//! the Android framework without needing a Tauri plugin.
//!
//! All callers must be inside an Android target (`#[cfg(target_os = "android")]`).
#![cfg(target_os = "android")]
use jni::objects::{JObject, JString, JValue};
use jni::JavaVM;
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's
/// mobile runtime sets up at process startup.
fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
let ctx = ndk_context::android_context();
let vm_ptr = ctx.vm() as *mut jni::sys::JavaVM;
if vm_ptr.is_null() {
return Err("ndk_context: JavaVM pointer is null".into());
}
let vm = unsafe { JavaVM::from_raw(vm_ptr) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let activity_ptr = ctx.context() as jni::sys::jobject;
if activity_ptr.is_null() {
return Err("ndk_context: activity pointer is null".into());
}
// SAFETY: ndk_context guarantees the pointer lives for the process
// lifetime; we wrap it as a JObject<'static> for convenience.
let activity: JObject<'static> = unsafe { JObject::from_raw(activity_ptr) };
Ok((vm, activity))
}
/// Get Android's `AudioManager` via `activity.getSystemService("audio")`.
fn audio_manager<'local>(
env: &mut jni::AttachGuard<'local>,
activity: &JObject<'local>,
) -> Result<JObject<'local>, String> {
let svc_name: JString<'local> = env
.new_string("audio")
.map_err(|e| format!("new_string(audio): {e}"))?;
let am = env
.call_method(
activity,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&svc_name)],
)
.and_then(|v| v.l())
.map_err(|e| format!("getSystemService(audio): {e}"))?;
if am.is_null() {
return Err("getSystemService returned null".into());
}
Ok(am)
}
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
///
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
/// sets this at startup, so by the time a call is up this is always true.
pub fn set_speakerphone(on: bool) -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(if on { 1 } else { 0 })],
)
.map_err(|e| format!("setSpeakerphoneOn({on}): {e}"))?;
tracing::info!(on, "AudioManager.setSpeakerphoneOn");
Ok(())
}
/// Query the current speakerphone state. Returns true if routing is on the
/// loud speaker, false if on earpiece / BT headset / wired headset.
pub fn is_speakerphone_on() -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
let on = env
.call_method(&am, "isSpeakerphoneOn", "()Z", &[])
.and_then(|v| v.z())
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
//! Call history store.
//!
//! Keeps a rolling JSON file of the last N direct-call events so the UI can
//! show "recent contacts" + "call history with callback buttons" on the
//! direct-call screen. Storage lives in `<APP_DATA_DIR>/call_history.json`
//! alongside the identity file. The file is read lazily on first access and
//! cached in an RwLock behind a OnceLock.
//!
//! This is a v1 — no duration tracking yet, entries are logged at the
//! moment the direction is decided (placed / received / missed).
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
/// Maximum number of history entries we keep. Older ones are pruned FIFO.
const MAX_ENTRIES: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CallDirection {
/// Local user placed the call.
Placed,
/// Remote user called and local user answered.
Received,
/// Remote user called but local user did not answer (rejected or
/// missed entirely — the UI treats these identically).
Missed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallHistoryEntry {
pub call_id: String,
pub peer_fp: String,
pub peer_alias: Option<String>,
pub direction: CallDirection,
/// Seconds since UNIX epoch, UTC.
pub timestamp_unix: u64,
}
// ─── In-process store (loaded from disk once) ─────────────────────────────
static STORE: OnceLock<RwLock<Vec<CallHistoryEntry>>> = OnceLock::new();
fn store() -> &'static RwLock<Vec<CallHistoryEntry>> {
STORE.get_or_init(|| RwLock::new(load_from_disk()))
}
fn history_path() -> PathBuf {
crate::APP_DATA_DIR
.get()
.cloned()
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".wzp")
})
.join("call_history.json")
}
fn load_from_disk() -> Vec<CallHistoryEntry> {
let path = history_path();
let Ok(bytes) = std::fs::read(&path) else {
return Vec::new();
};
serde_json::from_slice::<Vec<CallHistoryEntry>>(&bytes)
.inspect_err(|e| tracing::warn!(path = %path.display(), error = %e, "call_history.json parse failed"))
.unwrap_or_default()
}
fn save_to_disk(entries: &[CallHistoryEntry]) {
let path = history_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
// Atomic write via temp file + rename so a crash mid-write doesn't
// leave us with a half-file on disk.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ─── Public API ───────────────────────────────────────────────────────────
/// Append a new entry to the store and persist to disk. Trims the store to
/// `MAX_ENTRIES` after insertion.
pub fn log(
call_id: String,
peer_fp: String,
peer_alias: Option<String>,
direction: CallDirection,
) {
tracing::info!(
%call_id, %peer_fp, ?direction,
alias = ?peer_alias,
"history::log"
);
let entry = CallHistoryEntry {
call_id: call_id.clone(),
peer_fp,
peer_alias,
direction,
timestamp_unix: now_unix(),
};
let mut guard = store().write().unwrap();
// If an entry for this call_id already exists, update it in-place
// rather than appending a duplicate. Protects against the caller
// side adding a second Missed row when the callee's DirectCallOffer
// bounces back through federation / loopback, or when some future
// relay routing edge case double-emits a signal. The dedup keeps
// history tidy and matches what the user intuitively expects (one
// history row per call, not one per signal event).
if let Some(existing) = guard.iter_mut().rev().find(|e| e.call_id == call_id) {
tracing::info!(%call_id, from = ?existing.direction, to = ?direction, "history::log replacing existing entry");
existing.direction = direction;
existing.timestamp_unix = entry.timestamp_unix;
save_to_disk(&guard);
return;
}
guard.push(entry);
if guard.len() > MAX_ENTRIES {
let drop_n = guard.len() - MAX_ENTRIES;
guard.drain(0..drop_n);
}
save_to_disk(&guard);
}
/// Return a copy of all entries in reverse-chronological order
/// (most recent first).
pub fn all() -> Vec<CallHistoryEntry> {
let guard = store().read().unwrap();
guard.iter().rev().cloned().collect()
}
/// Unique peer contacts sorted by most recent interaction. Each contact
/// is represented by the newest history entry for that fingerprint.
pub fn contacts() -> Vec<CallHistoryEntry> {
let guard = store().read().unwrap();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out = Vec::new();
// iterate newest → oldest
for entry in guard.iter().rev() {
if seen.insert(entry.peer_fp.clone()) {
out.push(entry.clone());
}
}
out
}
/// Clear the entire history and persist the empty file.
pub fn clear() {
let mut guard = store().write().unwrap();
guard.clear();
save_to_disk(&guard);
}
/// Find a Missed-candidate entry that matches `call_id` and hasn't been
/// answered yet. Used by the signal loop to turn "pending incoming" into
/// "Received" when the user accepts.
pub fn mark_received_if_pending(call_id: &str) -> bool {
let mut guard = store().write().unwrap();
for entry in guard.iter_mut().rev() {
if entry.call_id == call_id && entry.direction == CallDirection::Missed {
entry.direction = CallDirection::Received;
save_to_disk(&guard);
return true;
}
}
false
}

View File

@@ -0,0 +1,823 @@
// WarzonePhone Tauri backend — shared between desktop (macOS/Windows/Linux)
// and Tauri mobile (Android/iOS). Platform-specific audio is cfg-gated.
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
// Call engine — now compiled on every platform. On desktop it runs the real
// CPAL/VPIO audio pipeline; on Android the engine calls into the standalone
// wzp-native cdylib (via the wzp_native module) for Oboe-backed audio.
mod engine;
// Android runtime binding to libwzp_native.so (Oboe audio backend, built as
// a standalone cdylib with cargo-ndk to avoid the Tauri staticlib symbol
// leak — see docs/incident-tauri-android-init-tcb.md).
#[cfg(target_os = "android")]
mod wzp_native;
// Android AudioManager bridge (routing earpiece / speaker / BT).
#[cfg(target_os = "android")]
mod android_audio;
// Direct-call history store (persisted JSON in app data dir).
mod history;
// CallEngine has a unified impl on both targets now — the Android branch of
// CallEngine::start() routes audio through the standalone wzp-native cdylib
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
use engine::CallEngine;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use wzp_proto::MediaTransport;
/// Short git hash captured at compile time by build.rs.
const GIT_HASH: &str = env!("WZP_GIT_HASH");
/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Adjective list — keep in sync with the noun list below. Both are powers of
/// 2 friendly so the modulo bias is negligible.
const ALIAS_ADJECTIVES: &[&str] = &[
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc",
];
const ALIAS_NOUNS: &[&str] = &[
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus",
];
/// Derive a stable human-readable alias from the seed bytes. Same seed →
/// same alias forever, different seeds → effectively random aliases.
fn derive_alias(seed: &wzp_crypto::Seed) -> String {
let adj_idx = (u16::from_le_bytes([seed.0[0], seed.0[1]]) as usize) % ALIAS_ADJECTIVES.len();
let noun_idx = (u16::from_le_bytes([seed.0[2], seed.0[3]]) as usize) % ALIAS_NOUNS.len();
format!("{} {}", ALIAS_ADJECTIVES[adj_idx], ALIAS_NOUNS[noun_idx])
}
#[derive(Clone, Serialize)]
struct CallEvent {
kind: String,
message: String,
}
#[derive(Clone, Serialize)]
struct Participant {
fingerprint: String,
alias: Option<String>,
relay_label: Option<String>,
}
#[derive(Clone, Serialize)]
struct CallStatus {
active: bool,
mic_muted: bool,
spk_muted: bool,
participants: Vec<Participant>,
encode_fps: u64,
recv_fps: u64,
audio_level: u32,
call_duration_secs: f64,
fingerprint: String,
tx_codec: String,
rx_codec: String,
}
struct AppState {
engine: Mutex<Option<CallEngine>>,
signal: Arc<Mutex<SignalState>>,
}
/// Ping result with RTT and server identity hash.
#[derive(Clone, Serialize)]
struct PingResult {
rtt_ms: u32,
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
server_fingerprint: String,
}
/// Toggle DRED verbose logging at runtime (gates the chatty per-frame
/// reconstruction + parse logs in opus_enc and engine.rs). Wired to the
/// "DRED debug logs" checkbox in the GUI settings panel.
#[tauri::command]
fn set_dred_verbose_logs(enabled: bool) {
wzp_codec::set_dred_verbose_logs(enabled);
tracing::info!(enabled, "DRED verbose logs toggled");
}
/// Read the current DRED verbose logging flag (so the GUI can hydrate
/// its checkbox on startup without trusting localStorage alone).
#[tauri::command]
fn get_dred_verbose_logs() -> bool {
wzp_codec::dred_verbose_logs()
}
/// Ping a relay to check if it's online, measure RTT, and get server identity.
#[tauri::command]
async fn ping_relay(relay: String) -> Result<PingResult, String> {
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
let _ = rustls::crypto::ring::default_provider().install_default();
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
let client_cfg = wzp_transport::client_config();
let start = std::time::Instant::now();
let conn_result = tokio::time::timeout(
std::time::Duration::from_secs(3),
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
)
.await;
// Always close endpoint to prevent resource leaks
endpoint.close(0u32.into(), b"done");
match conn_result {
Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u32;
let server_fingerprint = conn
.peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
.and_then(|certs| certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut hasher);
let h = hasher.finish();
format!("{h:016x}")
}))
.unwrap_or_else(|| {
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
});
conn.close(0u32.into(), b"ping");
Ok(PingResult { rtt_ms, server_fingerprint })
}
Ok(Err(e)) => Err(format!("{e}")),
Err(_) => Err("timeout (3s)".into()),
}
}
/// Return the directory where identity/config should live.
///
/// Resolved at startup from Tauri's `path().app_data_dir()` API which gives
/// us the platform-correct app-private location:
/// - Android: `/data/data/<package_id>/files/com.wzp.desktop`
/// - macOS: `~/Library/Application Support/com.wzp.desktop`
/// - Linux: `~/.local/share/com.wzp.desktop`
///
/// Falls back to `$HOME/.wzp` on the desktop side if the OnceLock hasn't been
/// initialised yet (shouldn't happen in normal startup, but keeps the fn
/// total).
fn identity_dir() -> PathBuf {
if let Some(dir) = APP_DATA_DIR.get() {
return dir.clone();
}
#[cfg(target_os = "android")]
{
// Last-resort default. The real path is set in setup() below.
std::path::PathBuf::from("/data/data/com.wzp.desktop/files")
}
#[cfg(not(target_os = "android"))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(home).join(".wzp")
}
}
fn identity_path() -> std::path::PathBuf {
identity_dir().join("identity")
}
/// Load the persisted seed, or generate-and-persist a new one if missing.
fn load_or_create_seed() -> Result<wzp_crypto::Seed, String> {
let path = identity_path();
if path.exists() {
let hex = std::fs::read_to_string(&path).map_err(|e| format!("read identity: {e}"))?;
return wzp_crypto::Seed::from_hex(hex.trim()).map_err(|e| format!("{e}"));
}
let seed = wzp_crypto::Seed::generate();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create identity dir: {e}"))?;
}
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
std::fs::write(&path, hex).map_err(|e| format!("write identity: {e}"))?;
Ok(seed)
}
/// Read fingerprint, generating a fresh identity if none exists yet.
#[tauri::command]
fn get_identity() -> Result<String, String> {
let seed = load_or_create_seed()?;
Ok(seed.derive_identity().public_identity().fingerprint.to_string())
}
/// Build/identity info shown on the home screen so the user can prove which
/// build is installed and what their stable alias is.
#[derive(Clone, Serialize)]
struct AppInfo {
/// Short git commit hash captured at build time.
git_hash: &'static str,
/// Stable adjective+noun derived from the seed.
alias: String,
/// Full fingerprint, e.g. "abcd:ef01:..."
fingerprint: String,
/// App data dir actually in use — useful for debugging EACCES issues.
data_dir: String,
}
#[tauri::command]
fn get_app_info() -> Result<AppInfo, String> {
let seed = load_or_create_seed()?;
let pub_id = seed.derive_identity().public_identity();
Ok(AppInfo {
git_hash: GIT_HASH,
alias: derive_alias(&seed),
fingerprint: pub_id.fingerprint.to_string(),
data_dir: identity_dir().to_string_lossy().into_owned(),
})
}
#[tauri::command]
async fn connect(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
relay: String,
room: String,
alias: String,
os_aec: bool,
quality: String,
) -> Result<String, String> {
let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() {
return Err("already connected".into());
}
// If we previously opened a quinn::Endpoint for the signaling connection
// (direct-call path), reuse it so the media connection shares the same
// UDP socket. This side-steps the Android issue where a second
// quinn::Endpoint silently hangs in the QUIC handshake.
let reuse_endpoint = state.signal.lock().await.endpoint.clone();
if reuse_endpoint.is_some() {
tracing::info!("connect: reusing existing signal endpoint for media connection");
}
let app_clone = app.clone();
match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, move |event_kind, message| {
let _ = app_clone.emit(
"call-event",
CallEvent {
kind: event_kind.to_string(),
message: message.to_string(),
},
);
})
.await
{
Ok(eng) => {
*engine_lock = Some(eng);
Ok("connected".into())
}
Err(e) => Err(format!("{e}")),
}
}
#[tauri::command]
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
let mut engine_lock = state.engine.lock().await;
if let Some(engine) = engine_lock.take() {
engine.stop().await;
Ok("disconnected".into())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_mic())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_speaker())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
let status = engine.status().await;
Ok(CallStatus {
active: true,
mic_muted: status.mic_muted,
spk_muted: status.spk_muted,
participants: status
.participants
.into_iter()
.map(|p| Participant {
fingerprint: p.fingerprint,
alias: p.alias,
relay_label: p.relay_label,
})
.collect(),
encode_fps: status.frames_sent,
recv_fps: status.frames_received,
audio_level: status.audio_level,
call_duration_secs: status.call_duration_secs,
fingerprint: status.fingerprint,
tx_codec: status.tx_codec,
rx_codec: status.rx_codec,
})
} else {
Ok(CallStatus {
active: false,
mic_muted: false,
spk_muted: false,
participants: vec![],
encode_fps: 0,
recv_fps: 0,
audio_level: 0,
call_duration_secs: 0.0,
fingerprint: String::new(),
tx_codec: String::new(),
rx_codec: String::new(),
})
}
}
// ─── Audio routing (Android-specific, no-op on desktop) ─────────────────────
/// Switch the call audio between earpiece (`on=false`) and loudspeaker
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
/// with the new routing — without the restart, changing the speakerphone
/// state mid-call silently tears down the running AAudio streams on some
/// OEMs and both capture + playout stop producing data.
///
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
/// window; they just observe empty reads / writes against the
/// process-global ring buffers, which is fine because the ring state
/// is preserved across stop+start.
#[tauri::command]
#[allow(unused_variables)]
async fn set_speakerphone(on: bool) -> Result<(), String> {
#[cfg(target_os = "android")]
{
android_audio::set_speakerphone(on)?;
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
tracing::info!(on, "set_speakerphone: restarting Oboe for route change");
// Oboe's stop/start are sync C-FFI calls that block for ~400ms
// on Nothing-class devices (Pixel is faster). Calling them
// directly from an async Tauri command stalls the tokio
// executor — the send/recv engine tasks were observed to
// freeze for ~20 seconds across a few rapid speaker toggles,
// piling up buffered QUIC datagrams and then flooding them
// all at once when the runtime finally caught up.
//
// Fix: run the audio teardown + reopen on a dedicated
// blocking thread so the runtime keeps scheduling everything
// else. AAudio's requestStop returns only after the stream
// is actually in Stopped state, so no explicit inter-call
// sleep is needed.
tokio::task::spawn_blocking(|| {
wzp_native::audio_stop();
wzp_native::audio_start()
.map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))??;
tracing::info!("set_speakerphone: Oboe restarted");
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// Query whether the call is currently routed to the loudspeaker.
#[tauri::command]
async fn is_speakerphone_on() -> Result<bool, String> {
#[cfg(target_os = "android")]
{
android_audio::is_speakerphone_on()
}
#[cfg(not(target_os = "android"))]
{
Ok(false)
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
fn get_call_history() -> Vec<history::CallHistoryEntry> {
history::all()
}
#[tauri::command]
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
history::contacts()
}
#[tauri::command]
fn clear_call_history() -> Result<(), String> {
history::clear();
Ok(())
}
// ─── Signaling commands — platform independent ───────────────────────────────
struct SignalState {
transport: Option<Arc<wzp_transport::QuinnTransport>>,
/// The quinn::Endpoint backing the signal connection. Reused for the
/// media connection when a direct call is accepted — Android phones
/// silently drop packets from a second quinn::Endpoint to the same
/// relay, so every call after register_signal MUST share this socket.
endpoint: Option<wzp_transport::Endpoint>,
fingerprint: String,
signal_status: String,
incoming_call_id: Option<String>,
incoming_caller_fp: Option<String>,
incoming_caller_alias: Option<String>,
/// Pending `ReflectResponse` channel. When the `get_reflected_address`
/// Tauri command fires, it drops a `oneshot::Sender<SocketAddr>` here
/// before sending a `SignalMessage::Reflect`. The spawned recv loop
/// picks the response off the next bi-stream and fires the sender.
/// If another Reflect request comes in while one is pending, we
/// replace the sender — the old receiver sees a `Cancelled` error
/// and the caller retries.
pending_reflect: Option<tokio::sync::oneshot::Sender<std::net::SocketAddr>>,
}
#[tauri::command]
async fn register_signal(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
relay: String,
) -> Result<String, String> {
use wzp_proto::SignalMessage;
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
let _ = rustls::crypto::ring::default_provider().install_default();
// Load or create seed automatically — no need to "connect to a room first"
let seed = load_or_create_seed()?;
let pub_id = seed.derive_identity().public_identity();
let fp = pub_id.fingerprint.to_string();
let identity_pub = *pub_id.signing.as_bytes();
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
let conn = wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config())
.await.map_err(|e| format!("{e}"))?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None,
}).await.map_err(|e| format!("{e}"))?;
match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {}
_ => return Err("registration failed".into()),
}
{ let mut sig = state.signal.lock().await; sig.transport = Some(transport.clone()); sig.endpoint = Some(endpoint.clone()); sig.fingerprint = fp.clone(); sig.signal_status = "registered".into(); }
tracing::info!(%fp, "signal registered, spawning recv loop");
let signal_state = Arc::clone(&state.signal);
let app_clone = app.clone();
tokio::spawn(async move {
loop {
match transport.recv_signal().await {
Ok(Some(SignalMessage::CallRinging { call_id })) => {
tracing::info!(%call_id, "signal: CallRinging");
let mut sig = signal_state.lock().await; sig.signal_status = "ringing".into();
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"ringing","call_id":call_id}));
}
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into();
sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone();
// Log as a Missed entry up-front. If the user accepts
// the call, answer_call upgrades it to Received via
// history::mark_received_if_pending(call_id). If they
// reject or ignore, it stays Missed.
history::log(
call_id.clone(),
caller_fingerprint.clone(),
caller_alias.clone(),
history::CallDirection::Missed,
);
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
let _ = app_clone.emit("history-changed", ());
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
tracing::info!(%call_id, %room, %relay_addr, "signal: CallSetup — emitting setup event to JS");
let mut sig = signal_state.lock().await; sig.signal_status = "setup".into();
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"setup","call_id":call_id,"room":room,"relay_addr":relay_addr}));
}
Ok(Some(SignalMessage::Hangup { reason })) => {
tracing::info!(?reason, "signal: Hangup");
let mut sig = signal_state.lock().await; sig.signal_status = "registered".into(); sig.incoming_call_id = None;
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"hangup"}));
}
Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => {
// "STUN for QUIC" response — the relay told us our
// own server-reflexive address. If a Tauri command
// is currently awaiting this, fire the oneshot;
// otherwise log and drop (unsolicited responses
// from a confused relay shouldn't crash the loop).
tracing::info!(%observed_addr, "signal: ReflectResponse");
match observed_addr.parse::<std::net::SocketAddr>() {
Ok(parsed) => {
let mut sig = signal_state.lock().await;
if let Some(tx) = sig.pending_reflect.take() {
// `send` returns Err(addr) only if the
// receiver was dropped (caller timed out
// or canceled). Either way, nothing to
// do — the value is gone.
let _ = tx.send(parsed);
} else {
tracing::debug!(%observed_addr, "reflect: unsolicited response (no pending sender)");
}
let _ = app_clone.emit(
"signal-event",
serde_json::json!({"type":"reflect","observed_addr":observed_addr}),
);
}
Err(e) => {
tracing::warn!(%observed_addr, error = %e, "reflect: relay returned unparseable addr");
// Treat unparseable response as a failed
// request so the caller doesn't hang.
let mut sig = signal_state.lock().await;
let _ = sig.pending_reflect.take();
}
}
}
Ok(Some(other)) => {
tracing::debug!(?other, "signal: unhandled message");
}
Ok(None) => {
tracing::warn!("signal recv returned None — peer closed");
break;
}
Err(e) => {
tracing::warn!(error = %e, "signal recv error — breaking loop");
break;
}
}
}
tracing::warn!("signal recv loop exited — signal_status=idle, transport dropped");
let mut sig = signal_state.lock().await; sig.signal_status = "idle".into(); sig.transport = None;
});
Ok(fp)
}
#[tauri::command]
async fn place_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
target_fp: String,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or("not registered")?;
let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
tracing::info!(%call_id, %target_fp, "place_call: sending DirectCallOffer");
transport.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(),
call_id: call_id.clone(), identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
}).await.map_err(|e| format!("{e}"))?;
history::log(call_id, target_fp, None, history::CallDirection::Placed);
let _ = app.emit("history-changed", ());
Ok(())
}
#[tauri::command]
async fn answer_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
call_id: String,
mode: i32,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or_else(|| {
tracing::warn!("answer_call: not registered (no transport)");
"not registered".to_string()
})?;
let accept_mode = match mode { 0 => wzp_proto::CallAcceptMode::Reject, 1 => wzp_proto::CallAcceptMode::AcceptTrusted, _ => wzp_proto::CallAcceptMode::AcceptGeneric };
tracing::info!(%call_id, ?accept_mode, "answer_call: sending DirectCallAnswer");
transport.send_signal(&SignalMessage::DirectCallAnswer {
call_id: call_id.clone(), accept_mode, identity_pub: None, ephemeral_pub: None, signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
}).await.map_err(|e| {
tracing::error!(%call_id, error = %e, "answer_call: send_signal failed");
format!("{e}")
})?;
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
// Upgrade the pending "Missed" entry to "Received" if the user
// accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
if mode != 0 {
if history::mark_received_if_pending(&call_id) {
let _ = app.emit("history-changed", ());
}
}
Ok(())
}
/// "STUN for QUIC" — ask the relay what our own public address looks
/// like from its side of the TLS-authenticated signal connection.
///
/// Wire flow:
/// 1. We install a `oneshot::Sender` in `SignalState.pending_reflect`
/// (replacing any stale one — last request wins).
/// 2. We release the state lock and send `SignalMessage::Reflect`
/// over the existing transport. The relay opens a fresh bi-stream
/// on its side to respond, which the spawned recv loop picks up.
/// 3. The recv loop's `ReflectResponse` match arm takes the sender
/// back out and fires it with the parsed `SocketAddr`.
/// 4. We await the receiver with a 1s timeout so a non-reflecting
/// relay (pre-Phase-1 build) doesn't hang the UI forever.
///
/// Returns the addr as a string so it can cross the Tauri IPC
/// boundary unchanged — JS-side can display it directly or parse it
/// with `new URL(...)` / a regex if needed.
#[tauri::command]
async fn get_reflected_address(
state: tauri::State<'_, Arc<AppState>>,
) -> Result<String, String> {
use wzp_proto::SignalMessage;
let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
let transport = {
let mut sig = state.signal.lock().await;
// Drop any older pending sender — we don't support more than
// one in-flight Reflect per connection. A prior request whose
// receiver has timed out will be cleaned up here automatically.
sig.pending_reflect = Some(tx);
sig.transport
.as_ref()
.ok_or_else(|| "not registered".to_string())?
.clone()
};
if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
// Clean up the pending sender so the next attempt doesn't see
// a stale channel. Re-acquire the lock inline since we already
// released it above to release `transport` back to the caller.
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
return Err(format!("send Reflect: {e}"));
}
// 1s is plenty for a same-datacenter relay (< 50ms RTT) and also
// the ceiling for "something's wrong, tell the user" — any older
// relay will never reply at all. 1100ms in the integration test.
match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
Ok(Ok(addr)) => Ok(addr.to_string()),
Ok(Err(_canceled)) => {
// The recv loop dropped the sender (relay returned
// unparseable addr, or loop exited mid-request).
Err("reflect channel canceled (signal loop exited or parse error)".into())
}
Err(_elapsed) => {
// Timeout — strip the pending sender so the next attempt
// starts clean. Old (pre-Phase-1) relays will land here.
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
Err("reflect timeout (relay may not support reflection)".into())
}
}
}
#[tauri::command]
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
let sig = state.signal.lock().await;
Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}))
}
/// Tear down the signal connection so the user goes back to idle. Called
/// when the user clicks "Deregister" on the direct-call screen. The
/// spawned recv loop will break out naturally when the transport closes.
#[tauri::command]
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
let mut sig = state.signal.lock().await;
if let Some(transport) = sig.transport.take() {
tracing::info!("deregister: closing signal transport");
transport.close().await.ok();
}
sig.endpoint = None;
sig.signal_status = "idle".into();
sig.incoming_call_id = None;
sig.incoming_caller_fp = None;
sig.incoming_caller_alias = None;
Ok(())
}
// ─── App entry point ─────────────────────────────────────────────────────────
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
/// entry point below.
pub fn run() {
tracing_subscriber::fmt().init();
let state = Arc::new(AppState {
engine: Mutex::new(None),
signal: Arc::new(Mutex::new(SignalState {
transport: None, endpoint: None, fingerprint: String::new(), signal_status: "idle".into(),
incoming_call_id: None, incoming_caller_fp: None, incoming_caller_alias: None,
pending_reflect: None,
})),
});
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(state)
.setup(|app| {
// Resolve the platform-correct app data dir once at startup so
// every command can read/write the seed without juggling AppHandle.
let data_dir = app
.path()
.app_data_dir()
.map(|p| p.join(".wzp"))
.unwrap_or_else(|_| identity_dir());
// create_dir_all is a no-op if it already exists.
if let Err(e) = std::fs::create_dir_all(&data_dir) {
tracing::warn!("failed to create app data dir {data_dir:?}: {e}");
}
tracing::info!("app data dir: {data_dir:?}");
let _ = APP_DATA_DIR.set(data_dir);
// Load the standalone wzp-native cdylib (Oboe audio bridge) and
// cache its exported function pointers. The library handle is
// kept alive in a 'static OnceLock for the lifetime of the
// process, so CallEngine::start() can invoke its audio FFI
// from anywhere. See src/wzp_native.rs and the incident report
// in docs/incident-tauri-android-init-tcb.md.
#[cfg(target_os = "android")]
{
match wzp_native::init() {
Ok(()) => {
tracing::info!(
"wzp-native loaded: version={} msg=\"{}\"",
wzp_native::version(),
wzp_native::hello()
);
}
Err(e) => {
tracing::warn!("wzp-native init failed: {e}");
}
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
ping_relay, get_identity, get_app_info,
connect, disconnect, toggle_mic, toggle_speaker, get_status,
register_signal, place_call, answer_call, get_signal_status,
get_reflected_address,
deregister,
set_speakerphone, is_speakerphone_on,
get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs,
])
.run(tauri::generate_context!())
.expect("error while running WarzonePhone");
}
/// Tauri mobile entry point (Android/iOS). On desktop this is a no-op —
/// `main.rs` calls `run()` directly.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn mobile_entry() {
run();
}

View File

@@ -0,0 +1,10 @@
// Desktop binary entry point. All logic lives in `lib.rs` so the same
// code can be built as a cdylib for Android/iOS via `cargo tauri android build`.
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
wzp_desktop_lib::run();
}

View File

@@ -0,0 +1,138 @@
//! Runtime binding to the standalone `wzp-native` cdylib.
//!
//! See `docs/incident-tauri-android-init-tcb.md` and the top of
//! `crates/wzp-native/src/lib.rs` for the full story on why this split
//! exists. Short version: Tauri's desktop cdylib cannot have any C++
//! compiled into it (via cc::Build) without landing in rust-lang/rust#104707's
//! staticlib symbol leak, which makes bionic's private `pthread_create`
//! symbols bind locally and SIGSEGV in `__init_tcb+4` at launch. So all
//! the Oboe + audio code lives in a standalone `wzp-native` .so built
//! with `cargo-ndk`, and we dlopen it here at runtime.
//!
//! The Library handle lives in a `'static` `OnceLock` for the lifetime of
//! the process; all function pointers cached below borrow from it safely.
#![cfg(target_os = "android")]
use std::sync::OnceLock;
// ─── Library handle (kept alive forever) ─────────────────────────────────
static LIB: OnceLock<libloading::Library> = OnceLock::new();
// Cached function pointers, resolved once at init(). Each is a raw
// `extern "C"` fn pointer with effectively `'static` lifetime because
// LIB is a OnceLock that never drops.
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
/// Load `libwzp_native.so` and resolve every exported function we use.
/// Call this once at app startup (from the Tauri `setup()` callback).
/// Subsequent calls are no-ops.
pub fn init() -> Result<(), String> {
if LIB.get().is_some() {
return Ok(());
}
// Open the sibling cdylib. The Android dynamic linker searches
// /data/app/<pkg>/lib/arm64/ which gradle populates from jniLibs.
let lib = unsafe { libloading::Library::new("libwzp_native.so") }
.map_err(|e| format!("dlopen libwzp_native.so: {e}"))?;
// Stash the Library into the OnceLock first so all Symbol lookups
// below borrow from the 'static reference rather than a local.
LIB.set(lib).map_err(|_| "wzp_native::LIB already set")?;
let lib_ref: &'static libloading::Library = LIB.get().unwrap();
unsafe {
macro_rules! resolve {
($cell:expr, $ty:ty, $name:expr) => {{
let sym: libloading::Symbol<$ty> = lib_ref.get($name)
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?;
// Dereference the Symbol to extract the raw fn pointer;
// it stays valid because lib_ref is 'static.
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?;
}};
}
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running");
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms");
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms");
}
Ok(())
}
/// Is `init()` done and all symbols cached?
pub fn is_loaded() -> bool {
AUDIO_START.get().is_some()
}
// ─── Smoke-test accessors ────────────────────────────────────────────────
pub fn version() -> i32 {
VERSION.get().map(|f| unsafe { f() }).unwrap_or(-1)
}
pub fn hello() -> String {
let Some(f) = HELLO.get() else { return String::new(); };
let mut buf = [0u8; 64];
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
String::from_utf8_lossy(&buf[..n]).into_owned()
}
// ─── Audio accessors ─────────────────────────────────────────────────────
/// Start the Oboe capture + playout streams. Returns `Err(code)` on
/// failure. Idempotent on the wzp-native side.
pub fn audio_start() -> Result<(), i32> {
let f = AUDIO_START.get().ok_or(-100_i32)?;
let ret = unsafe { f() };
if ret == 0 { Ok(()) } else { Err(ret) }
}
/// Stop both streams. Safe to call even if not running.
pub fn audio_stop() {
if let Some(f) = AUDIO_STOP.get() {
unsafe { f() };
}
}
/// Read captured i16 PCM into `out`. Returns bytes actually copied.
pub fn audio_read_capture(out: &mut [i16]) -> usize {
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; };
unsafe { f(out.as_mut_ptr(), out.len()) }
}
/// Write i16 PCM into the playout ring. Returns samples enqueued.
pub fn audio_write_playout(input: &[i16]) -> usize {
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; };
unsafe { f(input.as_ptr(), input.len()) }
}
pub fn audio_is_running() -> bool {
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false)
}
#[allow(dead_code)]
pub fn audio_capture_latency_ms() -> f32 {
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
}
#[allow(dead_code)]
pub fn audio_playout_latency_ms() -> f32 {
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
}

View File

@@ -0,0 +1,36 @@
{
"productName": "WarzonePhone",
"version": "0.1.0",
"identifier": "com.wzp.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "WarzonePhone",
"width": 400,
"height": 640,
"resizable": true,
"minWidth": 360,
"minHeight": 500
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.png"
],
"android": {
"minSdkVersion": 26
}
}
}

110
desktop/src/identicon.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Deterministic identicon generator — creates a unique symmetric pattern
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
*
* Returns an SVG data URL that can be used as an <img> src.
*/
function hashBytes(hex: string): number[] {
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
const bytes: number[] = [];
for (let i = 0; i < clean.length; i += 2) {
bytes.push(parseInt(clean.substring(i, i + 2), 16));
}
// Pad to at least 16 bytes
while (bytes.length < 16) bytes.push(0);
return bytes;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [
Math.round(f(0) * 255),
Math.round(f(8) * 255),
Math.round(f(4) * 255),
];
}
export function generateIdenticon(
fingerprint: string,
size: number = 36
): string {
const bytes = hashBytes(fingerprint);
// Derive colors from first bytes
const hue1 = (bytes[0] * 360) / 256;
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
const bg = `rgb(${r1},${g1},${b1})`;
const fg = `rgb(${r2},${g2},${b2})`;
// 5x5 grid, left-right symmetric (only need 3 columns)
const grid: boolean[][] = [];
for (let y = 0; y < 5; y++) {
const row: boolean[] = [];
for (let x = 0; x < 3; x++) {
const byteIdx = 2 + y * 3 + x;
row.push(bytes[byteIdx % bytes.length] > 128);
}
// Mirror: col 3 = col 1, col 4 = col 0
grid.push([row[0], row[1], row[2], row[1], row[0]]);
}
// Render SVG
const cellSize = size / 5;
const r = size * 0.12; // border radius
let rects = "";
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 5; x++) {
if (grid[y][x]) {
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
}
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
${rects}
</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/**
* Create an <img> element with the identicon.
* Click copies the fingerprint to clipboard.
*/
export function createIdenticonEl(
fingerprint: string,
size: number = 36,
clickToCopy: boolean = true
): HTMLImageElement {
const img = document.createElement("img");
img.src = generateIdenticon(fingerprint, size);
img.width = size;
img.height = size;
img.style.borderRadius = `${size * 0.12}px`;
img.style.cursor = clickToCopy ? "pointer" : "default";
img.title = fingerprint;
if (clickToCopy && fingerprint) {
img.addEventListener("click", (e) => {
e.stopPropagation();
navigator.clipboard.writeText(fingerprint).then(() => {
img.style.outline = "2px solid #4ade80";
setTimeout(() => {
img.style.outline = "";
}, 600);
});
});
}
return img;
}

1085
desktop/src/main.ts Normal file

File diff suppressed because it is too large Load Diff

1031
desktop/src/style.css Normal file

File diff suppressed because it is too large Load Diff

15
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

15
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
export default defineConfig({
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: "esnext",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});

View File

@@ -1,139 +0,0 @@
# Branch: `android-rewrite`
Pivot away from the legacy Kotlin + JNI Android client to a pure-Rust **Tauri 2.x Mobile** app that shares the same frontend and backend code as the desktop client.
## Why this branch exists
The Kotlin + JNI stack was a crash factory. Every failure mode we hit was at the Kotlin ↔ Rust boundary, and each fix uncovered the next layer of the onion:
| Symptom | Root cause | Fix |
|---|---|---|
| App crashed on launch before `onCreate` returned | `__init_tcb` / `pthread_create` bionic private symbols leaking out of `libwzp_android.so` because the Rust crate used `crate-type = ["cdylib", "staticlib"]`. rust-lang/rust#104707 documents that staticlib alongside cdylib leaks non-exported symbols from the staticlib into the cdylib, and Bionic's private internal pthread symbols got bound LOCALLY inside our `.so` instead of resolved against `libc.so` at `dlopen` time | Dropped `staticlib` from the crate-type list. `crate-type = ["cdylib", "rlib"]` only. |
| Stack overflow on `place_call` | `Dispatchers.IO` threads have a ~512 KB stack, too small for the Rust signal-connect path that does TLS handshake + quinn setup inside one closure | Launched JNI calls from a dedicated `java.lang.Thread` with an explicit 8 MB stack |
| `ring` / `libcrypto` TLS reuse crash on second call | tokio runtime got dropped between calls, but `ring` keeps a TLS-stored SSL context that is invalidated when the runtime thread is reused by a new runtime — `ring` sees stale context and segfaults | Single long-lived tokio runtime for the entire signal client lifetime; split `start()` into an inline `connect+register` path and a `run()` path on a separate thread to avoid the `thread::spawn` closure's stack overflow |
| Null dereference on register with fresh install | Identity seed file empty when it existed-but-was-blank, Rust side deref'd the zero-length slice | Generate seed if empty on register |
Every fix kept the app limping along but the fundamental design problem remained: **state management was split across a Kotlin ViewModel and a Rust engine, with a hand-rolled JNI bridge in between that had to be perfect to not crash**. The working desktop Tauri client (with the same Rust backend) had none of these problems because it spoke to the Rust code via in-process `invoke()` from a WebView, not JNI.
So: rewrite the Android app as a **Tauri 2.x Mobile app**, reusing the entire desktop codebase verbatim (`main.ts`, `style.css`, `index.html`, `main.rs`, `engine.rs` — everything). Tauri Mobile added Android support in v2, it's production-ready, and it eliminates the JNI boundary entirely.
The incident postmortem lives at [`docs/incident-tauri-android-init-tcb.md`](incident-tauri-android-init-tcb.md).
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Tauri 2.x Mobile │
│ │
│ Android WebView ────────── HTML/JS/CSS │ ← Shared with desktop
│ │ (main.ts) │
│ │ │
│ invoke() ─────────────── Rust Commands │ ← Shared with desktop
│ (main.rs) │
│ │ │
│ ┌───────────────┼────────────┐ │
│ │ │ │ │
│ SignalMgr CallEngine Identity │ ← Shared crates
│ (signal_hub) (wzp-client) (wzp-crypto)│
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ QUIC to relay Oboe audio (Android) │
│ via wzp-native cdylib │
└─────────────────────────────────────────────────┘
```
**What is reused from desktop verbatim** (zero rewrite):
- `desktop/src/main.ts` — entire frontend
- `desktop/src/style.css` — all styling
- `desktop/src/identicon.ts` — identicon rendering
- `desktop/index.html` — HTML structure
- `desktop/src-tauri/src/main.rs` — all Tauri commands (`connect`, `disconnect`, `register_signal`, `place_call`, …)
- `desktop/src-tauri/src/engine.rs``CallEngine` wrapper
**What is Android-specific**:
- `desktop/src-tauri/src/android_audio.rs` — JVM-side audio routing (`AudioManager.setSpeakerphoneOn` for earpiece/speaker toggle). Runs from Tauri's existing JNI context — no hand-rolled bridge, Tauri owns the JVM hookup.
- `desktop/src-tauri/src/wzp_native.rs` — runtime `dlopen` of `libwzp_native.so`, a standalone cdylib crate (`crates/wzp-native`) that owns all C++ (Oboe bridge). Kept in its own crate so its C/C++ static archives never get statically linked into `wzp-desktop`'s `.so`, which would re-trigger the `__init_tcb` / pthread leak.
- `crates/wzp-native/` — the standalone C++/Oboe bridge cdylib. Loaded via `libloading` at runtime from `wzp_native.rs`. Provides capture + playout streams using Oboe's `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` combo.
- Android-specific target dependencies in `desktop/src-tauri/Cargo.toml` (`jni`, `ndk-context`, `libloading`) — no CPAL, no VPIO.
## Key architectural decisions
### 1. `wzp-native` as a standalone cdylib loaded via `libloading`
The alternative — linking `wzp-native` as a regular Rust dep with C++ static archives — would cause the same `__init_tcb` crash that killed the Kotlin version. By making `wzp-native` its own cdylib and `dlopen`-ing it at runtime, Bionic's `libc.so` resolves every symbol at load time the way it's supposed to, and no private TCB symbols leak.
### 2. `crate-type = ["cdylib", "rlib"]` only (no `staticlib`)
Same reason. The `rlib` output is needed so the `wzp-desktop` binary target can link against the library; `cdylib` is needed for Android's `System.loadLibrary`; `staticlib` would reintroduce the symbol-leak bug.
### 3. Oboe audio config
`Usage::VoiceCommunication` + Java-side `MODE_IN_COMMUNICATION`. **Never** call `setAudioApi(AAudio)` explicitly — on some devices (Nothing Phone in particular) it causes Oboe to open the wrong stream type and audio goes silent. Let Oboe pick the audio API automatically. This is documented in the auto-memory `project_tauri_android_audio.md`.
### 4. Speaker/earpiece toggle uses `tokio::task::spawn_blocking`
Oboe's `stop()` + `start()` cycle is synchronous and can block for 50200 ms. Calling it on the tokio executor stalls every other async task (including the QUIC datagram loop), dropping audio packets. Wrapping the toggle in `spawn_blocking` isolates it to a dedicated thread pool. Fixed in commit `76a4c53`.
## Build pipeline
Docker on SepehrHomeserverdk, same pattern as the Android legacy pipeline and the Windows pipeline:
```
./scripts/build-tauri-android.sh # Full: pull + build + ntfy + rustypaste
./scripts/build-tauri-android.sh --pull # Explicit git pull (default)
./scripts/build-tauri-android.sh --clean # Blow away the Rust target cache
```
**Image**: `wzp-android-builder` (shared with the legacy Kotlin pipeline). The Dockerfile was extended to install Node.js 20 LTS, Android API level 36, build-tools 35.0.0, tauri-cli 2.x, and all four Android Rust targets on top of the legacy NDK 26.1 + cargo-ndk + Gradle setup. Both pipelines coexist in the same image.
**Output**: `wzp-release.apk` uploaded to rustypaste, URL delivered via `ntfy.sh/wzp`.
## Known quirks (Tauri Mobile specific)
1. **tauri-cli `android init` writes absolute paths** into `gradle.properties` for the NDK path. Those paths are local to wherever `android init` was run, so they break any cross-machine build unless overridden with `ANDROID_NDK_HOME` at build time. The build script exports `ANDROID_NDK_HOME` explicitly to work around this.
2. **API 36 vs API 34 coexistence**: the legacy Kotlin pipeline targets API 34, Tauri Mobile 2.x wants compileSdk 36. The shared Docker image installs both SDK levels so neither pipeline needs to reinstall.
3. **Identity seed lives in Android-specific app data dir**: `/data/data/com.wzp.phone/files/.wzp/identity` instead of `$HOME/.wzp/identity`. The shared `load_or_create_seed()` function in `desktop/src-tauri/src/lib.rs` uses Tauri's `app_data_dir()` which resolves correctly on both Android and desktop — no per-platform code needed.
4. **Direct calls on macOS previously hit an identity mismatch bug** — the `CallEngine` was using `$HOME/.wzp/identity` directly while `register_signal` used Tauri's `app_data_dir()`. Fixed by routing both through `load_or_create_seed()` (commit `2fd9465`). This was important for cross-platform consistency.
## Current state (snapshot)
What works:
- Tauri Mobile scaffold builds and runs on Android
- Signal hub connect + register works
- Room mode (SFU group calls) works with Oboe audio
- Direct 1:1 calls work with full parity to desktop
- Speaker/earpiece toggle works without stalling the audio pipeline
- Call history, recent contacts, deregister UI all present (inherited from desktop)
What remains (task list refs in parens):
- Background service for keeping signal alive when app is backgrounded (#19)
- Proper permission requests (microphone, notifications) on first launch (#19)
- Incoming call notification while backgrounded (#19)
- App icon + splash screen (#19)
## Testing
- **Build**: `./scripts/build-tauri-android.sh` — verify the APK lands on rustypaste and installs on device.
- **Smoke test**: Install → open app → Register → Place call → Receive call. No crashes, audio flows both ways.
- **Speaker toggle**: During a call, toggle speaker/earpiece several times in rapid succession. Audio should never stop, and the toggle should respond within ~200 ms.
- **Stress test**: Call for 10+ minutes continuous. No memory growth, no packet loss beyond what's attributable to the network.
## Files of interest
| Path | Purpose |
|---|---|
| `desktop/src-tauri/src/lib.rs` | Shared Tauri commands (desktop + Android) |
| `desktop/src-tauri/src/android_audio.rs` | JVM-side speaker/earpiece routing |
| `desktop/src-tauri/src/wzp_native.rs` | Runtime dlopen of libwzp_native.so |
| `crates/wzp-native/` | Standalone C++/Oboe cdylib, loaded at runtime |
| `scripts/build-tauri-android.sh` | Remote Docker build pipeline |
| `scripts/Dockerfile.android-builder` | Shared Android Docker image (legacy + Tauri) |
| `docs/incident-tauri-android-init-tcb.md` | Postmortem of the Kotlin+JNI crash cascade |

View File

@@ -0,0 +1,164 @@
# Branch: `feat/desktop-audio-rewrite`
Home of the Tauri desktop client for macOS, Windows, and Linux. Named "audio-rewrite" because the original driver was replacing a CPAL-only audio pipeline with platform-native backends that support OS-level echo cancellation (VoiceProcessingIO on macOS, WASAPI Communications on Windows), but the branch has grown into the full desktop story — Windows cross-compilation, vendored dependencies, history UI, direct calling, the whole thing.
## Purpose
The desktop client shares 100% of its frontend (`desktop/src/`) and Tauri command layer (`desktop/src-tauri/src/lib.rs`, `engine.rs`, `history.rs`) with the Android build on `android-rewrite`. Differences are limited to:
- **Audio backends**, which are platform-gated via Cargo target-dep sections in `desktop/src-tauri/Cargo.toml` and feature flags in `crates/wzp-client/Cargo.toml`.
- **Identity storage paths**, which resolve via Tauri's `app_data_dir()` (`~/Library/Application Support/…` on macOS, `%APPDATA%\…` on Windows, `~/.local/share/…` on Linux).
- **Build toolchains**: native `cargo build` on macOS/Linux, `cargo xwin` cross-compile from Linux for Windows via Docker on SepehrHomeserverdk.
## Audio backend matrix
| Target | Capture | Playback | AEC |
|---|---|---|---|
| macOS | CPAL (WASAPI/CoreAudio via cpal crate) OR VoiceProcessingIO (native Core Audio) | CPAL | VoiceProcessingIO native AEC (when `vpio` feature enabled) |
| Windows (default) | CPAL → WASAPI shared mode | CPAL → WASAPI shared mode | None |
| Windows (AEC build) | Direct WASAPI with `IAudioClient2::SetClientProperties(AudioCategory_Communications)` | CPAL → WASAPI shared mode | **OS-level**: Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC) |
| Linux | CPAL → ALSA/PulseAudio | CPAL → ALSA/PulseAudio | None |
The macOS VPIO path is gated behind the `vpio` feature in `wzp-client` and the `coreaudio-rs` dep is itself `cfg(target_os = "macos")`, so enabling the feature on Windows or Linux is a no-op.
The Windows AEC path is gated behind the `windows-aec` feature, also target-gated (the `windows` crate dep is only pulled in on Windows), and re-exports `WasapiAudioCapture as AudioCapture` when enabled so downstream code doesn't need to know which backend is active. The current Windows build at `target/windows-exe/wzp-desktop.exe` has `windows-aec` on; a baseline noAEC build is preserved at `target/windows-exe/wzp-desktop-noAEC.exe` for A/B comparison on real hardware.
See [`BRANCH-android-rewrite.md`](BRANCH-android-rewrite.md) for Oboe audio on Android, which is its own story.
## Recent major work
### 1. Desktop direct calling feature (commit `2fd9465` and neighbors)
Brought direct 1:1 calls to macOS with full parity to the Android client:
- **Identity path fix**: the desktop `CallEngine::start` was loading seed from `$HOME/.wzp/identity` while `register_signal` used Tauri's `app_data_dir()`, producing two different fingerprints per run. Both now route through `load_or_create_seed()` which uses `app_data_dir()` everywhere.
- **Call history with dedup**: `history.rs` stores a `Vec<CallHistoryEntry>` with a `CallDirection` enum (`Placed | Received | Missed`). The `log` function dedupes by `call_id` so an outgoing call isn't logged twice as "missed" (when the signal loop's `DirectCallOffer` handler fires) and then again as "placed" (when `place_call` returns). Instead the entry is updated in place.
- **Recent contacts row**: a horizontal chip UI in the direct-call panel showing the last N peers with friendly aliases, clickable to re-dial.
- **Deregister button**: lets a user drop their signal registration without quitting the app, useful when switching identities.
- **Random alias derivation**: a new client sees a human-friendly alias like "silent-forest-41" derived deterministically from its seed, so it's identifiable in the UI before manual naming.
- **Default room "general"** instead of "android", since the desktop client is not Android.
### 2. macOS VoiceProcessingIO integration
`crates/wzp-client/src/audio_vpio.rs` — a native Core Audio implementation using `AUGraph` + `AudioComponentInstance` with the VPIO audio unit. Gives you hardware-accelerated AEC (same AEC Apple ships in FaceTime / iMessage audio / voice memos) at the cost of tight coupling to Apple frameworks. Lock-free ring pattern matches the CPAL path so the upper layers don't notice the difference.
Enabled by `features = ["audio", "vpio"]` in the macOS target section of `desktop/src-tauri/Cargo.toml`.
### 3. Windows cross-compilation via cargo-xwin
Cross-compiling Rust + Tauri to `x86_64-pc-windows-msvc` from Linux using `cargo-xwin`, which downloads the Microsoft CRT + Windows SDK on demand and drives `clang-cl` as the compiler. No Windows machine is needed for the build itself — only for runtime testing.
**Build infrastructure**:
- `scripts/Dockerfile.windows-builder` — Debian bookworm + Rust + cargo-xwin + Node 20 + cmake + ninja + llvm + clang + lld + nasm. Pre-warms the xwin MSVC CRT cache at image build time (saves ~4 minutes per cold build).
- `scripts/build-windows-docker.sh` — fire-and-forget remote build via Docker on SepehrHomeserverdk. Same pattern as `build-tauri-android.sh`. Uploads the `.exe` to rustypaste and fires an `ntfy.sh/wzp` notification on start and on completion.
- `scripts/build-windows-cloud.sh` — alternative pipeline using a temporary Hetzner Cloud VPS. Slower (full VM spin-up), more expensive, but useful when Docker image rebuilds would be disruptive.
**Two critical blockers resolved** on the way to a working `.exe`:
1. **libopus SSE4.1 / SSSE3 intrinsic compile failure**. `audiopus_sys` vendors libopus 1.3.1, whose `CMakeLists.txt` gates the per-file `-msse4.1` `COMPILE_FLAGS` behind `if(NOT MSVC)`. Under `clang-cl`, CMake sets `MSVC=1` (because `CMAKE_C_COMPILER_FRONTEND_VARIANT=MSVC` triggers `Platform/Windows-MSVC.cmake` which unconditionally sets the variable), so the per-file flag is never set and the SSE4.1 source files compile without the target feature — then fail with 20+ "always_inline function '_mm_cvtepi16_epi32' requires target feature 'sse4.1'" errors.
Fixed by **vendoring audiopus_sys into `vendor/audiopus_sys/`** and patching its bundled libopus to introduce an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`). The eight `if(NOT MSVC)` SIMD guards are flipped to `if(NOT MSVC_CL)` and the global `/arch` block at line 445 becomes `if(MSVC_CL)`, so clang-cl gets the GCC-style per-file flags while real cl.exe keeps the `/arch:AVX` / `/arch:SSE2` globals.
Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root.
Upstream tracking: [xiph/opus#256](https://github.com/xiph/opus/issues/256), [xiph/opus PR #257](https://github.com/xiph/opus/pull/257) (both stale).
2. **tauri-build needs `icons/icon.ico` for the Windows PE resource**. The desktop only had `icon.png`. Generated a multi-size ICO (16/24/32/48/64/128/256) from the existing placeholder via Pillow and committed it. Placeholder quality — real branded icons can replace it later.
### 4. Windows `AudioCategory_Communications` capture path (task #24)
`crates/wzp-client/src/audio_wasapi.rs` — direct WASAPI capture via `IMMDeviceEnumerator → IAudioClient2 → SetClientProperties` with `AudioCategory_Communications`. This tells Windows "this is a VoIP call" and Windows routes the capture stream through the driver's registered communications APO chain, which on most Win10/11 consumer hardware includes AEC, NS, and AGC.
**Caveat**: quality is driver-dependent. On a machine with a good communications APO (Intel Smart Sound, Dolby, modern Realtek on Win11 24H2+, anything with Voice Clarity enabled) it's excellent. On generic class-compliant drivers with no communications APO registered, it's a no-op. For a guaranteed AEC regardless of driver, see task #26 which tracks implementing the classic Voice Capture DSP (`CLSID_CWMAudioAEC`) as a fallback.
Gated behind the `windows-aec` feature in `wzp-client`. Enabled by default in the Windows target section of `desktop/src-tauri/Cargo.toml`.
## Build pipelines
### Native macOS / Linux
```bash
cd desktop
npm install
npm run build
cd src-tauri
cargo build --release --bin wzp-desktop
```
### Windows x86_64 via Docker on SepehrHomeserverdk
```bash
./scripts/build-windows-docker.sh # Full: pull + build + download
./scripts/build-windows-docker.sh --no-pull # Skip git fetch
./scripts/build-windows-docker.sh --rust # Force-clean Rust target
./scripts/build-windows-docker.sh --image-build # (Re)build the Docker image (fire-and-forget)
```
Output lands at `target/windows-exe/wzp-desktop.exe`. Both `wzp-desktop.exe` and `wzp-desktop-noAEC.exe` can coexist in that directory; the script writes `wzp-desktop.exe` so renaming the prior build to `-noAEC.exe` (or any other name) before rebuilding preserves it.
### Windows x86_64 via Hetzner Cloud (alternative)
```bash
./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
./scripts/build-windows-cloud.sh --prepare # Create VM and install deps only
./scripts/build-windows-cloud.sh --build # Build on existing VM
./scripts/build-windows-cloud.sh --destroy # Delete the VM
WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Keep VM alive after build for debug
```
Remember to destroy the VM at end of day with `--destroy`.
### Linux x86_64 (relay + CLI + bench)
```bash
./scripts/build-linux-docker.sh # Fire-and-forget remote Docker build
./scripts/build-linux-docker.sh --install # Wait for completion and download
```
Uses the same `wzp-android-builder` Docker image as Android (not a separate image), since the deps (Rust + cmake + ring prereqs) are the same.
## Testing
### Direct calling parity
1. Build on two machines (macOS + Windows, or two macOS, or any combination).
2. Both machines register on the same relay.
3. Copy one machine's fingerprint into the other's direct-call panel.
4. Place the call. Confirm ringing UI on the callee and "calling…" UI on the caller.
5. Answer. Confirm audio flows both ways.
6. Hang up from either side. Confirm call-history entries are labeled correctly (`Outgoing` on caller, `Incoming` on callee, never `Missed` on a successful call).
### Windows AEC A/B
1. Install `wzp-desktop-noAEC.exe` and `wzp-desktop.exe` on the same Windows box.
2. Join a call from each (separately) while a second machine plays known audio through the first machine's speakers.
3. On the remote (listening) side: the `noAEC` call should have clear audible echo; the AEC call should have minimal or no echo after a 12 s convergence period.
4. If both builds sound identical (with echo) → the `AudioCategory_Communications` switch isn't triggering the driver's APO chain. Investigate via task #26 (Voice Capture DSP fallback).
## Known quirks
1. **libopus vendor path is workspace-relative**. `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` works from any crate in the workspace because Cargo resolves it against the root `Cargo.toml`'s directory. If the workspace is moved or vendored into another workspace, update the path.
2. **`cargo xwin` overwrites `override.cmake` on every invocation**. Any attempt to patch `~/.cache/cargo-xwin/cmake/clang-cl/override.cmake` at Docker image build time is inert because `src/compiler/clang_cl.rs` line ~444 writes the bundled file fresh on every run. All real fixes must land in the source tree (via the vendored audiopus_sys, as done here), not in the cargo-xwin cache.
3. **WebView2 runtime is a prerequisite on Windows 10**. Windows 11 ships with it. If the `.exe` launches and immediately exits with no error on a Win10 machine, that's the missing runtime — install it from [Microsoft's Evergreen bootstrapper](https://developer.microsoft.com/en-us/microsoft-edge/webview2/).
4. **Rust 2024 edition `unsafe_op_in_unsafe_fn` lint**. The WASAPI backend in `audio_wasapi.rs` emits ~18 of these warnings because Rust 2024 requires explicit `unsafe { ... }` blocks inside `unsafe fn` bodies. The warnings don't block the build and don't affect runtime behavior; cleaning them up is tracked informally as tech debt.
## Files of interest
| Path | Purpose |
|---|---|
| `desktop/src/` | Shared frontend (TypeScript + HTML + CSS) |
| `desktop/src-tauri/src/lib.rs` | Tauri commands shared with Android |
| `desktop/src-tauri/src/engine.rs` | `CallEngine` wrapper |
| `desktop/src-tauri/src/history.rs` | Persistent call history store with dedup |
| `crates/wzp-client/src/audio_io.rs` | CPAL capture + playback (baseline) |
| `crates/wzp-client/src/audio_vpio.rs` | macOS VoiceProcessingIO capture (AEC) |
| `crates/wzp-client/src/audio_wasapi.rs` | Windows WASAPI communications capture (AEC) |
| `vendor/audiopus_sys/opus/CMakeLists.txt` | Patched libopus for clang-cl SIMD |
| `scripts/Dockerfile.windows-builder` | Windows cross-compile Docker image |
| `scripts/build-windows-docker.sh` | Remote Docker build pipeline |
| `scripts/build-windows-cloud.sh` | Hetzner VPS alternative pipeline |
| `scripts/build-linux-docker.sh` | Linux x86_64 relay/CLI build pipeline |

View File

@@ -1,22 +0,0 @@
# PRD: Desktop Direct Calling — Backport SignalManager
## Problem
The desktop Tauri app has the direct calling UI (Room/Direct Call toggle, Register, Call buttons) but the backend uses inline async code in `main.rs` instead of a proper `SignalManager`. This needs to be backported from the Android refactor.
## Tasks
1. **Create `signal_mgr.rs` for desktop** — same pattern as Android, or reuse the crate directly
2. **Wire into Tauri commands**`register_signal` should use `SignalManager::connect()` + `run_recv_loop()` on a dedicated thread
3. **State polling**`get_signal_status` should call `SignalManager::get_state_json()`
4. **place_call / answer_call** — delegate to SignalManager methods
5. **Merge android branch into desktop branch** — resolve the 37 desktop-only + 90 android-only commit divergence
6. **Test** — Android calls Desktop, Desktop calls Android
## UI Fixes
1. **Default alias** — generate random name on first start (like Android does)
2. **Default room** — change from "android" to "general"
3. **Fingerprint display** — ensure full `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx` format (not truncated)
4. **Deregister button** — ability to disconnect signal channel
5. **Call state reset** — after hangup, return to "Registered" state, not stuck on "Ringing"

141
docs/PRD-local-recording.md Normal file
View File

@@ -0,0 +1,141 @@
# PRD: Local Recording + Cloud Mixer for Podcast-Quality Interviews
## Problem
WarzonePhone delivers real-time encrypted voice, but the audio quality is limited by network conditions (codec compression, packet loss, jitter). Podcasters and interviewers need pristine, studio-grade recordings of each participant — independent of what the network delivers.
## Solution
**Dual-path architecture**: each client simultaneously (1) participates in the live call at whatever codec quality the network supports, and (2) records their own microphone locally as lossless PCM. After the session, all local recordings are uploaded to a self-hosted mixer service that aligns, normalizes, and outputs a final multi-track or mixed file.
## Architecture
```
┌──────────────────┐
Mic ──┬── Opus/Codec2 ──► Network (live) │ ← real-time call
│ └──────────────────┘
└── WAV 48kHz ────► Local File │ ← pristine recording
(timestamped)
▼ (after hangup)
┌──────────────────┐
│ Mixer Service │ ← self-hosted
│ (align + mix) │
└──────────────────┘
Final MP3/WAV/FLAC
```
## Requirements
### Phase 1: Local Recording (MVP)
**All clients (Desktop, Android, Web):**
1. **Record toggle**: User can enable "Record this call" before or during a call
2. **Recording pipeline**: Tap raw PCM from the microphone capture path *before* it enters the codec encoder
3. **File format**: WAV (48kHz, 16-bit, mono) — simple, universally supported, lossless
4. **Sync markers**: Embed a monotonic timestamp (ms since call start) at the beginning of the recording, and periodically (every 10s) write a sync marker packet into a sidecar JSON file:
```json
{"ts_ms": 30000, "seq": 1500, "wall_clock_utc": "2026-04-07T12:00:30Z"}
```
This allows the mixer to align recordings from different participants even if they join at different times.
5. **Storage**:
- Desktop: `~/.wzp/recordings/{room}_{timestamp}.wav`
- Android: `Documents/WarzonePhone/{room}_{timestamp}.wav`
- Web: IndexedDB blob or File System Access API
6. **File size estimate**: 48kHz * 16-bit * mono = 96 KB/s = ~5.6 MB/min = ~345 MB/hour
7. **UI indicator**: Red dot + timer showing recording is active and file size growing
8. **On hangup**: Close the WAV file, show "Recording saved" with file path/size
### Phase 2: Upload to Mixer
1. **Upload endpoint**: Self-hosted HTTP service (Rust or Go) that accepts WAV uploads with metadata
2. **Chunked/resumable upload**: Large files need resumable uploads (tus protocol or simple chunked POST)
3. **Upload metadata**:
```json
{
"session_id": "uuid",
"participant_fingerprint": "xxxx:xxxx:...",
"alias": "Alice",
"room": "podcast-ep-42",
"duration_secs": 3600,
"sync_markers": [...],
"sample_rate": 48000,
"channels": 1,
"bit_depth": 16
}
```
4. **Upload UI**: Progress bar after hangup, option to upload now or later
5. **Retry on failure**: Queue uploads for retry if network is unavailable
### Phase 3: Mixer Service
1. **Alignment**: Use sync markers (wall clock + sequence numbers) to align recordings from all participants to a common timeline
2. **Silence trimming**: Detect and optionally trim leading/trailing silence
3. **Normalization**: Per-track loudness normalization (LUFS-based)
4. **Noise reduction**: Optional per-track noise gate or RNNoise pass
5. **Output formats**:
- Multi-track: ZIP of individual WAVs (aligned, normalized)
- Mixed: Single stereo or mono WAV/MP3/FLAC with all participants
- Podcast-ready: Loudness-normalized to -16 LUFS (podcast standard)
6. **Web UI**: Simple dashboard to see sessions, download outputs, preview waveforms
7. **Self-hosted**: Docker image, single binary, SQLite for metadata
## Implementation Notes
### Recording tap point
The recording must tap *after* AGC (so levels are normalized) but *before* the codec encoder (to avoid compression artifacts). In the current architecture:
```
Mic → Ring Buffer → AGC → [TAP HERE for recording] → Opus/Codec2 → Network
```
**Desktop** (`engine.rs`): After `capture_agc.process_frame()`, before `encoder.encode()`
**Android** (`engine.rs`): Same location — after AGC, before encode
**CLI** (`call.rs`): After `self.agc.process_frame()` in `CallEncoder::encode_frame()`
### WAV writer
Use a simple streaming WAV writer that:
- Writes the WAV header with placeholder data length
- Appends PCM samples as they come
- On close, seeks back to update the data length in the header
### Sync mechanism
Wall-clock UTC alone is insufficient (clocks drift). The sync strategy:
1. Each participant records their local monotonic time + wall clock at call start
2. Periodically (every 10s), each participant writes: `{local_mono_ms, seq_number, utc_iso}`
3. The mixer uses sequence numbers (which are shared via the wire protocol) as ground truth for alignment, with wall clock as a fallback
### Privacy
- Local recordings never leave the device without explicit user action
- Upload is manual, not automatic
- The mixer service processes files and can delete originals after mixing
- No recording data flows through the relay — only the user's own mic
## Non-Goals (v1)
- Live transcription (future)
- Video recording (audio only)
- Automatic upload without user consent
- Recording other participants' audio (only your own mic)
- Real-time mixing (post-session only)
## Milestones
| Phase | Scope | Effort |
|-------|-------|--------|
| 1a | Local WAV recording on Desktop | 1-2 days |
| 1b | Local WAV recording on Android | 1-2 days |
| 1c | Sync markers + metadata sidecar | 1 day |
| 2a | Upload service (HTTP + storage) | 2-3 days |
| 2b | Upload UI in clients | 1-2 days |
| 3a | Mixer: alignment + normalization | 2-3 days |
| 3b | Mixer: web dashboard | 2-3 days |
| 3c | Docker packaging | 1 day |

View File

@@ -0,0 +1,56 @@
# PRD: Studio Quality Tiers (Opus 32k/48k/64k)
## Status: Implemented
Studio quality tiers have been added to the wire protocol and all clients.
## What Was Added
### Wire Protocol (codec_id.rs)
Three new `CodecId` variants using the 4-bit header space (values 6-8):
| CodecId | Wire Value | Bitrate | Frame | Use Case |
|---------|-----------|---------|-------|----------|
| Opus32k | 6 | 32 kbps | 20ms | Studio low — noticeable improvement over 24k for voice |
| Opus48k | 7 | 48 kbps | 20ms | Studio — excellent voice, captures nuance |
| Opus64k | 8 | 64 kbps | 20ms | Studio high — near-transparent quality |
### Quality Profiles
| Profile | Codec | FEC | Bandwidth (with FEC) |
|---------|-------|-----|---------------------|
| STUDIO_32K | Opus 32k | 10% | ~35 kbps |
| STUDIO_48K | Opus 48k | 10% | ~53 kbps |
| STUDIO_64K | Opus 64k | 10% | ~70 kbps |
FEC is set to 10% (vs 20% for GOOD) — studio assumes a good network.
### Client Support
| Client | Selection | Status |
|--------|-----------|--------|
| Desktop (Tauri) | Quality slider in Settings (8 levels) | Done |
| CLI | `--profile studio-64k` / `studio-48k` / `studio-32k` | Done |
| Android | Needs codec picker update in SettingsScreen.kt | TODO |
| Web | Needs UI | TODO |
### Cross-Codec Interop
All decoder auto-switch paths (call.rs, desktop engine.rs) handle the new codec IDs. A studio-64k client can talk to a codec2-1200 client — the receiver auto-switches.
## When to Use Studio Tiers
- **Podcast recording sessions**: Use studio-64k for best quality (combined with local WAV recording for pristine output)
- **Music collaboration**: Opus at 48-64k captures instrument harmonics much better than 24k
- **Good network conditions**: Only useful when bandwidth isn't constrained; the extra bits are wasted on lossy networks
## When NOT to Use
- **Mobile data**: Stick with Auto/GOOD — studio tiers use 2-3x the bandwidth
- **High packet loss**: Studio profiles use minimal FEC (10%); degraded networks need DEGRADED or CATASTROPHIC profiles with 50-100% FEC
- **Large group calls**: Each participant's stream multiplies bandwidth; 64k * 10 participants = 640 kbps incoming
## Backward Compatibility
Old clients (before this change) will receive packets with CodecId 6/7/8 which they don't recognize. The `from_wire()` returns `None` for unknown values, causing the packet to be dropped. Old clients can still *send* to new clients fine (they use CodecId 0-5). This is acceptable for a pre-release protocol.

View File

@@ -1,431 +0,0 @@
# Incident report — Tauri Android `__init_tcb+4` SIGSEGV
**Status:** Blocked. Reproducible crash with a known trigger at the cc::Build /
rustc-link-lib layer that we cannot yet explain. Writing this report to hand
off for external help.
**Project:** WarzonePhone (Rust + Tauri 2.x Mobile) Android rewrite
**Branch:** `feat/desktop-audio-rewrite`
**Target phone:** Pixel 6 (`oriole`), Android 16 (`BP3A.250905.014`), arm64-v8a
**Date range of investigation:** 2026-04-09 (one working session, ~27 builds)
---
## One-paragraph summary
We're porting the existing CPAL-backed desktop Tauri app (`desktop/src-tauri`)
to Tauri Mobile Android so the same Rust + Tauri + WebView codebase runs on
both platforms. The Android `.apk` launches, renders the home screen, and
registers on a relay for signal-only builds (no audio backend). The moment
we add **any** `cc::Build::new().cpp(true).cpp_link_stdlib("c++_shared")`
call to `build.rs` — even with a 6-line cpp file that just returns 42 and is
never called from Rust — the built `.so` crashes at launch inside
`__init_tcb(bionic_tcb*, pthread_internal_t*)+4` via `pthread_create` via
`std::thread::spawn` via `tao::ndk_glue::create` via
`Java_com_wzp_desktop_WryActivity_create`, before our Rust entry point has
a chance to run. The exact same NDK, exact same Rust toolchain, exact same
Docker image is used by the legacy `wzp-android` crate (via `cargo-ndk`)
which compiles Oboe and runs fine on the same phone.
---
## Environment
**Docker build image:** `wzp-android-builder` (Dockerfile at
`scripts/Dockerfile.android-builder`)
- Base: `debian:bookworm`
- JDK 17
- Android SDK:
- cmdline-tools latest
- `platforms;android-34`, `platforms;android-36`
- `build-tools;34.0.0`, `build-tools;35.0.0`
- `ndk;26.1.10909125` (last stable before scudo/MTE crash on NDK r27+)
- `platform-tools`
- Node.js 20 LTS
- Rust stable `1.94.1 (e408947bf 2026-03-25)`
- Rust android targets: `aarch64-linux-android`, `armv7-linux-androideabi`,
`i686-linux-android`, `x86_64-linux-android`
- `cargo-ndk` + `cargo tauri-cli 2.10.1` (latest 2.x)
**Host:** Docker on `SepehrHomeserverdk` (remote build server).
**Phone:** Pixel 6, Android 16, kernel 6.1.134-android14-11, on the same LAN
as the build machine and a local `wzp-relay` binary.
**Tauri crate:** `desktop/src-tauri/` in the workspace at the root of the
repo. Depends on `tauri = "2"`, `tauri-plugin-shell = "2"`, `tokio`, `rustls`,
`wzp-proto`, `wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`, and (on
non-Android only) `wzp-client` with `features = ["audio", "vpio"]`. The
crate's `[lib]` section is:
```toml
[lib]
name = "wzp_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
```
The crate produces `libwzp_desktop_lib.so` which is `System.loadLibrary`'d by
Tauri's generated `WryActivity.onCreate` via JNI.
---
## The crash
Every failing build produces the same stack at launch, same pc offsets:
```
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00000072XXXXXX00f (write)
#00 pc 000000000130cc74 libwzp_desktop_lib.so (__init_tcb(bionic_tcb*, pthread_internal_t*)+4)
#01 pc 0000000001331cf0 libwzp_desktop_lib.so (pthread_create+360)
#02 pc 00000000012bee04 libwzp_desktop_lib.so (std::sys::thread::unix::Thread::new::h87be8e9feeaaaf84+184)
#03 pc 0000000000e37f5c libwzp_desktop_lib.so (std::thread::lifecycle::spawn_unchecked::h941f828f9a95150d+1504)
#04 pc 0000000000e461e8 libwzp_desktop_lib.so (std::thread::builder::Builder::spawn_unchecked::hec5f087680cb0248+112)
#05 pc 0000000000e441c8 libwzp_desktop_lib.so (std::thread::functions::spawn::ha3d3fbf2d9fe53e3+108)
#06 pc ... libwzp_desktop_lib.so (tao::platform_impl::platform::ndk_glue::create::h254c68662718841a+1792)
#07 pc ... libwzp_desktop_lib.so (Java_com_wzp_desktop_WryActivity_create+76)
```
The offsets are **byte-identical across every failing build**, even when the
cpp content changes drastically (cf. `cpp_smoke.cpp` at 6 lines, 20 lines,
200+ Oboe source files). We believe this is because cargo caches the Rust
compilation unit and only the build-script artifacts differ, and the final
link produces the same layout.
`__init_tcb` is defined locally inside our `.so` with C++ mangling:
```
_Z10__init_tcbP10bionic_tcbP18pthread_internal_t
```
It originates from bionic's `pthread_create.cpp`, which got pulled in
statically from the NDK's `sysroot/usr/lib/aarch64-linux-android/libc.a`.
Both failing and known-good (legacy `wzp_android.so`) builds contain this
same static symbol — the presence of the symbol is not the problem.
Fault address `0x72XXXXXX00f` with code `SEGV_ACCERR` (access permission
error, write). Aligned to `+4` inside `__init_tcb`, which is typically a
store into the passed-in `bionic_tcb*`. The pointer is either NULL-ish or
pointing into read-only memory.
---
## Bisection (the important part)
We started from a known-good commit (`5309938`) where the Tauri Android app
launches, registers on a relay, and behaves identically to the desktop app
modulo audio. Then we added features **one variable at a time**:
| Step | Commit | Change vs previous | Result |
|---|---|---|---|
| Baseline | `5309938` | — | ✅ launches, renders home, registers on relay |
| **A** | `f96d7ce` | Add `cc = "1"` build-dep + compile trivial `cpp/hello.c` via `cc::Build` (C, not C++). Static lib never linked in. | ✅ |
| **B** | `ae4f366` | Add `wzp-client` Android dep with `default-features = false` (no CPAL, no VPIO). No new imports. | ✅ |
| **C** | `19fd3dd` | Un-cfg-gate `mod engine;` in `lib.rs` so `engine.rs` compiles on Android. `CallEngine::start()` has an Android stub returning an error. | ✅ |
| **D** | `a852cad` | Compile `cpp/getauxval_fix.c` (legacy wzp-android shim). Still pure C. | ✅ |
| **E** | `4250f1b` | **Compile full Oboe C++ bridge** (200+ source files from `google/oboe@1.8.1`). `cc::Build::new().cpp(true).std("c++17").cpp_link_stdlib(Some("c++_shared"))` + `-llog` + `-lOpenSLES` link directives. Nothing called from Rust yet — the `extern "C"` bridge functions are exported but never referenced from the Rust side. | ❌ **crash** |
| E.4 | `aa240c6` | **Only change:** replace the entire Oboe compile with ONE tiny `cpp_smoke.cpp` file: `extern "C" int wzp_cpp_smoke(void) { std::lock_guard<std::mutex> lk(m); std::thread t([](){...}); t.join(); return g.load(); }`. Still `cpp(true) + cpp_link_stdlib("c++_shared")`. Drop `-llog`/`-lOpenSLES`. | ❌ **same crash, same offsets** |
| E.2 | `0224ce6` | Shrink `cpp_smoke.cpp` further: just `std::atomic<int>` + `fetch_add`, no mutex, no thread, no includes beyond `<atomic>`. | ❌ **same crash, same offsets** |
| E.1 | `0d74366` | **Absolute minimum:** `cpp_smoke.cpp` = `extern "C" int wzp_cpp_hello(void){return 42;}`. NO `#include`. NO STL. Just a function. Still compiled with `cpp(true) + cpp_link_stdlib("c++_shared")`. | ❌ **same crash, same offsets** |
### Additional confirming observations
1. **The cpp code is dead-stripped.** `llvm-nm -a libwzp_desktop_lib.so` shows
zero matches for `wzp_cpp_hello`, `wzp_cpp_smoke`, or any Oboe symbol in
builds E through E.1. The static archive (`libwzp_cpp_smoke.a` /
`liboboe_bridge.a`) exists on disk under
`target/aarch64-linux-android/debug/build/wzp-desktop-*/out/`, but because
nothing in Rust ever references the exported C function, the final linker
drops it.
2. **`build.rs` link directives are the real delta.** `cc::Build::new()
.cpp(true).cpp_link_stdlib(Some("c++_shared"))` emits a
`cargo:rustc-link-lib=c++_shared` directive that adds a `NEEDED` entry for
`libc++_shared.so` to the final `.so`'s dynamic table. `readelf -d` on
the crashing `.so` shows:
```
NEEDED Shared library: [libc++_shared.so]
NEEDED Shared library: [liblog.so] (only in full Oboe build)
NEEDED Shared library: [libOpenSLES.so] (only in full Oboe build)
```
The working baseline `.so` has no `NEEDED` entries beyond libc/liblog.
3. **Linker version doesn't matter.** We tried forcing
`aarch64-linux-android26-clang` as the linker (API 26 has proper dynamic
bindings to libc.so's runtime `pthread_create`/`__init_tcb`) via three
different mechanisms:
- `CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER` env var in `docker run`
- `.cargo/config.toml` workspace-level linker override
- **Binary replacement inside the image**: `mv
aarch64-linux-android24-clang .orig` and replace with a shell script
that `exec`s `aarch64-linux-android26-clang`. Verified by calling
`--version` which prints `Target: aarch64-unknown-linux-android26`.
All three made no difference. The `__init_tcb` symbol is pulled statically
from the **same** `libc.a` regardless of which clang wrapper is used — the
NDK ships ONE `libc.a` at
`sysroot/usr/lib/aarch64-linux-android/libc.a` shared across all API
levels. Only the per-API `libc.so` symlinks change (and we're linked
statically, not dynamically, against libc).
4. **Legacy `wzp-android` crate works on the same phone, same image.** Run
in the exact same Docker container, the legacy Kotlin app's JNI library
(`crates/wzp-android` built via `cargo ndk`) compiles a subset of the
same Oboe code, produces a `.so` that has the same static
`_Z10__init_tcbP...` + `pthread_create` + `pthread_create.cpp` symbols,
and launches cleanly on the Pixel 6. Key differences between the two
build paths:
| | `wzp-android` (works) | `wzp-desktop` Tauri (crashes) |
|---|---|---|
| Build driver | `cargo ndk -t arm64-v8a build --release -p wzp-android` | `cargo tauri android build --debug --target aarch64 --apk` |
| Profile | release | debug (release crashes identically) |
| Linker | `aarch64-linux-android26-clang` (via `.cargo/config.toml` which cargo-ndk honors) | `aarch64-linux-android24-clang` (tauri-cli hardcodes and ignores config; the shim redirect makes no difference) |
| crate-type | `["cdylib", "rlib"]` | `["staticlib", "cdylib", "rlib"]` |
| JNI entrypoint | direct Kotlin `System.loadLibrary` + our own `native fun` declarations; first `pthread_create` runs later from the tokio runtime inside a command | `WryActivity.onCreate` via Tauri's generated Java glue; first `pthread_create` runs **inside the JNI call** via `tao::ndk_glue::create` |
| Other heavy deps | tokio, wzp-{proto,codec,fec,crypto,transport} | tokio, tauri, tauri-runtime-wry, tao, wry, webview2-com, soup3, webkit2gtk (all platform-specific ones cfg-gated out of android), and also all of the above |
| Binary size | `libwzp_android.so` ≈ 14 MB (release) | `libwzp_desktop_lib.so` ≈ 160 MB (debug), 16 MB (release) |
5. **The crash happens in the JNI-callback thread during `onCreate`.** Frame
#06 `tao::platform_impl::platform::ndk_glue::create+1792` is tao's Android
event-loop bootstrap, which Tauri calls from inside
`Java_com_wzp_desktop_WryActivity_create` in response to the Java-side
activity lifecycle. This means the thread spawn is happening while the
Java VM still holds the native onCreate call, before `onCreate` has
returned to the Android runtime. Legacy `wzp-android` never spawns a
thread from an onCreate JNI call — it spawns threads only from
`nativeSignalConnect`/similar commands invoked later from Kotlin button
clicks, after the activity is fully initialised.
---
## Current suspect
One of the two items below, probably (2):
1. **The `.cpp(true)` mode in cc-rs changes something invisible in the link
pipeline** (for example, emitting a different `-x` flag to clang, or
changing linker driver selection). We have not yet verified this by
diffing the actual rustc linker invocation between a working and a
crashing build with `--verbose` + `-Clink-arg=-Wl,-t`.
2. **Adding `libc++_shared.so` as a NEEDED entry causes Android's dynamic
linker to load libc++_shared.so before our `.so`'s init runs, and
something in libc++_shared's `.init_array` interacts badly with
tao::ndk_glue's `pthread_create` call from inside the JNI onCreate
window**. The legacy crate doesn't hit this because (a) it has no
NEEDED libc++_shared when built without Oboe, and (b) even when it does
build Oboe, its thread spawns happen outside the onCreate JNI call so
whatever libc state is wrong at that moment is already stabilised.
We have not yet confirmed (2) with the obvious A/B test: keep `cpp_smoke.cpp`
but drop `.cpp_link_stdlib(Some("c++_shared"))` (and drop any manual
`cargo:rustc-link-lib=c++_shared`) so the NEEDED entry disappears but the
rest of the pipeline stays identical. That's the next experiment we were
going to run, but the user reasonably asked for this report first.
---
## What we've ruled out
- **NDK API level** — forcing API-26 linker via three independent mechanisms
made zero difference.
- **Build profile** — release (`0x6b8000` offset, 21 MB unsigned APK) and
debug (same 193 MB APK, same crash offsets) both crash identically.
- **Oboe specifically** — replacing the Oboe compile with 6 lines of C++
that does nothing still reproduces the crash.
- **cpp code being executed at runtime** — dead-stripped, not in the final
`.so` at all per `nm -a`.
- **minSdk in build.gradle** — bumped from 24 to 26, no effect.
- **libdl.a stub issue** — ruled out via logcat (`libdl.a is a stub --- use
libdl.so instead` was only surfacing from our own `dlsym` shim that we
subsequently deleted).
- **`pthread_create` interposition via `-Wl,--wrap=pthread_create`** — tried
and reverted; the wrap target still resolved to the broken static stub.
- **Keystore / signing** — debug signing with persistent `~/.android/
debug.keystore` works fine; no signature mismatch issues.
---
## The files involved
### `desktop/src-tauri/build.rs` (current state, E.1)
```rust
use std::path::PathBuf;
use std::process::Command;
fn main() {
// Embedded git hash
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=WZP_GIT_HASH={git_hash}");
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/refs/heads");
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("android") {
// Step A: plain C sanity file
println!("cargo:rerun-if-changed=cpp/hello.c");
cc::Build::new().file("cpp/hello.c").compile("wzp_hello");
// Step D: legacy getauxval shim
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
cc::Build::new().file("cpp/getauxval_fix.c").compile("getauxval_fix");
// Step E.1: minimal C++ smoke — THIS STEP BRINGS BACK THE CRASH
println!("cargo:rerun-if-changed=cpp/cpp_smoke.cpp");
cc::Build::new()
.cpp(true)
.std("c++17")
.cpp_link_stdlib(Some("c++_shared"))
.file("cpp/cpp_smoke.cpp")
.compile("wzp_cpp_smoke");
// Copy libc++_shared.so into gen/android jniLibs so the runtime
// linker can find it when the NEEDED entry fires.
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME").or_else(|_| std::env::var("NDK_HOME")) {
let triple = "aarch64-linux-android";
let abi = "arm64-v8a";
let lib_dir = format!(
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{triple}"
);
println!("cargo:rustc-link-search=native={lib_dir}");
let shared_so = format!("{lib_dir}/libc++_shared.so");
if std::path::Path::new(&shared_so).exists() {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let jni_dir = format!("{manifest}/gen/android/app/src/main/jniLibs/{abi}");
if std::fs::create_dir_all(&jni_dir).is_ok() {
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
}
}
}
}
tauri_build::build()
}
```
### `desktop/src-tauri/cpp/cpp_smoke.cpp` (E.1)
```cpp
extern "C" int wzp_cpp_hello(void) {
return 42;
}
```
### `desktop/src-tauri/Cargo.toml` (relevant excerpts)
```toml
[package]
name = "wzp-desktop"
version = "0.1.0"
edition = "2024"
[lib]
name = "wzp_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "wzp-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
cc = "1"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
wzp-proto = { path = "../../crates/wzp-proto" }
wzp-codec = { path = "../../crates/wzp-codec" }
wzp-fec = { path = "../../crates/wzp-fec" }
wzp-crypto = { path = "../../crates/wzp-crypto" }
wzp-transport = { path = "../../crates/wzp-transport" }
[target.'cfg(not(target_os = "android"))'.dependencies]
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
[target.'cfg(target_os = "android")'.dependencies]
wzp-client = { path = "../../crates/wzp-client", default-features = false }
```
---
## Reproduction
A fresh clone on a Linux x86_64 host with:
```bash
git clone ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git
cd wz-phone
git checkout feat/desktop-audio-rewrite
git reset --hard 0d74366 # <-- step E.1, smallest crashing commit
# Need: Android NDK r26.1.10909125, JDK 17, Node 20, Rust stable, cargo tauri 2.x
scripts/prep-linux-mint.sh # installs all the above into /opt/android-sdk etc.
cd desktop
npm install
cd src-tauri
cargo tauri android build --debug --target aarch64 --apk
adb install -r gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk
adb logcat -c && adb shell am start -n com.wzp.desktop/.MainActivity
adb logcat | grep -E "F DEBUG|__init_tcb|pthread_create"
```
Expected result: SIGSEGV at `__init_tcb+4` within ~500 ms of launch.
Reverting `cpp/cpp_smoke.cpp` + the `cc::Build` call for it in `build.rs`
(one git command: `git revert 0d74366 aa240c6 0224ce6 a852cad`) restores a
working build. Keeping the C sanity compile (`hello.c`, `getauxval_fix.c`)
is fine — only the `.cpp(true) + .cpp_link_stdlib("c++_shared")` combination
triggers the regression.
---
## What we'd like help with
1. **Is our suspect #2 actually the mechanism?** Is there a known issue
where a Tauri/tao android cdylib crashes on load when it has a
`libc++_shared.so` NEEDED entry and tries to spawn a thread from inside
an onCreate JNI call?
2. **What's the correct way to link Oboe (or any C++ Android audio
library) into a `cargo tauri android build` cdylib** without hitting
this? Is there a known-good combination of cc-rs flags / linker
arguments / cargo config?
3. **Is there a way to force `cargo tauri` to use the same linker setup
as `cargo ndk`**, which reliably produces working Oboe-linked .so
files from the exact same workspace? We've tried env var override,
`.cargo/config.toml`, and image-level binary replacement — cargo
tauri ignores all three and keeps using
`aarch64-linux-android24-clang`.
4. **Is there a way to defer `tao::ndk_glue::create`'s thread spawn to
after `onCreate` returns** so that whatever bionic state `__init_tcb`
depends on is ready?
5. **Lastly** — is there a fundamentally different approach we should
take (e.g., use the `oboe` Rust crate from crates.io instead of a
hand-rolled C++ bridge, use Android's AAudio directly via the `ndk`
crate's aaudio bindings, or even abandon the C++ audio path and
implement mic/speaker via JNI into Java `AudioRecord`/`AudioTrack`)?

View File

@@ -0,0 +1,59 @@
# =============================================================================
# WZ Phone — Linux x86_64 Tauri desktop build image
#
# Thin extension of wzp-android-builder that adds the GTK3 + WebKit2GTK 4.1 +
# libsoup-3.0 + AppIndicator dev packages needed to build the Tauri desktop
# app for Linux. Everything else (Rust, Node.js, cmake, pkg-config, cpal
# libasound deps, tauri-cli) is inherited from the base image.
#
# Build:
# docker build -t wzp-linux-desktop-builder -f Dockerfile.linux-desktop-builder .
#
# Run: driven by scripts/build-linux-desktop-docker.sh (see that file).
# =============================================================================
FROM wzp-android-builder
USER root
# Tauri 2.x Linux dependencies.
# - libwebkit2gtk-4.1-dev: the WebView backend. Tauri 2.x uses 4.1 (not 4.0).
# - libsoup-3.0-dev: HTTP client used by webkit2gtk. Must match its major.
# - libgtk-3-dev: GTK3 headers (webkit2gtk still uses GTK3).
# - libayatana-appindicator3-dev: system tray / status icon. Optional at
# runtime but tauri-build's feature-detection includes it.
# - librsvg2-dev: SVG rendering in the menu/icon code.
# - libglib2.0-dev: GObject introspection headers (transitive, but explicit).
# - patchelf: used by the tauri bundler to rewrite rpaths in the final binary.
# - file: already in the base, but tauri-build checks for it by name.
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libglib2.0-dev \
patchelf \
libwebrtc-audio-processing-dev \
clang \
&& rm -rf /var/lib/apt/lists/*
# ── webrtc-audio-processing build requirements ──────────────────────────────
# The `webrtc-audio-processing` Rust crate (0.3.x line) links against Debian
# Bookworm's `libwebrtc-audio-processing-dev` apt package (0.3-1+b1), which
# provides the PulseAudio fork of the WebRTC audio processing module. This is
# the library that Pulse's module-echo-cancel and PipeWire's filter-chain
# use for their AEC modes — same algorithm family, runtime-linked via
# pkg-config at cargo build time.
#
# An attempt was made to use the 2.x line with the `bundled` sub-feature
# (which would give AEC3 instead of AEC2) but both the crates.io tarball
# and the upstream git `main` branch hit a `meson setup --reconfigure` bug
# that panics on first-run empty build dirs. The 0.3 line avoids the
# bundled build path entirely and is what we ship for now.
#
# `clang` is listed explicitly because the Rust crate's bindgen may need
# it at compile time depending on the version of the underlying
# webrtc-audio-processing-sys build script.
USER builder
WORKDIR /build/source

View File

@@ -0,0 +1,99 @@
# =============================================================================
# WZ Phone — Windows (x86_64-pc-windows-msvc) cross-compile image
#
# Cross-compiles the Tauri desktop binary for Windows from a Linux host via
# `cargo xwin`, which auto-downloads the Microsoft CRT + Windows SDK at build
# time. This image pre-warms that cache so the cross-compile is as close as
# possible to a native Linux build on rebuild (~3 min warm vs ~20 min cold).
#
# Build:
# docker build -t wzp-windows-builder -f Dockerfile.windows-builder .
#
# Run: driven by scripts/build-windows-docker.sh (see that file).
# =============================================================================
FROM debian:bookworm
ARG RUST_TARGET=x86_64-pc-windows-msvc
ENV DEBIAN_FRONTEND=noninteractive
# ── System packages ──────────────────────────────────────────────────────────
# - build-essential + pkg-config + libssl-dev: baseline cargo build toolchain
# - cmake + ninja-build: audiopus_sys (libopus) uses cmake and expects Ninja
# as the generator for the windows target; without ninja-build the cmake
# build fails with "CMake was unable to find a build program corresponding
# to Ninja" partway through.
# - llvm + clang + lld: cargo-xwin uses clang + lld-link for PE/COFF output.
# - nasm: ring / rustls assembly for Windows needs NASM on non-Windows hosts.
# - curl, git, ca-certificates, unzip: obvious plumbing.
# - xz-utils: some Microsoft installer archives are xz-compressed.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
curl \
git \
pkg-config \
libssl-dev \
ca-certificates \
llvm \
clang \
lld \
nasm \
unzip \
xz-utils \
file \
&& rm -rf /var/lib/apt/lists/*
# ── Node.js 20 LTS (required by Tauri for frontend build) ────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version \
&& npm --version
# ── Builder user (1000:1000) — matches host bind-mount UID for the cache
# volumes so cargo-registry / target survive across runs without perms
# gymnastics.
RUN groupadd -g 1000 builder \
&& useradd -m -u 1000 -g 1000 -s /bin/bash builder
USER builder
WORKDIR /home/builder
# ── Rust toolchain + Windows target + cargo-xwin ────────────────────────────
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable \
&& . $HOME/.cargo/env \
&& rustup target add ${RUST_TARGET} \
&& cargo install cargo-xwin --locked
ENV PATH="/home/builder/.cargo/bin:$PATH" \
XWIN_ACCEPT_LICENSE=1 \
RUST_TARGET_WIN=${RUST_TARGET}
# ── Pre-warm the xwin cache ─────────────────────────────────────────────────
# cargo-xwin downloads the Microsoft CRT + Windows SDK (~1.5-2 GB) into
# ~/.cache/cargo-xwin the first time it runs. Baking that into an image
# layer saves ~4 minutes off every subsequent cold run.
#
# We do this by creating a throwaway Rust project, building it with
# cargo-xwin against the Windows target, then deleting the project but
# keeping the xwin cache.
RUN set -eux; \
mkdir -p /tmp/xwin-warmup && cd /tmp/xwin-warmup && \
. $HOME/.cargo/env && \
cargo new --bin xwin-warmup --quiet && \
cd xwin-warmup && \
cargo xwin build --release --target ${RUST_TARGET} 2>&1 | tail -5 && \
cd / && rm -rf /tmp/xwin-warmup && \
du -sh $HOME/.cache/cargo-xwin
# Note: the libopus SSE4.1/SSSE3 intrinsic compile failure under clang-cl
# is fixed at the source level by vendoring audiopus_sys and patching its
# bundled libopus CMakeLists.txt (see desktop/vendor/audiopus_sys in the
# source tree). Do NOT try to patch cargo-xwin's override.cmake at this
# layer — cargo-xwin rewrites that file on every `cargo xwin build`
# invocation, so any edits baked into the image are wiped at runtime.
WORKDIR /build/source

View File

@@ -139,7 +139,7 @@ ls -lh android/app/src/main/jniLibs/arm64-v8a/
echo ">>> APK build..." echo ">>> APK build..."
cd android && chmod +x gradlew cd android && chmod +x gradlew
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -50 ./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -3
echo "APK_BUILT" echo "APK_BUILT"
' '
@@ -153,7 +153,7 @@ if [ -n "$APK" ]; then
notify "WZP Android [$BRANCH @ $GIT_HASH] done! APK: $URL" notify "WZP Android [$BRANCH @ $GIT_HASH] done! APK: $URL"
echo ">>> Done! APK at: $URL" echo ">>> Done! APK at: $URL"
else else
notify "WZP build FAILED [$BRANCH @ $GIT_HASH] - no APK" notify "WZP Android FAILED [$BRANCH @ $GIT_HASH] - no APK"
echo "ERROR: No APK found" echo "ERROR: No APK found"
exit 1 exit 1
fi fi

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# WZ Phone — Linux x86_64 Tauri desktop build (Docker on SepehrHomeserverdk)
#
# Cross-compiles the Tauri desktop binary for Linux x86_64 inside the
# wzp-linux-desktop-builder image (a thin extension of wzp-android-builder
# that adds GTK3 + WebKit2GTK 4.1 + libsoup-3.0 + appindicator dev packages).
#
# Fires an ntfy.sh/wzp notification on build start and build completion, and
# uploads the resulting .deb + raw binary to rustypaste.
#
# Usage:
# ./scripts/build-linux-desktop-docker.sh # Full pipeline
# ./scripts/build-linux-desktop-docker.sh --no-pull # Skip git fetch
# ./scripts/build-linux-desktop-docker.sh --rust # Clean Rust target
# ./scripts/build-linux-desktop-docker.sh --image-build # (Re)build image
#
# Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
# =============================================================================
REMOTE_HOST="SepehrHomeserverdk"
BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/linux-desktop"
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}"
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
REBUILD_RUST=0
DO_PULL=1
IMAGE_BUILD=0
# WITH_AEC=1 enables the wzp-client `linux-aec` feature (WebRTC AEC via
# webrtc-audio-processing) and renames the output artifacts with an `-aec`
# suffix so both variants can coexist on disk.
WITH_AEC=0
for arg in "$@"; do
case "$arg" in
--rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--image-build) IMAGE_BUILD=1 ;;
--aec) WITH_AEC=1 ;;
-h|--help)
sed -n '3,25p' "$0"
exit 0
;;
esac
done
# Variant suffix used locally to rename downloaded artifacts so the noAEC
# baseline and the AEC build can coexist in $LOCAL_OUTPUT. Mirrors the
# same VARIANT declaration inside the remote REMOTE_SCRIPT heredoc.
if [ "$WITH_AEC" = "1" ]; then
VARIANT="aec"
else
VARIANT="noAEC"
fi
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh $SSH_OPTS "$REMOTE_HOST" "$@"; }
notify_local() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
mkdir -p "$LOCAL_OUTPUT"
# ─── Optional: (re)build the docker image on the remote ────────────────────
if [ "$IMAGE_BUILD" = "1" ]; then
log "Uploading Dockerfile.linux-desktop-builder to remote..."
scp $SSH_OPTS "$(dirname "$0")/Dockerfile.linux-desktop-builder" \
"$REMOTE_HOST:$BASE_DIR/Dockerfile.linux-desktop-builder"
log "Triggering remote image build (fire-and-forget)..."
ssh_cmd "cd $BASE_DIR && \
nohup docker build -f Dockerfile.linux-desktop-builder \
-t wzp-linux-desktop-builder . \
> /tmp/wzp-linux-desktop-image-build.log 2>&1 & \
echo 'image build PID: '\$!"
notify_local "WZP Linux desktop image build dispatched"
log "Image build running in background on $REMOTE_HOST."
log "Tail the log with: ssh $REMOTE_HOST 'tail -f /tmp/wzp-linux-desktop-image-build.log'"
exit 0
fi
# ─── Upload remote build runner script ─────────────────────────────────────
log "Uploading remote build script..."
ssh_cmd "cat > /tmp/wzp-linux-desktop-build.sh" <<'REMOTE_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp"
BRANCH="${1:-feat/desktop-audio-rewrite}"
DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}"
WITH_AEC="${4:-0}"
LOG_FILE=/tmp/wzp-linux-desktop-build.log
GIT_HASH="unknown"
ENV_FILE="$BASE_DIR/.env"
# Variant suffix for artifact filenames so the noAEC baseline and the AEC
# build can coexist on the host. Applied after the build to the downloaded
# files (we can't easily rename during the cargo tauri build itself).
if [ "$WITH_AEC" = "1" ]; then
VARIANT="aec"
else
VARIANT="noAEC"
fi
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
# Upload to rustypaste; print URL on stdout (or empty on failure).
upload_to_rustypaste() {
local file="$1"
[ ! -f "$file" ] && { echo ""; return; }
# shellcheck disable=SC1090
source "$ENV_FILE"
if [ -n "${rusty_address:-}" ] && [ -n "${rusty_auth_token:-}" ]; then
curl -s -F "file=@$file" -H "Authorization: $rusty_auth_token" "$rusty_address" || echo ""
else
echo ""
fi
}
on_error() {
local line="$1"
local log_url
log_url=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$log_url" ]; then
notify "WZP Linux desktop build FAILED [$GIT_HASH] (line $line)
log: $log_url"
else
notify "WZP Linux desktop build FAILED [$GIT_HASH] (line $line) — log upload failed"
fi
}
trap 'on_error $LINENO' ERR
exec > >(tee "$LOG_FILE") 2>&1
# ── git fetch + reset the target branch ───────────────────────────────────
if [ "$DO_PULL" = "1" ]; then
echo ">>> git fetch + reset $BRANCH"
cd "$BASE_DIR/data/source"
git reset --hard HEAD 2>/dev/null || true
git gc --prune=now 2>/dev/null || true
git fetch origin "$BRANCH" 2>&1 | tail -3
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
git submodule update --init --recursive || true
fi
GIT_HASH=$(cd "$BASE_DIR/data/source" && git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_MSG=$(cd "$BASE_DIR/data/source" && git log -1 --pretty=%s 2>/dev/null | head -c 60 || echo "?")
notify "WZP Linux desktop build STARTED [$GIT_HASH] — $GIT_MSG"
# Fix perms so builder uid 1000 can read/write the mounted source.
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache-linux-desktop" \
! -user 1000 -o ! -group 1000 2>/dev/null | \
xargs -r chown 1000:1000 2>/dev/null || true
if [ "$REBUILD_RUST" = "1" ]; then
echo ">>> Cleaning Linux desktop Rust target dir..."
rm -rf "$BASE_DIR/data/cache-linux-desktop/target/x86_64-unknown-linux-gnu" \
"$BASE_DIR/data/cache-linux-desktop/target/release"
fi
# ── Docker run ─────────────────────────────────────────────────────────────
# Cache volumes:
# - cargo-registry / cargo-git: shared with the android builder — both use
# the same crates, so the download cache is worth sharing.
# - cache-linux-desktop/target: separate target tree for the desktop build
# to keep it isolated from the Linux CLI build (build-linux-docker.sh
# uses cache-linux/target for wzp-relay / wzp-client).
mkdir -p "$BASE_DIR/data/cache/cargo-registry" \
"$BASE_DIR/data/cache/cargo-git" \
"$BASE_DIR/data/cache-linux-desktop/target"
chown -R 1000:1000 "$BASE_DIR/data/cache-linux-desktop/target" 2>/dev/null || true
# Pass WITH_AEC into the docker container so the inner build script can
# decide whether to enable the wzp-client `linux-aec` feature.
docker run --rm \
--user 1000:1000 \
-e WITH_AEC="$WITH_AEC" \
-v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
-v "$BASE_DIR/data/cache-linux-desktop/target:/build/source/target" \
wzp-linux-desktop-builder \
bash -c '
set -euo pipefail
cd /build/source/desktop
echo ">>> npm install"
npm install --silent 2>&1 | tail -5 || npm install 2>&1 | tail -20
echo ">>> npm run build"
npm run build 2>&1 | tail -5
# The linux-aec feature enables a WebRTC AEC3 capture backend in
# wzp-client. Opt in only when the caller asked for it; noAEC baseline
# builds keep the plain CPAL path for comparison. Tauri does not
# propagate --features through to the wzp-desktop crate directly
# because `cargo tauri build` invokes cargo underneath — so we use
# `cargo tauri build -- --features wzp-desktop/linux-aec` to pass it
# through. Wait — wzp-desktop is the bin crate, and its `linux-aec`
# feature needs to be defined there too. The simpler path is to set
# the feature at the wzp-client level via a bin-crate feature that
# forwards to wzp-client. Handled in Cargo.toml changes.
if [ "${WITH_AEC:-0}" = "1" ]; then
echo ">>> cargo tauri build WITH linux-aec feature"
cd src-tauri
cargo tauri build -- --features wzp-desktop/linux-aec 2>&1 | tail -40
else
echo ">>> cargo tauri build (noAEC baseline)"
cd src-tauri
cargo tauri build 2>&1 | tail -40
fi
echo ""
echo ">>> Build artifacts:"
ls -lh /build/source/target/release/wzp-desktop 2>/dev/null || echo "NO BINARY"
ls -lh /build/source/target/release/bundle/deb/*.deb 2>/dev/null || echo "NO DEB"
ls -lh /build/source/target/release/bundle/appimage/*.AppImage 2>/dev/null || echo "NO APPIMAGE"
'
# Locate the produced artifacts
BIN="$BASE_DIR/data/cache-linux-desktop/target/release/wzp-desktop"
DEB=$(ls "$BASE_DIR/data/cache-linux-desktop/target/release/bundle/deb/"*.deb 2>/dev/null | head -1 || true)
APPIMAGE=$(ls "$BASE_DIR/data/cache-linux-desktop/target/release/bundle/appimage/"*.AppImage 2>/dev/null | head -1 || true)
if [ ! -f "$BIN" ]; then
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then
notify "WZP Linux desktop build [$GIT_HASH]: no binary produced
log: $LOG_URL"
else
notify "WZP Linux desktop build [$GIT_HASH]: no binary produced — log upload failed"
fi
exit 1
fi
BIN_SIZE=$(du -h "$BIN" | cut -f1)
# Prefer to ship the .deb if we got one, otherwise fall back to the raw binary.
ARTIFACT="$BIN"
ARTIFACT_KIND="binary"
if [ -n "$DEB" ] && [ -f "$DEB" ]; then
ARTIFACT="$DEB"
ARTIFACT_KIND="deb"
ARTIFACT_SIZE=$(du -h "$DEB" | cut -f1)
else
ARTIFACT_SIZE="$BIN_SIZE"
fi
RUSTY_URL=$(upload_to_rustypaste "$ARTIFACT" || echo "")
if [ -n "$RUSTY_URL" ]; then
notify "WZP Linux desktop build OK [$GIT_HASH] ($ARTIFACT_KIND, $ARTIFACT_SIZE)
$RUSTY_URL"
else
notify "WZP Linux desktop build OK [$GIT_HASH] ($ARTIFACT_KIND, $ARTIFACT_SIZE) — rustypaste upload skipped"
fi
# Print paths so the local script can scp them back
echo "BIN_REMOTE_PATH=$BIN"
[ -n "$DEB" ] && echo "DEB_REMOTE_PATH=$DEB"
[ -n "$APPIMAGE" ] && echo "APPIMAGE_REMOTE_PATH=$APPIMAGE"
REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-linux-desktop-build.sh"
notify_local "WZP Linux desktop build dispatched (branch=$BRANCH)"
log "Triggering remote build (branch=$BRANCH)..."
# Run; last lines are *_REMOTE_PATH=...
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-linux-desktop-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$WITH_AEC'" || true)
echo "$REMOTE_OUTPUT" | tail -80
BIN_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^BIN_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
DEB_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^DEB_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
APPIMAGE_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APPIMAGE_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
if [ -n "$BIN_REMOTE" ]; then
log "Downloading wzp-desktop binary to $LOCAL_OUTPUT/wzp-desktop-$VARIANT ..."
scp $SSH_OPTS "$REMOTE_HOST:$BIN_REMOTE" "$LOCAL_OUTPUT/wzp-desktop-$VARIANT"
echo " $LOCAL_OUTPUT/wzp-desktop-$VARIANT ($(du -h "$LOCAL_OUTPUT/wzp-desktop-$VARIANT" | cut -f1))"
fi
if [ -n "$DEB_REMOTE" ]; then
# Apply the variant suffix to the downloaded .deb: cargo-tauri names the
# file WarzonePhone_<version>_amd64.deb regardless of what we built, so
# the variant lives only in our chosen filename.
DEB_BASENAME=$(basename "$DEB_REMOTE" .deb)
log "Downloading .deb to $LOCAL_OUTPUT/${DEB_BASENAME}-$VARIANT.deb ..."
scp $SSH_OPTS "$REMOTE_HOST:$DEB_REMOTE" "$LOCAL_OUTPUT/${DEB_BASENAME}-$VARIANT.deb"
ls -lh "$LOCAL_OUTPUT/${DEB_BASENAME}-$VARIANT.deb"
fi
if [ -n "$APPIMAGE_REMOTE" ]; then
APPIMG_BASENAME=$(basename "$APPIMAGE_REMOTE" .AppImage)
log "Downloading .AppImage to $LOCAL_OUTPUT/${APPIMG_BASENAME}-$VARIANT.AppImage ..."
scp $SSH_OPTS "$REMOTE_HOST:$APPIMAGE_REMOTE" "$LOCAL_OUTPUT/${APPIMG_BASENAME}-$VARIANT.AppImage"
ls -lh "$LOCAL_OUTPUT/${APPIMG_BASENAME}-$VARIANT.AppImage"
fi
if [ -z "$BIN_REMOTE" ]; then
log "No binary produced — see ntfy / remote log /tmp/wzp-linux-desktop-build.log"
exit 1
fi

View File

@@ -17,6 +17,12 @@ NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/linux-x86_64" LOCAL_OUTPUT="target/linux-x86_64"
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR" SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
# Branch to build. Default matches the current active development branch
# (opus-DRED-v2 as of 2026-04-11). Override with `WZP_BRANCH=<name> ./build-linux-docker.sh`
# if you need a different one — e.g. to rebuild the relay from a feature
# branch for A/B testing.
WZP_BRANCH="${WZP_BRANCH:-opus-DRED-v2}"
DO_PULL=1 DO_PULL=1
DO_CLEAN=0 DO_CLEAN=0
DO_INSTALL=0 DO_INSTALL=0
@@ -44,19 +50,21 @@ BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp" NTFY_TOPIC="https://ntfy.sh/wzp"
DO_PULL="${1:-0}" DO_PULL="${1:-0}"
DO_CLEAN="${2:-0}" DO_CLEAN="${2:-0}"
BRANCH="${3:-opus-DRED-v2}"
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; } notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
trap 'notify "WZP Linux build FAILED! Check /tmp/wzp-linux-build.log"' ERR trap 'notify "WZP Linux build FAILED! Check /tmp/wzp-linux-build.log"' ERR
if [ "$DO_PULL" = "1" ]; then if [ "$DO_PULL" = "1" ]; then
echo ">>> Pulling latest..." echo ">>> Pulling latest ($BRANCH)..."
cd "$BASE_DIR/data/source" cd "$BASE_DIR/data/source"
git reset --hard HEAD 2>/dev/null || true git reset --hard HEAD 2>/dev/null || true
git clean -fd 2>/dev/null || true git clean -fd 2>/dev/null || true
git gc --prune=now 2>/dev/null || true git gc --prune=now 2>/dev/null || true
git fetch origin feat/android-voip-client 2>&1 | tail -3 git fetch origin "$BRANCH" 2>&1 | tail -3
git reset --hard origin/feat/android-voip-client 2>/dev/null || true git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH" 2>/dev/null || true
fi fi
if [ "$DO_CLEAN" = "1" ]; then if [ "$DO_CLEAN" = "1" ]; then
@@ -133,7 +141,7 @@ ssh_cmd "chmod +x /tmp/wzp-linux-build.sh"
# Run in tmux # Run in tmux
log "Starting Linux build in tmux..." log "Starting Linux build in tmux..."
ssh_cmd "tmux kill-session -t wzp-linux 2>/dev/null; true" ssh_cmd "tmux kill-session -t wzp-linux 2>/dev/null; true"
ssh_cmd "tmux new-session -d -s wzp-linux '/tmp/wzp-linux-build.sh $DO_PULL $DO_CLEAN 2>&1 | tee /tmp/wzp-linux-build.log'" ssh_cmd "tmux new-session -d -s wzp-linux '/tmp/wzp-linux-build.sh $DO_PULL $DO_CLEAN $WZP_BRANCH 2>&1 | tee /tmp/wzp-linux-build.log'"
log "Build running! Notification on ntfy.sh/wzp when done." log "Build running! Notification on ntfy.sh/wzp when done."
echo "" echo ""

View File

@@ -199,6 +199,29 @@ else
echo ">>> WARNING: libwzp_native.so not produced" echo ">>> WARNING: libwzp_native.so not produced"
fi fi
# ─── libc++_shared.so — required by wzp-native at runtime ──────────────
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
# explicitly, the APK ships without it and the Android dynamic linker
# fails the dlopen with "library libc++_shared.so not found" at runtime.
# Same fix that build-and-notify.sh has had for the legacy wzp-android
# path (lines 126-134 there) — ported here for the Tauri pipeline.
# NOTE: no apostrophes in this comment block. The enclosing docker
# bash -c uses single quotes and a stray apostrophe closes the string
# prematurely, breaking variable scope for everything below.
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
else
echo ">>> ERROR: libc++_shared.so not found in NDK — APK will crash at dlopen time"
exit 1
fi
fi
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk" echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk

391
scripts/build-windows-cloud.sh Executable file
View File

@@ -0,0 +1,391 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone desktop .exe for Windows x86_64 using a temporary
# Hetzner Cloud VPS. Cross-compiles from Linux via `cargo xwin`, which
# auto-downloads the Windows SDK + MSVC CRT the first time it runs.
#
# No Windows machine needed for the build itself — the produced .exe
# still has to be copied to a real Windows host to run (we can only
# verify compile + link here, not runtime).
#
# Prerequisites:
# - hcloud CLI authenticated
# - SSH key "wz" registered in Hetzner
# - Local ssh-agent loaded with an SSH key that can read the
# git.manko.yoga repo (the script forwards the agent so the VM's
# git clone uses your identity). Run `ssh-add /Users/manwe/CascadeProjects/wzp`
# once before invoking this script if you haven't already.
#
# Usage:
# ./scripts/build-windows-cloud.sh Full build (create → build → download → destroy)
# ./scripts/build-windows-cloud.sh --prepare Create VM and install deps only
# ./scripts/build-windows-cloud.sh --build Build on existing VM
# ./scripts/build-windows-cloud.sh --transfer Download .exe from VM
# ./scripts/build-windows-cloud.sh --destroy Delete the VM
# ./scripts/build-windows-cloud.sh --all prepare + build + transfer (VM persists)
# ./scripts/build-windows-cloud.sh --upload Re-upload source to existing VM
#
# Environment variables (all optional):
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
# WZP_SERVER_TYPE Hetzner server type (default: cx23 — small, cheap, x86)
# WZP_KEEP_VM Set to 1 to skip destroy on full build
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}" # cx23 (4GB RAM) OOMs on tauri+rustls cross-compile — bump to cx33 (8GB, 8 vCPU)
IMAGE="ubuntu-24.04"
SERVER_NAME="wzp-windows-builder"
REMOTE_USER="root"
OUTPUT_DIR="target/windows-exe"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}"
KEEP_VM="${WZP_KEEP_VM:-0}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
RUST_TARGET="x86_64-pc-windows-msvc"
NTFY_TOPIC="https://ntfy.sh/wzp"
RUSTY_ENV_FILE="$HOME/.wzp/rustypaste.env"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
die() {
err "$@"
notify "WZP Windows build FAILED — $*"
# If the user wants to keep the VM alive for debugging (WZP_KEEP_VM=1),
# don't tear it down on failure — they might want to ssh in and poke at
# the build state. Only auto-destroy when KEEP_VM is explicitly off.
if [ "${KEEP_VM:-0}" != "1" ]; then
do_destroy_quiet
else
err "VM kept alive for debugging (WZP_KEEP_VM=1). Destroy with $0 --destroy"
fi
exit 1
}
notify() {
# Fire-and-forget ntfy. Silently ignored if there's no network.
curl -sf -m 5 -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true
}
# Upload a file to the online rustypaste (paste.dk.manko.yoga), return
# the public URL on stdout. Requires $RUSTY_ENV_FILE to contain
# rusty_address + rusty_auth_token (synced from SepehrHomeserverdk's
# /mnt/storage/manBuilder/.env once; see README).
rustypaste_upload() {
local file="$1"
[ -f "$file" ] || { echo ""; return; }
[ -f "$RUSTY_ENV_FILE" ] || { echo ""; return; }
# shellcheck disable=SC1090
source "$RUSTY_ENV_FILE"
if [ -n "${rusty_address:-}" ] && [ -n "${rusty_auth_token:-}" ]; then
curl -s -F "file=@$file" -H "Authorization: $rusty_auth_token" "$rusty_address" || echo ""
else
echo ""
fi
}
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
}
ssh_cmd() {
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found. Run --prepare first."
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
scp_down() {
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
}
do_destroy_quiet() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$name" ]; then
echo ""
err "Cleaning up — destroying VM $name"
hcloud server delete "$name" 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# --prepare: Create VM, install all build dependencies
# ---------------------------------------------------------------------------
do_prepare() {
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$existing" ]; then
log "VM already exists: $existing — reusing"
do_upload
return
fi
notify "WZP Windows build STARTED ($BRANCH) — spinning up $SERVER_TYPE"
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
hcloud server create \
--name "$SERVER_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location fsn1 \
--quiet \
|| die "Failed to create VM"
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "VM created but no IP found"
echo " VM: $SERVER_NAME @ $ip"
log "Waiting for SSH..."
local ok=0
for i in $(seq 1 30); do
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
# System packages — cargo-xwin needs llvm/lld; ring needs nasm on
# Windows; audiopus_sys (libopus) uses cmake + ninja to build for the
# Windows target; tauri's build.rs needs the frontend dist which needs
# node+npm.
log "Installing system packages (llvm, lld, clang, nasm, ninja, node)..."
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
apt-get update -qq && \
apt-get install -y -qq \
build-essential cmake ninja-build curl git pkg-config \
llvm clang lld nasm \
libssl-dev ca-certificates \
unzip wget \
> /dev/null 2>&1" \
|| die "Failed to install system packages"
# Node.js 20 via NodeSource
ssh_cmd "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 && \
apt-get install -y -qq nodejs > /dev/null 2>&1" \
|| die "Failed to install Node.js"
echo " clang: $(ssh_cmd "clang --version | head -1")"
echo " node: $(ssh_cmd "node --version")"
echo " npm: $(ssh_cmd "npm --version")"
# Rust
log "Installing Rust toolchain + target $RUST_TARGET..."
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|| die "Failed to install Rust"
ssh_cmd "source \$HOME/.cargo/env && rustup target add $RUST_TARGET > /dev/null 2>&1" \
|| die "Failed to add Windows target"
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
# cargo-xwin — the cross compiler glue that fetches Windows SDK + CRT
# on demand and shims cc/lld to produce PE/COFF output. The Microsoft
# license is auto-accepted via XWIN_ACCEPT_LICENSE=1 below (current
# cargo-xwin removed the --accept-license CLI flag in favour of the
# env var; --dry-run just prints what it would do).
log "Installing cargo-xwin..."
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-xwin > /dev/null 2>&1" \
|| die "Failed to install cargo-xwin"
echo " cargo-xwin: $(ssh_cmd "source \$HOME/.cargo/env && cargo xwin --version 2>&1 | head -1")"
# Make the license-accept env var persist across later ssh_cmd calls so
# `cargo xwin build` in do_build() doesn't prompt interactively.
ssh_cmd "echo 'export XWIN_ACCEPT_LICENSE=1' >> \$HOME/.bashrc"
# Do the source upload + git clone (agent-forwarded) here.
do_upload
log "VM ready!"
echo " IP: $ip"
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
}
# ---------------------------------------------------------------------------
# --upload: Clone the repo on the VM (not rsync — the branch we want
# lives in a separate worktree, and cloning from git is simpler + reuses
# whatever SSH identity the calling shell has loaded in its agent).
# ---------------------------------------------------------------------------
GIT_REPO="ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git"
do_upload() {
log "Cloning wz-phone on VM (branch $BRANCH, agent-forwarded)..."
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
# Accept the git host key once so `git clone` doesn't hang asking.
ssh_cmd "mkdir -p \$HOME/.ssh && \
ssh-keyscan -p 222 -t rsa,ecdsa,ed25519 git.manko.yoga >> \$HOME/.ssh/known_hosts 2>/dev/null"
# Fresh clone each run — cheap on a short-lived builder VM, avoids
# stale state if the branch was force-pushed. --recurse-submodules so
# deps/featherchat (which has the warzone-protocol workspace member)
# comes along for the ride.
ssh_cmd "rm -rf /root/wzp-build && \
git clone --depth 1 --branch $BRANCH --recurse-submodules --shallow-submodules $GIT_REPO /root/wzp-build 2>&1 | tail -5" \
|| die "git clone failed — is your ssh-agent loaded with a key that can read git.manko.yoga?"
echo " Cloned $BRANCH into /root/wzp-build (with submodules)"
}
# ---------------------------------------------------------------------------
# --build: Build frontend + cross-compile wzp-desktop.exe
# ---------------------------------------------------------------------------
do_build() {
log "Building frontend (vite)..."
ssh_cmd "cd /root/wzp-build/desktop && \
npm install --silent 2>&1 | tail -3 && \
npm run build 2>&1 | tail -5" \
|| die "Frontend build failed"
log "Cross-compiling wzp-desktop.exe ($RUST_TARGET) via cargo-xwin..."
# XWIN_ACCEPT_LICENSE=1 is required by recent cargo-xwin for headless
# runs; --cross-compiler clang-cl picks the system clang shipped by the
# apt install step in do_prepare.
ssh_cmd "source \$HOME/.cargo/env && \
export XWIN_ACCEPT_LICENSE=1 && \
cd /root/wzp-build/desktop/src-tauri && \
cargo xwin build --release --target $RUST_TARGET --bin wzp-desktop 2>&1 | tail -30" \
|| die "Windows cross-compile failed"
ssh_cmd "[ -f /root/wzp-build/target/$RUST_TARGET/release/wzp-desktop.exe ]" \
|| die "wzp-desktop.exe not found after build"
local exe_size
exe_size=$(ssh_cmd "du -h /root/wzp-build/target/$RUST_TARGET/release/wzp-desktop.exe | cut -f1")
echo " .exe: $exe_size"
local git_hash
git_hash=$(ssh_cmd "cd /root/wzp-build && git rev-parse --short HEAD")
notify "WZP Windows build OK [$git_hash] ($exe_size)"
export WZP_BUILD_GIT_HASH="$git_hash"
export WZP_BUILD_SIZE="$exe_size"
}
# ---------------------------------------------------------------------------
# --transfer: Download the .exe to local machine
# ---------------------------------------------------------------------------
do_transfer() {
log "Downloading wzp-desktop.exe..."
mkdir -p "$OUTPUT_DIR"
scp_down "/root/wzp-build/target/$RUST_TARGET/release/wzp-desktop.exe" "$OUTPUT_DIR/wzp-desktop.exe"
local local_size
local_size=$(du -h "$OUTPUT_DIR/wzp-desktop.exe" | cut -f1)
echo " $OUTPUT_DIR/wzp-desktop.exe ($local_size)"
# Upload to online rustypaste and notify with the URL.
log "Uploading to rustypaste..."
local url
url=$(rustypaste_upload "$OUTPUT_DIR/wzp-desktop.exe" || echo "")
if [ -n "$url" ]; then
echo " $url"
local hash="${WZP_BUILD_GIT_HASH:-?}"
notify "WZP Windows build ready [$hash] ($local_size)
$url"
else
echo " (rustypaste upload skipped — no creds in $RUSTY_ENV_FILE)"
notify "WZP Windows build transferred ($local_size) — rustypaste upload skipped"
fi
log "Transfer complete!"
echo ""
echo " Copy to a real Windows x86_64 host and double-click to run."
echo " WebView2 runtime is required on Windows 10 (ships with Win 11)."
}
# ---------------------------------------------------------------------------
# --destroy: Delete the VM
# ---------------------------------------------------------------------------
do_destroy() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -z "$name" ]; then
echo "No VM to destroy."
return
fi
log "Deleting VM: $name"
hcloud server delete "$name"
echo " Done."
}
# ---------------------------------------------------------------------------
# Full build: create → build → transfer → destroy
# ---------------------------------------------------------------------------
do_full() {
trap 'err "Build failed!"; [ "${KEEP_VM:-0}" = "1" ] || do_destroy_quiet; exit 1' ERR
do_prepare
do_build
do_transfer
if [ "$KEEP_VM" = "1" ]; then
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
else
do_destroy
fi
log "All done!"
echo ""
echo " ┌────────────────────────────────────────────────┐"
echo " │ Windows .exe: $OUTPUT_DIR/wzp-desktop.exe"
echo " │"
echo " │ Transfer to a Windows x86_64 machine and run."
echo " └────────────────────────────────────────────────┘"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--prepare) do_prepare ;;
--build) do_build ;;
--transfer) do_transfer ;;
--destroy) do_destroy ;;
--upload) do_upload ;;
--all)
do_prepare
do_build
do_transfer
log "VM still running. Destroy with: $0 --destroy"
;;
"")
do_full
;;
*)
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
echo ""
echo " (no args) Full build: create VM → build → download → destroy VM"
echo " --prepare Create VM and install deps"
echo " --build Build on existing VM"
echo " --transfer Download .exe from VM"
echo " --destroy Delete the VM"
echo " --all prepare + build + transfer (VM persists)"
echo " --upload Re-upload source to existing VM"
echo ""
echo "Environment:"
echo " WZP_BRANCH=$BRANCH"
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
exit 1
;;
esac

241
scripts/build-windows-docker.sh Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# WZ Phone — Windows x86_64 cross-compile (Docker on SepehrHomeserverdk)
#
# Cross-compiles the Tauri desktop binary for Windows via `cargo xwin`
# inside the wzp-windows-builder Docker image on SepehrHomeserverdk.
# Uploads the resulting .exe to rustypaste, fires ntfy.sh/wzp notifications
# at start + finish, and SCPs the .exe back locally.
#
# Same pattern as build-tauri-android.sh but for the Windows cross-compile
# pipeline:
# - Source: desktop/src-tauri/
# - Build: cargo xwin build --release --target x86_64-pc-windows-msvc
# - Output: target/x86_64-pc-windows-msvc/release/wzp-desktop.exe
#
# Usage:
# ./scripts/build-windows-docker.sh # full pipeline
# ./scripts/build-windows-docker.sh --no-pull # skip git fetch
# ./scripts/build-windows-docker.sh --rust # force-clean rust target
# ./scripts/build-windows-docker.sh --image-build # (re)build the docker image
#
# Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
# =============================================================================
REMOTE_HOST="SepehrHomeserverdk"
BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/windows-exe"
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}"
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
REBUILD_RUST=0
DO_PULL=1
IMAGE_BUILD=0
for arg in "$@"; do
case "$arg" in
--rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--image-build) IMAGE_BUILD=1 ;;
-h|--help)
sed -n '3,27p' "$0"
exit 0
;;
esac
done
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
notify_local() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
mkdir -p "$LOCAL_OUTPUT"
# ─── Optional: (re)build the docker image on the remote ────────────────────
# Runs once, whenever the Dockerfile changes. Fire-and-forget so the local
# script doesn't wait for the ~15 minute image build.
if [ "$IMAGE_BUILD" = "1" ]; then
log "Uploading Dockerfile.windows-builder to remote..."
scp $SSH_OPTS "$(dirname "$0")/Dockerfile.windows-builder" \
"$REMOTE_HOST:$BASE_DIR/Dockerfile.windows-builder"
log "Triggering remote image build (fire-and-forget)..."
ssh_cmd "cd $BASE_DIR && \
nohup docker build --pull -f Dockerfile.windows-builder \
-t wzp-windows-builder . \
> /tmp/wzp-windows-image-build.log 2>&1 & \
echo 'image build PID: '\$!"
notify_local "WZP Windows image build dispatched (check /tmp/wzp-windows-image-build.log on remote)"
log "Image build running in background on $REMOTE_HOST."
log "Tail the log with: ssh $REMOTE_HOST 'tail -f /tmp/wzp-windows-image-build.log'"
exit 0
fi
# ─── Upload remote build runner script ─────────────────────────────────────
log "Uploading remote build script..."
ssh_cmd "cat > /tmp/wzp-windows-build.sh" <<'REMOTE_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="/mnt/storage/manBuilder"
NTFY_TOPIC="https://ntfy.sh/wzp"
BRANCH="${1:-feat/desktop-audio-rewrite}"
DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}"
LOG_FILE=/tmp/wzp-windows-build.log
GIT_HASH="unknown"
ENV_FILE="$BASE_DIR/.env"
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
# Upload to rustypaste; print URL on stdout (or empty on failure).
upload_to_rustypaste() {
local file="$1"
[ ! -f "$ENV_FILE" ] && { echo ""; return; }
# shellcheck disable=SC1090
source "$ENV_FILE"
if [ -n "${rusty_address:-}" ] && [ -n "${rusty_auth_token:-}" ]; then
curl -s -F "file=@$file" -H "Authorization: $rusty_auth_token" "$rusty_address" || echo ""
else
echo ""
fi
}
on_error() {
local line="$1"
local log_url
log_url=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$log_url" ]; then
notify "WZP Windows build FAILED [$GIT_HASH] (line $line)
log: $log_url"
else
notify "WZP Windows build FAILED [$GIT_HASH] (line $line) — log upload failed, see $LOG_FILE on remote"
fi
}
trap 'on_error $LINENO' ERR
exec > >(tee "$LOG_FILE") 2>&1
# ── git fetch + reset the target branch ───────────────────────────────────
if [ "$DO_PULL" = "1" ]; then
echo ">>> git fetch + reset $BRANCH"
cd "$BASE_DIR/data/source"
git reset --hard HEAD 2>/dev/null || true
git gc --prune=now 2>/dev/null || true
git fetch origin "$BRANCH" 2>&1 | tail -3
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
git submodule update --init --recursive || true
fi
GIT_HASH=$(cd "$BASE_DIR/data/source" && git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_MSG=$(cd "$BASE_DIR/data/source" && git log -1 --pretty=%s 2>/dev/null | head -c 60 || echo "?")
notify "WZP Windows build STARTED [$GIT_HASH] — $GIT_MSG"
# Fix perms so builder uid 1000 can read/write the mounted source.
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache" \
! -user 1000 -o ! -group 1000 2>/dev/null | \
xargs -r chown 1000:1000 2>/dev/null || true
if [ "$REBUILD_RUST" = "1" ]; then
echo ">>> Cleaning Rust windows target dir..."
rm -rf "$BASE_DIR/data/cache/target-windows/x86_64-pc-windows-msvc" \
"$BASE_DIR/data/cache/target-windows/release"
fi
# ── Docker run ─────────────────────────────────────────────────────────────
# Cached volumes:
# - cargo-registry / cargo-git: shared with the android builder — both use
# the same crates, so the download cache is worth sharing.
# - target-windows: the Windows target tree. Kept separate from the android
# target-cache so the two pipelines don't stomp on each other's build
# artefacts (different triples, but the workspace root target dir has
# shared subdirs like release/build/ that can get confused).
# - cargo-xwin cache is BAKED into the docker image, no volume needed.
mkdir -p "$BASE_DIR/data/cache/cargo-registry" \
"$BASE_DIR/data/cache/cargo-git" \
"$BASE_DIR/data/cache/target-windows"
chown -R 1000:1000 "$BASE_DIR/data/cache/target-windows" 2>/dev/null || true
docker run --rm \
--user 1000:1000 \
-v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
-v "$BASE_DIR/data/cache/target-windows:/build/source/target" \
wzp-windows-builder \
bash -c '
set -euo pipefail
# (SSE4.1 / SSSE3 toolchain patch for libopus is baked into the image
# during the xwin pre-warm — see Dockerfile.windows-builder. No runtime
# patching needed.)
cd /build/source/desktop
echo ">>> npm install"
npm install --silent 2>&1 | tail -5 || npm install 2>&1 | tail -20
echo ">>> npm run build"
npm run build 2>&1 | tail -5
echo ">>> cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop"
cd src-tauri
cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop 2>&1 | tail -50
echo ""
echo ">>> Build artifacts:"
ls -lh /build/source/target/x86_64-pc-windows-msvc/release/wzp-desktop.exe 2>/dev/null || echo "NO EXE"
'
# Locate the produced .exe
EXE="$BASE_DIR/data/cache/target-windows/x86_64-pc-windows-msvc/release/wzp-desktop.exe"
if [ ! -f "$EXE" ]; then
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then
notify "WZP Windows build [$GIT_HASH]: no .exe produced
log: $LOG_URL"
else
notify "WZP Windows build [$GIT_HASH]: no .exe produced — log upload failed"
fi
exit 1
fi
EXE_SIZE=$(du -h "$EXE" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$EXE" || echo "")
if [ -n "$RUSTY_URL" ]; then
notify "WZP Windows build OK [$GIT_HASH] ($EXE_SIZE)
$RUSTY_URL"
else
notify "WZP Windows build OK [$GIT_HASH] ($EXE_SIZE) — rustypaste upload skipped"
fi
# Print path so the local script can scp it back
echo "EXE_REMOTE_PATH=$EXE"
REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-windows-build.sh"
notify_local "WZP Windows build dispatched (branch=$BRANCH)"
log "Triggering remote build (branch=$BRANCH)..."
# Run; last line is EXE_REMOTE_PATH=...
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-windows-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST'" || true)
echo "$REMOTE_OUTPUT" | tail -60
EXE_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^EXE_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
if [ -n "$EXE_REMOTE" ]; then
log "Downloading wzp-desktop.exe to $LOCAL_OUTPUT/..."
scp $SSH_OPTS "$REMOTE_HOST:$EXE_REMOTE" "$LOCAL_OUTPUT/wzp-desktop.exe"
echo " $LOCAL_OUTPUT/wzp-desktop.exe ($(du -h "$LOCAL_OUTPUT/wzp-desktop.exe" | cut -f1))"
else
log "No .exe produced — see ntfy / remote log /tmp/wzp-windows-build.log"
exit 1
fi

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# mint-tmux.sh — run a command inside a persistent tmux session on the
# Linux Mint build box so the user can attach and watch/interact at any time.
#
# Usage:
# mint-tmux.sh run <window-name> <command...> # start a new tmux window
# mint-tmux.sh send <window-name> <text...> # send keys to a window
# mint-tmux.sh kill <window-name> # close a window
# mint-tmux.sh list # list windows
# mint-tmux.sh tail <window-name> # dump last 200 lines
#
# Session name is always "wzp". Attach manually with:
# ssh -t root@172.16.81.192 tmux attach -t wzp
#
# If the wzp session doesn't exist yet, it's created automatically.
# =============================================================================
set -euo pipefail
HOST="root@172.16.81.192"
SESSION="wzp"
SSH_OPTS="-o ConnectTimeout=10 -o LogLevel=ERROR"
ensure_session() {
ssh $SSH_OPTS "$HOST" "
tmux has-session -t $SESSION 2>/dev/null || tmux new-session -d -s $SESSION -n home 'bash -l'
"
}
cmd="${1:-list}"
shift || true
case "$cmd" in
run)
WIN="${1:?window name required}"; shift
ensure_session
# Use a heredoc so multi-arg commands don't need escaping
CMD="$*"
ssh $SSH_OPTS "$HOST" bash -s <<REMOTE
if tmux list-windows -t $SESSION -F '#W' 2>/dev/null | grep -qx '$WIN'; then
tmux kill-window -t $SESSION:$WIN 2>/dev/null || true
fi
tmux new-window -t $SESSION -n '$WIN' "bash -l -c '$CMD; echo; echo --- window $WIN exited with code \\\$?; exec bash -l'"
REMOTE
echo "Started '$WIN' in tmux session $SESSION on $HOST"
echo "Attach: ssh -t $HOST tmux attach -t $SESSION"
;;
send)
WIN="${1:?window name required}"; shift
TEXT="$*"
ssh $SSH_OPTS "$HOST" "tmux send-keys -t $SESSION:$WIN '$TEXT' C-m"
;;
kill)
WIN="${1:?window name required}"
ssh $SSH_OPTS "$HOST" "tmux kill-window -t $SESSION:$WIN 2>/dev/null || true"
;;
list)
ensure_session
ssh $SSH_OPTS "$HOST" "tmux list-windows -t $SESSION"
;;
tail)
WIN="${1:?window name required}"
ssh $SSH_OPTS "$HOST" "tmux capture-pane -p -t $SESSION:$WIN -S -200 || echo 'no such window'"
;;
attach)
exec ssh -t $SSH_OPTS "$HOST" tmux attach -t $SESSION
;;
*)
sed -n '3,20p' "$0"
exit 1
;;
esac

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Prepare a Linux Mint / Debian / Ubuntu x86_64 host as a full WarzonePhone
# Android build environment. Installs everything the docker wzp-android-builder
# image has, but directly on the host — so we can iterate locally without
# docker layer caching, see real linker output, run gdbserver, etc.
#
# Target host: root@172.16.81.192 (Linux Mint on the LAN)
#
# Usage (from the macOS workstation):
# scp scripts/prep-linux-mint.sh root@172.16.81.192:/tmp/
# ssh root@172.16.81.192 'nohup bash /tmp/prep-linux-mint.sh > /var/log/wzp-prep.log 2>&1 &'
#
# The script is idempotent: safe to re-run if a step fails. Each stage tests
# for its target before doing work. Progress + completion is pinged to
# ntfy.sh/wzp so we can track it from the phone.
#
# On success the host has:
# - JDK 17
# - Android SDK (cmdline-tools + platforms 34/36, build-tools 34/35, NDK 26.1)
# - Node.js 20 LTS + npm
# - Rust stable + aarch64/armv7/i686/x86_64 android targets
# - cargo-ndk + cargo tauri-cli 2.x
# - /opt/wzp/warzonePhone (cloned workspace checkout on feat/desktop-audio-rewrite)
#
# Everything lives under /opt/android-sdk and /opt/wzp so nothing leaks into $HOME.
# =============================================================================
set -euo pipefail
NTFY_TOPIC="https://ntfy.sh/wzp"
NDK_VERSION="26.1.10909125"
ANDROID_API=34
ANDROID_API_TAURI=36
BUILD_TOOLS_TAURI="35.0.0"
ANDROID_HOME=/opt/android-sdk
WZP_DIR=/opt/wzp
GIT_REPO="ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git"
GIT_BRANCH="feat/desktop-audio-rewrite"
export DEBIAN_FRONTEND=noninteractive
export ANDROID_HOME ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
export NDK_HOME="$ANDROID_NDK_HOME"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:/root/.cargo/bin:$PATH"
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
log() { echo -e "\n\033[1;36m[prep-linux-mint]\033[0m $*"; }
die() { notify "wzp prep-linux-mint FAILED: $1"; echo "FATAL: $1" >&2; exit 1; }
trap 'die "line $LINENO"' ERR
notify "wzp prep-linux-mint STARTED on $(hostname) ($(whoami))"
# ─── 1. Base packages ────────────────────────────────────────────────────────
log "Installing base packages..."
apt-get update -qq
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
curl \
file \
git \
libasound2-dev \
libc6-dev \
libssl-dev \
openjdk-17-jdk-headless \
pkg-config \
unzip \
wget \
xz-utils \
zip
# ─── 2. Android SDK + NDK ────────────────────────────────────────────────────
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
log "Installing Android cmdline-tools..."
mkdir -p "$ANDROID_HOME/cmdline-tools"
cd /tmp
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip
unzip -qo cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
rm cmdtools.zip
else
log "cmdline-tools already installed"
fi
if [ ! -d "$ANDROID_HOME/ndk/$NDK_VERSION" ] || \
[ ! -d "$ANDROID_HOME/platforms/android-$ANDROID_API" ] || \
[ ! -d "$ANDROID_HOME/platforms/android-$ANDROID_API_TAURI" ]; then
log "Installing Android platforms + NDK $NDK_VERSION..."
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null 2>&1 || true
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \
"platforms;android-$ANDROID_API" \
"build-tools;$ANDROID_API.0.0" \
"platforms;android-$ANDROID_API_TAURI" \
"build-tools;$BUILD_TOOLS_TAURI" \
"ndk;$NDK_VERSION" \
"platform-tools" 2>&1 | grep -v '^\[' || true
else
log "Android SDK components already installed"
fi
# ─── 3. Node.js 20 LTS ───────────────────────────────────────────────────────
if ! command -v node >/dev/null 2>&1 || ! node --version | grep -q "^v20"; then
log "Installing Node.js 20 LTS..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
else
log "Node.js already at $(node --version)"
fi
# ─── 4. Rust + Android targets ───────────────────────────────────────────────
if ! command -v rustup >/dev/null 2>&1; then
log "Installing rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
. /root/.cargo/env
log "Ensuring Rust android targets + cargo-ndk + cargo-tauri..."
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
i686-linux-android \
x86_64-linux-android
command -v cargo-ndk >/dev/null 2>&1 || cargo install cargo-ndk
command -v cargo-tauri >/dev/null 2>&1 || cargo install tauri-cli --version "^2.0" --locked
# ─── 5. Clone the workspace ──────────────────────────────────────────────────
mkdir -p "$WZP_DIR"
cd "$WZP_DIR"
if [ -d warzonePhone/.git ]; then
log "Pulling latest on $GIT_BRANCH..."
cd warzonePhone
git fetch origin || true
git checkout "$GIT_BRANCH" 2>/dev/null || git checkout -b "$GIT_BRANCH" "origin/$GIT_BRANCH"
git reset --hard "origin/$GIT_BRANCH" || true
else
log "Cloning warzonePhone from $GIT_REPO..."
# The public repo URL needs ssh keys; if unavailable, skip and let the user sort it later
if git clone --branch "$GIT_BRANCH" "$GIT_REPO" warzonePhone 2>/dev/null; then
log " cloned ok"
else
log " clone failed (no SSH keys for $GIT_REPO — skipping, user will rsync)"
fi
fi
# ─── 6. Persistent env for the user ──────────────────────────────────────────
cat > /etc/profile.d/wzp-android.sh <<ENVEOF
export ANDROID_HOME=$ANDROID_HOME
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
export NDK_HOME=\$ANDROID_NDK_HOME
export PATH=\$ANDROID_HOME/cmdline-tools/latest/bin:\$ANDROID_HOME/platform-tools:/root/.cargo/bin:\$PATH
ENVEOF
chmod 644 /etc/profile.d/wzp-android.sh
# ─── 7. Sanity summary ───────────────────────────────────────────────────────
log "Sanity checks:"
echo " java: $(java -version 2>&1 | head -1)"
echo " node: $(node --version)"
echo " npm: $(npm --version)"
echo " rustc: $(rustc --version)"
echo " cargo-ndk: $(cargo ndk --version 2>&1 | head -1)"
echo " cargo-tauri:$(cargo tauri --version 2>&1 | head -1)"
echo " NDK dir: $ANDROID_NDK_HOME"
echo " WZP dir: $WZP_DIR/warzonePhone"
notify "wzp prep-linux-mint DONE on $(hostname) — ready at /opt/wzp/warzonePhone"
log "All done."