前端接入 AI 语音:从 Base64 PCM 到浏览器可播放的 WAV

更新日期: 2026-09-20 阅读: 16 标签: audio

给网页增加“朗读”按钮看似简单,真正接入文本转语音服务时,前端通常会遇到三个问题:API Key 不能暴露在浏览器里,接口返回的原始 PCM 不能直接交给 <audio> 播放,生成后的对象 URL 还需要及时释放。

本文以 FlowSpeech 的文本转语音接口为例,完整走一遍“浏览器提交文本 -> 服务端代理请求 -> PCM 封装为 WAV -> 页面播放与下载”的实现。示例采用 Next.js Route Handler 和原生浏览器 API,不依赖额外音频库。


1. 先确定前后端边界

不要在浏览器代码中直接写 API Key。即使它被放进前端环境变量,只要进入浏览器 bundle,用户仍能通过开发者工具查看。

更稳妥的结构是:

  1. 浏览器只向站内 /api/speech 提交文本和音色。
  2. 服务端读取环境变量中的密钥,再请求语音接口。
  3. 服务端只把音频数据和必要的格式信息返回给浏览器。

这样可以把认证信息留在服务端,也方便统一做长度限制、频率限制和错误处理。


2. 编写服务端代理

在 Next.js App Router 项目中创建 app/api/speech/route.ts:

import { NextResponse } from 'next/server';

const MAX_TEXT_LENGTH = 2000;

export async function POST(request: Request) {
const apiKey = process.env.FLOWSPEECH_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'Speech service is not configured' },
{ status: 500 }
);
}

const body = await request.json();
const text = typeof body.text === 'string' ? body.text.trim() : '';
const voiceName =
typeof body.voiceName === 'string' ? body.voiceName : 'Kore';

if (!text || text.length > MAX_TEXT_LENGTH) {
return NextResponse.json(
{ error: `Text must contain 1-${MAX_TEXT_LENGTH} characters` },
{ status: 400 }
);
}

const response = await fetch(
'https://flowspeech.io/api/ai/text-to-speech',
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text,
originalText: text,
speakers: [{ voiceName }],
}),
cache: 'no-store',
}
);

const result = await response.json();
if (!response.ok || result.code !== 0 || !result.data?.audioBase64) {
return NextResponse.json(
{ error: result.message || 'Speech generation failed' },
{ status: response.status || 502 }
);
}

return NextResponse.json({
audioBase64: result.data.audioBase64,
mimeType: result.data.mimeType,
sampleRate: result.data.sampleRate,
numChannels: result.data.numChannels,
bitsPerSample: result.data.bitsPerSample,
});
}

环境变量放在 .env.local 中,并确保该文件不会提交到 Git:

bash FLOWSPEECH_API_KEY=your_api_key

这里没有把第三方接口的完整响应原样透传,而是只保留前端真正需要的字段。这样可以减少前后端耦合,也避免无意中暴露内部信息。


3. 为什么 PCM 需要 WAV 文件头

接口返回的 audioBase64 是音频字节经过 Base64 编码后的字符串。mimeType 可能类似 audio/L16;rate=24000,同时响应还会给出采样率、声道数和位深。

L16/PCM 主要描述一串连续采样值,本身通常没有浏览器播放器所需的文件头。WAV 则是在 PCM 数据前增加 RIFF、采样率、声道数、位深和数据长度等元信息。给 PCM 补上 44 字节的标准 WAV 文件头后,浏览器就能把它当作普通音频文件处理。

下面的函数完成 Base64 解码和 WAV 封装:

type AudioFormat = {
sampleRate: number;
numChannels: number;
bitsPerSample: number;
};

function writeAscii(view: DataView, offset: number, value: string) {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i));
}
}

function base64PcmToWav(
audioBase64: string,
format: AudioFormat
): Blob {
const binary = atob(audioBase64);
const pcm = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const buffer = new ArrayBuffer(44 + pcm.byteLength);
const view = new DataView(buffer);
const bytesPerSample = format.bitsPerSample / 8;
const blockAlign = format.numChannels * bytesPerSample;

writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + pcm.byteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, format.numChannels, true);
view.setUint32(24, format.sampleRate, true);
view.setUint32(28, format.sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, format.bitsPerSample, true);
writeAscii(view, 36, 'data');
view.setUint32(40, pcm.byteLength, true);
new Uint8Array(buffer, 44).set(pcm);

return new Blob([buffer], { type: 'audio/wav' });
}

WAV 文件头中的整数采用小端序,因此 setUint16 和 setUint32 的最后一个参数要传 true。音频数据本身不应被字符串二次编码,直接复制字节即可。


4. 在 React 组件中生成和播放

接下来把代理接口和转换函数接到组件状态中:

import { useEffect, useState } from 'react';

export function SpeechDemo() {
const [text, setText] = useState('欢迎体验网页语音播放。');
const [audioUrl, setAudioUrl] = useState<string>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');

useEffect(() => {
return () => {
if (audioUrl) URL.revokeObjectURL(audioUrl);
};
}, [audioUrl]);

async function generate() {
setLoading(true);
setError('');

try {
const response = await fetch('/api/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voiceName: 'Kore' }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '生成失败');

const wav = base64PcmToWav(data.audioBase64, {
sampleRate: data.sampleRate || 24000,
numChannels: data.numChannels || 1,
bitsPerSample: data.bitsPerSample || 16,
});

const nextUrl = URL.createObjectURL(wav);
setAudioUrl((previousUrl) => {
if (previousUrl) URL.revokeObjectURL(previousUrl);
return nextUrl;
});
} catch (cause) {
setError(cause instanceof Error ? cause.message : '生成失败');
} finally {
setLoading(false);
}
}

return (
<section>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={2000}
/>
<button onClick={generate} disabled={loading || !text.trim()}>
{loading ? '生成中...' : '生成语音'}
</button>
{error && <p role="alert">{error}</p>}
{audioUrl && (
<>
<audio controls src={audioUrl} />
<a href={audioUrl} download="speech.wav">下载 WAV</a>
</>
)}
</section>
);
}

URL.createObjectURL() 会把 Blob 映射为当前页面可访问的临时 URL。每次替换音频以及组件卸载时,都应调用 URL.revokeObjectURL(),否则用户连续生成多段语音后,旧 Blob 仍会占用内存。


5. 上线前还要补齐哪些保护

示例解决了核心链路,但生产环境至少还应加入以下措施:

  • 在服务端限制文本长度,并根据用户或 IP 做频率限制。
  • 给上游请求设置超时,避免页面长时间停留在加载状态。
  • 不记录完整正文、API Key 或音频 Base64,日志只保留请求编号和错误类型。
  • 区分 4xx 与 5xx:参数错误直接提示用户,上游故障则提供重试入口。
  • 播放前检查 mimeType、采样率和位深,不要假设所有语音服务都返回同一种格式。
  • 长文本应分段生成,并在句子边界切分,避免截断语义。


总结

网页接入文本转语音的关键不只是“调通接口”,而是把认证、音频格式和浏览器资源生命周期一起处理好。把密钥放在服务端代理中,根据响应元数据为 PCM 添加 WAV 文件头,再正确释放对象 URL,就能得到一条清晰、可维护的播放与下载链路。

当后续需要支持多角色对话时,可以继续扩展请求中的 speakers 数组,为不同说话人分配音色;前端的 WAV 转换和播放部分不需要改变。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

相关推荐

小程序wx.createInnerAudioContext()获取不到时长问题

开发小程序中需要用到音频播放功能。但在初始化时,使用InnerAudioContext.duration获取不到音频的时长。使用innerAudioContext.onPlay()监听播放获取时长,此方法用于播放音频后获取;使用innerAudioContext.onCanplay()监听音频进入可以播放状态,此方法用于初始化时获取

解决vue动态绑定audio/video的src不能播放

方法一: 用$refs动态设置src,html代码如下(给video绑定个ref值);方法二:src地址已切换或已重新赋值,重新加载audio/video

audio标签以及audio对象

autoplay如果出现该属性,则音频在就绪后马上播放。controls如果出现该属性,则向用户显示控件,比如播放按钮。loop如果出现该属性,则每当音频结束时重新开始播放。

HTML5的video和audio

HTML5引入的<audio>和<video>元素,使用起来和<img>元素一样简单。对于支持HTML5的浏览器,不再需要使用插件(像flash)来在HTML文档中嵌入音频和视频:实际上,使用这些元素的时候要更加巧妙。由于各家浏览器制造商未能在对标准音频和视频编码支持上达成一致

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!