写给C/C++基础类的朋友:
很长时间都没有认真的来版上和网友们聊聊了,偶尔上来也是随便转转,仅处理一下版务。这些日子里来你们之中的有些人给我发了短消息,问道“嘿,哥们(大多数时候用的是‘老大’这个词,但我并不怎么喜欢这个称呼,感觉有点像黑社会?),最近怎么不见你露面啊,忙什么呢?”而我在极为敷衍的回答道:“在忙自己的活呢,不好意思啊。”之后也感觉到非常内疚,但是每当我一想起现在做的工作,能真正的给那些在C/C++版里做出贡献的网友们留下点东西,我也便觉得心安理得了。
在正式开始之间我还是想说一些废话,如果你是老手就向下拖动右边的滚动条30-50像素吧。Winamp是众所周知的世界上最好最流行的音乐(媒体)播放软件,它所支持的音频格式之多另其它同类软件望尘莫及。而这种广泛支持性又是由于它采用了非常合理巧妙的插件式设计,每个人都可以用winamp来播放自己发明的音频格式,只要你写出了相应的输入插件。但这篇文章不会教你用小波变换等高难度数学算法来编写比MP3或APE更为优秀的音频编码格式,如果你企图在文章里找到这些东西,我还是劝你到此为止,关掉浏览器,然后去图书馆翻看《信号学》。在这篇文章里我首先会教你怎样编写一个符合Winamp插件规范的输出插件,你一定会问,我刚才说的不是输入插件吗?其实这两种插件是一体的,你向下看便会得到答案。在练习编写输出插件的的同时,我还将帮你了解电声的基础原理、了解Winamp的设计结构、熟悉WAVE(PCM)的文件格式、声卡的基本工作机制等。最后,我们还会用DirectX来完成这个音频的输出模块。这是一系列非常令人激动的主题,但请你还是先不要急着热血沸腾,下面这几盆凉水是一定要泼的。在继续看下去之间请确定你有这些条件或能力:可以随时打开VC.net编写测试程序、装有DirectX8.1SDK、能够熟练的开发一个并不十分复杂的SDK程序,熟悉C++……好了,想来想去要求的条件就这么多了,我为了避免吓跑大批的读者还是尽量使用了较温合的词汇。还有一点要说的是,我希望你能在一个愉快的心境中阅读全文,而不是像蹲在厕所里读《量子力学》那种感觉一样,确定我们可以开始了吗?
为了从哪里开始这个问题我伤透了脑筋,想来想去毕竟这偏文章是发表在网上的,那么就从你移动鼠标开始吧,现在再打开一个浏览器,进入下面的网址:http://www.winamp.com/nsdn/winamp2x/dev/plugins/out.jhtml这里是Winamp插件开发的主页,他美其名曰NSDN,可是和MSDN相比,它除了一点原代码之外什么也没有。你现在需要做的是下载那个OUT_MINISDK,链接在:http://ftpwa.newaol.com/customize/component/nsdn/winamp2x/out_minisdk.zip,你打开这个压缩包你会看到三个文件in2.h、out.h和out_raw.c,什么,你还看到了别的文件?哦,在那是还有一些别的文件,但我们现在只需要这三个。把它们解压出来,然后打开他们,因为你在听我讲述Winamp的设计结构的时候需要对应着看看实现的代码。
当我第一次看到这个插件的封装结构时我惊呆了,原来Winamp比我想象中的更懒,它其实什么都没有做,仅仅是一个界面,调用了一些输入的接口,仅此而已。我们先看一下In_Module和Out_Module的结构:
typedef struct
{
int nVer; // 模块版本号
char *szDesc; //模块描述信息
HWND hMainWnd; // Winamp的主窗体句柄(由Winamp来填写)
HINSTANCE hDllInstance; // DLL实例句柄(由Winamp来填写)
char *szFileExt; // 扩展名过滤器,格式参见GetOpenFileName
int nIsSeekable; // 是否可索引媒体,是-你可以拖动进度条,否-反之
int UsesOutputPlug; // 是否使用输出插件?你想在这个模块里搞定一切?
// 下面都是函数指针,将被Winamp调用
void (*Config)(HWND hwndParent); // 配置对话框
void (*About)(HWND hwndParent); // 关于对话框
void (*Init)(); // 初始化
void (*Quit)(); // 退出
// szFile - 传入的文件名,szTitle - 传出的标题,nLen - 转出的时间长度,毫秒。
// 如果szFile传NULL,则返回当前播放文件的信息
void (*GetFileInfo)(char *szFile, char *szTitle, int *nLen);
int (*InfoBox)(char *szFile, HWND hwndParent); // 弹出文件信息对话框
int (*IsOurFile)(char *szFile); // 检查文件格式
int (*Play)(char *szFile); // 开始播放文件szFile,返回0正常,-1错误
void (*Pause)(); // 暂停处理
void (*UnPause)(); // 取消暂停
int (*IsPaused)(); // 是否斩停?1是暂停,0不是
void (*Stop)(); // 停止播放
int (*GetLength)(); // 取得长度,毫秒单位
int (*GetOutputTime)(); // 获取当前时间,一般调用out模块的同名函数即可
void (*SetOutputTime)(int nTime); // 索引到某一时刻
void (*SetVolume)(int volume); // 音量调节,从0 - 255
void (*SetPan)(int pan); // 左右声道平衡,从-127 - 127
//下面的函数多和AVS、可视化效果、均衡器等有关,具体咱们用不到,就暂时不讲了。
void (*SAVSAInit)(int maxlatency_in_ms, int srate);
void (*SAVSADeInit)(); // call in Stop()
void (*SAAddPCMData)(void *PCMData, int nch, int bps, int timestamp);
int (*SAGetMode)();
void (*SAAdd)(void *data, int timestamp, int csa);
void (*VSAAddPCMData)(void *PCMData, int nch, int bps, int timestamp);
int (*VSAGetMode)(int *specNch, int *waveNch);
void (*VSAAdd)(void *data, int timestamp);
void (*VSASetInfo)(int nch, int srate);
int (*dsp_isactive)();
int (*dsp_dosamples)(short int *samples, int numsamples, int bps, int nch, int srate);
void (*EQSet)(int on, char data[10], int preamp);
void (*SetInfo)(int bitrate, int srate, int stereo, int synched);
Out_Module *outMod; // 看看,Winamp终于露出马脚了吧?
} In_Module;
typedef struct
{
// 下面有些和In_Module一样,就不赘述了。
int nVer;
char *szDesc;
int nId; // 自己给一个ID,不知道有什么用,反正大于65536就行了。
HWND hMainWindow;
HINSTANCE hDllInstance;
void (*Config)(HWND hwndParent);
void (*About)(HWND hwndParent);
void (*Init)();
void (*Quit)();
// nSample - 采样率, nChannels - 声道数,1或2
// nBitPerSamp - 每采样的位率,nBufLen、nPreBufLen - 缓冲长度,咱们用不到
// 返回大于0正常播放,小于0失败
int (*Open)(int nSample, int nChannels, int nBitPerSamp, int nBufLen, int nPreBufLen);
void (*Close)(); // 关闭输出设备
// pBuf - 内存数据块,nLen - 数据块的长度
int (*Write)(char *pBuf, int len); // 返回0成功,其它不成功
int (*CanWrite)(); // 表示当前状态是否可写
int (*IsPlaying)(); // 表示是否正在播放
int (*Pause)(int pause); // 暂停,稍后详释
void (*SetVolume)(int volume);
void (*SetPan)(int pan);
void (*Flush)(int t); // 刷新缓冲
int (*GetOutputTime)(); // 获取输出时间
int (*GetWrittenTime)(); // 返回写入的时间
} Out_Module;
你应该还在out_raw.c里看到了这样的代码:
__declspec( dllexport ) Out_Module * winampGetOutModule()
{
return &out;
}
相信大多数有些经验的人看到这里就已经真像大白了,不过考虑到文章的“兼容性”我还是要详述一翻。就拿输出模块来讲,Winamp在启动的时候,会去Plugins目录中查找所有可用的输出插件,如果找到有可用的输出模块,就会先把这个DLL加载进来。而这个DLL被加载时会按照编写者的定义填写好一个Out_Module全局对象,里面的函数指针成员则指向已经实现的函数代码。而Winamp调用这些函数的方法就是通过这个DLL导出的winampGetOutModule()函数来得到那个Out_Module全局对象的指针,接着它就可以通过这个全局对象的成员函数指针来调用那些函数了。是不是很巧妙呢?而插件的反馈机制则是由Windows标准的消息机制来完成,PostMessage就是最简单的方法,这也正是每一个插件里都会有一个HWND hMainWindow主窗体句柄的原因。事实上Winamp在播放音乐时大多数调用的都只是In_Module的接口,因为In_Module里包括一个Out_Module的指针,所以In_Module自动调用Out_Module相应的函数,为Winamp完成所有的事情,Out_Module一般只是用来反馈。
在了解了Winamp的工作原理之后,下面我们将开始编写第一个Winamp输出模块。为了方便调试,并且避免对你的winamp造成意外的伤害,我们会先写一个winamp模拟程序,它会来调用Winamp的插件。就照下面的步骤去做吧,在VC.net中新建一个MFC应用程序,记住选对话框。添加两个文件MusicPlayer.h和MusicPlayer.cpp。然后把刚才的那两个大结构体Out_Module和In_Module的声明加入头文件,接着写一些咱们需要调用的函数声明:
void OutInit();
void OutConfig( HWND hWnd );
void OutAbout( HWND hWnd );
int OutOpen( int nSampleRate, int nChannels, int nBitPerSample, int nBufLen, int nPreBufLen );
void OutClose( void );
void OutQuit( void );
int OutWrite( char *pBuf, int nLen );
int OutCanWrite( void );
int OutIsPlaying( void );
int OutPause( int nPause );
void OutFlush( int nT );
int OutGetWrittenTime( void );
int OutGetOutputTime( void );
void OutSetVolume( int nVolume );
void OutSetPan( int nPan );
我们还需要在头文件里声明以下几个东东:
// 用于从DLL中获取上面说的那个接口的函数指针
typedef In_Module* (WINAPI *PWINAMPGETIN2MOD)( void );
// 用于方便的初始化对象
void InitModules( In_Module *pInModule, Out_Module *pOutModule );
现在回到MusicPlayer.cpp里,实现上面声明的那些函数,先把大括号写好,有返回值的函数都返回0,让整个工程能够顺利的通过编译,稍后我们再回来填写这些函数里的代码。在这之前我们先得实现一些输入模块的结构体里没有填好而等着Winamp来填的函数指针,如果我们不填,输入模块就会调用空地址,结果可想而知。下面是这些函数的声明及实现,直接放到MusicPlayer.cpp文件的顶部即可,不会占太多空间吧? :)
void SAVSAInit(int maxlatency_in_ms, int srate){}
void SAVSADeInit(){}
void SAAddPCMData(void *PCMData, int nch, int bps, int timestamp){}
int SAGetMode(){return 0;}
void SAAdd(void *data, int timestamp, int csa){}
void VSAAddPCMData(void *PCMData, int nch, int bps, int timestamp){}
int VSAGetMode(int *specNch, int *waveNch){return 0;}
void VSAAdd(void *data, int timestamp){}
void VSASetInfo(int nch, int srate){}
int dsp_isactive(){return 0;}
int dsp_dosamples(short int *samples, int numsamples, int bps, int nch, int srate){return 0;}
void EQSet(int on, char data[10], int preamp){}
void SetInfo(int bitrate, int srate, int stereo, int synched){}
上面这些函数都是用于可视化效果的,咱们当然不需要,所以用最简单的代码实现一下就行了!下面再实现一下InitModules函数:
void InitModules( In_Module *pInModule, Out_Module *pOutModule )
{
// 如果你用MFC的话,可以用下面的方法得到主窗体指针
// 如果不是,那你需要另想办法,比如全局变量
g_hWnd = ::AfxGetMainWnd()->GetSafeHwnd();
// 给传进来的输出模块结构体填值,使和在其被调用时将调用到上面相应的函数
pOutModule->description = "Creamdog DirectSount Out Module v1.0";
pOutModule->About = OutAbout;
pOutModule->CanWrite = OutCanWrite;
pOutModule->Open = OutOpen;
pOutModule->Close = OutClose;
pOutModule->Config = OutConfig;
pOutModule->Flush = OutFlush;
pOutModule->GetOutputTime = OutGetOutputTime;
pOutModule->GetWrittenTime = OutGetWrittenTime;
pOutModule->hDllInstance = ::AfxGetInstanceHandle();
pOutModule->hMainWindow = g_hWnd;
pOutModule->id = 32;
pOutModule->Init = OutInit;
pOutModule->IsPlaying = OutIsPlaying;
pOutModule->Pause = OutPause;
pOutModule->Quit = OutQuit;
pOutModule->SetPan = OutSetPan;
pOutModule->SetVolume = OutSetVolume;
pOutModule->version = 100;
pOutModule->Write = OutWrite;
pOutModule->Init();
// 输入模块的大部分功能函数在你加载DLL时已经填好
// 这里只需要填一下那写“无用而危险”的函数指针
pInModule->outMod = pOutModule;
pInModule->hMainWindow = pOutModule->hMainWindow;
pInModule->hDllInstance = pOutModule->hDllInstance;
pInModule->SAVSAInit = SAVSAInit;
pInModule->SAVSADeInit = SAVSADeInit;
pInModule->SAAddPCMData = SAAddPCMData;
pInModule->SAGetMode = SAGetMode;
pInModule->SAAdd = SAAdd;
pInModule->VSAAddPCMData = VSAAddPCMData;
pInModule->VSAGetMode = VSAGetMode;
pInModule->VSAAdd = VSAAdd;
pInModule->VSASetInfo = VSASetInfo;
pInModule->dsp_isactive = dsp_isactive;
pInModule->dsp_dosamples = dsp_dosamples;
pInModule->EQSet = EQSet;
pInModule->SetInfo = SetInfo;
// 最后初始化一下
pInModule->Init();
}
看看程序能不能正常编译?如果可以,那么开始我们的主题吧,现在回过头来填写上面那些声明在头文件里的重要的函数。你还是不要指望这一次我们就能播出音乐来,因为对于一些初学者来说,他们的基础知识离此还差一些,所以我需要先讲述一下电声学的基本原理,如果你是老鸟,那么就敬请您再一次向下拖动右边的滚动条了。
众所周知,声音是由于空气的振动而使耳膜产生谐振而引起的,振动的频率便是声波的频率,一般人耳的接受能力是20Hz到20000Hz,低于此泛围的叫次声波,高于此泛围的叫超声波。不同的音调的频率当然不一样,比如do、re、mi就是三种不同的频率。那么,不同的乐器发出同要频率的声音,但我们却可以听出它们的不同,这又是怎么回事呢?这就是音色的差别,事实上不同的乐器之间的差别并不在频率上而是在波形上,比如同样频率的声波,在一个周期的1/4阶段部分内陡升而在3/4阶段内缓升和1/4阶段部分内缓升而在3/4阶段内陡升所产生的声音是截然不一样的。
音箱中喇叭的原理其实很简单,就是通过电磁感应现象,将变化的电流转为盆膜的振动而产生空气的振动,所以只要有在人耳能够接受的频率泛围并且足够强大的振动的电流输入喇叭,人耳在足够近的声场距离内就可以得到声压了(听到声音)。当然在多媒体有源音箱上除了喇叭还有运放(电流放大器)和功放(功率放大器)等器件,这些器件的大致作用就是将输入的电流和功率放大至喇叭可以发出声音的泛围内。电脑上的声卡的作用就是将传递给声卡的数字音频数据经过缓冲、处理、分流、转换等操作再通过接口将载有模拟信号的电流传送到音箱,令其发声。
现在对于电声的原理因该比较清楚了,可是声波电信号是一条光滑的曲线,做为只有1和0表示的计算机数据该如何表示这种模拟信号呢?下面我们将引入一个概念“采样率”。比如一种声波的频率是1000Hz,也就代表它一秒钟内将振动1000次,即1000个周期,那么如果需要使这个声音从数字波还原成声波之后还能听个响,那么每一个周期里你就需要至少记录8个采样点才能较为完好的重放。那么这段数据波的采样率就是8*1000 = 8000 Samp/Sec。再来看一个概念“位率”,这个很好理解,如果是8位,那么就是用一个char类型的数据来记录每一个采样点,最大振幅当然就是-128 – 127,如果是16位,那就用一个short来记录,泛围是-32768 – 32767,你不要担心用小的位率造成振幅减小,而导至声音减少,因为声卡得到数据后不管是什么类型的数据,它都会映射到一个泛围内,也就是说8位里127的音量和16位里32767的音量是一样的,只不过16位可以表现更多的动态细节。
现在你是不是会很焦急的发问,说了这么多,那数据倒底是怎么存的呢?别急,我举个例子,比如一个1000Hz的正弦波,我们对它进行数字采样,采样率是8000,位率是16Bit(每一个采样点数据都是short型),单声道,那么它的采样点数据因该是0,16384,32768,16384,0,-16384,-32768,-16384、0、16384……这样循环1000次就代表1秒的1000Hz声波。目前的声卡一般都支持16和8位率,44100、22050、11025等采样率,至于为什么采样率不是整数,这是为了有效减少条幅波现象,这个原理有点复杂,如果你有兴趣可以参考一下相关书籍,我在此就不赘述了。下面我写的这个小程序可以生成指定频率、采样率、位率、时间长度的数据声波。
typedef struct tagWAVEINFO
{
WORD cbSize;
DWORD dwSamplePerSec;
double dSeconds;
WORD wChannels;
WORD wBitsPerSample;
DWORD dwHz;
BYTE byVolume;
DWORD dwMode;
} WAVEINFO, *PWAVEINFO;
// 用PWAVEINFO 指定你需要的声波的类型,函数将按照你的要求创建数据
// pwf用于传出符合WAVE标准的波型信息,pBufLen传出生成的数据长度
// 函数返回生成的数据的指针
void* CreateWaveData( const PWAVEINFO pwi, LPWAVEFORMATEX pwf, DWORD *pBufLen )
{
if ( pwi->cbSize != sizeof(WAVEINFO) )
{
return NULL;
}
double dCurTime;
int nCurValue;
double dCircle = 1.0 / (double)pwi->dwHz; // 周期时间
double dSecPerSample = 1.0 / (double)pwi->dwSamplePerSec; // 采样点距
WORD wBytePerSample = pwi->wBitsPerSample / 8; // 每采样字节数
DWORD dwSampleCount = (DWORD)ceil( pwi->dSeconds * (double)pwi->dwSamplePerSec ); // 全部采样点数
*pBufLen = dwSampleCount * wBytePerSample * pwi->wChannels; // 数据长度
void *pData = new char[*pBufLen];
for ( DWORD i = 0; i < dwSampleCount; i++ )
{
dCurTime = dSecPerSample * (double)i / dCircle;
dCurTime = ( dCurTime - (int)dCurTime ) * dCircle;
nCurValue = (int)(sin( dCurTime * M_PI * 2 / dCircle ) *
pow( 2.0, pwi->wBitsPerSample ) / 2.0 * (double)pwi->byVolume / 255.0);
for ( WORD j = 0; j < pwi->wChannels; j++ )
{
for ( WORD k = 0; k < wBytePerSample; k++ )
{
((char*)pData)[ ( i * pwi->wChannels + j ) * wBytePerSample + k ] =
( nCurValue << ( ( 3 - k ) * 8) ) >> 24;
}
}
}
pwf->cbSize = sizeof(WAVEFORMATEX);
pwf->wFormatTag = WAVE_FORMAT_PCM;
pwf->nChannels = pwi->wChannels;
pwf->nSamplesPerSec = pwi->dwSamplePerSec;
pwf->wBitsPerSample = pwi->wBitsPerSample;
pwf->nBlockAlign = wBytePerSample * pwi->wChannels;
pwf->nAvgBytesPerSec = pwi->dwSamplePerSec * pwf->nBlockAlign;
return pData;
}
在你确定你足够了解上面所说的电声原理之后,下面我将带你到Win32下的WAVE(PCM)格式中去游一遭。PCM的全称是:pulse code modulation(脉冲编码调制),说白了就是间隔采样的机制。WAVE文件的结构如下:
作用 长度 类型 默认值 注释
文件标识 4 char[4] “RIFF” Resource Interchange File Format(资源交换文件格式)WAVE是其中的一种
文件数据长度 4 DWORD N/A 不包括文件标识和数据长度的裸数据的长度
格式标识 4 char[4] “WAVE” 以此来判断是否WAVE文件
信息头标识 4 char[4] “fmt ” 表示下面开始信息头描述
信息头长度 4 DWORD 16或12 表示下面的信息头数据有多长
声道数 2 WORD 1或2 表示单声道或立体声
采样率 4 DWORD 44100等
每秒数据长度 4 DWORD N/A 位率 / 8 * 采样率 * 声道数
每数据包长度 4 WORD N/A 位率 / 8 * 声道数
位率 2 WORD 16或8
数据标识 4 char[4] “data” 表示下面这一块是数据块
数据长度 4 DWORD N/A 表示数据有多长
数据 N/A N/A N/A 数据
其它信息 不需要
上面这些信息是顺序存储的,而且信息头刚好和WAVEFORMATEX结构体对映起来,只是顺序有点乱,可千万不要搞差了。很简单是吧?现在你可以用上面的那个声波生成器生成一段声音数据,然后写一个WAVE文件试一下,看能不能用Winamp放出来。
说了这么多,相信菜鸟也对这些东东有些了解了,那么现在我们回过头去继续编写我们的输出模块。先确定一下我们要编写怎样的一个输出模块?输出到声卡吗?那太难了,让我们先来点简单的测一下,输出一个WAVE文件。文件操作模块我个人认为使用STL库里的fstream是很方便的,所以你得先在MusicPlayer.cpp的顶上定一个文件全局变量来打开你需要写的文件:
ofstream file( “文件名”, ios_base::out | ios_base::binary );
另外还需要两个全局变量:
DWORD g_dwWrited; // 当前写入的数据的长度
DWORD g_dwBytePerSec; // 每秒字节数
在调用In_Module的Play函数后,也就是音乐开始播放后,In_Module会调用Out_Module的Open函数,并指定数据信息,所以我们应该在这里写下Wave 文件的头信息,按照上面说的WAVE文件格式,我们这样写Open函数:
int OutOpen( int nSampleRate, int nChannels, int nBitPerSample, int nBufLen, int nPreBufLen )
{
DWORD dwFlag;
dwFlag = (DWORD)'RIFF';
file.write( (char*)&dwFlag, 4 );
file.write( (char*)&dwFlag, 4 ); // 空出4个字节写数据长度
dwFlag = (DWORD)'WAVE';
file.write( (char*)&dwFlag, 4 );
dwFlag = (DWORD)'fmt ';
file.write( (char*)&dwFlag, 4 );
WAVEFORMATEX wf;
ZeroMemory( &wf, sizeof(wf) );
wf.cbSize = sizeof(wf);
wf.nSamplesPerSec = nSampleRate;
wf.wBitsPerSample = nBitPerSample;
wf.nChannels = nChannels;
wf.nBlockAlign = nBitPerSample / 8 * nChannels;
wf.nAvgBytesPerSec = wf.nBlockAlign * nSampleRate;
wf.wFormatTag = WAVE_FORMAT_PCM;
g_dwBytePerSec = wf.nAvgBytesPerSec;
file.write( (char*)&wf.cbSize, sizeof(wf.cbSize) ); // 写低位
wf.cbSize = 0;
file.write( (char*)&wf.cbSize, sizeof(wf.cbSize) ); // 写高位,0
file.write( (char*)&wf.wFormatTag, sizeof(wf)-sizeof(wf.cbSize) ); //跟据偏移量一次性写入
dwFlag = (DWORD)'data';
file.write( (char*)&dwFlag, 4 );
}
在开始播放之后,In_Module会反复执行下面的过程:调用Out_Module的OutCanWrite函数确定是否可写后就会调用Out_Module的OutWrite函数向其写数据。因为在这里是文件操作,所以我们在OutCanWrite函数使终返回0x1000就行。
int OutCanWrite( void )
{
return 0x1000;
}
OutWrite函数当然更简单,只需要简单的把数据写入文件,并把数据长度累加就行了。
int OutWrite( char *pBuf, int nLen )
{
file.write( pBuf, nLen );
g_dwWrited += nLen;
}
[1] [2]
