Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which achieves stereo mic + A2DP output. Five fixes: 1. configureStereoCapture now calls setPreferredInput + setInputDataSource (mirroring TeamTalk5's SoundDevicesModel). Previously omitted based on incorrect diagnosis that setPreferredInput collapsed A2DP — the real culprit was setPreferredInputNumberOfChannels(2), which neither project uses. 2. New C ABI: vc_audio_restart (full stop + re-init, unlike suspend/resume which only stop/start). Swift wrapper added. The withAudioSuspend wrapper that used it was removed after on-device testing showed it killed all audio (including VoiceOver) when switching presets — the core's set_capture_channels handles engine restart internally. 3. Bluetooth options: Voice Chat preset now includes BOTH .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's UtilSound.swift:228). Previously HFP-only blocked A2DP headphones. 4. Capture channels now reset when switching stereo→mono via selectCaptureChannels/applyPreset. AudioSessionManager tracks activeMicStreamId (set by SessionState on join/leave voice). 5. Docs synced: voice.md, tech-stack.md, architecture.md, PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2) references. Verified: ctest --preset dev 21/21 green, iOS client builds. Stereo mic + A2DP output still needs on-device debugging — the core recipe is correct but iOS 26 route behavior requires hands-on testing with a debugger.
106 KiB
PROGRESS — VoiceCat
Living status. Update this file in the same commit as your work so the next agent picks up instantly. Newest status at the top.
- Date convention: ISO (YYYY-MM-DD).
- Statuses:
[ ]not started ·[~]in progress ·[x]done.
▶ Where we left off / next action
-
Planned (not started): External PCM feed/tap API (
vc_stream_feed_pcm+vc_set_pcm_sink) (2026-06-19, plan written on Windows; implement on Mac). A public, documented API for driving audio streams with externally-provided PCM instead of (or in addition to) miniaudio's hardware device. Motivated by four concrete use cases — all in our roadmap — that the current "miniaudio owns the device" model can't serve:- ReplayKit Broadcast Upload Extension (iOS
SCREEN_AUDIO) — the extension is a separate process with a ~50 MB memory cap and can't link the fullAudioEngine(ma_device, capture/playback threads). It needs to feedCMSampleBufferaudio (system app audio) into the encode path without any audio hardware. The current plan indocs/voice.md §9says the extension links "a minimal slice of the core (Opus encode + media send only)" — a public feed-PCM API is that minimal slice. The extension links Opus + the feed entry point, noma_deviceneeded. - ScreenCaptureKit (macOS
SCREEN_AUDIO) —SCStreamdeliversCMSampleBufferin a callback; convert to int16 and feed. No need to route through miniaudio's device layer. This is how macOS screen-audio actually gets implemented — today it does NOT work:VOICECAT_HAS_LOOPBACKis Windows-only (core/CMakeLists.txt:88-95), so on macOSAudioEngine::start_loopback_capture()hits the#elsestub (audio_engine.cpp:647-649) and returnsfalse. The macOS client's "Share Screen Audio" button (MainWindowController.swift:800-816) callsstartStream(.screenAudio)which announces the stream to peers but captures zero audio — peers hear silence. The button is left in place (not touched per user request); it'll work once this API + a ScreenCaptureKit tap ship on Mac. - Bots — music bot, TTS bot, radio relay, transcription bot. They create a
SCREEN_AUDIO/AUX_DEVICEstream and feed synthesized or decoded PCM via the feed API. No audio hardware required — runs headless on a server. Today the only way to feed external PCM isvc_test_inject_capture(TEST-ONLY, name signals "don't ship this") or re-implementing Opus encode + AEAD + UDP framing yourself (~500 lines of duplicated crypto/codec code per consumer). - Custom clients / accessibility — soundboard, DAW integration, TTS of incoming chat, recording/transcription of remote audio. Need either feed (send) or tap (receive) or both.
What we already have (input half, gated as test-only):
vc_test_inject_capture (stream_id, pcm, samples)(voicecat.h,client.cpp:1452) feeds raw int16 PCM into the encode pipeline viaAudioEngine::inject_capture(kind, pcm, n). It works for any stream kind, supports multiple concurrent injection taps (one ring buffer per local kind), and goes through the full encode → AEAD → UDP path. The encode path already handleschannels == 1 || 2(proven by the WASAPI stereo loopback work, 2026-06-17 entry below). The only problems: it's marked TEST-ONLY in the header, the name signals "don't use this in production," and it hardcodes mono (nochannelsparameter).What's missing (output half): today decoded remote audio is mixed and pushed to the miniaudio playback device (
on_playback). There's no way for an external consumer to intercept the decoded PCM of a specific remote stream — it all goes to the hardware device. A bot that wants to record, transcribe, or re-broadcast remote audio has no hook.Plan (API design — clean, append-only, no struct changes, ABI-stable):
vc_stream_feed_pcm— promotevc_test_inject_captureto a public, documented API and add achannelsparameter:/* External PCM feed — replaces the hardware capture device for this stream. Caller provides interleaved int16 PCM at the stream's sample rate. The core frames it, encodes (Opus), seals (AEAD), and sends (UDP). Works for any stream kind (MIC/SCREEN_AUDIO/AUX_DEVICE). The stream must be started first (vc_stream_start); this just replaces the capture source. channels = 1 (mono) or 2 (stereo interleaved). Thread-safe; may be called from any thread including audio callbacks. */ vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id, const int16_t* pcm, size_t samples_per_channel, uint32_t channels);vc_set_pcm_sink— symmetric output side: receive decoded remote audio as int16 PCM instead of (or in addition to) the hardware playback device:/* External PCM tap — receive decoded, mixed remote audio as int16 PCM. The callback fires on the audio thread with the mixed output for a specific remote stream. Pass cb=NULL to disable (default: disabled, hardware playback only). When enabled, PCM is delivered to the sink AND the hardware device (dual output) so a bot can record without disabling local monitoring. user_id+stream_id identify the source stream. The callback MUST NOT block — copy what you need and return (same contract as vc_callbacks.on_event). */ typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id, const int16_t* pcm, size_t samples_per_channel, uint32_t channels, uint32_t sample_rate); vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user);- Core changes:
core/include/voicecat.h— addvc_pcm_sink_cbtypedef + the two function declarations (append-only, aftervc_test_inject_capture). Full doc comments on both (contract, thread-safety, lifetime, use cases).core/src/voicecat.cpp— thin C trampolines →vc_client::stream_feed_pcm/set_pcm_sink.core/src/core/client.{h,cpp}—stream_feed_pcm: validatesstream_id, looks up theLocalStream's kind, callsaudio_engine_.inject_capture(kind, pcm, n)(existing path) with the channel count forwarded.set_pcm_sink: stores the callback + user pointer;on_playback(or a new fan-out in the mixer) invokes it per remote stream alongside the existing hardware write. Keepvc_test_inject_captureas a deprecated alias callingstream_feed_pcm(..., channels=1)for source compatibility.core/src/audio/audio_engine.{h,cpp}—inject_capturealready exists per-kind; add achannelsparameter to the ring-buffer write path (or a parallel stereo-aware variant). The encode path inclient.cpp::on_capture_framealready handleschannels==2via the stereo encode branch — just plumb the value through. For the sink: add apcm_sink_member (callback + user); inon_playbackafter mixing, if the sink is set, copy the mixed PCM for the current stream and invoke the callback. The copy must stay off the RT-critical path — document the non-blocking contract.
- Skeleton stub path: update
client.cpp's#else(no-deps) stub section to addvc_stream_feed_pcm/vc_set_pcm_sinkreturningVC_ERR_NOT_IMPLEMENTED— keeps the skeleton preset green. - Swift
VoiceCatCore: addfeedPcm(streamId:pcm:samplesPerChannel:channels:)andsetPcmSink(_:user:)(the Swift wrapper aroundvc_pcm_sink_cb— a@convention(c)closure +Unmanagedcontext, mirroringCallbacks.swift). Wraps both new ABI functions. - C#
VoiceCat.Interop: addStreamFeedPcm(streamId, pcm, samples, channels)(withint16[]marshaling) andSetPcmSink(delegates via[UnmanagedCallersOnly]thunk, mirroring the event-callback pattern). Wraps both new ABI functions. - Tests:
tests/test_external_pcm.cpp(new) —test_feed_pcm_round_trip: two clients, A feeds a known mono sine wave viavc_stream_feed_pcmon a MIC stream, B receives via the normal decode path and asserts energy matches.test_feed_pcm_stereo: same withchannels=2, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test).test_pcm_sink: B sets avc_pcm_sink_cb, A feeds PCM, assert the sink callback receives the decoded PCM with matching energy. All headless, no audio hardware.clients/apple/Tests/VoiceCatCoreTests/— Swift wrapper round-trip forfeedPcm.clients/windows/VoiceCat.Interop.Tests/— C# wrapper round-trip.
- Docs:
docs/architecture.md §4— new subsection on external PCM feed/tap: the contract (caller provides interleaved int16 at the stream's sample rate; core frames/encodes/ seals/sends for feed; core decodes/mixes/delivers for sink; sink callback must not block), the use cases (ReplayKit, ScreenCaptureKit, bots, custom clients), and the relationship tovc_test_inject_capture(deprecated alias).docs/voice.md §9— update the iOS ReplayKit and macOS ScreenCaptureKit rows: both now consumevc_stream_feed_pcminstead of a "minimal slice of the core." Update the iOS detail bullets: the extension links Opus +vc_stream_feed_pcm(not a parallel media stack). Add a macOS ScreenCaptureKit note: convertCMSampleBuffer→ int16, feed viavc_stream_feed_pcm— this is how macOS screen-audio actually ships.docs/protocol.md— no protocol changes (the feed/sink are client-local; the wire format is identical whether PCM came from miniaudio or an external source). Note this explicitly.docs/roadmap.md— add a milestone entry; update the iOS ReplayKit and macOS ScreenCaptureKit pending items to referencevc_stream_feed_pcm.
- Implementation order:
- C ABI + core (
voicecat.h,voicecat.cpp,client.{h,cpp},audio_engine.{h,cpp}) + skeleton stub. Verifyctest --preset devgreen. tests/test_external_pcm.cpp— the three behavior tests. Verify green.- Swift
VoiceCatCorewrapper +VoiceCatCoreTestsround-trip. - C#
VoiceCat.Interopwrapper +VoiceCatClientSmokeTestsround-trip. - Docs (
architecture.md,voice.md,protocol.md,roadmap.md, header comments). - Then ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of this API instead of parallel media stacks.
- C ABI + core (
- Verification:
ctest --preset devgreen (3 new tests);swift testgreen;dotnet testgreen;xcodebuild(skeleton) green. The feed/sink tests are fully headless — no audio hardware, no simulator, no device — so they run in CI on every platform. - Files to touch:
- Core C++:
core/include/voicecat.h,core/src/voicecat.cpp,core/src/core/client.{h,cpp},core/src/audio/audio_engine.{h,cpp}. - Tests:
tests/test_external_pcm.cpp(new),tests/CMakeLists.txt. - Swift:
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift,clients/apple/Sources/VoiceCatCore/Callbacks.swift,clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift(new). - C#:
clients/windows/VoiceCat.Interop/VoiceCatClient.cs,clients/windows/VoiceCat.Interop/NativeMethods.cs,clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs(new). - Docs:
docs/architecture.md,docs/voice.md,docs/protocol.md,docs/roadmap.md.
- Core C++:
- ABI stability: append-only — two new functions + one new typedef, no existing
structs/enums changed.
vc_test_inject_capturestays as a deprecated alias for source compatibility. Treat as a deliberate, versioned ABI event perdocs/protocol.md §8. - Relationship to the iOS audio routing plan (entry below): orthogonal. That plan is about which hardware route miniaudio opens (AVAudioSession config in Swift). This plan is about bypassing miniaudio's hardware entirely (external PCM feed/tap). Both ship; they don't conflict. ReplayKit/ScreenCaptureKit consume this API; the iOS routing layer controls the mic path which still uses miniaudio's device.
- ReplayKit Broadcast Upload Extension (iOS
-
Done: iOS audio preset fixes — A2DP output muting + broken stereo capture (2026-06-19). Two user-reported bugs with the audio presets (esp. with Bluetooth headphones connected), both root-caused against Apple docs/WWDC20 + dev-forum reports and fixed in
IOSAudioRouter.applyConfiguration()/AudioSessionManager.ensureSessionActive():- Output (and VoiceOver) went dead on the Stereo Mic / A2DP presets (BUG — fixed).
applyConfiguration()always inserted.defaultToSpeaker, which breaks A2DP routing in.playAndRecord(route ends up muted, taking the shared hardware output — and VoiceOver — with it). And the A2DP path usedmode = .default, which iOS 17+ routes to the speaker. Fix:.defaultToSpeakernow set only for the speaker preset;.mixWithOtherskept always (VoiceOver must stay audible for a blind user); A2DP/stereo paths now usemode = .videoRecording(the documented mode that keeps Bluetooth output and supports multi-capsule stereo); aftersetActive(true)on an A2DP preset we calloverrideOutputAudioPort(.none)to clear any lingering speaker override. NOTE: built-in mic + A2DP output during active recording is inherently flaky on iOS (A2DP is output-only; recording wants to collapse BT to mono HFP) — this is best-effort, not guaranteed on every BT device. Needs on-device testing. - Stereo mic only captured the left channel (BUG — fixed). Real built-in-mic stereo
needs more than
setPreferredInputNumberOfChannels(2): per WWDC20 you must select a data source whosesupportedPolarPatternscontains.stereo, callsetPreferredPolarPattern(.stereo), setsetPreferredInputOrientation(.portrait), then request 2 channels. The old code set the data source/polar pattern tonilfor the stereo preset, so channel 2 was silent. Fix: newconfigureStereoCapture()does the full WWDC20 sequence and falls back to mono if the route has no stereo-capable capsule (BT/wired mic). NewconfigureMonoCapture()resets to 1 channel (so stereo→mono actually takes effect) and applies the user's chosen mono orientation/polar pattern.
- Verified:
xcodebuild -scheme VoiceCatiOS -destination 'generic/platform=iOS Simulator' ARCHS=arm64— BUILD SUCCEEDED. On-device behavior (BT headphones + VoiceOver + stereo pickup) still to be confirmed by the user. - Follow-up (same day) after device testing. User reported: default + BT-mono-mic work,
but Stereo Mic still kills output, and remote audio may never be audible.
b. Remote audio never routed (BUG — fixed). The AVAudioSession was activated lazily on
the
.streamStartedevent, but the core opens its miniaudio playback device before emitting that event — so playback opened against an inactive session and produced no sound. Fix: activate the session proactively on.authResult == .ok(AppState.swift), so it's live before any remote stream opens a device. Mirrors the macOS "session active while connected" model.
- Output (and VoiceOver) went dead on the Stereo Mic / A2DP presets (BUG — fixed).
-
Done: iOS stereo mic + A2DP output — audio death fix (2026-06-19). The previous "fix" (line below, "Stereo mic killed A2DP output") was never verified on-device and still had the bug: selecting the Stereo Mic preset killed all audio (including VoiceOver) — only kill & relaunch recovered it. Root cause found by comparing against TeamTalk5 (
Client/iTeamTalk/), which achieves stereo mic + A2DP output. Five issues fixed:configureStereoCapturewas missingsetPreferredInput+setInputDataSource(BUG — fixed). The previous fix threw outsetPreferredInputtogether withsetPreferredInputNumberOfChannels(2), blaming the combination for collapsing A2DP. WRONG — TeamTalk5 keepssetPreferredInput+setInputDataSource(SoundDevicesModel.selectDataSource:147-148) and only omitssetPreferredInputNumberOfChannels(2). Without the explicit input anchor, whensetPreferredPolarPattern(.stereo)fired, iOS had no session-level input anchor and the route reconfiguration collapsed the A2DP output. Fix:configureStereoCapturenow callssetPreferredInput(builtIn)+setInputDataSource(stereoSource)after the polar pattern. Also removedsetPreferredInputOrientation(.portrait)(TeamTalk doesn't use it; possible route-collapse contributor on iOS 26).- Core devices not restarted around
applyConfiguration(BUG — fixed). TeamTalk5'ssetupSoundDevicescallscloseSoundDevices()FIRST, then reconfigures, then reopens — so the audio unit never sees a route change mid-flight. VoiceCat reconfiguredAVAudioSessionwhile the core's miniaudio devices were still open, leaving them bound to the dead route. Fix: new C ABI functionvc_audio_restart(full stop + re-init, unlikesuspend/resumewhich only stop/start).IOSAudioRouter's setters now wrapapplyConfiguration()withaudioSuspend→ reconfigure →audioRestart.AudioSessionManager.handleRouteChangealso callsaudioRestarton device plug/unplug so the playback device picks up the new route. - Deprecated
.allowBluetoothinstead of.allowBluetoothHFP(modernized). TeamTalk5 uses.allowBluetoothHFP(iOS 17+). VoiceCat was using the deprecated.allowBluetoothalias. Replaced; also added.bluetoothHighQualityRecordingon iOS 26+ (mirrors TeamTalk5UtilSound.swift:229-231). - Capture channels not reset when switching stereo→mono (BUG — fixed). Switching from
stereo to mono only changed the
AVAudioSessionpolar pattern, not theLocalStream'scapture_channelsfield — so the next engine start still opened 2 channels. Fix:AudioSessionManagernow tracksactiveMicStreamId(set bySessionStateon join/leave voice);selectCaptureChannelsandapplyPresetcallsetCaptureChannels(streamId, channels.channelCount)inside the suspend window so the field is updated before the engine restarts. - Docs corrected.
docs/voice.md§8,docs/tech-stack.md§2,docs/architecture.md§4 — removed stalesetPreferredInputNumberOfChannels(2)references; documented the actual recipe (.stereopolar pattern +setPreferredInput+setInputDataSource+vc_set_capture_channels) and the newvc_audio_restartABI + close→reconfigure→reopen ordering rule.voicecat.hvc_set_capture_channelsdoc comment also corrected.
-
New C ABI:
vc_result vc_audio_restart(vc_client* c)— full audio engine restart (stop + uninit + re-init), append-only addition. Skeleton stub added. Swift wrapper:VoiceCatClient.audioRestart(). -
Verified:
cmake --build --preset dev+ctest --preset dev— 21/21 green (includingtest_stereo_mic_capture).xcodebuild -arch arm64 ONLY_ACTIVE_ARCH=YES— BUILD SUCCEEDED (iOS client compiles + links). On-device verification still pending user test: (a) Voice Chat → Stereo Mic, (b) BT Headphones + Mono Mic → Stereo Mic, (c) Stereo Mic → Voice Chat, (d) unplug/replug Bluetooth mid-stereo-session.a. Stereo mic killed A2DP output (BUG — fixed correctly this time). My first follow-up wrongly concluded stereo ⊥ Bluetooth was a hardware limit and forced stereo → speaker. WRONG — TeamTalk5 (
Client/iTeamTalk/iTeamTalk/UtilSound.swift) and Ferrite both do built-in stereo mic + A2DP output. The actual cause was how stereo was requested:setPreferredInputNumberOfChannels(2)+setPreferredInput(builtInMic)+mode=.videoRecordingtogether collapse the A2DP output route. Fix (TT5 recipe): stereo is now enabled purely by setting the built-in mic data source's.stereopolar pattern (configureStereoCapture); NOsetPreferredInputNumberOfChannels, NOsetPreferredInput(with HFP disabled the system already routes input to the built-in mic);mode=.defaultfor stereo (.voiceChat/VPIO forces mono). The channel count is requested by miniaudio at the AU level viavc_set_capture_channels(2). Reverted the force-to-speaker workaround:.stereoMic/.studiopresets use A2DP again (speaker fallback when no BT); re-removedshowsStereoForcesSpeakerWarning;.allowAirPlayadded to BT presets.clearStereoPolarPattern()resets the capsule when returning to mono. So stereo mic + A2DP output now coexist. -
Verified: rebuilt — BUILD SUCCEEDED. On-device confirmation (hearing remote clients; stereo mic + A2DP output with both channels) still pending user test.
-
Done: iOS audio overhaul + Join/Leave Voice + channel-id sync fix (2026-06-19). Three problems found while reviewing the iOS client, all fixed:
- Mic button permanently dimmed (BUG — fixed).
VoiceControlsView.swift:26gated the mic button onsession.currentChannelId == 0, butSessionStatenever syncedcurrentChannelIdfrom the self user'schannelIdon connect. The server auto-places every newly-authed user into the Lobby (channel 1,server/src/session_registry.cpp:111), but the iOS client ignored it. Fix: addedsyncSelfChannel()(mirrors macOSMainWindowController.swift:461,491,522); called frominit,.channelList,.userJoined/.userLeft/.userUpdated,.joinResult. AddedapplyServerMuteState(muted:deafened:)(mirrors macOS lines 693-700); called from.userUpdated. AddedserverMuted/serverDeafenedtoVoiceState. - No Join/Leave Voice button (fixed — parity with macOS). Replaced the icon-only mic
toggle with an explicit "Join Voice"/"Leave Voice" text button (mirrors macOS
micToggleButton). Mute/deafen buttons now disable when not in voice. PTT path kept. - Limited audio input/output options (fixed — full
IOSAudioRouter.swift). NewIOSAudioRoutersingleton drives all iOS audio routing viaAVAudioSessionbefore the core (miniaudio) opens its device: input port selection (availableInputs), built-in mic orientation (setPreferredDataSource: front/back/top/bottom), polar patterns (setPreferredPolarPattern: omni/cardioid/subcardioid/bidirectional), Bluetooth mode (.allowBluetoothHFP voice /.allowBluetoothA2DPstereo output / neither), mic processing mode (.voiceChatStandard with AEC/AGC/HPF /.measurementRaw with all processing off + speaker echo warning), stereo capture (setPreferredInputNumberOfChannels(2)→vc_set_capture_channels), AirPlay viaAVRoutePickerView. All choices persisted inUserDefaults; re-applied on route changes.AudioSessionManagerrefactored to delegate routing toIOSAudioRouter. - Core stereo-mic capture (new C ABI:
vc_set_capture_channels). Append-only ABI addition:vc_result vc_set_capture_channels(vc_client*, uint32_t stream_id, uint32_t channels)(1=mono, 2=stereo).LocalStreamgained acapture_channelsfield;ensure_audio_running()reads it intoAudioParams.capture_channelsbefore the device opens.audio_engine.cppcapture_accum_sized toframe_samples_ * capture_channels;on_captureupdated to the same channel-aware accumulation pattern ason_loopback. Skeleton stub added. Testtest_stereo_mic_capture(headless, feeds L≠R stereo through the mic accumulator path, asserts L≠R end-to-end).ctest --preset dev— 21/21 green. Swift wrapper:VoiceCatClient.setCaptureChannels(streamId:channels:). - Settings UI rework.
SettingsViewreplaced the miniaudio-based device picker with the AVAudioSession-derived tree: Audio Input (port picker → built-in mic orientation/polar pattern sub-pickers + mic mode Standard/Raw + channels Mono/Stereo), Audio Output (bluetooth mode + current route read-only + AirPlay), Voice (input mode, VAD threshold). - Deployment target raised to iOS 18.0.
Package.swift+project.pbxproj(4 occurrences). Unlocks newest AVAudioSession APIs. - Docs updated:
docs/tech-stack.md§2 (iOS audio routing via IOSAudioRouter, stereo mic, deployment 18.0, fixed stale "deferred" claim aboutvc_audio_suspend/resume),docs/architecture.md§4 (Swift binding notes — IOSAudioRouter, fixed stale "deferred" claim),docs/voice.md(new iOS mic capture subsection),docs/roadmap.md(iOS pending list updated),docs/building.md§9 (deployment target 18.0),PROGRESS.md(this entry).
- Verified:
cmake --build --preset dev+ctest --preset dev— 21/21 green (including newtest_stereo_mic_capture:total_diff=8433549, seen_channels=2). - Original plan (kept for reference): Three problems found while reviewing the iOS client:
- Mic button permanently dimmed (BUG — root cause).
VoiceControlsView.swift:26gates the mic button onsession.currentChannelId == 0, butSessionStatenever syncscurrentChannelIdfrom the self user'schannelIdon connect. The server auto-places every newly-authed user into the Lobby (channel 1,server/src/session_registry.cpp:111), but the iOS client ignores it —currentChannelIdis set in only two places:.joinResult(SessionState.swift:97) and reset to 0 byleaveChannel()(SessionState.swift:135). Theinit(SessionState.swift:55-57) callsrefreshUsers()but throws away self's channelId; the.channelList/.userUpdatedhandlers (SessionState.swift:76-79) likewise only re-fetch the list. macOS does this sync correctly (MainWindowController.swift:461,491,522). Because the button stays dimmed,startMicStream()(and thus the mic permission prompt) can never be triggered — a chicken-and-egg that looks like a permission issue. Not a permission bug:Info.plist:23declaresNSMicrophoneUsageDescriptionandSessionState.swift:139callsAVAudioApplication.requestRecordPermission. Fix = mirror macOS: addsyncSelfChannel(), call it frominit/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult; also addapplyServerMuteState(muted:deafened:)(iOS currently ignores server mute/deafen). - No Join/Leave Voice button (parity gap). macOS (
MainWindowController.swift:303,767-797) and Windows have an explicit "Join Voice"/"Leave Voice" button; iOS has only an icon mic toggle. Add an explicit button. Screen-audio button deferred to the ReplayKit Broadcast Upload Extension milestone (PROGRESS.md line below) — needs the extension to actually function. - Limited audio input/output options (feature gap). Current
SettingsView.swift:41-60uses miniaudio'svc_list_deviceswhich on iOS returns ~2 entries ("iPhone mic"/"Default"). miniaudio does NOT touchAVAudioSessionon iOS — it opens the current default route via CoreAudio (AudioUnit/AudioQueue) and that's it. All iOS audio routing (input port selection, mic orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be driven manually from Swift viaAVAudioSessionbefore the core opens its device. The currentAudioSessionManager.swift:13-15uses.voiceChat+.allowBluetooth, which forces HFP (mono 8/16 kHz + heavy output processing) whenever a Bluetooth headset is connected — this is why stereo output is degraded with BT headsets.
Plan (decisions locked with user 2026-06-19):
- Part A — Channel-id sync (bug fix, smallest, unblocks mic button):
SessionStategainssyncSelfChannel()(finds self inusers, setscurrentChannelId = user.channelId); called frominit,.channelList,.userJoined,.userLeft,.userUpdated,.joinResult. AddapplyServerMuteState(muted:deafened:)(mirror macOS lines 693-700); call from.userUpdated. - Part B — Join/Leave Voice button: Replace the icon-only mic toggle in
VoiceControlsViewwith an explicit "Join Voice"/"Leave Voice" button (mirror macOSmicToggleButton). Keep PTT path, mute/deafen, disconnect, level meter. No screen-audio button (deferred). - Part C — iOS audio routing layer (Swift, new
IOSAudioRouter.swift): Drive selection viaAVAudioSessionbefore the core opens its device. Input enumeration viaavailableInputs→ ports (builtInMic/bluetoothHFP/headsetMic/usbAudio/airPlay); for builtInMic walkport.dataSources→ expose orientation (front/back/top/bottom) + polar patterns (supportedPolarPatterns: omni/cardioid/subcardioid/bidirectional). Apply viasetPreferredInput/setPreferredDataSource/setPreferredPolarPattern/setPreferredInputNumberOfChannels(2)for stereo. Bluetooth mode as category options: "BT HFP voice" (.allowBluetooth, mono voice, BT mic) / "Built-in Mic + BT A2DP stereo" (.allowBluetoothA2DPonly — stereo output, built-in mic, no HFP processing) / "Built-in Mic + Speaker" (neither). Mic processing mode: "Standard" (.voiceChat, AEC/AGC/HPF on) / "Raw / Studio" (.measurement, all processing off — allowed always with a warning when output route is the speaker, echo risk since no AEC). Output: read-onlycurrentRoute.outputsdisplay + AirPlay viaAVRoutePickerView. Persist choices inUserDefaults; re-apply on route changes. Note: Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center for.voiceChatapps — surface as a hint, not a programmatic toggle. - Part D — Core stereo-mic capture (C ABI change, append-only):
AudioParams.capture_channelsis hardcoded to 1 (audio_engine.h:85); encode path already stereo-capable (proven by WASAPI loopback stereo work, 2026-06-17 entry below). New C ABI:vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels);(1 or 2) — new setter, not a struct change, keepsvc_stream_descstable.client.{h,cpp}:set_capture_channels→ per-stream, flows intoAudioParams.capture_channelsbeforeAudioEngine::startfor MIC kind.client.cppon_capture_frame: mic path withchannels==2reuses the existing stereo encode branch.audio_engine.cpp: capture device already opens withp.capture_channels— just needs the value to propagate. SwiftVoiceCatCore: addsetCaptureChannels(streamId:channels:);IOSAudioRoutercalls it when user picks stereo built-in mic. - Part E — Settings UI rework: Audio Input section (input port picker → if builtInMic, show
orientation + polar pattern sub-pickers + mic mode Standard/Raw + channels Mono/Stereo); Audio
Output section (bluetooth mode HFP/A2DP/Off + current route read-only + AirPlay button); Voice
section (existing: input mode, VAD threshold, PTT). Replace the current miniaudio-based device
picker (
SettingsView.swift:41-60) with the AVAudioSession-derived tree. - Part F — Docs + deployment target: Raise iOS deployment target to 18.0 (user decision —
unlocks newest audio APIs;
project.pbxproj+Package.swift.iOS(.v18)). Updatedocs/tech-stack.md§2 (iOS audio routing via AVAudioSession, stereo mic, deployment 18.0),docs/voice.md(new iOS mic capture subsection),docs/roadmap.md(iOS pending list),docs/architecture.md§4 (Swift binding notes — iOS audio routing in Swift layer, core stereo-mic via new ABI setter),PROGRESS.md(this entry + next-action pointer). - Implementation order: A (channel-id sync, testable immediately) → B (Join/Leave Voice) →
D (core stereo-mic + ABI, verify with
ctest --preset dev) → C (Swift routing layer, depends on D) → E (Settings UI, depends on C) → F (docs, same commit as work). - Verification:
xcodebuild -target VoiceCatiOS -sdk iphonesimulator -configuration Debug build→ BUILD SUCCEEDED;swift testgreen + stereo-mic capture round-trip test;ctest --preset devgreen (core changed in D); manual (simulator/device): join channel → Join Voice works → input picker shows built-in mic sub-options (orientation/polar pattern) → toggle Raw mode (warning appears on speaker) → toggle Bluetooth A2DP stereo output → toggle stereo capture. - Files to touch:
- iOS Swift:
SessionState.swift,AudioSessionManager.swift(+ newIOSAudioRouter.swift),Views/VoiceControlsView.swift,Views/SettingsView.swift,VoiceCatiOSApp.swift,VoiceCatiOS.xcodeproj/project.pbxproj(iOS 18.0). - Swift core:
Sources/VoiceCatCore/VoiceCatClient.swift(setCaptureChannels),Sources/VoiceCatCore/Models.swift(input-port model),Package.swift(.iOS(.v18)). - Core C++:
core/include/voicecat.h(vc_set_capture_channels),core/src/voicecat.cpp,core/src/core/client.{h,cpp},core/src/audio/audio_engine.{h,cpp}. - Docs:
docs/tech-stack.md,docs/voice.md,docs/roadmap.md,docs/architecture.md,PROGRESS.md. - Tests:
clients/apple/Tests/VoiceCatCoreTests/,tests/(stereo-mic encode test).
- iOS Swift:
- Why miniaudio can't do this alone: On iOS, miniaudio uses CoreAudio (AudioUnit/AudioQueue)
to open the current default route for PCM I/O — it never touches
AVAudioSession.ma_context_get_deviceson iOS returns a near-empty list (iOS doesn't expose a real CoreAudio device list like macOS does). All iOS audio routing (input ports, data sources, polar patterns, HFP/A2DP, measurement mode,preferredInputNumberOfChannels) must be driven from Swift viaAVAudioSessionbefore miniaudio opens its device. miniaudio just reads whatever route AVAudioSession has established.
- Mic button permanently dimmed (BUG — fixed).
-
Done: iOS SwiftUI client —
VoiceCatiOS(2026-06-19). Full SwiftUI app atclients/apple/iOS/VoiceCatiOS.xcodeproj. Mirrors the macOS AppKit and Windows WinForms clients feature-for-feature: saved server list (JSON + Keychain passwords via App Groupgroup.cat.voice.VoiceCat), TOFU server-identity sheet (first-connect and mismatch), connect flow (guest + account auth, connection state labels, password prompt), main window (NavigationSplitViewon iPad,TabViewon iPhone viahorizontalSizeClass), channel tree (OutlineGrouprecursive —children: nilfor leaf nodes), user list with context menu (kick/ban/move/set-permissions/server-mute), chat (ScrollViewReader+LazyVStack), activity log, voice controls (mic toggle, PTT viaDragGesture(minimumDistance: 0)+@GestureState, level meterCanvas), per-user tuning (gain slider, mute, NR toggles), admin sheets (channels CRUD, accounts CRUD, ban duration, move-to-channel), settings (input mode, VAD threshold, device picker, disconnect). All 24 Swift source files in place (10 model/state + 14 views).@Observable+@MainActorthroughout — bothAppStateandSessionStateare@MainActor.onEventclosures dispatch to main actor viaTask { @MainActor in ... }. C ABI additions:vc_audio_suspend/vc_audio_resume(no-op when engine stopped) — called byAudioSessionManageron AVAudioSession interruption (phone calls, Siri). XCFramework: iOS arm64 + arm64-simulator slices added alongside existing macOS-arm64 slice;Package.swiftupdated with.iOS(.v17). Verified:xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 -configuration Debug ... SYMROOT=... OBJROOT=... build→ BUILD SUCCEEDED (clean build).- Next: ReplayKit Broadcast Upload Extension (
VoiceCatBroadcast) for screen/system audio sharing on iOS — separate Xcode target, App Group credential sharing,SampleHandler.swift. Or: DRED/audio-quality polish (M5).
- Next: ReplayKit Broadcast Upload Extension (
-
Done: macOS AppKit client builds clean (Debug + Release) (2026-06-18). The previous "shipped" claim in the entry below was incorrect —
xcodebuildactually failed on a fresh clone. Two real defect classes fixed, no source architecture changed:- Swift compile errors in
MainWindowController.swift(the previous agent wrote AppKit API calls from memory that didn't match the SDK):NSAccessibility.post(notification:element:userInfo:)— wrong argument order. The Swift import (verified againstAppKit.apinotesin the macOS 26.5 SDK) isNSAccessibility.post(element:notification:userInfo:). 3 call sites fixed.NSAccessibilityPriorityMedium— not a Swift symbol. The C constantsNSAccessibilityPriorityHigh/Medium/Loware imported by Swift as cases of theNSAccessibilityPriorityLevelenum (it's anNS_ENUM(NSInteger, ...)inNSAccessibilityConstants.h, no apinotes rename). Replaced withNSAccessibilityPriorityLevel.mediumat 3 call sites.streams.first(where: { $0.streamId == event.streamId })—StreamSummaryhas nostreamIdproperty; the init parameter is namedstreamIdbut the stored property isid(perVoiceCatCore/Models.swift:102-110,Identifiableconformance). The bad property access made the closure fail to type-check, which made the compiler treatstreams.firstas a property (returningStreamSummary?) and then try to call it — hence "cannot call value of non-function type 'StreamSummary?'". Fixed to$0.id.- Removed a redundant
fileprivate var descriptionextension onVoiceCatResultinConnectWindowController.swift—VoiceCatResultalready has apublic var descriptioninVoiceCatCore/Enums.swift, so the redeclaration would have errored ("invalid redeclaration") once the compiler got pastMainWindowController.swift.
- Linker error —
libvoicecat-fat.ais C++ but the app target didn't link libc++ (the previous agent'sproject.pbxprojhadOTHER_LDFLAGSempty). The pure-Swift app pulls inlibvoicecat-fat.a(a static C++20 archive that referencesstd::__1::*,__cxa_*,operator new/delete, etc.), but the linker has no reason to pull in libc++ on its own — there are no.cppsources in the app target. The VoiceCatCore package's test target sidesteps this withlinkerSettings: [.linkedLibrary("c++")]inPackage.swift:54-56, which is whyswift testwas green butxcodebuildwasn't. Fixed by addingOTHER_LDFLAGS = ("$(inherited)", "-lc++")to BOTH the Debug and Release target configurations inVoiceCatMac.xcodeproj/project.pbxproj.otool -Lon the produced dylib confirms/usr/lib/libc++.1.dylibis now linked. - Release config tried to build x86_64 (XCFramework only has arm64) — the project-level
Release config (
AAAA…000C) lackedONLY_ACTIVE_ARCH = YES, so Release built the standardARCHS_STANDARD(arm64 + x86_64) and the x86_64 slice failed withUndefined symbols for architecture x86_64: _vc_authenticate_guest …becauseVoiceCatCore.xcframework/macos-arm64only contains an arm64 slice. AddedONLY_ACTIVE_ARCH = YESto the project-level Release config to match the XCFramework. For distribution (App Store / universal binary), the right fix is to makebuild-xcframework.sh --allalso produce an x86_64 macOS slice — left as a future enhancement; the current setup builds a working arm64 Release on Apple Silicon.
- Verified:
xcodebuild -project … -scheme VoiceCatMac -configuration Debug build→ BUILD SUCCEEDED (clean build, not incremental). Release config → BUILD SUCCEEDED.otool -LonVoiceCatMac.debug.dylibshowslibc++.1.dylib,Security.framework,AppKit,Foundation,CoreFoundation, swift runtime libs.nmshows_vc_client_create_vc_version_stringare present (the staticlibvoicecat-fat.alinked in). App launches and stays running (verified via background launch +kill -0).
- Lesson: the previous "shipped" entry was written without ever running
xcodebuild— a clean compile is the floor, not the goal (AGENTS.md). Theswift testgreen status was real but tested only the VoiceCatCore package, not the macOS app target. The app target had never been built.
- Swift compile errors in
-
Done: macOS AppKit UI —
VoiceCatMac(2026-06-18). Full AppKit application atclients/apple/macOS/VoiceCatMac.xcodeproj. Mirrors the Windows WinForms client feature-for-feature: saved server list (JSON + Keychain passwords), TOFU server-identity sheet (first-connect and mismatch paths), connect flow (guest + account auth, connection state labels), main window (NSSplitView layout with NSOutlineView channel tree, NSTableView user list, NSTextView chat, activity log), voice controls panel (Join/Leave mic, screen audio, mute/deafen checkboxes, VAD/PTT/Always-On segmented control, VAD sensitivity slider, PTT key capture, input device picker, level meter), compose bar (scope picker for channel/private text, NSTextField + Send), right-click context menus on channels and users (join, create/edit/delete channels, kick/ban/move/server-mute/deafen/ set permissions), admin menu (server accounts sheet with CRUD), 10 sheet view controllers. Full VoiceOver accessibility: every control hassetAccessibilityLabel;NSAccessibility.post (.announcementRequested)on join, talk-state, stream events. All 17 Swift source files in place;PttKeyCaptureSheetuses aKeyCaptureView: NSViewsubclass that becomes first responder and captureskeyDown. Build prerequisite: runclients/apple/scripts/build-xcframework.shfirst; thenxcodebuild -project clients/apple/macOS/VoiceCatMac.xcodeproj -scheme VoiceCatMac build.- Next: iOS SwiftUI client (
clients/apple/iOS/) — same VoiceCatCore Swift package, SwiftUI instead of AppKit. Or: DRED/audio-quality polish (M5).
- Next: iOS SwiftUI client (
-
Done: macOS/iOS Swift core —
VoiceCatCorepackage + tests (2026-06-18). The shared Swift core for the macOS (AppKit) and iOS (SwiftUI) clients is built and verified. This is the foundation that both platform UIs will build on — mirrors the Windows client's provenVoiceCat.Interoplayer using Swift-native C interop.- Architecture decision: macOS UI = AppKit (not SwiftUI), for the most mature
VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-
WinUI-3 decision. iOS stays SwiftUI. Recorded in
docs/roadmap.md§2 anddocs/tech-stack.md§2. VoiceCatCoreSwift Package (clients/apple/Package.swift): binary target (VoiceCatCoreXCF→VoiceCatCore.xcframework) + library target (VoiceCatCore— the Swift wrapper) + test target (VoiceCatCoreTests). 7 source files:Enums.swift(Swift mirrors of the 9 C enums),Config.swift,Event.swift(copiesev.texttoStringinside the callback — the #1 lifetime rule),Models.swift(Channel/User/Stream/Device/Permissions/Account/AudioConfig/…),Marshaling.swift(C arrays → Swift arrays + immediatevc_free_*),Callbacks.swift(@convention(c)+Unmanaged.passUnretained— the Swift analog of C#'s[UnmanagedCallersOnly]+GCHandle),VoiceCatClient.swift(ownsvc_client*, all 38 C functions,deinit→vc_client_destroythen frees config CStrings, event delivery on@MainActorvia coalescedDispatchQueue.maindrain).build-xcframework.sh(clients/apple/scripts/): runscmake --preset apple-dev, mergeslibvoicecat.a+ 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) vialibtool -static, stagesvoicecat.h+ a generatedmodule.modulemap(module VoiceCatC { header "voicecat.h" }) into the XCFramework headers, runsxcodebuild -create-xcframework→clients/apple/VoiceCatCore.xcframework. The fat library is needed because SPM binary targets link ONE.aper slice — without it, the final executable gets undefined-symbol errors for protobuf/mbedtls/sodium/opus/… (the Apple equivalent of how the Windows client ships a singlevoicecat.dllwith all deps statically linked).- Tests — 6/6 green:
swift testagainst a realvoicecat-server(built bycmake --preset dev). Tests mirror the C#VoiceCat.Interop.Tests:testVersionStringIsNonEmpty,testResultStringRoundTrips,testConnectTofuAuthListChannelsRoundTrips(connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected),testAdminChannelCrudAccountCrudRoundTrips(admin auth → channel CRUD → account CRUD),testScreenAudioStreamStartsAndStops(screen-audio stream lifecycle through Swift),testPerStreamRecvControlsRoundTrip(two clients, per-stream gain/mute/NR round-trip). Catches Swift-specific interop bugs (@convention(c)callback lifetime,Unmanagedpointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest can't. - Key C interop discoveries: (1) Swift imports
vc_client*(incomplete C struct) asOpaquePointer?, not a named type —private var handle: OpaquePointer?. (2) C enums are imported asUInt32-backed (notInt32) — all Swift enum mirrors useUInt32;vc_event.resultisint32_t(signed), bridged viaUInt32(bitPattern:). (3) Swift auto-marshalsStringtoconst char*for function params, but NOT for C struct fields —vc_stream_desc/vc_channel_infostring fields needstrdup+defer { free }. (4)selfPointermust be a computed property (not stored) to break the circular init dependency (Unmanaged.passUnretained(self)needsselffully initialized, but stored properties must be set first). - Docs updated:
docs/tech-stack.md§2 (AppKit macOS / SwiftUI iOS / sharedVoiceCatCorepackage / fat static lib),docs/architecture.md§4 (Swift binding notes),docs/roadmap.mdM4 + §2 (AppKit decision recorded),clients/apple/README.md(full rewrite),PROGRESS.md(this entry),.gitignore(XCFramework + SPM artifacts). - Next: macOS AppKit app (
clients/apple/macOS/) — connect dialog, saved-server list (Keychain), TOFU identity dialog, main window (NSOutlineView + NSTableView + NSTextView- activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors
the Windows
VoiceCat.Appfeature set.
- activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors
the Windows
- Architecture decision: macOS UI = AppKit (not SwiftUI), for the most mature
VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-
WinUI-3 decision. iOS stays SwiftUI. Recorded in
-
Done: macOS port —
dev+apple-devpresets validated (2026-06-18). The core, server, tools, and tests now build and run on macOS (Apple Silicon, macOS 26.5, Apple clang 21). This lays the groundwork for the macOS/iOS Swift client. Three real bugs found and fixed (all were cross-platform issues that manifested on macOS but were latent on Windows/Linux):- Missing
<netdb.h>intest_m2_voice.cppPOSIX branch — the raw-socket test's#elsebranch included<arpa/inet.h>/<netinet/in.h>/<sys/socket.h>/<unistd.h>but not<netdb.h>(needed foraddrinfo/getaddrinfo/freeaddrinfo). On Linux glibc these headers transitively include<netdb.h>; on macOS they don't. Fixed by adding# include <netdb.h>to the POSIX branch (mirrorscore/src/core/client.cpp:16which already had it). Real latent bug — would fail on any strict POSIX system. - SIGPIPE killing processes on macOS — on macOS, writing to a closed TCP socket
raises
SIGPIPEby default (unlike Windows where it doesn't exist, or Linux where it's often benign). This killedtest_tofu_flow(intermittent SIGPIPE/SEGFAULT) and would also killvoicecat-serverandvccliin production when a peer dropped mid-write. Fixed by ignoring SIGPIPE (std::signal(SIGPIPE, SIG_IGN)) in both the core client init (core/src/core/client.cppPOSIX branch of the#ifdef _WIN32WSAStartup block) and the server startup (server/src/server.cppbefore theasio::signal_set). Both are POSIX-only (#ifndef _WIN32), process-global, and idempotent. The server'sasio::signal_set(SIGINT, SIGTERM)is unaffected (independent signals). - Use-after-free of Asio's kqueue reactor on server shutdown — the root cause of the
deterministic
test_tofu_flowsegfault (EXC_BAD_ACCESS inkqueue_reactor::deregister_descriptor(this=0x0000000000000000)).TcpServerConn'stls_read_loopruns on a dedicated blocking-I/O thread (not async onio_context). WhenServer::stop()→io.stop()→Server::run()returned, the localasio::io_contextwas destroyed whiletls_read_loopthreads were still running. When a thread detected the disconnect and calledTcpServerConn::close()→socket.close()→ Asio tried to deregister from the kqueue reactor — but the reactor (owned byio_context) was already destroyed, and on macOS kqueue the reactor pointer is null'd immediately. Latent on Windows (IOCP) and Linux (epoll) where the timing is more forgiving. Fix:TcpAcceptornow tracks its connections (newconns_vector +conns_mu_);TcpAcceptor::stop()closes all tracked connections whileio_contextis still alive; newTcpAcceptor::shutdown()method callsstop()thenwait_closed()on each connection (newTcpServerConn::wait_closed()joinstls_thread_);Server::run()callsacceptor.shutdown()afterio.run()returns and beforeiois destroyed;Server::stop()'sstop_fn_now callsacceptor.stop()+media_relay->stop()+io.stop()(was justio.stop()). Afteracceptor.shutdown(), whentls_read_loopthreads exit and callon_disconnected→ConnSession::close()→TcpServerConn::close(), theclose()is a no-op (closing_.exchange(true)returns true) — no reactor access occurs afteriois destroyed.
- Environment setup: vcpkg cloned to
~/code/vcpkg+ bootstrapped.VCPKG_ROOTmust be set. Homebrewautoconf-archiveis required (vcpkg's libsodium port needs it for autoreconf —brew install autoconf-archive).autoconf/automake/libtoolwere already installed;glibtoolize(Homebrew's macOS name forlibtoolize) is handled by vcpkg automatically. - Verified:
cmake --preset dev+cmake --build --preset devgreen (21 binaries).ctest --preset dev --parallel 1— 21/21 green (2 consecutive runs, 64s each).cmake --preset apple-dev+cmake --build --preset apple-devgreen → valid 1.9 MB arm64libvoicecat.a(167 exported C ABI symbols, correct visibility).xcodebuild -create-xcframework→ validVoiceCatCore.xcframework(macOS-arm64 slice withvoicecat.hheaders). Server runtime:voicecat-serverstarts, generates identity, SQLite, Lobby, binds TCP+UDP, clean SIGINT shutdown. Twovcclitext chat over TLS (M1 exit criterion on Mac).vccli --voice --input-mode vadstarts a MIC stream via CoreAudio (M2 protocol-level on Mac — ear test pending).vccli --list-devicesenumerates 4 CoreAudio input + 3 output devices with correct defaults. - Apple framework linking: NOT needed — modern macOS ld (Xcode 26.5) auto-discovers
CoreAudio/CoreFoundation frameworks in
/System/Library/Frameworkswithout explicit-frameworkflags. miniaudio'sMINIAUDIO_IMPLEMENTATIONcompiles the CoreAudio calls inline, and the linker resolves them automatically. Noif(APPLE)CMake block was added. - Docs updated:
docs/building.md(apple-devrow + §6 updated from "scaffolding" to "validated"),clients/apple/README.md(macOS slice build confirmed, iOS slices still scaffolding),PROGRESS.md(this entry). - Still deferred (per scope): macOS
SCREEN_AUDIOloopback via ScreenCaptureKit (stub returnsfalse— perdocs/voice.md §9); iOS cross-compile presets (apple-ios/apple-ios-sim— scaffolding);vc_audio_suspend/vc_audio_resumeABI hooks (defer to iOS client milestone, keep ABI stable).
- Missing
-
Done: CMake preset cleanup + cross-platform build config (2026-06-18). The preset set was a mess —
dev(never used),m1-dev(the one everyone used),m2-dev(cache-identical tom1-dev, never used), no optimized+tests preset, no stripping. Cleaned up to a sensible set + added cross-platform triplet auto-resolution + Apple platform scaffolding:- Renames:
dev→skeleton(no-deps stub smoke — accurately named now);m1-dev→dev(the default development preset — milestone-named presets were misleading since the project is past M5);m2-devdropped (cache-identical tom1-dev). - New presets:
release(optimized Release + tests on, symbols kept — run the suite against optimized code or profile);apple-dev/apple-ios/apple-ios-sim(scaffolding — staticlibvoicecat.aslices for the Swift Package / XCFramework; marked "not yet CI-validated, build on macOS to verify"). server-releasenow strips binaries (CMAKE_EXE_LINKER_FLAGS=-s+CMAKE_SHARED_LINKER_FLAGS=-s) — smaller executables for deployment.- Cross-platform triplet auto-resolution: new
cmake/voicecat-toolchain.cmakewraps vcpkg's toolchain and resolvesVCPKG_TARGET_TRIPLET/VCPKG_HOST_TRIPLETfromCMAKE_HOST_SYSTEM_NAME+CMAKE_HOST_SYSTEM_PROCESSOR—x64-mingw-staticon Windows,x64-linuxon Linux,arm64-osxon Apple Silicon. The main presets (dev,release,server-release) now work on all three platforms without per-OS variants. Cross-compile presets (apple-ios,apple-ios-sim) overrideVCPKG_TARGET_TRIPLETexplicitly. Hidden base preset renamedvcpkg-base→vcpkg-common(now points at the wrapper toolchain instead of vcpkg's toolchain directly). - No C++ source changes — the core was already portable (every
#ifdef _WIN32inclient.cppalready had a POSIX#else;voicecat.h's export macro already handled GCC visibility;transport.cppis pure Asio; miniaudio abstracts WASAPI/CoreAudio/ALSA). - Docs updated:
docs/building.md(full rewrite — 8-preset table, platform matrix, preset history mapping old names to new, Apple scaffolding section),CLAUDE.md(build section reframed arounddevas default, status line updated to M5-done),README.md(stale "M0 skeleton" framing replaced with current M5 reality),AGENTS.md(build section updated, stale "placeholder builtin-baseline" sentence deleted),docs/deployment.md(server-release now stripped + cross-platform note),docs/tech-stack.md§4 (triplet auto-resolution + Apple scaffolding note),clients/apple/README.md(new "Building the core for Apple platforms" section with XCFramework workflow),clients/windows/README.md(m1-dev→dev). - Code comments updated:
core/include/voicecat.h,core/src/net/transport.h,core/src/voicecat.cpp,tests/CMakeLists.txt— preset name references updated. - Historical
PROGRESS.mdentries left intact —m1-dev/m2-devmentions in older entries are a true record of what was run; rewriting them would falsify history. The preset history table indocs/building.md§1 maps old names to new. - Verified:
cmake --list-presetsshows all 8 presets.skeleton+devconfigure and build green on Windows. Apple presets are scaffolding (won't build on Windows — expected; they require macOS).
- Renames:
-
Done: Disconnect, timeout & keepalive system (2026-06-18). Three reported bugs traced to one root cause + two missing designed features, all fixed:
- Stale users after disconnect + eternal PLC hiss (root cause):
ConnSession::close()silently erased dropped users from the registry without broadcastingUserEvent::LEFT. Peers never learned the user left → their user lists stayed stale AND their audio engines never calledremove_stream→ Opus PLC synthesized comfort noise forever (the "soft hissing that never goes away"). Fix:SessionRegistry::broadcast_left()helper (mirrorskick_user's first half);ConnSession::close()now broadcasts LEFT before erasing. Regression testtest_disconnect_left(tests/). - PLC cap (defense-in-depth):
AudioEngine::on_playbacknow caps pure-PLC at ~2 s (kPlcCapSamples); after that it emits digital silence instead of more comfort noise, so a stale stream can never hiss forever even ifremove_streamis never called. Resets automatically when fresh packets arrive. Testtest_plc_cap(tests/). - No timeout / no ping (missing feature): the client never sent
Ping, the server had nolast_seen/ reaper, and half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client sendsPingevery 15 s from the io thread (RTT measured fromPongnonce);ConnSession::last_seenbumped on every inbound TCP/UDP frame;asio::steady_timerreaper sweeps every 15 s and drops sessions older than 45 s (configurable viaserver::Config::reaper_timeout_ms/reaper_sweep_ms). Each drop broadcasts LEFT via fix #1. Testtest_reaper_timeout(tests/, 2 s timeout for fast turnaround). - UDP KEEPALIVE (missing feature): client sends a plaintext
KEEPALIVEframe every 5 s (voice_frame.h::kFrameKeepalive); server bumpslast_seen+ echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. - Graceful client disconnect:
vc_disconnect()now sendsDisconnect{code=0}when fully authenticated (VC_STATE_CONNECTED): it queues the envelope, sets agraceful_disconnect_pending_flag, and joins the io thread — the io thread'sdrain_sends()sends the Disconnect, sees the flag, setsio_stop_, and exits naturally (its cleanup handlesteardown_voice()+ socket close). No main-thread socket close → no double-close race. Server handles client-sentDisconnect(handle_client_disconnect→close()→ immediate LEFT broadcast, no reaper/EOF wait). In non-authenticated states, the original force-close path runs (with TOFU unblock).
- Docs updated:
docs/protocol.md §6/§7(graceful disconnect, pinned N=3, reaper, last_seen),docs/voice.md §6(KEEPALIVE plaintext + echo + last_seen),docs/architecture.md §5(reaper timer),server::Config(reaper fields).ctest --preset m2-dev— 21/21 green (3 new tests: disconnect_left, plc_cap, reaper_timeout).
- Stale users after disconnect + eternal PLC hiss (root cause):
-
Done: Stereo screen-audio loopback capture on Windows (2026-06-17). The WASAPI loopback path (
start_loopback_capture) used to hardcodecfg.capture.channels = 1, downmixing the system's stereo mix to mono before the encoder ever saw it — so even on a stereo channel,SCREEN_AUDIOwas effectively mono (the encoder then upmixed L=R to produce a fake stereo bitstream). Now the loopback device opens in the channel's mode: stereo (interleaved L/R) when the channel is configured stereo, mono when mono. Real stereo flows end-to-end through loopback → Opus encode → decode → stereo playback mixer.audio_engine.h—CaptureCallbackgained anint channelsparameter (the encoder needs to know whether the PCM is real stereo or mono to avoid upmixing real stereo).start_loopback_capture(int kind)→start_loopback_capture(int kind, int channels). Newloopback_channels_member; newfeed_loopback_for_testtest hook (self-contained, works on headless CI where the real WASAPI device can't init).audio_engine.cpp—on_loopbackaccumulator is now channel-aware (sized toframe_samples_*loopback_channels_);start_loopback_capturesizes the accumulator off the RT thread beforema_device_start, opens the device withchannels, and falls back to mono if the render endpoint rejects stereo (mirrors the playback path's fallback).on_capture/inject_capture/feed_capture_for_testforwardchannelsthrough the callback (mic path always 1; loopback path 1 or 2).client.cpp—on_capture_frametakeschannels; forchannels==2(real stereo loopback PCM) it encodes directly with no upmix; forchannels==1on a stereo channel it keeps the existing L=R upmix (mic stays mono in v1).handle_stream_announce_resultreadseffective_params.stereounder the lock and passes2or1tostart_loopback_capture.test_vad_ptt_devices.cpp— newtest_loopback_stereo_capturebehavior test: feeds a loud-L / silent-R stereo signal throughfeed_loopback_for_test, encodes (ason_capture_framenow does forchannels==2), decodes, mixes, and asserts L≠R across the frame (total_diff ~8.2M, well above the 960k threshold). A mono-downmixed-then- upmixed bitstream would have L==R. Existing test lambda updated for the 4-arg callback.docs/voice.md §8/§9— diagram + notes updated: mic stays mono;SCREEN_AUDIOloopback captures stereo when the channel is stereo.cmake --build --preset m2-dev+ctest --preset m2-dev --parallel 1— 18/18 green. Thevad_ptt_devicesVAD-gate sub-test has a pre-existing parallel-run timing flake (passes serially and in isolation); unrelated to this change (VAD gate logic is unchanged forchannels==1, the only path the MIC uses).
-
Done: Screen-audio sharing wired into the Windows WinForms client (2026-06-17). The core already fully supported
SCREEN_AUDIOcapture on Windows (post-M3 WASAPI loopback viaVOICECAT_HAS_LOOPBACK, always on for thewindows-clientpreset —core/CMakeLists.txt:85; loopback start/stop atclient.cpp:1093/audio_engine.cpp:529; VAD/PTT/self-mute/server-mute correctly bypassed for non-MIC kinds atclient.cpp:862-879) and the C# Interop layer was already complete (VcStreamKind.ScreenAudio,StartStream/StopStream/SetRemoteStream/ListUserStreamsall generic). The gap was purely UI wiring. No core, proto, or C ABI changes were needed — confirming the "the core should support it already" assessment.MainForm.Designer.cs— newbtnScreenShareTogglebutton in the voice panel top row (flpVoiceTop), right afterbtnMicToggle, with fullAccessibleName/AccessibleDescriptionper the existing accessibility convention.MainForm.cs— new_screenStreamIdfield;BtnScreenShareToggle_Clickhandler mirroringBtnMicToggle_Clickbut with no device picker / VAD / PTT / mode / mute (screen audio bypasses all of those in the core). Independent of mic — can share without joining voice and vice versa.HandleDisconnectednow resets_screenStreamIdand disables the screen toggle.OnFormClosednow explicitly stops both mic and screen streams beforeDisconnect()(cleanStreamStopmessages go out before the control channel closes).HandleStreamStartedalready labeledScreenAudio => "screen audio";PerUserTuningDialog.ApplySettingsalready iterates all of a peer's streams — both unchanged, peers can independently volume-tune a screen-audio stream vs that user's mic.VoiceCatClientSmokeTests.cs— newScreenAudioStream_Starts_And_Stopstest: connects + TOFU + guest auth,StartStream(ScreenAudio), assertsVC_EVENT_STREAM_STARTEDarrives with matchingStreamId,StopStream, assertsVC_EVENT_STREAM_STOPPED. Passes headless (theStreamAnnouncesucceeds regardless of whether the loopback device initializes on a CI box).dotnet build— 0 warnings/errors.dotnet test— 4/4 tests green (3 existing + 1 new). Event trace confirms the fullStreamStarted → UserUpdated → StreamStoppedpath through P/Invoke against a livevoicecat-server.exe.- Not yet confirmed audible by ear — pending manual two-instance live test (one
shares screen audio while something plays on the default render endpoint, the other
hears it). This is the observable-behavior exit criterion per
AGENTS.md. - Documented caveat (
docs/voice.md §9, unchanged): whole-device WASAPI loopback inherently re-captures this app's own incoming voice mix (self-echo loop) — accepted characteristic, not a bug. Process-specific loopback (Windows 10 2004+AUDIOCLIENT_ACTIVATION_PARAMS) is a future enhancement; miniaudio doesn't expose it.
-
In progress: M5 — moderation & admin (2026-06-17). Server-side and C ABI are implemented and tested: permissions, kick/ban/move/server-mute, channel CRUD, in-app account management. Four new tests pass:
test_m5_permissions,test_m5_kick_ban_move_mute,test_m5_admin_accounts,test_m5_channel_crud.vcclinow exposes all M5 operations via CLI flags (--kick,--ban,--move,--server-mute/-unmute/-deafen/-undeafen,--set-permission,--create-channel,--edit-channel,--delete-channel,--create-account,--reset-password,--delete-account,--list-accounts) plus--username/--passwordfor account auth and--self-mute/--self-deafen. Docs updated:docs/protocol.md(envelope tags forServerMuteRequest/ListAccountsResult,User.server_deafened,GenericResultusage),docs/security.md(BLAKE2b channel passwords,bansschema).ctest --preset m1-dev— 18/18 green. Windows WinForms UI now exposes all M5 operations: channel CRUD with full per-channel Opus audio config, user moderation (kick/ban/move/server mute/server deafen/set permissions), and server account management.dotnet testof the Windows solution passes. Still to do: DRED/audio-quality polish. -
Done: Fixed multi-user voice — relayed frames failed AEAD decryption (nonce desync) (2026-06-17, reported live: with 2+ people in a channel, audio was one-directional — "I can hear them but they can't hear me" — and a 3rd joiner heard nobody). Root cause was in the SFU relay (
server/src/media_relay.cpp). The media AEAD nonce is an implicit per-direction monotonic counter;open()reconstructs it from the 14-byte header'sseqfield (the AAD), so the wire contract isheader.seq == the counter seal() used(the client honors this atclient.cpp:907). The relay decrypted each inbound frame with the sender's key, then re-sealed with the recipient'ssend_crypto(its own counter) but forwarded the sender's header verbatim — soheader.seqcarried the sender's counter, not the recipient's. The recipient'sopen()rebuilt the wrong nonce → every relayed frame failed auth and was silently dropped. It only "worked" while the sender's counter coincidentally equalled the server→recipient counter (a single first-ever sender into a fresh recipient), which is exactly why the first/sole talker was heard but reverse/3rd-party audio was not. Fix: before re-sealing, the relay rewrites the outgoing header'sseq(bytes [8..9]) to the recipient'speek_send_counter(), so each server→client direction is one contiguous monotonic counter and the nonce always matches (the anti-replay window also stops seeing false replays from interleaved senders). Safe because the jitter buffer orders bytimestamp, notseq(audio_engine.h);seqexists only to carry the AEAD counter. No wire-format/proto/ABI change. Regression test added intests/test_media_aead.cpp(test_relay_interleaved_reseal): two senders interleaved into one recipient all decrypt with the fix, and the verbatim-seq path is asserted to fail.ctest --preset m1-dev— 18/18 green (run via PowerShell; Git Bash can't resolve the runtime DLLs.vad_ptt_devicesis timing-flaky over loopback — passes on re-run — unrelated to this fix). Latent, separate: the secondary "3rd joiner sometimes can't see other users" report is a control-plane (TCP snapshot/UserEvent) issue, not this AEAD bug — re-verify after live testing before investigating. Also still latent: the 16-bitseqwraps after 65536 frames per direction (faster on a busy relay) with no ROC, so the implicit counter desyncs on long continuous sessions (crypto.cppopen() TODO). -
Done: Fixed "randomly bumped to Lobby" in the Windows client — actors were excluded from their own state-change broadcasts (2026-06-17, reported live: a connected client would intermittently snap from its joined channel back to Lobby in the UI). Root cause was a design inconsistency, not a disconnect: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private
*Resultto the actor and broadcast the authoritativeUserEvent::UPDATEDto everyone else. The core never applied the join result to itsSessionModel, sovc_list_users()kept self in the old channel; the WindowsHandleUserUpdatedrebuilds_currentChannelIdfromvc_list_users()on any user's UPDATED event, so the next unrelated event (someone joining, announcing/stopping a stream, being muted) surfaced the stale self-channel → "bumped to Lobby." Flaky because it depended on other users' activity. Fix (broad, per the actor-sees-own-changes principle): the server now broadcasts theseUserEvent::UPDATEDs to all clients including the actor (server/src/conn_session.cpp,broadcast(…, /*exclude*/ 0)), and text fan-out now includes the sender (resolve_text_targets), so every client converges via one authoritative path. The*Resultis now purely ack/correlation/actor-private payload; response handlers no longer mutate the local model. Windows client drops its optimistic text echo (the relay comes back) and renders the sender's own message viaHandleTextMessage. Documented the response-vs-broadcast contract indocs/protocol.md§6. Registry-level admin broadcasts (move/mute/kick/channel CRUD) already usedexclude=0and were correct.ctest --test-dir build/m1-dev— 18/18 green (PowerShell); WindowsVoiceCat.Appbuilds 0 warnings.Latent, not fixed: the client never sends theResolved (2026-06-18): full keepalive/timeout/disconnect system implemented — see "disconnect, timeout & keepalive" entry below.Pingkeepalive thatdocs/protocol.md§7 describes (only the server answers pings) — unrelated to this bug, noted for later. -
Done: Fixed a second silent-playback bug — the playout clock free-ran and drifted off the stream (2026-06-17, reported live: both
vccliand the Windows client showedtalking=1/0correctly on VAD/PTT, mic + screen-share were recognized by peers, but nothing was audible). Root cause:RemoteStream::playout_tswas only ever seeded to0and then advanced one Opus frame per playback callback via the PLC path too (core/src/audio/audio_engine.cppon_playback), so it free-ran at ~1× wall-clock regardless of whether the sender was transmitting. The sender's frame timestamps only advance while it actually sends (the VAD/PTT gate incore/src/core/client.cppreturns beforels.timestamp += samples). Across a late join or any VAD/PTT silence gap the two clocks diverged without bound; once past the jitter buffer's 500 ms late-drop window, every real frame was dropped-as-late (clock ahead) or never-due (clock behind) → permanent silence, while the talk indicator (driven bypush_recv_frame, independent of the jitter buffer) stayed lit. The M3 E2E test missed it because clients there talked continuously right after joining, keeping the clocks aligned. Fix:JitterBuffergainedpeek_front_ts()(try-lock, RT-safe);on_playbacknow seeds/re-syncsplayout_tsto the earliest buffered frame on the first frame and whenever it has drifted past ±200/500 ms (kResyncAheadSamples/kResyncBehindSamples), which both seeds startup and recovers after every silence gap. New regression testtest_playout_resync(tests/test_vad_ptt_devices.cpp): free-runs the clock ~2 s past the drop window, pushes ats=0frame, asserts audible output — verified to fail (energy=0) with the fix disabled, pass (energy≈15M) with it.ctest --test-dir build/m1-dev— 14/14 green (run via PowerShell; Git Bash exec gotcha for these binaries, seedocs/building.md). Not yet confirmed audible by ear — pending the user re-running their live test. -
Done: Fixed silent-playback bug in
AudioEngine::on_playback(2026-06-16, found via live manual test: twovccli --voiceclients, control-plane events and VAD all correct, but zero audible output). Root cause:opus_decode()'smax_sampleswas being passed the hardware playback callback's frame count (miniaudio's own choice, frequently smaller than one Opus frame — e.g. ~480 samples on default low-latency WASAPI periods), instead of the decoder's fixed frame size (960 @ 20ms/48kHz). Since the real packet almost always decodes to more samples than that,opus_decodereturnedOPUS_BUFFER_TOO_SMALLon nearly every callback — frames were correctly received/decrypted/jitter-buffered, just never decoded into audible PCM.mix_for_test()'s white-box test masked this because it always calledon_playbackwithframes == frame_samples, the one case where the bug is invisible. Fix:RemoteStream(core/src/audio/audio_engine.h) gained a small ring buffer (init_ring/push_ring/pop_ring) that decouples decode cadence from playback-callback cadence —on_playback(core/src/audio/audio_engine.cpp) now tops the ring up by decoding whole Opus frames (decoder.frame_samples(), never the hardwareframes) and drains exactlyframessamples-per-channel from it each callback, silence-padding (PLC) on underrun. Also fixes a latentplayout_tsbug: it now advances by the actual decoded sample count per Opus frame, not by the hardware callback's (unrelated) frame count, which was the wrong unit for jitter-buffer timestamp comparisons.ctest --test-dir build/m1-dev— 12/12 green (run via PowerShell; Git Bash exec gotcha for these binaries, seedocs/building.md). Not yet confirmed audible by ear — pending the user re-running their live two-vcclitest. -
Done: Post-M3 follow-up — device enumeration, VAD/PTT gate, stereo playback, WASAPI loopback ✓ complete (2026-06-16). Closes all three items M3 explicitly carried forward as out of scope (see the dated section below for the full file-by-file change list).
ctest --test-dir build/m1-dev— 12/12 tests green (3 consecutive full-suite runs), including the newtest_vad_ptt_devices(realvc_clients against a real server, plus a white-boxAudioEnginestereo-mix check — same ABI-level-coverage lesson as M2/M3). Manually verified live:vccli --list-devicesagainst real hardware, andvccli --voice --input-mode vadconnecting/streaming without incident. Still explicitly out of scope (carried forward, not silently dropped):- Real
webrtc-audio-processing/AEC — no working Windows/MSVC build upstream; v1 ships a lightweight energy/RMS VAD instead (see docs/roadmap.md §2, docs/voice.md §8/§11). There is no AEC, NS, or AGC implementation at all, not just a deferred VAD.vc_set_remote_stream(..., noise_reduction)'s per-stream NS toggle is unaffected by this pass and stays exactly as inert as it was after M3 (ApmPassthrough, no PCM modification). - macOS/iOS
SCREEN_AUDIOcapture (ScreenCaptureKit / ReplayKit) — this pass is Windows-only for real loopback capture; other platforms keepvc_test_inject_captureas the only way to feedSCREEN_AUDIO. - Process-specific WASAPI loopback — miniaudio's loopback mode captures the whole render endpoint (including this app's own incoming voice mix), not a single process.
- The pre-existing RT-thread rule violation in
on_capture_frame/AudioEngine::on_capture(mutex lock, heap allocation, blockingsendtoon the miniaudio real-time callback thread) — predates this work, documented but not fixed; fixing it needs the lock-free ring-buffer hand-offdocs/architecture.md §3specifies, a separate, larger refactor.
- Real
-
Done: M4 — Windows WinForms C# client ✓ complete (2026-06-17). Full details in the M4 section below.
ctest --preset m1-dev— 14/14 tests green.dotnet build— 0 warnings/errors across all three C# projects. Manually verified: saved servers, TOFU first-connect dialog, channel tree, join, voice (VAD/PTT/always-on + sensitivity slider + per-user gain/mute/NR), text chat (channel + private), device pickers, level meter. -
Next: macOS/iOS Swift client (M4 continued) and/or M5 (admin UI, moderation, kick/ ban). The C ABI is complete and stable through M4 — both directions are unblocked. See
docs/roadmap.md §M4–M5.
Milestones (see docs/roadmap.md for full detail)
- M0 — Scaffolding ✓ complete
- M1 — Control plane ✓ complete (2026-06-15)
- M2 — Voice, single stream ✓ complete (2026-06-16)
- M3 — Multi-stream & per-channel tuning ✓ complete (2026-06-16)
- M4 — Native clients — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19)
- [~] M5 — Moderation, polish, beyond (perms, bans, DRED; then file transfer, E2EE, …)
M0 — Scaffolding ✓ (completed)
- Repo layout (
core/ server/ tools/ clients/ tests/), CMake + presets, vcpkg manifest. - C ABI header
core/include/voicecat.h(full surface, stubbed). - Protocol source-of-truth
core/proto/voicecat.proto(matches docs/protocol.md). - Core stubs for all six subsystems (net/crypto/codec/protocol/session/audio) +
vc_client. voicecat-server(arg parsing, config, stub run) andvccli(drives the C ABI).- CTest smoke test asserting the C ABI contract (not just "it compiles").
.gitattributes(LF),.gitignore,.clang-format, onboarding docs.- Verified:
cmake --preset dev && cmake --build --preset dev && ctest --preset dev→ green.
M1 — Control plane ✓ (completed 2026-06-15)
Exit criterion: ✓ test_m1_integration — two clients authenticate over TLS 1.3 (guest
- Argon2id password), exchange channel and private text messages. Passes in ~1 s.
- vcpkg baseline +
m1-devpreset;find_packagefor protobuf/mbedTLS/libsodium/asio/sqlite3. FrameCodecfeed + emit;encode_envelope/decode_envelope.- Asio TCP acceptor +
TcpServerConn(TLS path: blocking handshake thread +tls_read_loop). TlsContext(mbedTLS 1.3, server cert/identity, ECDSA-P256 self-signed, TOFU on client).WorkerPool(3 threads, used for Argon2id).Database— SQLite, Argon2id via libsodium,create_account/authenticate/ bootstrap admin.voicecat-admin— account add/reset/del/list against live DB file.ServerIdentityManager— generate/persist Ed25519 key + cert; fingerprint display.ConnSession— WaitingHello → WaitingAuth → Authenticated state machine; full protocol relay.SessionRegistry— channel tree, user map, broadcast, text routing.vc_client(client.cpp) — full M1 C ABI: connect/TLS/ClientHello/AuthRequest/text/disconnect.Server::run()— io_context, acceptor, worker pool, signal handling,on_readycallback.test_m1_integration— M1 exit criterion. Verified green 2026-06-15.
Key bug fixed: double-framing in ConnSession::send_envelope — encode_envelope was
pre-framing the protobuf, then TcpServerConn::send_frame re-framed it. Fixed by serializing
raw protobuf bytes directly and letting send_frame add the single [4-byte len] prefix.
M2 — Voice, single stream ✓ (completed 2026-06-16)
Exit criterion: ✓ test_m2_voice — two headless clients authenticate over TLS, bind UDP,
announce a MIC stream, send 50 encrypted Opus frames; server SFU relay re-encrypts + forwards
to the second client; B receives ≥ 25 frames and all decrypt correctly. Passes in ~4 s.
m2-devpreset (inheritsvcpkg-base, binaryDirbuild/m2-dev);m1-devalso builds all M2 code.core/CMakeLists.txt—find_package(Opus),find_path(MINIAUDIO_INCLUDE_DIR).core/src/net/voice_frame.h— 14-byte UDP header (type/flags/codec/ssrc/seq/ts), serialize/parse,make_udp_binding_packet.SodiumMediaCrypto— ChaCha20-Poly1305 AEAD; counter-nonce; 64-bit sliding-window anti-replay;derive_send/recvfrom TLS RFC 5705 exporter.OpusEncoder/OpusDecoder— libopus 1.6, FEC, DTX, PLC (free; nullptr → decoder extrapolates).UdpMediaChannel— async UDP socket (asio); thread-safesend_to; async recv loop.JitterBuffer— per-ssrc, EWMA jitter estimation, adaptive depth 20–200 ms, late-drop at 500 ms.AudioEngine— miniaudio capture+playback;inject_capture()bypass for headless tests; per-ssrc RemoteStream with OpusDecoder + JitterBuffer.ApmProcessor—ApmPassthroughstub (VAD always open); WebRTC APM deferred until M3.on_tls_readycallback inTcpChannelCallbacks— server derives and stores media AEAD keys immediately after TLS handshake.ConnSessionM2 —udp_tokengenerated at construction; included inAuthResult;handle_udp_binding(verifies token, TCP ack);handle_stream_announce(assigns SSRC via registry);udp_media_portinServerHello.SessionRegistryM2 —register_udp_token,find_by_udp_token,register_udp_endpoint,find_by_udp_endpoint,assign_ssrc,find_channel_sessions,user_channel.MediaRelay— SFU UDP relay;kFrameUdpBinding→ endpoint binding;kFrameVoice→ decrypt/re-encrypt/forward to channel members.Server::run()— creates and bindsMediaRelay; passes media port toConnSession; wireson_tls_readyto derive per-connection media AEAD keys.test_voice_frame— header round-trip, big-endian layout, binding packet format.test_media_aead— seal/open round-trip, anti-replay, tamper detection, multi-packet sequence.test_opus_codec— encode/decode round-trip energy check (within 3 dB), PLC, frame-samples helper.test_m2_voice— M2 exit criterion (raw-socket harness). Verified green 2026-06-16.
Follow-up (same day): the above made test_m2_voice pass, but vc_client's public voice
methods were still stubs — the actual M2 exit criterion ("two vccli/early-GUI clients talk")
wasn't met. Closed the gap:
core/src/core/client.cpp— realstream_start/stream_stop/set_self_mute/set_remote_stream; UDP-binding handshake (start_udp_binding/handle_udp_binding_ack/finish_udp_binding); media key derivation fromtls_(RFC 5705 exporter);run_udp_recv(AEAD-open →JitterBuffer::Frame→audio_engine_.push_recv_frame);on_capture_frame(encode → seal →sendto);sync_remote_streams(diffs aUserproto'sstreamsagainstremote_streams_, wiring upOpusDecoders and emittingSTREAM_STARTED/STOPPED).set_input_device/set_input_mode/set_push_to_talk/list_devicesremainVC_ERR_NOT_IMPLEMENTED— no device-enumeration backend yet; scoped to M3 (VAD/PTT).core/src/session/session.cpp/h—SessionModel::find_user,find_user_by_ssrc,Stream{stream_id, ssrc, kind, label, sample_rate, frame_ms}.server/src/conn_session.cpp/h—handle_stream_announce/handle_stream_stopnow broadcast viaSessionRegistry::set_user_stream/clear_user_stream→UserEvent::UPDATED.server/src/session_registry.cpp/h—set_user_stream/clear_user_stream(mutate a user'sStreamInfolist, return the updatedUserproto for broadcast).tests/test_voice_client_abi.cpp— drives two realvc_clientinstances throughvc_connect/vc_authenticate_guest/vc_stream_start/vc_stream_stop; asserts client B observes client A'sSTREAM_STARTED/STOPPEDevents. Verified green 2026-06-16.tools/vccli/src/main.cpp— argv parsing (--host/--port/--nick/--channel/--voice/ --mute/--text);--voicestarts a MIC stream and blocks on SIGINT, printingon_eventcallbacks live (unbuffered stdout — MinGW/MSVCRT treat_IOLBFas full buffering for non-console streams). Dropped the originally-planned--voice-loopbackand thetx=N rx=M lost=K jitter=Jstats line:voicecat.hexposes no PCM-injection hook or jitter/loss stats getter publicly, onlyon_event+on_level(RMS). Manually verified: twovccli --voiceinstances see each other's stream start in real time.
M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
Exit criterion: ✓ test_m3_multistream — a real vc_client (A) runs two concurrent local
streams (MIC + SCREEN_AUDIO) with distinct stream ids; a second client (B) sees both as
separate STREAM_STARTED events and a VC_EVENT_TALK_STATE talking edge for A's MIC stream;
B independently sets gain/mute/noise-reduction on each of A's streams without one call
affecting the other; A then joins "Music Room" (channel 2: stereo/128kbps/OPUS_AUDIO/no
DTX) and announces a fresh MIC stream there, while B stays in "Lobby" (channel 1: mono/24kbps/
OPUS_VOIP/DTX on) — vc_get_stream_audio_config shows their effective Opus config differs
exactly as the server enforces per channel. Passes in ~2.4s; verified across 8 consecutive
standalone runs + 3 consecutive full-suite runs with no flakes.
Exploration before implementing turned up several bugs/gaps where the wire format already supported this milestone but the client/server logic didn't — these were fixed as part of M3, not treated as pre-existing-and-out-of-scope:
- Server
stream_idbug —handle_stream_announcealways wrotestream_id=1, so a second stream from the same user silently overwrote the first inSessionRegistry::set_user_stream's replace-by-id logic. Fixed with a per-session counter (ConnSession::next_stream_id_) +announced_stream_ids_(also now validated inhandle_stream_stop, rejecting stops for ids the session never announced). - Per-channel
AudioConfigwas modeled but never populated/enforced.SessionRegistry::init_default_channels()now seeds Lobby (id=1: mono, 24kbps,OPUS_VOIP, FEC+DTX on) and a new "Music Room" (id=2: stereo, 128kbps,OPUS_AUDIO, FEC+DTX off) with realAudioConfigs; newSessionRegistry::channel_audio_config(channel_id)accessor (there was no per-id channel getter before, onlychannel_snapshot()).handle_stream_announcenow treats the channel's config as authoritative (mode/frame_ms/application/fec/dtx/ complexity), clamping (not overriding)bitrate_bpsto the channel's ceiling. - Client silently dropped
mode/dtx/complexity/applicationfromeffective_audioeven for the single M2 stream —handle_stream_announce_resultandsync_remote_streamsonly copiedsample_rate/bitrate_bps/frame_ms/fecintoOpusParams. New sharedopus_params_from_audio_config()helper (client.cpp) fixes both the send and receive paths. core/src/codec/opus_codec.h/.cpp— newOpusApplicationenum +OpusParams::applicationfield;OpusEncoder::initnow honors it instead of hardcodingOPUS_APPLICATION_VOIP.core/src/session/session.h/.cpp—Streamstruct extended with the fullAudioConfig(mode/bitrate_bps/application/fec/expected_packet_loss/dtx/complexity), not just sample_rate/frame_ms;copy_streams()now copies all of it.core/src/core/client.h/.cpp— local-stream state is now astd::unordered_map<int, LocalStream>keyed byvc_stream_kind(one active stream per kind — MIC/SCREEN_AUDIO/ AUX_DEVICE are each singletons for a client), replacing the M2 single-stream fields.StreamAnnounce/StreamAnnounceResultround-trips are now correlated byrequest_id(already round-tripped on the wire; just wasn't read) viapending_announce_kind_, so multiple concurrent announces from one client resolve to the rightLocalStream.on_capture_frametakes akindandchannelsparameter; for mono capture (channels==1) on a stereo channel it upmixes L=R, and for real stereo capture (channels==2, theSCREEN_AUDIOloopback path on a stereo channel) it encodes directly with no upmix.vc_set_self_mute'smic_mutedonly gates theMICkind — a concurrentSCREEN_AUDIOshare keeps playing while muted.set_remote_streamnow actually wiresnoise_reductionthrough (previously parsed and discarded). Newrun_talk_timer()(a small dedicated thread, started alongside the UDP media path, never the miniaudio callback thread) polls both remote talk-state edges (AudioEngine::poll_talk_transitions()) and local capture-activity edges, emittingVC_EVENT_TALK_STATE.- Fixed a thread-join race in
teardown_voice()— it's called both fromrun_io()'s own cleanup and fromdisconnect(), on different threads; without serialization both could seeudp_thread_/talk_timer_thread_asjoinable()simultaneously and race tojoin()the samestd::thread(UB; surfaced as an intermittentstd::system_error: No such processunderctest). Added ateardown_mu_guard around the whole function. This pre-existed forudp_thread_alone (likely the same root cause as thetest_m1_integration/test_m2_voicecleanup-path flake noted in the M2 section above) — addingtalk_timer_thread_'s join just made it surface more often, so it was fixed properly here rather than carried forward again. core/src/audio/audio_engine.h/.cpp—CaptureCallbackgained akindparameter (the real miniaudio capture device is always taggedkind=0/MIC; a second concurrent local stream is fed via its owninject_capture(kind, ...)ring buffer —inject_taps_, keyed by kind — since there is only one real hardware capture device in M3). Fixed a buffer-sizing bug inon_playback's per-stream decode (opus_decode'sframe_sizeparameter is samples-per-channel, not total samples — the old code passedframes * params_.channels, which would have overflowed the decode buffer for any stereo stream). Stereo decoder output is downmixed (avg L/R) into the engine's mono mix accumulator immediately after decode.RemoteStreamgainedrecv_ns/noise_reduction_enabled(lazyApmProcessorinstantiation — freed on disable, so no separate instance cap is needed per the roadmap's guidance) andlast_voice_ms/talking(talk-indicator edge state, updated inpush_recv_frame); newset_stream_noise_reduction()andpoll_talk_transitions(). Note: untilVOICECAT_HAS_APMis wired to a real WebRTC APM build, the NS toggle is plumbed end-to-end but behaviorally a passthrough no-op (ApmPassthroughdoesn't touch PCM) — same situation send-side APM has been in since M2; M3's job was the plumbing, not the DSP backend.- New C ABI surface (
core/include/voicecat.h, additive only):vc_audio_configstruct +vc_get_stream_audio_config(c, user_id, stream_id, out)— the effective Opus config for a stream you own or a peer's, reading from the (now richer)LocalStream/session::Stream.vc_test_inject_capture(c, stream_id, pcm, samples)— clearly-marked test-only, forwards toAudioEngine::inject_capture, sotest_m3_multistreamcan drive two concurrent synthetic-audio streams through the real ABI without a microphone. tests/test_m3_multistream.cpp— the M3 exit criterion (ABI-level, mirrorstest_voice_client_abi.cpp's approach per the M2 lesson). Registered intests/CMakeLists.txt.
Explicitly out of scope for this pass (confirmed with the user before implementing):
vc_set_input_device/vc_set_input_mode/vc_set_push_to_talk/vc_list_devices(device enumeration + VAD/PTT input gate) — stillVC_ERR_NOT_IMPLEMENTED. These were mentioned as "scoped to M3" in the M2 follow-up notes above, but docs/roadmap.md's M3 bullets never actually listed them — deferred again, now tracked explicitly rather than implicitly.- Real WASAPI desktop-audio loopback capture for
SCREEN_AUDIO— the engine now supports feeding a second concurrent local stream viainject_capture, but only synthetic PCM is wired up; a real loopback capture device is a follow-up. - True stereo playback output —
AudioEngine's mixer/output device stays mono; stereo streams are downmixed after decode (see above). The Opus wire format itself is fully stereo-correct.
Post-M3 follow-up — device enumeration, VAD/PTT gate, stereo playback, WASAPI loopback ✓ (completed 2026-06-16)
Closes all three items the M3 section above explicitly carried forward as out of scope.
Exit verification: ctest --test-dir build/m1-dev — 12/12 tests green (3 consecutive
full-suite runs), including the new test_vad_ptt_devices (device enumeration + VAD/PTT gate
through real vc_clients against a real server, plus a white-box AudioEngine stereo-mix
check — no audio hardware needed for that last part). Also verified 5 consecutive standalone
runs of the new test alone, no flakes. Manually verified live on Windows: vccli --list-devices against real hardware (3 input / 4 output devices, correct is_default
flags), and vccli --voice --input-mode vad connecting + streaming without incident.
- Device enumeration (
vc_list_devices) —AudioEngine::enumerate_devices(bool capture)(static, works without a running engine — inits a throwawayma_contextviama_context_get_devices).device_id/vc_device.idis an opaque hex-encoded rawma_device_id(not the device name — names aren't guaranteed unique); documented as an internal contract callers must round-trip, never construct by hand.vc_client::list_devicesworks in any connection state (noVC_STATE_CONNECTEDgate) since device pickers need to populate pre-connect.vc_free_device_listis now a real free (was a no-op stub). - Input device selection (
vc_set_input_device) — stores the device id on the targetedLocalStream(new field); for the MIC stream, if the engine is already running, restarts it (stop()+ensure_audio_running()) to pick up the new device. Simplified: it restarts unconditionally rather than trying to detect whether the device id actually changed (AudioEnginehas no getter for "current device"). - VAD/PTT input gate (
vc_set_input_mode,vc_set_push_to_talk) — newEnergyVadProcessor(core/src/audio/apm_processor.cpp) implementing the existingApmProcessorinterface: energy/RMS threshold (default ~0.025 normalized) + hang-time (default 300 ms, matchingkTalkHangoverMs). New factoryApmProcessor::create_vad(), kept separate fromcreate()(which recv-side per-stream NS still uses, unaffected by this pass).vc_clientgainedcurrent_input_mode_/ptt_active_/mic_vad_; the gate is inserted inon_capture_frame, MIC-only —SCREEN_AUDIO/AUX_DEVICEalways bypass it (gating a desktop-audio share on the user's own voice activity would silently drop shared music/video audio).last_capture_ms(drives the talk indicator) is now updated after the gate check, not before, so a VAD/PTT-closed frame never shows as "talking".mic_vad_is constructed once the MIC stream'sStreamAnnounceResultlands (onio_thread_), not lazily inside the capture path. - True stereo playback —
AudioParams::channelssplit intocapture_channels(stays- and
playback_channels(now 2, unconditionally).AudioEngine::on_playbackno longer downmixes decoded stereo streams to mono before mixing — stereo decode output is mixed directly (L→L, R→R); mono decode output is upmixed (duplicated into both channels). Falls back to a 1-channel playback device once if the 2-channelma_device_initfails (unusual hardware). New test-onlyAudioEngine::mix_for_test()exposes the mixer for white-box testing without a realma_device.
- and
- WASAPI loopback capture for
SCREEN_AUDIO— newVOICECAT_HAS_LOOPBACKmacro (core/CMakeLists.txt, Windows-only).AudioEnginegained a separateloopback_device_(own lifecycle, decoupled from the mic capture/playback devices) withstart_loopback_capture()/stop_loopback_capture(), using miniaudio'sma_device_type_loopbackagainst the default render endpoint. Its callback feedscapture_cb_directly (same pattern as the real mic capture device), not throughinject_capture()'s test-only ring. Wired intovc_client::handle_stream_announce_result(start, alongsideensure_audio_running()) andstream_stop(stop) forVC_STREAM_SCREEN_AUDIO. Non-Windows builds keepvc_test_inject_captureas the only way to feedSCREEN_AUDIO. tools/vccli/src/main.cpp— new flags--list-devices,--input-device,--input-mode vad|ptt,--share-screen-audio; while--voiceis running, a background stdin-reader thread acceptsptt on/ptt off/mode vad/mode ptt(the most portable way to drive PTT interactively from a headless CLI — no SIGUSR1 equivalent on Windows). Also printsVC_EVENT_TALK_STATE. Known minor caveat: on Windows the stdin-reader thread is detached (not joined) on exit, sincestd::getlinecan't be interrupted from another thread — avc_client*use-after-free is theoretically possible if a command line arrives in the brief window between teardown and process exit; acceptable for a headless test/dev tool.tests/test_vad_ptt_devices.cpp— new test covering all four items above; registered intests/CMakeLists.txt.tests/test_smoke.cpp's device-list assertion is now conditional onVOICECAT_HAS_AUDIO(was a hardVC_ERR_NOT_IMPLEMENTEDassertion) —VC_OKonly, nevercount > 0(a headless CI build agent may legitimately report zero audio devices).
Still explicitly out of scope (carried forward, not silently dropped):
- Real
webrtc-audio-processing/AEC — no working Windows/MSVC build upstream (see docs/roadmap.md §2's superseding decision-log entry). There is no AEC, NS, or AGC implementation at all, not just a deferred VAD. The per-stream NS toggle (vc_set_remote_stream(..., noise_reduction)) is unaffected by this pass and stays exactly as inert as it was after M3 (ApmPassthrough, no PCM modification) — don't mistake this pass for having fixed it. - macOS/iOS
SCREEN_AUDIOcapture (ScreenCaptureKit / ReplayKit) — Windows-only loopback in this pass. - Process-specific WASAPI loopback — whole-device capture only; inherently captures this app's own incoming voice mix along with everything else playing.
- The pre-existing RT-thread rule violation in
on_capture_frame/AudioEngine::on_capture(mutex lock, heap allocation for the stereo-upmix path, blockingsendto, all on the miniaudio real-time callback thread) — predates this work (was already present in M2/M3); documented here explicitly rather than silently carried forward again. Fixing it properly needs the lock-free ring-buffer hand-offdocs/architecture.md §3specifies — a separate, larger refactor, out of scope for this pass.
M4 — Windows WinForms C# client ✓ (completed 2026-06-17)
Exit criterion: ✓ ctest --preset m1-dev — 14/14 tests green (existing 12 + 2 new
C++ tests: test_channel_user_list_abi, test_tofu_flow). dotnet build — 0 warnings/errors.
Manually verified: connect, TOFU first-connect dialog, channel tree, join, voice, text, device
pickers, level meter. Accessibility: explicit AccessibleName/AccessibleDescription on every
control, & mnemonics on every button, activity-log ListBox as screen-reader record.
New C++ ABI surface (all additive, backward-compatible):
vc_list_channels/vc_list_users/vc_list_user_streams— pull-based snapshot getters for the channel tree + user list;session_model_mu_added for cross-thread safety.SessionModel::apply_snapshot/apply_channel_eventfixed to populateparent_id,password_protected,max_users(were permanently zeroed despite the struct declaring them).vc_join_channel(channel_id, password)— join with optional password; server replies via newVC_EVENT_JOIN_RESULT.VC_EVENT_SERVER_IDENTITY+vc_confirm_server_identity(accept)— TOFU gate that blocksio_thread_until the UI approves or rejects. Pins the TLS leaf-cert SHA-256 fingerprint (verifiable directly at handshake), not the declared Ed25519 value (see docs/security.md §1.1 for why — the TLS cert and Ed25519 key are generated independently, no binding).vc_get_server_identity_displayexposes the Ed25519 fingerprint for human-readable display.vc_config::tofu_store_path— optional per-user pin file path; defaults to a relative"./voicecat_tofu_pins.txt"so existing tests need no change.TcpAcceptornow dual-stacks (IPv6 + IPv4 fallback) solocalhost→::1on Windows connects correctly without forcing users to type127.0.0.1.VC_INPUT_ALWAYS_ON = 2invc_input_mode— transmit unconditionally (no VAD gate).vc_set_vad_threshold(float)— live VAD threshold update;EnergyVadProcessorstores it atomically so the RT capture path reads without a lock or allocation.
New C++ tests:
test_channel_user_list_abi— snapshot getters,parent_id/password_protected/max_usersregression, per-user stream list, invalid-user-id error, double-free idempotency.test_tofu_flow— first-connect blocks until confirmed; reject doesn't persist; reconnect to same identity reportsMATCHED; rotated identity reportsMISMATCH;vc_confirm_*with nothing pending returns an error.
windows-client CMake preset — Release, VOICECAT_BUILD_SHARED=ON, static MinGW runtime
(-static-libgcc -static-libstdc++ -static -lwinpthread), no tools/tests. Outputs
build/windows-client/bin/voicecat.dll with zero MinGW DLL dependencies (only Windows system
DLLs remain — verified via objdump -p).
C# solution (clients/windows/, .NET 10 LTS net10.0-windows):
VoiceCat.Interop—[LibraryImport]P/Invoke surface,[UnmanagedCallersOnly]callbacks,System.Threading.Channels.Channel<VoiceCatEvent>event delivery drained by 30ms WinForms Timer;VoiceCatClientHandle : SafeHandleguaranteesvc_client_destroy.VoiceCat.App— WinForms UI:ConnectDialog— saved-serverListBox, Add/Remove/Edit; servers persisted to%AppData%\VoiceCat\servers.json; passwords DPAPI-encrypted (ProtectedData, opt-in).ServerIdentityDialog— shown only onFIRST_CONNECT/MISMATCH(neverMATCHED); mismatch text and button ordering are starkly different ("WARNING" framing, Cancel default).MainForm—TreeViewchannel tree,ListBoxuser list,RichTextBoxchat, scopeComboBox(Channel/Private), activity-logListBox, voice panel with mic toggle, mute/deafen checkboxes, VAD/PTT/Always-On radio group, VAD sensitivityTrackBar(1–100, hidden for non-VAD modes), deviceComboBox+ refresh, levelProgressBar.PerUserTuningDialog— real-time gainTrackBar+ mute/NR checkboxes; applied to all of a user's streams immediately (no OK/Cancel round-trip).PttKeyCaptureDialog— focus-scoped PTT key capture.
VoiceCat.Interop.Tests— xunit smoke test: connect → TOFU → guest auth → list channels purely via P/Invoke against a livevoicecat-server.exe.
Explicitly out of scope for this pass:
- macOS/iOS Swift client — pending.
- Admin/moderation UI (kick/ban/permissions/account provisioning) — server-side dispatch for these messages is M5's job; building the UI now would require building the server side too.
- PTT hotkey is focus-scoped only (works while VoiceCat window has focus). A system-wide
WH_KEYBOARD_LLhook would require escalated permissions and risk AV flagging — documented limitation, not silently omitted. - Receive-side noise reduction (
vc_set_remote_stream(..., noise_reduction)) is end-to-end plumbed but behaviorally a passthrough no-op (ApmPassthrough, no PCM modification) — same as before M4. The per-user NR checkbox inPerUserTuningDialogis labeled accordingly.
M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17)
Exit criterion: four ABI-level tests green (test_m5_permissions,
test_m5_kick_ban_move_mute, test_m5_admin_accounts, test_m5_channel_crud);
vccli can drive all moderation/admin/channel operations against a live server.
- Server-side moderation & permissions:
server/src/session_registry.h/.cpp— per-sessionPermissions, permission helpers (can_kick,can_ban, etc.), kick/ban/move/server-mute, channel CRUD, DB-backed channel tree load/save, in-memory channel state.server/src/conn_session.cpp— M5 dispatch handlers, permission checks, channel-passwordmax_usersenforcement,UserEvent::UPDATEDbroadcast on join/leave.
core/proto/voicecat.proto—ServerMuteRequest,UserEvent.reason,User.server_deafened,ListAccountsResult,AccountEntry.
- C ABI / client-side:
core/include/voicecat.h—vc_permissions,vc_channel_info,vc_kick_user,vc_ban_user,vc_set_permission,vc_set_server_mute,vc_move_user,vc_create_channel,vc_edit_channel,vc_delete_channel,vc_create_account,vc_reset_password,vc_delete_account,vc_list_accounts,vc_get_permissions; new eventsVC_EVENT_GENERIC_RESULTandVC_EVENT_ACCOUNT_LIST.core/src/voicecat.cpp,core/src/core/client.h/.cpp— implementations + server-mute/deafen gating on the client.
- Database:
server/src/db.h/.cppschema v2 (channels,bans), Argon2id accounts, BLAKE2b channel passwords, migrations. - Tests: four new M5 tests registered in
tests/CMakeLists.txt:test_m5_permissions— grant/revoke permissions, verify enforcement.test_m5_kick_ban_move_mute— kick, ban, move, server-mute/deafen.test_m5_admin_accounts— create/reset/delete/list accounts.test_m5_channel_crud— create/edit/delete channels, password + max_users enforcement.
- vccli (
tools/vccli/src/main.cpp) — all M5 operations exposed via flags; account auth via--username/--password; asyncVC_EVENT_GENERIC_RESULT/VC_EVENT_ACCOUNT_LISThandling. - Docs kept in sync:
docs/protocol.md,docs/security.md,PROGRESS.md.
Key bug fixed: test_m5_channel_crud failed because SessionRegistry::create_channel
broadcast ChannelEvent::CREATED from a moved-from entry.proto after
channels_[id] = std::move(entry). Fixed by building the event before moving into the map.
Still to do:
- DRED/audio-quality polish.
- macOS/iOS Swift client (carried from M4).
Decisions log
All architecture/scope decisions are settled and recorded in
docs/roadmap.md §2 "Resolved decisions" and reflected across docs/.
If you make a new decision, record it there and link it here.
How to update this file
- Check off tasks as you complete them; flip a milestone to
[x]only when its exit criterion test passes. - Keep the "Where we left off / next action" block at the top accurate — it's the first thing the next agent reads.
- When you start a milestone, copy its task list from
docs/roadmap.mdinto a section here.