feat(ios): real echo cancellation/NR via native Voice-Processing engine

iOS "voice chat" had echo and no noise suppression: real iOS AEC/NS/AGC
come only from Apple's Voice-Processing I/O unit (VPIO), but the core
plays/captures via miniaudio's plain RemoteIO units, so .voiceChat mode
alone never engaged AEC.

Core (ABI PATCH 1->2):
- vc_set_mixed_output_sink + vc_set_external_playback. In external mode the
  AudioEngine opens no hardware playback device; a mixer-timer thread drives
  on_playback (decode+mix) on a ~20ms cadence and ships the final mix to the
  sink. start() also skips the hardware capture device when the MIC stream is
  external_feed (AudioParams.external_capture).
- New white-box test test_external_playback (drives the timer with no hw).

iOS/Swift:
- StreamDescriptor.externalFeed; VoiceCatClient.setMixedOutputSink /
  setExternalPlayback wrappers.
- IOSVoiceProcessingEngine: AVAudioEngine + setVoiceProcessingEnabled; mic
  tap -> feedPcm, mixed-sink lock-free ring -> AVAudioSourceNode (both share
  the VPIO unit so AEC has its reference signal).
- IOSAudioRouter.currentConfigUsesVoiceProcessing scopes VPIO to the AEC
  presets; SessionState join/leave + reconcileVoicePath() switch paths;
  Voice Chat defaults to speaker; Settings surfaces AEC/NS state.

Known: pending on-device verification; a few bugs to fix afterward.
This commit is contained in:
2026-06-22 02:38:01 +02:00
parent e806b698ec
commit 6c17881cc0
19 changed files with 755 additions and 4 deletions

View File

@@ -46,7 +46,7 @@ extern "C" {
/* ── Version ──────────────────────────────────────────────────────────────── */
#define VOICECAT_VERSION_MAJOR 0
#define VOICECAT_VERSION_MINOR 0
#define VOICECAT_VERSION_PATCH 1
#define VOICECAT_VERSION_PATCH 2 /* +vc_set_mixed_output_sink / vc_set_external_playback (iOS VPIO) */
/* The control-protocol version this build speaks (docs/protocol.md §4).
* v2 widened the UDP voice frame seq field u16 → u64 (docs/voice.md §2); a v2 server
@@ -460,6 +460,43 @@ typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id,
uint32_t channels, uint32_t sample_rate);
VC_API vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user);
/* ── External playback (iOS VPIO / echo cancellation) ───────────────────────────
* On iOS, real echo cancellation + noise suppression + AGC are provided ONLY by Apple's
* Voice-Processing I/O audio unit (VPIO), which the Swift AVAudioEngine layer owns. For VPIO
* to cancel echo, the remote-audio playback must go through the SAME VPIO unit as the mic
* capture (VPIO subtracts the played-back signal from the mic). So in that topology the core
* must NOT open/drive its own hardware playback device — its output would bypass VPIO, giving
* it no reference signal and producing echo. Instead, enable external playback: the core keeps
* decoding + mixing every remote stream on a steady ~20 ms cadence and delivers the FINAL
* MIXED PCM (post output-volume, all streams summed) to this sink, which the Swift layer
* renders through the VPIO output.
*
* cb(user, pcm, samples_per_channel, channels, sample_rate)
*
* pcm : final mixed int16 PCM, interleaved when channels == 2.
* samples_per_channel : samples per channel for this block (960 @ 20 ms / 48 kHz).
* channels : the engine's playback channel count (2 = stereo).
* sample_rate : always 48000.
*
* The callback fires on the core's mixer-timer thread (NOT a hardware audio thread). It fires
* steadily even with no remote streams (a silent block), so the renderer has a continuous
* clock. The callback MUST NOT block, lock, or allocate — copy into a lock-free ring and
* return. Independent of vc_set_pcm_sink (the per-stream tap), which still works. Pass cb=NULL
* to disable (default: disabled). */
typedef void (*vc_mixed_output_cb)(void* user, const int16_t* pcm,
size_t samples_per_channel, uint32_t channels,
uint32_t sample_rate);
VC_API vc_result vc_set_mixed_output_sink(vc_client* c, vc_mixed_output_cb cb, void* user);
/* Enable/disable external-playback mode (default: disabled = normal hardware playback). When
* enabled, the core does NOT open a hardware playback device; decode+mix runs on an internal
* ~20 ms timer and the result is delivered via vc_set_mixed_output_sink. To also bypass the
* hardware mic (feeding VPIO-processed mic PCM instead), start the MIC stream with
* vc_stream_desc.external_feed=1 and push frames via vc_stream_feed_pcm — the core then skips
* the hardware capture device too. Apply BEFORE the engine starts, or follow with
* vc_audio_restart() to apply to a running engine. `enable` is a bool (0/1). */
VC_API vc_result vc_set_external_playback(vc_client* c, int enable);
/* ── Text ─────────────────────────────────────────────────────────────────── */
VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target_id,
const char* utf8);

View File

@@ -216,6 +216,10 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
}
ma_context* ctx = context_inited_ ? &context_ : nullptr;
// External playback (iOS VPIO): skip the hardware playback device entirely — a timer
// thread drives the mixer and the final mix goes to the Swift VPIO renderer (launched
// after the capture block below).
if (!external_playback_) {
// ── Playback device (opened first on iOS: commits the output route — e.g. A2DP —
// before the capture device starts. Starting stereo capture can trigger an iOS audio
// route reconfiguration; opening playback first ensures A2DP is already committed
@@ -252,7 +256,11 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
}
}
}
} // end if (!external_playback_)
// External capture (iOS VPIO / external feed): skip the hardware mic device — PCM is fed
// via inject_capture / vc_stream_feed_pcm. capture_cb_ (set above) still fires for fed frames.
if (!params_.external_capture) {
// ── Capture device (opened after playback so the output route is already committed) ──
ma_device_id cap_id{};
bool have_cap_id = !p.capture_device_id.empty() &&
@@ -277,6 +285,17 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
ma_device_uninit(&capture_device_);
}
}
} // end if (!params_.external_capture)
// External playback: launch the mixer-timer thread now that the engine is configured. It
// drives on_playback() (decode + mix all remote streams) every frame_ms and ships the final
// mix to mixed_sink_ for the Swift VPIO renderer. No hardware playback device exists.
if (external_playback_) {
mixer_scratch_.assign(
static_cast<size_t>(frame_samples_) * params_.playback_channels, 0);
mixer_timer_stop_.store(false, std::memory_order_release);
mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); });
}
#endif // VOICECAT_HAS_AUDIO
return true;
@@ -322,6 +341,9 @@ void AudioEngine::stop() {
if (!running_.exchange(false)) return;
#ifdef VOICECAT_HAS_AUDIO
// External playback: stop + join the mixer-timer thread before tearing down state it reads.
mixer_timer_stop_.store(true, std::memory_order_release);
if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join();
if (capture_started_) {
ma_device_stop(&capture_device_);
ma_device_uninit(&capture_device_);
@@ -338,6 +360,12 @@ void AudioEngine::stop() {
bool AudioEngine::suspend() {
#ifdef VOICECAT_HAS_AUDIO
if (!running_.load(std::memory_order_acquire)) return true;
// External playback: pause the mixer timer (there is no playback device to stop). This
// matches the VPIO renderer going down during an AVAudioSession interruption.
if (external_playback_) {
mixer_timer_stop_.store(true, std::memory_order_release);
if (mixer_timer_thread_.joinable()) mixer_timer_thread_.join();
}
bool ok = true;
if (capture_started_) ok &= (ma_device_stop(&capture_device_) == MA_SUCCESS);
if (playback_started_) ok &= (ma_device_stop(&playback_device_) == MA_SUCCESS);
@@ -350,6 +378,11 @@ bool AudioEngine::suspend() {
bool AudioEngine::resume() {
#ifdef VOICECAT_HAS_AUDIO
if (!running_.load(std::memory_order_acquire)) return true;
// External playback: relaunch the mixer timer (mixer_scratch_ is still sized from start()).
if (external_playback_ && !mixer_timer_thread_.joinable()) {
mixer_timer_stop_.store(false, std::memory_order_release);
mixer_timer_thread_ = std::thread([this] { run_mixer_timer(); });
}
bool ok = true;
if (capture_started_) ok &= (ma_device_start(&capture_device_) == MA_SUCCESS);
if (playback_started_) ok &= (ma_device_start(&playback_device_) == MA_SUCCESS);
@@ -515,6 +548,11 @@ void AudioEngine::set_pcm_sink(PcmSink cb, void* user) {
pcm_sink_.store(cb, std::memory_order_release);
}
void AudioEngine::set_mixed_output_sink(MixedSink cb, void* user) {
mixed_sink_user_.store(user, std::memory_order_relaxed);
mixed_sink_.store(cb, std::memory_order_release);
}
#ifdef VOICECAT_HAS_AUDIO
void AudioEngine::capture_data_cb(ma_device* dev, void* /*out*/,
@@ -707,6 +745,32 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
#endif
}
// External-playback timer (iOS VPIO): with no hardware playback device to "pull" frames, this
// dedicated thread drives the mixer on a steady cadence. It is NOT a real-time audio thread (a
// plain timed worker, same class as vc_client::run_talk_timer), but it must still not allocate
// in the loop because on_playback() takes streams_mu_ via try_lock and runs the RT-safe decode
// path — so mixer_scratch_ is pre-sized in start(). Uses a deadline-based sleep to bound drift.
void AudioEngine::run_mixer_timer() {
using clock = std::chrono::steady_clock;
const uint32_t ch = params_.playback_channels;
const uint32_t spc = static_cast<uint32_t>(frame_samples_);
const auto period = std::chrono::milliseconds(params_.frame_ms);
auto next = clock::now();
while (!mixer_timer_stop_.load(std::memory_order_acquire)) {
on_playback(mixer_scratch_.data(), spc);
if (auto sink = mixed_sink_.load(std::memory_order_relaxed)) {
sink(mixed_sink_user_.load(std::memory_order_relaxed), mixer_scratch_.data(), spc, ch,
params_.sample_rate);
}
next += period;
// If we fell badly behind (e.g. the thread was descheduled), reset the deadline rather
// than spin to catch up — the VPIO renderer rides its own clock + jitter ring.
auto now = clock::now();
if (next < now) next = now + period;
std::this_thread::sleep_until(next);
}
}
#ifdef VOICECAT_HAS_LOOPBACK
void AudioEngine::loopback_data_cb(ma_device* dev, void* /*out*/, const void* in,
ma_uint32 frame_count) {

View File

@@ -21,6 +21,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include <thread>
#include <unordered_map>
#include <vector>
@@ -92,6 +93,10 @@ struct AudioParams {
uint32_t frame_ms = 20;
std::string capture_device_id; // "" = default; opaque id from AudioEngine::enumerate_devices
std::string playback_device_id; // "" = default; opaque id from AudioEngine::enumerate_devices
// External capture: the mic is fed via inject_capture/vc_stream_feed_pcm (e.g. iOS VPIO),
// so start() skips opening the hardware capture device. Derived from the MIC LocalStream's
// external_feed flag in vc_client::ensure_audio_running().
bool external_capture = false;
};
// One enumerated device, returned by AudioEngine::enumerate_devices(). `id` is an internal,
@@ -202,6 +207,18 @@ class AudioEngine {
using PcmSink = void(*)(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t);
void set_pcm_sink(PcmSink cb, void* user);
// External mixed-output sink (iOS VPIO). Receives the FINAL mixed PCM (post output-volume,
// all remote streams summed) on the mixer-timer thread when external playback is enabled.
// Matching signature to vc_mixed_output_cb (cast at the C-ABI boundary). Pass nullptr to
// disable. Thread-safe (atomic store; the timer read is relaxed-load).
using MixedSink = void(*)(void*, const int16_t*, size_t, uint32_t, uint32_t);
void set_mixed_output_sink(MixedSink cb, void* user);
// External-playback mode (iOS VPIO): when enabled, start() does NOT open a hardware
// playback device; a timer thread drives the mixer (on_playback) on a ~20 ms cadence and
// ships the final mix to the mixed-output sink. Set before start() (or apply via restart).
void set_external_playback(bool enable) { external_playback_ = enable; }
// External PCM feed overload: stereo-aware variant of inject_capture. samples_per_channel
// is samples per channel; total samples written = samples_per_channel * channels.
void inject_capture(int kind, const int16_t* pcm, size_t samples_per_channel, int channels);
@@ -336,6 +353,14 @@ class AudioEngine {
bool capture_started_ = false;
bool playback_started_ = false;
// External-playback mode (iOS VPIO): no hardware playback device; this timer thread drives
// on_playback() on a ~20 ms cadence and delivers the final mix to mixed_sink_. mixer_scratch_
// is pre-sized in start() (frame_samples_ * playback_channels) so the loop never allocates.
std::thread mixer_timer_thread_;
std::atomic<bool> mixer_timer_stop_{false};
std::vector<int16_t> mixer_scratch_;
void run_mixer_timer();
#ifdef VOICECAT_HAS_LOOPBACK
// Desktop-audio loopback capture (SCREEN_AUDIO) — own lifecycle, decoupled from
// capture_device_/playback_device_ start/stop (a screen-share can start/stop independently
@@ -491,6 +516,13 @@ class AudioEngine {
std::atomic<PcmSink> pcm_sink_{nullptr};
std::atomic<void*> pcm_sink_user_{nullptr};
// External mixed-output sink + mode flag (iOS VPIO). mixed_sink_ is written by
// set_mixed_output_sink (any thread); read by run_mixer_timer via relaxed load.
// external_playback_ is read in start() to gate hardware-playback-device creation.
std::atomic<MixedSink> mixed_sink_{nullptr};
std::atomic<void*> mixed_sink_user_{nullptr};
bool external_playback_ = false;
#ifdef VOICECAT_HAS_OPUS
::OpusDREDDecoder* dred_dec_ = nullptr; // shared DRED decoder; null if unsupported
#endif

View File

@@ -1086,8 +1086,14 @@ void vc_client::ensure_audio_running() {
if (it != local_streams_.end()) {
p.capture_device_id = it->second.capture_device_id;
p.capture_channels = it->second.capture_channels;
// iOS VPIO: when the mic is fed externally, skip the hardware capture device — the
// Swift AVAudioEngine VPIO path feeds processed mic PCM via vc_stream_feed_pcm.
p.external_capture = it->second.external_feed;
}
}
// iOS VPIO: skip the hardware playback device and drive the mixer on a timer, delivering the
// final mix to the mixed-output sink for the Swift VPIO renderer (vc_set_external_playback).
audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire));
audio_engine_.start(p, [this](int kind, const int16_t* pcm, int samples, int channels) {
on_capture_frame(kind, pcm, samples, channels);
});
@@ -1518,6 +1524,20 @@ vc_result vc_client::set_pcm_sink(vc_pcm_sink_cb cb, void* user) {
return VC_OK;
}
vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb cb, void* user) {
audio_engine_.set_mixed_output_sink(
reinterpret_cast<voicecat::audio::AudioEngine::MixedSink>(cb), user);
return VC_OK;
}
vc_result vc_client::set_external_playback(bool enable) {
external_playback_.store(enable, std::memory_order_release);
// Stored on the engine too; takes effect on the next start()/vc_audio_restart() (matching
// the vc_set_capture_channels "apply on next restart" contract).
audio_engine_.set_external_playback(enable);
return VC_OK;
}
vc_result vc_client::test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples) {
return stream_feed_pcm(stream_id, pcm, samples, 1);
}
@@ -1898,6 +1918,12 @@ vc_result vc_client::stream_feed_pcm(uint32_t, const int16_t*, size_t, uint32_t)
vc_result vc_client::set_pcm_sink(vc_pcm_sink_cb, void*) {
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::set_mixed_output_sink(vc_mixed_output_cb, void*) {
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::set_external_playback(bool) {
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) {
return VC_ERR_NOT_IMPLEMENTED;
}

View File

@@ -89,6 +89,10 @@ struct vc_client {
// External PCM sink (see voicecat.h: vc_set_pcm_sink). Delegates to AudioEngine.
vc_result set_pcm_sink(vc_pcm_sink_cb cb, void* user);
// External mixed-output sink + external-playback mode (iOS VPIO; see voicecat.h).
vc_result set_mixed_output_sink(vc_mixed_output_cb cb, void* user);
vc_result set_external_playback(bool enable);
// TEST-ONLY (see voicecat.h) — deprecated alias for stream_feed_pcm(..., channels=1).
vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples);
@@ -287,6 +291,11 @@ struct vc_client {
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
std::atomic<bool> ptt_active_{false};
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
// External-playback mode (iOS VPIO): when true, ensure_audio_running() configures the
// AudioEngine to skip its hardware playback device and drive the mixer on a timer instead,
// delivering the final mix to the mixed-output sink. Set via vc_set_external_playback.
std::atomic<bool> external_playback_{false};
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
// teardown_voice() is called both from run_io()'s own cleanup (on the io_thread_, when

View File

@@ -158,6 +158,16 @@ vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user) {
return c->set_pcm_sink(cb, user);
}
vc_result vc_set_mixed_output_sink(vc_client* c, vc_mixed_output_cb cb, void* user) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_mixed_output_sink(cb, user);
}
vc_result vc_set_external_playback(vc_client* c, int enable) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_external_playback(enable != 0);
}
vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_capture_channels(stream_id, channels);