音頻波形圖是音頻編輯、語音識別、聲紋分析等應用的基礎視覺化組件。本文提供完整的 WAV 文件解析器、Swing 波形繪製面板,並介紹開源項目 WaveAccess 的架構設計。
WAV 遵循 RIFF 容器格式,核心 Chunk 結構:
RIFF Chunk
├── ChunkID: "RIFF" (4 bytes)
├── ChunkSize: 文件大小 - 8
├── Format: "WAVE" (4 bytes)
├── fmt Sub-chunk
│ ├── Subchunk1ID: "fmt " (4 bytes)
│ ├── Subchunk1Size: 16 (PCM)
│ ├── AudioFormat: 1 (PCM)
│ ├── NumChannels: 1/2 (單/雙聲道)
│ ├── SampleRate: 采樣率 (如 44100)
│ ├── ByteRate: SampleRate × NumChannels × BitsPerSample/8
│ ├── BlockAlign: NumChannels × BitsPerSample/8
│ └── BitsPerSample: 8/16/24/32
└── data Sub-chunk
├── Subchunk2ID: "data" (4 bytes)
├── Subchunk2Size: 音頻數據大小
└── Audio Data: 采樣點數據
import java.io.*;
public class WaveFileReader {
// WAV 頭部字段
private String chunkID; // "RIFF"
private long chunkSize;
private String format; // "WAVE"
private String subchunk1ID; // "fmt "
private long subchunk1Size;
private int audioFormat; // 1=PCM
private int numChannels; // 聲道數
private long sampleRate; // 采樣率
private long byteRate;
private int blockAlign;
private int bitsPerSample; // 8 或 16
private String subchunk2ID; // "data"
private long subchunk2Size;
// 解析後的音頻數據 [channel][sampleIndex]
private int[][] data;
private int dataLength; // 每聲道采樣點數
private boolean success = false;
public WaveFileReader(String filename) {
parseFile(filename);
}
private void parseFile(String filename) {
try (FileInputStream fis = new FileInputStream(filename);
BufferedInputStream bis = new BufferedInputStream(fis)) {
// 讀取並驗證 RIFF/WAVE 標識
chunkID = readString(bis, 4);
if (!"RIFF".equals(chunkID)) throw new IllegalArgumentException("非 RIFF 文件");
chunkSize = readLong(bis);
format = readString(bis, 4);
if (!"WAVE".equals(format)) throw new IllegalArgumentException("非 WAVE 文件");
// 讀取 fmt 子塊
subchunk1ID = readString(bis, 4);
if (!"fmt ".equals(subchunk1ID)) throw new IllegalArgumentException("缺少 fmt 子塊");
subchunk1Size = readLong(bis);
audioFormat = readInt(bis);
numChannels = readInt(bis);
sampleRate = readLong(bis);
byteRate = readLong(bis);
blockAlign = readInt(bis);
bitsPerSample = readInt(bis);
// 讀取 data 子塊
subchunk2ID = readString(bis, 4);
if (!"data".equals(subchunk2ID)) throw new IllegalArgumentException("缺少 data 子塊");
subchunk2Size = readLong(bis);
// 計算采樣點數
dataLength = (int) (subchunk2Size / (bitsPerSample / 8) / numChannels);
data = new int[numChannels][dataLength];
// 讀取音頻數據(小端序)
for (int i = 0; i < dataLength; i++) {
for (int ch = 0; ch < numChannels; ch++) {
if (bitsPerSample == 8) {
data[ch][i] = bis.read() & 0xFF; // 無符號 8-bit
} else if (bitsPerSample == 16) {
data[ch][i] = readInt(bis); // 有符號 16-bit 小端序
}
}
}
success = true;
} catch (Exception e) {
e.printStackTrace();
success = false;
}
}
// --- 低級讀取輔助方法 (小端序) ---
private String readString(BufferedInputStream bis, int len) throws IOException {
byte[] buf = new byte[len];
if (bis.read(buf) != len) throw new IOException("文件截斷");
return new String(buf);
}
private int readInt(BufferedInputStream bis) throws IOException {
byte[] buf = new byte[2];
if (bis.read(buf) != 2) throw new IOException("文件截斷");
return (buf[0] & 0xFF) | ((buf[1] & 0xFF) << 8); // 小端序
}
private long readLong(BufferedInputStream bis) throws IOException {
long val = 0;
for (int i = 0; i < 4; i++) {
int b = bis.read();
if (b == -1) throw new IOException("文件截斷");
val |= ((long) b & 0xFF) << (i * 8);
}
return val;
}
// --- Getter 方法 ---
public boolean isSuccess() { return success; }
public int getBitsPerSample() { return bitsPerSample; }
public long getSampleRate() { return sampleRate; }
public int getNumChannels() { return numChannels; }
public int getDataLength() { return dataLength; }
public int[][] getData() { return data; }
}
import java.awt.*;
import javax.swing.JPanel;
@SuppressWarnings("serial")
public class DrawPanel extends JPanel {
private final int[] data; // 單聲道數據
public DrawPanel(int[] data) {
this.data = data;
setBackground(Color.WHITE);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (data == null || data.length == 0) return;
int width = getWidth();
int height = getHeight();
int len = data.length;
// 縮放比例:水平壓縮以適應面板寬度
int step = Math.max(1, len / width);
// 垂直縮放:16-bit 有符號範圍 -32768~32767 映射到面板高度
double scaleY = (height / 2.0) / 32768.0;
int centerY = height / 2;
g.setColor(Color.RED);
int prevX = 0;
int prevY = centerY - (int)(data[0] * scaleY);
for (int i = 0, x = 0; i < len; i += step, x++) {
int y = centerY - (int)(data[i] * scaleY);
g.drawLine(prevX, prevY, x, y);
prevX = x;
prevY = y;
}
}
}
import javax.swing.*;
public class WaveFormDemo {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
String filename = args.length > 0 ? args[0] : "test.wav";
WaveFileReader reader = new WaveFileReader(filename);
if (!reader.isSuccess()) {
JOptionPane.showMessageDialog(null, "無效的 WAV 文件: " + filename);
return;
}
// 取第一聲道繪製
int[] channelData = reader.getData()[0];
JFrame frame = new JFrame("波形圖: " + filename);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.add(new DrawPanel(channelData));
frame.setSize(1000, 500);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
作者已將完整代碼開源至 GitHub:https://github.com/sintrb/WaveAccess/
| 功能 | 技術點 | 建議庫 |
|---|---|---|
| MP3/AAC 解碼 | 需原生解碼器 | JLayer, JAVE2 (FFmpeg 包裝) |
| 實時波形/頻譜 | FFT、重採樣、雙緩衝 | TarsosDSP, JAudioLibs |
| 大文件流式繪製 | 虛擬化渲染、LOD | 自定義 Canvas + 區塊加載 |
| Web 端波形 | Web Audio API + Canvas | wavesurfer.js, waveform.js |
sampleRate 讀取正確,播放時匹配bitsPerSample、聲道數、字節序處理audioFormat != 1 並解碼