欢迎光临
我们一直在努力

Android VAD实战:集成 WebRTC VAD 实现高精度离线语音人声检测(抗噪版)

        在移动端语音开发中,VAD(Voice Activity Detection,语音端点检测) 是一个绕不开的核心功能。无论是做语音助手、实时对讲,还是会议录音,我们需要精准地判断“什么时候有人在说话”,以便节省带宽或触发后续逻辑。

        虽然 Android 系统自带了 API,但效果往往不尽人意。Google 开源的 WebRTC VAD 模块因其体积小(几百KB)、检测准、纯离线运行,成为了业界的黄金标准。

        本文分享如何在 Android 项目中集成 WebRTC VAD,并分享一套“双重校验 + 连续帧防抖”的工业级抗噪算法,解决环境噪音导致的状态跳动问题。

一、 为什么选择 WebRTC VAD?

  • 轻量级:核心库极小,对 APK 体积影响微乎其微。

  • 低延迟:毫秒级处理,适合实时流。

  • 高兼容:纯 C 实现,通过 JNI 调用,适配所有 Android 机型。

  • 可配置:支持从“普通”到“极其激进”的多种检测模式

        为了避免去 WebRTC 庞大的源码中“扣代码”的痛苦,本文基于封装库 com.github.gkonovalov:android-vad 进行二次开发。

二、 快速集成

1. 引入依赖

        在 settings.gradle 中确保配置 jitpack(以下是完整示例):

pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\\\.android.*")
includeGroupByRegex("com\\\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}

rootProject.name = "Audio-VAD"
include(":app")

        在 App 模块的 build.gradle 中添加依赖,并解决 Android 15 的 16KB 对齐问题(以下为完整示例):

plugins {
alias(libs.plugins.android.application)
}

android {
namespace = "com.example.your-project"
compileSdk = 36

defaultConfig {
applicationId = "com.example.your-project"
minSdk = 34
targetSdk = 36
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

//防止在 Android 15+ 设备上因 16KB 内存页对齐问题导致 Crash
packaging {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

dependencies {

implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
implementation("com.github.gkonovalov:android-vad:2.0.6")
}

2. 权限声明

        在 AndroidManifest.xml 中添加录音权限:

<uses-permission android:name="android.permission.RECORD_AUDIO" />

三、 核心痛点与优化方案

        直接调用 VAD 的 isSpeech() 接口在安静环境下表现完美,但在嘈杂环境(如办公室、商场)中,容易出现两个问题:

  • 误触:空调声、键盘声被误判为人声。

  • 闪烁:状态在“说话”和“静音”之间疯狂跳变。

  • 为此,实现了一套“三维过滤机制”:

  • 算法层:使用 VERY_AGGRESSIVE(最激进)模式,提高算法门槛。

  • 物理层(能量门限):计算音频的分贝(dB),强制忽略低音量的“疑似人声”。

  • 时间层(软件防抖):引入计数器,只有连续 N 帧满足条件,才认定状态改变。

  • 四、 完整代码实现

    1. 布局文件 (activity_main.xml)

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F8F9FA"
    android:padding="24dp">

    <!– 顶部状态区域 –>
    <TextView
    android:id="@+id/tv_status_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="当前检测状态"
    android:textColor="#757575"
    android:textSize="14sp"
    android:letterSpacing="0.1"
    app:layout_constraintBottom_toTopOf="@+id/tv_final_decision"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    android:layout_marginBottom="8dp"
    app:layout_constraintVertical_chainStyle="packed"
    app:layout_constraintTop_toTopOf="parent" />

    <TextView
    android:id="@+id/tv_final_decision"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="等待启动"
    android:textSize="40sp"
    android:textStyle="bold"
    android:textColor="#263238"
    android:fontFamily="sans-serif-medium"
    app:layout_constraintTop_toBottomOf="@id/tv_status_label"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintBottom_toTopOf="@+id/card_volume"
    android:layout_marginBottom="32dp"/>

    <!– 音量可视化卡片 –>
    <androidx.cardview.widget.CardView
    android:id="@+id/card_volume"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="16dp"
    app:cardElevation="0dp"
    app:cardBackgroundColor="#FFFFFF"
    android:layout_marginBottom="24dp"
    app:layout_constraintTop_toBottomOf="@id/tv_final_decision"
    app:layout_constraintBottom_toTopOf="@+id/card_details">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="20dp">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:gravity="center_vertical"
    android:layout_marginBottom="12dp">

    <TextView
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:text=""
    android:textStyle="bold"
    android:textColor="#333333"
    android:textSize="16sp"/>

    <TextView
    android:id="@+id/tv_volume_db"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="0 dB"
    android:textSize="16sp"
    android:textColor="#2196F3"
    android:textStyle="bold"
    android:fontFamily="monospace"/>
    </LinearLayout>

    <ProgressBar
    android:id="@+id/pb_volume"
    style="?android:attr/progressBarStyleHorizontal"
    android:layout_width="match_parent"
    android:layout_height="12dp"
    android:max="100"
    android:progress="30"
    android:progressDrawable="@drawable/progress_fill"
    android:background="@drawable/progress_bg" />

    </LinearLayout>
    </androidx.cardview.widget.CardView>

    <!– 详细指标卡片 –>
    <androidx.cardview.widget.CardView
    android:id="@+id/card_details"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="16dp"
    app:cardElevation="4dp"
    app:cardBackgroundColor="#FFFFFF"
    android:layout_marginBottom="32dp"
    app:layout_constraintTop_toBottomOf="@id/card_volume"
    app:layout_constraintBottom_toTopOf="@+id/btn_toggle">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="20dp">

    <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="监测详情"
    android:textSize="16sp"
    android:textStyle="bold"
    android:textColor="#333333"
    android:layout_marginBottom="16dp"/>

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:layout_marginBottom="8dp">
    <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="WebRTC 原始判定:"
    android:textColor="#757575"
    android:textSize="14sp"/>
    <View android:layout_width="0dp" android:layout_height="0dp" android:layout_weight="1"/>
    <TextView
    android:id="@+id/tv_vad_raw"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="FALSE"
    android:textSize="14sp"
    android:textStyle="bold"
    android:textColor="#FF5252"
    android:fontFamily="monospace"/>
    </LinearLayout>

    <View
    android:layout_width="match_parent"
    android:layout_height="1dp"
    android:background="#F0F0F0"
    android:layout_marginVertical="8dp"/>
    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:layout_marginBottom="12dp">
    <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="状态持续时间:"
    android:textColor="#757575"
    android:textSize="14sp"/>
    <View android:layout_width="0dp" android:layout_height="0dp" android:layout_weight="1"/>
    <TextView
    android:id="@+id/tv_duration"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="0 ms"
    android:textSize="14sp"
    android:textColor="#333333"
    android:fontFamily="monospace"/>
    </LinearLayout>

    <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="● 激进模式 (Very Aggressive) + 能量门限过滤已启用"
    android:textSize="11sp"
    android:textColor="#2196F3"
    android:background="#E3F2FD"
    android:paddingHorizontal="8dp"
    android:paddingVertical="4dp"
    android:layout_marginTop="4dp"/>
    </LinearLayout>
    </androidx.cardview.widget.CardView>

    <Button
    android:id="@+id/btn_toggle"
    android:layout_width="match_parent"
    android:layout_height="56dp"
    android:text="开始检测"
    android:textSize="16sp"
    android:textStyle="bold"
    android:textColor="#FFFFFF"
    android:backgroundTint="#2196F3"
    android:stateListAnimator="@null"
    android:elevation="4dp"
    app:cornerRadius="28dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintTop_toBottomOf="@id/card_details"
    app:layout_constraintVertical_bias="1.0"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

    2. 样式文件

    2.1.progress_bg.xml

    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="10dp"/>
    <solid android:color="#E0E0E0"/>
    </shape>

    2.2.progress_fill.xml

    <?xml version="1.0" encoding="utf-8"?>
    <scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:scaleWidth="100%">
    <shape>
    <corners android:radius="10dp"/>
    <gradient
    android:startColor="#4FC3F7"
    android:endColor="#2196F3"
    android:angle="0"/>
    </shape>
    </scale>

    3. 核心逻辑 (MainActivity.java)

    package com.example.audio_vad;

    import android.Manifest;
    import android.content.pm.PackageManager;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.widget.Button;
    import android.widget.ProgressBar;
    import android.widget.TextView;
    import android.widget.Toast;

    import androidx.annotation.NonNull;
    import androidx.appcompat.app.AppCompatActivity;
    import androidx.core.app.ActivityCompat;
    import androidx.core.content.ContextCompat;

    import com.konovalov.vad.webrtc.VadWebRTC;
    import com.konovalov.vad.webrtc.config.FrameSize;
    import com.konovalov.vad.webrtc.config.Mode;
    import com.konovalov.vad.webrtc.config.SampleRate;

    public class MainActivity extends AppCompatActivity {

    private static final int PERMISSION_REQ_CODE = 1001;

    // — 音频参数 —
    private static final int SAMPLE_RATE_HZ = 16000;
    private static final int FRAME_SIZE_MS = 20;
    private static final int FRAME_SIZE_SAMPLES = SAMPLE_RATE_HZ * FRAME_SIZE_MS / 1000; // 320

    // — 能量门限 —
    // 办公室/嘈杂环境建议设为 45.0 – 55.0 dB
    // 只有比环境音明显大声的人声才会被检测到
    private static final double DB_THRESHOLD = 45.0;

    // — 连续帧校验阈值 —
    // 连续 5 帧 (100ms) 满足条件才算开始,防止瞬间爆破音误触
    private static final int MIN_CONSECUTIVE_FRAMES = 8;

    private TextView tvFinalDecision, tvVolumeDb, tvVadRaw, tvDuration;
    private ProgressBar pbVolume;
    private Button btnToggle;

    private boolean isRecording = false;
    private AudioRecord audioRecord;
    private Thread recordingThread;
    private VadWebRTC vad;

    // 状态统计
    private long lastStateChangeTime = 0;
    private boolean lastFinalState = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    initViews();
    setupStrictVad();

    btnToggle.setOnClickListener(v -> {
    if (isRecording) {
    stopRecording();
    } else {
    checkPermissionAndStart();
    }
    });
    }

    private void initViews() {
    tvFinalDecision = findViewById(R.id.tv_final_decision);
    tvVolumeDb = findViewById(R.id.tv_volume_db);
    tvVadRaw = findViewById(R.id.tv_vad_raw);
    tvDuration = findViewById(R.id.tv_duration);
    pbVolume = findViewById(R.id.pb_volume);
    btnToggle = findViewById(R.id.btn_toggle);
    }

    private void setupStrictVad() {
    try {
    // — VAD 参数 —
    vad = new VadWebRTC(
    SampleRate.SAMPLE_RATE_16K,
    FrameSize.FRAME_SIZE_320,
    Mode.VERY_AGGRESSIVE, // 最激进模式
    200, // 启动延时:增加到 200ms。必须连续说话 0.2s 算法才开始介入
    500 // 静音延时:500ms,保证说话停顿不被打断
    );
    } catch (Exception e) {
    e.printStackTrace();
    Toast.makeText(this, "VAD 初始化失败", Toast.LENGTH_SHORT).show();
    btnToggle.setEnabled(false);
    }
    }

    private void checkPermissionAndStart() {
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
    != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
    new String[]{Manifest.permission.RECORD_AUDIO}, PERMISSION_REQ_CODE);
    } else {
    startRecording();
    }
    }

    private void startRecording() {
    if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return;
    int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);

    try {
    audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE_HZ,
    AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
    Math.max(minBufferSize, FRAME_SIZE_SAMPLES * 10));

    audioRecord.startRecording();
    isRecording = true;
    lastStateChangeTime = System.currentTimeMillis();
    btnToggle.setText("停止检测");

    recordingThread = new Thread(this::processAudioLoop);
    recordingThread.start();

    } catch (Exception e) {
    e.printStackTrace();
    stopRecording();
    }
    }

    private void stopRecording() {
    isRecording = false;
    if (audioRecord != null) {
    audioRecord.stop();
    audioRecord.release();
    audioRecord = null;
    }
    recordingThread = null;
    btnToggle.setText("开始检测");
    tvFinalDecision.setText("已停止");
    }

    // — 核心处理循环—
    private void processAudioLoop() {
    short[] audioBuffer = new short[FRAME_SIZE_SAMPLES];

    // 连续帧计数器
    int speechFrameCounter = 0;
    // 最终稳定状态
    boolean stableIsSpeech = false;

    while (isRecording) {
    int readSize = audioRecord.read(audioBuffer, 0, FRAME_SIZE_SAMPLES);
    if (readSize > 0) {

    // 1. 基础数据获取
    boolean isVadSpeech = vad.isSpeech(audioBuffer);
    double db = calculateDb(audioBuffer, readSize);

    // 2. 单帧初步判定 (双重校验)
    // 条件:VAD 认为是人声 AND 音量大于高门限 (55dB)
    boolean currentFrameIsSpeech = isVadSpeech && (db > DB_THRESHOLD);

    // 3. 连续帧校验 (防抖动)
    if (currentFrameIsSpeech) {
    // 如果当前帧满足条件,计数器 +1
    speechFrameCounter++;
    } else {
    // 只要断了一帧,立刻清零(严格要求连续性)
    speechFrameCounter = 0;
    }

    // 只有连续满足 N 帧,才判定状态切换为 TRUE
    if (speechFrameCounter >= MIN_CONSECUTIVE_FRAMES) {
    stableIsSpeech = true;
    // 防止计数器无限增长溢出,保持在阈值即可
    speechFrameCounter = MIN_CONSECUTIVE_FRAMES;
    } else {
    // 如果计数器没达到阈值,保持为 FALSE
    stableIsSpeech = false;
    }

    // 4. 更新 UI
    boolean finalDecision = stableIsSpeech;
    runOnUiThread(() -> updateUi(isVadSpeech, finalDecision, db));
    }
    }
    }

    private void updateUi(boolean isVadSpeech, boolean isFinalSpeech, double db) {
    pbVolume.setProgress((int) Math.min(Math.max(db, 0), 100));
    tvVolumeDb.setText(String.format("音量: %.1f dB", db));

    // 动态改变音量文字颜色,辅助调试
    if (db > DB_THRESHOLD) {
    tvVolumeDb.setTextColor(0xFF2196F3); // 超过阈值显示蓝色
    } else {
    tvVolumeDb.setTextColor(0xFF000000); // 未超过显示黑色
    }

    tvVadRaw.setText(" " + isVadSpeech);

    // 状态切换计时
    if (isFinalSpeech != lastFinalState) {
    lastStateChangeTime = System.currentTimeMillis();
    lastFinalState = isFinalSpeech;
    }
    long duration = System.currentTimeMillis() – lastStateChangeTime;
    tvDuration.setText(String.format("%d ms", duration));

    if (isFinalSpeech) {
    tvFinalDecision.setText("🗣️ 正在说话");
    tvFinalDecision.setTextColor(0xFFFF0000);
    } else {
    tvFinalDecision.setText("🔇 保持安静");
    tvFinalDecision.setTextColor(0xFFAAAAAA);
    }
    }

    private double calculateDb(short[] buffer, int readSize) {
    double sum = 0;
    for (short s : buffer) {
    sum += s * s;
    }
    double rms = Math.sqrt(sum / readSize);
    return rms > 0 ? 20 * Math.log10(rms) : 0;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == PERMISSION_REQ_CODE && grantResults.length > 0 &&
    grantResults[0] == PackageManager.PERMISSION_GRANTED) {
    startRecording();
    }
    }

    @Override
    protected void onDestroy() {
    super.onDestroy();
    stopRecording();
    if (vad != null) {
    vad.close();
    }
    }
    }

    五、 效果展示与参数说明

    效果展示:

    效果:

  • 背景静音:在办公室有空调声、敲键盘声时,状态栏始终显示“🔇 保持安静”。

  • 精准触发:只有当你对着手机正常说话时,状态栏才会瞬间变红显示“🗣️ 正在说话”。

  • 无闪烁:说话结束时,不会因为轻微的尾音而反复跳变。

  • 如何调整参数适配你的环境?

    如果觉得检测太难触发,或仍然太敏感,可以调节 MainActivity 顶部的两个参数:

    • DB_THRESHOLD (推荐 45.0 – 60.0):

      • 调大:更严格,需要更大声说话,适合户外/商场。

      • 调小:更灵敏,适合安静卧室/图书馆。

    • MIN_CONSECUTIVE_FRAMES (推荐 3 – 8):

      • 调大:防抖更强,但开始说话时的延迟会增加(会有种“迟钝感”)。

      • 调小:反应极快,但可能误触。

    六、 总结

            通过集成 WebRTC VAD 并结合 能量门限 与 连续帧校验,可以用很少的代码量就实现了一个工业级的语音端点检测模块。这种方案不仅去除了繁重的网络依赖,还解决了实际场景中令人头疼的噪声干扰问题,非常适合用于语音唤醒、离线指令和实时对讲场景。

     

    赞(0)
    未经允许不得转载:171主机测评 » Android VAD实战:集成 WebRTC VAD 实现高精度离线语音人声检测(抗噪版)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址