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.

Base URL: https://narrator.corrin.ai/api/v1

API Capabilities

1.
Script Formatting

AI-powered character detection & dialogue tagging

2.
TTS Synthesis

100+ voices, streaming & batch, multi-character

3.
Speech to Text

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

REQUIRED

All 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.

BASH cURL
curl "https://narrator.corrin.ai/api/v1/tts/voices" \
  -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/voices

Get a list of all available TTS voices. Returns voice aliases that can be used with synthesis endpoints.

BASH cURL
curl -X GET "https://narrator.corrin.ai/api/v1/tts/voices" \
  -H "x-api-key: YOUR_API_KEY"
JSON Response
{
  "voices": [
    "Thundervox",
    "Wonderstruck",
    "Chronicler",
    "Serenity",
    "Storyteller",
    // ... 100+ more voices
  ],
  "count": 107
}

Popular Voice Categories

Narration: Storyteller, Chronicler, Narrator, Mythweaver
Calm: Serenity, Tranquil, Peaceful, Stillwater
Expressive: Thundervox, Wonderstruck, Amazement, Firebrand
Characters: Margaret, David, Elizabeth, James, Professor

Synthesize Speech

POST /tts/synthesize

Convert 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.
PYTHON requests
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)
BASH cURL
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/stream

Ultra-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.

JAVASCRIPT Browser
// 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

{"status": "ready"} - Connection established
{"type": "audio", "data": "base64...", "sample_rate": 24000} - Audio chunk
{"type": "complete", "chunk": 1, "total_chunks": 3} - Text chunk progress
{"type": "finalComplete", "total_chunks": 3} - All synthesis done
{"status": "error", "error": "message"} - Error occurred

Script Narration

WEBSOCKET /tts/script/stream

Stream multi-character narration with different voices per character. Perfect for stories, dialogues, and audiobook production.

JAVASCRIPT Browser
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/stream

Stream 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.

JAVASCRIPT Browser / Obsidian — audio-frame handling
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-json

AI-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.

PYTHON requests
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.
JSON Response
{
  "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

TUTORIAL

End-to-end example: Read a Markdown note, format it as a script with character detection, and synthesize multi-voice narration.

PYTHON complete_workflow.py
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

1
Read Note

Markdown or plain text

2
Check Speech Readiness

/speech/ready?model=tts

3
Format Script

/script/format-json

4
Narrate

/tts/script/stream


FastRTC Integration

GUIDE

Build 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.

WebRTC Streaming Voice Activity Detection Pause Detection FastAPI Integration
BASH Installation
# Install FastRTC with voice activity detection
pip install "fastrtc[vad]"

# Additional dependencies for this example
pip install websockets numpy
PYTHON voice_assistant.py
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

ReplyOnPause

Triggers your handler when the user stops speaking. Handles voice activity detection automatically.

Stream

Core class that manages WebRTC connections and routes audio between client and your handler.

Audio Format

tuple[int, np.ndarray] - sample rate and audio samples as numpy array.

Mounting

Use .mount(app) for FastAPI or .ui.launch() for testing.

Voice Assistant Architecture

1
User Speaks

FastRTC captures audio via WebRTC

2
Pause Detected

ReplyOnPause triggers handler

3
Process + LLM

STT, generate response

4
Narrator TTS

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.