feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture
Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
This commit is contained in:
@@ -581,6 +581,87 @@ static void test_vad_and_ptt_gate() {
|
||||
std::printf("test_vad_and_ptt_gate: done\n");
|
||||
}
|
||||
|
||||
// ── 5. Stereo mic capture (vc_set_capture_channels) ───────────────────────────
|
||||
// Verifies that the mic capture accumulator path handles stereo (channels=2) correctly:
|
||||
// the accumulator is sized to frame_samples_*capture_channels, on_capture forwards the
|
||||
// correct channel count, and the encoder receives real interleaved L/R PCM (not a mono
|
||||
// downmix). Mirrors test_loopback_stereo_capture but routes through the mic capture
|
||||
// accumulator (feed_capture_for_test with channels=2) instead of the loopback path.
|
||||
// This is the headless CI test for the iOS stereo built-in mic feature (Part D).
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
static void test_stereo_mic_capture() {
|
||||
voicecat::audio::AudioEngine engine;
|
||||
voicecat::audio::AudioParams p;
|
||||
p.sample_rate = 48000;
|
||||
p.capture_channels = 2; // stereo mic capture (vc_set_capture_channels path)
|
||||
p.playback_channels = 2; // stereo mix output (for mix_for_test below)
|
||||
p.frame_ms = 20;
|
||||
CHECK(engine.start(p)); // no capture_cb — the real mic (if any) won't touch capture_accum_
|
||||
|
||||
voicecat::codec::OpusParams stereo_params;
|
||||
stereo_params.stereo = true;
|
||||
stereo_params.application = voicecat::codec::OpusApplication::Voip; // mic stream
|
||||
stereo_params.bitrate_bps = 64000; // mic default
|
||||
int frame_samples = voicecat::codec::opus_frame_samples(stereo_params);
|
||||
|
||||
voicecat::codec::OpusEncoder enc;
|
||||
CHECK(enc.init(stereo_params));
|
||||
|
||||
// Loud left channel, silent right — a mono downmix would average them; true stereo
|
||||
// keeps them distinct (same signal as test_loopback_stereo_capture).
|
||||
std::vector<int16_t> interleaved(static_cast<size_t>(frame_samples) * 2);
|
||||
for (int i = 0; i < frame_samples; ++i) {
|
||||
float t = static_cast<float>(i) / 48000.0f;
|
||||
interleaved[i * 2] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
|
||||
interleaved[i * 2 + 1] = 0;
|
||||
}
|
||||
|
||||
// Encode via the mic capture accumulator path: feed_capture_for_test with channels=2
|
||||
// drives on_capture's accumulator and invokes the callback with channels=2. The callback
|
||||
// encodes exactly as on_capture_frame does for channels==2 — direct stereo, no upmix.
|
||||
uint8_t opus_buf[1500];
|
||||
int opus_len = 0;
|
||||
int seen_channels = 0;
|
||||
auto cb = [&](int /*kind*/, const int16_t* pcm, int /*samples*/, int channels) {
|
||||
seen_channels = channels;
|
||||
if (channels == 2) {
|
||||
// The capture accumulator must have preserved L/R distinctness pre-encode.
|
||||
int64_t pre_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
pre_diff += std::abs(static_cast<int>(pcm[i * 2]) - static_cast<int>(pcm[i * 2 + 1]));
|
||||
CHECK(pre_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
}
|
||||
opus_len = enc.encode(pcm, frame_samples, opus_buf, sizeof(opus_buf));
|
||||
};
|
||||
engine.feed_capture_for_test(interleaved.data(), frame_samples, 2, cb);
|
||||
CHECK(seen_channels == 2); // the mic capture path reported stereo, not mono
|
||||
CHECK(opus_len > 0);
|
||||
|
||||
// Decode + mix — same recv path as test_stereo_mix. A real stereo bitstream should
|
||||
// survive with L != R; a mono-downmixed-then-upmixed bitstream would have L == R.
|
||||
engine.init_recv_stream(/*ssrc=*/5, stereo_params);
|
||||
voicecat::audio::JitterBuffer::Frame f;
|
||||
f.seq = 0;
|
||||
f.timestamp = 0;
|
||||
f.fec_present = false;
|
||||
f.payload.assign(opus_buf, opus_buf + opus_len);
|
||||
engine.push_recv_frame(5, std::move(f));
|
||||
|
||||
std::vector<int16_t> out(static_cast<size_t>(frame_samples) * 2, 0);
|
||||
engine.mix_for_test(out.data(), static_cast<uint32_t>(frame_samples));
|
||||
|
||||
int64_t total_diff = 0;
|
||||
for (int i = 0; i < frame_samples; ++i)
|
||||
total_diff += std::abs(static_cast<int>(out[i * 2]) - static_cast<int>(out[i * 2 + 1]));
|
||||
CHECK(total_diff > static_cast<int64_t>(frame_samples) * 1000);
|
||||
|
||||
engine.remove_stream(5);
|
||||
engine.stop();
|
||||
std::printf("test_stereo_mic_capture: ok (total_diff=%lld, seen_channels=%d)\n",
|
||||
static_cast<long long>(total_diff), seen_channels);
|
||||
}
|
||||
#endif
|
||||
|
||||
int main() {
|
||||
test_device_enumeration();
|
||||
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
|
||||
@@ -588,6 +669,7 @@ int main() {
|
||||
#if defined(VOICECAT_HAS_LOOPBACK)
|
||||
test_loopback_stereo_capture();
|
||||
#endif
|
||||
test_stereo_mic_capture();
|
||||
test_playout_resync();
|
||||
#endif
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
|
||||
Reference in New Issue
Block a user