本帖最后由 YY菌 于 2026-9-19 02:11 编辑
DLS(Downloadable Sounds)是 MIDI 时代遗留的一种采样音色库格式。它把一组音频样本、循环点、音高映射打包进一个 RIFF 容器里,供软波表合成器在运行时加载。相比 SF(Sound Font)来说,DLS 的文档较少、工具更稀缺,但它的结构其实比想象中更接近标准 WAV——这一点让提取音频数据变得出奇地简单。
这篇文章不讲合成,只讲如何从 DLS 文件里把音频数据抠出来。
一、DLS 文件的整体结构
DLS 文件是一个标准的 RIFF 文件,最外层:
RIFF [] 'DLS ' [dlid, colh, INSTLIST, WAVEPOOL, INFOLIST]
注意 'DLS ' 末尾有个空格,是 4 个字节。
这次我们只做提取音频,所以关心的只有WAVEPOOL(波形池),而WAVEPOOL中只有两个顶级子块:
块名 作用
ptbl 音色指针表(Pool Table),告诉我们每个音色对应波形池中的哪个偏移
wvpl 波形池(Wave Pool),LIST 块,里面是一个个波形
ptbl 和 wvpl 的关系是:ptbl 只存偏移量,实际的波形数据全在 wvpl 里。这是 DLS 的关键设计——音色表与波形池分离,同一个波形可以被多个音色共享。
二、PTBL:音色指针表
ptbl 的内容是一个 POOLTABLE 结构,定义在 dls1.h 中:
- typedef struct _POOLCUE {
- ULONG ulOffset; /* Offset to the entry in the list */
- } POOLCUE, FAR *LPPOOLCUE;
- typedef struct _POOLTABLE {
- ULONG cbSize; /* size of the pool table structure */
- ULONG cCues; /* count of cues in the list */
- } POOLTABLE, FAR *LPPOOLTABLE;
复制代码
POOLCUE 是变长数组的元素,POOLTABLE 头后面紧跟 cCues 个 POOLCUE。常用做法是定义一个 0 长度数组的扩展结构:
- typedef const struct POOLTABLEX : POOLTABLE {
- POOLCUE pcues[0];
- } FAR *LPCPTX;
复制代码
这样 pptx->pcues[idx].ulOffset 就能直接访问第 i 个音色的波形偏移。
不过需要注意两个容易踩的坑:
1.ulOffset 是相对于 wvpl 数据区的偏移,而不是文件绝对偏移。计算实际位置时,需要加上 wvpl 的 dwDataOffset(指向 type 字段),再跳过 'wvpl' 的 4 字节:
文件位置 = wvpl.dwDataOffset + sizeof(FOURCC) + pcues[idx].ulOffset
2.POOL_CUE_NULL 是空 cue,值为 0xffffffff。遍历 ptbl 时如果遇到它,应当跳过该 cue,而不是去 wvpl 里找不存在的波形:
#define POOL_CUE_NULL 0xffffffffl
三、WVPL:波形池
wvpl 是一个 LIST,它的 fccType 是 'wvpl',数据区是一串 LIST 块,每个都是一个 wave:
- LIST [] 'wvpl'
- LIST [] 'wave' [dlid, RIFFWAVE] ← 一个波形
- 'fmt ' <size> ...
- 'data' <size> ...
- 'wsmp' <size> ... (可选,循环点)
- LIST [] 'INFO'
- 'INAM' <size> ... (可选,名称)
- LIST [] 'wave' [dlid, RIFFWAVE] ← 下一个波形
- ...
复制代码
关键洞察来了:LIST 'wave' 的结构和标准 WAV 文件的 RIFF 'WAVE' 几乎一模一样。
标准 WAV:
- 'RIFF' <size> 'WAVE'
- 'fmt ' <size> ...
- 'data' <size> ...
复制代码
DLS 波形:
- 'LIST' <size> 'wave'
- 'fmt ' <size> ...
- 'data' <size> ...
复制代码
LIST 和 RIFF 的块头布局完全相同,都是 ckid + cksize + type。区别只有两处:
1.块标识:LIST vs RIFF
2.类型串:'wave'(小写)vs 'WAVE'(大写)
这就是为什么 DLS 提取可以做到几乎零成本——直接把 LIST wave 的内容原样搬出来,改个头部,就是一个完全合法的 WAV。
四、用 MMIO 解析
Windows 提供了 mmio* 系列 API 专门处理 RIFF 文件。它的好处是自动处理块的升降、查找、字节对齐、边界检查等。核心流程:
- MMCKINFO dls, cur;
- mmioDescend(hmmio, &dls, NULL, 0); // 进入 RIFF 顶层
- if (dls.fccType != FOURCC_DLS) return ERR;
- // 定位 PTBL 块
- ptbl.ckid = FOURCC_PTBL;
- mmioDescend(hmmio, &ptbl, &dls, MMIO_FINDCHUNK);
- // 离开 PTBL 块
- mmioAscend(hmmio, &ptbl, 0);
- // 定位 WVPL 块
- MMCKINFO wvpl;
- wvpl.fccType = FOURCC_WVPL;
- mmioDescend(hmmio, &wvpl, &dls, MMIO_FINDLIST);
复制代码
然后遍历每个波形:
- // 定位到第 idx 个波形
- mmioSeek(hmmio, wvpl.dwDataOffset + sizeof(FOURCC) + pptx->pcues[idx].ulOffset, SEEK_SET);
- mmioDescend(hmmio, &wave, &wvpl, 0);
- if (wave.fccType != FOURCC_wave) return ERR;
- // 遍历 wave 内的子块
- for (;;) {
- MMRESULT mmr = mmioDescend(hmmio, &cur, &wave, 0);
- if (mmr == MMIOERR_CHUNKNOTFOUND) break;
- switch (cur.ckid) {
- case FOURCC_fmt: pwfx = (WAVEFORMATEX*)(pData + cur.dwDataOffset); break;
- case FOURCC_data: pdat = pData + cur.dwDataOffset; size = cur.cksize; break;
- case FOURCC_WSMP: pwsx = (WSMPL*)(pData + cur.dwDataOffset); break;
- case FOURCC_LIST:
- if (cur.fccType == MAKEFOURCC('I','N','F','O')) {
- // 从 INAM 取名称
- }
- break;
- }
- mmioAscend(hmmio, &cur, 0);
- }
复制代码
整个过程中,pData 是内存映射视图的起始地址,所有指针都直接指向映射内存,没有任何数据拷贝。
五、导出为 WAV:一行的魔术
导出时,最省事的做法是:读 DLS 的 RIFF 头,取 wave 块的 cksize,把 'wave' 转成 'WAVE',然后从 wave 块的第一个子块开始原样复制内容。
- typedef struct RiffChunk {
- FOURCC ckid; // 'RIFF'
- DWORD cksize; // 复用 wave 块的 cksize
- FOURCC type; // 'WAVE'
- } RiffChunk;
- const RiffChunk *wave = (RiffChunk*)(pData + wvpl.dwDataOffset + sizeof(FOURCC) + pptx->pcues[idx].ulOffset);
- RiffChunk head = {
- *(DWORD*)pData, // 取 DLS 的 'RIFF'
- wave->cksize, // 大小直接复用
- wave->type & 0xDFDFDFDF, // 'wave' → 'WAVE'
- };
- WriteFile(hFile, &head, sizeof(head), ...);
- WriteFile(hFile, wave + 1, wave->cksize - sizeof(FOURCC), ...);
复制代码
这里几个技巧值得展开:
技巧一:wave->type & 0xDFDFDFDF
ASCII 里大写字母和小写字母只差一个 bit:'a' = 0x61,'A' = 0x41,差别在 bit 5。0xDF 的二进制是 11011111,正好把 bit 5 清零。四个字节同时处理,就把 'wave' 变成了 'WAVE',其它字符(比如已经是 'WAVE')不受影响。
技巧二:cksize 直接复用
RIFF 的 cksize 定义是「块大小减去 8」,也就是 type + 内容。两者完全一致,所以 wave->cksize 可以无缝搬到 WAV 头里。
技巧三:从 wave + 1 开始复制
wave 指针指向 LIST 头(12 字节)。wave + 1 跳过整个 RiffChunk 结构,正好落在第一个子块(通常是 'fmt ')上。复制长度是 cksize - sizeof(FOURCC),减去那 4 个字节的 'wave'。
坑点:DLS 特有的子块
LIST wave 里除了标准子块 fmt 和 data,还可能有 wsmp(循环点)、LIST INFO(名称)等 WAV 很少用的块。导出的 WAV 会带着它们。大多数播放器会忽略未知块,但严格来说这不是"干净"的 WAV。如果需要彻底干净,得显式重建 fmt 和 data 两个块,代价是多一次拷贝。
六、循环点:DLS 和 WAV 的分水岭
wsmp 块是 DLS 最独特的地方,它定义了采样循环。官方 dls1.h 中的定义如下:
- typedef struct _rwsmp {
- ULONG cbSize;
- USHORT usUnityNote; /* MIDI Unity Playback Note */
- SHORT sFineTune; /* Fine Tune in log tuning */
- LONG lAttenuation; /* Overall Attenuation to be applied to data */
- ULONG fulOptions; /* Flag options */
- ULONG cSampleLoops; /* Count of Sample loops, 0 loops is one shot */
- } WSMPL, FAR *LPWSMPL;
- typedef struct _rloop {
- ULONG cbSize;
- ULONG ulType; /* Loop Type */
- ULONG ulStart; /* Start of loop in samples */
- ULONG ulLength; /* Length of loop in samples */
- } WLOOP, FAR *LPWLOOP;
- /* 目前 Level 1 只定义了前向循环一种类型 */
- #define WLOOP_TYPE_FORWARD 0
复制代码
字段含义:
字段 含义
usUnityNote 原始音高(MIDI 音符号),决定采样回放时的基准音
sFineTune 对数音高微调
lAttenuation 整体衰减(单位是 0.1 dB 的十分之一?实际是 cB,即 1/100 dB)
fulOptions 选项标志,F_WSMP_NO_TRUNCATION / F_WSMP_NO_COMPRESSION
cSampleLoops 循环数量,0 表示一次性播放
ulType 循环类型,Level 1 只有 WLOOP_TYPE_FORWARD
ulStart 循环起点,单位是采样数,不是字节
ulLength 循环长度,单位是采样数,不是字节
用 0 长度数组扩展 WSMPL,就能把 WLOOP 数组拼在它后面:
- typedef const struct WSMPLX : WSMPL {
- WLOOP wloops[0];
- } FAR *LPCWSX;
复制代码
这样 wloops[idx].ulStart 才能正确落在 WSMPL 头之后的第 i 个 WLOOP 上。注意 ulStart / ulLength 的单位是采样数,转成字节要乘以 pwfx->nBlockAlign。
把 wave 原样导出成 WAV,这些信息会丢失——WAV 格式本身没有循环点概念(虽然 smpl 块可以承载,但 DLS 用的是 wsmp,不是 smpl)。
如果只是"提取音频数据"听听效果,忽略循环点没问题。但如果你想在播放器里保留循环行为,就得:
手动构建 smpl 块并附加到 WAV 末尾,或者在应用层读取 wsmp 并自行实现循环播放。
我自己的 DLS 查看器就选择了第二条路:waveOut 播放时,把波形拆成三段缓冲区——前置 + 循环体 + 后置,循环体用近似无限循环,前置播放完自动进入循环,用户切换音色时再打破循环让后置段收尾。这样既保留了 DLS 的循环语义,又不用修改 WAV 文件。
七、几个实现细节
内存映射 + MMIO 内存流
解析时用 CreateFileMapping + MapViewOfFile 把整个文件映射到内存,然后构造一个 MMIOINFO,fccIOProc 设为 FOURCC_MEM,pchBuffer 指向映射视图,调用 mmioOpen 得到一个基于内存的 HMMIO。这样解析全程零拷贝:所有 pwfx、pdat、name 指针都直接指向映射内存。
偏移量是相对 WVPL 数据区的
pcues[idx].ulOffset 不是文件绝对偏移,是相对于 wvpl 数据区(不含 LIST 头,也不含 type)的偏移。计算实际位置时别忘了两个额外字节数:
文件位置 = wvpl.dwDataOffset + sizeof(FOURCC) + pcues[idx].ulOffset
↑ 'wvpl' 类型的 4 字节
MMIO 的 dwDataOffset 指向 type 字段
mmioDescend 填充 MMCKINFO 后,dwDataOffset 指向的是块数据区的起点,但对于 LIST 块,这个"起点"是 type 字段,而不是第一个子块。这就是为什么遍历 wvpl 时要加上 sizeof(FOURCC) 跳过 'wvpl'。
WAVEFORMATEX 的大小可能不固定
fmt 块的内容可能是 WAVEFORMAT、PCMWAVEFORMAT、WAVEFORMATEX 或带 cbSize 扩展的版本。直接用 (WAVEFORMATEX*) 转换后读 nBlockAlign、nSamplesPerSec 等基本字段是安全的,因为这些字段布局固定。但要访问 cbSize 之后的扩展数据(如 WAVEFORMATEXTENSIBLE 的通道掩码),需要自己检查。
八、总结
DLS 解析的核心洞察只有一句话:DLS 里的每个波形,本质上就是一个被 LIST 'wave' 包装的标准 WAV。
理解这一点后,提取音频数据的整个流程就变成了:
用 MMIO 遍历 RIFF 块,找到 ptbl 和 wvpl
从 ptbl 读偏移量,定位到 wvpl 里对应的 LIST wave(注意跳过 POOL_CUE_NULL)
遍历 wave 的子块,拿到 fmt、data、wsmp、INAM
导出时把 LIST 'wave' 的头改成 RIFF 'WAVE',内容原样复制
想保留循环行为,就自己解析 wsmp(WSMPL + WLOOP 数组)并在播放层实现
整个过程中没有复杂的位运算、没有压缩解码、没有端序转换——RIFF 的设计哲学就是「自描述、层次清晰、易于遍历」。DLS 作为 RIFF 家族的一员,继承了这个优点。作为 RIFF 学习案例,DLS 依然是最好的教材之一。
|
|