Neural Documentation
Transform documents into narrated audio with the Narrator API. Our pipeline handles script generation with character detection, high-fidelity text-to-speech synthesis with 100+ unique voices, and live speech-to-text streaming for note capture.
API Capabilities
AI-powered character detection & dialogue tagging
100+ voices, streaming & batch, multi-character
Live mic transcription via WebSocket streaming
Readiness and warmup — TTS and ASR
All TTS and ASR processing endpoints use the same speech container. After inactivity, bringing that container online may take a minute or more before a response arrives. This applies to HTTP speech synthesis, streaming narration, multi-voice script narration, and speech-to-text streaming.
You may notice this more during the plugin's early days, when fewer people are using the service and workers spend more time idle. As usage grows, more frequent requests should keep workers warm more often, reducing these startup delays. Warmup can still occur after idle periods or when new workers start; immediate responses are not guaranteed.
Before a speech request, call
POST /api/v1/speech/ready?model=tts for narration or
POST /api/v1/speech/ready?model=asr for transcription, with your
x-api-key header. Both check the shared container's readiness.
A successful response contains {"status":"warming"} or
{"status":"ready"}. This check can bring an idle worker online.
Show a “Warming up” indicator and repeat warming responses about
every three seconds, with a cancel control and a bounded wait (the demo allows five
minutes). Handle non-200 responses as errors. Once ready, begin your HTTP request or
open the WebSocket. Readiness is a point-in-time check: for WebSocket protocols that
send status: ready, wait for that message before sending text or audio.
If a connection times out, check readiness again before retrying.
# Use model=asr for transcription. curl -X POST 'https://narrator.corrin.ai/api/v1/speech/ready?model=tts' \ -H "x-api-key: $NARRATOR_API_KEY"
Authentication
REQUIREDAll API requests require authentication via the x-api-key header.
Get a free Narrator API key for 2,500 narrated words per UTC calendar month and 10 selected voices. The same key works for TTS and ASR. Save it in a password manager; keys are not sent by email. Check your allowance at word usage. Narration allowance resets monthly with no rollover; only one narration can run per account.
-H "x-api-key: YOUR_API_KEY"
Additional Headers
Some endpoints (script formatting, AI models) require an OpenRouter API key:
x-openrouter-api-key: sk-or-v1-xxxxxxxxxxxxx
List Voices
GET /tts/voicesGet a list of all available TTS voices. Returns voice aliases that can be used with synthesis endpoints.
curl -X GET "https://narrator.corrin.ai/api/v1/tts/voices" \ -H "x-api-key: YOUR_API_KEY"
{
"voices": [
"Thundervox",
"Wonderstruck",
"Chronicler",
"Serenity",
"Storyteller",
// ... 100+ more voices
],
"count": 107
}
Popular Voice Categories
Synthesize Speech
POST /tts/synthesizeConvert text to speech and receive a complete WAV audio file. Best for non-real-time generation (podcasts, articles, audiobooks).
| Parameter | Type | Description |
|---|---|---|
| text * | string | Text to convert to speech. Max 10,000 chars. Markdown auto-converted to plain text. |
| voice | string | Voice alias (e.g., "Thundervox", "Chronicler"). Defaults to "Thundervox". |
| temperature | float | Randomness/creativity (0.2-1.2, default: 0.8). Lower = more consistent. |
| top_k | int | Limits sampling to K most likely tokens (default: 250). |
| cfg_alpha | float | Voice guidance strength (0.5-3.0, default: 1.5). Higher = stronger voice character. |
| seed | int | Random seed for reproducible output. |
import requests url = "https://narrator.corrin.ai/api/v1/tts/synthesize" payload = { "text": "Welcome to the future of audio narration.", "voice": "Chronicler", "temperature": 0.8 } headers = { "x-api-key": "YOUR_API_KEY", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) # Save audio file (WAV format, 24kHz, PCM Float32) with open("speech.wav", "wb") as f: f.write(response.content)
curl -X POST "https://narrator.corrin.ai/api/v1/tts/synthesize" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Hello world!", "voice": "Thundervox"}' \ --output speech.wav
Real-time Streaming
WEBSOCKET /tts/streamUltra-low latency streaming via WebSocket. Audio chunks are received as they're generated - perfect for real-time playback.
1. Connect
Connect with voice and api_key as query parameters.
2. Send Text
Send text as plain string. Server handles chunking automatically.
3. Receive Audio
Get base64-encoded PCM Float32 chunks for immediate playback.
// Connect to WebSocket with voice and API key const ws = new WebSocket( 'wss://narrator.corrin.ai/api/v1/tts/stream?voice=Chronicler&api_key=YOUR_API_KEY' ); const audioContext = new AudioContext(); let scheduledTime = 0; ws.onmessage = async (event) => { const msg = JSON.parse(event.data); if (msg.status === 'ready') { // Connection ready - send text to synthesize ws.send("Hello! This is real-time streaming synthesis."); } else if (msg.type === 'audio') { // Decode base64 to raw bytes const audioBytes = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0)); const audioFloat32 = new Float32Array(audioBytes.buffer); // Create AudioBuffer for playback const audioBuffer = audioContext.createBuffer(1, audioFloat32.length, msg.sample_rate); audioBuffer.getChannelData(0).set(audioFloat32); // Schedule for seamless playback const source = audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(audioContext.destination); source.start(audioContext.currentTime + scheduledTime); scheduledTime += audioBuffer.duration; } else if (msg.type === 'finalComplete') { console.log('Synthesis complete!'); scheduledTime = 0; // Reset for next request } };
Message Types
Script Narration
WEBSOCKET /tts/script/streamStream multi-character narration with different voices per character. Perfect for stories, dialogues, and audiobook production.
const ws = new WebSocket( 'wss://narrator.corrin.ai/api/v1/tts/script/stream?api_key=YOUR_API_KEY' ); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.status === 'ready') { // Send script with character-voice mappings ws.send(JSON.stringify({ "content": `[NARRATOR] Once upon a time, in a distant land... [PRINCESS] "I wish for adventure," she sighed. [NARRATOR] said the princess. [KNIGHT] "Then adventure you shall have!" [NARRATOR] replied the brave knight.`, "voices": { "NARRATOR": "Chronicler", "PRINCESS": "Serenity", "KNIGHT": "Thundervox" }, "default_voice": "Chronicler", "filename": "chapter1" })); } else if (msg.type === 'audio') { console.log(`Audio from ${msg.character} (${msg.section}/${msg.total_sections})`); playAudioChunk(msg.data, msg.sample_rate); } else if (msg.type === 'sectionComplete') { console.log(`Completed: ${msg.character}`); } else if (msg.type === 'finalComplete') { console.log('All narration complete!'); } };
| Request Field | Type | Description |
|---|---|---|
| content * | string | Script with [CHARACTER] delimiters for each speaking part. |
| voices * | object | Mapping of character names to voice aliases. |
| default_voice | string | Fallback voice for unmapped characters. |
| filename | string | Optional script filename for tracking. |
Speech to Text Stream (ASR)
WEBSOCKET /stt/streamStream microphone PCM to the API and receive live transcribed words. The browser never talks to Moshi/Runpod directly — credentials stay on the server. Audio must be 24 kHz mono float32, ideally in ~80 ms frames (1920 samples).
Transcription allowances are separate from narrated words: Free includes 10 minutes per UTC calendar month; Scribe includes 120 minutes per paid billing period; Author has no monthly transcription cap. Audio duration includes silence; warmup and failed upstream sends do not count. Frames must contain finite float32 samples and at most five seconds of audio (80 ms frames are recommended).
When an allowance is exhausted, a new stream returns an error with
code: 429. A stream reaching its limit flushes accepted audio and returns
finalComplete with limit_reached: true and a message.
Keep the final transcript and show that message. No audio beyond the allowance is accepted.
Check usage at GET /api/v1/stripe/account: transcription fields are
transcription_seconds_used and transcription_seconds_allowance
(null means unlimited).
Use the same Narrator API key as TTS. This endpoint transcribes audio; it does not require an OpenRouter key. WAV, MP3, and WebM files cannot be sent directly: decode and resample them to the PCM format below. The Python example converts a 24 kHz mono 16-bit PCM WAV to float32 frames.
1. Connect
Open the WebSocket with api_key as a query parameter. Wait for
status: ready.
2. Send Audio
Send JSON frames with base64 little-endian float32 PCM (preferred) or a raw float array.
3. Stop & Flush
Send {"type":"stop"}.
The API flushes delayed words, then returns finalComplete.
The JavaScript fragment assumes your microphone capture supplies
pcmFrame as 1920 float32 samples resampled to 24 kHz mono. Call the audio
send operation for each frame after readiness. Browser MediaRecorder output is encoded
media and must be decoded first; it is not raw PCM.
const ws = new WebSocket( 'wss://narrator.corrin.ai/api/v1/stt/stream?api_key=YOUR_API_KEY' ); function encodePcmBase64(pcm /* Float32Array */) { const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.status === 'ready') { // sample_rate is 24000 — start sending ~80ms frames ws.send(JSON.stringify({ type: 'audio', data: encodePcmBase64(pcmFrame) })); } else if (msg.type === 'word') { console.log(msg.text, msg.start_time); // live insert into the editor } else if (msg.type === 'finalComplete') { console.log('Transcript:', msg.text); } }; // When the user stops recording: // ws.send(JSON.stringify({ type: 'stop' }));
Python: transcribe a WAV file
Run the shared readiness check first. This example sends audio at
real-time speed while receiving words concurrently. Keep receiving after stop until
finalComplete; closing earlier can lose the final words.
# Install: python -m pip install websockets
# Set NARRATOR_API_KEY, then run: python transcribe.py speech.wav
# Input: mono, 24,000 Hz, 16-bit PCM WAV.
import asyncio, base64, json, os, struct, sys, wave
from urllib.parse import urlencode
import websockets
async def transcribe(path):
query = urlencode({"api_key": os.environ["NARRATOR_API_KEY"]})
url = "wss://narrator.corrin.ai/api/v1/stt/stream?" + query
with wave.open(path, "rb") as audio:
assert (audio.getnchannels(), audio.getframerate(), audio.getsampwidth()) == (1, 24000, 2)
async with websockets.connect(url, open_timeout=30) as ws:
ready = json.loads(await asyncio.wait_for(ws.recv(), timeout=300))
if ready.get("status") != "ready":
raise RuntimeError(ready.get("error", "ASR is not ready"))
async def send_audio():
while raw := audio.readframes(1920):
samples = [s[0] / 32768.0 for s in struct.iter_unpack("<h", raw)]
pcm = struct.pack("<" + "f" * len(samples), *samples)
await ws.send(json.dumps({"type": "audio", "data": base64.b64encode(pcm).decode()}))
await asyncio.sleep(len(samples) / 24000)
await ws.send(json.dumps({"type": "stop"}))
sender = asyncio.create_task(send_audio())
try:
while True:
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=60))
if msg.get("status") == "error":
raise RuntimeError(msg["error"])
if msg.get("type") == "word":
print(msg["text"], end=" ", flush=True)
if msg.get("type") == "finalComplete":
print("\nTranscript:", msg["text"])
break
await sender
finally:
sender.cancel()
await asyncio.gather(sender, return_exceptions=True)
asyncio.run(transcribe(sys.argv[1]))
| Direction | Message | Description |
|---|---|---|
| Server → Client | {"status":"ready","sample_rate":24000} | Upstream ASR is ready; begin sending audio. |
| Client → Server | {"type":"audio","data":"<base64>"} | Preferred: little-endian float32 PCM as base64. Alternate:
{"type":"audio","pcm":[...]}. |
| Client → Server | {"type":"stop"} | End recording; server sends Marker + silence flush upstream. |
| Server → Client | {"type":"word","text":"…","start_time":0.12} | Live word (join with spaces). May arrive during recording and after stop. |
| Server → Client | {"type":"finalComplete","text":"…"} | Full joined transcript after flush. |
| Server → Client | {"status":"error","error":"…"} | Auth, demo limit, or upstream ASR failure. |
Notes:
Demo API keys require an allowed Origin and are capped by
DEMO_MAX_STT_SECONDS (default 120).
Moshi credentials never leave the API process.
Format Script
POST /script/format-jsonAI-powered script formatting. Automatically detects characters, extracts dialogue, and creates narration-ready scripts with [CHARACTER] tags.
Note: This endpoint
requires an OpenRouter API key for AI processing. Pass it via the x-openrouter-api-key header.
import requests url = "https://narrator.corrin.ai/api/v1/script/format-json" payload = { "text": """Once upon a time, there was a princess. "I would like a cup of coffee," said the princess. "I'll have the same," replied the prince with a smile.""", "model": "gpt-4o-mini" } headers = { "x-api-key": "YOUR_API_KEY", "x-openrouter-api-key": "sk-or-v1-xxxxxxxxxxxxx", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) result = response.json() # Use the formatted script print(result["formatted_text"]) # Output: # [NARRATOR] Once upon a time, there was a princess. # [PRINCESS] "I would like a cup of coffee," # [NARRATOR] said the princess. # [PRINCE] "I'll have the same," # [NARRATOR] replied the prince with a smile.
{
"filename": "input.txt",
"success": true,
"script": {
"title": "Untitled",
"lines": [
{"character": "NARRATOR", "text": "Once upon a time...", "line_number": 1},
{"character": "PRINCESS", "text": "\"I would like...\"", "line_number": 2}
],
"characters": ["NARRATOR", "PRINCESS", "PRINCE"]
},
"formatted_text": "[NARRATOR] Once upon a time...",
"processing_time": 3.2,
"model_used": "gpt-4o-mini"
}
Complete Workflow Example
TUTORIALEnd-to-end example: Read a Markdown note, format it as a script with character detection, and synthesize multi-voice narration.
import requests import asyncio import websockets import json import base64 BASE_URL = "https://narrator.corrin.ai/api/v1" API_KEY = "YOUR_API_KEY" OPENROUTER_KEY = "sk-or-v1-xxxxxxxxxxxxx" headers = { "x-api-key": API_KEY, "x-openrouter-api-key": OPENROUTER_KEY, } # Step 1: Read a Markdown note or plain-text file with open("story.md", encoding="utf-8") as f: note_text = f.read() # Step 2: Format as script with character detection script_resp = requests.post( f"{BASE_URL}/script/format-json", json={"text": note_text, "model": "gpt-4o-mini"}, headers={**headers, "Content-Type": "application/json"} ) script_data = script_resp.json() formatted_script = script_data["formatted_text"] characters = script_data["script"]["characters"] print(f"Detected characters: {characters}") # Step 3: Create voice mapping for each character voice_mapping = { "NARRATOR": "Chronicler", "PRINCESS": "Serenity", "PRINCE": "Thundervox", "WITCH": "Mysterion", } # Step 4: Stream multi-character narration async def stream_narration(): uri = f"wss://narrator.corrin.ai/api/v1/tts/script/stream?api_key={API_KEY}" audio_chunks = [] async with websockets.connect(uri) as ws: # Wait for ready ready = json.loads(await ws.recv()) print(f"Connected: {ready}") # Send script with voice mappings await ws.send(json.dumps({ "content": formatted_script, "voices": voice_mapping, "default_voice": "Chronicler", "filename": "my_story" })) # Collect audio chunks while True: msg = json.loads(await ws.recv()) if msg["type"] == "audio": audio_chunks.append(base64.b64decode(msg["data"])) print(f" {msg['character']} ({msg['section']}/{msg['total_sections']})") elif msg["type"] == "finalComplete": print(f"Complete! {msg['total_sections']} sections") break # Save combined audio (would need WAV header in production) with open("narration.raw", "wb") as f: f.write(b"".join(audio_chunks)) print("Saved narration.raw") asyncio.run(stream_narration())
Workflow Summary
Markdown or plain text
/speech/ready?model=tts
/script/format-json
/tts/script/stream
FastRTC Integration
GUIDEBuild real-time voice assistants by combining FastRTC with the Narrator API. FastRTC handles WebRTC/WebSocket streaming with automatic voice activity detection and pause detection, while Narrator provides high-quality TTS for responses.
What is FastRTC?
FastRTC is a Python library that transforms functions into real-time audio/video streams. It handles the complexity of WebRTC connections, voice activity detection, and turn-taking automatically.
# Install FastRTC with voice activity detection pip install "fastrtc[vad]" # Additional dependencies for this example pip install websockets numpy
import asyncio import json import base64 import numpy as np import websockets from fastapi import FastAPI from fastrtc import ReplyOnPause, Stream API_KEY = "YOUR_API_KEY" NARRATOR_WS_URL = "wss://narrator.corrin.ai/api/v1/tts/stream" async def synthesize_with_narrator(text: str, voice: str = "Chronicler"): """Stream TTS audio from Narrator API.""" uri = f"{NARRATOR_WS_URL}?voice={voice}&api_key={API_KEY}" async with websockets.connect(uri) as ws: # Wait for ready signal ready = json.loads(await ws.recv()) if ready.get("status") != "ready": return # Send text for synthesis await ws.send(text) # Receive and yield audio chunks while True: msg = json.loads(await ws.recv()) if msg.get("type") == "audio": # Decode base64 audio to numpy array audio_bytes = base64.b64decode(msg["data"]) audio_array = np.frombuffer(audio_bytes, dtype=np.float32) sample_rate = msg.get("sample_rate", 24000) yield (sample_rate, audio_array) elif msg.get("type") == "finalComplete": break def voice_assistant(audio: tuple[int, np.ndarray]): """ Handler triggered when user stops speaking. Receives audio, processes it, and yields TTS response. """ sample_rate, audio_array = audio # Step 1: Transcribe user audio with Narrator /api/v1/stt/stream # user_text = transcribe(audio_array, sample_rate) user_text = "Hello, tell me about the weather." # Placeholder # Step 2: Generate response (use your preferred LLM) # response_text = generate_response(user_text) response_text = "The weather today is sunny with a high of 72 degrees." # Step 3: Stream TTS response from Narrator API async def stream_response(): async for chunk in synthesize_with_narrator(response_text): yield chunk # Run async generator in sync context loop = asyncio.new_event_loop() gen = stream_response() try: while True: yield loop.run_until_complete(gen.__anext__()) except StopAsyncIteration: pass finally: loop.close() # Create the FastRTC stream stream = Stream( handler=ReplyOnPause(voice_assistant), modality="audio", mode="send-receive", ) # Mount on FastAPI app = FastAPI() stream.mount(app) # Run with: uvicorn voice_assistant:app --host 0.0.0.0 --port 8000 # Test with: stream.ui.launch() for built-in UI
Key Concepts
Triggers your handler when the user stops speaking. Handles voice activity detection automatically.
Core class that manages WebRTC connections and routes audio between client and your handler.
tuple[int, np.ndarray] - sample rate and
audio samples as numpy array.
Use .mount(app) for
FastAPI or .ui.launch() for testing.
Voice Assistant Architecture
FastRTC captures audio via WebRTC
ReplyOnPause triggers handler
STT, generate response
Stream audio back to user
Tip: For production, you can use Narrator’s own /stt/stream endpoint for transcription, or integrate with services like Groq Whisper or Deepgram.