最近把 Hacker News 中文播客 改成了双人对话的形式,由于目前的语音合成模型还不能很好地处理双人对话,所以需要把每个人的音频文件拼接起来。
由于项目之前运行在 Cloudflare Workflow 的 Worker Runtime, 众所周知 Worker Runtime 缺少不少 Node.JS 特性,无法调用 C++ 扩展。而且 Cloudflare Container 还没有正式上线,所以只能使用 Browser Rendering 来实现。
合并音频文件一般都使用 FFMpeg 来做,现在 FFMpeg 也可以通过 WASM 在浏览器内运行了。所以大体的技术方案是:
- 使用 Worker Binding 来启动浏览器实例
- 浏览器打开音频合并页面,合成语音文件,返回 Blob
- 将 Blob 返回给 Worker 后存入 R2
整体代码量不多,但是由于 Browser Rendering 只能远程调用,调试比较麻烦。
最终实现代码:
浏览器内音频合并代码
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio</title>
</head>
<body>
<script>
const concatAudioFilesOnBrowser = async (audioFiles) => {
const script = document.createElement('script')
script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js'
document.head.appendChild(script)
await new Promise((resolve) => (script.onload = resolve))
const { createFFmpeg, fetchFile } = FFmpeg
const ffmpeg = createFFmpeg({ log: true })
await ffmpeg.load()
// Download and write each file to FFmpeg's virtual file system
for (const [index, audioFile] of audioFiles.entries()) {
const audioData = await fetchFile(audioFile)
ffmpeg.FS('writeFile', `input${index}.mp3`, audioData)
}
// Create a file list for ffmpeg concat
const fileList = audioFiles.map((_, i) => `file 'input${i}.mp3'`).join('\n')
ffmpeg.FS('writeFile', 'filelist.txt', fileList)
// Execute FFmpeg command to concatenate files
await ffmpeg.run(
'-f',
'concat',
'-safe',
'0',
'-i',
'filelist.txt',
'-c:a',
'libmp3lame',
'-q:a',
'5',
'output.mp3',
)
// Read the output file
const data = ffmpeg.FS('readFile', 'output.mp3')
// Create a downloadable link
const blob = new Blob([data.buffer], { type: 'audio/mp3' })
// Clean up
audioFiles.forEach((_, i) => {
ffmpeg.FS('unlink', `input${i}.mp3`)
})
ffmpeg.FS('unlink', 'filelist.txt')
ffmpeg.FS('unlink', 'output.mp3')
return blob
}
</script>
</body>
</html>
Worker 调用代码
export async function concatAudioFiles(audioFiles: string[], BROWSER: Fetcher, { workerUrl }: { workerUrl: string }) {
const browser = await puppeteer.launch(BROWSER)
const page = await browser.newPage()
await page.goto(`${workerUrl}/audio`)
console.info('start concat audio files', audioFiles)
const fileUrl = await page.evaluate(async (audioFiles) => {
// 此处 JS 运行在浏览器中
// @ts-expect-error 浏览器内的对象
const blob = await concatAudioFilesOnBrowser(audioFiles)
const result = new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
return await result
}, audioFiles) as string
console.info('concat audio files result', fileUrl.substring(0, 100))
await browser.close()
const response = await fetch(fileUrl)
return await response.blob()
}
const audio = await concatAudioFiles(audioFiles, env.BROWSER, { workerUrl: env.HACKER_NEWS_WORKER_URL })
return new Response(audio)
上面的代码基本是 Cursor 写的,最终效果可以去 Hacker News 代码仓库 看。