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

通过 Supabase Edge Functions 生成和流式传输语音。在 Supabase Storage 中存储语音,并通过内置 CDN 缓存响应。

操作指南 · 假设你已完成 ElevenAPI 快速入门,并拥有 Supabase 账户。

简介

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

要求

设置

在本地创建 Supabase 项目

安装 Supabase CLI 后,运行以下命令在本地创建新的 Supabase 项目:

supabase init

配置存储桶

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

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

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

为 Supabase Edge Functions 配置后台任务

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

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

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

为语音生成创建 Supabase Edge Function

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

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
# Find / create an API key at https://elevenlabs.io/app/settings/api-keys
ELEVENLABS_API_KEY=your_api_key

依赖项

项目使用以下依赖项:

由于 Supabase Edge Function 使用 Deno runtime,无需安装依赖项,而是可通过 npm: 前缀导入它们。

编写 Supabase Edge Function

在新创建的 supabase/functions/text-to-speech/index.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" },
});
}
});

代码详解

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

1

处理传入请求

使用 Deno.serve 处理程序处理传入请求。演示中未验证请求来源,但你可以验证请求来源,或附加用户访问令牌并通过 Supabase Auth 进行验证。

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

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

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);
// ...
})
2

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

Supabase Storage 内置智能 CDN,可轻松缓存和提供文件。

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

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;
}
3

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

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

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

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" },
});
}
4

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

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

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

// 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 });
}

本地运行

要在本地运行函数,请执行以下命令:

supabase start

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

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 创建,并将本地项目关联到 Supabase 账户:

supabase link

完成后,运行以下命令部署函数:

supabase functions deploy

设置函数密钥

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

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

测试函数

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

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

后续步骤