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>
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>
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>
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>
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>
Both Android and Linux build scripts now send ntfy notification
when build fails, not just on success.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same Docker image as Android build. Separate cache dirs (cache-linux/)
to avoid conflicts when running both builds simultaneously.
Builds: wzp-relay, wzp-client, wzp-client-audio, wzp-web, wzp-bench
Uploads tar.gz to rustypaste, notifies ntfy.sh/wzp.
Usage:
./scripts/build-linux-docker.sh --pull # fire and forget
./scripts/build-linux-docker.sh --pull --install # wait + download
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay identity:
- Stored in ~/.wzp/relay-identity (hex-encoded 32-byte seed)
- Generated on first run, reused on restart
- Fingerprint stays consistent across relay restarts
Linux build script (scripts/build-linux-notify.sh):
- Fire and forget: Hetzner VM → build all binaries → upload to rustypaste → ntfy notify → destroy VM
- Builds: wzp-relay, wzp-client, wzp-client-audio, wzp-web, wzp-bench
- Packages as tar.gz, uploads to rustypaste
- --keep flag to preserve VM
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay recognizes SNI "ping" and returns immediately — no handshake,
no stream accept, no timeout error logs. Client closes after QUIC
connect for RTT measurement.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ping was a static JNI method that loaded the .so before nativeInit,
crashing jemalloc. Now ping is an instance method on WzpEngine:
- Engine is created once (nativeInit), reused for both ping and call
- pingRelay() uses same tokio runtime pattern as startCall()
- Auto-pings all servers on app launch (after engine init)
- No process restart needed
- TOFU fingerprints saved on first successful ping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Ping button: pings all servers via native QUIC, saves RTT + fingerprint
to SharedPreferences, then exits process (System.exit)
- On restart: loads saved ping results (no native .so loading needed)
- Avoids jemalloc crash: native lib only loaded once per process lifetime
- Removed broken UDP probe (QUIC servers don't respond to it)
- SettingsRepository: savePingRtt/loadPingRtt for cached results
- PingResult: added reachable field
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioPipeline.debugRecording defaults to false (was true)
- SettingsRepository: persist debug_recording preference
- CallViewModel: debugRecording StateFlow + setter, wired to AudioPipeline
- Only records PCM + RMS when explicitly enabled in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace WzpEngine.pingRelay() (JNI, loads native .so, crashes jemalloc
on Android 16 MTE) with pure Kotlin DatagramSocket UDP probe.
- RelayPinger: sends QUIC Version Negotiation trigger packet, measures
RTT from response. No native lib, no JNI, zero crash risk.
- Periodic: pings all servers every 5 seconds via coroutine
- Server fingerprint: filled lazily on first real QUIC connection
(TOFU still works, just delayed)
- Lock status: OFFLINE when ping fails, NEW until first connection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Backport from desktop client to Android:
Identicons:
- New Identicon.kt composable: deterministic 5x5 symmetric Canvas pattern
from fingerprint hash (same algorithm as desktop identicon.ts)
- Participant list shows identicon + name + tappable fingerprint
- Settings page shows identicon next to fingerprint
CopyableFingerprint:
- Tap any fingerprint text to copy to clipboard with Toast feedback
- Used in participant list and settings page
Recent rooms:
- SettingsRepository: persists last 5 (relay, room) pairs
- CallViewModel: saves on startCall, exposes as StateFlow
- InCallScreen: clickable chips that fill room + select matching server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous attempt allocated DirectByteBuffer as local variables inside
runCapture/runPlayout. ART's JIT On-Stack Replacement nulled them
when recompiling the hot loop mid-execution.
Fix: allocate as class fields on AudioPipeline (captureDirectBuf,
playoutDirectBuf). Object fields live on the heap, immune to OSR
stack frame replacement.
Eliminates JNI array copies (GetShortArrayRegion/SetShortArrayRegion)
from the audio hot path, preventing ART GC SIGBUS crashes on
Android 16 with concurrent mark-compact GC.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
The jni crate emits VERBOSE logs for every JNI method lookup (~10 lines
per call, 100+ calls/sec on audio threads). This floods logcat, consumes
CPU, and triggers system kills. Filter to only show INFO+ for our crates
and WARN+ for everything else.
Also fix build script: clean full Rust target to ensure libc++_shared.so
is always copied by cargo-ndk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DirectByteBuffer.clear() crashes with null pointer in ART's JIT OSR
compiled code on Android 16. Revert AudioPipeline to use the original
ShortArray writeAudio/readAudio path.
The DirectByteBuffer JNI functions remain in WzpEngine.kt and
jni_bridge.rs for future use once the OSR issue is resolved.
The original SIGBUS from ART GC is rare (~1 crash per 8 min call)
and doesn't warrant the DirectByteBuffer approach until we can
allocate the buffer as a class field outside the hot loop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter hcloud by SERVER_NAME to avoid touching other servers
- Use rsync instead of tar (handles submodules, no macOS xattr spam)
- Default server type cx33
- Release APK failure is non-fatal (debug APK still produced)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Adds nativeWriteAudioDirect / nativeReadAudioDirect JNI functions
that accept a DirectByteBuffer instead of ShortArray. The buffer's
native memory is accessed directly by Rust via pointer — no
GetShortArrayRegion / SetShortArrayRegion, no GC-managed array
copies on the audio hot path.
This fixes SIGBUS crashes on Android 16 where ART's concurrent
mark-compact GC crashes when flipping thread roots during JNI
array operations on MAX_PRIORITY audio threads.
Old ShortArray methods kept for backward compatibility.
AudioPipeline switched to use Direct variants.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Android 16's concurrent mark-compact GC crashes when flipping
thread roots on our MAX_PRIORITY audio threads during JNI calls
(AudioRecord.read / AudioTrack.write). Not our code — all crash
frames are in libart.so.
Proposed fixes:
- Short term: DirectByteBuffer to reduce JNI transitions
- Long term: Oboe native audio from Rust (no JNI, no GC)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cmake 3.28 works when ANDROID_NDK is set (not just ANDROID_NDK_HOME).
Relaxed version check from <=3.26 to <=3.30.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents WHY each version is pinned:
- cmake 3.25: 3.27+ rewrote Android-Determine.cmake with bugs
- NDK 26.1: NDK 27 scudo crashes on MTE devices (Nothing A059)
- JDK 17: Gradle 8.5 + AGP 8.2.0 official support
- ANDROID_NDK: cmake checks this, not ANDROID_NDK_HOME
Idempotent, works from clone or existing tree.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioRing: use vec![].into_boxed_slice() instead of Box::new([]) to
avoid 32KB stack allocation that crashes scudo on Android
- JNI bridge: wrap tracing_subscriber init in catch_unwind to survive
sharded_slab allocation failures on some devices
- Engine: per-step encode profiling (avg_agc_us, avg_opus_us, avg_fec_us,
avg_send_us) logged every 5 seconds in send stats
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds average microsecond timings for each encode step:
- avg_agc_us: AGC processing
- avg_opus_us: Opus encoding
- avg_fec_us: FEC encode + repair generation
- avg_send_us: QUIC send_media
- avg_total_us: sum of above
Logged every 5 seconds in send stats. Resets each interval.
Use to identify which step is bottlenecking the encode loop
on devices where fps drops below 50.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
SIGSEGV on hangup: capture thread calls writeAudio() via JNI after
teardown() has freed the native engine handle. TOCTOU race between
the nativeHandle==0L check and destroy() on the ViewModel thread.
Fix: CountDownLatch(2) — audio threads count down after exiting loops,
teardown() awaits before destroy(). 2 Kotlin files, no Rust changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete spec for fixing the playout ring buffer cursor race that
causes 12-16s bidirectional silence mid-call. Includes exact code,
memory ordering rationale, unit tests, and verification steps.
Any agent can implement from this document alone.
See also: debug/INCIDENT-2026-04-06-playout-ring-desync.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
- 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>
- 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>
- 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>