# Stitching long speech through a backend

This is a server-side pattern for an app that wraps a TTS provider. Do not call it from a browser, expose your provider credential, or log the text of private audio scripts. The app's caller must authenticate to your endpoint first.

## Algorithm

1. Split at paragraph and sentence boundaries. If a sentence is too long, break it at spaces or a conservative hard limit.
2. Generate chunks in sequence with the same voice, model, format, and settings.
3. If the model supports request stitching, capture the upstream `request-id` header. For each next chunk, supply at most three previous IDs. If the IDs are unavailable, supply a short `previous_text` fragment instead. `previous_text` is ignored when `previous_request_ids` are supplied.
4. Give the model a little `next_text` so it knows how to end the current sentence. Do not let context text become part of the audible `text` body.
5. Save each successful audio part and concatenate with a format-aware tool. FFmpeg's concat demuxer works for MP3 parts generated with the same codec settings. Store progress per chunk if the job may outlive one request.

The relevant ElevenLabs request parameters and history restrictions are documented at https://elevenlabs.io/docs/api-reference/text-to-speech/convert. The current API disables history-based request stitching for zero-retention requests. Validate a model's behavior instead of assuming every model supports it.

## Core backend loop

```typescript
type SpeechPart = { audio: Uint8Array; requestId?: string };

async function generateParts(
  chunks: string[],
  voiceId: string,
  modelId: string,
  secretFromManager: string,
  supportsRequestStitching: boolean
): Promise<SpeechPart[]> {
  const recentIds: string[] = [];
  const parts: SpeechPart[] = [];
  for (let index = 0; index < chunks.length; index++) {
    const body = {
      text: chunks[index],
      model_id: modelId,
      voice_settings: { stability: 0.5, similarity_boost: 0.75, speed: 1 },
      ...(index + 1 < chunks.length
        ? { next_text: chunks[index + 1].slice(0, 240) }
        : {}),
      ...(supportsRequestStitching && recentIds.length
        ? { previous_request_ids: recentIds.slice(-3) }
        : index > 0
          ? { previous_text: chunks[index - 1].slice(-240) }
          : {})
    };

    const response = await fetch(
      `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}`,
      {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "xi-api-key": secretFromManager
        },
        body: JSON.stringify(body)
      }
    );
    if (!response.ok) {
      throw new Error(`Speech chunk ${index + 1} failed: ${response.status}`);
    }
    const requestId = response.headers.get("request-id")
      ?? response.headers.get("x-request-id")
      ?? undefined;
    if (supportsRequestStitching && requestId) recentIds.push(requestId);
    parts.push({ audio: new Uint8Array(await response.arrayBuffer()), requestId });
  }
  return parts;
}
```

Add bounded retry and resume handling around each call before using this in production. Treat a missing request ID as a normal fallback, not a reason to discard successfully generated audio. The returned parts are **not** yet one valid audio file; concatenate and verify them before responding with `audio/mpeg`.

In Sauna, pin the provider connection to the app and let its outbound proxy inject the secret instead of including a manual key header. Outside Sauna, get `secretFromManager` from your backend secret manager. Never pass it through client JSON.
