一个简单的C# Wave编辑器,第一部分:背景和分析






4.77/5 (76投票s)
2004年8月6日
11分钟阅读

364571

8268
一个RIFF/Wave编辑“瑞士军刀”的第一阶段,我们将学习如何提取通用Wave文件中存在的所有数据并将其存储在XML文档中。
引言
由于GIMP在Windows上免费提供,有些人会说没必要继续编写基础的开源或免费图像编辑器。我不是其中之一,但一些问题一直困扰着我:为什么有如此多的图像处理程序可用和在开发中,尤其是当MS Paint已经有了很大的改进?为什么音频编辑器如此之少,而它们的需求又如此之大,并且像Wave文件分割这样的基本功能在免费软件中却缺失?
我猜想人们对处理图像更自在。毕竟,有大量的现有图像控件,而音频控件则相对较少。在编码社区中似乎也有一个持续的论调:“音频处理是一门黑艺术”。通过这一系列文章,我将描述一个基本(但希望是健壮而强大)的命令行“瑞士军刀”的音频文件处理的设计和创建。我们将以模块化的方式构建工具,因此您可以轻松地进行(并希望发布!)自己的贡献。
在本文中,我们将讨论RIFF文件格式,特别是PCM RIFF-wave。我们将详细介绍构成它的最常见的数据结构,并简要讨论您可能看到的变体。最后,我们将开发一个“分析器”,它解析、加载到内存并以XML格式输出相关的文件数据。
背景
资源交换文件格式
RIFF是由微软和IBM在1991年创建的全能多媒体文件格式。Wave音频并非RIFF文件中存储的唯一多媒体;AVI视频也使用RIFF。(有关RIFF及其Amiga祖先IFF的历史的更多信息,请参阅Wikipedia。)
每个RIFF文件都以一个包含三个四字节字段的头部开始。数据结构如下
public string sGroupID; //Surprisingly enough, this is always "RIFF"
public uint dwFileLength; //File length in bytes, measured from offset 8
public string sRiffType; //In wave files, this is always "WAVE"
RIFF由称为“块”的部分组成。每个块都以一个八字节的头部开始
public string sChunkID; //Four bytes: "fmt ", "data", "fact", etc.
public uint dwChunkSize; //Length of header in bytes
专有文件格式的乐趣
不幸的是,虽然一些官方文件确立了文件格式的基础,但从未发布过Wave文件的官方标准。在官方文件缺失的情况下,人们做了他们最擅长的事情:即兴创作。结果是,有许多不同的块类型,其中许多复制和重复功能。目前,我们将忽略大多数这些块类型,而专注于每个Wave文件中保证存在的两个块:格式块和数据块。
分块
格式块详细说明了音频数据所需的所有必要信息,包括音频的格式(目前我们假设是未压缩的脉冲编码调制音频)、通道数(单声道、立体声、四声道、5声道)、音频的频率、每个音频样本的位数(通常为8或16),以及每帧的字节数。此块的数据结构如下
public string sChunkID; //Four bytes: "fmt "
public uint dwChunkSize; //Length of header in bytes
public ushort wFormatTag; //1 if uncompressed Microsoft PCM audio
public ushort wChannels; //Number of channels
public uint dwSamplesPerSec; //Frequency of the audio in Hz
public uint dwAvgBytesPerSec;//For estimating RAM allocation
public ushort wBlockAlign; //Sample frame size in bytes
public uint dwBitsPerSample; //Bits per sample
您可能会说:“等一下。帧?帧与**样本**是相同的,但与*样本*不同。明白吗?您必须清楚这一点;这非常重要。好吧,好吧,我来解释。帧是一个完整的多声道音频样本。`SamplesPerSecond`字段实际上给出的是每秒帧数。而`BitsPerSample`字段指的是*单个通道*中一个样本的位数。
另一个块更重要:数据块。正如您所料,它包含了所有PCM音频数据。它有一个非常简单的数据结构
public string sChunkID; //Four bytes: "data"
public uint dwChunkSize; //Length of header in bytes
//Different arrays for the different frame sizes
public byte [] byteArray; //8 bit unsigned data; or...
public short [] shortArray; //16 bit signed data
符号/无符号约定对我们的数据有什么影响?嗯,这是一个很好的问题,我们将在下面分析一个样本文件时进行讨论。
专有文件格式的乐趣,重演
但还有另一个复杂之处:块数据的顺序没有保证!由于从未发布过标准,技术上讲,存储实际音频数据的`data`块可以放在告知用户如何处理它的`fmt`(格式)头部之前。虽然这种情况很少发生,但编写良好的音频程序仍然会考虑它。新音频程序员常见的错误是假设`fmt`头部出现在文件前面;虽然这在大多数情况下是正确的,但有几个音频程序会生成不符合规范的Wave文件。
关于RIFF块,最后一点需要注意的是:它们必须是偶数字节。如果一个块是奇数字节,它必须用零填充。就我们目前的目的而言,只有一种情况可能发生:8位单声道文件的数据流。
Wave文件内部
小人国之战
这一切都很好,但文件内部是什么样的?“大头端”和“小头端”的旧有斗争是否会再次出现?如果您猜对了,那您就猜对了,可以跳过两段。如果您不知道这些术语是什么意思,这里有一点题外话。
很久以前,在一个叫Intellia的国家,一位微处理器设计师说:“法!这些Motorolia工程师用他们的大端内存存储把事情弄得太容易了。当他们的处理器将整数写入磁盘时,它的字节会一个接一个地按顺序写入磁盘。堆栈向下填充。事情就像汇编程序员喜欢的那样工作。我们必须结束这一切!”他停顿了一下,沉思着邪恶且反直觉的汇编代码编写方式,以思考一种使内存存储对程序员来说更困难、更混乱的方法。“我明白了!”他喊道,“我们将让堆栈向上填充,并以... *相反*的顺序存储连续字节!哈哈哈哈!”
好吧,真实的故事与被称为“专利”的不方便的事情有关,但重要的是要记住这一点:大端系统(使用Motorola芯片及其后代)将数据按内存中的排列顺序写入磁盘。如果您有一个`short`值为0x4567,并在大端系统上将其写入磁盘,它将在磁盘上存储为0x4567。在小端系统(使用Intel芯片及其亲属)上,它将存储为0x6745。每个字节的位顺序相同,但字节的*顺序*不同。
那么,Wave文件的数据是按小端还是大端格式存储的?考虑到该格式是由微软和IBM制定的,因此它对字段和音频数据都使用小端格式也就不足为奇了。
拍照
在下图所示,您可以看到文件中的所有头部:RIFF头部的三个32位(双字)字段(红色高亮显示),格式块的字段(绿色高亮显示),fact块的字段(蓝色;有关此块的非常简短的讨论,请参阅本文末尾),以及数据块的最开头(黄色高亮显示)。
当您将十六进制值转换为十进制时,您应该得到以下值
<?xml version="1.0" ?>
- <WaveFile xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
- <maindata>
<sGroupID>RIFF</sGroupID>
<dwFileLength>407534</dwFileLength>
<sRiffType>WAVE</sRiffType>
</maindata>
- <format>
<sChunkID>fmt</sChunkID>
<dwChunkSize>18</dwChunkSize>
<wFormatTag>1</wFormatTag>
<wChannels>2</wChannels>
<dwSamplesPerSec>44100</dwSamplesPerSec>
<dwAvgBytesPerSec>176400</dwAvgBytesPerSec>
<wBlockAlign>4</wBlockAlign>
<dwBitsPerSample>16</dwBitsPerSample>
</format>
- <fact>
<sChunkID>fact</sChunkID>
<dwChunkSize>4</dwChunkSize>
<dwNumSamples>101871</dwNumSamples>
</fact>
- <data>
<sChunkID>data</sChunkID>
<dwChunkSize>407484</dwChunkSize>
</data>
</WaveFile>
[为什么是XML?原因之一是它易于导航输出。此外,因为XML文档将来可能会在我们需要实现更高级功能时派上用场——XML文档可以保存峰值、更改记录等。.NET的XML解析方法使其成为组织数据存储和访问的非常方便的方法。最后,积累一些经验也是好事。CodeProject all about learning,所以如果您以前从未处理过XML,您就有理由开始尝试了。]
希望您能理解我们如何检索XML中的各种信息。关于上图,最后再评论一句,然后我们来看一些实际代码。正如您所见,在数据块的`chunkID`和`chunkSize`双字之后,还有一个最后的双字。正如您所猜测的,这就是第一个帧。由于文件是16位立体声,我们知道哪些字节代表什么
- 再次,`F4 06 3E FF`是第一个帧。
0x06F4
是**左**立体声通道中的第一个样本。0xFF3E
是**右**立体声通道中的第一个样本。
找出这四个字节在其他配置下的含义可能是一个有用的练习
- 在8位立体声中,前两帧将是[L: 0xF4 R: 0x06],[L: 0x3E R: 0xFF]。
- 在16位单声道中,前两帧将是0x06F4,0xFF3E。
- 在8位单声道中,前四帧将是0xF4,0x06,0x3E,0xFF。
如果您对RIFF和RIFF/Wave文件感兴趣,请查看SonicSpot指南和一个业余(在好的意义上!)尝试撰写的全面的RIFF/Wave规范。
另一方面,如果您想直接看代码,请继续阅读。
WaveEdit:名不副实
尽管我们从一开始就称该软件为WaveEdit,但更准确的称呼可能是“WaveInfo”,因为此版本仅获取Wave数据并将其写入XML。但在深入代码库之前,让我们先定义一些要求。
每个体面的音频编辑器都必须执行一些标准操作:音量调整、文件截断、音高/速度控制,以及可能的淡入/淡出。除了这些功能,我们将在本系列的其余部分中添加这些功能,我还会添加Wave文件分割。如果您自己录制过,或者将黑胶唱片或音频磁带传输到CD,您就会明白为什么这很重要。这是一个相对直接的功能,但目前可用的免费音频软件似乎都不支持。
大部分代码都非常直接(而且我认为相当易读)。我们将重点介绍一些代码片段:有趣的部分、令人困惑的部分以及我认为值得讨论的部分。
您在*EntryPoint.cs*中首先会注意到的是XML序列化器的初始化。让我们把所有的XML代码放在一个地方
XmlSerializer xmlout = new XmlSerializer(typeof(WaveFile));
Stream writer = new FileStream(args[1], FileMode.Create);
...
xmlout.Serialize(writer, contents);
XML序列化器根据传递给它的类类型创建一个XML文件的“模板”。我们传递`WaveFile`,它具有以下类定义
public class WaveFile {
public riffChunk maindata;
public fmtChunk format;
public factChunk fact;
public dataChunk data;
}
这些“chunkdata”数据结构定义在*Structs.cs*中,并且(大部分)包含您已经看到的数据。`riffChunk`类包含一个用于存储文件名的字段
public class riffChunk {
public string FileName;
//These three fields constitute the riff header
public string sGroupID; //RIFF
public uint dwFileLength; //In bytes, measured from offset 8
public string sRiffType; //WAVE, usually
}
`dataChunk`类包含四个新字段
public class dataChunk {
public string sChunkID; //Four bytes: "data"
public uint dwChunkSize; //Length of header
public long lFilePosition; //Position of data chunk in file
public uint dwMinLength; //Length of audio in minutes
public double dSecLength; //Length of audio in seconds
public uint dwNumSamples; //Number of audio frames
}
`lFilePosition`用于存储音频数据在文件中的起始位置;这将有助于我们以后进行编辑。`dwMinLength`和`sSecLength`主要是为了在XML文件中方便人类阅读。最后,`dwNumSamples`复制了`fact`头部中的一个字段,该字段
- 不保证存在,并且
- 不那么方便。
我们使用一个自定义的`FileReader`,称为`WaveFileReader`,来从Wave文件中检索数据。除了符合良好的编码约定外,它还可以简化代码:在`EntryPoint`类中,我们只关注“宏观”,而在`WaveFileReader`类中,我们只关注一个地方发生的事情。由此产生的代码非常容易理解
WaveFileReader reader = new WaveFileReader(args[0]);
WaveFile contents = new WaveFile();
contents.maindata = reader.ReadMainFileHeader();
contents.maindata.FileName = args[0];
我们如何解决以可能随机的顺序读取块的问题?一个while
循环和一系列if
语句的设置将很好地满足我们的需求
while (reader.GetPosition() < (long) contents.maindata.dwFileLength)
{
temp = reader.GetChunkName();
if (temp=="fmt ")
{
contents.format = reader.ReadFormatHeader();
if (reader.GetPosition() +
contents.format.dwChunkSize ==
contents.maindata.dwFileLength)
break;
}
else if (temp=="fact")
{
contents.fact = reader.ReadFactHeader();
if (reader.GetPosition() +
contents.fact.dwChunkSize ==
contents.maindata.dwFileLength)
break;
}
else if (temp=="data")
{
contents.data = reader.ReadDataHeader();
if (reader.GetPosition() +
contents.data.dwChunkSize ==
contents.maindata.dwFileLength)
break;
}
else
{ //This provides the required skipping of unsupported chunks.
reader.AdvanceToNext();
}
}
最后,我们将深入研究`WaveFileReader`代码。`WaveFileReader`具有与`WaveFile`相同的字段,原因很快就会明了。还有一个`BinaryReader` `reader`,我们将使用它来访问Wave文件。我们通过一个自定义构造函数初始化`reader`。
public class WaveFileReader : IDisposable
{
BinaryReader reader;
riffChunk mainfile;
fmtChunk format;
factChunk fact;
dataChunk data;
public WaveFileReader(string filename)
{
reader = new BinaryReader(new FileStream(filename,
FileMode.Open, FileAccess.Read, FileShare.Read));
}
}
WaveFileReader
中的字段都不是公共的(那是`WaveFile`的用途!),所以我们需要编写适当的接口方法。我们特别需要处理`reader`的方法,因为它对整个结构至关重要。最少,我们需要函数来
- 获取当前位置
public long GetPosition() { return reader.BaseStream.Position; }
- 获取文件中的下一个四个字符作为字符串
public string GetChunkName() { return new string(reader.ReadChars(4)); }
- 跳到下一个块
public void AdvanceToNext() { //Get next chunk offset long NextOffset = (long) reader.ReadUInt32(); //Seek to the next offset from current position reader.BaseStream.Seek(NextOffset,SeekOrigin.Current); }
这些“通用文件流”函数位于*WaveFileReader.cs*的通用工具#region
中。
最后,我们有了头部提取函数。这些函数大体相同,所以我们只看最复杂的一个……实际上并不复杂。
public dataChunk ReadDataHeader()
{
data = new dataChunk();
data.sChunkID = "data";
data.dwChunkSize = reader.ReadUInt32();
//ReadUInt32 is the most important function here.
//Once we've read in the ChunkSize,
//we're at the start of the actual data.
data.lFilePosition = reader.BaseStream.Position;
//If the fact chunk exists, we don't have to calculate
//the number of samples ourselves.
if (!fact.Equals(null))
data.dwNumSamples = fact.dwNumSamples;
else
data.dwNumSamples = data.dwChunkSize /
(format.dwBitsPerSample/8 * format.wChannels);
//The above could be written as data.dwChunkSize / format.wBlockAlign,
//but I want to emphasize
//what the frames look like.
data.dwMinLength = (data.dwChunkSize / format.dwAvgBytesPerSec) / 60;
data.dSecLength = ((double)data.dwChunkSize /
(double)format.dwAvgBytesPerSec) -
(double)data.dwMinLength*60;
return data;
}
结论:下一步去哪里?
到目前为止,我已有足够的素材和动力来完成一个三部系列。下一部分将涵盖音高和音量调整;第三部分将涵盖截断和文件分割。如果大家兴趣浓厚,该系列还可以扩展到涵盖许多内容,从数字信号处理到快速傅里叶变换(用于查看文件频率谱)。第二部分见!
附录:`fact`头部
`fact`头部非常直观。数据结构仅仅是
public string sChunkID; //Four bytes: "fact"
public uint dwChunkSize; //Length of header
public uint dwNumSamples; //Number of audio frames
样本数可以通过将`format.dwSamplesPerSecond`乘以文件长度(以秒为单位)来计算。