YY菌 发表于 前天 02:09

【C++】深入 DLS 文件:从音色库中提取音频数据

本帖最后由 YY菌 于 2026-9-19 02:11 编辑

DLS(Downloadable Sounds)是 MIDI 时代遗留的一种采样音色库格式。它把一组音频样本、循环点、音高映射打包进一个 RIFF 容器里,供软波表合成器在运行时加载。相比 SF(Sound Font)来说,DLS 的文档较少、工具更稀缺,但它的结构其实比想象中更接近标准 WAV——这一点让提取音频数据变得出奇地简单。

这篇文章不讲合成,只讲如何从 DLS 文件里把音频数据抠出来。

一、DLS 文件的整体结构
DLS 文件是一个标准的 RIFF 文件,最外层:

RIFF [] 'DLS '
注意 '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;
} FAR *LPCPTX;

这样 pptx->pcues.ulOffset 就能直接访问第 i 个音色的波形偏移。

不过需要注意两个容易踩的坑:

1.ulOffset 是相对于 wvpl 数据区的偏移,而不是文件绝对偏移。计算实际位置时,需要加上 wvpl 的 dwDataOffset(指向 type 字段),再跳过 'wvpl' 的 4 字节:
文件位置 = wvpl.dwDataOffset + sizeof(FOURCC) + pcues.ulOffset

2.POOL_CUE_NULL 是空 cue,值为 0xffffffff。遍历 ptbl 时如果遇到它,应当跳过该 cue,而不是去 wvpl 里找不存在的波形:
#define POOL_CUE_NULL0xffffffffl

三、WVPL:波形池
wvpl 是一个 LIST,它的 fccType 是 'wvpl',数据区是一串 LIST 块,每个都是一个 wave:


LIST [] 'wvpl'
    LIST [] 'wave'       ← 一个波形
      'fmt ' <size> ...
      'data' <size> ...
      'wsmp' <size> ...                (可选,循环点)
      LIST [] 'INFO'
            'INAM' <size> ...            (可选,名称)
    LIST [] 'wave'       ← 下一个波形
    ...

关键洞察来了: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.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'
    DWORDcksize;// 复用 wave 块的 cksize
    FOURCC type;    // 'WAVE'
} RiffChunk;

const RiffChunk *wave = (RiffChunk*)(pData + wvpl.dwDataOffset + sizeof(FOURCC) + pptx->pcues.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 {
    ULONGcbSize;
    USHORT usUnityNote;      /* MIDI Unity Playback Note */
    SHORTsFineTune;      /* Fine Tune in log tuning */
    LONG   lAttenuation;   /* Overall Attenuation to be applied to data */
    ULONGfulOptions;       /* Flag options */
    ULONGcSampleLoops;   /* 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;
} FAR *LPCWSX;

这样 wloops.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.ulOffset 不是文件绝对偏移,是相对于 wvpl 数据区(不含 LIST 头,也不含 type)的偏移。计算实际位置时别忘了两个额外字节数:
文件位置 = wvpl.dwDataOffset + sizeof(FOURCC) + pcues.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 依然是最好的教材之一。
页: [1]
查看完整版本: 【C++】深入 DLS 文件:从音色库中提取音频数据