跳到主要内容

设计 AI 播客助手

问题

如果让你设计一个"AI 播客助手"的前端功能(如喜马拉雅场景),需要支持音频转文字、智能摘要、关键时间戳标注、语义搜索等能力,你会关注哪些技术点?

答案

AI 播客助手是一个融合音频处理语音识别(ASR)NLP 分析交互式 UI 的综合系统。核心挑战在于大音频文件的高效处理、ASR 结果与音频的时间对齐、以及 AI 生成内容(摘要/章节/关键词)的实时展示。

一、整体架构

模块职责关键技术
音频处理层音频加载、波形渲染、分片上传Web Audio API、AudioWorklet、OffscreenCanvas
播放器 UI播放控制、波形交互、时间戳跳转Canvas 波形图、Touch/Pointer 事件
转写面板展示逐字/逐句转写结果,支持跟读高亮ASR 时间戳对齐、虚拟列表
AI 摘要面板章节划分、内容摘要、关键词提取LLM 流式输出、Markdown 渲染
搜索面板语义搜索播客内容,跳转到对应时间点向量搜索、关键词高亮

二、音频处理与波形渲染

波形数据提取

lib/audio-waveform.ts
/**
* 从音频文件提取波形数据(用于渲染波形图)
* 在 Web Worker 中执行,避免阻塞主线程
*/
export async function extractWaveform(
audioBuffer: ArrayBuffer,
samplesPerPixel = 256 // 每像素采样数(控制波形精度)
): Promise<Float32Array> {
// AudioContext 解码音频(支持 mp3/aac/ogg/wav 等格式)
const audioCtx = new OfflineAudioContext(1, 1, 44100);
const decoded = await audioCtx.decodeAudioData(audioBuffer);
const channelData = decoded.getChannelData(0); // 取单声道

// 降采样:将百万级采样点压缩为千级像素点
const pixelCount = Math.ceil(channelData.length / samplesPerPixel);
const waveform = new Float32Array(pixelCount);

for (let i = 0; i < pixelCount; i++) {
const start = i * samplesPerPixel;
const end = Math.min(start + samplesPerPixel, channelData.length);
let max = 0;

// 取每段的峰值(绝对值最大值)
for (let j = start; j < end; j++) {
const abs = Math.abs(channelData[j]);
if (abs > max) max = abs;
}
waveform[i] = max;
}

return waveform;
}

Canvas 波形图组件

components/WaveformView.tsx
import { useRef, useEffect, useCallback } from 'react';

interface WaveformViewProps {
waveform: Float32Array;
currentTime: number; // 当前播放时间(秒)
duration: number; // 总时长(秒)
chapters?: Array<{ time: number; title: string }>; // AI 生成的章节
onSeek: (time: number) => void;
}

export function WaveformView({
waveform, currentTime, duration, chapters, onSeek
}: WaveformViewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const progress = duration > 0 ? currentTime / duration : 0;

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !waveform.length) return;

const ctx = canvas.getContext('2d')!;
const { width, height } = canvas;
const centerY = height / 2;
const barWidth = width / waveform.length;

ctx.clearRect(0, 0, width, height);

// 绘制波形:已播放部分用主题色,未播放部分用灰色
for (let i = 0; i < waveform.length; i++) {
const x = i * barWidth;
const barHeight = waveform[i] * centerY * 0.9;
const isPlayed = i / waveform.length <= progress;

ctx.fillStyle = isPlayed ? '#6366f1' : '#d1d5db';
ctx.fillRect(x, centerY - barHeight, Math.max(barWidth - 1, 1), barHeight * 2);
}

// 绘制章节标记
if (chapters) {
ctx.fillStyle = '#f59e0b';
for (const chapter of chapters) {
const x = (chapter.time / duration) * width;
ctx.fillRect(x - 1, 0, 2, height);
}
}
}, [waveform, progress, duration, chapters]);

// 点击跳转
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
onSeek(ratio * duration);
}, [duration, onSeek]);

return (
<canvas
ref={canvasRef}
width={800}
height={80}
className="w-full h-20 cursor-pointer"
onClick={handleClick}
/>
);
}

三、语音识别(ASR)与时间戳对齐

ASR 是播客助手的核心能力。前端需要处理大音频文件的分片上传转写结果的时间同步展示

ASR 结果数据模型

types/transcript.ts
// ASR 返回的转写结果(带时间戳)
interface TranscriptSegment {
id: string;
start: number; // 开始时间(秒)
end: number; // 结束时间(秒)
text: string; // 文本内容
speaker?: string; // 说话人识别
confidence: number; // 置信度 0-1
words?: TranscriptWord[]; // 逐字时间戳
}

interface TranscriptWord {
word: string;
start: number;
end: number;
confidence: number;
}

// AI 分析结果
interface PodcastAnalysis {
summary: string; // 全文摘要
chapters: ChapterInfo[]; // 章节划分
keywords: string[]; // 关键词
speakers: SpeakerInfo[]; // 说话人信息
}

interface ChapterInfo {
title: string;
start: number; // 章节开始时间
end: number;
summary: string; // 章节摘要
}

interface SpeakerInfo {
id: string;
name?: string; // 可能需要用户手动标注
totalDuration: number; // 总发言时长
segmentCount: number; // 发言段数
}

转写结果跟读高亮

components/TranscriptPanel.tsx
import { useRef, useEffect, useMemo, memo } from 'react';

interface TranscriptPanelProps {
segments: TranscriptSegment[];
currentTime: number;
onSeek: (time: number) => void;
}

export function TranscriptPanel({ segments, currentTime, onSeek }: TranscriptPanelProps) {
const activeRef = useRef<HTMLDivElement>(null);

// 二分查找当前时间对应的 segment
const activeIndex = useMemo(() => {
let low = 0;
let high = segments.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (segments[mid].end < currentTime) low = mid + 1;
else if (segments[mid].start > currentTime) high = mid - 1;
else return mid;
}
return -1;
}, [segments, currentTime]);

// 自动滚动到当前 segment
useEffect(() => {
activeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeIndex]);

return (
<div className="transcript-panel h-full overflow-y-auto p-4">
{segments.map((seg, i) => (
<TranscriptSegmentItem
key={seg.id}
segment={seg}
isActive={i === activeIndex}
currentTime={currentTime}
ref={i === activeIndex ? activeRef : undefined}
onSeek={onSeek}
/>
))}
</div>
);
}

// memo 化:非活跃的 segment 不重渲染
const TranscriptSegmentItem = memo(
function TranscriptSegmentItem({
segment, isActive, currentTime, onSeek,
}: {
segment: TranscriptSegment;
isActive: boolean;
currentTime: number;
onSeek: (time: number) => void;
ref?: React.Ref<HTMLDivElement>;
}) {
return (
<div
className={`py-2 px-3 rounded cursor-pointer transition-colors ${
isActive ? 'bg-indigo-50 dark:bg-indigo-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => onSeek(segment.start)}
>
{/* 时间标签 */}
<span className="text-xs text-gray-400 mr-2 font-mono">
{formatTime(segment.start)}
</span>

{/* 说话人标签 */}
{segment.speaker && (
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded mr-2">
{segment.speaker}
</span>
)}

{/* 文本内容:逐字高亮 */}
<span className="text-sm">
{segment.words ? (
segment.words.map((word, i) => (
<span
key={i}
className={
// 当前正在播放的词高亮
isActive && currentTime >= word.start && currentTime <= word.end
? 'text-indigo-600 font-semibold bg-indigo-100 dark:bg-indigo-800'
: ''
}
>
{word.word}
</span>
))
) : (
segment.text
)}
</span>
</div>
);
}
);

function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}

四、AI 分析能力

章节智能划分 + 摘要生成

hooks/usePodcastAnalysis.ts
import { useState, useCallback } from 'react';

interface AnalysisState {
status: 'idle' | 'analyzing' | 'done' | 'error';
summary: string;
chapters: ChapterInfo[];
keywords: string[];
}

export function usePodcastAnalysis(podcastId: string) {
const [state, setState] = useState<AnalysisState>({
status: 'idle',
summary: '',
chapters: [],
keywords: [],
});

const analyze = useCallback(async () => {
setState(prev => ({ ...prev, status: 'analyzing' }));

try {
// 流式获取 AI 分析结果
const response = await fetch(`/api/podcast/${podcastId}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tasks: ['summary', 'chapters', 'keywords'],
}),
});

// SSE 流式接收(摘要可能很长)
const reader = response.body!.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });

// 解析 SSE 事件
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6));

switch (data.type) {
case 'summary_chunk':
setState(prev => ({
...prev,
summary: prev.summary + data.content,
}));
break;
case 'chapters':
setState(prev => ({ ...prev, chapters: data.chapters }));
break;
case 'keywords':
setState(prev => ({ ...prev, keywords: data.keywords }));
break;
}
}
}

setState(prev => ({ ...prev, status: 'done' }));
} catch (error) {
setState(prev => ({ ...prev, status: 'error' }));
}
}, [podcastId]);

return { ...state, analyze };
}

后端 AI 处理流程

五、语义搜索与时间戳跳转

用户可以用自然语言搜索播客内容,搜索结果附带时间戳,点击即可跳转。

components/PodcastSearch.tsx
import { useState, useCallback, useRef } from 'react';

interface SearchResult {
segment: TranscriptSegment;
score: number;
highlight: string; // 高亮摘要
}

export function PodcastSearch({
podcastId, onSeek
}: {
podcastId: string;
onSeek: (time: number) => void;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const abortRef = useRef<AbortController>();

const handleSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); return; }

// 取消上一次请求
abortRef.current?.abort();
abortRef.current = new AbortController();

setIsSearching(true);
try {
const res = await fetch(`/api/podcast/${podcastId}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, topK: 10 }),
signal: abortRef.current.signal,
});
const data = await res.json();
setResults(data.results);
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error(e);
} finally {
setIsSearching(false);
}
}, [podcastId]);

// 防抖搜索
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const onInput = (value: string) => {
setQuery(value);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => handleSearch(value), 300);
};

return (
<div className="podcast-search">
<input
value={query}
onChange={e => onInput(e.target.value)}
placeholder="搜索播客内容,如「讨论了什么技术栈」"
className="w-full p-3 border rounded-lg"
/>

{isSearching && <div className="text-center py-4 text-gray-400">搜索中...</div>}

<div className="mt-2 space-y-2">
{results.map((result, i) => (
<div
key={i}
className="p-3 rounded-lg border cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => onSeek(result.segment.start)}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded">
{formatTime(result.segment.start)}
</span>
{result.segment.speaker && (
<span className="text-xs text-gray-500">{result.segment.speaker}</span>
)}
<span className="text-xs text-gray-400 ml-auto">
相似度 {(result.score * 100).toFixed(0)}%
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300"
dangerouslySetInnerHTML={{ __html: result.highlight }}
/>
</div>
))}
</div>
</div>
);
}

六、音频上传处理

大音频文件(播客通常 50MB-500MB)需要分片上传并在上传过程中并行进行波形提取。

lib/audio-uploader.ts
interface UploadProgress {
phase: 'preparing' | 'uploading' | 'processing';
progress: number; // 0-1
message: string;
}

export async function uploadPodcast(
file: File,
onProgress: (progress: UploadProgress) => void
): Promise<string> {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 分片
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

// 1. 计算文件 Hash(用于秒传判断)
onProgress({ phase: 'preparing', progress: 0, message: '计算文件指纹...' });
// 在 Worker 中计算 Hash 避免阻塞 UI
const hash = await computeFileHashInWorker(file);

// 2. 检查秒传
const checkRes = await fetch('/api/podcast/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename: file.name, size: file.size, totalChunks }),
});
const { podcastId, uploadedChunks } = await checkRes.json();

if (uploadedChunks.length === totalChunks) {
onProgress({ phase: 'uploading', progress: 1, message: '秒传成功!' });
return podcastId;
}

// 3. 分片上传(跳过已上传的分片 → 断点续传)
const pendingChunks = Array.from({ length: totalChunks }, (_, i) => i)
.filter(i => !uploadedChunks.includes(i));

let uploaded = uploadedChunks.length;

// 并发上传控制(3 个并发)
const concurrency = 3;
const pool: Promise<void>[] = [];

for (const chunkIndex of pendingChunks) {
const task = uploadChunk(file, podcastId, chunkIndex, CHUNK_SIZE).then(() => {
uploaded++;
onProgress({
phase: 'uploading',
progress: uploaded / totalChunks,
message: `上传中 ${uploaded}/${totalChunks}`,
});
});

pool.push(task);
if (pool.length >= concurrency) {
await Promise.race(pool);
pool.splice(pool.findIndex(p => p === task), 1);
}
}

await Promise.all(pool);

// 4. 通知服务端合并
await fetch(`/api/podcast/${podcastId}/merge`, { method: 'POST' });
onProgress({ phase: 'processing', progress: 1, message: '上传完成,开始转写...' });

return podcastId;
}

async function uploadChunk(
file: File, podcastId: string, index: number, chunkSize: number
): Promise<void> {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);

const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', String(index));

await fetch(`/api/podcast/${podcastId}/upload`, {
method: 'POST',
body: formData,
});
}

七、关键技术点总结

技术点方案说明
音频波形渲染Web Audio API + CanvasOfflineAudioContext 解码,Canvas 绘制波形,60fps 播放进度
大文件上传分片上传 + 断点续传5MB 分片,文件 Hash 秒传,3 并发上传
语音识别服务端 ASR(Whisper)返回逐字时间戳,支持说话人分离
转写跟读二分查找 + 逐字高亮当前播放时间 → 活跃 segment → 活跃 word
AI 摘要LLM 流式生成SSE 流式推送摘要/章节/关键词
语义搜索向量数据库转写文本分段 Embedding → 余弦相似度检索
性能优化Worker + 虚拟列表波形提取/Hash 计算在 Worker,长转写用虚拟列表
音频预处理AudioWorklet重采样、降噪、VAD(Voice Activity Detection)
设计要点
  1. 波形提取放 Worker:1 小时播客的 PCM 数据约 600MB,主线程处理会卡死
  2. 转写结果用虚拟列表:1 小时播客约 3000-5000 个 segment,全量渲染 DOM 过重
  3. 搜索用向量数据库:关键词搜索无法理解语义(如搜"性能优化"匹配不到"提升速度"),向量搜索可以
  4. ASR + LLM 异步:ASR 转写可能需要 5-30 分钟,用 WebSocket/SSE 推送进度

常见面试问题

Q1: 前端如何处理大音频文件的波形渲染?

答案

  1. 解码:使用 OfflineAudioContext.decodeAudioData() 将压缩音频(mp3/aac)解码为 PCM 数据
  2. 降采样:原始 PCM 数据量巨大(44100 采样/秒 × 3600 秒 = 1.5 亿采样点),按「每像素 N 个采样」降采样,取每段峰值
  3. Worker 处理:降采样在 Web Worker 中执行,避免阻塞 UI
  4. Canvas 渲染:用 Canvas 绘制波形柱状图,已播放部分和未播放部分用不同颜色
  5. 分段加载:超长音频可以只解码当前可视区域附近的片段

Canvas 比 SVG 更适合波形渲染——波形点数量大(1000+),SVG 的 DOM 节点开销太高。

Q2: 如何实现转写文本与音频的实时同步高亮?

答案

核心是时间戳对齐

  1. ASR 返回每个 segment 的 start / end 时间戳(秒级),甚至逐字时间戳
  2. 播放器通过 requestAnimationFrametimeupdate 事件获取 currentTime
  3. 二分查找 O(log n) 找到当前时间对应的 segment(避免 O(n) 遍历)
  4. 活跃 segment 自动滚动到可视区域(scrollIntoView
  5. 如有逐字时间戳,进一步高亮当前正在播放的词

性能关键:用 React.memo 包裹每个 segment 组件,只有活跃 segment 重渲染。

Q3: 播客语义搜索和普通关键词搜索有什么区别?

答案

维度关键词搜索语义搜索
匹配方式字符串精确匹配向量余弦相似度
"性能优化" 搜 "提升速度"❌ 匹配不到✅ 语义相近
"React" 搜 "前端框架"❌ 匹配不到✅ 语义相关
实现复杂度低(LIKE/全文索引)高(Embedding + 向量数据库)
延迟极低较高(需要向量计算)

播客搜索适合混合方案:先做关键词搜索(快速、精确),无结果时降级到语义搜索。或者两者并行,合并去重后按相关度排序。

Q4: AI 播客助手中有哪些适合端侧处理的任务?

答案

任务端侧 / 云端原因
波形数据提取端侧(Worker)纯计算,不需要模型
文件 Hash端侧(Worker)秒传判断,不应上传原文件
音频 VAD(静音检测)端侧简单信号处理
ASR 语音识别云端需要大模型(Whisper Large)
简单分类(音乐/语音)端侧可用浏览器端小模型
摘要生成云端需要 LLM
Embedding 生成端侧或云端小模型可端侧,精度要求高用云端
语义搜索云端需要向量数据库支持大规模检索

Q5: Web Audio API 中 AudioContext 和 OfflineAudioContext 有什么区别?

答案

特性AudioContextOfflineAudioContext
用途实时音频播放和处理离线音频处理(不播放)
实时性实时输出到扬声器尽快处理,不受实时约束
速度1 倍速(实时)可以超实时(10 倍速以上)
典型场景播放、录音、实时特效波形提取、格式转换、音频分析

播客助手中:

  • AudioContext:播放音频、实时音量可视化
  • OfflineAudioContext:提取波形数据、音频预处理(在 Worker 中执行)

Q6: 如果播客时长 3 小时,转写结果有几千条 segment,前端如何高效渲染?

答案

  1. 虚拟列表:使用 react-window 或 @tanstack/virtual 只渲染可视区域的 segment(~20-30 个),而非全部 3000+ 个
  2. memo 化:每个 segment 组件用 React.memo,只有活跃 segment 因高亮变化而重渲染
  3. 时间查找用二分O(log n) 查找当前时间对应的 segment,避免每帧遍历
  4. 批量更新timeupdate 事件每秒触发 4 次,配合 requestAnimationFrame 确保不超过帧率
  5. 懒加载 segment:初始只加载前 100 条,滚动到底部时加载更多

相关链接