diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 011ffc4..efc98c9 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -577,13 +577,16 @@ final class IOSAudioRouter: ObservableObject { captureChannels = channels savePreferences() applyConfiguration() - // Sync the core's capture channel count. The core's set_capture_channels handles - // the engine restart internally (stop + ensure_audio_running) — no need for the - // Swift layer to suspend/restart separately. + // Update the core's stored capture channel count (does not restart the engine). if let streamId = AudioSessionManager.shared.activeMicStreamId { _ = AudioSessionManager.shared.client?.setCaptureChannels( streamId: streamId, channels: channels.channelCount) } + // Restart the engine AFTER AVAudioSession routing has settled and the channel + // count is stored. The engine reopens playback first (committing the A2DP/output + // route), then capture — avoiding the race where stereo capture activation drops + // A2DP before the playback device has a chance to claim the route. + _ = AudioSessionManager.shared.client?.audioRestart() } // MARK: - Presets @@ -620,12 +623,14 @@ final class IOSAudioRouter: ObservableObject { UserDefaults.standard.set(preset.rawValue, forKey: kPreset) savePreferences() applyConfiguration() - // Sync the core's capture channel count. The core's set_capture_channels handles - // the engine restart internally. + // Update the core's stored capture channel count (does not restart the engine). if let streamId = AudioSessionManager.shared.activeMicStreamId { _ = AudioSessionManager.shared.client?.setCaptureChannels( streamId: streamId, channels: preset.captureChannels.channelCount) } + // Restart the engine AFTER AVAudioSession routing has settled and the channel + // count is stored. Playback opens first (commits A2DP route), then capture. + _ = AudioSessionManager.shared.client?.audioRestart() refreshRoutes() logger.info("applyPreset — \(preset.rawValue)") } diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift index f79c322..3423c97 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -209,9 +209,11 @@ final class SessionState { // applyPreset). Without this, switching stereo→mono leaves the LocalStream's // capture_channels field at 2 and the next engine start still opens stereo. AudioSessionManager.shared.activeMicStreamId = streamId - // Apply the user's capture channel selection (mono/stereo) from IOSAudioRouter. - // The core opens the capture device via miniaudio on the next engine start; - // vc_set_capture_channels tells it to open in stereo (2) or mono (1). + // Store the user's capture channel selection before the server acknowledges + // the stream. The engine hasn't started yet at this point (it starts when + // handle_stream_announce_result fires), so vc_set_capture_channels just + // stores the value — no restart. ensure_audio_running() picks it up when + // the stream is confirmed and opens the device with the right channel count. let channels = IOSAudioRouter.shared.captureChannels.channelCount if channels != 1 { client.setCaptureChannels(streamId: streamId, channels: channels) diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 83d454c..dda05a4 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -397,14 +397,15 @@ VC_API vc_result vc_test_inject_capture(vc_client* c, uint32_t stream_id, const size_t samples); /* Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved). - * Must be called after vc_stream_start; takes effect on the next AudioEngine restart (e.g. when - * joining voice, or immediately if the engine is already running — it stops and restarts the - * capture device with the new channel count). Defaults to 1 (mono). On iOS the Swift - * AVAudioSession routing layer enables stereo built-in mic capture by switching the built-in - * mic's data source to the .stereo polar pattern (setPreferredDataSource + - * setPreferredPolarPattern(.stereo) + setPreferredInput + setInputDataSource) and then calls - * this to tell the core to open the capture device in stereo. VC_ERR_INVALID_ARG if stream_id - * is unknown, the stream is not a MIC stream, or channels is not 1 or 2. */ + * Must be called after vc_stream_start. Stores the value; it takes effect on the next engine + * (re)start. Does NOT restart the engine itself — the caller must follow up with + * vc_audio_restart() after AVAudioSession routing has settled (iOS) or after any platform + * audio-session reconfiguration. On iOS the Swift AVAudioSession routing layer enables stereo + * built-in mic capture by switching the built-in mic's data source to the .stereo polar + * pattern (setPreferredDataSource + setPreferredPolarPattern(.stereo) + setPreferredInput + + * setInputDataSource), calls this to record the desired channel count, and then calls + * vc_audio_restart() so the core reopens capture and playback against the new route. + * VC_ERR_INVALID_ARG if stream_id is unknown or channels is not 1 or 2. */ VC_API vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels); /* ── Text ─────────────────────────────────────────────────────────────────── */ diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp index e18b9e8..a6c73c7 100644 --- a/core/src/audio/audio_engine.cpp +++ b/core/src/audio/audio_engine.cpp @@ -163,32 +163,10 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) { #endif #ifdef VOICECAT_HAS_AUDIO - // ── Capture device ────────────────────────────────────────────────────── - ma_device_id cap_id{}; - bool have_cap_id = !p.capture_device_id.empty() && - hex_decode_device_id(p.capture_device_id, &cap_id); - - ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture); - cap_cfg.capture.format = ma_format_s16; - cap_cfg.capture.channels = p.capture_channels; - cap_cfg.sampleRate = p.sample_rate; - cap_cfg.dataCallback = capture_data_cb; - cap_cfg.pUserData = this; - cap_cfg.capture.pDeviceID = have_cap_id ? &cap_id : nullptr; // null = default device - // Hint: request the encoder's frame size as the callback period. WASAPI shared mode may - // not honor this (the hardware period is fixed), but when it is honored the accumulator - // below becomes a zero-copy passthrough rather than a copy every two callbacks. - cap_cfg.periodSizeInFrames = static_cast(frame_samples_); - - if (ma_device_init(nullptr, &cap_cfg, &capture_device_) == MA_SUCCESS) { - if (ma_device_start(&capture_device_) == MA_SUCCESS) { - capture_started_ = true; - } else { - ma_device_uninit(&capture_device_); - } - } - - // ── Playback device ───────────────────────────────────────────────────── + // ── 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 + // and is less likely to be dropped when the capture AudioUnit activates.) ────────── ma_device_id pb_id{}; bool have_pb_id = !p.playback_device_id.empty() && hex_decode_device_id(p.playback_device_id, &pb_id); @@ -221,6 +199,31 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) { } } } + + // ── 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() && + hex_decode_device_id(p.capture_device_id, &cap_id); + + ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture); + cap_cfg.capture.format = ma_format_s16; + cap_cfg.capture.channels = p.capture_channels; + cap_cfg.sampleRate = p.sample_rate; + cap_cfg.dataCallback = capture_data_cb; + cap_cfg.pUserData = this; + cap_cfg.capture.pDeviceID = have_cap_id ? &cap_id : nullptr; // null = default device + // Hint: request the encoder's frame size as the callback period. WASAPI shared mode may + // not honor this (the hardware period is fixed), but when it is honored the accumulator + // below becomes a zero-copy passthrough rather than a copy every two callbacks. + cap_cfg.periodSizeInFrames = static_cast(frame_samples_); + + if (ma_device_init(nullptr, &cap_cfg, &capture_device_) == MA_SUCCESS) { + if (ma_device_start(&capture_device_) == MA_SUCCESS) { + capture_started_ = true; + } else { + ma_device_uninit(&capture_device_); + } + } #endif // VOICECAT_HAS_AUDIO return true; diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index 83b9edc..707ac51 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -1320,24 +1320,15 @@ vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id) vc_result vc_client::set_capture_channels(uint32_t stream_id, uint32_t channels) { if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG; - int kind = -1; - { - std::lock_guard lk(local_streams_mu_); - LocalStream* ls = find_local_stream_by_id(stream_id); - if (!ls) return VC_ERR_INVALID_ARG; - ls->capture_channels = channels; - for (auto& [k, entry] : local_streams_) { - if (&entry == ls) { kind = k; break; } - } - } - // Only the real capture device (MIC) is affected by channel count — SCREEN_AUDIO uses - // loopback capture (channel count driven by the channel's stereo mode, not this setter) - // and AUX_DEVICE isn't backed by a real device path yet. Restart the engine so the capture - // device re-opens with the new channel count. - if (kind == static_cast(VC_STREAM_MIC) && audio_engine_.running()) { - audio_engine_.stop(); - ensure_audio_running(); - } + std::lock_guard lk(local_streams_mu_); + LocalStream* ls = find_local_stream_by_id(stream_id); + if (!ls) return VC_ERR_INVALID_ARG; + ls->capture_channels = channels; + // Engine restart is the caller's responsibility (via vc_audio_restart), issued AFTER + // AVAudioSession routing has settled. The stored channel count is picked up by + // ensure_audio_running() on the next (re)start. On iOS this avoids the race where + // starting the stereo capture AudioUnit immediately (before the playback device is + // committed to its route) collapses the A2DP output. return VC_OK; }