The per-listener noise-reduction toggle (vc_set_remote_stream) did nothing on Windows/macOS/iOS. The decode loop gated the RNNoise pass on dec_channels == 1 as a proxy for "this stream is voice" (assuming stereo => screen-share). The stereo-mic capture commit broke that: a stereo mic with send-side NR off transmits stereo Opus, so the receiver decoded two channels and skipped NR entirely. gain/mute have no channel guard, which is why only NR appeared broken. Thread the stream kind through init_recv_stream into RemoteStream::is_voice (set from si.kind() == STREAM_MIC), gate receive NR on is_voice instead of channel count, and fold a stereo voice frame to mono -> denoise -> duplicate back across both channels in place (symmetric with the send-side downmix; RNNoise is mono-only). Screen-audio shares are never denoised. New test test_recv_noise_reduction drives AudioEngine and asserts a stereo voice stream's noise floor collapses with NR on (RMS 1046 -> 0.1) while a screen-audio share stays unchanged. ctest --preset dev green 29/29. Docs: voice.md section 10. Shared-core fix; clients need only a rebuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
79 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
-
[ ] Soon — jitter buffer should measure REAL arrival jitter (RFC 3550), not sender timestamps.
JitterBuffer::push(core/src/audio/audio_engine.cpp:84-108) estimates jitter fromgap = ts - last_push_ts_, wheretsis the sender's timestamp — which is perfectly regular (ls.timestamp += samplesevery frame, independent of when the packet is actually sent). Sodiffis always ~0,jitter_est_stays 0, andtarget_depth_ms_is pinned at its ~20 ms floor. The buffer is therefore blind to real network/arrival jitter and to bursty senders — it never deepens. Combined with the playout deliberately seeding to near-zero depth (on_playback, ~line 715), the receiver tolerates only a steady sender. This is exactly why the iOS mic needed a send-side pacing cushion (below) and why genuine network jitter would also cause underruns. Fix: measure inter-arrival jitter the RFC 3550 way —D = (arrival_j - arrival_i) - (ts_j - ts_i)using a wall-clock arrival stamp captured inpush()— and drivetarget_depth_ms_off that EWMA (keep the existing marker/silence-gap outlier rejection). Then the receiver absorbs bursts itself and the iOS send cushion could be reduced or removed. Shared-core change → add a test and re-verify desktop↔desktop stays low-latency (steady sender ⇒ ~0 arrival jitter ⇒ no regression). -
Done (2026-06-23): Fixed: receive-side noise reduction silently skipped on stereo mic streams (regression from stereo-mic capture below). The per-listener NR toggle (
vc_set_remote_stream(... noise_reduction)) did nothing on Windows/macOS/iOS — the UI and the whole C-ABI→core path were correctly wired, but the decode loop gated the RNNoise pass ondec_channels == 1(core/src/audio/audio_engine.cpp), an old proxy for "this stream is voice" that assumed stereo ⇒ screen-share. The stereo-mic commit broke it: a stereo mic with send-side NR off transmits stereo Opus, so the receiver decodeddec_channels == 2and skipped NR entirely (gain/mute have no channel guard, which is why only NR looked broken). Fix: thread the stream kind throughinit_recv_streamintoRemoteStream::is_voice(set fromsi.kind() == STREAM_MICinclient.cpp), gate receive NR onis_voiceinstead of channel count, and fold a stereo voice frame to mono → denoise → duplicate back across both channels in place (symmetric with the send-side downmix; RNNoise is mono-only). A stereo voice stream now plays mono while NR is on; a screen-audio share is never touched. New testtests/test_recv_noise_reduction.cppdrivesAudioEngineand asserts a stereo voice stream's noise floor collapses with NR on (RMS 1046 → 0.1) while a screen-audio share stays unchanged (RMS ≈ 1015). Fullctest --preset devgreen — 29/29. Docs: voice.md §10. Clients need no change (shared-core fix). Not yet re-verified two-client E2E on real hardware. -
Done (2026-06-23): Stereo mic capture on Windows & macOS desktop clients. Both desktop mics were hard-mono:
ensure_audio_running()defaultscapture_channels = 1and neither client ever calledvc_set_capture_channels(only iOS did). Added a "Stereo microphone" toggle to each client's Audio settings (off by default, persisted —VoiceSettings.StereoMicon Windows,MainWindowController.stereoMic/voice.stereoMicUserDefaults on macOS). It's applied to the core when the mic stream starts (stored on the stream before the announce round-trip, so the first device open picks it up) and live in settings viavc_set_capture_channels+vc_audio_restart. Exposed both ABI calls in the Windows interop (NativeMethods/VoiceCatClient); the macOS wrapper already had them. Core fix:encode_and_send_frame(core/src/core/client.cpp) now folds a stereo mic frame to mono when the channel is mono — previously thechannels == 2branch encoded interleaved L/R directly even on a mono channel, feeding a monoopus_encode2× its samples (wrong pitch / garbage). Real stereo still only reaches the wire on a stereo channel (encoder channel count = channel's Opus mode); on a mono channel the mic is cleanly downmixed. Test:test_stereo_mic_mono_channelintests/test_vad_ptt_devices.cpp. Fullctest --preset devgreen — 28/28. macOS Xcode build not compiled here (Windows host); the Swift changes follow existingnrChanged/setInputDevicepatterns. Docs: voice.md §8. -
Done (2026-06-23): Fixed iOS dual-stream / crackly mic — core opened a second (miniaudio) capture device alongside the AVAudioEngine tap. Symptom: with two clients in a channel, the remote end heard the iOS mic twice and crackly. With Voice Chat + a BT headset, both the BT mic and the internal mic were captured; with Stereo Mic, both a mono and a stereo copy of the internal mic were sent simultaneously. Root cause is a timing gap in
vc_client::ensure_audio_running()(core/src/core/client.cpp):external_capturewas only set when a MIC stream already existed, butensure_audio_runningis also called fromsync_remote_streams(triggered by the post-authServerStateSnapshot) before the user joins voice — so with no MIC stream,external_capturestayedfalseandAudioEngine::start()opened a real miniaudio capture device. Later the user joined voice →IOSAudioEngine.startMicinstalled the AVAudioEngine input tap →feedPcm→inject_capture→on_capture_frame. The miniaudio device was still open (the engine was alreadyrunning(), so the laterensure_audio_runningearly-returned and never appliedexternal_feed), andon_capture_frameencodes+sends every frame with no deduplication → the mic was sent twice. The two unsynchronized capture clocks interleaving in the encoder is the crackle; the mono miniaudio device + stereo AVAudioEngine tap is the "mono and stereo at the same time" on Stereo Mic.- Fix 1 (core,
core/src/core/client.cpp:ensure_audio_running): forcep.external_capture = truewheneverexternal_playback_is set. In iOS unified mode the core must never open a hardware capture device — the AVAudioEngine owns the only mic path. No-op on desktop (external_playback_is never set there). - Fix 2 (iOS,
clients/apple/iOS/VoiceCatiOS/AppState.swift): moveclient.setExternalPlayback(true)from theauthResulthandler to beforeclient.connect(...). The server sendsAuthResultimmediately followed byServerStateSnapshot;handle_server_staterunsensure_audio_runningon the io thread before the main thread drainsauthResult, so setting the flag post-auth raced. Setting it pre-connect guaranteesexternal_playback_is true before any message is processed — eliminating the playback-device race too (the mixer timer + AVAudioEngine playback path + VPIO AEC reference are correct from the first frame). - Verify:
cmake --build --preset devclean;ctest --preset dev= 24/28 — the 4 failures (vad_ptt_devices,external_pcm,frame_ms_reframe,channel_samplerate) are a pre-existing teardownmutex lock failedrace, reproduced identically with the changes stashed.external_playback(the one test exercising this code path) passes. No xcframework rebuild needed (no new symbols). Next (manual, on device): two clients in a channel — Voice Chat + BT, and Stereo Mic — confirm the remote end hears the iOS mic once, clean (no duplicate, no crackle); confirm the iOS user hears the remote user cleanly with AEC working in Voice Chat.
- Fix 1 (core,
-
Done (2026-06-23): Fixed iOS mic flutter / crackle / octave-up. The iOS mic was unusable: a consistent ~40–60 ms flutter with volume fade ("talking through a slow fan") on every preset. Root cause: the core sends each captured frame synchronously (
on_capture_frame→encode_and_send_frame, no send pacer), so packet cadence == capture cadence; and the receiver's playout keeps near-zero buffering by design and its jitter estimate is blind to arrival timing (see RFC-3550 item above). That's smooth only for a steady sender (desktop miniaudio = steady 20 ms), but the iOSAVAudioEngineinput tap delivers ~2 frames per ~40 ms callback (more under VPIO) → bursty → receiver underruns → PLC fade.- Fix (iOS-only,
clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift): the mic tap converts to 48 kHz int16 and writes a lock-free SPSC ring; a 20 ms feed pump drains it and callsfeedPcmat a steady cadence so packets leave the core every 20 ms (what the receiver expects). The pump primes a small prebuffer cushion (PumpState.targetFrames, 3 frames ≈ 60 ms, self-healing up to ~120 ms on underrun) before releasing, so the tap's bursts can't drain it to empty. Two correctness rules (each had bit us): never read a partial frame (readconsumes what it returns → discarding partials caused crackle), and rebuild the pump with the current channel count everyrebuild()(a frozen channel count fed mono-as-stereo = octave-up on a Stereo→Voice Chat switch). Trade-off: ~60–120 ms added mic-send latency — unavoidable when de-bursting for a near-zero-buffer receiver; the RFC-3550 fix above would let us shrink it. - Verify:
xcodebuildDebug BUILD SUCCEEDED (iOS Simulator, arm64). Audible test requires a real device (simulator has no real mic route): mic should be smooth on Voice Chat / Mono Mic / Stereo Mic, including switching presets while live (no octave).
- Fix (iOS-only,
-
Done (2026-06-23): Fixed Apple client link failure (stale xcframework missing RNNoise). Both
VoiceCatMacandVoiceCatiOSfailed to link withUndefined symbols for architecture arm64: _rnnoise_create / _rnnoise_destroy / _rnnoise_process_frame. Root cause:clients/apple/scripts/build-xcframework.shmerged vcpkg deps into the fat static lib but NOT the locally-built vendoredlibrnnoise.a(a CMake target fromthird_party/rnnoise/, linked privately intovoicecatviaVOICECAT_HAS_NS— not a vcpkg dep). The xcframework had been rebuilt at 14:17 after the RNNoise commit but still omitted the symbols, so every slice'slibvoicecat-fat.areferenced_rnnoise_*with no defining object. The iOS slices were also stale (pre-rnnoise) and absent from the xcframework entirely.- Fix:
build-xcframework.shnow collects.afiles frombuild/<preset>/lib/(excludinglibvoicecat*) in addition tovcpkg_installed/<triplet>/lib/, so vendored CMake-target static libs likelibrnnoise.aare merged into the fat lib. Future-proof: any new vendored static-lib target landing inbuild/<preset>/lib/is picked up automatically. README "Fat static library" section updated. - Verify: rebuilt
VoiceCatCore.xcframework --all→ all 3 slices (macos-arm64, ios-arm64, ios-arm64-simulator) now carry 10_rnnoise_*symbols each; fat lib ~30 MB → ~33 MB.xcodebuildDebug BUILD SUCCEEDED forVoiceCatMac,VoiceCatiOS(iphonesimulator arm64), andVoiceCatiOS(iphoneos arm64,CODE_SIGNING_ALLOWED=NO). No core/ABI/proto changes — xcframework artifact only.
- Fix:
-
Done (2026-06-23): Remote-stream noise suppression — real backend (RNNoise). The two-sided NR plumbing (
RemoteStream::recv_ns+vc_set_remote_stream(... noise_reduction)) was wired but inert —ApmProcessor::create()returned a no-op passthrough, because the originally-plannedwebrtc-audio-processingwon't build on Windows/macOS. Replaced with RNNoise (BSD-3 + CC0), vendored atthird_party/rnnoise/(the vcpkg port is!windows !arm), built as a standalone C static lib +VOICECAT_HAS_NS. OneRnnoiseProcessor(core/src/audio/apm_processor.cpp) now backs both NR paths:- Receive-side (per-listener, per-
ssrc): lit up automatically via the factory; gated to mono streams (audio_engine.cpp~L791). - Send-side (mic, new):
vc_set_input_noise_reduction(client, enable)ABI +vc_client::mic_ns_, run before input gain/VAD inon_capture_frame. A stereo mic is downmixed to mono only when NR is on; with NR off a stereo mic keeps full stereo. - RNNoise is mono/48 kHz/480-sample; our clock is fixed 48 kHz and Opus frame sizes are all multiples of 480, so no resampling. RT-safe: alloc at construction, lock-free in the callback.
- Verify status:
ctest --preset devgreen — 28/28 (newnoise_suppressiontest: feeds white noise throughApmProcessor::create(), measures 99.9% RMS reduction). Build clean on thedevMinGW preset. Next (manual): add the on/off toggles to the client UIs (Windows Audio Settings dialog, macOS/iOS settings) calling the two ABIs; buildwindows-clientapple-devpresets to confirm RNNoise compiles under MinGW-DLL and arm64; two-client E2E.
- Receive-side (per-listener, per-
-
Done (2026-06-23): Aux outgoing stream (mic + a second input device) — Windows + macOS. Users can now transmit a second hardware input device (e.g. line-in / aux) alongside the mic, with its own device picker and volume, from Audio Settings. No core/ABI/proto changes — the aux is a
VC_STREAM_AUX_DEVICEstream started withexternal_feed=1, captured client-side, and fed viavc_stream_feed_pcm(the same external-feed pipeline screen-audio uses). Per-kindlocal_streams_already allows mic + screen + one aux to coexist; volume is a client-side gain multiply (the core'svc_set_input_gainis mic-only/global). The aux is always-on (the core never gatesAUX_DEVICEon VAD/PTT) and is tied to the voice session (started on Join Voice when enabled, stopped on Leave).- Windows: new
Audio/InputDeviceCapture.cs(WASAPI shared-mode capture from a real input endpoint viaIMMDevice.Activate(IAudioClient), 48 kHz/s16, 20 ms frames) +InputDeviceEnumerator(WASAPI capture-endpoint list — separate from the core's miniaudio ids). Aux section inAudioSettingsForm.cs(enable checkbox, device combo, refresh, volume slider, accessible names, live-apply + Cancel revert via callbacks). Lifecycle inMainForm.cs(_auxStreamId+InputDeviceCapture). Persisted inVoiceSettings.cs(AuxEnabled/AuxDeviceId/AuxGain). - macOS: new
Audio/InputDeviceCapture.swift(AVAudioEngine input-node tap pinned to the chosen Core Audio device viakAudioOutputUnitProperty_CurrentDevice; AVAudioConverter → 48 kHz int16; 20 ms framing modelled onScreenAudioCapture) +InputDeviceEnumerator(Core Audio device list by stable UID). Aux section inSettingsWindowController.swift; lifecycle + UserDefaults persistence (voice.aux*) inMainWindowController.swift. New file added toproject.pbxproj. - Verify status: Windows C# solution builds clean (0 warn/0 err);
ctestcore suite unchanged (no core edits). Next (manual): on a Mac, buildVoiceCatMac.xcodeproj; then two-client E2E — enable aux on a second input device, confirm two distinct streams for the sender and that the aux volume slider moves the aux level independently of the mic; confirm persistence across relaunch.
- Windows: new
-
Done (2026-06-23): Input-settings persistence, mic input gain, + two iOS bugs (all 3 clients). Four fixes:
- Input settings now persist. Transmission mode (VAD/PTT/Always-On), VAD threshold, and the
new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD
defaults. Each client now persists them and re-applies on connect: iOS via
UserDefaults(SessionState.loadAndApplyVoiceSettings+ setter writes, keysvoice.*); macOS viaUserDefaults(MainWindowControllerdidSet+loadPersistedAudioSettings, also restores the VAD slider from the stored threshold); Windows via newVoiceCat.App/Models/VoiceSettings.cs(JSON at%AppData%\VoiceCat\voice.json, mirrorsFeedbackSettings) loaded/applied inMainForm. - Microphone input gain. New global send-side API
vc_set_input_gain(voicecat.h →client.cpp::on_capture_frame, applied to MIC PCM before the VAD gate, clamped to int16) plus Swift (setInputGain) and C# (SetInputGain) bindings. Mic-volume slider (0–300 %, default 100 %) added to all three clients' input settings, persisted with the rest. - iOS chat send fixed.
ChatViewcalledsendText(scope:.channel)with notargetId(→ 0), so channel messages went nowhere; now passessession.currentChannelId. - iOS per-user tuning reachable via VoiceOver. The tuning sheet was long-press
.contextMenuonly (invisible to VoiceOver);UserRownow also exposes the same buttons as.accessibilityActions(no visual change), so the actions rotor reaches tuning + admin actions.
- Verified: core
cmake --build --preset devclean;ctest --preset dev= 24/27 (the 3 failures —external_pcm,frame_ms_reframe,channel_samplerate— are a pre-existing teardown crash on this machine, reproduced identically with the changes stashed). xcframework rebuilt (--all); VoiceCatMac and VoiceCatiOS (arm64 sim) → BUILD SUCCEEDED;VoiceCat.Interop(dotnet build) succeeded. Windows App not built (WinForms net10.0-windows can't build on macOS) — changes follow existing patterns; needs a Windows build + manual check. - Next (manual): on each client, set PTT + non-default VAD/mic-gain, relaunch → settings restored; boost a quiet mic and confirm others hear it louder; iOS send a channel message; iOS VoiceOver → focus a user → actions rotor opens tuning.
- Input settings now persist. Transmission mode (VAD/PTT/Always-On), VAD threshold, and the
new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD
defaults. Each client now persists them and re-applies on connect: iOS via
-
Done (2026-06-22): Fixed growing voice latency (jitter-buffer depth ratchet). Symptom: end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/ rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was not the codec settings (10% loss is just an
OPUS_SET_PACKET_LOSS_PERCencoder hint; FEC/DRED add no standing latency) but the receiver playout logic incore/src/audio/audio_engine.cpp: the playout clock free-ran in real time while the sender omitted silence from its timestamps and set no header flags at all, and the only correction snapped the clock to the oldest buffered frame (could only add latency) — withtarget_depth_ms_computed but never enforced, so latency could only grow or be reset. Fix: bounded-depth playout — (re)seed to the leading edge (newest frame) on start/marker/ starve, and frame-skip catch-up that trims a backlog beyondtarget + hysteresis(the missing downward force). Plus hardening: adaptive late-drop window, talkspurtkFlagMarker/kFlagDtxnow actually stamped by the sender (client.cppsend path) and consumed on recv, EWMA outlier rejection (silence gaps/stragglers no longer poison the estimate), duplicate counting, ring- underrun diagnostics (stream_underruns/stream_duplicates). New regression testtests/test_jitter_depth.cppasserts depth stays bounded (<200 ms) while arrivals outrun playout for ~4 s.ctest --preset devgreen — 27/27. Docs:docs/voice.md§5 rewritten.- Next (manual E2E): two clients in a channel, DTX/FEC/DRED on — talk in alternating bursts for several minutes and confirm latency stays low/stable (no backward drift, no rejoin needed).
-
Windows done / Apple awaiting Mac build (2026-06-22): Event sound effects + optional text-to-speech for all clients. Clients now play a cue per session event and can optionally speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies). One canonical event→sound mapping (defined off the shared C ABI
vc_eventstream) is mirrored across all three clients;selfvs others isuser_id == self_user_id, and outgoing messages echo back as events so sent/recv cues need no separate send-path hook. Conservative defaults (join/leave, channel/PM sent+recv, login, logout, connection-lost, mic on/off ON; per-utterance self voice-activityva_start/va_stopand the PTT cue OFF). WAVs ship fromassets/sounds/.- Windows (built + verified): new
VoiceCat.App/Notifications/(FeedbackSettings→%AppData%\VoiceCat\feedback.json,SoundPlayerPoolviaSystem.Media.SoundPlayer,SpeechAnnouncervia the Prismatoid NuGet 0.3.0,EventFeedbackdispatcher); hooks inForms/MainForm.cs;Forms/NotificationSettingsForm.csunder a new Settings ▸ Notifications menu..csprojadds the Prismatoid PackageRef and copies the WAVs intosounds\.dotnet buildclean; WAVs +Prismatoid.dllconfirmed in output. Note:SoundPlayerhas no gain control, so volume is honoured as a mute gate (0 = silent) — swap to NAudio if finer/overlap control is needed. - macOS + iOS (written, NOT yet built — needs a Mac): shared
Sources/VoiceCatCore/Feedback/(SoundEvent,EventFeedback=AVAudioPlayerpool + nativeAVSpeechSynthesizer,FeedbackSettingsoverUserDefaults); WAVs copied intoSources/VoiceCatCore/Sounds/and bundled viaPackage.swiftresources: [.process("Sounds")](Bundle.module). Hooks: iOSSessionState.handleEvent(+ splituserJoined/userLeft, added a.disconnectedcue case),AppStateauth-success login cue, PTT cue insetPushToTalk; macOSMainWindowControllerhandlers + NSEvent PTT monitor. Settings UI: iOSSettingsViewNotifications section (@AppStorage), macOSSettingsWindowControllercheckboxes + volume slider. No.pbxprojedits needed (shared files are SPM-managed; app files already in the projects). - Next: on a Mac,
clients/apple/scripts/build-xcframework.sh --allthen build VoiceCatMac/VoiceCatiOS; fix compile fallout. Watch the iOS audio session: cues/TTS play over the live VPIOplayAndRecordsession — verify they mix and don't duck/interrupt the call or get silenced by the mute switch (most likely bug site). Then runctest --preset dev(unchanged — no core/server code touched).
- Windows (built + verified): new
-
Done (2026-06-22): UDP media now shares the TCP port (self-host port-forward fix). Symptom: a remote self-hosted server (
iamtalon.me:8384, TCP+UDP 8384 forwarded) accepted TCP connections but passed no voice. Root cause:Config::media_portdefaulted to0= OS-assigned, andmain.cpp's--portonly setbind_port(TCP) — so the UDP relay bound a random high port, advertised it to clients in HELLO (udp_port), and clients sent voice there. With only8384/udpforwarded those packets were dropped → connect OK, no audio. This contradicteddocs/deployment.md("Control and media share one port number on TCP+UDP"). Fix (server/src/server.cpp): media follows bind_port whenmedia_port == 0—media_want = cfg_.media_port != 0 ? cfg_.media_port : cfg_.bind_port. The0 = OS-assignedescape hatch survives whenbind_portis also 0, so tests that bind ephemeral ports are unaffected (kept the logic in server.cpp rather than hardcoding 8384 as the default, which would collide parallel tests on UDP 8384). Banner now readsTCP :8384 UDP :8384. Build +ctest --preset devgreen (24/24); live-verified banner with--port 8390→UDP :8390. Action for self-hosters: redeploy and confirm the startup banner shows matching TCP/UDP ports; the existing single forward rule is now correct. If voice still fails, watch the server's rate-limited[media] dropped frames — unmapped-endpoint=…line (NAT source-port rewrite would be the next suspect). -
Done (2026-06-23, Swift-only — no core/ABI change; awaiting on-device verification): iOS audio stack unified — one always-external
AVAudioEngine, miniaudio dropped on iOS. The iOS audio path was a fragile hybrid: Voice-Chat-class presets ran a native VPIOAVAudioEngine(core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every bug lived in the seam (lingering miniaudio capture unit fighting VPIO, theaudioRestartordering dance, the route-change "glitching" loop, stereo↔mono stickiness, "can't hear anyone"), and switching presets/routes mid-call routinely dropped input, output, or both. Fix: drive all iOS audio through oneAVAudioEnginewith the core fully external at all times —vc_set_external_playback(1)once at connect, every MIC streamexternal_feed=1, mic viavc_stream_feed_pcm, playback viavc_set_mixed_output_sink.IOSVoiceProcessingEngine.swift→IOSAudioEngine(same file): always-onAVAudioSourceNodeplayback (runs whenever connected, so remote audio plays before you join voice); conditional mic tap; VPIO + AGC toggled per config. One privaterebuild()(stop → set VPIO → install tap → start) backsstartListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. Kept thePCMRingand ring-stats diagnostics.IOSAudioRouter: presets cut from seven to four — Voice Chat (VPIO mono, system output), Stereo Mic / Mono Mic (internal built-in mic regardless of output, A2DP-capable, no VPIO), Advanced (manual). New persistedvoiceProcessingEnabled(master AEC+NS) +agcEnabled; setters now callIOSAudioEngine.reconfigure()instead ofclient.audioRestart()+reconcileVoicePath. Kept the proven AVAudioSession recipes (category/mode/options, stereo capsule,applyA2dpSpeakerFallback).AudioSessionManagerslimmed (dropsclient/activeMicStreamId/reconcileVoicePath; addsisActive); interruption-end & device-change nowreconfigure()the engine.SessionStatedoStartMicStream/stopMicStreamcollapsed to start-stream +startMic/stopMic(nosetExternalPlayback/audioRestarttoggling);reconcileVoicePathdeleted.AppStatesets external playback +startListeningat connect,stop()at disconnect.SettingsView→ four presets + Advanced VPIO/AGC toggles.- No core/ABI/test change — relies on the already-shipped
vc_set_external_playback/external_feed/vc_set_mixed_output_sink/vc_stream_feed_pcmpath (test_external_pcm,test_external_playback).xcodebuildiOS device Debug BUILD SUCCEEDED. Rebuild the xcframework is NOT required (no new symbols). - Next (user, on device): two iPhones in a channel — verify BOTH directions survive every transition and are never silent unless intended: Voice Chat (no echo, NR), listen-only before joining, join↔leave repeatedly, switch Voice Chat↔Stereo↔Mono↔Advanced while in voice, A2DP connect/unplug, wired connect/unplug, phone-call interruption + resume, screen-audio share.
-
Superseded by the 2026-06-23 unification above (2026-06-22): iOS real echo cancellation / noise suppression via native VPIO. Root cause of "voice chat doesn't sound like a call" (echo + no NR): real iOS AEC/NS/AGC come only from Apple's Voice-Processing I/O unit (VPIO), but the core uses miniaudio's plain RemoteIO units — so
.voiceChatmode alone never engaged AEC. Fix moves both mic capture and playback to a native SwiftAVAudioEngine(setVoiceProcessingEnabled) on the AEC presets, with the core in external mode.- Core (done, builds + tests green): new ABI
vc_set_mixed_output_sink+vc_set_external_playback(voicecat.h PATCH→2).AudioEnginegains a mixer-timer thread that driveson_playback(decode+mix) on a ~20 ms cadence with NO hardware playback device and ships the final mix to the mixed-output sink;start()also skips the hardware capture device when the MIC stream isexternal_feed(AudioParams.external_capture). New white-box testtest_external_playback(23/24; pre-existingexternal_pcmteardown crash on Darwin 25.5 is UNRELATED — original tree crashes too). - Swift (done, builds):
VoiceCatCorewrappers (externalFeedonStreamDescriptor,setMixedOutputSink,setExternalPlayback); newIOSVoiceProcessingEngine.swift(VPIOAVAudioEngine: mic tap→feedPcm, mixed-sink lock-free ring→AVAudioSourceNode);IOSAudioRouter.currentConfigUsesVoiceProcessinggates the path per preset;SessionStatejoin/leave +reconcileVoicePath()switch between VPIO and the miniaudio path; Voice Chat defaults to speaker; SettingsView shows AEC/NS state. Rebuild the xcframework before building the app:clients/apple/scripts/build-xcframework.sh --all(new ABI symbols).xcodebuildiOS sim Debug BUILD SUCCEEDED. - Post-verification fixes (2026-06-22, Swift-only — no core/ABI change): two on-device bugs fixed.
- Voice Chat (VPIO) silent playback:
SessionState.doStartMicStream()calledaudioRestart()BEFOREstartStream, so when the engine was already running (a remote stream had started it) it reopened withexternal_capture=falseand opened a hardware miniaudio capture device; the announce-result restart then early-returned (engine already running) so that device was never dropped and fought theAVAudioEngineVPIO unit, silencing playback. Fix: setsetExternalPlaybackfirst, thenstartStream(which storesexternal_feedsynchronously), THENaudioRestart()— the core reopens in full external mode (no hardware devices). Added VPIO diagnostics (graph/route formats at start; ring written/read totals at teardown). - Stereo Mic / Studio quiet earpiece: the
.builtInMicBtA2dppresets omit.defaultToSpeaker(it breaks A2DP) and skipforceSpeaker, so with no Bluetooth connected output pinned to the quiet receiver. NewIOSAudioRouter.applyA2dpSpeakerFallback()overrides to the built-in speaker when no external (A2DP/wired/AirPlay) output is present, clears the override when one is — called after activation and on device-change route changes (AudioSessionManager).
- Voice Chat (VPIO) silent playback:
- Next (user, on device): two iPhones on speaker, Voice Chat preset → confirm (a) no echo, (b) background noise suppressed, (c) speaker output by default AND remote audio is now audible; then Stereo Mic / Studio with no BT → confirm loud speaker (not earpiece), and A2DP takes over when a BT headset connects. Tune the mixer-timer/ring sizing if there's under/overrun.
- Core (done, builds + tests green): new ABI
-
Done (2026-06-21): Docker + Linux deployment + GitHub Actions cross-build. Added the complete Linux server deployment story (the only missing platform — Windows and macOS already have native binaries):
Dockerfile— multi-stage (builder:ubuntu:24.04+ vcpkg +cmake --preset server-release; runtime:ubuntu:24.04, non-rootvoicecatuser,/datavolume, TCP+UDP 8384). vcpkg is fetched via the GitHub archive tarball at the exactbuiltin-baselinecommit (d46283cf…), avoiding a full git-history clone. BuildKit cache mounts on/vcpkg/downloads,/vcpkg/buildtrees,/vcpkg/packages(scoped byTARGETARCH) keep rebuilds fast. Bothvoicecat-serverandvoicecat-adminare copied into the runtime image.docker-compose.yml— single-service compose file withrestart: unless-stopped, named volumevoicecat-data, and port mappings for TCP+UDP 8384.command:shows how to set--name..dockerignore— excludes.git/,build/,clients/(Swift/C# code),docs/, markdown, editor config; build context is justcore/,server/,tools/,cmake/, and the three root CMake/vcpkg files.deploy/linux/voicecat.service— hardened systemd unit (non-root,ProtectSystem,NoNewPrivileges,AmbientCapabilities=CAP_NET_BIND_SERVICE) for bare-metal deploys.- Multi-arch:
docker buildx build --platform linux/amd64,linux/arm64 .works without any triplet override —cmake/voicecat-toolchain.cmakeauto-detects from the host arch cmake sees inside the buildx container. - Quick start:
docker compose up -d(ordocker run -d -p 8384:8384/tcp -p 8384:8384/udp -v voicecat-data:/data voicecat). First run auto-generates identity- cert + DB; check logs for fingerprint + admin password.
- GitHub Actions (
.github/workflows/build-linux.yml): primary cross-platform binary build path — amd64 usesubuntu-24.04, arm64 usesubuntu-24.04-arm(native, not QEMU). Triggers on push to main (when C++/cmake files change) and manually viaworkflow_dispatch. Downloads land as 90-day artifacts.scripts/build-linux-binaries.shis the local Docker fallback (needs ~10–15 GB free disk; suits Linux dev machines, not Windows Docker Desktop).
-
Done (2026-06-21): Fix permanent voice-loss bug + harden the UDP media path (protocol v2). Field report: two iOS users lost all audio mid-call after a bad-network blip and could not recover even by restarting the apps. Root causes found in the UDP media path:
- Anti-replay window poisoned by unauthenticated packets (the trigger).
SodiumMediaCrypto::open()advancedrecv_highest_from the plaintext headerseqbefore verifying the AEAD tag and never rolled it back on failure. One corrupted/forged frame (a bit-flip on flaky wifi) shoved the high-water mark far ahead, after which every legitimate frame was rejected as "too old" — permanently. Fixed by reordering to replay-check → authenticate → update (RFC 3711 §3.3): the window is now touched only after a successful tag check. Regression test intest_media_aead.cpp(test_corrupted_seq_does_not_poison_window) — fails on the old code, passes now. - 16-bit seq wrap with no rollover counter. The wire header carried only the low 16 bits
of the nonce counter (zero-extended on receive); after 65,536 frames the reconstructed
nonce diverged and all frames failed auth. Wire format widened to a full u64 seq
(
voice_frame.h: header 14 → 20 bytes,sequ16 → u64;crypto.cpp,client.cpp,media_relay.cppupdated;JitterBuffer::Frame::seqwidened). This is a versioned wire change →VOICECAT_PROTOCOL_VERSION1 → 2; theHellohandshake rejects on mismatch (conn_session.cpp). The voice frame is parsed only incore/+server/+tests/, so the Swift/C# clients need only a rebuild — no parser changes. - Server leaked UDP state on disconnect.
SessionRegistry::unregister_session()now also freesudp_endpoints_/udp_tokens_/ssrc_to_session_(scan-and-erase by session id). - Diagnostics.
MediaRelaynow emits rate-limited dropped-frame counters (unmapped-endpoint / no-recv-crypto / open-failed) so a wedged media path is observable.
- Verified:
cmake --build --preset devclean;ctest --preset dev -E external_pcm22/22 pass (incl.m2_voicee2e relay + the two new AEAD regressions).external_pcmstill aborts on the pre-existing CoreAudio shutdown mutex race (confirmed identical on a clean baseline checkout under the same harness — unrelated to these changes). Docs updated:voice.md§2 (header),protocol.md(v2 + negotiation),security.md(authenticate-then-advance).
- Anti-replay window poisoned by unauthenticated packets (the trigger).
-
Done (2026-06-21): Expose all channel codec params + guest nickname in every client.
- DRED everywhere + ABI fix.
dred(Opus 1.6 Deep REDundancy) existed in the C ABI (vc_audio_config.dred) and proto but was absent from both client marshaling layers — a latent ABI mismatch: SwiftAudioConfigand the C#VcAudioConfigNativeblittable struct were each oneintshort of the native struct passed tovc_create_channel/vc_edit_channel. Addeddredthrough Swift (Models.swift,Marshaling.swift,VoiceCatClient.toNative) and C# (Structs.cs,Models.cs,Marshaling.cs,VoiceCatClient.cs). - Windows: added the one missing DRED checkbox to
ChannelEditDialog(all other params were already present). - macOS:
ChannelEditSheetnow exposes the previously-hidden params — application profile, sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX). - iOS:
ChannelEditViewwas name+topic only; rebuilt into a full create and edit form (General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/ frame/application/packet-loss/complexity/FEC/DTX/DRED). AddedSessionState.editChanneland an "Edit" swipe action (admins) inChannelTreeView+ChannelBrowserView(iOS previously had no edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on edit the audio fields start from codec defaults — same limitation as macOS/Windows. - Guest nickname. Guests could not set a display name on iOS or macOS (the field was
absent/disabled; only Windows had it). Added a dedicated
nicknametoSavedServeron both (backward-compatible Codable), a Nickname field shown in Guest mode (AddServerView/AddServerSheet), and wired the guest auth path to use it (AppState,ConnectWindowController). - Verified:
xcodebuildDebug — macOS BUILD SUCCEEDED; iOS (sim,ARCHS=arm64) BUILD SUCCEEDED. Corectest --preset dev22/23 (onlyexternal_pcmaborts on a pre-existing shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed.
- DRED everywhere + ABI fix.
-
Done (2026-06-21): iOS iPhone-layout UX fixes. (1) Channels are now a drill-down on iPhone — new
ChannelBrowserView(root list of top-level channels) →ChannelDetailView(people in the channel + sub-channels + an explicit "Join Channel" button with password prompt). The iPad 3-columnNavigationSplitViewis unchanged. (2) Extracted a self-containedUserRow(context menu + sheets) fromUserListViewso admin actions are reused in the drill-down. (3) Fixed the off-screen chat compose box:MainViewnow placesVoiceControlsViewvia.safeAreaInset(edge: .bottom)instead of a floating.overlay, so it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4) Collapsed Activity into Chat like macOS/Windows:ChatViewrenders a merged, time-sorted timeline ofmessages+activityLog(activity rows in gray); the separate Activity tab andActivityLogView.swiftare removed.xcodebuildDebug forgeneric/platform=iOSBUILD SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the drill-down + compose box + unified timeline. -
Done (2026-06-22): Windows exclude mode is now a real native exclude + self-echo removal. The "All apps except selected" mode previously captured the complement of a frozen app snapshot in INCLUDE mode (missed late-launched apps, system sounds; wasted captures on silent windows). It now opens a single
ProcessLoopbackCapturein EXCLUDE mode (AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE) of the one chosen app — true system-mix-minus-one, dynamic.AppAudioPickerDialogenforces single-selection in exclude mode (the API takes one target PID). Added an "Exclude VoiceCat's own audio (prevents echo)" checkbox (default on, entire-desktop only) that routes the desktop capture through the same EXCLUDE path targetingEnvironment.ProcessId, killing the whole-device self-echo loop. TouchedProcessAudioMixer.cs(ResolveCaptures),AppAudioPickerDialog.cs,MainForm.cs,AudioSessionEnumerator.cs(EntireDesktop(bool ExcludeSelf)); docs in voice.md §9. No C++ / ABI changes.dotnet buildclean. Still to verify on-device: exclude actually silences the chosen app while the rest plays, late-launched apps appear without restart, and the self-exclude checkbox removes the echo. -
Done (2026-06-21): Screen-audio sharing on macOS + iOS. macOS uses ScreenCaptureKit (
ScreenAudioCapture.swift) →vc_stream_feed_pcm; iOS uses a ReplayKit Broadcast Upload Extension (VoiceCatBroadcast) that forwards captured.audioAppPCM through a shared App Group SPSC ring (BroadcastAudioRing.swift) to the host'sBroadcastAudioPump, which owns theSCREEN_AUDIOstream and feeds it — single session, no creds on disk. No C++ changes (the core was already ready). macOSxcodebuildDebug BUILD SUCCEEDED; iOS app + extension build for device (the xcframework sim slice is arm64-only, so x86_64-simulator link is N/A). Next: on-device end-to-end verification (two clients hear the shared audio; iOS broadcast start/stop). NOTE:ctest --preset devis 22/23 —external_pcmpasses its assertions but aborts at shutdown (mutex lock failed), a pre-existing teardown crash unrelated to this change (no C++ was modified). -
Done (2026-06-21): macOS per-app screen-audio selection. Before sharing, a new
ScreenSharePickerSheetlets the user choose scope — share Everything / Only selected apps / All except selected apps — plus a first-class "Exclude screen reader (VoiceOver) audio" toggle.ScreenAudioCapturenow takes aScreenAudioSelectionand builds the matchingSCContentFilter(including:/excludingApplications:); app list comes fromSCShareableContent. macOSxcodebuildDebug BUILD SUCCEEDED. iOS deliberately untouched — ReplayKit only delivers the mixed system stream, so per-app/VoiceOver filtering is impossible there (documented in voice.md §9). Still to verify on-device: which process actually carries VoiceOver speech (VoiceOver app vs.com.apple.speech.speechsynthesisd) — the exclude set covers both candidates inScreenAudioCapture.screenReaderBundleIDs; confirm exclusion actually silences it in a real share. -
Done (2026-06-20): macOS client UI overhaul — mirrors the Windows client's UI overhaul (commit
97fa659+540ec13), adapted to Mac-native conventions. Also fixed and verified the previously-uncompiled Swift changes from the external PCM feed/tap commit (615d2a8). The main window is now just toolbar + channels + users + chat; audio device settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless Settings window (⌘,). Details in M5 section below.swift test10/10;xcodebuildDebug- Release BUILD SUCCEEDED with 0 Swift warnings.
Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel
counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit
consumers of
vc_stream_feed_pcm.
- Release BUILD SUCCEEDED with 0 Swift warnings.
Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel
counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit
consumers of
-
Awaiting on-device verification: iOS stereo mic kills headphone/A2DP output — REAL root cause found & fixed (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the 2026-06-19 entries below) targeted the Swift
IOSAudioRouteron the false premise that "miniaudio does NOT touch AVAudioSession on iOS." It does. The core opened its miniaudio devices withma_device_init(nullptr, ...); with a NULL context, miniaudio (0.11.25) runs an iOS "hack" (miniaudio.h~44057) that picks a session category by device type, thenma_context_init__coreaudio(~36552) callssetCategory()+setActive()on every device open — capture →AVAudioSessionCategoryRecordwith zero options. That wiped the.playAndRecordcategory, the mode, and.allowBluetoothA2DP/.mixWithOthers/.allowAirPlaythatIOSAudioRouterhad just configured → headphone/A2DP (and even wired) output died. The stereo presets broke worst because they depend on the A2DP output route the wipe removed. TeamTalk never hits this: its SDK opens RemoteIO/VPIO AudioUnits directly and leaves the session entirely to the app (UtilSound.swift); miniaudio insists on managing it.- Fix (core, cross-platform safe):
AudioEnginenow owns ama_contextbuilt bymake_context_config()withcoreaudio.sessionCategory = ma_ios_session_category_none+noAudioSessionActivate/noAudioSessionDeactivate = MA_TRUE, and passes it to allma_device_initcalls (playback, capture, loopback) and toenumerate_devices's context. miniaudio now never touches AVAudioSession; the SwiftIOSAudioRouteris the sole owner (session is already activated on connect inAppState.swift:authResult, before any device opens, so removing miniaudio's self-activation is safe). Context is lazily inited instart(), reused across restarts, uninited in~AudioEngine. Files:core/src/audio/audio_engine.{h,cpp}. - TEMP diagnostics (remove after verification):
AudioSessionManager.logSessionState(_:)logs category/mode/options/route; called afterensureSessionActive, on every route change, and on.streamStarted(right after the core opens its devices). On Mac, watch the log when joining voice with the Stereo Mic preset: category must stay…PlayAndRecordwithallowBluetoothA2DPand the output route must remain the headphones/A2DP device — NOT flip to…Record. If confirmed, delete thelogSessionStatecalls + method and the prior band-aid comments inIOSAudioRouter/audio_engine.cppcan be trimmed. - Verified on Windows:
cmake --build --preset devclean,ctest --preset dev23/23 (22/22 prior +test_external_pcmnew binary). iOS build & on-device run still to be done by the user on the Mac.
- Fix (core, cross-platform safe):
-
Done (2026-06-20): External PCM feed/tap API (
vc_stream_feed_pcm+vc_set_pcm_sink) — see detail in M5 section below.ctest --preset dev23/23 (was 22/22 + 1 new test binary with 3 sub-tests). Next: iOS ReplayKit and macOS ScreenCaptureKit consumers of this API. 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: orthogonal. The iOS routing layer controls 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
Recent completed work
All items below are [x] done; ctest --preset dev 26/26 on Windows after all.
-
Per-channel sample_rate as a bandwidth cap (2026-06-22): the channel
sample_ratefield was inert (the codec is pinned to 48 kHz). Made it meaningful without changing the 48 kHz clock: it's carried asOpusParams::max_bandwidth_hzand applied viaOPUS_SET_MAX_BANDWIDTHinOpusEncoder::init(8000→narrowband … 48000→full). Made it channel-authoritative on the server (conn_session.cppno longer overrides effectivesample_ratewith the client's always-48000 request — likeframe_ms/mode).vc_get_stream_audio_confignow reports the channel's configured rate for own streams too. New ctestchannel_samplerate: a 7 kHz tone is attenuated ~1000× on an 8 kHz channel vs a 48 kHz channel. Files:opus_codec.{h,cpp},client.cpp,server/src/conn_session.cpp,docs/voice.md,tests/test_channel_samplerate.cpp,tests/CMakeLists.txt. (Future: a true non-48k stack is possible but unnecessary — 48 kHz is what nearly all hard/software runs at; the bandwidth cap covers the narrowband use case.) -
Non-20ms channel frame_ms fix (2026-06-22): the AudioEngine capture clock is fixed at 48 kHz / 20 ms (960-sample frames), but a channel may set any Opus
frame_ms(2.5…60 ms, docs/voice.md §3) and the server enforces it unclamped. The send path handed the engine's 960-sample frame straight to an encoder configured for the channel's window — silently ignoringframe_ms > 20and breakingframe_ms < 20entirely (receiver sized its decode buffer too small →OPUS_BUFFER_TOO_SMALL→ dead audio). Affected the hardware mic ANDvc_stream_feed_pcm. Fix:vc_client::on_capture_framenow reframes each captured/fed block tols.frame_samplesvia a per-LocalStreamaccumulator (pre-sized at announce, no RT-thread alloc) beforeencode_and_send_frame; the 20 ms case stays a zero-copy fast path. Also pinned the codec to 48 kHz internally inopus_params_from_audio_config(was honoring a non-48k effective sample_rate against a 48k PCM clock). New ctestframe_ms_reframe(40 ms accumulate- 10 ms split round trips). Files:
client.{h,cpp},voicecat.h(feed doc),docs/voice.md,tests/test_frame_ms_reframe.cpp,tests/CMakeLists.txt.
- 10 ms split round trips). Files:
-
External PCM feed/tap API (2026-06-20):
vc_stream_feed_pcm+vc_set_pcm_sinkshipped. Promotesvc_test_inject_capture(mono-only, TEST-ONLY) to a public, stereo-capable API. Adds symmetric PCM sink on the playback thread. Swift wrapper (feedPcm/setPcmSinkinVoiceCatClient.swift, 4 XCTest smoke tests). C# wrapper (StreamFeedPcm/SetPcmSinkinVoiceCatClient.cs+NativeMethods.cs, 4 xUnit smoke tests inExternalPcmTests.cs). Three new headless C++ ctests. Docs: architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Files:voicecat.h,voicecat.cpp,client.{h,cpp},audio_engine.{h,cpp},tests/test_external_pcm.cpp,tests/CMakeLists.txt, Swift + C# wrappers. -
iOS A2DP + stereo root cause fix (2026-06-20): miniaudio's NULL-context
ma_device_initwas callingAVAudioSession setCategory(Record)on every device open, wiping the session configIOSAudioRouterhad set. Fixed by sharing ama_contextwithsessionCategory=none+noAudioSessionActivate/Deactivate=MA_TRUE— miniaudio never touchesAVAudioSession;IOSAudioRouteris the sole owner. Files:audio_engine.{h,cpp}. -
iOS audio routing overhaul (2026-06-19): Full
IOSAudioRoutersingleton drives allAVAudioSessionconfig before miniaudio opens devices. Fixed stereo mic polar-pattern setup (WWDC20 recipe:setPreferredInput+setInputDataSource+.stereopolar pattern + nosetPreferredInputNumberOfChannels). Addedvc_audio_restartABI (full stop+reinit for close→reconfigure→reopen ordering). Addedvc_set_capture_channelsABI (core stereo-mic support). AVAudioSession activated proactively on.authResult, not lazily on.streamStarted. Join/Leave Voice button added (parity with macOS). Channel-id sync fixed (mic button was permanently dimmed). iOS deployment target raised to 18.0. -
iOS SwiftUI client (2026-06-19):
VoiceCatiOS.xcodeprojatclients/apple/iOS/. Full feature parity with macOS/Windows: saved server list (JSON + Keychain, App Groupgroup.cat.voice.VoiceCat), TOFU, connect flow, channel tree, user list with context menus, chat, admin sheets, voice controls, settings.xcodebuild→ BUILD SUCCEEDED. -
macOS AppKit client (2026-06-18):
VoiceCatMac.xcodeprojatclients/apple/macOS/. Fixed compile errors (NSAccessibilitycall-site arg order,StreamSummary.idvs.streamId) and linker issues (OTHER_LDFLAGS = -lc++,ONLY_ACTIVE_ARCH = YESfor Release). Debug + Release both BUILD SUCCEEDED. -
Swift
VoiceCatCorepackage + XCFramework (2026-06-18): Shared Swift wrapper atclients/apple/.build-xcframework.shmergeslibvoicecat.a+ 107 vcpkg static deps into a fat.avialibtool -static. 6/6 Swift tests green (real server, mirrors C# Interop tests). Supports macOS-arm64 + iOS-arm64 + iOS-sim slices. -
macOS port validated (2026-06-18): 21/21 on macOS. Three cross-platform bugs fixed: missing
<netdb.h>in POSIX test branch; SIGPIPE kills (addedSIG_IGN); use-after-free of Asio kqueue reactor on server shutdown (fixedTcpAcceptorshutdown/connection-drain sequence). -
CMake preset cleanup (2026-06-18):
m1-dev→dev,dev→skeleton,m2-devdropped. Newrelease,server-release(stripped),apple-dev/apple-ios/apple-ios-sim. Cross- platform triplet auto-resolved bycmake/voicecat-toolchain.cmake. -
Disconnect, keepalive & reaper (2026-06-18): Client sends
Pingevery 15 s; server reaper drops sessions after 45 s; UDPKEEPALIVEevery 5 s keeps NAT alive.vc_disconnectsends gracefulDisconnectproto. Stale-user LEFT broadcast on drop. PLC capped at ~2 s. Three new tests:test_disconnect_left,test_plc_cap,test_reaper_timeout. -
Stereo screen-audio loopback (2026-06-17): WASAPI loopback opens in channel's stereo/mono mode (was hardcoded mono). Real stereo flows end-to-end through loopback → encode → decode → mixer. New
test_loopback_stereo_capture. -
Windows screen-audio UI wired (2026-06-17):
btnScreenShareToggleinMainForm.cs. No core/proto/ABI changes — all the plumbing was already there.dotnet test4/4 green. -
Bug fixes (2026-06-16 – 2026-06-17):
- AEAD nonce desync in SFU relay — relay forwarded sender's
seqverbatim; recipient nonce reconstruction used the wrong counter. Fixed by rewriting the outgoingseqfield to the recipient'speek_send_counter(). - Playout clock free-ran —
playout_tsadvanced even during VAD/PTT silence gaps, eventually dropping all frames as too-late. Fixed with resync inon_playbackviaJitterBuffer::peek_front_ts(). - Stale users after disconnect —
ConnSession::close()didn't broadcastUserEvent::LEFTbefore erasing. Fixed; PLC cap added as defense-in-depth. - "Randomly bumped to Lobby" — server excluded the actor from its own state-change
broadcasts. Fixed:
UserEvent::UPDATEDnow goes to all clients including the actor. - Silent playback after join —
opus_decodereceived hardware callback frame count asmax_samplesinstead of the Opus frame size. Fixed with a decode ring buffer.
- AEAD nonce desync in SFU relay — relay forwarded sender's
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 ✓
Repo layout (core/ server/ tools/ clients/ tests/), CMake + vcpkg manifest, C ABI header
(voicecat.h), proto source of truth, core stubs for all six subsystems, voicecat-server +
vccli skeletons, smoke CTest, .clang-format/.gitattributes/.gitignore.
M1 — Control plane ✓ (completed 2026-06-15)
Exit criterion: test_m1_integration — two clients authenticate over TLS 1.3 (guest +
Argon2id), exchange channel + private text. ~1 s.
FrameCodec, TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed, TOFU pins TLS leaf-cert
SHA-256), WorkerPool, Database (SQLite + Argon2id), ServerIdentityManager,
ConnSession state machine, SessionRegistry, vc_client full M1 C ABI, voicecat-admin
CLI, dual-stack TcpAcceptor. Key bug fixed: send_frame double-framing — encode_envelope
was pre-framing the protobuf; fixed by passing raw protobuf bytes.
M2 — Voice, single stream ✓ (completed 2026-06-16)
Exit criterion: test_m2_voice + test_voice_client_abi — two headless clients auth, bind
UDP, 50 Opus frames relayed + re-encrypted by SFU, B receives ≥25 and decrypts. ~4 s.
14-byte UDP voice header, SodiumMediaCrypto (ChaCha20-Poly1305 + 64-bit anti-replay),
OpusEncoder/OpusDecoder (FEC, PLC), UdpMediaChannel, JitterBuffer, AudioEngine
(miniaudio), MediaRelay SFU. Key bug fixed: on_playback passed hardware callback frame
count as opus_decode max_samples; fixed with a per-stream decode ring buffer.
M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
Exit criterion: test_m3_multistream — client A runs two concurrent streams (MIC +
SCREEN_AUDIO); B sees both; per-stream gain/mute/NR independent; effective Opus config matches
channel's server-enforced settings. ~2.4 s.
Fixed server stream_id counter bug (always wrote 1). Per-channel AudioConfig populated
(Lobby: mono/24kbps/VOIP + DTX; Music Room: stereo/128kbps/AUDIO). LocalStream map,
pending_announce_kind_, run_talk_timer(), thread-join race in teardown_voice() fixed.
New C ABI: vc_get_stream_audio_config, vc_test_inject_capture.
Post-M3 follow-up ✓ (completed 2026-06-16)
- Device enumeration —
vc_list_devices/vc_set_input_device; opaque hex device ids;vc_free_device_listnow frees. Works pre-connect. - VAD/PTT gate —
EnergyVadProcessor(RMS threshold ~0.025, 300 ms hang-time);vc_set_input_mode/vc_set_push_to_talk; MIC-only (SCREEN_AUDIO/AUX_DEVICE bypass). - True stereo playback —
playback_channels=2; stereo decoded L→L R→R in mixer; mono upmixed L=R; hardware fallback to mono on failure. - WASAPI loopback —
loopback_device_withma_device_type_loopback;VOICECAT_HAS_LOOPBACKmacro (Windows-only).vccli --share-screen-audio.
Known deferred (still open): AEC/NS/AGC (no working Windows/MSVC WebRTC APM build);
process-specific WASAPI loopback; RT-thread rule violation in on_capture_frame (mutex lock
on audio callback thread — pre-existing, needs lock-free ring-buffer refactor).
M4 — Native clients ✓ (completed 2026-06-17 – 2026-06-19)
Exit criterion: ctest --preset dev 21/21 green; dotnet build 0 warnings; xcodebuild
BUILD SUCCEEDED (macOS + iOS); manually verified: connect, TOFU, channel tree, join, voice,
text, device pickers, level meter on each platform.
New C ABI (additive): vc_list_channels/vc_list_users/vc_list_user_streams,
vc_join_channel, VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity,
vc_config::tofu_store_path, VC_INPUT_ALWAYS_ON, vc_set_vad_threshold,
vc_audio_suspend/vc_audio_resume, vc_audio_restart, vc_set_capture_channels.
Windows (clients/windows/): VoiceCat.Interop (P/Invoke, [UnmanagedCallersOnly]),
VoiceCat.App (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI,
PerUserTuningDialog, PttKeyCaptureDialog), VoiceCat.Interop.Tests. PTT is focus-scoped.
macOS (clients/apple/macOS/VoiceCatMac.xcodeproj): NSOutlineView channel tree,
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
menu, 17 Swift source files. build-xcframework.sh produces VoiceCatCore.xcframework.
iOS (clients/apple/iOS/VoiceCatiOS.xcodeproj): SwiftUI, NavigationSplitView/TabView,
OutlineGroup channel tree, IOSAudioRouter AVAudioSession driver, 24 Swift source files,
iOS 18.0 deployment target. App Group group.cat.voice.VoiceCat for Keychain sharing.
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 — per-session
Permissions, kick/ban/move/ server-mute, channel CRUD, DB schema v2 (channels,bans), BLAKE2b channel passwords. - C ABI —
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; eventsVC_EVENT_GENERIC_RESULT,VC_EVENT_ACCOUNT_LIST. - Four M5 tests passing —
ctest --preset dev21/21. - vccli M5 flags:
--kick,--ban,--move,--server-mute/-unmute/-deafen/-undeafen,--set-permission, channel CRUD, account CRUD,--username/--password. - All three client UIs (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full M5 moderation and admin surface.
- Docs —
docs/protocol.md,docs/security.mdkept in sync. - DRED/audio-quality polish — done (2026-06-20).
bool dredadded toAudioConfigproto (field 11) andvc_audio_configC ABI. Encoder:OPUS_SET_DRED_DURATION(2)when enabled (20 ms of ML redundancy per packet). Decoder:OpusDREDDecoder+ per-streamOpusDREDscratch pre-allocated;JitterBuffer::try_copy_front_payloadpeeks at the next buffered packet on every PLC step; if DRED data is present,opus_decoder_dred_decodereconstructs the lost frame — otherwise falls back to standard PLC. New test:test_dred_toggle(ctest 22/22). Files:voicecat.proto,voicecat.h,opus_codec.{h,cpp},audio_engine.{h,cpp},client.cpp,session.{h,cpp}. - In-band FEC decoder wiring — done (2026-06-22). The encoder set
OPUS_SET_INBAND_FECall along, but the decoder never invoked it — the loss path went DRED → PLC, so FEC redundancy was emitted (and paid for in bitrate) but never consumed. Wired the FEC recovery intoAudioEngine::on_playback's loss branch between DRED and PLC: copy the next buffered packet once, try DRED, else (if the stream negotiated FEC)decode(next_pkt, …, fec=true), else PLC. Added per-streamRemoteStream::fec_enabled_, captured fromOpusParamsininit_recv_stream. Recovery priority is now DRED → FEC → PLC. ctest 27/27 green. Files:audio_engine.{h,cpp},docs/voice.md. - DRED toggle in client UIs — expose the
dredflag in all three channel-config UIs so admins can enable it per channel. Windows:ChannelEditForm/vc_channel_info.audio.dredcheckbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs already have full channel CRUD wired; this is an additive checkbox on the existing audio-config section. (Core/protocol/ABI all done — this is UI-only work.) - macOS ScreenCaptureKit screen-audio — done 2026-06-21.
ScreenAudioCapture.swiftdrives anSCStream(audio-only,excludesCurrentProcessAudio), converts Float32 → int16 in the channel's mono/stereo mode, and callsvc_stream_feed_pcm. Capture starts on the self.streamStartedevent (when the effective config is known); wired intoMainWindowController.screenAudioClicked(). - iOS ReplayKit Broadcast Extension (
VoiceCatBroadcast) — done 2026-06-21. Forward-to-host design: the extension (SampleHandler.swift) captures.audioApp, converts to 48 kHz int16 stereo, and writes a shared App Group SPSC ring (BroadcastAudioRing.swift); the host'sBroadcastAudioPumpowns theSCREEN_AUDIOstream and feeds viavc_stream_feed_pcm(single session, no creds on disk). UI is anRPSystemBroadcastPickerViewinVoiceControlsView. (Replaced the speculativeBroadcastCredentials.swiftself-connecting design, now removed.) - External PCM feed/tap API (
vc_stream_feed_pcm+vc_set_pcm_sink) — done 2026-06-20. Promotesvc_test_inject_capture(mono-only, TEST-ONLY) to a public API with stereo support. Adds a symmetric PCM sink fired on the playback thread per decoded remote stream. Full wrappers for Swift (feedPcm/setPcmSink) and C# (StreamFeedPcm/SetPcmSink). Three new C++ ctests (test_feed_pcm_round_trip,test_feed_pcm_stereo,test_pcm_sink), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated (architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry).ctest --preset dev23/23. - macOS client UI overhaul — done 2026-06-20. Mirrors the Windows client's UI
overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard
shortcuts), adapted to Mac-native conventions:
- NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons), and Output Volume slider (NSSlider 0–100, default 80). Voice actions + mute/deafen + output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps input-mode segmented control / VAD slider / PTT key / device picker / level meter.
- Unified log: chat
NSTextView+ activityNSTableViewcollapsed into a singleNSTextView— activity events insecondaryLabelColor(gray), chat in default color. RemovedactivityTableViewandactivityLogarray. - Private messaging: scope dropdown removed; compose bar always sends to the current
channel. Each PM conversation opens in its own modeless
PrivateMessageWindowController(NSWindow). Incoming.textMessagewith.privatescope routed to the right window; outgoing PMs echoed by server arrive through the same path. "Send Private Message…" added to user context menu. "New Private Message…" (⌘⇧N) opensUserPickerSheetlisting all server users. - Channel counts: outline view renders
"Name (n)"with live user counts;refreshChannelTree()called on.userJoined/.userLeft(was missing). - Voice menu (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and Messages
menu (⌘⇧N new PM) added to
NSApp.mainMenuviaNSMenuItemkey equivalents with[.command, .shift]mask. Removed onwindowWillClose. Mac-native: ⌘ not Ctrl, dispatched by the responder chain (no custom key monitor needed). - Output volume:
setOutputVolume(_:)wrapper added toVoiceCatClient.swift(was missing — the C ABI + C# wrapper shipped in commit97fa659but the Swift wrapper was never added). Wired end-to-end: toolbar slider →client.setOutputVolume(gain). - Part A (uncompiled Swift fix): the external PCM feed/tap Swift wrapper (commit
615d2a8) was never compiled — the local xcframework predating thevoicecat.hPCM additions. Fixed: rebuilt xcframework (regenerated module map), fixedUInt→Inttype mismatch infeedPcm(Swift importssize_tasIntnotUInt), addedVoiceCatPcmSinkCallbacktypealias (Swift-idiomatic alias for the Cvc_pcm_sink_cbso consumers don't need to directly importVoiceCatC).swift test10/10 green. - Audio settings moved to Settings window: the bottom voice panel (input mode, VAD
slider, PTT key, device picker, level meter) was removed from the main window and moved
into a new
SettingsWindowController— a modeless window opened via the app menu's "Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat. Source-of-truth for audio settings (selectedInputMode,vadThresholdValue,selectedInputDeviceId,pttKeyCode) lives inMainWindowControllerso voice start can apply them even before the settings window has been opened;SettingsWindowControllerreads from and writes back to those properties and applies changes to the client immediately when voice is active. The level meter is forwarded fromMainWindowController.handleLevel→settingsWindowController.updateLevel(rms:).keyCodeNamehelper deduplicated (was duplicated inPttKeyCaptureSheet.swift+MainWindowController.swift— now shared fromMainWindowController.swift). - Files:
MainWindowController.swift(overhauled),PrivateMessageWindowController.swift(new),UserPickerSheet.swift(new),SettingsWindowController.swift(new),VoiceCatClient.swift(setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm type fix),ExternalPcmTests.swift(use typealias),PttKeyCaptureSheet.swift(removed duplicatekeyCodeName),VoiceCatMac.xcodeproj/project.pbxproj(register 3 new files). - Platform-specific adaptations (vs. Windows):
NSToolbarinstead ofToolStrip; global menu bar +NSMenuItemkey equivalents (⌘ not Ctrl, responder-chain dispatched); PM windows as modelessNSWindows; picker as Mac sheet; gray =secondaryLabelColor; SF Symbols for toolbar icons.
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.