Media API
The Media API provides endpoints for text-to-speech synthesis, audio transcription, and YouTube music search. /api/speech is used by in-OS Ryo chat; Maps, Books, TextEdit, Calculator, and the desktop assistant speak via browser speechSynthesis.
All endpoints run on the standalone Bun server and implement rate limiting to prevent abuse.
/api/speech is implemented with the shared apiHandler utility. /api/audio-transcribe is a multipart upload route and still uses explicit body-parser/CORS handling.
Text-to-Speech
Convert text to spoken audio using OpenAI or ElevenLabs voice synthesis.
Endpoint
| Method | Path | Description |
|---|
| POST | /api/speech | Convert text to speech audio |
| POST | /api/speech/permits | Mint a short-lived Ryo-chat speech permit bound to server-held assistant text |
Request
Headers:
| Header | Required | Description |
|---|
Content-Type | Yes | application/json |
Authorization | Yes | Bearer {token} (cookie session also accepted). Unauthenticated callers receive 401. |
X-Username | Yes | Username matching the token |
Body (JSON):
| Field | Type | Required | Description |
|---|
text | string | Yes for OpenAI; optional for Ryo when permitId is set | Text to convert to speech. For Ryo / ElevenLabs, a mismatched text is refused — only the permit-bound assistant text is synthesized. |
model | "openai", "elevenlabs" | No | TTS provider (default: "elevenlabs") |
source | string | For Ryo / ElevenLabs | Speech intent. Ryo ElevenLabs voices (including Default → Ryo PVC) require "ryo-chat". OpenAI does not. This flag is client-supplied and spoofable — first gate only. |
permitId | string | For Ryo / ElevenLabs | Opaque id from POST /api/speech/permits. Required for Ryo voices. The server synthesizes the bound text, not arbitrary client text. |
contentHash | string | No | Optional SHA-256 of the cleaned permit text. Rejected on mismatch. |
OpenAI Options:
| Field | Type | Default | Description |
|---|
voice | string | "alloy" | OpenAI voice name |
speed | number | 1.1 | Speech speed multiplier |
ElevenLabs Options:
| Field | Type | Default | Description |
|---|
voice_id | string | "OHP6tMHkOsRKrsDdbPah" | ElevenLabs voice ID |
model_id | string | "eleven_turbo_v2_5" | ElevenLabs model |
output_format | string | "mp3_44100_128" | Audio format |
voice_settings | object | See below | Voice customization |
Voice Settings Object:
{
"stability": 0.3,
"similarity_boost": 0.8,
"use_speaker_boost": true,
"speed": 1.1
}
Output Format Options:
mp3_44100_128 - MP3 at 44.1kHz, 128kbps
mp3_22050_32 - MP3 at 22.05kHz, 32kbpspcm_16000 - PCM at 16kHzpcm_22050 - PCM at 22.05kHzpcm_24000 - PCM at 24kHzpcm_44100 - PCM at 44.1kHzulaw_8000 - μ-law at 8kHz
Response
Success (200):
Returns audio stream with headers:
| Header | Value |
|---|
Content-Type | audio/mpeg |
Content-Length | Audio byte length |
Cache-Control | no-store |
Unauthorized (401):
{
"error": "Unauthorized - missing credentials"
}
/api/speech and /api/speech/permits require a signed-in user. Anonymous callers are rejected before synthesis, permit mint, or the Ryo source/permit gates. Browser speechSynthesis used by other apps does not call these endpoints and does not require login.
Error (400):
{
"error": "'text' is required"
}
Forbidden (403) — Ryo voice without source: "ryo-chat":
{
"error": "ryo_voice_forbidden",
"message": "Ryo ElevenLabs voices are limited to Ryo chat speech. Set source to \"ryo-chat\" or use model \"openai\".",
"source": null
}
Forbidden (403) — Ryo voice without a valid speech permit:
{
"error": "speech_permit_required",
"message": "A speech permit is required for Ryo ElevenLabs voices."
}
Other permit failures use speech_permit_invalid, speech_permit_expired, speech_permit_exhausted, speech_permit_owner_mismatch, speech_permit_text_mismatch, or speech_permit_hash_mismatch.
Speech permits
POST /api/speech/permits mints an opaque, server-held permit for a Ryo chat assistant chunk. Chat writes a short-lived speech draft while streaming and on turn complete; proactive greetings write a draft after persist. Mint verifies the requested text is a cleaned substring of that draft or of a stored assistant message, then stores the bound text. The chat client retries mint briefly on 404 / speech_text_not_bound while the streaming draft catches up. Permits last 5 minutes and may be used twice; consume is atomic so parallel /api/speech calls cannot exceed that reuse limit. A signed-in user is required (user:<username>). Anonymous callers are rejected with 401 before any permit or synthesis work. Permit mint is separately limited to 30 requests / minute.
Body (JSON):
| Field | Type | Required | Description |
|---|
messageId | string | Yes | Assistant message id from the Ryo chat stream |
text | string | Yes | Speakable chunk. Must be part of the server-held assistant text. |
source | string | Yes | Must be "ryo-chat" |
contentHash | string | No | SHA-256 of the cleaned chunk |
Success (200):
{
"permitId": "uuid",
"contentHash": "hex sha-256",
"expiresInSeconds": 300,
"messageId": "assistant-message-id"
}
Rate Limit (429):
{
"error": "rate_limit_exceeded",
"scope": "burst" | "daily",
"limit": 10,
"windowSeconds": 60,
"resetSeconds": 45,
"identifier": "username or anon:ip"
}
Rate Limits
| Scope | Limit | Window |
|---|
| Burst | 10 requests | 1 minute |
| Daily | 50 requests | 24 hours |
> Note: The authenticated ryo account bypasses speech and permit rate limiting. Other users and admins do not.
Example
Request:
curl -X POST https://your-domain.com/api/speech \
-H "Content-Type: application/json" \
-H "Authorization: Bearer AUTH_TOKEN" \
-H "X-Username: your-username" \
-d '{
"text": "Hello, welcome to ryOS!",
"model": "elevenlabs",
"source": "ryo-chat",
"permitId": "SPEECH_PERMIT_ID",
"voice_id": "OHP6tMHkOsRKrsDdbPah"
}' \
--output speech.mp3
With OpenAI:
curl -X POST https://your-domain.com/api/speech \
-H "Content-Type: application/json" \
-H "Authorization: Bearer AUTH_TOKEN" \
-H "X-Username: your-username" \
-d '{
"text": "Hello, welcome to ryOS!",
"model": "openai",
"voice": "nova",
"speed": 1.0
}' \
--output speech.mp3
Audio Transcription
Transcribe audio files to text using OpenAI Whisper.
Endpoint
| Method | Path | Description |
|---|
| POST | /api/audio-transcribe | Transcribe audio to text |
Request
Headers:
| Header | Required | Description |
|---|
Content-Type | Yes | multipart/form-data |
Form Data:
| Field | Type | Required | Description |
|---|
audio | File | Yes | Audio file to transcribe |
File Constraints:
| Constraint | Value |
|---|
| Max Size | 2 MB |
| Type | Must start with audio/ |
Response
Success (200):
{
"text": "Transcribed text content here"
}
Error (400) - No file:
{
"error": "No audio file provided"
}
Error (400) - Invalid type:
{
"error": "Invalid file type. Must be an audio file."
}
Error (400) - Too large:
{
"error": "File exceeds maximum size of 2MB"
}
Rate Limit (429):
{
"error": "rate_limit_exceeded",
"scope": "burst" | "daily",
"limit": 10,
"windowSeconds": 60,
"resetSeconds": 45,
"identifier": "ip:xxx.xxx.xxx.xxx"
}
Rate Limits
| Scope | Limit | Window |
|---|
| Burst | 10 requests | 1 minute |
| Daily | 50 requests | 24 hours |
Example
Request:
curl -X POST https://your-domain.com/api/audio-transcribe \
-F "[email protected]"
Response:
{
"text": "This is the transcribed text from the audio recording."
}
JavaScript (FormData):
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const response = await fetch('/api/audio-transcribe', {
method: 'POST',
body: formData,
});
const { text } = await response.json();
console.log('Transcription:', text);
YouTube Search
Search YouTube for music videos. Results are filtered to the Music category.
Endpoint
| Method | Path | Description |
|---|
| POST | /api/youtube-search | Search YouTube for music |
Request
Headers:
| Header | Required | Description |
|---|
Content-Type | Yes | application/json |
Body (JSON):
| Field | Type | Required | Default | Description |
|---|
query | string | Yes | - | Search query |
maxResults | number | No | 10 | Results to return (1-25) |
Response
Success (200):
{
"results": [
{
"videoId": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up",
"channelTitle": "Rick Astley",
"thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg",
"publishedAt": "2009-10-25T06:57:33Z"
}
]
}
Result Item:
| Field | Type | Description |
|---|
videoId | string | YouTube video ID |
title | string | Video title |
channelTitle | string | Channel name |
thumbnail | string | Thumbnail URL (medium quality preferred) |
publishedAt | string | ISO 8601 publish date |
Error (400) - Invalid body:
{
"error": "Invalid request body"
}
Error (503) - Every key out of quota:
{
"error": "All YouTube API keys have exceeded their daily quota",
"code": "youtube_quota_exhausted"
}
Error (502) - YouTube rejected the request:
{
"error": "YouTube search is unavailable",
"code": "youtube_unavailable"
}
The raw Google error is only written to server logs; clients map code to a localized message.
Error (500) - Not configured:
{
"error": "YouTube API is not configured",
"hint": "Add YOUTUBE_API_KEY to your .env.local file and restart the API server"
}
Rate Limit (429):
{
"error": "rate_limit_exceeded",
"scope": "burst" | "daily"
}
Rate Limits
| Scope | Limit | Window |
|---|
| Burst | 20 requests | 1 minute |
| Daily | 200 requests | 24 hours |
API Key Rotation
The endpoint supports multiple YouTube API keys for quota rotation:
YOUTUBE_API_KEY - Primary key
YOUTUBE_API_KEY_2 - Backup keyYOUTUBE_API_KEY_3 - Second backup key
Keys are tried in order. A request moves on to the next key when Google reports a quota or rate-limit error (HTTP 429, or 403 with reason quotaExceeded, dailyLimitExceeded, rateLimitExceeded, etc.), when the key itself is rejected (invalid, referrer/IP-restricted, API not enabled in that project), or on a network error. Request errors that no key can fix (e.g. invalidParameter) stop immediately. Each failed attempt is logged with its key index, label, HTTP status, and Google reason, never the key. Quota is per Google Cloud project, so keys only add capacity if they belong to different projects.
Example
Request:
curl -X POST https://your-domain.com/api/youtube-search \
-H "Content-Type: application/json" \
-d '{
"query": "Taylor Swift Shake It Off",
"maxResults": 5
}'
Response:
{
"results": [
{
"videoId": "nfWlot6h_JM",
"title": "Taylor Swift - Shake It Off",
"channelTitle": "TaylorSwiftVEVO",
"thumbnail": "https://i.ytimg.com/vi/nfWlot6h_JM/mqdefault.jpg",
"publishedAt": "2014-08-19T04:00:02Z"
}
]
}
JavaScript:
const response = await fetch('/api/youtube-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: 'Daft Punk Around the World',
maxResults: 10,
}),
});
const { results } = await response.json();
results.forEach(video => {
console.log(`${video.title} - ${video.channelTitle}`);
});
Apple Music Artwork
Resolves Apple Music catalog artwork (and basic metadata) for an am: catalog song ID using Apple's public iTunes Lookup API (no Apple auth required). Apple Music songs cached in Redis often have no cover because client-side MusicKit artwork is never persisted server-side; this endpoint backfills real cover art (used by the admin UI). Results are cached in Redis.
Endpoint
| Method | Path | Description |
|---|
| GET | /api/apple-music-artwork | Resolve cover art + metadata for a catalog song |
Request
Query Parameters:
| Parameter | Type | Required | Description |
|---|
id | string | Yes | Apple Music catalog ID in the form am:<number> |
Only numeric catalog IDs can be resolved. Library IDs (i.<hash>), stations (station:…), and playlists (playlist:…) are not catalog songs and return 400.
Response
Success (200):
{
"cover": "https://.../{w}x{h}bb.jpg",
"artist": "Artist Name",
"title": "Track Name",
"album": "Album Name",
"url": "https://music.apple.com/..."
}
| Field | Type | Description |
|---|
cover | string \ | null | Cover URL with {w}/{h} size placeholders, or null when unavailable |
artist | string? | Artist name |
title | string? | Track name |
album | string? | Collection/album name |
url | string? | Track or collection view URL |
Not Found (404):
When no artwork can be resolved, the same shape is returned with cover: null.
{
"cover": null
}
Error (400) - Invalid ID:
{
"error": "Expected an Apple Music catalog id (am:<number>)"
}
Caching
- Resolved artwork is cached for 30 days; "not found" results for 1 day.
- Successful responses send
Cache-Control: public, max-age=86400; misses send public, max-age=3600.
Rate Limits
| Scope | Limit | Window |
|---|
| Per-IP | 60 requests | 1 minute |
Environment Variables
| Variable | Required For | Description |
|---|
OPENAI_API_KEY | TTS (OpenAI), Transcription | OpenAI API key |
ELEVENLABS_API_KEY | TTS (ElevenLabs) | ElevenLabs API key |
YOUTUBE_API_KEY | YouTube Search | Primary YouTube Data API v3 key |
YOUTUBE_API_KEY_2 | YouTube Search | Backup YouTube API key (optional) |
YOUTUBE_API_KEY_3 | YouTube Search | Second backup YouTube API key (optional) |
REDIS_KV_REST_API_URL | Rate Limiting, Artwork cache | Upstash Redis URL |
REDIS_KV_REST_API_TOKEN | Rate Limiting, Artwork cache | Upstash Redis token |
Related
- Song API - Song library management for karaoke