@robottwo/openclaw-tts-voice-router
v0.1.0
Published
OpenClaw plugin that routes TTS synthesis to agent-specific voice configurations using text similarity correlation
Readme
🎙️ openclaw-tts-voice-router
An OpenClaw plugin that gives each agent its own voice. When multiple agents share a conversation (e.g., a group chat with Robby, 3PO, and a main assistant), this plugin automatically routes TTS synthesis to the correct voice for whichever agent just spoke — no manual configuration needed.
The Problem
In multi-agent setups, all TTS output typically uses a single voice. If Robby the Robot and a human-sounding assistant both reply in the same group chat, they sound identical when read aloud. There is no built-in mechanism to match a TTS request back to the agent that generated the text.
The Solution
This plugin correlates LLM output with TTS requests using text similarity matching:
┌─────────────┐ llm_output ┌────────────────────┐
│ LLM │ ──────────────────────► │ Correlation Store │
│ (any agent)│ agentId + text │ (trigram index) │
└─────────────┘ └────────┬───────────┘
│
│ findBestMatch()
│ trigram Jaccard similarity
▼
┌─────────────┐ synthesize(text) ┌────────────────────┐
│ TTS │ ◄────────────────────── │ Voice Router │
│ Pipeline │ voiceId + settings │ (proxy provider) │
└─────────────┘ └────────────────────┘- The
llm_outputhook fires after every LLM turn, capturing theagentIdand the assistant's text - Text is normalized (strip markdown, TTS directives, collapse whitespace) and indexed as trigrams
- When TTS is requested, the incoming text is compared against stored entries using trigram Jaccard similarity
- If a match exceeds the threshold (default 0.7), the request is routed to that agent's configured voice
- The matched entry is evicted — each correlation is used exactly once
Installation
openclaw plugins install robottwo/openclaw-tts-voice-routerOr clone and build locally:
git clone https://github.com/robottwo/openclaw-tts-voice-router.git
cd openclaw-tts-voice-router
npm install
npm run buildConfiguration
Minimal Setup
Give a single agent a custom voice while all others use the default:
plugins:
entries:
tts-voice-router:
config:
agents:
robby:
voiceId: "21m00Tcm4TlvDq8ikWAM" # ElevenLabs "Rachel"Multi-Agent Setup
Different voices for different agents in a group chat:
plugins:
entries:
tts-voice-router:
config:
agents:
robby:
voiceId: "pNInz6obpgDQGcFmaJgB" # ElevenLabs "Adam" — robotic tone
voiceSettings:
stability: 0.5
similarity_boost: 0.75
main:
voiceId: "21m00Tcm4TlvDq8ikWAM" # ElevenLabs "Rachel" — natural voice
3po:
voiceId: "AZnzlk1XvdvUeBnXmlld" # ElevenLabs "Domi" — warm, friendly
modelId: "eleven_multilingual_v2"
defaultAgent: "main" # Fallback when correlation misses
debug: trueDifferent Providers Per Agent
Route specific agents to different TTS backends:
plugins:
entries:
tts-voice-router:
config:
agents:
robby:
providerId: "openai" # OpenAI TTS
voiceId: "onyx"
modelId: "tts-1-hd"
main:
providerId: "elevenlabs" # ElevenLabs
voiceId: "21m00Tcm4TlvDq8ikWAM"
3po:
providerId: "google" # Google Cloud TTS
voiceId: "en-GB-Standard-A"
defaultAgent: "main"Reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| agents | object | (required) | Per-agent voice configuration. Keys are agent IDs. |
| agents.<id>.voiceId | string | — | Voice ID for this agent |
| agents.<id>.providerId | string | (global) | TTS provider override for this agent |
| agents.<id>.modelId | string | — | Model ID override (e.g., eleven_multilingual_v2) |
| agents.<id>.voiceSettings | object | — | Provider-specific voice settings |
| agents.<id>.normalization | boolean | true | Enable voice normalization for this agent |
| defaultAgent | string | — | Fallback agent ID when no correlation match is found |
| minSimilarity | number | 0.7 | Minimum Jaccard similarity threshold (0.1–1.0) |
| ttlMs | number | 60000 | How long correlation entries live (milliseconds) |
| maxEntries | number | 50 | Maximum stored correlation entries |
| debug | boolean | false | Log matching decisions and similarity scores |
Architecture
src/
├── index.ts # Plugin entry point — hooks + provider registration
├── normalize.ts # Text normalization (replicates OpenClaw's TTS pipeline)
├── correlation.ts # Trigram extraction, Jaccard similarity, correlation store
├── voice-router-provider.ts # Proxy SpeechProviderPlugin with per-agent routing
├── types.ts # Internal types
└── openclaw-types.d.ts # Ambient declarations for plugin SDK
test/
├── normalize.test.ts # 17 tests — markdown stripping, directive removal
├── correlation.test.ts # 27 tests — trigram Jaccard, store, TTL, eviction
├── voice-router-provider.test.ts # 14 tests — routing, fallback, debug logging
└── types.test.ts # 6 tests — type smoke tests64 tests, 0 dependencies (Node.js stdlib only for the matching algorithm).
Development
# Install
npm install
# Run all checks (lint + typecheck + test)
npm run check
# Individual commands
npm run lint # oxlint
npm run typecheck # tsc --noEmit
npm run test # vitest
npm run build # tsc (outputs to dist/)Running with Ralph
The repo includes a ralph.yml configuration (excluded from version control) for Ralph Orchestrator development loops. To run:
ralph run -c ralph.yml -H builtin:code-assistContributing
Contributions are welcome! This is an open-source plugin for the OpenClaw ecosystem.
Getting Started
- Fork the repository
- Clone your fork:
git clone https://github.com/<your-username>/openclaw-tts-voice-router.git - Install dependencies:
npm install - Run checks:
npm run check— all must pass
Making Changes
- One logical change per commit — keep the history clean and reviewable
- Tests first — write failing tests before implementation (TDD)
- Follow existing patterns — the codebase uses oxlint rules consistent with the OpenClaw project
- No external dependencies — the correlation algorithm uses only Node.js stdlib
- TypeScript strict mode —
tsc --noEmitmust pass with zero errors
Code Style
- Curly braces required on all
if/for/whileblocks (enforced by oxlint) - No
anytypes except in test files (enforced by oxlint) - ESM only —
"type": "module"in package.json - Full forms — no contractions in code comments
Pull Request Process
- Create a feature branch from
main - Make your changes with passing tests
- Run
npm run checklocally — CI runs the same pipeline - Open a PR with a clear description of the change
- CI must pass on Node.js 20 and 22 before merge
Reporting Issues
Please open a GitHub issue with:
- What happened — the observed behavior
- What you expected — the desired behavior
- Reproduction steps — config, agent setup, minimal example
- Logs — enable
debug: trueand include the matching output
