首页
/ 基于 Supabase Edge Functions 与 ElevenLabs 的文本转语音:流式输出、Storage 存储与缓存实践

基于 Supabase Edge Functions 与 ElevenLabs 的文本转语音:流式输出、Storage 存储与缓存实践

2026-09-06 18:12:59作者:邬祺芯Juliet

在 Supabase Edge Functions 中调用 ElevenLabs API 生成语音,并以**流式(Streaming)**方式直接返回给浏览器,同时将生成的音频落盘到 Supabase Storage、结合对象级缓存避免重复调用计费——这是构建低成本、低延迟 AI 语音接口的典型模式。本文以仓库中的 elevenlabs-text-to-speech 示例 为蓝本,完整讲解从本地配置、Storage Bucket 声明、Edge Runtime 后台任务策略,到函数部署、密钥注入与前端 <audio> 集成的全链路,并逐行剖析 index.ts 的核心实现,帮你理解其中"流式响应 + 后台落盘 + 命中缓存"三项关键技术点。

整体思路:函数如何做到"既流式返回又缓存落地"

该函数对外暴露一个 HTTP 端点,设计上可以被 <audio> 元素当作音频源直接使用。其单次请求的处理流程如下(对应 index.ts 的实现顺序):

  1. 解析 URL 参数 text(必填)与 voiceId(可选,带默认值);
  2. 用 MD5 对 textvoiceId 的组合生成 requestHash,作为缓存键;
  3. 先在 Storage 的 audio bucket 中查找是否存在 ${requestHash}.mp3,若存在则直接通过签名 URL 取回文件并返回——这就是请求级缓存,同一段文本不会重复调用 ElevenLabs 计费;
  4. 若未命中,则调用 ElevenLabs 的流式 TTS 接口,将返回的音频流通过 ReadableStream 透传,借助 stream.tee() 把一路分给 HTTP 响应、另一路交给 EdgeRuntime.waitUntil 注册的后台任务写入 Storage;
  5. 浏览器收到 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.tomlfunctions/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 示例sentryfiedtelegram-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 生成并把流式音频返回给浏览器(音频数据流);
  • 本地会同时生成 audio bucket 中的对象,随后可打开 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 负责把 reqctx(内含 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/mpeg Content-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 两种鉴权模式可加深理解。

常见坑与调优建议

  1. 本地改了代码不生效per_worker 策略关闭了热重载,改完必须重启 supabase functions serve,否则调试时会误以为修改无效;
  2. 私有桶与签名 URLpublic = false 的桶无法直接外链,本函数每次命中缓存都用 createSignedUrl 生成临时 URL 再代理,过期时间 60 秒在量级上可覆盖单次播放,无需调大暴露面;
  3. 缓存与成本:MD5 缓存键是防重复计费的关键。若业务允许不同请求共享相同文本(例如常见 FAQ 播报),可考虑把缓存查询从"整文本匹配"改为"规范化后匹配"以提升命中率;
  4. 鉴权与直连的矛盾<audio> 无法附加自定义 Header,若你的端点启用了 verify_jwt = true,前端直连时需要考虑把 JWT 放入 query(牺牲安全性)或改用前端先取签名 URL 再播放的两段式方案;
  5. 模型与格式对齐:ElevenLabs 输出格式、Storage 桶 allowed_mime_types、响应 Content-Type 三者必须保持同一种音频类型,否则可能出现播放器拒绝渲染或上传被拒。

小结

本示例演示了一条完整的 AI 语音生产链路:Edge Function 作为鉴权网关与流式代理、ElevenLabs 提供多语言流式 TTS、ReadableStream.tee() 实现"边响应边落盘"、MD5 缓存键 + 私有 Storage 桶构成请求级缓存。你可以在此基础上替换音色列表、接入用户上传的文本、或将缓存键扩展为带用户维度的复合键,快速演进成自己的 TTS 服务。所有源码与配置均可直接在 elevenlabs-text-to-speech 目录 下查看与运行。

登录后查看全文
热门项目推荐
相关项目推荐