我们用 Next.js 构建了全栈 AI 音乐代理——我们学到了什么
抱歉,我需要您提供要翻译的具体文本内容(除了已经给出的源链接),才能为您完成翻译。请把文章的正文粘贴在这里,我会按照要求将其翻译成简体中文并保留原有的格式。
技术栈
| 组件 | 技术 |
|---|---|
| 框架 | Next.js 16 (App Router) |
| 认证 | Clerk |
| 支付 | Stripe |
| 音频 | Web Audio API + WaveSurfer.js |
| 人工智能 | 自定义代理,编排多个音乐 AI 提供商 |
| 国际化 | next-intl (32 种语言) |
| 状态管理 | Zustand + TanStack Query |
| 用户界面 | Radix primitives + Tailwind |
| 托管 | Vercel + 与 S3 兼容的对象存储 |
Lesson 1: Streaming AI Responses Requires Rethinking Your Data Flow
当用户说 “帮我做一段带爵士钢琴的 lo‑fi 节拍” 时,AI 代理并不仅仅返回文本——它会生成一首歌曲、创建封面艺术、提取元数据,并在单个对话回合中将进度更新流式传回 UI。
最天真的做法是等整个响应完成后再渲染。但音乐生成需要 30–120 秒。你 必须 使用流式传输。
What we learned
- Server‑Sent Events (SSE) over
fetch– 而不是 WebSockets。对于对话式 AI 界面,SSE 更简单,并且能够完美配合 Vercel 的无服务器模型。WebSockets 则需要持久连接和额外的基础设施层。
// Simplified streaming pattern
const response = await fetch('/api/agent', {
method: 'POST',
body: JSON.stringify({ message: userInput }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE events: text deltas, resource creation, progress updates
processStreamEvents(chunk);
}
-
State management during a stream – 当代理在流式过程中创建新的音频资源时,你必须:
- 更新聊天消息(追加文本)
- 将新资源添加到资源面板
- 为新音频触发波形渲染
- 更新信用余额
所有这些都需要平滑进行,避免因重新渲染导致音频播放出现卡顿。
What we’d do differently: 从第一天起就围绕流式传输设计状态管理。我们最初使用简单的 useState,后来不得不重构为 Zustand stores + refs,以避免在活跃流期间出现级联重新渲染。
Source: …
第 2 课:浏览器音频处理比你想象的更难
工作室包含一个实时母带处理链——均衡、压缩、立体声宽度、限制器——全部通过 Web Audio API 在浏览器中运行。用户可以调节母带设置并实时听到变化,然后导出已母带处理的 MP3。
实时渲染 vs. 离线渲染
目标: 实时播放和离线渲染必须产生 完全相同 的输出。
// The mastering pipeline (simplified)
async function renderMasteredBuffer(
audioUrl: string,
settings: MasteringSettings
): Promise {
const offlineCtx = new OfflineAudioContext(
2, // stereo
sampleRate * duration,
sampleRate
);
// Build the same effect chain used in real‑time playback
const source = offlineCtx.createBufferSource();
const eq = createParametricEQ(offlineCtx, settings.eq);
const compressor = createCompressor(offlineCtx, settings.compression);
const limiter = createLimiter(offlineCtx, settings.limiter);
source.connect(eq).connect(compressor).connect(limiter).connect(offlineCtx.destination);
source.start(0);
return offlineCtx.startRendering();
}
坑点: OfflineAudioContext 与普通的 AudioContext 在滤波器频率或参数斜坡不完全相同的情况下会产生细微差别。我们将所有共享常量提取到同一个 TypeScript 文件中,以保证位级一致性。
在浏览器中进行 MP3 编码
我们使用 lamejs(一个 JavaScript LAME 移植版)在客户端将 AudioBuffer 编码为 MP3,避免往返服务器。然而,lamejs 对 CPU 的消耗很大——对一首 3 分钟的歌曲进行编码可能会阻塞主线程 2–3 秒。
解决方案: 分块处理并让出事件循环。
async function encodeToMp3(audioBuffer: AudioBuffer): Promise {
const mp3encoder = new lamejs.Mp3Encoder(2, audioBuffer.sampleRate, 192);
const chunks: Int8Array[] = [];
const blockSize = 1152;
for (let i = 0; i 0) chunks.push(mp3buf);
// Yield to prevent UI freeze
if (i % (blockSize * 100) === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
const end = mp3encoder.flush();
if (end.length > 0) chunks.push(end);
return new Blob(chunks, { type: 'audio/mp3' });
}
第3课:Vercel 文件上传的隐藏限制
Vercel 无服务器函数对请求体大小设有 4.5 MB 的限制。听起来还好,直到你意识到单个已完成母带处理的音频文件轻易就会达到 5–10 MB。
我们最初的方案是 client → Next.js API route → object storage。对于任何真实的音频文件,这个方案立刻就会失效。
解决方案:使用预签名 URL 进行客户端直接上传到存储
1. Client requests a signed upload URL from our API (tiny JSON payload)
2. Client uploads the file dire
(其余流程照常进行:客户端向存储端点发送 PUT 请求上传文件,然后通知我们的后端上传已完成。)
Source: …
第 3 课 – 绕过 Vercel 的请求体大小限制进行大文件上传
当需要上传大于 Vercel 4.5 MB 请求限制的文件时,最简洁的方案是使用 直接上传至对象存储 的方式,通过签名 URL 完成。
- 客户端向 API 路由请求签名的上传 URL。
- 客户端将文件直接上传 到存储服务(S3、Cloudflare R2 等)。
- 客户端将生成的公开 URL 返回给 API(仅是一个很小的 JSON 负载)。
- API 将文件元数据写入数据库。
所有步骤的请求体均远低于 4.5 MB 限制;大文件的传输完全绕过 Vercel。
// Upload flow that bypasses Vercel's body limit
export async function uploadFileToStorageFromClient({
file,
filename,
key,
}: {
file: Blob;
filename: string;
key: string;
}): Promise {
// Step 1: Get signed URL (tiny request)
const tokenResp = await fetch('/api/upload/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, filename, contentType: file.type }),
});
const { uploadUrl, publicUrl } = await tokenResp.json();
// Step 2: Upload directly to object storage (no Vercel in the middle)
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
return { url: publicUrl };
}
这种模式对于在 Vercel 上运行的任何媒体密集型应用都是必不可少的。
第4课 – 大规模 i18n 是产品决策,而非技术决策
Gliss 支持 32 种语言(而不是 3 种或 5 种)。以下是 i18n 设置:
// routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: SUPPORTED_LOCALE_CODES, // 32 locales
defaultLocale: 'en',
localePrefix: 'as-needed', // No /en prefix for English
});
localePrefix: 'as-needed' 消除了约 790 毫秒的从 / → /en 的重定向,从而在 Lighthouse 中取得优势。
实践经验
- 使用 AI 完成初始翻译,随后请母语者审校。纯 AI 翻译会在音乐术语上出现尴尬错误。
- 保留行业术语的英文(例如 “mastering”、 “stems”、 “BPM”、 “MIDI”)。全球音乐人都使用这些词汇。
- RTL 语言(阿拉伯语、希伯来语、乌尔都语、波斯语)需要布局测试,不仅仅是翻译。Flex 布局可能会出错,需要彻底测试。
- 不要动态翻译。在构建时加载所有翻译。
next-intl的服务器组件可以避免不必要地向客户端发送翻译包。
Lesson 5 – 内容安全策略将破坏你所热爱的所有东西
添加正确的 CSP 头部不可避免地会开启“一打就中”的一天。每个外部脚本、字体、分析像素和身份验证小部件都需要明确的权限:
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://your-auth-provider.com https://*.yourdomain.com",
"connect-src 'self' https://*.yourdomain.com https: blob: data: wss:",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"media-src 'self' https: blob: data:",
"worker-src 'self' blob:",
].join('; ')
media-src 中的 blob: 和 data: 条目对音频应用至关重要——Web Audio API 会为播放创建 blob URL,OfflineAudioContext 会渲染为 data URI。
无论如何都要这么做。 对于处理支付和用户数据的生产应用,CSP 是不可协商的。
第6课 – 使用 Next.js 优化包大小
我们的初始包包含了完整的 react‑icons,体积非常庞大。启用 Next.js 的 optimizePackageImports 为我们带来了显著的收益:
experimental: {
optimizePackageImports: [
'react-icons/si',
'react-icons/fa6',
'react-icons/md',
'react-icons/lu',
'lucide-react',
'@clerk/nextjs',
],
},
这告诉 Next.js 更加积极地对这些包进行 tree‑shake。仅
react-icons就为包体积削减了约 200 KB。
其他优化
inlineCss: true– 消除单独的 CSS 请求,缩短首次渲染时间。- 使用
next/dynamic对体积较大的查看器(MIDI 查看器、波形渲染器)进行懒加载。
我们会做的不同之处
- 从流式架构开始。将流式功能套入请求‑响应的思维模型会非常痛苦。
- 从第一天起使用兼容 S3 的直接上传。不要把二进制文件通过 API 层转发。
- 在第一天就设置 CSP。事后再添加会导致需要调试已经嵌入的所有第三方集成。
- 尽早投入 i18n 基础设施。当流水线自动化时,添加第 32 种语言很容易;而在代码中到处硬编码字符串后再添加第 2 种语言则是噩梦。
- 先使用
OfflineAudioContext构建音频管线,然后迁移到实时。离线渲染做好了,就能保证实时版本的正确性。
试一试
如果你想看到所有这些实际效果,请查看 Gliss。你可以从文字描述生成歌曲,在浏览器中进行母带处理并导出——首次创作无需账号。
音乐 AI 领域发展极其迅速。如果你在浏览器中构建任何音频相关的项目,我们希望这些经验教训能为你节省我们所花的调试时间。
在浏览器中进行音频开发时,你遇到的最困难的技术挑战是什么?欢迎在评论中分享你的经历。