ryOS ryOS / Docs
GitHub Launch

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

MethodPathDescription
POST/api/speechConvert text to speech audio
POST/api/speech/permitsMint a short-lived Ryo-chat speech permit bound to server-held assistant text

Request

Headers:
HeaderRequiredDescription
Content-TypeYesapplication/json
AuthorizationYesBearer {token} (cookie session also accepted). Unauthenticated callers receive 401.
X-UsernameYesUsername matching the token
Body (JSON):
FieldTypeRequiredDescription
textstringYes for OpenAI; optional for Ryo when permitId is setText to convert to speech. For Ryo / ElevenLabs, a mismatched text is refused — only the permit-bound assistant text is synthesized.
model"openai", "elevenlabs"NoTTS provider (default: "elevenlabs")
sourcestringFor Ryo / ElevenLabsSpeech intent. Ryo ElevenLabs voices (including Default → Ryo PVC) require "ryo-chat". OpenAI does not. This flag is client-supplied and spoofable — first gate only.
permitIdstringFor Ryo / ElevenLabsOpaque id from POST /api/speech/permits. Required for Ryo voices. The server synthesizes the bound text, not arbitrary client text.
contentHashstringNoOptional SHA-256 of the cleaned permit text. Rejected on mismatch.
OpenAI Options:
FieldTypeDefaultDescription
voicestring"alloy"OpenAI voice name
speednumber1.1Speech speed multiplier
ElevenLabs Options:
FieldTypeDefaultDescription
voice_idstring"OHP6tMHkOsRKrsDdbPah"ElevenLabs voice ID
model_idstring"eleven_turbo_v2_5"ElevenLabs model
output_formatstring"mp3_44100_128"Audio format
voice_settingsobjectSee belowVoice 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, 32kbps
  • pcm_16000 - PCM at 16kHz
  • pcm_22050 - PCM at 22.05kHz
  • pcm_24000 - PCM at 24kHz
  • pcm_44100 - PCM at 44.1kHz
  • ulaw_8000 - μ-law at 8kHz

Response

Success (200):

Returns audio stream with headers:

HeaderValue
Content-Typeaudio/mpeg
Content-LengthAudio byte length
Cache-Controlno-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):
FieldTypeRequiredDescription
messageIdstringYesAssistant message id from the Ryo chat stream
textstringYesSpeakable chunk. Must be part of the server-held assistant text.
sourcestringYesMust be "ryo-chat"
contentHashstringNoSHA-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

ScopeLimitWindow
Burst10 requests1 minute
Daily50 requests24 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

MethodPathDescription
POST/api/audio-transcribeTranscribe audio to text

Request

Headers:
HeaderRequiredDescription
Content-TypeYesmultipart/form-data
Form Data:
FieldTypeRequiredDescription
audioFileYesAudio file to transcribe
File Constraints:
ConstraintValue
Max Size2 MB
TypeMust 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

ScopeLimitWindow
Burst10 requests1 minute
Daily50 requests24 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

MethodPathDescription
POST/api/youtube-searchSearch YouTube for music

Request

Headers:
HeaderRequiredDescription
Content-TypeYesapplication/json
Body (JSON):
FieldTypeRequiredDefaultDescription
querystringYes-Search query
maxResultsnumberNo10Results 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:
FieldTypeDescription
videoIdstringYouTube video ID
titlestringVideo title
channelTitlestringChannel name
thumbnailstringThumbnail URL (medium quality preferred)
publishedAtstringISO 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

ScopeLimitWindow
Burst20 requests1 minute
Daily200 requests24 hours

API Key Rotation

The endpoint supports multiple YouTube API keys for quota rotation:

  • YOUTUBE_API_KEY - Primary key
  • YOUTUBE_API_KEY_2 - Backup key
  • YOUTUBE_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

MethodPathDescription
GET/api/apple-music-artworkResolve cover art + metadata for a catalog song

Request

Query Parameters:
ParameterTypeRequiredDescription
idstringYesApple 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/..."
}
FieldTypeDescription
coverstring \nullCover URL with {w}/{h} size placeholders, or null when unavailable
artiststring?Artist name
titlestring?Track name
albumstring?Collection/album name
urlstring?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

ScopeLimitWindow
Per-IP60 requests1 minute

Environment Variables

VariableRequired ForDescription
OPENAI_API_KEYTTS (OpenAI), TranscriptionOpenAI API key
ELEVENLABS_API_KEYTTS (ElevenLabs)ElevenLabs API key
YOUTUBE_API_KEYYouTube SearchPrimary YouTube Data API v3 key
YOUTUBE_API_KEY_2YouTube SearchBackup YouTube API key (optional)
YOUTUBE_API_KEY_3YouTube SearchSecond backup YouTube API key (optional)
REDIS_KV_REST_API_URLRate Limiting, Artwork cacheUpstash Redis URL
REDIS_KV_REST_API_TOKENRate Limiting, Artwork cacheUpstash Redis token

Related

  • Song API - Song library management for karaoke