Custom engines & architecture¶
flowchart LR
A(["audio"]) --> STT["SttEngine<br/>(whisper.cpp)"]
STT --> T["text"]
T --> LLM["LlmEngine<br/>(Ollama)"]
LLM --> R["reply"]
R --> TTS["TtsEngine<br/>(Piper)"]
TTS --> O(["audio out"])
P["VoicePipeline<br/>(owns history)"] -. wires .-> STT & LLM & TTS
Design¶
whispa is deliberately thin. The core is VoicePipeline, which depends only on three
interfaces:
interface SttEngine { transcribe(audio: Uint8Array | string): Promise<string>; }
interface LlmEngine { chat(messages: Message[]): Promise<string>; }
interface TtsEngine { synthesize(text: string): Promise<Uint8Array>; }
Everything else — whisper.cpp, Ollama, Piper — is an adapter implementing one of these. This keeps the loop pure and fully unit-testable with mocks, and lets you mix engines freely.
Write your own engine¶
Any provider works — a cloud STT, a different local LLM, a hosted TTS — as long as it satisfies the interface:
import type { LlmEngine, Message } from 'whispa';
class MyLlm implements LlmEngine {
async chat(messages: Message[]): Promise<string> {
// call your model, return the assistant text
return '...';
}
}
Then drop it in:
Conversation state¶
historyholds the runningMessage[](system prompt first, if provided).respond()appends the user message, calls the LLM, appends the reply.maxHistorybounds retained turns (the system prompt is always kept).reset()clears everything except the system prompt.
Extending the loop¶
Common additions you can layer on top without touching the core:
- Wake word / VAD before calling
turn(). - Streaming TTS by implementing a
TtsEnginethat yields chunks. - Tool calling inside your
LlmEngine.chatimplementation. - Barge-in by cancelling playback when new audio arrives.