/* * test_external_playback — verifies AudioEngine's external-playback mode (iOS VPIO path). * * When external playback is enabled, the engine opens NO hardware playback device; a mixer-timer * thread drives decode+mix on a ~20ms cadence and delivers the FINAL mixed PCM to the * mixed-output sink (the Swift AVAudioEngine VPIO renderer consumes this). This test asserts: * 1. The mixed sink fires steadily on the timer thread (count grows over time) with the right * format (48kHz, stereo), and carries real energy while a stream is being decoded. * 2. The per-stream pcm_sink still fires concurrently (both taps coexist). * 3. With no remote streams, the mixed sink KEEPS firing (silent-but-present blocks) so the * renderer has a continuous clock. * * White-box: constructs AudioEngine directly (no server, no audio hardware needed) — the timer * thread drives the mixer with no ma_device, which is the core new behavior under test. */ #include #include #include #include #include #include #if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS) #include "audio/audio_engine.h" #include "codec/opus_codec.h" static int g_failures = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \ ++g_failures; \ } \ } while (0) // Shared state written by the sink callbacks (timer thread) and read by main. struct MixedSinkState { std::atomic calls{0}; std::atomic max_energy{0}; std::atomic last_channels{0}; std::atomic last_sample_rate{0}; }; static MixedSinkState g_mixed; static std::atomic g_pcm_sink_calls{0}; static void mixed_cb(void* user, const int16_t* pcm, size_t spc, uint32_t ch, uint32_t sr) { auto* s = static_cast(user); s->calls.fetch_add(1, std::memory_order_relaxed); s->last_channels.store(ch, std::memory_order_relaxed); s->last_sample_rate.store(sr, std::memory_order_relaxed); int64_t e = 0; for (size_t i = 0; i < spc * ch; ++i) e += std::abs(static_cast(pcm[i])); int64_t prev = s->max_energy.load(std::memory_order_relaxed); while (e > prev && !s->max_energy.compare_exchange_weak(prev, e, std::memory_order_relaxed)) { } } static void pcm_cb(void*, uint32_t, uint32_t, const int16_t*, size_t, uint32_t, uint32_t) { g_pcm_sink_calls.fetch_add(1, std::memory_order_relaxed); } int main() { voicecat::audio::AudioEngine engine; engine.set_external_playback(true); engine.set_mixed_output_sink(&mixed_cb, &g_mixed); engine.set_pcm_sink(&pcm_cb, nullptr); voicecat::audio::AudioParams p; p.sample_rate = 48000; p.capture_channels = 1; p.playback_channels = 2; p.frame_ms = 20; CHECK(engine.start(p)); // no hardware device opened — the timer thread drives the mixer voicecat::codec::OpusParams op; op.sample_rate = 48000; op.frame_ms = 20; op.stereo = false; int frame_samples = voicecat::codec::opus_frame_samples(op); // 960 voicecat::codec::OpusEncoder enc; CHECK(enc.init(op)); std::vector sine(static_cast(frame_samples)); for (int i = 0; i < frame_samples; ++i) { float t = static_cast(i) / 48000.0f; sine[i] = static_cast(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f); } uint8_t opus_buf[1500]; int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf)); CHECK(opus_len > 0); const uint32_t ssrc = 1; engine.init_recv_stream(ssrc, op, /*user_id=*/7, /*stream_id=*/3); // ── Phase 1: feed ~600ms of real frames; the timer must decode + mix them. ────────── uint32_t ts = 0; for (int i = 0; i < 30; ++i) { // 30 * 20ms = 600ms of audio voicecat::audio::JitterBuffer::Frame f; f.seq = static_cast(i); f.timestamp = ts; f.fec_present = false; f.payload.assign(opus_buf, opus_buf + opus_len); engine.push_recv_frame(ssrc, std::move(f)); ts += static_cast(frame_samples); } std::this_thread::sleep_for(std::chrono::milliseconds(500)); int active_calls = g_mixed.calls.load(std::memory_order_relaxed); std::printf("external_playback: phase1 mixed-sink calls=%d max_energy=%lld pcm_sink=%d\n", active_calls, static_cast(g_mixed.max_energy.load()), g_pcm_sink_calls.load()); // ~25 blocks expected at a 20ms cadence over 500ms; allow generous slack for scheduler/debug. CHECK(active_calls >= 10); CHECK(g_mixed.max_energy.load(std::memory_order_relaxed) > 0); // real decoded audio in the mix CHECK(g_mixed.last_channels.load(std::memory_order_relaxed) == 2); CHECK(g_mixed.last_sample_rate.load(std::memory_order_relaxed) == 48000); CHECK(g_pcm_sink_calls.load(std::memory_order_relaxed) > 0); // per-stream tap coexists // ── Phase 2: remove the stream; the mixed sink must KEEP firing (silent blocks). ───── engine.remove_stream(ssrc); int before = g_mixed.calls.load(std::memory_order_relaxed); std::this_thread::sleep_for(std::chrono::milliseconds(200)); int after = g_mixed.calls.load(std::memory_order_relaxed); std::printf("external_playback: phase2 silent blocks delivered=%d\n", after - before); CHECK(after - before >= 5); // continuous clock even with nothing to play engine.stop(); // joins the mixer-timer thread enc.destroy(); if (g_failures == 0) { std::printf("external_playback: all checks passed\n"); return 0; } std::printf("external_playback: %d failure(s)\n", g_failures); return 1; } #else int main() { std::printf("external_playback: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n"); return 0; } #endif