moeru-ai/airi · error · Error
No streaming TTS model selected and server returned no defau
Error message
No streaming TTS model selected and server returned no default
What it means
Guard in the official streaming speech playground: the model to synthesize with comes from the stored provider config or, failing that, a server-provided default. The catalog and default are fetched from the server's `/api/v1/audio/models/streaming` endpoint (operator-controlled via `UNSPEECH_UPSTREAM.streaming`); `serverDefaultModel` is `getDefaultStreamingModel() ?? providerModels[0]?.id ?? null`. If the stored model is unset AND the server returned an empty catalog with no curated default, `model.value` resolves to `''` and this error is thrown before opening the WebSocket session.
Source
Thrown at packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue:95
})
// Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL
// vs uranus/saturn; see unspeech voices.go). Re-fetch on model change so the
// list switches accordingly.
watch(model, async () => {
await loadVoices()
})
// Synthesize via the streaming session helper. The page uses the SAME
// transport the runtime pipeline uses (ws → API proxy → unspeech
// bridge → Volcengine v3 bidirectional) so the preview faithfully
// represents what the user hears in actual chat. The session is opened
// per-preview because there's no LLM token stream here — we just send
// one `text` frame containing the static preview prompt.
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean): Promise<ArrayBuffer> {
const requestedModel = model.value
if (!requestedModel)
throw new Error('No streaming TTS model selected and server returned no default')
// `model` looks like `volcengine/seed-tts-2.0`. The trailing path is
// forwarded as Volcengine's `api_resource_id` so the upstream knows which
// model variant to use; matches the wiring in `Stage.vue`. We require the
// `<backend>/<resource>` shape and refuse anything else — silently picking
// a fallback resource id hides config drift.
const slashIndex = requestedModel.indexOf('/')
if (slashIndex < 0)
throw new Error(`Streaming model id missing backend prefix: ${requestedModel}`)
const apiResourceId = requestedModel.slice(slashIndex + 1)
const result = await streamingSynthesize({
model: requestedModel,
voice: voiceId,
input,
extraBody: {
api_resource_id: apiResourceId,
audio: { sample_rate: 24000, bit_rate: 64000 },
},
})View on GitHub (pinned to 677329427f)
Solutions
- Wait for the model picker to populate (modelsLoading) and explicitly select a streaming model, which persists it to provider config.
- Verify the server exposes streaming TTS: check the `/api/v1/audio/models/streaming` response in the network tab and have the operator set `UNSPEECH_UPSTREAM.streaming` with a default model.
- Re-login if the session expired — an unauthenticated models fetch can come back empty.
- Restart the app after the operator fixes server config so onMounted re-fetches the catalog.
- If the catalog is intentionally empty, remove/hide the provider page rather than letting users hit this dead end.
Example fix
// before
const requestedModel = model.value
if (!requestedModel)
throw new Error('No streaming TTS model selected and server returned no default')
// after
const requestedModel = model.value
if (!requestedModel)
throw new Error(modelsLoading.value
? 'Streaming model catalog is still loading — try again in a moment'
: 'No streaming TTS model available: select one, or ask the operator to configure UNSPEECH_UPSTREAM.streaming') Defensive patterns
Strategy: validation
Validate before calling
const canGenerate = computed(() => !!model.value && !modelsLoading.value)
// template: <button :disabled="!canGenerate">Generate</button>
if (modelsLoading.value)
throw new Error('Streaming model catalog still loading')
if (!model.value)
throw new Error('Select a streaming model first') Type guard
function hasStreamingModel(m: string | null | undefined): m is string {
return typeof m === 'string' && m.length > 0
} Try / catch
try { return await streamingSynthesize({ ... }) }
catch (error) { showError(errorMessageFrom(error)) } Prevention
- Persist the selected model to provider config as soon as the catalog loads (the page already does this when serverDefaultModel resolves).
- Operators: always curate a default model in UNSPEECH_UPSTREAM.streaming.
- Distinguish 'loading' from 'empty catalog' in the UI so users retry instead of failing.
When it happens
Trigger: Operator has not configured `UNSPEECH_UPSTREAM.streaming` (or no default model) server-side, so the models request returns an empty list; the models request failed (auth expired, network) leaving both stored config and server default empty; user cleared the stored model value; generating before the onMounted fetch resolves.
Common situations: Self-hosted deployments without the streaming upstream configured; expired login so the API returns an empty/unauthorized payload treated as no models; new backend versions that renamed models leaving stale-empty state; pointing the client at a server that does not expose the streaming endpoint.
Related errors
- Streaming model id missing backend prefix: ${requestedModel}
- [Speech Pipeline] bidirectional-ws provider reached per-segm
- MiniMax TTS request failed: ${response.status} ${response.st
- OpenRouter audio request failed: ${response.status} ${await
- OpenRouter audio response has no body
AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18).
Data as JSON: /api/errors/31db7ddc194834e5.
Report an issue: GitHub.