docs(progress): plan external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)

This commit is contained in:
2026-06-19 03:51:14 +02:00
parent cd530db024
commit a10a18aebe

View File

@@ -10,6 +10,168 @@ up instantly. Newest status at the top.
## ▶ 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:
1. **ReplayKit Broadcast Upload Extension (iOS `SCREEN_AUDIO`)** — the extension is a
*separate process* with a ~50 MB memory cap and can't link the full `AudioEngine`
(`ma_device`, capture/playback threads). It needs to feed `CMSampleBuffer` audio (system
app audio) into the encode path without any audio hardware. The current plan in
`docs/voice.md §9` says 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, no `ma_device` needed.
2. **ScreenCaptureKit (macOS `SCREEN_AUDIO`)**`SCStream` delivers `CMSampleBuffer` in 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_LOOPBACK` is Windows-only (`core/CMakeLists.txt:88-95`), so on macOS
`AudioEngine::start_loopback_capture()` hits the `#else` stub (`audio_engine.cpp:647-649`)
and returns `false`. The macOS client's "Share Screen Audio" button
(`MainWindowController.swift:800-816`) calls `startStream(.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.
3. **Bots** — music bot, TTS bot, radio relay, transcription bot. They create a
`SCREEN_AUDIO`/`AUX_DEVICE` stream 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 is `vc_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).
4. **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 via `AudioEngine::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 handles
`channels == 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 (no `channels` parameter).
**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`** — promote `vc_test_inject_capture` to a public, documented API
and add a `channels` parameter:
```c
/* 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:
```c
/* 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` — add `vc_pcm_sink_cb` typedef + the two function
declarations (append-only, after `vc_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`: validates `stream_id`, looks up
the `LocalStream`'s kind, calls `audio_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. Keep `vc_test_inject_capture` as a deprecated
alias calling `stream_feed_pcm(..., channels=1)` for source compatibility.
- `core/src/audio/audio_engine.{h,cpp}` — `inject_capture` already exists per-kind; add
a `channels` parameter to the ring-buffer write path (or a parallel stereo-aware
variant). The encode path in `client.cpp::on_capture_frame` already handles
`channels==2` via the stereo encode branch — just plumb the value through. For the
sink: add a `pcm_sink_` member (callback + user); in `on_playback` after 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 add
`vc_stream_feed_pcm`/`vc_set_pcm_sink` returning `VC_ERR_NOT_IMPLEMENTED` — keeps the
skeleton preset green.
- **Swift `VoiceCatCore`:** add `feedPcm(streamId:pcm:samplesPerChannel:channels:)` and
`setPcmSink(_:user:)` (the Swift wrapper around `vc_pcm_sink_cb` — a
`@convention(c)` closure + `Unmanaged` context, mirroring `Callbacks.swift`). Wraps both
new ABI functions.
- **C# `VoiceCat.Interop`:** add `StreamFeedPcm(streamId, pcm, samples, channels)` (with
`int16[]` marshaling) and `SetPcmSink` (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 via `vc_stream_feed_pcm` on a MIC stream, B receives via the
normal decode path and asserts energy matches. `test_feed_pcm_stereo`: same with
`channels=2`, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test).
`test_pcm_sink`: B sets a `vc_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 for `feedPcm`.
- `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 to `vc_test_inject_capture` (deprecated alias).
- `docs/voice.md §9` — update the iOS ReplayKit and macOS ScreenCaptureKit rows: both
now consume `vc_stream_feed_pcm` instead 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: convert `CMSampleBuffer` → int16,
feed via `vc_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 reference `vc_stream_feed_pcm`.
- **Implementation order:**
1. C ABI + core (`voicecat.h`, `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`) +
skeleton stub. Verify `ctest --preset dev` green.
2. `tests/test_external_pcm.cpp` — the three behavior tests. Verify green.
3. Swift `VoiceCatCore` wrapper + `VoiceCatCoreTests` round-trip.
4. C# `VoiceCat.Interop` wrapper + `VoiceCatClientSmokeTests` round-trip.
5. Docs (`architecture.md`, `voice.md`, `protocol.md`, `roadmap.md`, header comments).
6. **Then** ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of
this API instead of parallel media stacks.
- **Verification:** `ctest --preset dev` green (3 new tests); `swift test` green; `dotnet
test` green; `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`.
- **ABI stability:** append-only — two new functions + one new typedef, no existing
structs/enums changed. `vc_test_inject_capture` stays as a deprecated alias for source
compatibility. Treat as a deliberate, versioned ABI event per `docs/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.
- **Planned (not started):** **iOS audio overhaul + Join/Leave Voice + channel-id sync fix**
(2026-06-19, plan written on Windows; implement on Mac). Three problems found while reviewing
the iOS client: