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.
This commit is contained in:
Siavash Sameni
2026-04-09 21:24:26 +04:00
parent 96be740fd9
commit cfa9ff67cf
3 changed files with 124 additions and 15 deletions

View File

@@ -210,10 +210,13 @@ public:
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
calls++;
if ((calls % 50) == 0) {
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu",
int state = (int)stream->getState();
int xruns = stream->getXRunCount().value_or(-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);
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
state, xruns);
}
// Update latency estimate
@@ -273,26 +276,30 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
(int)g_capture_stream->getSharingMode(),
(int)g_capture_stream->getPerformanceMode());
// Build playout stream
// Build playout stream.
//
// Usage::Media (NOT VoiceCommunication) routes to the media audio
// stream which plays through the loud speaker and uses the media
// volume slider. VoiceCommunication routes to the in-call earpiece
// stream which is silent unless AudioManager.setMode(IN_COMMUNICATION)
// has been called from the Activity, and even then only the earpiece
// (or a bluetooth headset) gets audio by default. For a debug-friendly
// smoke test we want loud speaker by default. A future polish step
// will wire setMode + setSpeakerphoneOn from MainActivity.kt so we
// can switch back to VoiceCommunication (for AEC benefits etc).
// Usage::Media was a failed experiment — diagnosis from build 96be740
// showed the whole pipeline is healthy (capture → encode → network →
// decode → playout ring → C++ callback reads 960 samples every 20ms
// with real audio content) but nothing was audible. This means Oboe
// received the PCM and routed it to a silent output. Usage::Media
// alone is not enough — the AudioManager must also be switched to
// MODE_IN_COMMUNICATION and speakerphone explicitly turned on from
// the Activity side, which MainActivity.kt now does on startup.
//
// Reverting to Usage::VoiceCommunication + ContentType::Speech +
// explicit AAudio API (more reliable routing than OpenSLES default)
// on top of the Kotlin-side setMode/setSpeakerphoneOn changes.
oboe::AudioStreamBuilder playoutBuilder;
playoutBuilder.setDirection(oboe::Direction::Output)
->setAudioApi(oboe::AudioApi::AAudio)
->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::Media)
->setUsage(oboe::Usage::VoiceCommunication)
->setContentType(oboe::ContentType::Speech)
->setDataCallback(&g_playout_cb);