我们用 Next.js 构建了全栈 AI 音乐代理——我们学到了什么

发布: (2026年2月15日 GMT+8 06:59)
12 分钟阅读
原文: Dev.to

抱歉,我需要您提供要翻译的具体文本内容(除了已经给出的源链接),才能为您完成翻译。请把文章的正文粘贴在这里,我会按照要求将其翻译成简体中文并保留原有的格式。

技术栈

组件技术
框架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 – 当代理在流式过程中创建新的音频资源时,你必须:

    1. 更新聊天消息(追加文本)
    2. 将新资源添加到资源面板
    3. 为新音频触发波形渲染
    4. 更新信用余额

    所有这些都需要平滑进行,避免因重新渲染导致音频播放出现卡顿。

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 完成。

  1. 客户端向 API 路由请求签名的上传 URL
  2. 客户端将文件直接上传 到存储服务(S3、Cloudflare R2 等)。
  3. 客户端将生成的公开 URL 返回给 API(仅是一个很小的 JSON 负载)。
  4. 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 领域发展极其迅速。如果你在浏览器中构建任何音频相关的项目,我们希望这些经验教训能为你节省我们所花的调试时间。

在浏览器中进行音频开发时,你遇到的最困难的技术挑战是什么?欢迎在评论中分享你的经历。

0 浏览
Back to Blog

相关文章

阅读更多 »

Vonage 开发者讨论

Dev Discussion 我们希望这里成为一个可以休息并讨论软件开发人性化方面的空间。第一话题:音乐 🎶 说到音乐……