杰理 SDK 文档中心
首页
首页
  • 快速开始

    • 环境要求与权限配置
    • 集成SDK依赖
    • 运行示例应用
  • 核心架构与协议

    • RCSP协议与数据通道
    • 蓝牙连接与设备管理
    • TWS双耳功能
    • 基础功能接口与自定义命令
  • 设备功能控制

    • 设备音乐控制与ID3信息
    • 文件浏览与传输
    • FM收音与发射
    • 灯光控制
    • 闹钟与时间管理
    • 查找设备与防丢
    • ANC与噪声处理
    • 按键功能设置
    • 彩屏仓控制
    • AI翻译
  • 音效与音频处理

    • 均衡器音效调节
    • 录音与语音控制
    • Line-in、SPDIF与声卡功能
    • 音频编解码库
  • 扩展功能库

    • OTA固件升级
    • 数据加密与解密
    • 图片与动图格式转换
  • 示例应用 btsmart

    • 应用架构与界面导航
    • 设备功能适配与数据层
    • 设备配置JSON与资源文件
  • 参考与版本

    • 错误码参考
    • 版本历史与更新日志
    • 开发文档中心导航

录音与语音控制

本文档介绍 Android-JL_Bluetooth(PiHome btsmart 应用)中与录音采集、PCM 数据处理、WAV 文件封装以及语音控制相关的完整实现,覆盖从麦克风/蓝牙设备采集原始音频,到 PCM 工具处理、编码封装,再到播放与语音指令执行的端到端链路。

Purpose and Scope

本页面聚焦「录音与语音控制」这一能力域,内容包括:

  • PCM 录音数据处理:PcmKit 提供的立体声声道分离等 PCM 工具方法;
  • WAV 文件封装:AppUtil.savePcmToWav 手工构建 WAV 头并落盘的实现细节;
  • 录音文件约定:TranslateUtil 中 .pcm / .wav 后缀与录音文件命名约定;
  • 语音控制:PlayControl 中以语音控制方式执行的音量调节指令。

以下相关主题属于其他页面,本文档不展开:蓝牙设备音频流传输与编解码(见 SDK 传输相关页面)、豆包 TTS/翻译的云端对接细节(见 AI 翻译页面)、音乐播放控制(见播放控制页面)。

Overview

在杰理(Jieli)蓝牙生态的 Android 应用中,录音能力被多个场景复用:

  1. 翻译场景:用户按住说话采集 PCM 音频,经 AudioCodec 编码后上传云端翻译,翻译结果经 TTS 返回(AudioPlayer 播放);
  2. 本地处理场景:采集到的立体声 PCM 需要分离左右声道(PcmKit.splitStereoPcmData),或以 16kHz 单声道 16bit 的规格封装为 WAV 文件(AppUtil.savePcmToWav);
  3. 语音控制场景:设备端通过语音指令控制播放音量(PlayControl 中标记为「语音控制」的增大/减小音量入口)。

整套设计遵循「采集原始 PCM → 统一工具处理 → 按需封装/编码 → 消费」的流水线思想:PCM 作为中间标准格式贯穿始终,工具类保持无状态静态方法,便于在 UI 线程之外(后台线程/服务)复用。

Architecture

flowchart TD
    subgraph sg_Source["音频采集层"]
        Mic["麦克风 / JL 蓝牙设备"]
        TranslateRecorder["翻译模块录音<br/>(TranslateAudioParam)"]
    end

    subgraph sg_Process["PCM 处理层 (util)"]
        PcmKit["PcmKit<br/>声道分离/位深处理"]
        SaveWav["AppUtil.savePcmToWav<br/>WAV 头封装"]
    end

    subgraph sg_Consumer["消费层"]
        AudioCodec["AudioCodec<br/>编码上传"]
        AudioPlayer["AudioPlayer<br/>播放"]
        PlayControl["PlayControl<br/>语音控制音量"]
        FileStore["PCM/WAV 文件<br/>(TranslateUtil 后缀约定)"]
    end

    Mic -->|"原始 PCM 流"| PcmKit
    TranslateRecorder -->|"录音 PCM"| AudioCodec
    AudioCodec -->|"编码数据"| FileStore
    PcmKit -->|"分离后声道"| SaveWav
    SaveWav -->|".wav 文件"| FileStore
    FileStore -->|"读取播放"| AudioPlayer
    PcmKit -->|"PCM 数据"| FileStore
    PlayControl -->|"增大/减小音量(语音)"| Mic

架构说明

  • 采集层:原始音频的来源有两类——系统麦克风采集(翻译录音)与蓝牙设备侧的音频数据。采集产物统一为 PCM 裸数据,以便后续任意处理。
  • PCM 处理层:PcmKit 提供与 Android AudioFormat 常量对齐的位深处理(默认 ENCODING_PCM_16BIT),AppUtil.savePcmToWav 负责把裸 PCM 包装成带 44 字节头的标准 WAV 文件,采样率为 16kHz、单声道、16bit。
  • 消费层:翻译模块通过 AudioCodec 将录音编码、经 AudioPlayer 播放返回的 TTS 音频;PlayControl 则把「增大/减小音量」的语音指令映射为播放器控制动作。
  • 关键设计意图:以 PCM 作为统一中间格式,把「采集」「处理」「封装」「消费」解耦为独立静态工具,任何场景都能按需组合,而无需为每个业务复制录音逻辑。

核心实现详解

1. PCM 处理工具:PcmKit

PcmKit 是仓库中唯一的 PCM 专属工具类(com.jieli.btsmart.util),定位为「PCM 工具栏」,当前提供立体声 PCM 数据的左右声道分离能力。

/**
 * 分离立体声PCM数据(默认是PCM_16Bit)
 *
 * @param pcmData byte[] 立体声PCM数据
 * @return byte[] 立体声PCM数据
 * <p>
 *     固定为2个ByteArray的列表,第一个是左声道,第二个是右声道
 * </p>
 */
public static byte[][] splitStereoPcmData(byte[] pcmData) {
    return splitStereoPcmData(pcmData, AudioFormat.ENCODING_PCM_16BIT);
}

来源:PcmKit.java

设计意图:蓝牙设备/麦克风采集的 PCM 常常是交错(interleaved)立体声数据,即左右声道采样交替排列。若后续只需单声道(如语音识别、翻译、WAV 封装为单声道),必须先按采样粒度拆分。方法默认采用 Android 标准常量 AudioFormat.ENCODING_PCM_16BIT(=2 字节/采样),避免硬编码魔数。

带位深参数的重载方法实现了实际分离算法:

public static byte[][] splitStereoPcmData(byte[] pcmData, int bitRate) {
    if (null == pcmData || pcmData.length < bitRate * 2) return new byte[2][0];
    ByteArrayOutputStream leftData = new ByteArrayOutputStream();
    ByteArrayOutputStream rightData = new ByteArrayOutputStream();
    ByteBuffer dataBuf = ByteBuffer.wrap(pcmData);
    byte[] buf = new byte[bitRate * 2];
    try {
        while (dataBuf.remaining() >= buf.length) {
            dataBuf.get(buf);
            leftData.write(Arrays.copyOfRange(buf, 0, bitRate));
            rightData.write(Arrays.copyOfRange(buf, bitRate, bitRate * 2));
        }
    } catch (IOException ignore) {
    }
    return new byte[][]{
            leftData.toByteArray(),
            rightData.toByteArray()
    };
}

来源:PcmKit.java

算法要点:

  • 输入校验:null 或长度不足一个立体声采样帧(bitRate * 2 字节)时,直接返回两个空数组 new byte[2][0],保证调用方无需判空即可安全使用;
  • 帧对齐:通过 ByteBuffer.wrap 按 bitRate * 2 字节(一帧 = 左采样 + 右采样)切块,避免数组越界;剩余不足一帧的尾部数据被丢弃(while (dataBuf.remaining() >= buf.length));
  • 声道输出:每个帧的前 bitRate 字节归左声道,后 bitRate 字节归右声道,分别写入 ByteArrayOutputStream;
  • 异常处理:ByteArrayOutputStream.write 理论上不抛 IOException,此处 catch 后忽略,保证纯内存操作不中断调用方。

2. WAV 封装:AppUtil.savePcmToWav

AppUtil.savePcmToWav(String input, String output) 将裸 PCM 文件转换为标准 WAV 文件,核心工作是手工构造 44 字节 WAV 头:

public static void savePcmToWav(String input, String output) throws IOException {
    FileInputStream fis = new FileInputStream(input);
    FileOutputStream fos = new FileOutputStream(output);

    byte[] header = new byte[44];
    int size = fis.available() + 36;

    //资源交换文件标志(RIFF)
    header[0] = 'R';
    header[1] = 'I';
    header[2] = 'F';
    header[3] = 'F';

    //从下个地址开始到文件尾的总字节数
    header[4] = (byte) (size & 0xff);
    header[5] = (byte) ((size >> 8) & 0xff);
    header[6] = (byte) ((size >> 16) & 0xff);
    header[7] = (byte) ((size >> 24) & 0xff);

    //WAV文件标志(WAVE)
    header[8] = 'W';
    header[9] = 'A';
    header[10] = 'V';
    header[11] = 'E';

    //波形格式标志(fmt ),最后一位空格。
    header[12] = 'f';
    header[13] = 'm';
    header[14] = 't';
    header[15] = ' ';

    //过滤字节(一般为00000010H),若为00000012H则说明数据头携带附加信息(见“附加信息”)。
    header[16] = 16;
    header[17] = 0;
    header[18] = 0;
    header[19] = 0;

    //format
    header[20] = 1;
    header[21] = 0;

    //channel
    header[22] = 1;
    header[23] = 0;

    int longSampleRate = 16000;
    header[24] = (byte) (longSampleRate & 0xff);
    header[25] = (byte) ((longSampleRate >> 8) & 0xff);
    header[26] = (byte) ((longSampleRate >> 16) & 0xff);
    header[27] = (byte) ((longSampleRate >> 24) & 0xff);

    int channels = 1;
    int byteRate = 16 * channels * longSampleRate / 8;
    header[28] = (byte) (byteRate & 0xff);
    header[29] = (byte) ((byteRate >> 8) & 0xff);
    header[30] = (byte) ((byteRate >> 16) & 0xff);
    header[31] = (byte) ((byteRate >> 24) & 0xff);

    int bitPerSample = 16;
    int blockAlign = channels * bitPerSample / 8;
    // ...(后续代码继续填充 blockAlign/bitPerSample 字段与 data 块头,并将 PCM 数据写入输出文件)
}

来源:AppUtil.java

WAV 头字段解析(已验证部分):

字节偏移长度内容值
0-34RIFF 标志RIFF
4-74文件总大小PCM 字节数 + 36
8-114WAVE 标志WAVE
12-154fmt 块标志fmt
16-194fmt 块长度16(无附加信息)
20-212音频格式1(PCM)
22-232声道数1(单声道)
24-274采样率16000 Hz
28-314字节率16 × 1 × 16000 / 8

设计意图:

  • 手写字节而非依赖系统 API:直接操纵 byte[] 构建 RIFF/WAVE 头,避免引入第三方库,逻辑完全可控;对小文件场景(语音片段通常仅数秒),性能开销可忽略;
  • 固定规格 16kHz/单声道/16bit:该规格是语音类应用(识别/翻译)的通用输入标准,与 TranslateUtil 中 PCM/WAV 后缀约定及翻译链路对齐;
  • 小端字节序:多字节字段(size、采样率、字节率)均按 WAV 规范以小端序逐字节写入。

3. 录音文件命名约定:TranslateUtil

TranslateUtil 定义了录音文件的后缀常量,是录音落盘与后续读取的公共约定:

public static final String PCM_SUFFIX = ".pcm";
public static final String WAV_SUFFIX = ".wav";

来源:TranslateUtil.java

PCM 后缀用于原始采集数据(体积小、无头信息、便于直接编码上传),WAV 后缀用于需要播放/分享的封装产物。这一约定让录音工具(如 AudioCodec)与播放器(AudioPlayer)无需关心文件来源,仅凭扩展名即可区分处理路径。

4. 语音控制:PlayControl

语音控制能力体现在 PlayControl 播放控制器中:音量调节动作被明确标注为支持语音控制触发。

/**
 * 增大音量,,语音控制
 */
public static final int ACTION_UP_VOLUME = 0x01;

/**
 * 减小音量 ,语音控制
 */
public static final int ACTION_DOWN_VOLUME = 0x02;

来源:PlayControl.java

设计意图:PlayControl 将「语音控制」作为音量动作的第一类入口标注,说明该常量不仅被 UI 按钮复用,也被语音指令解析路径复用——语音识别结果只需映射为 ACTION_UP_VOLUME / ACTION_DOWN_VOLUME 动作码,即可复用同一套播放控制逻辑,避免为语音入口单独实现音量调整。

核心流程

录音 → 处理 → 封装 → 消费 端到端流程

sequenceDiagram
    participant Src as 音频源(麦克风/设备)
    participant Rec as 录音模块(翻译场景)
    participant Kit as PcmKit
    participant Wav as AppUtil.savePcmToWav
    participant Enc as AudioCodec
    participant File as PCM/WAV 文件
    participant Player as AudioPlayer / PlayControl

    Src->>Rec: 采集原始音频
    Rec->>Kit: 传递 PCM 字节流
    Kit->>Kit: splitStereoPcmData 分离声道
    Kit-->>Wav: 左/右声道 PCM
    Wav->>File: 写 44 字节 WAV 头 + PCM 数据
    Note over Wav,File: 固定规格 16kHz / 单声道 / 16bit
    Rec->>Enc: 编码录音
    Enc->>File: 写入 .pcm / .wav (TranslateUtil 后缀约定)
    File-->>Player: 读取音频文件
    Player->>Player: 播放 / 音量控制(语音指令)

流程要点

  1. 采集:音频源产生原始 PCM 数据,进入录音模块;
  2. 处理:PcmKit.splitStereoPcmData 按帧(bitRate * 2 字节)拆分左右声道,丢弃不足一帧的尾部数据;
  3. 封装:AppUtil.savePcmToWav 按 16kHz/单声道/16bit 规格生成 WAV 头,将 PCM 载荷写入输出文件;
  4. 编码/存储:翻译场景由 AudioCodec 对录音编码,文件命名遵循 TranslateUtil 的 .pcm/.wav 后缀约定;
  5. 消费:AudioPlayer 播放语音结果,PlayControl 响应语音控制指令(增大/减小音量)。

翻译场景中的录音链路

录音能力在 AI 翻译(tool/translate 与 tool/ai/doubao)场景中被具体化:

  • TranslateAudioParam(doubao 翻译模块):封装待翻译音频的参数对象,即录音数据的结构化载体;
  • AudioCodec(tool/translate/codec):负责录音音频的编码,将 PCM 原始数据转为可上传/可存储的格式;
  • AudioPlayer(tool/translate/player):播放翻译返回的 TTS 音频,是录音产物被消费的典型出口;
  • Audio(doubao TTS 模型):代表 TTS 生成的音频数据模型。
flowchart LR
    subgraph sg_Record["录音端"]
        Rec["录音采集"]
        AudioCodec["AudioCodec 编码"]
    end
    subgraph sg_Cloud["云端"]
        TTS["豆包 TTS 服务"]
    end
    subgraph sg_Play["播放端"]
        AudioPlayer["AudioPlayer 播放"]
        AudioModel["Audio 模型"]
    end
    Rec -->|"PCM"| AudioCodec
    AudioCodec -->|"编码上传"| TTS
    TTS -->|"合成结果"| AudioModel
    AudioModel -->|"解码"| AudioPlayer

该链路的文件与录音共用同一套 PCM/WAV 约定,编码与播放组件均可独立替换,体现了「录音产物标准化」的设计原则:只要中间产物是标准 PCM/WAV,编码器与播放器就无需感知采集细节。

使用示例

示例 1:分离立体声 PCM 声道(默认 16bit)

从录音/采集模块拿到立体声 PCM 后,使用 PcmKit 分离左右声道,便于后续按单声道处理(如语音识别、翻译):

// 假设 stereoPcm 为采集到的立体声 PCM 数据(PCM_16Bit)
byte[] stereoPcm = getRecordedStereoPcm();
byte[][] channelData = PcmKit.splitStereoPcmData(stereoPcm);
byte[] leftChannel = channelData[0];   // 左声道
byte[] rightChannel = channelData[1];  // 右声道

来源:PcmKit.java

示例 2:指定位深分离声道

若采集数据不是标准 16bit(例如 8bit 或 32bit 浮点),传入对应位深参数。位深即单个采样点的字节数:

// 按 2 字节(16bit)位深分离
byte[][] channels = PcmKit.splitStereoPcmData(pcmData, 2);
// 按 4 字节(32bit)位深分离
byte[][] floatChannels = PcmKit.splitStereoPcmData(floatPcmData, 4);

来源:PcmKit.java

示例 3:PCM 转 WAV

录音完成后,将临时 PCM 文件转换为可播放/可分享的 WAV 文件:

try {
    AppUtil.savePcmToWav(recordPcmPath, recordWavPath);
} catch (IOException e) {
    // 输入文件不存在或输出路径不可写时抛出
    handleConvertError(e);
}

来源:AppUtil.java

示例 4:录音文件命名

按 TranslateUtil 的约定区分原始 PCM 与 WAV 产物:

String pcmFile = recordDir + File.separator + "record_" + timestamp + TranslateUtil.PCM_SUFFIX;
String wavFile = recordDir + File.separator + "record_" + timestamp + TranslateUtil.WAV_SUFFIX;

来源:TranslateUtil.java

配置选项

录音与语音控制链路中不涉及独立的配置文件;相关行为由代码常量与文件后缀约定确定:

选项类型默认值说明
PCM 编码位深intAudioFormat.ENCODING_PCM_16BIT(2 字节)PcmKit.splitStereoPcmData 默认位深
WAV 采样率int16000 HzAppUtil.savePcmToWav 固定写入的采样率
WAV 声道数int1(单声道)WAV 头中 channel 字段
WAV 位深int16 bitWAV 头中 bitPerSample 字段
WAV 头长度int44 字节标准 PCM WAV 头,无扩展信息
PCM 文件后缀String.pcmTranslateUtil.PCM_SUFFIX
WAV 文件后缀String.wavTranslateUtil.WAV_SUFFIX
语音控制音量动作int0x01 增大 / 0x02 减小PlayControl.ACTION_UP_VOLUME / ACTION_DOWN_VOLUME

API 参考

PcmKit.splitStereoPcmData(byte[] pcmData): byte[][]

分离立体声 PCM 数据,默认按 PCM_16BIT(2 字节/采样)位深。

参数:

  • pcmData(byte[]):立体声 PCM 数据

返回: 固定长度为 2 的 byte[][],[0] 为左声道,[1] 为右声道;输入非法时返回两个空数组。

PcmKit.splitStereoPcmData(byte[] pcmData, int bitRate): byte[][]

按指定位深分离立体声 PCM 数据。

参数:

  • pcmData(byte[]):立体声 PCM 数据
  • bitRate(int):编码深度(单个采样点字节数)

返回: 固定长度为 2 的 byte[][],[0] 为左声道,[1] 为右声道。

边界行为:

  • pcmData == null 或 pcmData.length < bitRate * 2 时返回 new byte[2][0];
  • 不足一帧(bitRate * 2 字节)的尾部数据被丢弃。

AppUtil.savePcmToWav(String input, String output): void

将 PCM 文件转换为 16kHz / 单声道 / 16bit 的 WAV 文件。

参数:

  • input(String):输入 PCM 文件路径
  • output(String):输出 WAV 文件路径

Throws:

  • IOException:输入文件不存在、不可读,或输出文件不可写时抛出

注意: 该方法固定以 16000Hz、单声道、16bit 写入 WAV 头,转换前应确保 PCM 数据与该规格一致(必要时先用 PcmKit 分离/抽取单声道数据)。

失败模式、边界情况与并发

失败模式

失败场景表现处理方式
PCM 文件不存在/不可读savePcmToWav 构造 FileInputStream 失败抛出 IOException,由调用方捕获处理
输出路径不可写FileOutputStream 构造失败抛出 IOException,调用方需保证目录存在且可写
输入的 PCM 与固定规格不符(非 16kHz/单声道/16bit)WAV 播放速率/时长异常需调用方先通过 PcmKit 处理为规格匹配的数据
PCM 数据为空或过短splitStereoPcmData 返回两个空数组调用方对空数组做后续判断,避免 NPE
立体声数据长度不为帧对齐尾部长不足一帧被 while (remaining >= buf.length) 安全丢弃

边界情况

  • 帧对齐:splitStereoPcmData 只处理完整帧;例如 7 字节的 16bit 立体声数据只能拆出 1 帧(4 字节),剩余 3 字节被丢弃——这是交错 PCM 拆分的标准行为;
  • 零数据输入:new byte[0] 会直接命中 length < bitRate * 2 分支,返回空数组而非抛异常;
  • 大位深:bitRate 由调用方传入,若传入非法值(如 0 或负数),ByteBuffer/copyOfRange 会抛出 IllegalArgumentException——源码未对 bitRate 做显式校验,调用方需保证位深合法(实现细节未在源码中显式防护)。

并发与线程

  • PcmKit 与 AppUtil.savePcmToWav 均为无状态静态方法:不持有实例字段,线程安全,可安全地在后台线程/Executor/HandlerThread 中并发调用;
  • savePcmToWav 内部仅使用局部变量与独立文件流,不同输入/输出文件之间无共享可变状态,天然支持并行转换;
  • 由于方法签名是同步的(非 suspend/异步),在主线程调用大文件转换可能造成 UI 卡顿,建议在后台线程执行。

性能与运维注意事项

  • 纯内存处理:splitStereoPcmData 全程使用 ByteArrayOutputStream/ByteBuffer,无磁盘 IO,性能主要受输入数据量影响;对秒级语音片段开销可忽略;
  • 文件 IO:savePcmToWav 使用 FileInputStream.available() 估算文件大小,注意 available() 对流长度并不总是可靠(对管道/压缩流可能不准确),但在本地普通文件场景下可用;fis.available() 会读取整个文件长度用于计算 RIFF size 字段;
  • WAV 体积:16kHz/16bit/单声道 = 32000 字节/秒(31.25 KB/s),长录音会线性增长,建议按业务需要分段保存;
  • 语音控制:PlayControl 的 ACTION_UP_VOLUME/ACTION_DOWN_VOLUME 为常量动作码,语音指令解析后直接映射,无额外延迟开销。

扩展点

  • 新 PCM 处理能力:可在 PcmKit 中追加静态方法(如重采样、音量归一化、降噪),保持「无状态工具类」风格,与现有方法一致;
  • WAV 规格可变:savePcmToWav 当前固定 16kHz/单声道/16bit,若需支持其他规格(如 48kHz 立体声、24bit),可仿照现有逐字节写头逻辑扩展参数;
  • 录音消费方:翻译模块的 AudioCodec/AudioPlayer 以标准 PCM/WAV 为契约,新增录音场景只需产出标准格式文件即可复用编码与播放链路;
  • 语音指令扩展:PlayControl 已示范「语音控制」动作码模式,新增语音指令(如静音、切歌)可按同一模式添加动作常量并复用播放控制逻辑。

测试

仓库中未发现针对 PcmKit/savePcmToWav 的独立单元测试文件(搜索 record/PCM 相关测试未命中),录音逻辑的正确性主要依赖业务场景集成验证。测试线索:btsmart/src/test 下的 demo 工程覆盖蓝牙链路(如 LEAudioDemo、Ble 相关 demo),可作为音频功能集成测试的参照入口。

Related Links

  • PcmKit.java(PCM 工具类)
  • AppUtil.java(savePcmToWav)
  • TranslateUtil.java(PCM/WAV 后缀约定)
  • PlayControl.java(语音控制音量)
  • AudioCodec.java(翻译录音编码)
  • AudioPlayer.java(音频播放)
  • 相关页面:播放控制(PlayControl 全量动作)、AI 翻译(豆包 TTS 与翻译链路)、蓝牙音频传输(SDK 音频流)
Prev
均衡器音效调节
Next
Line-in、SPDIF与声卡功能