基于 Supabase Edge Functions 与 ElevenLabs 的文本转语音:流式输出、Storage 存储与缓存实践
在 Supabase Edge Functions 中调用 ElevenLabs API 生成语音,并以**流式(Streaming)**方式直接返回给浏览器,同时将生成的音频落盘到 Supabase Storage、结合对象级缓存避免重复调用计费——这是构建低成本、低延迟 AI 语音接口的典型模式。本文以仓库中的 elevenlabs-text-to-speech 示例 为蓝本,完整讲解从本地配置、Storage Bucket 声明、Edge Runtime 后台任务策略,到函数部署、密钥注入与前端 <audio> 集成的全链路,并逐行剖析 index.ts 的核心实现,帮你理解其中"流式响应 + 后台落盘 + 命中缓存"三项关键技术点。
整体思路:函数如何做到"既流式返回又缓存落地"
该函数对外暴露一个 HTTP 端点,设计上可以被 <audio> 元素当作音频源直接使用。其单次请求的处理流程如下(对应 index.ts 的实现顺序):
- 解析 URL 参数
text(必填)与voiceId(可选,带默认值); - 用 MD5 对
text与voiceId的组合生成requestHash,作为缓存键; - 先在 Storage 的
audiobucket 中查找是否存在${requestHash}.mp3,若存在则直接通过签名 URL 取回文件并返回——这就是请求级缓存,同一段文本不会重复调用 ElevenLabs 计费; - 若未命中,则调用 ElevenLabs 的流式 TTS 接口,将返回的音频流通过
ReadableStream透传,借助stream.tee()把一路分给 HTTP 响应、另一路交给EdgeRuntime.waitUntil注册的后台任务写入 Storage; - 浏览器收到
audio/mpeg流即可开始播放(首字节延迟低),落盘在后台异步完成,下次请求直接命中缓存。
README 中提到的"cache responses via built-in smart CDN"指的是:一旦音频对象存在于 Supabase Storage,托管在 CDN 之后的 Storage 节点就可以提供边缘缓存能力;而函数层级的缓存命中逻辑则保证相同请求根本不会重复触发一次 TTS 生成。
前置要求
开始前需要准备以下环境:
- 一个 ElevenLabs 账号及对应的 API Key;
- 一个 Supabase 账号(可通过
database.new免费注册); - 本机安装 Supabase CLI(用于本地启动、函数部署与密钥管理);
- 本机安装 Deno runtime,并可在 IDE 中配置 Deno 语言服务以启用自动补全与类型检查。
本地搭建:初始化项目与两份关键 TOML 配置
初始化 Supabase 项目
安装好 Supabase CLI 后,在准备存放函数的工作目录执行:
supabase init
该命令会生成一个包含 config.toml 与 functions/、migrations/ 等目录的本地项目骨架。仓库中 edge-functions 示例工程的对应结构为 examples/edge-functions/supabase/config.toml,每个 Edge Function 都放在 functions/<function-name>/ 下,函数入口统一命名为 index.ts。
用 config.toml 声明音频存储桶
ElevenLabs 返回的音频是二进制文件,需要落盘到 Storage。示例推荐把 bucket 配置直接写进 config.toml,让 supabase start 在本地自动创建同名 bucket:
[storage.buckets.audio]
public = false
file_size_limit = "50MiB"
allowed_mime_types = ["audio/mp3"]
objects_path = "./audio"
各字段含义与取值要点:
public = false:桶保持私有,读取时通过函数内生成的临时签名 URL(有效期 60 秒)访问,避免音频被公开抓取;file_size_limit = "50MiB":单文件上限 50 MiB,足以容纳常规 TTS 片段,同时防止超大文件上传;allowed_mime_types = ["audio/mp3"]:只允许 mp3 类型写入,与服务端audio/mpeg输出保持一致;objects_path = "./audio":本地开发时对象实际存储在本机文件系统该目录,便于直接查看验证。
注意:运行 supabase start 时该配置会在本地创建 bucket;若想把这套 bucket 配置同步到托管(Hosted)项目,需要执行:
supabase seed buckets --linked
仓库 config.toml 中还能看到多种 bucket 声明形态的对照:[storage.buckets.my-bucket]、[storage.buckets.videos] 是纯占位空桶;[storage.buckets.images] 则展示了 public = true + objects_path 的公开桶写法,可对比理解公开/私有桶的差异。
开启 Edge Runtime 的后台任务策略
函数需要在响应返回后继续把音频上传到 Storage,这依赖 Supabase Edge Runtime 的后台任务能力(EdgeRuntime.waitUntil)。本地运行时默认情况下后台任务不被保留,因此必须在 config.toml 中声明:
[edge_runtime]
policy = "per_worker"
关键注意点(README 原话强调):当 edge_runtime.policy = "per_worker" 时,函数在本地不会因代码修改而自动热重载,每次改动后需要手动重启函数:
supabase functions serve
从仓库源码看,background-upload-storage 示例 与 sentryfied、telegram-bot 等函数同样依赖后台任务机制,它们也都要求在本地以 per_worker 策略运行——这是编写"先响应、后落盘"类函数的通用前提。
运行与联调
本地启动完整链路
先启动本地 Supabase 全家桶(Postgres、Storage、Auth、Edge Runtime 等):
supabase start
随后单独启动函数并观察日志:
supabase functions serve
函数启动后可直接在浏览器验证:
http://127.0.0.1:54321/functions/v1/elevenlabs-text-to-speech?text=hello%20world
54321是本地 API 网关端口(见 config.toml 中的[api] port = 54321);- 首次请求会触发 ElevenLabs 生成并把流式音频返回给浏览器(音频数据流);
- 本地会同时生成
audiobucket 中的对象,随后可打开 Storage 控制台确认文件已落盘:
http://127.0.0.1:54323/project/default/storage/buckets/audio
(54323 为本地 Studio 端口,可以看到新出现的 <requestHash>.mp3 文件。)
密钥准备
函数通过 Deno.env.get('ELEVENLABS_API_KEY') 读取 ElevenLabs 密钥(见 index.ts)。本地联调需要把该变量写入环境。仓库在 .env.local.example 中预留了该变量条目,可在本地项目 supabase/functions/.env 中按此格式配置后再用 --env-file 注入运行。
部署到托管项目
完成本地验证后,部署步骤如下。
关联远程项目并部署函数
supabase link
将本地项目链接到你账户下的托管 Supabase 项目(若还没有项目,先在 database.new 创建)。随后部署函数:
supabase functions deploy
注入生产密钥
确保密钥在云端与本地一致,执行:
supabase secrets set --env-file supabase/functions/.env
该命令会读取 .env 文件并把其中变量写入云端 Functions 的环境配置。
鉴权配置说明
从 config.toml 可以看到,本函数声明了:
[functions.elevenlabs-text-to-speech]
verify_jwt = true
这与源码中 withSupabase({ auth: 'user' }, ...) 的取值一致(见 index.ts):该端点要求调用方携带有效用户 JWT,属于鉴权端点。对比同目录下 elevenlabs-speech-to-text(Telegram 机器人回调,verify_jwt = false)即可理解两种模式的差异。若前端以 <audio> 直连且不想暴露鉴权头,可改用 verify_jwt = false 并配合其他防护手段,但这会脱离本示例"服务端受保护资源"的默认定位,需要自行权衡。
前端直接播放:一行 <audio> 搞定
该函数被刻意设计为"可以直接作为 <audio> 元素的 src"。部署后得到形如下面的端点 URL:
<audio
src="https://${SUPABASE_PROJECT_REF}.supabase.co/functions/v1/elevenlabs-text-to-speech?text=Hello%2C%20world!&voiceId=JBFqnCBsd6RMkjVDRZzb"
controls
/>
URL 参数说明:
| 参数 | 必填 | 说明 | 示例 |
|---|---|---|---|
text |
是 | 待合成的文本,需要 URL 编码 | Hello%2C%20world! |
voiceId |
否 | ElevenLabs 音色 ID,缺省时使用 JBFqnCBsd6RMkjVDRZzb |
JBFqnCBsd6RMkjVDRZzb |
若缺少 text,函数会返回 400 与 {"error":"Text parameter is required"}(见 index.ts)。浏览器原生支持流式播放音频,因此无需额外 JavaScript,只要请求能携带有效鉴权即可直接获得"点击即播放"的体验。
源码级拆解:流式、分流与缓存是怎么实现的
以下是 index.ts 的完整逻辑拆解,它是理解本示例真正价值(异步任务 + 缓存)的核心。
1. 依赖注入与客户端初始化
import { withSupabase } from 'npm:@supabase/server@^1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@^2'
import { ElevenLabsClient } from 'npm:elevenlabs@^1'
import * as hash from 'npm:object-hash@^3'
const client = new ElevenLabsClient({
apiKey: Deno.env.get('ELEVENLABS_API_KEY'),
})
- 通过
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'引入 Edge Runtime 内置 API 的类型定义; withSupabase负责把req、ctx(内含supabaseAdmin,即具有管理员权限的客户端)注入处理器;- ElevenLabs 官方 SDK 在 Deno 下通过 npm 兼容层导入(仓库中 deno.json 的 imports 保持为空,依赖按 npm 协议直接引用)。
2. 缓存键:先查桶,命中即返回
const requestHash = hash.MD5({ text, voiceId })
const { data } = await ctx.supabaseAdmin.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
}
- 缓存键由"文本 + 音色"共同决定:同一文本换音色会生成不同文件,互不污染;
- bucket 私有,因此不直接读对象,而是
createSignedUrl生成 60 秒有效的临时 URL,再用fetch代理返回; - 找不到对象时
data为空,走下面的生成分支——注意这里对象存在性判断发生在text参数校验之前,属于实现细节,若文件已存在则即使缺失text也会命中缓存返回音频,从缓存一致性角度看并无问题。
3. 调 ElevenLabs 流式接口并透传
const response = await client.textToSpeech.convertAsStream(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()
},
})
convertAsStream返回的是异步可迭代的分块流,配合ReadableStream边收边吐,浏览器端能尽快听到首帧;mp3_44100_128指定 44.1kHz / 128kbps 的 mp3 输出,与 Storage 桶允许的audio/mp3、响应的audio/mpegContent-Type 三者保持一致;eleven_multilingual_v2是多语言模型,支持含中文在内的多种语言合成。
4. tee() 分流:一路给浏览器,一路落 Storage
const [browserStream, storageStream] = stream.tee()
EdgeRuntime.waitUntil(uploadAudioToStorage(ctx.supabaseAdmin, storageStream, requestHash))
return new Response(browserStream, {
headers: { 'Content-Type': 'audio/mpeg' },
})
这是本示例最精妙之处:
ReadableStream.tee()把单一音频流分成两路独立的流;browserStream立刻被包装成Response返回,用户无需等待上传完成;storageStream连同requestHash交给EdgeRuntime.waitUntil注册的后台任务——即使 HTTP 响应已经结束,运行时也会等待该 Promise 完成,保证文件真正落盘到audio/${requestHash}.mp3。
后台上传函数本身很简单:
const { data, error } = await supabaseAdmin.storage
.from('audio')
.upload(`${requestHash}.mp3`, stream, { contentType: 'audio/mp3' })
上传结果通过 console.log('Storage upload result', { data, error }) 输出,便于在 supabase functions serve 的日志里排查。
5. 异常处理
} catch (error) {
console.log('error', { error })
return Response.json({ error: error.message }, { status: 500 })
}
ElevenLabs 调用失败、鉴权失败等异常统一捕获并返回 500 JSON,避免向客户端泄漏堆栈。
演进参照:同仓库的其他相似函数
把本函数放回 functions 目录 中对照阅读,可以更快理解模式差异:
- background-upload-storage:使用 OpenAI TTS 的同构示例,后台任务改用
globalThis.addEventListener+dispatchEvent的事件驱动写法,适合需要更细粒度控制后台任务时参考; - elevenlabs-speech-to-text:ElevenLabs 反向能力(语音转文本 + Telegram 机器人)示例,同样要求本地
per_worker策略,可佐证该配置是后台任务类函数在本地联调时的通用前提; - og-image-with-storage-cdn:另一条"生成产物 + Storage 落盘"的流水线(用
auth: 'secret'鉴权),对比user/secret两种鉴权模式可加深理解。
常见坑与调优建议
- 本地改了代码不生效:
per_worker策略关闭了热重载,改完必须重启supabase functions serve,否则调试时会误以为修改无效; - 私有桶与签名 URL:
public = false的桶无法直接外链,本函数每次命中缓存都用createSignedUrl生成临时 URL 再代理,过期时间 60 秒在量级上可覆盖单次播放,无需调大暴露面; - 缓存与成本:MD5 缓存键是防重复计费的关键。若业务允许不同请求共享相同文本(例如常见 FAQ 播报),可考虑把缓存查询从"整文本匹配"改为"规范化后匹配"以提升命中率;
- 鉴权与直连的矛盾:
<audio>无法附加自定义 Header,若你的端点启用了verify_jwt = true,前端直连时需要考虑把 JWT 放入 query(牺牲安全性)或改用前端先取签名 URL 再播放的两段式方案; - 模型与格式对齐:ElevenLabs 输出格式、Storage 桶
allowed_mime_types、响应Content-Type三者必须保持同一种音频类型,否则可能出现播放器拒绝渲染或上传被拒。
小结
本示例演示了一条完整的 AI 语音生产链路:Edge Function 作为鉴权网关与流式代理、ElevenLabs 提供多语言流式 TTS、ReadableStream.tee() 实现"边响应边落盘"、MD5 缓存键 + 私有 Storage 桶构成请求级缓存。你可以在此基础上替换音色列表、接入用户上传的文本、或将缓存键扩展为带用户维度的复合键,快速演进成自己的 TTS 服务。所有源码与配置均可直接在 elevenlabs-text-to-speech 目录 下查看与运行。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00