Series: AgentZero Lite — Part 7. The previous post (Part 6 — Step 1 Embedding an Open vLLM, Step 2 Akka Streams) laid out the roadmap. This one reports back: Step 2 is in the trunk, opt-in behind a feature flag, with eleven TestKit cases passing and the legacy batch path still primary until live testing validates the stream path.
RunnableGraphs owned by a single VoiceStreamActor at /user/stage/voice.KillSwitch so the actor can tear them down atomically — BargeIn, CancelInflight, EndTurn, DeviceLost are all Tell-only messages that end up in the same teardown path.SmallestMailboxPool routers, not fused SelectAsync(parallelism: N) constants. Operations knob, not a code constant.StatefulSelectMany on FlowOperations (it's on SourceOperations only). Custom GraphStage<FlowShape<...>> is the canonical pattern for stateful flows in this codebase, and TestKit's SourceProbe / SinkProbe hook into it cleanly.VoiceSettings.UseStreamPipeline (default false). The legacy OnVoiceUtteranceEnded → SendCurrentInput batch path stays primary until live testing on real devices.Implementation summary: 27 files, +2,062 LoC, eleven stream TestKit cases passing in 337 ms, full Debug build clean. Git commit 0a87d1b on main.
Part 6 made the architectural argument; here's the operational one. The old voice path was a sequence of awaited calls:
UtteranceEnded event → byte[] PCM → await STT → set txtInput.Text →
SendCurrentInput → reactor finishes → await TTS → Play(byte[])
It worked, but every link in that chain has a different reason to fail differently:
| Link | Failure mode the chain didn't model |
|---|---|
| Microphone | OS-level buffer overflow if downstream stalls |
| STT | Transient 5xx from the cloud provider, mid-utterance |
| LLM | SSE token feed dropping mid-sentence |
| TTS | Per-chunk failure (a single sentence rendered poorly) |
| Speaker | Device disappeared (Bluetooth headset walked out of range) |
| User | Talked over the AI mid-sentence (barge-in) |
The batch chain reacted to all of these the same way: throw, log, drop the turn. A streaming pipeline lets each stage carry its own backpressure, retry, and cancellation policy without the others changing — the same shape Akka actors already gave us for terminal management, just now per-element instead of per-mailbox.
flowchart TD
Actor["<b>VoiceStreamActor</b><br/>/user/stage/voice<br/>• owns SharedKillSwitch<br/>• Tell-only protocol<br/>• holds materialized handles"]
subgraph INPUT ["INPUT graph (mic → reactor)"]
direction TB
I1["Source.Queue<MicFrame><br/>(64, OverflowStrategy.DropHead)"]
I2["KillSwitches.Single"]
I3["<b>VoiceSegmenterStage</b><br/>VAD + 1 s pre-roll + utterance FSM"]
I4[".Async()"]
I5["SelectAsync(p, sttPool.Ask)"]
I6["Sink.ActorRefWithAck(Self)"]
I1 --> I2 --> I3 --> I4 --> I5 --> I6
end
subgraph OUTPUT ["OUTPUT graph (LLM tokens → speaker)"]
direction TB
O1["Source.Queue<string><br/>(64, OverflowStrategy.Backpressure)"]
O2["KillSwitches.Single"]
O3["<b>SentenceChunkerStage</b><br/>.?!\\n + min/max chunk len"]
O4["TtsTextCleaner.StripMarkdown"]
O5["SelectAsync(p, ttsPool.Ask)"]
O6["Sink.ForEach(playback.Enqueue)"]
O1 --> O2 --> O3 --> O4 --> O5 --> O6
end
Actor -- "owns + materializes" --> I1
Actor -- "owns + materializes" --> O1
classDef actor fill:#1f2937,stroke:#0ea5e9,color:#e5e7eb,stroke-width:2px
classDef stage fill:#0b3d2e,stroke:#10b981,color:#d1fae5
class Actor actor
class I3,O3 stage
The crucial property is that the actor is the only thing the rest of the app talks to. WPF code never touches a graph or a kill switch. NAudio's capture callback Tells the actor with each MicFrame. The reactor's eventual reply Tells the actor with SpeakText (the OUTPUT graph then chunks per sentence and synthesizes in parallel with playback). The Cancel button Tells CancelInflight. Every cross-thread interaction is either a Tell or a Source.Queue.OfferAsync — both fire-and-forget, both backpressured by their own rules.