找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
技术宅的结界 门户 查看主题

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

发布者: YY菌 | 发布时间: 2026-9-19 02:09| 查看数: 36| 评论数: 0|帖子模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×
本帖最后由 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 中:


  1. typedef struct _POOLCUE {
  2.     ULONG ulOffset;          /* Offset to the entry in the list */
  3. } POOLCUE, FAR *LPPOOLCUE;

  4. typedef struct _POOLTABLE {
  5.     ULONG cbSize;            /* size of the pool table structure */
  6.     ULONG cCues;             /* count of cues in the list */
  7. } POOLTABLE, FAR *LPPOOLTABLE;
复制代码

POOLCUE 是变长数组的元素,POOLTABLE 头后面紧跟 cCues 个 POOLCUE。常用做法是定义一个 0 长度数组的扩展结构:


  1. typedef const struct POOLTABLEX : POOLTABLE {
  2.     POOLCUE pcues[0];
  3. } 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:


  1. LIST [] 'wvpl'
  2.     LIST [] 'wave' [dlid, RIFFWAVE]      ← 一个波形
  3.         'fmt ' <size> ...
  4.         'data' <size> ...
  5.         'wsmp' <size> ...                (可选,循环点)
  6.         LIST [] 'INFO'
  7.             'INAM' <size> ...            (可选,名称)
  8.     LIST [] 'wave' [dlid, RIFFWAVE]      ← 下一个波形
  9.     ...
复制代码

关键洞察来了:LIST 'wave' 的结构和标准 WAV 文件的 RIFF 'WAVE' 几乎一模一样。

标准 WAV:

  1. 'RIFF' <size> 'WAVE'
  2.     'fmt ' <size> ...
  3.     'data' <size> ...
复制代码


DLS 波形:

  1. 'LIST' <size> 'wave'
  2.     'fmt ' <size> ...
  3.     'data' <size> ...
复制代码


LIST 和 RIFF 的块头布局完全相同,都是 ckid + cksize + type。区别只有两处:
1.块标识:LIST vs RIFF
2.类型串:'wave'(小写)vs 'WAVE'(大写)

这就是为什么 DLS 提取可以做到几乎零成本——直接把 LIST wave 的内容原样搬出来,改个头部,就是一个完全合法的 WAV。

四、用 MMIO 解析
Windows 提供了 mmio* 系列 API 专门处理 RIFF 文件。它的好处是自动处理块的升降、查找、字节对齐、边界检查等。核心流程:


  1. MMCKINFO dls, cur;
  2. mmioDescend(hmmio, &dls, NULL, 0);              // 进入 RIFF 顶层
  3. if (dls.fccType != FOURCC_DLS) return ERR;

  4. // 定位 PTBL 块
  5. ptbl.ckid = FOURCC_PTBL;
  6. mmioDescend(hmmio, &ptbl, &dls, MMIO_FINDCHUNK);

  7. // 离开 PTBL 块
  8. mmioAscend(hmmio, &ptbl, 0);

  9. // 定位 WVPL 块
  10. MMCKINFO wvpl;
  11. wvpl.fccType = FOURCC_WVPL;
  12. mmioDescend(hmmio, &wvpl, &dls, MMIO_FINDLIST);
复制代码


然后遍历每个波形:

  1. // 定位到第 idx 个波形
  2. mmioSeek(hmmio, wvpl.dwDataOffset + sizeof(FOURCC) + pptx->pcues[idx].ulOffset, SEEK_SET);
  3. mmioDescend(hmmio, &wave, &wvpl, 0);
  4. if (wave.fccType != FOURCC_wave) return ERR;

  5. // 遍历 wave 内的子块
  6. for (;;) {
  7.     MMRESULT mmr = mmioDescend(hmmio, &cur, &wave, 0);
  8.     if (mmr == MMIOERR_CHUNKNOTFOUND) break;
  9.     switch (cur.ckid) {
  10.     case FOURCC_fmt:  pwfx = (WAVEFORMATEX*)(pData + cur.dwDataOffset); break;
  11.     case FOURCC_data: pdat = pData + cur.dwDataOffset; size = cur.cksize; break;
  12.     case FOURCC_WSMP: pwsx = (WSMPL*)(pData + cur.dwDataOffset); break;
  13.     case FOURCC_LIST:
  14.         if (cur.fccType == MAKEFOURCC('I','N','F','O')) {
  15.             // 从 INAM 取名称
  16.         }
  17.         break;
  18.     }
  19.     mmioAscend(hmmio, &cur, 0);
  20. }
复制代码

整个过程中,pData 是内存映射视图的起始地址,所有指针都直接指向映射内存,没有任何数据拷贝。

五、导出为 WAV:一行的魔术

导出时,最省事的做法是:读 DLS 的 RIFF 头,取 wave 块的 cksize,把 'wave' 转成 'WAVE',然后从 wave 块的第一个子块开始原样复制内容。

  1. typedef struct RiffChunk {
  2.     FOURCC ckid;    // 'RIFF'
  3.     DWORD  cksize;  // 复用 wave 块的 cksize
  4.     FOURCC type;    // 'WAVE'
  5. } RiffChunk;

  6. const RiffChunk *wave = (RiffChunk*)(pData + wvpl.dwDataOffset + sizeof(FOURCC) + pptx->pcues[idx].ulOffset);

  7. RiffChunk head = {
  8.     *(DWORD*)pData,                 // 取 DLS 的 'RIFF'
  9.     wave->cksize,                   // 大小直接复用
  10.     wave->type & 0xDFDFDFDF,        // 'wave' → 'WAVE'
  11. };

  12. WriteFile(hFile, &head, sizeof(head), ...);
  13. 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 中的定义如下:

  1. typedef struct _rwsmp {
  2.     ULONG  cbSize;
  3.     USHORT usUnityNote;      /* MIDI Unity Playback Note */
  4.     SHORT  sFineTune;        /* Fine Tune in log tuning */
  5.     LONG   lAttenuation;     /* Overall Attenuation to be applied to data */
  6.     ULONG  fulOptions;       /* Flag options */
  7.     ULONG  cSampleLoops;     /* Count of Sample loops, 0 loops is one shot */
  8. } WSMPL, FAR *LPWSMPL;

  9. typedef struct _rloop {
  10.     ULONG cbSize;
  11.     ULONG ulType;            /* Loop Type */
  12.     ULONG ulStart;           /* Start of loop in samples */
  13.     ULONG ulLength;          /* Length of loop in samples */
  14. } WLOOP, FAR *LPWLOOP;

  15. /* 目前 Level 1 只定义了前向循环一种类型 */
  16. #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 数组拼在它后面:


  1. typedef const struct WSMPLX : WSMPL {
  2.     WLOOP wloops[0];
  3. } 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 依然是最好的教材之一。

DlsView.7z

13.7 KB, 下载次数: 0

售价: 1 个宅币  [记录]  [购买]

源码

最新评论

浏览过的版块

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2026-9-20 09:31 , Processed in 0.018556 second(s), 12 queries , Gzip On, Redis On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表