【无标题】

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 功能拆解

  1. 音频截取:从 ExoPlayer 渲染管线中实时获取 PCM 音频数据。
  2. 数据预处理:重采样(16kHz)、声道混合(Stereo -> Mono)。
  3. 缓冲管理:维护 30秒 环形缓冲区(Whisper Context Window)。
  4. 本地推理:调用 Whisper Native Engine (C++) 进行推理。
  5. 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)

  1. 播放爆音
    • 原因 :最初在 queueInput 中使用了 AudioFormat.channelCount 进行浮点运算,导致处理时长偶尔超过 20ms。
    • 解决 :将所有复杂逻辑移出播放线程,只保留 System.arraycopy
  2. 幻听(Hallucination)
    • 原因:Whisper 在纯静音片段有时会强行输出 "Thank you" 或重复上一句。
    • 解决 :增加 RMS 能量检测,低于 -50dB 的整段 Buffer 拒绝送入引擎;增加 shouldFlush 机制,在句尾强制清空上下文。
  3. 内存泄漏
    • 原因 :Native Engine 需要手动 deinitialize
    • 解决 :在 releaseEngine() 中严格管理生命周期。

7. 验证方式与测试点 (Verification)

  • 长句连续测试:说话 >30s,验证环形 Buffer 覆盖旧数据逻辑。
  • 短语停顿测试:说短语后立即停顿,验证 VAD 能否触发立即上屏(响应速度应 <500ms)。
  • Seek 测试:拖动进度条,验证 Buffer 是否被 Reset,字幕是否匹配新画面。

8. 重新实现 Checklist

  • 1. 依赖 :引入 libwhisper.sowhisper-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();
  }
}
相关推荐
山海青风5 小时前
语音合成 - 用 Python 合成藏语三大方言语音
开发语言·python·音视频
coding-fun10 小时前
电脑音频录制工具(语音聊天录音软件)
音视频
却道天凉_好个秋10 小时前
音视频学习(七十二):视频压缩:分块与预处理
音视频·视频压缩
gf132111112 小时前
python_字幕文本、音频、视频一键组合
python·音视频·swift
YANshangqian12 小时前
音频录制和编辑软件
音视频
gf132111113 小时前
python_字幕、音频、媒体文件(图片或视频)一键组合
python·音视频·swift
daizhe13 小时前
基于JavaCV实现FFmpeg设置视频moov前置以及截取封面图片
ffmpeg·音视频·javacv
DsirNg14 小时前
Vue3 实时音频录制与转写 Composable 技术实现
音视频
平凡灵感码头16 小时前
第一次做蓝牙产品,从零开发(5)蓝牙音频项目中功放芯片
单片机·嵌入式硬件·音视频