> This is a page from the ElevenLabs documentation. For a complete page index, fetch https://elevenlabs.io/docs/llms.txt. For the full documentation in a single file, fetch https://elevenlabs.io/docs/llms-full.txt.

# 使用 Supabase 进行流式传输和缓存

> **Note**
>
> **操作指南** · 假设你已完成 [ElevenAPI 快速入门](/docs/zh/eleven-api/quickstart)，并拥有 Supabase 账户。

## 简介

本指南将介绍如何使用 Supabase Edge Functions、Supabase Storage 和 ElevenLabs 构建边缘 API，以生成、流式传输、存储和缓存语音。

## 要求

* 拥有 [API 密钥](https://elevenlabs.io/app/settings/api-keys)的 ElevenLabs 账户。
* [Supabase](https://supabase.com) 账户（可通过 [database.new](https://database.new) 免费注册）。
* 在本机安装 [Supabase CLI](https://supabase.com/docs/guides/local-development)。
* 在本机安装 [Deno runtime](https://docs.deno.com/runtime/getting_started/installation/)，也可选择[在常用 IDE 中进行设置](https://docs.deno.com/runtime/getting_started/setup_your_environment)。

## 设置

### 在本地创建 Supabase 项目

安装 [Supabase CLI](https://supabase.com/docs/guides/local-development) 后，运行以下命令在本地创建新的 Supabase 项目：

```bash
supabase init
```

### 配置存储桶

可以通过在 `config.toml` 文件中添加以下配置，让 Supabase CLI 自动生成存储桶：

**`./supabase/config.toml`**

```toml ./supabase/config.toml
[storage.buckets.audio]
public = false
file_size_limit = "50MiB"
allowed_mime_types = ["audio/mp3"]
objects_path = "./audio"
```

> **Note**
>
> 运行 `supabase start` 后，本地 Supabase 项目中将创建新的存储桶。如需将其推送到托管的 Supabase 项目，可运行 `supabase seed
>   buckets --linked`。

### 为 Supabase Edge Functions 配置后台任务

要在本地开发时使用 Supabase Edge Functions 的后台任务，需要在 `config.toml` 文件中添加以下配置：

**`./supabase/config.toml`**

```toml ./supabase/config.toml
[edge_runtime]
policy = "per_worker"
```

> **Note**
>
> 使用 `per_worker` 策略运行时，Function 不会在编辑后自动重新加载。需要通过运行 `supabase functions serve` 手动重启。

### 为语音生成创建 Supabase Edge Function

运行以下命令创建新的 Edge Function：

```bash
supabase functions new text-to-speech
```

如果使用 VS Code 或 Cursor，当 CLI 提示“Generate VS Code settings for Deno? \[y/N]”时，选择 `y`！

### 设置环境变量

在 `supabase/functions` 目录中创建新的 `.env` 文件，并添加以下变量：

**`supabase/functions/.env`**

```env supabase/functions/.env
# Find / create an API key at https://elevenlabs.io/app/settings/api-keys
ELEVENLABS_API_KEY=your_api_key
```

### 依赖项

项目使用以下依赖项：

* 用于与 Supabase 数据库交互的 [@supabase/supabase-js](https://supabase.com/docs/reference/javascript) 库。
* 用于与文本转语音 API 交互的 ElevenLabs [JavaScript SDK](/docs/zh/eleven-api/quickstart)。
* 用于从请求参数生成哈希值的开源 [object-hash](https://www.npmjs.com/package/object-hash)。

由于 Supabase Edge Function 使用 [Deno runtime](https://deno.land/)，无需安装依赖项，而是可通过 `npm:` 前缀[导入](https://docs.deno.com/examples/npm/)它们。

## 编写 Supabase Edge Function

在新创建的 `supabase/functions/text-to-speech/index.ts` 文件中添加以下代码：

**`supabase/functions/text-to-speech/index.ts`**

```ts supabase/functions/text-to-speech/index.ts
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "jsr:@supabase/supabase-js@2";
import { ElevenLabsClient } from "npm:elevenlabs";
import * as hash from "npm:object-hash";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

const elevenlabs = new ElevenLabsClient({
  apiKey: Deno.env.get("ELEVENLABS_API_KEY"),
});

// Upload audio to Supabase Storage in a background task
async function uploadAudioToStorage(stream: ReadableStream, requestHash: string) {
  const { data, error } = await supabase.storage
    .from("audio")
    .upload(`${requestHash}.mp3`, stream, {
      contentType: "audio/mp3",
    });

  console.log("Storage upload result", { data, error });
}

Deno.serve(async (req) => {
  // To secure your function for production, you can for example validate the request origin,
  // or append a user access token and validate it with Supabase Auth.
  console.log("Request origin", req.headers.get("host"));
  const url = new URL(req.url);
  const params = new URLSearchParams(url.search);
  const text = params.get("text");
  const voiceId = params.get("voiceId") ?? "JBFqnCBsd6RMkjVDRZzb";

  const requestHash = hash.MD5({ text, voiceId });
  console.log("Request hash", requestHash);

  // Check storage for existing audio file
  const { data } = await supabase.storage.from("audio").createSignedUrl(`${requestHash}.mp3`, 60);

  if (data) {
    console.log("Audio file found in storage", data);
    const storageRes = await fetch(data.signedUrl);
    if (storageRes.ok) return storageRes;
  }

  if (!text) {
    return new Response(JSON.stringify({ error: "Text parameter is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    console.log("ElevenLabs API call");
    const response = await elevenlabs.textToSpeech.stream(voiceId, {
      output_format: "mp3_44100_128",
      model_id: "eleven_multilingual_v2",
      text,
    });

    const stream = new ReadableStream({
      async start(controller) {
        for await (const chunk of response) {
          controller.enqueue(chunk);
        }
        controller.close();
      },
    });

    // Branch stream to Supabase Storage
    const [browserStream, storageStream] = stream.tee();

    // Upload to Supabase Storage in the background
    EdgeRuntime.waitUntil(uploadAudioToStorage(storageStream, requestHash));

    // Return the streaming response immediately
    return new Response(browserStream, {
      headers: {
        "Content-Type": "audio/mpeg",
      },
    });
  } catch (error) {
    console.log("error", { error });
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
});
```

### 代码详解

代码中有几个值得注意的地方。让我们逐步了解。

#### 处理传入请求

使用 `Deno.serve` 处理程序处理传入请求。演示中未验证请求来源，但你可以验证请求来源，或附加用户访问令牌并通过 [Supabase Auth](https://supabase.com/docs/guides/functions/auth) 进行验证。

函数从传入请求中提取 `text` 和 `voiceId` 参数。`voiceId` 参数是可选的，默认使用 ElevenLabs 的“Allison”音色 ID。

函数使用 `object-hash` 库根据请求参数生成哈希值。此哈希值用于检查 Supabase Storage 中是否已有音频文件。

```ts {1,5-8}
Deno.serve(async (req) => {
// To secure your function for production, you can for example validate the request origin,
// or append a user access token and validate it with Supabase Auth.
console.log("Request origin", req.headers.get("host"));
const url = new URL(req.url);
const params = new URLSearchParams(url.search);
const text = params.get("text");
const voiceId = params.get("voiceId") ?? "JBFqnCBsd6RMkjVDRZzb";

const requestHash = hash.MD5({ text, voiceId });
console.log("Request hash", requestHash);

// ...
})
```

#### 检查 Supabase Storage 中是否已有音频文件

Supabase Storage 内置[智能 CDN](https://supabase.com/docs/guides/storage/cdn/smart-cdn)，可轻松缓存和提供文件。

此处，函数会检查 Supabase Storage 中是否已有音频文件。如果文件存在，函数将从 Supabase Storage 返回该文件。

```ts {4,9}
const { data } = await supabase
  .storage
  .from("audio")
  .createSignedUrl(`${requestHash}.mp3`, 60);

if (data) {
  console.log("Audio file found in storage", data);
  const storageRes = await fetch(data.signedUrl);
  if (storageRes.ok) return storageRes;
}
```

#### 将语音生成为流并分为两个分支

函数使用 ElevenLabs API 的流式传输功能生成音频流。其优势在于，即使文本较长，也能立即开始向用户流式传输音频，并在后台将该流上传至 Supabase Storage。

这能提供尽可能好的用户体验，让大段文本也感觉响应迅速。关键在第 17 行：`stream.tee()` 方法将可读流分为两个分支，一个用于浏览器，另一个用于 Supabase Storage。

```ts {1,17,20,22-27}
try {
  const response = await elevenlabs.textToSpeech.stream(voiceId, {
    output_format: "mp3_44100_128",
    model_id: "eleven_multilingual_v2",
    text,
  });

  const stream = new ReadableStream({
    async start(controller) {
      for await (const chunk of response) {
        controller.enqueue(chunk);
      }
      controller.close();
    },
  });

  // Branch stream to Supabase Storage
  const [browserStream, storageStream] = stream.tee();

  // Upload to Supabase Storage in the background
  EdgeRuntime.waitUntil(uploadAudioToStorage(storageStream, requestHash));

  // Return the streaming response immediately
  return new Response(browserStream, {
    headers: {
      "Content-Type": "audio/mpeg",
    },
  });
} catch (error) {
  console.log("error", { error });
  return new Response(JSON.stringify({ error: error.message }), {
    status: 500,
    headers: { "Content-Type": "application/json" },
  });
}
```

#### 在后台将音频流上传至 Supabase Storage

上一步第 20 行的 `EdgeRuntime.waitUntil` 方法使用 `uploadAudioToStorage` 函数，在后台将音频流上传至 Supabase Storage。这样函数可立即向浏览器返回流式响应，同时将音频上传到 Supabase Storage。

存储对象创建后，下次用户使用相同参数发出请求时，函数将从 Supabase Storage CDN 返回音频文件。

```ts {2,8-10}
// Upload audio to Supabase Storage in a background task
async function uploadAudioToStorage(
  stream: ReadableStream,
  requestHash: string,
) {
  const { data, error } = await supabase.storage
    .from("audio")
    .upload(`${requestHash}.mp3`, stream, {
      contentType: "audio/mp3",
    });

  console.log("Storage upload result", { data, error });
}
```

## 本地运行

要在本地运行函数，请执行以下命令：

```bash
supabase start
```

本地 Supabase 环境启动并运行后，执行以下命令启动函数并查看日志：

```bash
supabase functions serve
```

### 试用

访问 `http://127.0.0.1:54321/functions/v1/text-to-speech?text=hello%20world`，体验函数效果。

随后访问 `http://127.0.0.1:54323/project/default/storage/buckets/audio`，查看本地 Supabase Storage 存储桶中的音频文件。

## 部署到 Supabase

如果尚未创建 Supabase 账户，请前往 [database.new](https://database.new) 创建，并将本地项目关联到 Supabase 账户：

```bash
supabase link
```

完成后，运行以下命令部署函数：

```bash
supabase functions deploy
```

### 设置函数密钥

现在已在本地设置所有密钥，可以运行以下命令在 Supabase 项目中设置密钥：

```bash
supabase secrets set --env-file supabase/functions/.env
```

## 测试函数

该函数的设计方式使其可直接作为 `<audio>` 元素的源使用。

```html
<audio
  src="https://${SUPABASE_PROJECT_REF}.supabase.co/functions/v1/text-to-speech?text=Hello%2C%20world!&voiceId=JBFqnCBsd6RMkjVDRZzb"
  controls
/>
```

## 后续步骤

#### [TTS 流式传输](/docs/zh/eleven-api/guides/how-to/text-to-speech/streaming)

无需 Supabase 即可渐进式流式传输音频，适合作为更简单的起点。

#### [API 参考文档](/docs/zh/api-reference/text-to-speech/stream)

完整的 TTS 流式传输 API 参考文档和参数。