A modular, voice-driven state machine application built with XState v5 + React + TypeScript.
This project demonstrates a production-ready architecture for voice-controlled applications, using the state machine as the application "spine" with clean separation of concerns.
┌─────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ VoiceConsole│ │ StatePanel │ │ LogsPanel │ ... │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ AppShell│ (thin client, no business logic)│
└────────────────────┴────┬────┴──────────────────────────────────┘
│
▼ events
┌─────────────────────────────────────────────────────────────────┐
│ Voice Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │intentRouter │ │responseBank │ │ aliases │ │
│ └──────┬──────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ machine events │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ State Machine │
│ ┌─────────────────────────────────────────────────┐ │
│ │ animalMachine.ts │ │
│ │ (pure state machine - the application spine) │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────┐ ┌─────────┴─────────┐ ┌──────────┐ │
│ │ types.ts │ │ guards.ts │ │actions.ts│ │
│ └──────────┘ └───────────────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼ invokes
┌─────────────────────────────────────────────────────────────────┐
│ Actors │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │playbackActor│ │grooveSearch │ │narrationActor│ │
│ │ (timers) │ │ (search) │ │ (LLM/phi3) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
src/
├── machine/ # Core state machine (pure, deterministic)
│ ├── animalMachine.ts # The spine - machine definition
│ ├── types.ts # Context, events, and type definitions
│ ├── guards.ts # Pure guard functions
│ └── actions.ts # Action implementations
│
├── actors/ # Side effects, async, cancellable
│ ├── playbackActor.ts # Timer-based playback simulation
│ ├── grooveSearchActor.ts # Deterministic groove search
│ └── narrationActor.ts # LLM-powered narration (phi3:mini)
│
├── voice/ # Voice/utterance handling
│ ├── intentRouter.ts # Utterance → intent → events
│ ├── responseBank.ts # Fallback phrase banks
│ └── aliases.ts # Selection phrase aliases (A/B/C)
│
├── runtime/ # Machine runtime utilities
│ ├── interpret.ts # Actor creation and wiring
│ └── logger.ts # Structured logging
│
├── ui/ # React UI (thin client)
│ ├── AppShell.tsx # Main application shell
│ └── panels/ # UI panels
│ ├── StatePanel.tsx
│ ├── ContextPanel.tsx
│ ├── LogsPanel.tsx
│ ├── PlaybackPanel.tsx
│ ├── VoiceConsole.tsx
│ └── AnimalSaysPanel.tsx
│
├── util/ # Shared utilities
│ ├── time.ts # Time formatting, delays
│ └── random.ts # Random selection helpers
│
├── App.tsx # Entry point
└── App.css # Styles
idleArmed (powered on, not listening)
↓ WAKE
idleListening (awaiting command)
↓ NEW_SESSION
sessionActive (parent state)
├── askingVibe (prompts user)
├── waitingForRequest (expects groove description)
├── lookingUpGrooves (async search)
├── announceAudition (brief pause)
├── auditioning (A→B→C, 4 bars each)
├── unsureSelection (retry/fallback)
└── jamming (loop selected groove)
error (recoverable)
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build- Buttons simulate spoken phrases
VoiceConsolesendsVOICE_UTTERANCE({ text })intentRoutermaps utterances to machine events- No real speech recognition required
- Add
src/adapters/speechRecognitionAdapter.ts - Use Web Speech API
- Same intent routing, just different input source
- Add
src/adapters/ttsAdapter.ts - Use SpeechSynthesis or macOS
say - Speak
context.animalLineon change
The only LLM usage is in narrationActor.ts for generating Animal's spoken lines.
# Install Ollama (macOS)
brew install ollama
# Start Ollama server
ollama serve
# Pull the model
ollama pull phi3:miniCreate a .env file:
VITE_OLLAMA_BASE_URL=http://localhost:11434
VITE_NARRATION_ENABLED=true- Strict timeout: 600ms, then fallback to response bank
- JSON output: Forces
{ "line": "..." }format - Validation: Invalid responses trigger fallback
- Caching: Repeated prompts return cached responses
- Graceful degradation: Works without Ollama
- Wake greeting
- Ask vibe line
- Audition intro
- Unsure prompt
- Jamming confirmation
- Optional candidate callouts
Follow this exact click sequence to test the full workflow:
-
Click "animal" (wake word)
- State:
idleArmed→idleListening - Animal greets you
- State:
-
Click "start a new session"
- State:
idleListening→sessionActive.waitingForRequest - Animal asks for vibe
- State:
-
Click "play a chill rock beat at 112 BPM..."
- State:
waitingForRequest→lookingUpGrooves→auditioning - Watch grooves A, B, C play
- State:
- While auditioning, click "first" (or "A")
- State:
auditioning→jamming - Selected groove loops
- State:
- Click BARGE_IN
- State:
jamming→waitingForRequest - Loop stops, session preserved
- State:
- Start a new audition
- Click "gibberish / unclear" twice
- Fallback buttons appear
- Click a fallback button to select
const MY_COMMAND_PHRASES = [
'do something',
'make it happen',
];export type IntentType =
| ...existing...
| 'my_command';const INTENT_VALID_STATES = {
...existing...,
my_command: ['sessionActive.waitingForRequest'],
};case 'my_command':
send({ type: 'MY_COMMAND' });
break;Add the event type and transitions in animalMachine.ts.
| Scenario | Behavior |
|---|---|
| "new session" before wake | Implicitly wakes and starts session |
| Groove request in wrong state | Logged as blocked |
| "A"/"first" during audition | Interrupts and starts jamming |
| 2 unclear utterances | Enables fallback buttons |
| STOP from any state | Resets to idleArmed |
| BARGE_IN during jamming | Pauses without losing session |
| 200 bars in jamming | Auto-stops (safety limit) |
- Vite - Build tool
- React 18 - UI
- TypeScript - Type safety
- XState v5 - State machine library
- @xstate/react - React bindings
- Ollama + phi3:mini - LLM for narration (optional)
- Machine as Spine: The state machine is the source of truth
- Pure Core: Machine logic is deterministic and testable
- Actors for Effects: All side effects live in actors
- Thin UI: No business logic in components
- One LLM Actor: Narration only; everything else deterministic
- Graceful Fallbacks: Works without external dependencies
MIT