在本章中,我们考虑构建交互式移动应用程序所需的最后一个非视觉组件。我们正在为安卓和桌面电脑寻找一个真正便携的音频播放实现。我们建议使用 OpenAL 库,因为它是桌面平台上一个成熟的库。音频回放本质上是一个异步的过程,所以解码和向声音应用编程接口提交数据应该在一个单独的线程上完成。我们将基于第三章、联网中的多线程代码创建一个音频流库。
未压缩的原始音频会占用大量内存,因此经常使用不同风格的压缩格式。我们在本章中考虑了其中的一些,并将向您展示如何使用本机 C++ 代码和流行的第三方库在 Android 中播放它们。
我们在本章通篇使用 OpenAL 跨平台音频库。为了让所有的例子变得简单和独立,我们从最简单的例子开始,它可以从一个未压缩的.wav
文件中播放声音。
让我们简单描述一下为了产生声音我们需要做什么。OpenAL 的例程处理回放和记录过程中遇到的对象。ALCdevice
对象代表音频硬件的单位。由于多个线程可能同时产生声音,因此引入了另一个名为ALCcontext
的对象。首先,应用程序打开一个设备,然后创建一个上下文并附加到打开的设备。每个上下文维护许多Audio Source
对象,因为即使是一个应用程序也可能需要同时播放多个声音。
我们正在接近实际的声音制作。还需要一个对象作为波形容器,称为缓冲区。录音可能会很长,所以我们不会将整个声音作为一个缓冲区提交。我们以小块读取样本,并使用几个缓冲区(通常是几个)将这些块提交到音频源队列中。
以下伪代码描述了如何播放完全符合记忆的声音:
- 首先打开一个设备,创建一个上下文,并将该上下文附加到该设备。
- 创建一个音频源,分配一个声音缓冲区。
- 将波形数据载入缓冲器。
- 将缓冲区排入音频源。
- 等待播放完成。
- 销毁缓冲区、源和上下文,并关闭设备。
在第 5 步有一个明显的问题。我们不能在几秒钟内阻塞应用程序的用户界面线程,因此声音回放必须是异步的。幸运的是,OpenAL 调用是线程安全的,我们可以在单独的线程中执行回放,而无需自己进行任何 OpenAL 同步。
我们来看看例子1_InitOpenAL
。为了在步骤 3 中执行波形加载并保持代码尽可能简单,我们获取一个.wav
文件并将其加载到一个clBlob
对象中。在步骤 2 中,我们创建一个音频源和一个缓冲区,其参数对应于WAV
头中的参数。步骤 1、4 和 6 仅由一些 OpenAL API 调用组成。步骤 5 是通过原子条件变量上的忙等待循环完成的。
本例的本机 C++ 入口点从创建一个单独的音频线程开始,该线程被声明为一个全局对象g_Sound
。g_FS
对象包含用于从文件加载音频数据的clFileSystem
类的实例:
clSoundThread g_Sound;
clPtr<clFileSystem> g_FS;
int main()
{
g_FS = make_intrusive<clFileSystem>();
g_FS->Mount( "." );
g_Sound.Start();
g_Sound.Exit( true );
return 0;
}
clSoundThread
类包含一个 OpenAL 设备和一个上下文。对于这个一源一缓冲的例子,也声明了音频源和缓冲句柄:
class clSoundThread: public iThread
{
ALCdevice* FDevice;
ALCcontext* FContext;
ALuint FSourceID;
ALuint FBufferID;
方法Run()
完成所有的初始化、加载和终结:
virtual void Run()
{
要使用 OpenAL 例程,我们应该加载库。对于安卓、Linux 和 OS X 来说,实现起来很容易,我们只需要使用一个静态链接库,就是这样。但是,对于 Windows,我们加载OpenAL32.dll
文件,并从动态链接库中检索所有必要的函数指针:
LoadAL();
首先,我们打开一个设备并创建一个上下文。alcOpenDevice()
的nullptr
参数意味着我们使用默认的声音设备:
FDevice = alcOpenDevice( nullptr );
FContext = alcCreateContext( FDevice, nullptr );
alcMakeContextCurrent( FContext );
然后我们创建一个音频源,并将其音量设置为最大级别:
alGenSources( 1, &FSourceID );
alSourcef( FSourceID, AL_GAIN, 1.0 );
对应于伪代码中步骤 3 的波形加载是通过将整个.wav
文件读入clBlob
对象来完成的:
auto data = LoadFileAsBlob( g_FS, "test.wav" );
可以通过以下方式访问标题:
const sWAVHeader* Header = ( const sWAVHeader* )Blob->GetData();
我们将clBlob
中的字节复制到声音缓冲区中,跳过与报头大小相对应的字节数:
const unsigned char* WaveData = ( const unsigned char* )Blob->GetData() +
sizeof( sWAVHeader );
PlayBuffer( WaveData, Header->DataSize,
Header->SampleRate );
现在让我们只忙着等待声音结束:
while ( IsPlaying() ) {}
最后,我们停止源代码,删除所有对象并卸载 OpenAL 库:
alSourceStop( FSourceID );
alDeleteSources( 1, &FSourceID );
alDeleteBuffers( 1, &FBufferID );
alcDestroyContext( FContext );
alcCloseDevice( FDevice );
UnloadAL();
}
clSoundThread
类还包含两个助手方法。IsPlaying()
方法通过请求声音的状态来检查声音是否仍在播放:
bool IsPlaying() const
{
int State;
alGetSourcei( FSourceID, AL_SOURCE_STATE, &State );
return State == AL_PLAYING;
}
PlayBuffer()
方法创建一个缓冲对象,用来自Data
参数的波形填充它,并开始回放:
void PlayBuffer( const unsigned char* Data, int DataSize, int SampleRate )
{
alBufferData( FBufferID, AL_FORMAT_MONO16,
Data, DataSize, SampleRate );
alSourcei( FSourceID, AL_BUFFER, FBufferID );
alSourcei( FSourceID, AL_LOOPING, AL_FALSE );
alSourcef( FSourceID, AL_GAIN, 1.0f );
alSourcePlay( FSourceID );
}
前面的代码依赖于两个全局函数。Env_Sleep()
休眠给定的毫秒数。Windows 版本的代码与安卓和 OS X 略有不同:
void Env_Sleep( int Milliseconds )
{
#if defined(_WIN32)
Sleep( Milliseconds );
#elif defined(ANDROID)
std::this_thread::sleep_for(
std::chrono::milliseconds( Milliseconds ) );
#else
usleep( static_cast<useconds_t>( Milliseconds ) * 1000 );
#endif
}
我们在 Windows 上使用Sleep()
是为了兼容一些缺乏std::chrono
支持的 MinGW 发行版。如果你想用 Visual Studio,就坚持用std::this_thread::sleep_for()
。
LoadFileAsBlob()
函数使用所提供的clFileSystem
对象将文件的内容加载到内存块中。我们在大多数后续代码示例中重用了这个例程。
clPtr<clBlob> LoadFileAsBlob( const clPtr<clFileSystem>& FileSystem, const std::string& Name )
{
auto Input = FileSystem->CreateReader( Name );
auto Res = make_intrusive<clBlob>();
Res->AppendBytes( Input->MapStream(), Input->GetSize() );
return Res;
}
如果你通过输入make all
在台式机器上编译运行这个例子,你应该会听到一个短暂的叮声。让我们继续前进,在我们最终使用安卓应用程序之前,学习如何进行声音流。
现在我们可以播放短音频样本,是时候将我们的音频系统组织成类,仔细看看2_Streaming
示例了。长音频样本(如背景音乐)需要大量解压缩形式的内存。流式传输是一种将它们一点一点地分成小块解压缩的技术。clAudioThread
类管理初始化,除了播放声音之外,执行上一个示例中的所有操作:
class clAudioThread: public iThread
{
public:
clAudioThread()
: FDevice( nullptr )
, FContext( nullptr )
, FInitialized( false )
{}
virtual void Run()
{
if ( !LoadAL() ) { return; }
FDevice = alcOpenDevice( nullptr );
FContext = alcCreateContext( FDevice, nullptr );
alcMakeContextCurrent( FContext );
FInitialized = true;
while ( !IsPendingExit() ) { Env_Sleep( 100 ); }
alcDestroyContext( FContext );
alcCloseDevice( FDevice );
UnloadAL();
}
此方法用于将音频线程的开始与其用户同步:
virtual void WaitForInitialization() const
{
while ( !FInitialized ) {}
}
private:
std::atomic<bool> FInitialized;
ALCdevice* FDevice;
ALCcontext* FContext;
};
clAudioSource
类代表一个单独的发声实体。波形数据没有存储在源本身中,我们推迟了对clAudioSource
类的描述。现在,我们介绍iWaveDataProvider
接口类,它为下一个音频缓冲区提供数据。对iWaveDataProvider
实例的引用存储在clAudioSource
类中:
class iWaveDataProvider: public iIntrusiveCounter
{
public:
音频信号属性存储在这三个字段中:
int FChannels;
int FSamplesPerSec;
int FBitsPerSample;
iWaveDataProvider()
: FChannels( 0 )
, FSamplesPerSec( 0 )
, FBitsPerSample( 0 ) {}
两种纯虚拟方法可以访问音频源播放的当前波形数据。它们将在实际的解码器子类中实现:
virtual unsigned char* GetWaveData() = 0;
virtual size_t GetWaveDataSize() const = 0;
IsStreaming()
方法告诉我们这个提供者是代表一个连续的流还是一个音频数据块,就像前面例子中的那个。StreamWaveData()
方法在GetWaveData()
函数访问的缓冲区中加载、解码或生成值;它通常也在子类中实现。当clAudioSource
需要更多音频数据入队到缓冲区时,它调用StreamWaveData()
方法:
virtual bool IsStreaming() const { return false; }
virtual int StreamWaveData( int Size ) { return 0; }
最后一个辅助函数返回 OpenAL 使用的内部数据格式。这里,我们只支持立体声和单声道信号,每个采样 8 或 16 位:
ALuint GetALFormat() const
{
if ( FBitsPerSample == 8 )
return ( FChannels == 2 ) ?
AL_FORMAT_STEREO8 : AL_FORMAT_MONO8;
if ( FBitsPerSample == 16 )
return ( FChannels == 2 ) ?
AL_FORMAT_STEREO16 : AL_FORMAT_MONO16;
return AL_FORMAT_MONO8;
}
};
我们基本的声音解码是在clStreamingWaveDataProvider
班完成的。它包含FBuffer
数据向量和其中的有用字节数:
class clStreamingWaveDataProvider: public iWaveDataProvider
{
public:
clStreamingWaveDataProvider()
: FBufferUsed( 0 )
{}
virtual bool IsStreaming() const override
{ return true; }
virtual unsigned char* GetWaveData() override
{ return ( unsigned char* )&FBuffer[0]; }
virtual size_t GetWaveDataSize() const override
{ return FBufferUsed; }
std::vector<char> FBuffer;
size_t FBufferUsed;
};
我们准备描述做实际重举的类clAudioSource
。构造函数创建一个 OpenAL 音频源对象,设置音量并禁用循环:
class clAudioSource: public iIntrusiveCounter
{
public:
clAudioSource()
: FWaveDataProvider( nullptr )
, FBuffersCount( 0 )
{
alGenSources( 1, &FSourceID );
alSourcef( FSourceID, AL_GAIN, 1.0 );
alSourcei( FSourceID, AL_LOOPING, AL_FALSE );
}
我们有两个不同的用例。如果附带的iWaveDataProvider
支持流媒体,我们至少需要创建和维护两个声音缓冲区。两个缓冲区都被排入 OpenAL 播放队列,一旦其中一个缓冲区播放完毕,就会被交换。在每个交换事件中,我们调用iWaveDataProvider
的StreamWaveData()
方法将数据流式传输到下一个音频缓冲区。如果iWaveDataProvider
没有流式传输,我们只需要一个在开始初始化的缓冲区。
Play()
方法用解码数据填充两个缓冲区,并调用alSourcePlay()
开始回放:
void Play()
{
if ( IsPlaying() ) { return; }
if ( !FWaveDataProvider ) { return; }
int State;
alGetSourcei( FSourceID, AL_SOURCE_STATE, &State );
if ( State != AL_PAUSED && FWaveDataProvider->IsStreaming() )
{
UnqueueAll();
StreamBuffer( FBufferID[0], BUFFER_SIZE );
StreamBuffer( FBufferID[1], BUFFER_SIZE );
alSourceQueueBuffers( FSourceID, 2, &FBufferID[0] );
}
alSourcePlay( FSourceID );
}
Stop()
和Pause()
方法分别调用适当的 OpenAL 例程来停止和暂停回放:
void Stop()
{
alSourceStop( FSourceID );
}
void Pause()
{
alSourcePause( FSourceID );
UnqueueAll();
}
LoopSound()
和SetVolume()
方法控制回放参数:
void LoopSound( bool Loop )
{
alSourcei( FSourceID, AL_LOOPING, Loop ? 1 : 0 );
}
void SetVolume( float Volume )
{
alSourcef( FSourceID, AL_GAIN, Volume );
}
IsPlaying()
方法是从前面的例子中复制过来的:
bool IsPlaying() const
{
int State;
alGetSourcei( FSourceID, AL_SOURCE_STATE, &State );
return State == AL_PLAYING;
}
StreamBuffer()
方法将新生成的音频数据写入一个缓冲器:
int StreamBuffer( unsigned int BufferID, int Size )
{
int ActualSize = FWaveDataProvider->StreamWaveData( Size );
alBufferData( BufferID,
FWaveDataProvider->GetALFormat(),
FWaveDataProvider->GetWaveData(),
( int )FWaveDataProvider->GetWaveDataSize(),
FWaveDataProvider->FSamplesPerSec );
return ActualSize;
}
Update()
方法的调用频率应足以防止音频缓冲区下溢。然而,该方法仅在所附的iWaveDataProvider
代表音频流时才起作用:
void Update( float DeltaSeconds )
{
if ( !FWaveDataProvider ) { return; }
if ( !IsPlaying() ) { return; }
if ( FWaveDataProvider->IsStreaming() )
{
我们询问 OpenAL 已经处理了多少缓冲区:
int Processed;
alGetSourcei( FSourceID, AL_BUFFERS_PROCESSED, &Processed );
我们从队列中移除每个已处理的缓冲区,并调用StreamBuffer()
来解码更多的数据。最后,我们将缓冲区读入回放队列:
while ( Processed-- )
{
unsigned int BufID;
alSourceUnqueueBuffers( FSourceID, 1, &BufID );
StreamBuffer( BufID, BUFFER_SIZE );
alSourceQueueBuffers( FSourceID, 1, &BufID );
}
}
}
析构器停止播放并破坏 OpenAL 音频源和缓冲区:
virtual ~clAudioSource()
{
Stop();
alDeleteSources( 1, &FSourceID );
alDeleteBuffers( FBuffersCount, &FBufferID[0] );
}
BindWaveform()
方法将新的iWaveDataProvider
附加到该音频源实例:
void BindWaveform( clPtr<iWaveDataProvider> Wave )
{
FWaveDataProvider = Wave;
if ( !Wave ) { return; }
对于一个流iWaveDataProvider
,我们需要两个缓冲区。一个正在播放,另一个正在更新:
if ( FWaveDataProvider->IsStreaming() )
{
FBuffersCount = 2;
alGenBuffers( FBuffersCount, &FBufferID[0] );
}
else
如果附加的波形不是一个流,或者更确切地说没有被压缩,我们创建一个单独的缓冲区,并将所有数据复制到其中:
{
FBuffersCount = 1;
alGenBuffers( FBuffersCount, &FBufferID[0] );
alBufferData( FBufferID[0],
FWaveDataProvider->GetALFormat(),
FWaveDataProvider->GetWaveData(),
( int )FWaveDataProvider->GetWaveDataSize(),
FWaveDataProvider->FSamplesPerSec );
alSourcei( FSourceID, AL_BUFFER, FBufferID[0] );
}
}
私有UnqueueAll()
方法使用alSourceUnqueueBuffers()
清除开放回放队列:
private:
void UnqueueAll()
{
int Queued;
alGetSourcei( FSourceID, AL_BUFFERS_QUEUED, &Queued );
if ( Queued > 0 )
{
alSourceUnqueueBuffers( FSourceID, Queued, &FBufferID[0] );
}
}
这个类的尾部定义了对附加的iWaveDataProvider
的引用、OpenAL 对象的内部句柄和分配的缓冲区数量:
clPtr<iWaveDataProvider> FWaveDataProvider;
unsigned int FSourceID;
unsigned int FBufferID[2];
int FBuffersCount;
};
为了演示一些基本的流功能,我们从1_InitOpenAL
开始更改示例代码,并使用附带的音调发生器创建一个音频源,如以下代码所述:
class clSoundThread: public iThread
{
virtual void Run()
{
g_Audio.WaitForInitialization();
auto Src = make_intrusive<clAudioSource>();
Src->BindWaveform( make_intrusive<clToneGenerator>() );
Src->Play();
double Seconds = Env_GetSeconds();
while ( !IsPendingExit() )
{
float DeltaSeconds = static_cast<float>( Env_GetSeconds() - Seconds );
Src->Update( DeltaSeconds );
Seconds = Env_GetSeconds();
}
}
};
在这个的例子中,我们特意避免了解压声音的问题,专注于流逻辑。所以我们从程序产生的声音开始。clToneGenerator
类覆盖StreamWaveData()
方法,生成正弦波或纯音。为了避免听得见的毛刺,我们必须仔细采样正弦函数,并记住最后生成的样本的整数索引。该索引存储在FLastOffset
字段中,用于每次迭代的计算。
类的构造函数将音频参数设置为 16 位 44.1kHz,并在FBuffer
容器中分配一些空间。该音调的基本频率设置为 440 赫兹:
class clToneGenerator : public clStreamingWaveDataProvider
{
public:
clToneGenerator()
: FFrequency( 440.0f )
, FAmplitude( 350.0f )
, FLastOffset( 0 )
{
FBufferUsed = 100000;
FBuffer.resize( 100000 );
FChannels = 2;
FSamplesPerSec = 44100;
FBitsPerSample = 16;
}
在StreamWaveData()
中,我们检查FBuffer
向量中的可用空间,并在必要时重新分配:
virtual int StreamWaveData( int Size )
{
if ( Size > static_cast<int>( FBuffer.size() ) )
{
FBuffer.resize( Size );
LastOffset = 0;
}
最后,我们计算音频样本。基于样本计数重新计算频率:
const float TwoPI = 2.0f * 3.141592654f;
float Freq = TwoPI * FFrequency /
static_cast<float>( FSamplesPerSec );
由于我们需要Size
字节,并且我们的信号包含两个具有 16 位样本的通道,因此我们总共需要Size/4
个样本:
for ( int i = 0 ; i < Size / 4 ; i++ )
{
float t = Freq * static_cast<float>( i + LastOffset );
float val = FAmplitude * std::sin( t );
我们将浮点值转换为 16 位有符号整数,并将该整数的低字节和高字节放入FBuffer
。对于每个通道,我们存储两个字节:
short V = static_cast<short>( val );
FBuffer[i * 4 + 0] = V & 0xFF;
FBuffer[i * 4 + 1] = V >> 8;
FBuffer[i * 4 + 2] = V & 0xFF;
FBuffer[i * 4 + 3] = V >> 8;
}
计算后,我们增加样本计数并取余数,以避免计数器中的整数溢出:
LastOffset += Size / 4;
LastOffset %= FSamplesPerSec;
return ( FBufferUsed = Size );
}
float FFrequency;
float FAmplitude;
private:
int LastOffset;
};
编译后的示例将产生 440 赫兹的纯音。我们鼓励您改变clToneGenerator::FFrequency
的值,看看它是如何工作的。您甚至可以使用这个例子为您的乐器创建一个简单的音叉应用程序。至于乐器,让我们生成一些音频数据来模仿弦乐器。
让我们用前面例子中的代码实现一个简单的弦乐器物理模型。以后你可以用这些例程为安卓创建一个小的交互合成器。
弦被模拟成一系列垂直振动的点质量。严格地说,我们求解具有一定初始条件和边界条件的线性一维波动方程。声音是通过获取拾音位置的解的值而产生的。
我们需要clGString
类来存储所有的模型值和最终结果。方法GenerateSound()
预先计算字符串参数,并相应地调整数据容器的大小:
class clGString
{
public:
void GenerateSound()
{
// 4 seconds, 1 channel, 16 bit
FSoundLen = 44100 * 4 * 2;
FStringLen = 200;
Frc
值是声音的归一化基频。泛音是由物理模型隐式创建的:
float Frc = 0.5f;
InitString( Frc );
FSamples.resize( FsoundLen );
FSound.resize( FsoundLen );
float MaxS = 0;
在初始化阶段之后,我们通过循环调用Step()
方法来执行波动方程的积分。Step()
成员函数返回弦在拾取位置的位移:
for ( int i = 0; i < FSoundLen; i++ )
{
FSamples[i] = Step();
在每个步骤中,我们将该值限制在最大值:
if ( MaxS < fabs(FSamples[i]) )
MaxS = fabs( FSamples[i] );
}
最后,我们将浮点值转换为有符号短整数。为避免溢出,每个样本除以MaxS
值:
const float SignedShortMax = 32767.0f;
float k = SignedShortMax / MaxS;
for ( int i = 0; i < FSoundLen; i++ )
{
FSound [i] = FSamples [i] * k;
}
}
std::vector<short int> FSound;
private:
int FPickPos;
int FSoundLen;
std::vector<float> FSamples;
std::vector<float> FForce;
std::vector<float> FVel;
std::vector<float> FPos;
float k1, k2;
int FStringLen;
void InitString(float Freq)
{
FPos.resize(FStringLen);
FVel.resize(FStringLen);
FForce.resize(FStringLen);
const float Damping = 1.0f / 512.0f;
k1 = 1 - Damping;
k2 = Damping / 2.0f;
我们将拾音器放在更靠近末端的位置:
FPickPos = FStringLen * 5 / 100;
for ( int i = 0 ; i < FStringLen ; i++ )
{
FVel[i] = FPos[i] = 0;
}
为了获得更好的结果,我们在弦元素的质量上产生一个微小的变化:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
float m = 1.0f + 0.5f * (frand() - 0.5f);
FForce[i] = Freq / m;
}
开始时,我们为弦的第二部分设置非零速度:
for ( int i = FStringLen/2; i < FStringLen - 1; i++ )
{
FVel[i] = 1;
}
}
frand()
成员函数返回一个 0 中的伪随机浮点值..1 范围:
inline float frand()
{
return static_cast<float>( rand() ) / static_cast<float>( RAND_MAX );
}
如果您的编译器支持,使用std::random
是获取伪随机数的首选方式。
以下是使用新的 C++ 11 标准库生成均匀分布在 0…1 范围内的伪随机浮点数的方法:
std::random_device rd;
std::mt19937 gen( rd() );
std::uniform_real_distribution<> dis( 0.0, 1.0 );
float frand()
{
return static_cast<float>( dis( gen ) );
}
虽然这个简短的代码片段没有在我们的源代码包中使用,但它可能对您有用。让我们回到我们例子的代码。
Step()
方法单步执行并整合弦线运动方程。在该步骤结束时,来自位于FPickPos
位置的FPos
向量的值被作为声音的下一个样本。对于熟悉数值方法的读者来说,似乎很奇怪没有时间步长规范,隐式地它是 1/44100 秒:
float Step()
{
首先,我们强制边界条件,它们是字符串两端的固定端点:
FPos[0] = FPos[FStringLen - 1] = 0;
FVel[0] = FVel[FStringLen - 1] = 0;
根据胡克定律(http://en.wikipedia.org/wiki/Hooke's_law,力与延伸成正比:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
float d = (FPos[i - 1] + FPos[i + 1]) * 0.5f - FPos[i];
FVel[i] += d * FForce[i];
}
为了保证数值的稳定性,我们应用了一些人工阻尼并取相邻速度的平均值。否则会产生一些不想要的叮当声:
for ( int i = 1 ; i < FStringLen - 1 ; i++ )
{
FVel[i] = FVel[i] * k1 +
(FVel[i - 1] + FVel[i + 1]) * k2;
}
最后,我们更新位置:
for ( int i = 1 ; i < FStringLen ; i++ )
{
FPos[i] += FVel[i];
}
为了记录我们的声音,我们只取弦的一个位置:
return FPos[FPickPos];
}
};
1_InitOpenAL
示例很容易修改为生成字符串声音,而不是加载.wav
文件。我们创建clGString
实例并调用GenerateSound()
方法。之后,我们获得FSound
向量,并将其提交给音频源的PlayBuffer()
方法:
clGString String;
String.GenerateSound();
const unsigned char* Data = (const unsigned char*)&String.FSound[0];
PlayBuffer( Data, (int)String.FSound.size() );
这里,采样率被硬编码为 44100 赫兹。完整代码试试3_GuitarStringSound
例子,自己听听。请注意,由于在播放声音之前进行了大量的预先计算,启动时间可能会有点长。然而,代码非常简单,我们将它作为一个练习留给读者来为安卓编译它,从后续的例子中获取所有必要的 makefiles 和包装器。与此同时,我们将做可以在安卓系统上现成运行的东西。
现在我们已经实现了基本的音频流系统,是时候使用几个第三方库来读取压缩的音频文件了。基本上,我们需要做的就是覆盖clStreamingWaveDataProvider
类内部的StreamWaveData()
函数。该函数依次调用ReadFromFile()
方法,在该方法中完成实际解码。解码器的初始化在构造函数中完成,对于抽象的iDecodingProvider
类,我们只存储对数据块的引用。文件的所有压缩数据都存储在一个clBlob
对象中:
class iDecodingProvider: public StreamingWaveDataProvider
{
protected:
virtual int ReadFromFile( int Size, int BytesRead ) = 0;
clPtr<clBlob> FRawData;
public:
bool FLoop;
bool FEof;
iDecodingProvider( const clPtr<clBlob>& Blob )
: FRawData( Blob )
, FLoop( false )
, FEof( false )
{}
virtual bool IsEOF() const { return FEof; }
StreamWaveData()
方法完成解码工作。前几行确保FBuffer
中有足够的空间容纳解码数据:
virtual int StreamWaveData( int Size ) override
{
int OldSize = ( int )FBuffer.size();
if ( Size > OldSize )
{
重新分配缓冲区后,我们用零填充新字节,因为非零值会产生意外的噪音:
FBuffer.resize( Size, 0 );
}
if ( FEof ) { return 0; }
由于ReadFromFile()
可能会返回不足的数据,我们称之为循环增加读取的字节数:
int BytesRead = 0;
while ( BytesRead < Size )
{
int Ret = ReadFromFile( Size, BytesRead );
if ( Ret > 0 ) BytesRead += Ret;
从ReadFromFile()
返回零的值意味着我们已经到达流的末尾:
else if ( Ret == 0 )
{
FEof = true;
通过调用Seek()
并设置FEof
标志来完成循环:
if ( FLoop )
{
Seek( 0 );
FEof = false;
continue;
}
break;
}
Ret
中的负值表示出现了读数错误。在这种情况下,我们停止解码:
else
{
Seek( 0 );
FEof = true;
break;
}
}
return ( FBufferUsed = BytesRead );
}
};
接下来的两节将展示如何使用流行的第三方库解码不同格式的音频文件。
我们将着手解码音频文件的第一个库是奥利维尔·拉皮克的 ModPlug 库。最流行的追踪器音乐文件格式http://en.wikipedia.org/wiki/Module_file可以使用 ModPlug 解码并转换成适合 OpenAL 的波形。我们将介绍实现ReadFromFile()
例程的clModPlugProvider
类。该类的构造函数将内存块加载到ModPlugFile
对象中,并分配默认音频参数:
class clModPlugProvider: public iDecodingProvider
{
private:
ModPlugFile* FModFile;
public:
ModPlugProvider( const clPtr<clBlob>& Blob ):
{
DecodingProvider( Blob )
FChannels = 2;
FSamplesPerSec = 44100;
FBitsPerSample = 16;
FModFile = ModPlug_Load_P(
( const void* ) FRawData->GetDataConst(), ( int )FRawData->GetSize()
);
}
析构函数清理 ModPlug:
virtual ~ModPlugProvider() { ModPlug_Unload_P( FModFile ); }
ReadFromFile()
方法调用ModPlug_Read()
填充FBuffer
:
virtual int ReadFromFile( int Size, int BytesRead )
{
return ModPlug_Read_P( FModFile,
&FBuffer[0] + BytesRead, Size - BytesRead );
}
使用ModPlug_Seek()
例程进行流定位。在 ModPlug 应用编程接口中,所有的计时都是以毫秒为单位进行的:
virtual void Seek( float Time )
{
FEof = false;
ModPlug_Seek_P( FModFile, ( int )( Time * 1000.0f ) );
}
};
为了使用这个波形数据提供程序,我们将其实例附加到一个clAudioSource
对象:
Src->BindWaveform( make_intrusive<clModPlugProvider>( LoadFileAsBlob( g_FS, "augmented_emotions.xm" )
)
);
其他细节从我们之前的例子中重用。 4_ModPlug
文件夹可以在安卓和 Windows 上构建和运行。使用ndk-build
和ant
T3 为安卓创建.apk
,使用make all
创建一个 Windows 可执行文件。
大多数 MPEG-1 第三层格式的专利在 2015 年底到期,所以值得一提的是法布里斯·贝拉的 MiniMP3 库。使用这个库并不比 ModPlug 难,因为我们已经完成了iDecodingProvider
中所有的繁重工作。我们来看看5_MiniMP3
的例子。clMP3Provider
类创建解码器实例,并通过从头开始读取一些帧来读取流参数:
class clMP3Provider: public iDecodingProvider
{
public:
clMP3Provider( const clPtr<clBlob>& Blob )
: iDecodingProvider( Blob )
{
FBuffer.resize(MP3_MAX_SAMPLES_PER_FRAME * 8);
FBufferUsed = 0;
FBitsPerSample = 16;
mp3 = mp3_create();
bytes_left = ( int )FRawData->GetSize();
开始时,我们将流位置设置为clBlob
对象的开头:
stream_pos = 0;
byte_count = mp3_decode((mp3_decoder_t*)mp3,
( void* )FRawData->GetData(), bytes_left,
(signed short*)&FBuffer[0], &info);
bytes_left -= byte_count;
我们需要关于音频数据的信息,所以我们从info
结构中获取它:
FSamplesPerSec = info.sample_rate;
FChannels = info.channels;
}
析构函数没有什么特别的,这里是:
virtual ~MP3Provider()
{
mp3_done( &mp3 );
}
ReadFromFile()
方法跟踪源流中剩余的字节,并填充FBuffer
容器。构造函数和该方法都使用bytes_left
和stream_pos
字段来保持当前流位置和剩余字节数:
virtual int ReadFromFile( int Size, int BytesRead )
{
byte_count = mp3_decode( (mp3_decoder_t*)mp3, (( char* )FRawData->GetData()) + stream_pos, bytes_left, (signed short *)(&FBuffer[0] + BytesRead), &info);
bytes_left -= byte_count;
stream_pos += byte_count;
return info.audio_bytes;
}
对于可变比特率流来说,查找并不那么明显,所以我们将这个实现留给感兴趣的读者。最简单的固定比特率情况应该是从秒到采样率单位重新计算Time
,然后设置stream_pos
变量:
virtual void Seek( float Time ) override
{
FEof = false;
}
private:
mp3_decoder_t mp3;
mp3_info_t info;
int stream_pos;
int bytes_left;
int byte_count;
};
为了使用它,我们将提供者附加到一个clAudioSource
对象,就像使用 ModPlug 一样:
Src->BindWaveform( make_intrusive<clMP3Provider>( LoadFileAsBlob( g_FS, "test.mp3" ) ) );
同样,这个例子可以在安卓上运行,去试试吧。
这段代码不能正确处理一些 ID3 标签。如果你想根据我们的代码写一个通用的音乐播放器,参考这个由作者写的开源项目:https://github.com/corporateshark/PortAMP。
还有另一种流行的音频格式值得一提。Ogg Vorbis 是一项完全开放、无专利的专业音频编码和流媒体技术,具备 Open 的所有优势来源http://www.vorbis.com。OGG 解码和回放过程的大图类似于 MP3。我们来看一下例子6_OGG
。Decoders.cpp
文件扩展了,定义了 OGG 沃尔比斯函数OGG_clear_func()
、OGG_open_callbacks_func()
、OGG_time_seek_func()
、OGG_read_func()
、OGG_info_func()
和OGG_comment_func()
。这些功能与安卓系统上的静态库相关联,或者从视窗系统上的.dll
文件中加载。与 MiniMP3 API 相比,主要区别在于向 OGG 解码器提供了一组数据读取回调。这些回调在OGG_Callbacks.inc
文件中实现。OGG_ReadFunc()
回调将数据读入解码器:
static size_t OGG_ReadFunc( void* Ptr, size_t Size, size_t NMemB, void* DataSource )
{
clOggProvider* OGG = static_cast<clOggProvider*>( DataSource );
size_t DataSize = OGG->FRawData->GetSize();
ogg_int64_t BytesRead = DataSize - OGG->FOGGRawPosition;
ogg_int64_t BytesSize = Size * NMemB;
if ( BytesSize < BytesRead ) { BytesRead = BytesSize; }
它基于我们的文件系统抽象和内存映射文件:
memcpy(Ptr, ( unsigned char* )OGG->FRawData->GetDataConst() +
OGG->FOGGRawPosition, ( size_t )BytesRead );
OGG->FOGGRawPosition += BytesRead;
return ( size_t )BytesRead;
}
OGG_SeekFunc()
回调使用不同的相对定位模式寻找输入流:
static int OGG_SeekFunc( void* DataSource, ogg_int64_t Offset, int Whence )
{
clOggProvider* OGG = static_cast<clOggProvider*>( DataSource );
size_t DataSize = OGG->FRawData->GetSize();
if ( Whence == SEEK_SET )
{
OGG->FOGGRawPosition = Offset;
}
else if ( Whence == SEEK_CUR )
{
OGG->FOGGRawPosition += Offset;
}
else if ( Whence == SEEK_END )
{
OGG->FOGGRawPosition = DataSize + Offset;
}
if ( OGG->FOGGRawPosition > ( ogg_int64_t )DataSize )
{
OGG->FOGGRawPosition = ( ogg_int64_t )DataSize;
}
return static_cast<int>( OGG->FOGGRawPosition );
}
OGG_CloseFunc()
和OGG_TellFunc()
功能微不足道:
static int OGG_CloseFunc( void* DataSource )
{
return 0;
}
static long OGG_TellFunc( void* DataSource )
{
return static_cast<int>(
(( clOggProvider* )DataSource )->FOGGRawPosition );
}
这些回调在clOggProvider
的构造器中用于设置解码器:
clOggProvider( const clPtr<clBlob>& Blob )
: iDecodingProvider( Blob )
, FOGGRawPosition( 0 )
{
ov_callbacks Callbacks;
Callbacks.read_func = OGG_ReadFunc;
Callbacks.seek_func = OGG_SeekFunc;
Callbacks.close_func = OGG_CloseFunc;
Callbacks.tell_func = OGG_TellFunc;
OGG_ov_open_callbacks( this, &FVorbisFile, nullptr, -1, Callbacks );
这里检索流参数,如通道数、采样率和每个样本的位数:
vorbis_info* VorbisInfo = OGG_ov_info ( &FVorbisFile, -1 );
FChannels = VorbisInfo->channels;
FSamplesPerSec = VorbisInfo->rate;
FBitsPerSample = 16;
}
析构函数微不足道:
virtual ~clOggProvider()
{
OGG_ov_clear( &FVorbisFile );
}
ReadFromFile()
和Seek()
方法在精神上与我们在处理 MiniMP3 时所做的非常相似:
virtual int ReadFromFile( int Size, int BytesRead ) override
{
return ( int )OGG_ov_read( &FVorbisFile, &FBuffer[0] + BytesRead, Size - BytesRead, 0, FBitsPerSample / 8, 1, &FOGGCurrentSection );
}
virtual void Seek( float Time ) override
{
FEof = false;
OGG_ov_time_seek( &FVorbisFile, Time );
}
private:
这是定义上一节提到的回调的地方。当然,可以就地定义它们,而不是将它们移动到单独的文件中。然而,我们发现这种分离对于这个例子来说更符合逻辑;从逻辑上分离数据提供者概念和OGG Vorbis
相关 API:
#include "OGG_Callbacks.inc"
OggVorbis_File FVorbisFile;
ogg_int64_t FOGGRawPosition;
int FOGGCurrentSection;
};
这个例子也是支持安卓的现成产品。运行以下命令获取设备上的.apk
:
>ndk-build
>ant debug
>adb install -r bin/App1-debug.apk
现在开始活动,享受音乐吧!在接下来的章节中,我们将在本章内容的基础上添加更多有趣的音频内容。
在本章中,我们学习了如何使用可移植的 C++ 代码和开源第三方库在 Android 上播放音频。所提供的示例能够与.it
、.xm
、.mod
和.s3m
模块一起播放.mp3
和.ogg
音频文件。我们还学习了如何生成自己的波形来模拟乐器。代码可以跨很多系统移植,可以在安卓和 Windows 上运行和调试。现在,一旦我们完成了音频,是时候进入下一章,用 OpenGL 渲染一些图形了。