Tuesday, August 14, 2007

CWaveFile--一个操作和表示WAV数据的类

实现方法

介绍:

我先从简单介绍数字声音和它在计算机中的文档开始。很久很久以前,声音信号,像其他信号一样,用连续波形表示。它们被称作模拟信号。

模拟信号有很多优点,其中一个优点是它和物理变化一一对应。例如:当我们说话,我们的声带发生震动,声波通过空气传播。使用模拟仪器,我们很容易记录和保存声波(例如使用磁带)。但模拟信号也有一个很不好的缺点:抗干扰能力差。

数字信号没有这个缺点,因为数字表示可以有冗余数据。通过冗余数据的信息,即使传输过程中信号发生严重变化,也可以恢复原来的信号。因此数据信号被广泛使用:通讯、领航、医药、声音处理、计算机等。

我知道你更干兴趣的问题是:数字信号在计算机中是怎样存储的?我怎样处理它?我不想深入解释数字信号原理。你,作为一个程序员,必须知道的只有一件事:数字信号是一个数组(你会得到你自己的数组,如果你读完这篇文章的话)。对于声音数字信号,它可以是8位或16位的数字。

现在有大量的声音数字信号存储的标准(AU, VOC, WAVE, AIFF, AIFF-C, and IFF/8VX),但是实际上,微软的WAV文件使用得最广泛。

WAVE文件格式:

所有的WAVE文件符合RIFF规范。因此,WAVE文件满足以下条件:

由独立的数据块(称为chunk)组成,这些数据块组织称树状结构。

每个数据块由一个块头和数据组成。

RIFF文件的第一块(也是主要的块)是一个RIFF块,它像树的根。

通常的WAVE PCM文件是这样的:

文件的开始是RIFF头,然后是FMT块和DATA块。RIFF头有三个元素:RIFF_ID,RIFF_SIZE,RIFF_FORMAT:

  1. struct RIFF    
  2. {    
  3.   _TCHAR riffID[4];         //RIFF标识    
  4.   DWORD riffSIZE;           //文件长度减8 字节    
  5.   _TCHAR riffFORMAT[4];     //WAVE标识:"WAVE"    
  6. };  

RIFF头之后,是格式描述块format descriptor block (FMT):

  1. struct FMT    
  2. {    
  3.   _TCHAR fmtID[4];          //FMT标识: "fmt " (含空格)    
  4.   DWORD fmtSIZE;            //块大小(对于PCM16而言)                               
  5.   WAVEFORM fmtFORMAT;       //WAVEFORMATEX结构(但是没有cbSize)    
  6. };   

WAVRFORMAT机构是理解WAVE文件的关键所在。它包含我们处理WAVE文件中的所需要的信息。

  1. struct WAVEFORM    
  2. {    
  3.   WORD wFormatTag;          //数字声音的格式    
  4.   WORD nChannels;           //声道的数量(1为单声道、2为立体声)    
  5.   DWORD nSamplesPerSec;     //每秒样本数    
  6.   DWORD nAvgBytesPerSec;    //每秒平均字节数    
  7.   WORD nBlockAlign;         //播放的最小字节数    
  8.   WORD wBitsPerSample;      //每样本位数(8 或16)    
  9. };  

最后是数据块:

  1. struct DATA    
  2. {    
  3.   _TCHAR dataID[4];         //DATA块表示: "data"    
  4.   DWORD dataSIZE;           //数据大小    
  5. };  

这就是你所需要知道的WAVE文件头;文件头之后是数据。让我们看看CWaveFile接口:

  1. class CWaveFile : protected CFileMap, public  CObject {    
  2. public:    
  3.   CWaveFile( LPCTSTR fileName );    
  4.     ~CWaveFile() {}    
  5.     WAVEFORM* GetWaveFormat() { return &pFMT->fmtFORMAT; }    
  6.     DATA* GetWaveData() { return pDATA; }    
  7.     LPVOID GetData() { return reinterpret_castLPVOID >    
  8.                      ( dataAddress ); }    
  9.     BOOL DrawData( CDC *pDC, RECT *pRect, CSize *pNewSize );    
  10. protected:    
  11.   PBYTE dataAddress;    
  12.   RIFF* pRIFF;    
  13.   FMT* pFMT;    
  14.   DATA* pDATA;    
  15. private:    
  16.   BOOL CheckID(_TCHAR* idPar,_TCHAR A, _TCHAR B, _TCHAR C,    
  17.                _TCHAR D);    
  18.     void ReadWave();    
  19.     void ReadRIFF();    
  20.     void ReadFMT();    
  21.     void ReadDATA();    
  22.     void DrawByte( CDC *pDC );    
  23.     void DrawWord( CDC *pDC );    
  24. };   

正如你所看到的,CWaveFile从CObject和CFileMap继承。

内存映射文件:

内存映射文件是一个windows系统中非常有用的特性。我第一次实现CWaveFile类的时候,我不知道内存映射文件,我的代码使用缓冲区,从缓冲区中拷贝内容并...产生很多问题(虽然它可以使用)。内存映射文件是通过指针将操作磁盘文件变成如操作内存一样方便的技术。内存映射文件的操作快速而容易;另外,你不需要缓冲区。我在网上找到了Vitali Brusetsev的对内存映射文件封状的类(感谢Vitali!)。他的类封装了你需要的所有函数。因此,我问他在我的程序中使用他的类,并得到了肯定的回答。我选择他的类作为我的CWaveFile类的基类。

使用CWaveFile:

在你的工程中很容易使用CWaveFile。CWaveFile只有一个构造器,并且它只有一个参数--WAVE文件的路径。如果出错,CWaveFile会产生C++异常,所有的异常组织在下面的名字空间:

  1. namespace WaveErrors {    
  2.   class FileOperation {};    //文件错误    
  3.   class RiffDoesntMatch {};    
  4.   class WaveDoesntMatch {};    
  5.   class FmtDoesntMatch {};    
  6.   class DataDoesntMatch {};    
  7. }   

还有另外一些标识符号不匹配的异常会发生。顺便说说,我在微软的"录音机"写入的WAVE文件中发现了一些有趣的特性:数据在这些文件中被转换称6位,它从50字节开始。因此我这样做:

  1. inline void CWaveFile::ReadDATA()    
  2. {    
  3.   try {   
  4.     pDATA = reinterpret_cast> DATA* >( dataAddress );    
  5.     if( !CheckID( pDATA->dataID, 'd''a''t''a') ) {    
  6.       throw WaveErrors::DataDoesntMatch();    
  7.     }   
  8.   }catch( WaveErrors::DataDoesntMatch & ) {    
  9.     //奇怪的事情:在微软的WAVE文件中DATA表示可以是偏移量(可能是因为地址对齐)    
  10.     //手工寻找DATA_ID    
  11.     PBYTE b = Base();    
  12.     BOOL foundData = FALSE;    
  13.     while(  (dataAddress - b) !=  dwSize ) {    
  14.       if( *dataAddress == 'd' ) {    
  15.         //It can be DATA_ID, check it!    
  16.         pDATA = reinterpret_cast< DATA * >( dataAddress );    
  17.         if( CheckID( pDATA->dataID, 'd','a','t','a' ) ) {    
  18.           //DATA_ID was found    
  19.           foundData = TRUE;    
  20.           break;    
  21.         }    
  22.       }    
  23.       dataAddress++;    
  24.     }    
  25.     if( !foundData ) {    
  26.       //这个文件可能不完整    
  27.       throw WaveErrors::DataDoesntMatch();    
  28.     }    
  29.   }    
  30. }  

如果DATA标识不匹配,函数产生一个异常,并自己catch它。然后试图手工寻找DATA标识,如果找到了,一切OK,如果找不到,sorry,文件可能不完整。

ReadDATA()是三个负责读取文件头,并效验所有的标识的私有函数之一。它们组织在ReadWave函数中:

  1. void CWaveFile::ReadWave()    
  2. {    
  3.   ReadRIFF();    
  4.   //移动到下一块    
  5.     dataAddress += sizeof( *pRIFF );    
  6.   ReadFMT();    
  7.   //移动到下一块    
  8.   dataAddress += sizeof( *pFMT );    
  9.   ReadDATA();    
  10.   dataAddress += sizeof( *pDATA );    
  11.   //Wave has been read!    
  12. }  

正如你所看到,我们开始的时候读取RIFF块,如果一切顺利,我们移动到下一块(移动dataAddress指针)。不要忘记我们使用内存映射文件,

操作磁盘文件就像操作内存数据一样,酷吧?然后,我们读取FMT和DATA块,当我们离开程序,指针指向声音数据。我使用一个类型定义:

typedef short          AudioWord;

typedef unsigned char  AudioByte;

现在是时候使用简单的例子来检验我们的CWaveFile了。让我们打开一个WAVE文件,然后从中读出所有声音信息。

  1. #include <iostream>    
  2. using namespace std;   
  3. #include "CWaveFile.h"   
  4. int main()    
  5. {    
  6.   try {   
  7.     CWaveFile wave("noise.wav");    
  8.     WAVEFORM *format = wave.GetWaveFormat();    
  9.     cout << "Format: " << format->wFormatTag << endl;    
  10.     cout << "Samples per second: " << format->nSamplesPerSec    
  11.          << endl;    
  12.     cout << "Channels: " << format->nChannels << endl;    
  13.     cout << "Bit per sample: " << format->wBitsPerSample << endl;    
  14.     if( format->wBitsPerSample == 16 ) {    
  15.       AudioWord *buffer = reinterpret_cast< AudioWord * >    
  16.                           ( wave.GetData() );    
  17.       DATA *data = wave.GetWaveData();    
  18.       DWORD samples = data->dataSIZE / sizeof(AudioData);    
  19.       cout << "Samples number: " << samples << endl;    
  20.       cin.get();    
  21.       forDWORD p = 0; p < samples; p++ ) {    
  22.         cout << p << ": " << buffer[p] << endl;    
  23.       }    
  24.     }   
  25.   }catch(WaveErrors::FileOperation & ) {    
  26.     cout << "File operation error!\n";    
  27.   }catch(WaveErrors::RiffDoesntMatch & ) {    
  28.     cout << "Riff doesn't match!\n";    
  29.   }catch(WaveErrors::WaveDoesntMatch & ) {    
  30.     cout << "Wave doesn't match!\n";    
  31.   }catch(WaveErrors::DataDoesntMatch & ) {    
  32.     cout << "Data doesn't match!\n";    
  33.   }    
  34.   return 0;    
  35. }  

这是一个简单的控制台程序,但它做了大量的工作。在我的计算机,我得到下面的结果:

  Format: 1

  Samples per second: 22050

  Channels: 1

  Bits per sample: 16

  Samples number: 174680

首先,是检测"noise.wav"的有效性,如果一切顺利,接着进行下面的工作。(注意格式描述=1;这是最简单的未经压缩的PCM格式。你也很容易使用CWaveFile操作其它格式的文件,但是你必须自己关心数据怎样去解释。)Samples per second : 22050. Channels: 1 = mono sound.

Bits per sample: 16。我想知道它们是因为我需要利用它们去解释声音数据。我使用reinterpret_cast转换由GetData()返回的LPVOID。这里最重要的部分是数据的长度。

  1. DATA *data = wave.GetWaveData();    
  2. DWORD samples = data->dataSIZE / sizeof(AudioData);  

你必须使用上面的代码去获取数据大小的信息;dataSIZE包含数据大小的字节数,但我们知道当前处理的是16位的声音。

  1. if( format->wBitsPerSample == 16 ) {  

因此我们将dataSIZE除以sizeof(AudioData)(或者简单地除以2,16位是两字节)。你可能只想要声音数据,我只是将它输出到控制台。

显示数据:

毫无疑问,程序运行良好,然而它只是一个控制台程序。现在是使用windows GUI的时候了。这些数据看起来像什么?现在,我们将回答这个问题。

正如你所看到的,CWaveFile从CObject继承,是因为我想在MFC中使用它。

CWaveFile有成员函数:

BOOL DrawData( CDC *pDC, RECT *pRect, CSize *pNewSize )

这个函数负责在设备上下文中描绘数据。为了你能更好地使用它,我准备了如下例子:

  1. void CAnalyseView::OnDraw(CDC* pDC )    
  2. {    
  3.   CAnalyseDoc* pDoc = GetDocument();    
  4.   ASSERT_VALID(pDoc);   
  5.   // TODO: add draw code for native data here    
  6.   pDC->SaveDC();    
  7.   CRect rect;    
  8.   CBrush brush( RGB( 150, 200, 230 ) );    
  9.   GetClientRect( &rect );    
  10.   FillRect( *pDC, &rect, brush );   
  11.   pDC->MoveTo( rect.left, rect.bottom/2 );    
  12.   CWaveFile wave(pDoc->m_fileName);    
  13.   wave.DrawData( pDC, &rect, &m_szSize );   
  14.   pDC->MoveTo( rect.left, rect.bottom/2 );    
  15.   pDC->LineTo(rect.right, rect.bottom/2 );    
  16.   pDC->RestoreDC(-1);    
  17. }   

DrawData函数有三个参数:指向CDC的指针、指向CRect的指针,指向CSize的指针。你可以在OnSize中加入下面的代码:

  1. void CAnalyseView::OnSize(UINT nType, int cx, int cy)    
  2. {    
  3.   CView::OnSize(nType, cx, cy);   
  4.   // TODO: Add your message handler code here    
  5.   if( cx == 0 || cy == 0 ) return;    
  6.   m_szSize = CSize( cx, cy );    
  7. }   

就是这样。但我忘记告诉你DrawData方法的缺点。它只正确显示单声道数据。立体声和单声道不同,立体声样本是左右声道交替的(左、右、左、右...)。

最后:

我想在这多谢所有读到这里的人。我知道CWaveFile还未完成;如果你在这上面添加了一些函数,或者有什么Bug,请让我知道。网上见。

by:Alexander Beletsky 2003.2.3

from:codeGuru

翻译:snoopy

环境:VC6 SP4, VC.NET

No comments: