Novolis.Audio.Voice — Phase 1 (STT + capture) plan
Policies that keep the org coherent
name: Voice STT Phase 1
overview: "Align novolis-audio with the Voice concept spec by delivering Phase 1: microphone capture, input DSP, VAD-segmented offline STT via Sherpa-ONNX, and a ListenAsync orchestration API—building on the existing TTS/effects/Profiles stack without renaming packages yet."
todos:
- id: ci-fix-push
content: Commit/push SherpaOnnx targets csproj fix (pwsh + post-build zip copy) so Merge CI passes status: completed
- id: abstractions-stt
content: Add IAudioCapture, IVAD, ISpeechRecognizer, ISpeechService, null stubs in Voice.Abstractions status: completed
- id: playback-capture
content: Implement NaudioMicrophoneCapture + NullAudioCapture in Novolis.Audio.Playback status: completed
- id: input-effects
content: Add microphone preprocessor effect chain in Effects status: completed
- id: sherpa-stt-vad
content: Implement Sherpa VAD + OfflineRecognizer + model paths in Voice.SherpaOnnx status: completed
- id: speech-manifest
content: Add speech model manifest, fetch/pack/extract, codegen SpeechModelCatalog status: completed
- id: speech-service
content: Implement SpeechService ListenAsync pipeline + AddNovolisSpeech DI status: completed
- id: tests-docs-release
content: Unit tests, design/speech-models docs, verify-nuget-only, version bump, GPR publish status: completed isProject: false
Current state vs spec
| Spec layer | Today | Gap |
|---|---|---|
| TTS orchestration | [`VoiceService`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice\VoiceService.cs) + [`IVoiceService`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Abstractions\IVoiceService.cs) | Done (batch utterance) |
| Output effects | [`Novolis.Audio.Effects`](d:\novolis\novolis-audio\src\Novolis.Audio.Effects) + [`AtcRadioEffects`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Atc\AtcRadioEffects.cs) | Done |
| Phraseology | [`DefaultPhraseologyNormalizer`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Phraseology\DefaultPhraseologyNormalizer.cs) (digits only) | Partial |
| Domain profiles | [`Voice.Profiles`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Profiles) (archetypes) + [`Voice.Atc`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Atc) (delivery) | Naming differs from spec (`Profiles.Atc`); functionally similar |
| STT / VAD / `ListenAsync` | **Absent** | **Phase 1** |
| Streaming / interrupt | **Absent** | Phase 2 |
| Semantic intents | **Absent** | Phase 3 |
| Hint-based voice resolution | Fixed archetype → model | Phase 4 |
Intentional keep (for now): dual stacks from `docs/design.md` — miniaudio for game SFX, NAudio + Sherpa for voice. Unifying output graphs is out of Phase 1 scope.
flowchart LR
subgraph phase1 [Phase1_STT]
Mic[IAudioCapture]
InFx[InputEffectPipeline]
VAD[IVoiceActivityDetector]
end
subgraph existing [Existing_TTS]
Phrase[Phraseology]
TTS[IVoiceSynthesizer]
OutFx[OutputEffects]
Play[IAudioPlayback]
end
Mic --> InFx --> VAD --> STT[ISpeechRecognizer]
STT --> Norm[ITranscriptNormalizer]
Norm --> Listen[ISpeechService_ListenAsync]
Phrase --> TTS --> OutFx --> PlayPrerequisite: green CI (uncommitted fix)
The failed run and follow-up failed for two reasons:
powershellon Linux → fixed topwshin `build/Novolis.Audio.Voice.SherpaOnnx.targets`.- Zip
Contentitems evaluated beforeCreateVoiceModelZipson clean CI → fixed locally withCopyNovolisVoiceModelZipsToOutput+AddNovolisVoiceModelZipsForPack(not yet onorigin/mainper last check).
Action: Commit and push the pending changes to `build/Novolis.Audio.Voice.SherpaOnnx.targets` and `Novolis.Audio.Voice.SherpaOnnx.csproj` before or with Phase 1 work so Merge/publish stays green.
Phase 1 goal (your priority: STT path)
Deliver the spec’s STT leg end-to-end for offline, segment-based recognition (VAD boundaries + OfflineRecognizer), exposed as:
await foreach (var utterance in speech.ListenAsync(options, ct))
{
// utterance.Text — normalized transcript
}Non-goals for Phase 1: OnlineRecognizer streaming, wake-word, semantic command types, cloud adapters, miniaudio capture, full ICAO STT normalization, package renames.
Package changes
1. [`Novolis.Audio.Voice.Abstractions`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.Abstractions) — contracts only
Add types (no Sherpa/NAudio refs):
| Type | Responsibility |
|---|---|
| `IAudioCapture` | `IAsyncEnumerable<PcmBuffer>` or `ReadChunkAsync` from default mic |
| `CaptureOptions` | Device id (optional), sample rate, frame size |
| `IVoiceActivityDetector` | Feed float/PCM chunks; expose completed segments (`SpeechSegment`) |
| `ISpeechRecognizer` | `RecognizeAsync(PcmBuffer segment, SpeechRecognitionOptions)` → text |
| `ITranscriptNormalizer` | Post-STT text cleanup (default: trim + collapse whitespace; hook for future ICAO) |
| `ISpeechService` | `ListenAsync` orchestration |
| `SpeechRecognitionOptions` | Model profile, language, optional VAD thresholds |
| `SpeechUtterance` | `Text`, `IsFinal`, optional timing metadata |
| `NullSpeechRecognizer` / `NullVoiceActivityDetector` | CI/headless (empty stream or no-op) |
Keep `IVoiceService` unchanged; add ISpeechService as sibling (spec example can map to ISpeechService or a thin IVoiceService extension later).
2. [`Novolis.Audio.Playback`](d:\novolis\novolis-audio\src\Novolis.Audio.Playback) — capture (reuse NAudio dep)
Add alongside playback:
IAudioCaptureimplementation:NaudioMicrophoneCapture(WaveInEvent→ mono Int16PcmBufferchunks at 16 kHz default for Sherpa STT).NullAudioCapturefor CI.
Update package description to “PCM I/O” (playback + capture).
3. [`Novolis.Audio.Effects`](d:\novolis\novolis-audio\src\Novolis.Audio.Effects) — input preset
Effects are already direction-neutral. Add:
InputSpeechEffects(orMicrophonePreprocessorEffects) — chain: high-pass → simple noise gate / AGC (reuse existing primitives where possible; add minimalNoiseGateEffectonly if needed).- Used before VAD/STT, symmetric to `AtcRadioEffects`.
4. [`Novolis.Audio.Voice.SherpaOnnx`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.SherpaOnnx) — runtime
Extend existing package (already references org.k2fsa.sherpa.onnx 1.12.40):
| Class | Sherpa API |
|---|---|
| `SherpaVoiceActivityDetector` | `VoiceActivityDetector` + `VadModelConfig` (Silero) |
| `SherpaOfflineSpeechRecognizer` | `OfflineRecognizer` + `OfflineStream` |
| `SherpaSpeechModelPaths` | Mirror [`SherpaVoiceModelPaths`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice.SherpaOnnx\SherpaVoiceModelPaths.cs) for STT model roots |
| Graceful fallback | Missing model → `NullSpeechRecognizer` (like TTS → silence) |
Recognition mode: VAD emits segments → each segment decoded with OfflineRecognizer.Decode (not OnlineRecognizer yet). Supports push-to-talk later by bypassing VAD and passing a single buffer.
5. [`Novolis.Audio.Voice`](d:\novolis\novolis-audio\src\Novolis.Audio.Voice) — orchestration
SpeechServiceimplementingISpeechService:
await foreachcapture chunksinputEffects.Process(chunk)- VAD
AcceptWaveform/ drain segments - STT per segment
ITranscriptNormalizeryield returnSpeechUtterance
SpeechServiceBuilder+AddNovolisSpeech()mirroring `AddNovolisVoice`.- Optional:
SpeechServiceBuilderaccepts custom capture/VAD/recognizer for tests.
6. Manifest + codegen — one bundled STT model
Parallel to TTS (`NovolisAudioVoiceModelsManifest.cs`):
- Add
NovolisAudioSpeechModelsManifest(or extend manifest withModelKind: Tts | Stt— prefer separate manifest to keep codegen clear). - Bundle one small English offline model (recommendation: Sherpa’s compact Zipformer/Whisper-tiny English preset; exact id chosen to stay < ~50 MB with LFS).
- Extend pipeline: verify
models/{id}/, emitSpeechModelCatalog.g.csin Abstractions. - Scripts:
fetch-speech-model.ps1, pack/extract targets analogous to TTS (reuse fixed `Novolis.Audio.Voice.SherpaOnnx.targets` copy/extract pattern).
7. Tests and docs
- Unit tests: null capture → empty
ListenAsync; normalizer; VAD segment stitching (mock); optional gated integration test when STT model present (same pattern as `VoiceStackTests`). - Update `docs/design.md` with STT flow diagram and new packages/APIs.
- Add
docs/speech-models.md(mirrorvoice-models.md). - Run
verify-nuget-only.ps1; bumpbuild/version.props; publish via existing Merge workflow.
8. Dogfood (optional, small)
`BridgeCommander` — optional --listen or key-bound PTT printing transcripts (not required for Phase 1 acceptance).
API sketch (target)
// Abstractions
public interface ISpeechService
{
IAsyncEnumerable<SpeechUtterance> ListenAsync(
ListenOptions options,
CancellationToken cancellationToken = default);
}
public sealed record ListenOptions
{
public SpeechModelProfile Model { get; init; }
public bool UseVoiceActivityDetection { get; init; } = true;
public IAudioEffectPipeline? InputEffects { get; init; }
public Func<string, string>? NormalizeTranscript { get; init; }
}Register: services.AddNovolisSpeech() (capture + Sherpa VAD/STT + defaults).
Later phases (roadmap only)
| Phase | Focus |
|---|---|
| **2** | Streaming TTS (`GenerateWithConfig` callback) + interruptible play queue; optional `OnlineRecognizer` |
| **3** | Semantic layer: `ITranscriptNormalizer` → intent types; domain packages (`Voice.Profiles.Bridge`, etc.) |
| **4** | Hint-based `VoiceProfile` resolution; consolidate naming with spec |
| **5** | Shared output device graph (miniaudio + voice), device enumeration |
Risks and mitigations
| Risk | Mitigation |
|---|---|
| STT model size / LFS on CI | One small model; null fallback; fetch script for devs |
| Linux CI / `pwsh` | Already standardized on `pwsh`; keep extract targets cross-platform |
| NAudio capture Linux gaps | Document Windows-first for Phase 1; `NullAudioCapture` on unsupported platforms |
| Package proliferation | Capture lives in existing `Playback`; no new NuGet unless capture abstractions need to stay out of Playback |
Acceptance criteria
dotnet build+ CI green onmain(including TTS zip copy/extract fix).ISpeechService.ListenAsyncworks on Windows with bundled STT model: mic → input FX → VAD → transcript events.- Without models:
ListenAsynccompletes without throw (empty or no-op), suitable for CI. - Abstractions remain free of Sherpa/NAudio; SherpaOnnx implements engines only.
- Docs describe STT path and how it composes with existing TTS/ATC.