录音与语音控制
本文档介绍 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 应用中,录音能力被多个场景复用:
- 翻译场景:用户按住说话采集 PCM 音频,经
AudioCodec编码后上传云端翻译,翻译结果经 TTS 返回(AudioPlayer播放); - 本地处理场景:采集到的立体声 PCM 需要分离左右声道(
PcmKit.splitStereoPcmData),或以 16kHz 单声道 16bit 的规格封装为 WAV 文件(AppUtil.savePcmToWav); - 语音控制场景:设备端通过语音指令控制播放音量(
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提供与 AndroidAudioFormat常量对齐的位深处理(默认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-3 | 4 | RIFF 标志 | RIFF |
| 4-7 | 4 | 文件总大小 | PCM 字节数 + 36 |
| 8-11 | 4 | WAVE 标志 | WAVE |
| 12-15 | 4 | fmt 块标志 | fmt |
| 16-19 | 4 | fmt 块长度 | 16(无附加信息) |
| 20-21 | 2 | 音频格式 | 1(PCM) |
| 22-23 | 2 | 声道数 | 1(单声道) |
| 24-27 | 4 | 采样率 | 16000 Hz |
| 28-31 | 4 | 字节率 | 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";
PCM 后缀用于原始采集数据(体积小、无头信息、便于直接编码上传),WAV 后缀用于需要播放/分享的封装产物。这一约定让录音工具(如 AudioCodec)与播放器(AudioPlayer)无需关心文件来源,仅凭扩展名即可区分处理路径。
4. 语音控制:PlayControl
语音控制能力体现在 PlayControl 播放控制器中:音量调节动作被明确标注为支持语音控制触发。
/**
* 增大音量,,语音控制
*/
public static final int ACTION_UP_VOLUME = 0x01;
/**
* 减小音量 ,语音控制
*/
public static final int ACTION_DOWN_VOLUME = 0x02;
设计意图: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: 播放 / 音量控制(语音指令)
流程要点
- 采集:音频源产生原始 PCM 数据,进入录音模块;
- 处理:
PcmKit.splitStereoPcmData按帧(bitRate * 2字节)拆分左右声道,丢弃不足一帧的尾部数据; - 封装:
AppUtil.savePcmToWav按 16kHz/单声道/16bit 规格生成 WAV 头,将 PCM 载荷写入输出文件; - 编码/存储:翻译场景由
AudioCodec对录音编码,文件命名遵循TranslateUtil的.pcm/.wav后缀约定; - 消费:
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;
配置选项
录音与语音控制链路中不涉及独立的配置文件;相关行为由代码常量与文件后缀约定确定:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| PCM 编码位深 | int | AudioFormat.ENCODING_PCM_16BIT(2 字节) | PcmKit.splitStereoPcmData 默认位深 |
| WAV 采样率 | int | 16000 Hz | AppUtil.savePcmToWav 固定写入的采样率 |
| WAV 声道数 | int | 1(单声道) | WAV 头中 channel 字段 |
| WAV 位深 | int | 16 bit | WAV 头中 bitPerSample 字段 |
| WAV 头长度 | int | 44 字节 | 标准 PCM WAV 头,无扩展信息 |
| PCM 文件后缀 | String | .pcm | TranslateUtil.PCM_SUFFIX |
| WAV 文件后缀 | String | .wav | TranslateUtil.WAV_SUFFIX |
| 语音控制音量动作 | int | 0x01 增大 / 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 音频流)