Android 端实时字幕生成(Real-time Captioning)技术方案总结
1. 背景与目标 (Why)
1.1 需求背景
在移动端播放器(ExoPlayer/Media3)场景下,用户需要对视频/音频内容进行实时的语音转文字(ASR),以实现无障碍访问或静音观看体验。
1.2 原有问题
- 非实时性:传统方案依赖服务端 ASR,延迟高且依赖网络环境。
- 性能瓶颈:端侧大模型(如 Whisper)计算量大,若直接在主线程处理会导致播放卡顿(Underrun)。
- 接入复杂:ExoPlayer 默认架构对解码后 PCM 数据的"窃听"(Tap)支持有限。
1.3 本次目标
- 实时性:在设备端本地(On-device)实时生成字幕,延迟控制在 <1s。
- 低侵入 :不修改 ExoPlayer 核心源码,通过插件化
RenderersFactory方式接入。 - 高性能:严格的线程隔离与背压控制,确保 0 丢帧、0 爆音。
2. 需求拆解与技术约束 (What)
2.1 功能拆解
- 音频截取:从 ExoPlayer 渲染管线中实时获取 PCM 音频数据。
- 数据预处理:重采样(16kHz)、声道混合(Stereo -> Mono)。
- 缓冲管理:维护 30秒 环形缓冲区(Whisper Context Window)。
- 本地推理:调用 Whisper Native Engine (C++) 进行推理。
- UI 渲染:将推理结果转换为字幕 Cue 上屏。
2.2 技术约束
- ExoPlayer 线程模型 :音频回调在
PlaybackThread,绝对禁止执行耗时操作(如推理、大内存分配),否则会导致音频爆音。 - Whisper 模型限制:必须为 16kHz 单声道输入,且对 30秒 上下文依赖较强。
- 硬件资源:移动端 CPU/NPU 资源有限,需限制并发推理频率(Throttle)。
3. 方案对比与技术选型 (Decision)
| 方案 | 描述 | 优缺点 | 决策理由 |
|---|---|---|---|
| 方案 A: TeeAudioProcessor (采用) | 自定义 AudioProcessor 注入 DefaultAudioSink |
✅ 低侵入 ,直接获取解码后 PCM。 ✅ 官方推荐扩展点。 | 选中。能稳定获取同步音频,且不破坏原有渲染链。 |
| 方案 B: Visualizer | 使用 Android Visualizer API |
❌ 数据精度低(8bit),仅适用于频谱显示。 | 舍弃。精度不足以支撑 ASR。 |
| 方案 C: AudioTrack Hook | Hook 系统 AudioTrack |
❌ 兼容性极差,无法获取 ExoPlayer 内部时钟同步信息。 | 舍弃。风险不可控。 |
关键决策点:
- JNI 引擎 :选择
WhisperEngineNative利用 C++ 层加速,而非纯 Java 实现。 - 环形缓冲 (Circular Buffer) :采用固定大小
float[]数组,避免ArrayList频繁扩容导致的 GC。 - VAD (静音检测):基于 RMS 能量的简单 VAD,而非复杂模型,以降低预处理开销。
4. 整体架构设计 (How - 全局)
4.1 模块划分
graph LR
A[ExoPlayer / MediaCodec] -->|PCM Stream| B(PcmTeeAudioProcessor)
B -->|Original PCM| C[DefaultAudioSink / AudioTrack]
B -.->|Copy PCM (Callback)| D[TranscriptionManager]
subgraph "Core Logic (Background)"
D -->|Resample/Downmix| E[Circular Buffer]
E -->|Threshold Check| F[VAD Strategy]
F -->|Trigger| G[Whisper Native Engine]
end
G -->|Text Result| H[SubtitleView (Main Thread)]
4.2 关键类职责
DemoRenderersFactory:组装工厂,将自定义 Processor 注入 ExoPlayer。PcmTeeAudioProcessor:数据水龙头。只负责搬运数据,不做处理。TranscriptionManager:大脑。负责预处理、缓冲、VAD、推理调度。
5. 核心实现细节 (How - 关键点)
5.1 音频流截取:零阻塞分流
在 AudioProcessor 中,核心挑战是绝对不能阻塞播放线程。我们采用了"先复制、后透传"的策略。
java
// PcmTeeAudioProcessor.java 核心逻辑伪代码
public void queueInput(ByteBuffer inputBuffer) {
// 1. 快速拷贝(Sidecar Pattern)
// 关键:只做内存拷贝,不做重采样等耗时操作
if (active && inputBuffer.hasRemaining()) {
int bytes = inputBuffer.remaining();
// 复用类成员数组,避免频繁 new 产生 GC
ensureCapacity(copyBuffer, bytes);
inputBuffer.get(copyBuffer);
// 通过接口回调抛出数据,交给 TranscriptionManager 异步处理
listener.onPcm(copyBuffer, ...);
// 重置 inputBuffer 位置,以便后续透传给 AudioTrack
inputBuffer.rewind();
}
// 2. 原样透传给下游 AudioSink
// 保证对原播放链路无侵入
super.queueInput(inputBuffer);
}
5.2 缓冲管理:环形缓冲区展开 (Unrolling)
Whisper 模型需要线性的时间序列数据,而为了节省内存,我们在内存中维护的是环形缓冲。在推理前,需要将环形数据"展开"。
逻辑示意图:
text
内存状态 (WritePos 在中间):
[ ...新数据... | ...旧数据... ]
↑
WritePos
展开为模型输入 (30s Window):
[ ...旧数据... ] + [ ...新数据... ] = [ 完整的时间线性音频 ]
关键实现伪代码:
java
// TranscriptionManager.java - prepareInputSamples()
float[] inputSamples = new float[MAX_BUFFER_SIZE]; // 30s Window
synchronized (bufferLock) {
if (!isBufferFull) {
// A. 缓冲区未满:直接拷贝 0 -> current
System.arraycopy(audioBuffer, 0, inputSamples, 0, bufferWritePos);
} else {
// B. 缓冲区已满(环形):需要两段拼接
// Part 1: Copy Oldest (WritePos -> End)
int part1Len = MAX_BUFFER_SIZE - bufferWritePos;
System.arraycopy(audioBuffer, bufferWritePos, inputSamples, 0, part1Len);
// Part 2: Copy Newest (0 -> WritePos)
System.arraycopy(audioBuffer, 0, inputSamples, part1Len, bufferWritePos);
}
}
// inputSamples 现在是按时间顺序排列的音频数据,可直接喂给 Whisper
5.3 智能触发策略:VAD 与 背压控制
为了平衡实时性与性能,并不是每一帧都推理。我们实现了一个简单的状态机。
核心控制逻辑:
java
// 在 onPcm 回调中运行 (高频触发)
// 1. 计算当前短块的能量 (RMS)
boolean isCurrentSilence = calculateRMS(samples) < -50dB;
// 2. 检测语音结束沿 (Falling Edge)
boolean isSpeechEnd = !wasLastFrameSilence && isCurrentSilence;
// 3. 决定是否触发推理
// 规则:(每隔0.5s OR 刚说完一句话) AND (当前没有在跑推理)
if ((timeSinceLastRun > 500ms || isSpeechEnd) && !isInferencing.get()) {
// CAS 锁,如果当前正在推理,直接丢弃本次请求(背压丢帧策略,防止积压)
if (isInferencing.compareAndSet(false, true)) {
// 关键点:如果是因为"话说完了"触发的,
// 告诉后台线程在推理结束后清空 Buffer,防止这句话被重复识别
boolean shouldFlush = isSpeechEnd;
backgroundExecutor.submit(() -> {
try {
String text = whisperEngine.transcribe(buffer);
updateUI(text);
// 4. 清理逻辑
if (shouldFlush) {
resetBuffer(); // 避免 "幽灵音" 残留
}
} finally {
isInferencing.set(false); // 释放锁
}
});
}
}
6. 问题记录与踩坑总结 (Pitfalls)
- 播放爆音 :
- 原因 :最初在
queueInput中使用了AudioFormat.channelCount进行浮点运算,导致处理时长偶尔超过 20ms。 - 解决 :将所有复杂逻辑移出播放线程,只保留
System.arraycopy。
- 原因 :最初在
- 幻听(Hallucination) :
- 原因:Whisper 在纯静音片段有时会强行输出 "Thank you" 或重复上一句。
- 解决 :增加 RMS 能量检测,低于 -50dB 的整段 Buffer 拒绝送入引擎;增加
shouldFlush机制,在句尾强制清空上下文。
- 内存泄漏 :
- 原因 :Native Engine 需要手动
deinitialize。 - 解决 :在
releaseEngine()中严格管理生命周期。
- 原因 :Native Engine 需要手动
7. 验证方式与测试点 (Verification)
- 长句连续测试:说话 >30s,验证环形 Buffer 覆盖旧数据逻辑。
- 短语停顿测试:说短语后立即停顿,验证 VAD 能否触发立即上屏(响应速度应 <500ms)。
- Seek 测试:拖动进度条,验证 Buffer 是否被 Reset,字幕是否匹配新画面。
8. 重新实现 Checklist
- 1. 依赖 :引入
libwhisper.so和whisper-tiny.tflite。 - 2. Processor :创建
PcmTeeAudioProcessor(见附录 A)。 - 3. Manager :创建
TranscriptionManager(见附录 B),实现环形 Buffer 和 VAD。 - 4. Factory :创建
DemoRenderersFactory(见附录 C)。 - 5. 注入 :在播放器初始化时使用
new ExoPlayer.Builder(ctx, new DemoRenderersFactory(...))。
10. 附录:核心代码清单
A. PcmTeeAudioProcessor.java
负责从 ExoPlayer 音频管线中"窃取"PCM 数据。
java
package androidx.media3.demo.main;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.audio.AudioProcessor;
import androidx.media3.common.util.UnstableApi;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@UnstableApi
public final class PcmTeeAudioProcessor implements AudioProcessor {
public interface Listener {
void onPcm(short[] data, int frames, int channelCount, int sampleRate);
}
private final Listener listener;
private AudioFormat inputFormat = AudioFormat.NOT_SET;
private AudioFormat outputFormat = AudioFormat.NOT_SET;
private boolean ended;
private ByteBuffer empty = EMPTY_BUFFER;
private ByteBuffer outBuffer = EMPTY_BUFFER;
private short[] copyBuffer = new short[0]; // Reuse buffer
public PcmTeeAudioProcessor(Listener listener) {
this.listener = listener;
empty.order(ByteOrder.nativeOrder());
}
@Override
public AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudioFormatException {
// Only support PCM 16-bit
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT || inputAudioFormat.sampleRate <= 0) {
throw new UnhandledAudioFormatException(inputAudioFormat);
}
inputFormat = inputAudioFormat;
outputFormat = inputAudioFormat; // Pass-through
return outputFormat;
}
@Override
public boolean isActive() {
return outputFormat != AudioFormat.NOT_SET;
}
@Override
public void queueInput(ByteBuffer inputBuffer) {
if (!isActive() || !inputBuffer.hasRemaining()) {
return;
}
final int bytes = inputBuffer.remaining();
final int samples = bytes / 2; // PCM16
// 1. Prepare copy buffer
if (copyBuffer.length < samples) {
copyBuffer = new short[samples];
}
// 2. Read PCM data (Side-channel copy)
inputBuffer.order(ByteOrder.LITTLE_ENDIAN);
inputBuffer.asShortBuffer().get(copyBuffer, 0, samples);
// 3. Pass-through logic (Copy input to output buffer for ExoPlayer)
if (outBuffer == EMPTY_BUFFER || outBuffer.capacity() < bytes) {
outBuffer = ByteBuffer.allocateDirect(bytes).order(ByteOrder.nativeOrder());
} else {
outBuffer.clear();
}
ByteBuffer dup = inputBuffer.duplicate();
dup.order(ByteOrder.nativeOrder());
outBuffer.put(dup);
outBuffer.flip();
// Consume input
inputBuffer.position(inputBuffer.position() + bytes);
// 4. Notify listener (Offload processing)
final int frames = samples / Math.max(1, outputFormat.channelCount);
listener.onPcm(copyBuffer, frames, outputFormat.channelCount, outputFormat.sampleRate);
}
@Override
public void queueEndOfStream() { ended = true; }
@Override
public ByteBuffer getOutput() { return outBuffer.hasRemaining() ? outBuffer : EMPTY_BUFFER; }
@Override
public boolean isEnded() { return ended; }
@Override
public void flush() {
ended = false;
outBuffer = EMPTY_BUFFER;
}
@Override
public void reset() {
inputFormat = AudioFormat.NOT_SET;
outputFormat = AudioFormat.NOT_SET;
ended = false;
copyBuffer = new short[0];
empty = EMPTY_BUFFER;
outBuffer = EMPTY_BUFFER;
}
}
B. TranscriptionManager.java
业务逻辑核心:缓冲、预处理、调度。
java
package androidx.media3.demo.main;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.media3.ui.SubtitleView;
import androidx.media3.common.text.Cue;
import com.google.common.collect.ImmutableList;
import com.whispertflite.engine.WhisperEngine;
import com.whispertflite.engine.WhisperEngineNative;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class TranscriptionManager implements PcmTeeAudioProcessor.Listener {
private static final String TAG = "TranscriptionManager";
private static final int WHISPER_SAMPLE_RATE = 16000;
private static final int MAX_BUFFER_SIZE = WHISPER_SAMPLE_RATE * 30; // 30s buffer
private static final int INFERENCE_INTERVAL_SAMPLES = WHISPER_SAMPLE_RATE / 2; // 0.5s
private final Context context;
private final SubtitleView subtitleView;
private final Handler mainHandler;
private final ExecutorService executor;
private WhisperEngine whisperEngine;
private final AtomicBoolean isRunning = new AtomicBoolean(false);
private final AtomicBoolean isInferencing = new AtomicBoolean(false);
// Circular Buffer
private final float[] audioBuffer = new float[MAX_BUFFER_SIZE];
private int bufferWritePos = 0;
private int samplesSinceLastInference = 0;
private boolean isBufferFull = false;
private boolean wasLastFrameSilence = true;
public TranscriptionManager(Context context, SubtitleView subtitleView) {
this.context = context;
this.subtitleView = subtitleView;
this.mainHandler = new Handler(Looper.getMainLooper());
this.executor = Executors.newSingleThreadExecutor();
}
public void start() {
if (isRunning.compareAndSet(false, true)) {
resetBuffer();
executor.submit(this::initEngine);
}
}
public void stop() {
if (isRunning.compareAndSet(true, false)) {
executor.submit(this::releaseEngine);
}
}
private void initEngine() {
// ... Load Asset Model and new WhisperEngineNative(context) ...
// See full implementation for asset loading details
}
private void releaseEngine() {
if (whisperEngine != null) {
whisperEngine.deinitialize();
whisperEngine = null;
}
}
@Override
public void onPcm(short[] data, int frames, int channels, int sampleRate) {
if (!isRunning.get() || whisperEngine == null) return;
// 1. Resample & Downmix (Fast, on playback thread)
float[] processedSamples = convertToWhisperFormat(data, frames, channels, sampleRate);
if (processedSamples == null) return;
boolean shouldTriggerInference = false;
boolean isSpeechEnd = false;
boolean isCurrentChunkSilence = isSilence(processedSamples);
synchronized (audioBuffer) {
// 2. Write to Circular Buffer
int length = processedSamples.length;
for (int i = 0; i < length; i++) {
audioBuffer[bufferWritePos++] = processedSamples[i];
if (bufferWritePos >= MAX_BUFFER_SIZE) {
bufferWritePos = 0;
isBufferFull = true;
}
}
samplesSinceLastInference += length;
// 3. Check Triggers
boolean isIntervalReached = samplesSinceLastInference >= INFERENCE_INTERVAL_SAMPLES;
isSpeechEnd = !wasLastFrameSilence && isCurrentChunkSilence;
if (isIntervalReached || isSpeechEnd) {
shouldTriggerInference = true;
samplesSinceLastInference = 0;
}
wasLastFrameSilence = isCurrentChunkSilence;
}
// 4. Trigger Background Inference
if (shouldTriggerInference) {
if (!isInferencing.compareAndSet(false, true)) {
return; // Drop frame if busy
}
final boolean shouldFlush = isSpeechEnd;
executor.submit(() -> {
try {
if (!isRunning.get()) return;
runInference(shouldFlush);
} finally {
isInferencing.set(false);
}
});
}
}
private void runInference(boolean shouldFlush) {
float[] inputSamples;
synchronized (audioBuffer) {
// Unroll circular buffer logic ...
inputSamples = new float[MAX_BUFFER_SIZE];
if (!isBufferFull) {
System.arraycopy(audioBuffer, 0, inputSamples, 0, bufferWritePos);
} else {
int part1Len = MAX_BUFFER_SIZE - bufferWritePos;
System.arraycopy(audioBuffer, bufferWritePos, inputSamples, 0, part1Len);
System.arraycopy(audioBuffer, 0, inputSamples, part1Len, bufferWritePos);
}
}
if (isSilence(inputSamples)) return;
String result = whisperEngine.transcribeBuffer(inputSamples);
if (result != null && !result.isEmpty()) {
updateSubtitle(result);
}
if (shouldFlush) {
resetBuffer();
}
}
// ... helper methods: isSilence, convertToWhisperFormat, updateSubtitle, resetBuffer ...
}
C. DemoRenderersFactory.java
用于注入 Processor。
java
package androidx.media3.demo.main;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.audio.AudioProcessor;
import androidx.media3.exoplayer.RenderersFactory;
import androidx.media3.exoplayer.audio.AudioSink;
import androidx.media3.exoplayer.audio.DefaultAudioSink;
import androidx.media3.exoplayer.DefaultRenderersFactory;
@UnstableApi
public final class DemoRenderersFactory extends DefaultRenderersFactory implements RenderersFactory {
@Nullable private final AudioProcessor teeProcessor;
public DemoRenderersFactory(Context context, @Nullable AudioProcessor teeProcessor) {
super(context);
this.teeProcessor = teeProcessor;
}
@Override
@Nullable
protected AudioSink buildAudioSink(
Context context, boolean enableFloatOutput, boolean enableAudioTrackPlaybackParams) {
DefaultAudioSink.Builder builder =
new DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false) // Force PCM16
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams);
if (teeProcessor != null) {
builder.setAudioProcessors(new AudioProcessor[] { teeProcessor });
}
return builder.build();
}
}