Voice calls degraded progressively from 60ms to 130ms per-frame arrival
rate over 20 seconds, even on fast 1-hop local WiFi links. Root cause:
each RNS.Packet.send() holds the Python GIL for encryption (AES-256-CBC
+ HMAC-SHA256) and transport dispatch, creating a feedback loop where
both devices' TX/RX paths compete for GIL time.
Batch 3 audio frames per RNS.Packet.send() call, reducing crypto
overhead from ~16.7 calls/sec to ~5.6 calls/sec (67% reduction).
The LXST wire format already supports frame lists ({0x01: [f1, f2, f3]})
and the receiver already handles both single frames and lists.
Results: packet arrival rate stable at ~60ms/frame for 40+ seconds,
zero silence callbacks, zero PLC, buffer steady at 6-9 frames.
Also updates LXST-kt submodule with adaptive playout drain for ring
buffer latency bounding during packet bursts and speaker toggles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
__link_closed() was calling Kotlin callbacks (signal + onCallEnded)
while holding _call_handler_lock. The signal triggered Kotlin hangup()
synchronously, running NativePlaybackEngine.destroy() which blocks on
Oboe stream close — keeping the Python lock held for the entire
duration and preventing subsequent call() from acquiring it.
Fix: move Kotlin callbacks outside the lock (same pattern as hangup())
and dispatch hangup() async in Kotlin's STATUS_AVAILABLE handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Mutex to serialize mute IPC calls in CallViewModel, preventing
race between PTT button release (setCallMuted(true)) and PTT mode
toggle (setCallMuted(false)) on Dispatchers.IO thread pool
- Move link.teardown() outside _call_handler_lock in Python
call_manager.hangup() to prevent deadlock: teardown() can trigger
__link_closed() synchronously on the same thread, which tries to
re-acquire the non-reentrant threading.Lock, blocking all subsequent
call() invocations forever
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a threading.Event that hangup() sets and the identity/path
discovery loops check, so a cancelled outgoing call exits promptly
instead of blocking the thread for up to 75 seconds.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Logs identity recall, destination hash, and has_path status so
call setup issues are visible without code changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Announce lxst.telephony destination alongside LXMF announces so
remote peers discover both messaging and call paths together
- Add periodic re-announcing in CallManager (3-hour interval,
matching reference LXST Telephony implementation)
- Auto-request identity path from network when unknown, with 5s
retry loop (mirrors existing LXMF pattern in reticulum_wrapper)
- Move setupLxstCallManager() to first position in setupBridges()
so Telephone is available immediately after Python init (was last,
causing up to ~47s delay before calls could be placed)
- Remove stale "call.audio" announce handler that could never match
the actual "lxst.telephony" aspect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The call() method checked and set self.active_call without holding
_call_handler_lock. During the up-to-70s path discovery window, an
incoming call could set active_call via __caller_identified, which
call() would then overwrite — orphaning the incoming link.
Fix: set _busy=True under lock before path discovery so incoming
calls see the reservation and get rejected. Clear _busy in finally.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents orphaned Reticulum links when call() is invoked while an
existing call is active or connecting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use local reference capture instead of locks for receive_audio_packet()
and receive_signal() to prevent race with hangup()/__link_closed()
nulling self.active_call. Lock-free approach avoids audio jitter from
contention on the 25-50Hz packet path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _call_handler_lock to answer(), hangup(), and __link_closed()
to prevent race conditions on self.active_call (Sentry HIGH)
- Remove invalid `=*` gitignore pattern (Greptile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 40 new tests across 12 test classes covering previously untested
code paths: incoming/outgoing link handling, packet reception, signal
routing, audio forwarding, and Kotlin callback notification. Add
pragma: no cover to RNS fallback stub (dead code in test context).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove vestigial Python LXST artifacts that are no longer needed
now that audio, codecs, and filters are handled by Kotlin LXST-kt:
- Remove numpy pip dependency (was for Python audio float32 conversion)
- Remove pycodec2 pip install and pre-built wheels (Kotlin JNI handles codecs)
- Delete chaquopy_audio_backend.py (617 lines, replaced by AudioDevice.kt)
- Delete lxst and lxst-filterlib wheels (never installed)
- Remove dead set_audio_bridge() plumbing from wrapper and manager
- Update call_manager.py to use renamed onInboundPacket/onInboundSignal
- Update LXST-kt submodule with transport-agnostic core/ renames
Remaining Python: call_manager.py (Reticulum bridge), rns, lxmf, u-msgpack.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Python's _notify_kotlin callback (via Chaquopy function reference) silently
fails to reach Telephone.onIncomingCall(), leaving isIncomingCall=false so
answer() always rejected. Fix with three changes:
- Add Telephone.prepareForAnswer() for lightweight JIT state setup
- Binder answerCall() falls back to CallBridge identity when answer() fails
- VoiceCallScreen auto-answer no longer gates on callState, preventing
notification answer button from starting a new outgoing call
Also: answer() now returns Boolean, onIncomingCall() no longer sends
duplicate STATUS_RINGING (Python already sent it to remote).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add set_kotlin_audio_active() method to CallManager
- Delegates to chaquopy_audio_backend.set_kotlin_audio_active()
- Provides Kotlin-accessible method to control Python audio disable
- Logs state change for debugging
- Add global _kotlin_audio_active flag (default False)
- Add set_kotlin_audio_active() and is_kotlin_audio_active() functions
- ChaquopyRecorder._record_chunk() returns silence when flag True
- ChaquopyPlayer.play() drops frames when flag True
- Prevents dual audio pipeline conflict during Kotlin calls
- Add STATUS_* signalling constants (0x00-0x06) to CallManager
- Send INTEGER signals via send_signal() in all LXST callbacks:
- _handle_ringing() -> STATUS_RINGING (0x04)
- _handle_established() -> STATUS_CONNECTING (0x05) + STATUS_ESTABLISHED (0x06)
- _handle_ended() -> STATUS_AVAILABLE (0x03)
- _handle_busy() -> STATUS_BUSY (0x00)
- _handle_rejected() -> STATUS_REJECTED (0x01)
- Preserve existing _notify_kotlin() string events for UI
This fixes the signal type mismatch (Gap 1 from 11-VERIFICATION.md)
where Kotlin Telephone expected INTEGER signals but Python only sent
STRING events. Kotlin Telephone can now transition to ESTABLISHED state
and open audio pipelines.
- Add on_state_changed() for Kotlin state notifications
- Add on_profile_changed() for profile sync
- Add set_kotlin_telephone_callback() for callback registration
- Add _notify_kotlin() for Python->Kotlin event notifications
- Update receive_audio_packet() to forward to LXST Packetizer
- Update receive_signal() to forward to LXST signalling
- Wire existing handlers to notify Kotlin Telephone
- Remove redundant path request code from call_manager.py - LXST's
Telephony.call() already handles path discovery correctly
- Move AIDL call operations to IO dispatcher to prevent ANR when
LXST blocks for path discovery (up to 70s)
- Add retry mechanism in CallViewModel for CallManager initialization
(retries up to 10x with 1s delay after fresh install)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
LXST's Mixer.unmute() has a bug where unmute(True) sets self.muted=True
instead of self.muted=False (inverted logic). This caused the unmute
button to not work - calling unmute_transmit() actually kept audio muted.
Changes:
- call_manager.py: Use telephone.mute_transmit(muted) with explicit
True/False instead of separate mute_transmit()/unmute_transmit() calls
- KotlinAudioBridge: Remove software mute (LXST handles at codec level)
- ReticulumServiceBinder: Improve logging for mute state changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Complete voice call implementation for Android with cross-process support:
- Add call AIDL interface (IReticulumService/IReticulumServiceCallback)
- Implement ServiceReticulumProtocol for call IPC to :reticulum process
- Add ReticulumServiceBinder call methods bridging to Python CallManager
- Wire KotlinAudioBridge to Chaquopy audio backend for audio streaming
- Add incoming call notifications with IPC broadcast to UI process
- Fix microphone recording by adding FOREGROUND_SERVICE_MICROPHONE
- Support mute/unmute via software mute in audio bridge
- Add libopus native dependency for Opus codec support
Audio routing now works correctly between devices with earpiece default.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>