1. 从一次线上会议录音需求说起
前阵子帮一个做在线教育的朋友处理一个需求:他们的课程平台需要增加一个"课堂录音"功能,老师端点击开始录音,讲完课后自动生成一个 wav 文件上传到服务器存档。听起来很简单对吧?我一开始也这么觉得,结果真正动手才发现,Java 原生音频 API 的坑比想象中多得多——采样率不匹配导致声音变调、缓冲区大小设置不当导致录音断续、wav 文件头写错导致播放器打不开,这些问题我几乎挨个踩了一遍。
这篇文章就把我用 Java 实现录音并保存为 wav 格式的完整过程拆开来讲。核心关键词就三个:Java 音频采集、wav 文件格式、音频流处理。适合两类人看:一是刚接触 Java 音频编程、需要快速跑通一个录音功能的开发者;二是已经能录出声音、但文件格式或音质总出问题的朋友。我会从底层原理讲到可直接复制的代码,再把我踩过的坑和排查思路完整呈现出来,尽量让你少走弯路。
先说结论:Java 实现录音保存 wav,核心依赖javax.sound.sampled包里的几个类,不需要任何第三方库就能完成。但"能跑"和"跑得好"之间,差的是对音频参数和 wav 文件结构的理解。下面我按实际开发的思路一步步展开。
2. Java 声音采集的底层逻辑与核心类分工
2.1 为什么是 javax.sound.sampled 而不是别的方案
Java 处理音频有两套 API:一套是javax.sound.sampled,属于 Java SE 标准库,专门做音频采集和播放;另一套是 JavaFX 里的javafx.scene.media,偏向媒体播放。录音这件事,标准库的 sampled 包就是正解,因为它直接对接操作系统的音频输入设备,能拿到原始的 PCM 数据流。
PCM 是什么?你可以把它理解成音频的"原材料"——一串按时间顺序排列的数字,每个数字代表某一瞬间声波的振幅。wav 格式本质上就是"PCM 数据 + 一个描述这些数据的文件头"。所以整个流程可以拆成两步:先用 sampled 包把 PCM 数据采集到内存,再按照 wav 规范把文件头和数据拼起来写进磁盘。
这里有个关键认知:Java 不会帮你自动生成 wav 文件头。很多人以为调个 API 就能直接录出 wav,实际上TargetDataLine给你的只是一堆裸的字节数据,文件头得自己写。这是新手最容易卡住的地方。
2.2 四个核心类的职责划分
整个录音流程涉及四个类,我按数据流动的顺序说清楚它们各自干什么:
- AudioFormat:描述音频的"规格参数",包括采样率、采样位数、声道数、是否签名、字节序。它决定了你录出来的声音是什么品质,也决定了文件头里要写什么。
- DataLine.Info:一个"能力描述器",用来告诉系统"我需要一条满足某种 AudioFormat 的输入线路"。它是你向系统申请资源的凭证。
- TargetDataLine:真正干活的录音线路。你从它里面读字节,读到的就是实时的 PCM 数据。可以理解为一根连着麦克风的水管,你不断从管口接水。
- AudioSystem:工厂类,负责根据 DataLine.Info 去操作系统里找到匹配的线路并交给你。
数据流向是这样的:麦克风 → TargetDataLine(PCM 字节流)→ 你的缓冲区 → 写入文件(前面补上 wav 头)。理解这条链路,后面调参数就不会盲目。
2.3 AudioFormat 参数怎么定才不出错
AudioFormat 的构造函数签名是AudioFormat(float sampleRate, int sampleSizeInBits, int channels, boolean signed, boolean bigEndian)。五个参数,每个都有讲究,我逐个说:
采样率(sampleRate):每秒采集多少个点。常见值 8000(电话音质)、16000(语音识别常用)、44100(CD 音质)、48000(专业音频)。录人声做存档,16000 或 44100 都够用。采样率越高文件越大,但音质越好。注意:不是所有声卡都支持任意采样率,设了个设备不支持的值得不到线路。
采样位数(sampleSizeInBits):每个采样点用多少位表示。8 位或 16 位。强烈建议用 16 位,因为 8 位音质差且很多设备默认不支持,16 位是事实标准。
声道数(channels):1 是单声道,2 是立体声。录音存档用单声道就够了,文件小一半,而且大多数麦克风本身就是单声道采集。
signed:采样值是否有符号。16 位 PCM 标准是有符号的,填 true。
bigEndian:字节序。wav 格式规定用小端序(little-endian),所以填 false。这个参数填错,录出来的声音会变成刺耳的噪音,因为字节被反着解释了。
我常用的一个稳妥配置是:new AudioFormat(44100, 16, 1, true, false)。44100 采样率兼容性极好,16 位单声道,小端序,几乎所有设备都支持。
提示:如果你不确定设备支持哪些格式,可以用
AudioSystem.isLineSupported(info)先判断,避免直接 open 时抛异常。
3. 从零写出可用的录音程序
3.1 环境准备与最小依赖
这个功能不需要任何第三方依赖,JDK 8 及以上都能跑。你只需要确认一件事:运行环境有可用的录音设备。在 Windows 上通常是"麦克风",在 Linux 服务器上可能是ALSA或PulseAudio提供的虚拟设备。如果是无头服务器(没有声卡),录音会直接失败,这点后面排查章节会细说。
开发时建议在本地有麦克风的机器上先跑通,再考虑部署到服务器。服务器录音往往需要额外的虚拟音频设备配置,那是另一个话题。
3.2 录音主流程的代码骨架
先给一个能直接跑的最小实现,我把它拆成"打开线路—循环读取—写入文件"三段来看:
import javax.sound.sampled.*; import java.io.*; public class WavRecorder { private TargetDataLine line; private AudioFormat format; private volatile boolean recording = false; public void start(String outputPath) throws LineUnavailableException { format = new AudioFormat(44100, 16, 1, true, false); DataLine.Info info = new DataLine.Info(TargetDataLine.class, format); if (!AudioSystem.isLineSupported(info)) { throw new IllegalStateException("当前设备不支持该音频格式"); } line = (TargetDataLine) AudioSystem.getLine(info); line.open(format); line.start(); recording = true; // 录音线程:持续读取并写入文件 new Thread(() -> writeToFile(outputPath)).start(); } private void writeToFile(String outputPath) { File file = new File(outputPath); try (AudioInputStream ais = new AudioInputStream(line); AudioSystem.write(ais, AudioFileFormat.Type.WAVE, file)) { // AudioSystem.write 会阻塞直到流关闭 } catch (IOException e) { e.printStackTrace(); } } public void stop() { recording = false; if (line != null) { line.stop(); line.close(); } } }这段代码有个巧妙之处:new AudioInputStream(line)把 TargetDataLine 包装成了一个输入流,然后AudioSystem.write(..., AudioFileFormat.Type.WAVE, file)会自动帮你写好 wav 文件头。这是最省事的写法,因为 Java 内置了 wav 编码器,文件头它替你处理了。
但这里有个坑:AudioSystem.write是阻塞的,它会一直读到流结束(也就是 line 关闭)才返回。所以必须放在独立线程里,否则主线程会被卡死,你连停止按钮都点不了。
3.3 手动写 wav 文件头:理解每一段字节
虽然上面的写法能自动生成文件头,但我强烈建议你至少手动实现一次,因为理解了文件头结构,排查"文件打不开""播放器报格式错误"这类问题才有底气。
wav 文件头固定 44 字节,结构如下:
| 偏移量 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 0 | 4 | "RIFF" | 固定标识 |
| 4 | 4 | 文件总长度-8 | 小端序整数 |
| 8 | 4 | "WAVE" | 固定标识 |
| 12 | 4 | "fmt " | 格式块标识,注意末尾空格 |
| 16 | 4 | 16 | fmt 块长度,PCM 固定为 16 |
| 20 | 2 | 1 | 音频格式,1 表示 PCM |
| 22 | 2 | 声道数 | 1 或 2 |
| 24 | 4 | 采样率 | 如 44100 |
| 28 | 4 | 字节率 | 采样率×声道数×位深/8 |
| 32 | 2 | 块对齐 | 声道数×位深/8 |
| 34 | 2 | 位深 | 如 16 |
| 36 | 4 | "data" | 数据块标识 |
| 40 | 4 | 数据长度 | PCM 字节数 |
| 44 | ... | PCM 数据 | 实际音频数据 |
手动写的时候,最容易错的是字节序。wav 规定所有多字节整数用小端序,而 Java 的DataOutputStream.writeInt()默认写的是大端序。所以要么用writeInt(Integer.reverseBytes(value)),要么干脆用ByteBuffer指定LITTLE_ENDIAN。我见过太多人因为这个问题,录出来的文件播放器直接报"格式不支持"。
还有一个细节:偏移 4 处的"文件总长度-8",这个值要等所有数据写完才能回填。所以手动写头的正确姿势是:先写 44 字节占位,写完 PCM 数据后,用RandomAccessFile回到开头把真实长度填进去。
3.4 缓冲区大小与录音流畅度的关系
line.read(byte[] buffer, int offset, int length)里的 buffer 大小直接影响录音的稳定性。太小会导致频繁的系统调用,可能丢帧;太大则增加延迟,停止录音时要等缓冲区读完。
经验值:缓冲区设为采样率的 1/10 左右。比如 44100 采样率、16 位单声道,每秒数据量是 44100×2 = 88200 字节,缓冲区设 8192 字节比较合适,大约对应 90 毫秒的音频。这个大小既能保证读取效率,又不会造成明显延迟。
如果你发现录音有"咔咔"的断续声,八成是缓冲区太小或者读取线程被阻塞了。可以适当加大缓冲区,同时确保读取循环里不要做耗时操作(比如直接在里面写磁盘,应该先读到内存再异步落盘)。
4. 那些让我熬夜的坑与排查链路
4.1 录出来的声音变调、像快进或慢放
这是最典型的问题,根因几乎都是采样率不匹配。具体分两种情况:
第一种,你声明录音格式用 44100,但实际播放时播放器按 16000 解释,声音就会变慢变低沉。反过来则变快变尖。排查方法:用音频工具查看文件的真实采样率,和你代码里设置的是否一致。
第二种更隐蔽:某些声卡驱动会"偷偷"用硬件支持的采样率采集,然后重采样成你要求的格式。如果驱动重采样做得不好,音质会受损。这种情况可以尝试换一个设备原生支持的采样率,比如从 44100 换成 48000。
我的排查习惯是:先用系统自带的录音机录一段,用工具看它的格式参数,然后代码里用完全相同的参数,这样能排除大部分格式问题。
4.2 文件能生成但播放器打不开
这个问题的排查链路我完整走一遍,你可以照着复现:
第一步,确认文件大小。如果文件只有 44 字节,说明只有文件头没有数据,问题出在录音线路没读到数据。检查line.start()是否调用、麦克风是否被静音、是否有其他程序占用了录音设备。
第二步,用十六进制工具看文件头。打开文件前 44 字节,对照上面的表格逐字段核对。重点看偏移 0 是不是 "RIFF"、偏移 8 是不是 "WAVE"、偏移 20 的音频格式是不是 1。如果 "RIFF" 变成了别的,说明文件头根本没写对。
第三步,检查长度字段。偏移 4 和偏移 40 的两个长度值,如果明显不对(比如是 0 或者负数),播放器就无法确定数据边界,直接拒绝打开。手动写头时忘记回填长度,就会这样。
第四步,检查字节序。如果长度字段的值看起来"大得离谱",比如本该是 88200 却显示成 1448033280,那就是字节序反了。这是大端小端搞混的典型症状。
走完这四步,基本能定位到 90% 的"文件打不开"问题。
4.3 服务器上录音直接抛 LineUnavailableException
本地跑得好好的,一部署到服务器就报LineUnavailableException,这是无头服务器的经典问题。原因很简单:服务器通常没有物理声卡,Java 找不到任何录音设备。
解决思路有两条:一是给服务器配置虚拟音频设备(Linux 下常用 ALSA 的 null 设备或 loopback 设备),让系统"看起来"有录音设备;二是改变架构,录音在客户端完成,服务器只负责接收上传的文件。对于在线教育这类场景,第二种方案其实更合理,因为录音本来就该在老师本地做。
如果你确实需要在服务器端处理音频,建议用专门的音频处理库,它们对无头环境的支持更好。但纯录音这个需求,客户端方案永远是首选。
注意:排查
LineUnavailableException时,先看异常信息里有没有"device not found"之类的提示,再确认运行用户是否有访问音频设备的权限。Linux 下音频设备通常属于 audio 用户组,权限不对也会导致线路打不开。
5. 让录音功能更靠谱的几个进阶处理
5.1 用状态标志优雅地停止录音
前面代码里我用了volatile boolean recording标志,这不是多余的。录音线程在AudioSystem.write里阻塞着,你没法直接中断它。正确的停止流程是:先把标志置为 false,然后调用line.stop()和line.close(),这时输入流读到末尾,AudioSystem.write自然返回,文件正常收尾。
如果直接line.close()而不做任何协调,可能出现数据还没写完文件就被关闭的情况,导致文件尾部数据丢失。所以停止逻辑要保证"先停采集,再等写入完成"。
5.2 录音时长与文件体积的预估
做存档功能时,产品经理常问"录一小时多大文件"。计算很简单:
文件体积(字节)≈ 采样率 × 位深/8 × 声道数 × 秒数 + 44
以 44100 采样率、16 位、单声道为例,每秒约 88.2KB,一小时约 310MB。如果换成 16000 采样率,一小时只要约 112MB。所以如果只是语音存档、不需要高保真,用 16000 采样率能省一大半存储和带宽。这个账在方案设计阶段就要算清楚,别等上线了才发现存储成本超预期。
5.3 录音权限与设备占用的处理
有两个运行时问题很常见。一是设备被占用:如果微信、会议软件正在用麦克风,你的程序可能拿不到线路。这时AudioSystem.getLine会抛异常,要做好捕获和友好提示。二是权限问题:部分系统需要用户授权应用访问麦克风,首次运行时可能失败,重试或引导用户授权即可。
我的做法是在打开线路前先做一次isLineSupported检查,失败时给出明确提示"未检测到可用录音设备或设备被占用",而不是让用户面对一个看不懂的异常堆栈。
5.4 从 wav 到其他格式的扩展思路
wav 是无损未压缩格式,体积大。如果后续要节省空间,可以在录完后转成压缩格式。Java 标准库对 mp3 支持有限,通常需要引入第三方库。但我的建议是:录音阶段坚持用 wav,因为它是无损的、格式简单的、任何工具都能处理的"中间格式"。需要压缩时再离线转码,这样原始素材永远保留着,不会因为一次有损压缩就丢失细节。
这个思路在音频处理里很通用:采集用无损,分发用压缩。理解了这一点,你的音频方案设计就有了主心骨。
最后分享一个我自己的小习惯:每次写完录音功能,我都会用至少三个不同的播放器(系统自带、浏览器、专业音频软件)各播一遍,确认兼容性。因为不同播放器对 wav 文件头的容错程度不一样,能过三个播放器的文件,基本就稳了。这个笨办法帮我提前发现过好几次文件头字段的小错误,比事后被用户投诉强得多。