前言
代码演示环境:
- 软件环境:Windows 10
- 开发工具:Visual Studio Code
- JDK版本:OpenJDK 15
声效和音乐
声效基础知识
当我们玩游戏时,我们可能会听到声效,但是不会真正注意它们。因为希望听到他们,所以声效在游戏中是非常重要的。
另外,在游戏中的音乐会动态被修改来配合游戏的剧情的发展。那么什么是声效(声音)呢?声效是通过媒体振动产生的效果。该媒体是空气和计算机中的扬声器产生的振动—从而发出了声音—传送到我们耳朵里;然后我们的耳膜会捕获这些信号,接着传送给我们的大脑,从而人类听到了声音。
共振(vibration)是通过空气的压缩振动(fluctuations)产生的,快速的振动产生高频声效,让我们听到高音。每个振动的压缩数量是使用振幅(amplitude)来表示的。高振幅会让我们听到声音大;简而言之,声波(sound waves)就是在持久时间不停修改振幅而已。如下图所示:
数码声效、CD和计算机的音效格式都是一系列的声波,每秒中的音波振幅叫做音频采样。当然高采样的音波可以更加精确的表现声音,这些采样是使用16位来表示65535种可能的振幅。许多声音允许多个声道,比如CD有两个声道—一个给左扬声器,一个给右扬声器。
Java声效API
Java可以播放8位和16位的采样,它的范围从8000hz到48000hz,当然它也可以播放单声道和立体声声效。那么使用什么声音,这需要根据游戏的剧情,比如16位单声道,44100Hz声音。Java支持三种声频格式文件:AIFF, AU和WAV文件。
我们装载音频文件时使用AudioSystem类,该类有几个静态方法,一般我们使用getAudioInputStream()方法来打开一个音频文件,可以从本地系统,或者从互联网打开,然后返回AudioInputStream对象。然后使用该对象读取音频采样,或者查询音频样式。
File file = new File(“sound.wav”);
AudioInputStream stream = AudioSystem.getAudioInputStream(file);
AudioFormat format = stream.getFormat();
其中AudioFormat类提供了获取声效采样的功能,比如采样率和声道数量。另外它还提供了frame尺寸--一些字节数量。比如16位立体声,它的frame大小是4,或者2个字节表示采样值,这样我们可以很方便的计算出立体声可以占多少内存。比如16位三分之二长度的立体音频格式采样所占内存值:44100x 3x 4字节 = 517KB,如果是单声道,那么采样容量是立体声的一半。
当我们知识声频采样的大小与格式之后,接下来就是从这些声频文件中读取内容了。接口Line是用来发送和接收系统的音频的API。我们可以使用Line发送声音采样到OS的声音系统去播放,或者接收OS的声音系统的声音,比如microphone声音等。
Line有几个子接口,最主要的子接口是SourceDataLine,该接口可以让我们向OS中的声音系统写入声音数据。Line的实例是通过AudioSystem的getLine()方法获取,我们可以传送参数Line.Info对象来指定返回的Line类型。
因为Line.Info有一个DataLine.Info子类,它知道Line类型除了SourceDataLine接口之外,还有另外一个Line叫做Clip()接口。该接口可以为我们做许多事情,比如把采样从AudioInputStream流装载到内存中去,并且自动向音频系统输送这些数据去播放。下面是使用Clipe来实现声音的播放代码:
//指定哪种line需要被创建
DataLine.Info info = new DataLine.Info(Clip.class,format);
//创建该line
Clip clip = (Clip)AudioSystem.getLine(info);
//从流对象装载采样
clip.open(stream);
//开始播放音频内容
clip.start();
Clip接口非常好用,它非常类似于JDK 1.0版本中AudioClip对象,但是它有一些缺点,比如Java声效有限制Line的数量,这种限制是在相同的时间打开Line时出现,一般最多有32个Line对象同时存在。
也就是说,我们只能打开有限个line对象使用。另外,如果我们想同时播放多个Clip对象,那么Clip只能在同一时间播放一个声音,比如我们想同时播放两到三个爆炸声,但是一个声音只能应用一个爆炸声。因为这种缺陷,所以我们会创建一种解决方案来克服这种问题。
播放声音
下面我们创建一个简单的声音播放器,主要使用AudioInputStream类把音频文件读到字节数组中,然后使用Line对象来自动播放。因为ByteArrayInputStream类封装了字节数组,所以,我们可以同时播放多个相同音频的复本。
getSamples(AudioInputStream)方法从AudioInputStream流中读采样数据,然后保存到字节数组中,最后使用play()方法从InputStream流对象中读取数据到缓存,然后写到SourceDataLine对象中让它播放。
由于Java声效API中有bug,所以让Java进程不会自己退出,通常情况下,JVM只运行精灵线程,但是当我们使用Java声效时,非精灵线程在台后进行中运行,所以我们必须呼叫System.exit(0)结束Java声效进程。
SimpleSoundPlayer类
package com.funfree.arklis.sounds;
import java.io.*;
import javax.sound.sampled.*;
/**
功能:书写一个的类,用来封装声音从文件系统打开,然后进行播放
作者:技术大黍
*/
public class SimpleSoundPlayer {
private AudioFormat format;
private byte[] samples;//保存声音采样
/**
Opens a sound from a file.
*/
public SimpleSoundPlayer(String filename) {
try {
//打开一个音频流
AudioInputStream stream =
AudioSystem.getAudioInputStream(
new File(filename));
format = stream.getFormat();
//取得采样
samples = getSamples(stream);
}
catch (UnsupportedAudioFileException ex) {
ex.printStackTrace();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
/**
Gets the samples of this sound as a byte array.
*/
public byte[] getSamples() {
return samples;
}
/**
从AudioInputStream获取采样,然后保存为字节数组。--这里的数组会被封装ByteArrayInputStream类中,
以便Line可以同时播放多个音频文件。
*/
private byte[] getSamples(AudioInputStream audioStream) {
//获取读取字节数
int length = (int)(audioStream.getFrameLength() *
format.getFrameSize());
//读取整个流
byte[] samples = new byte[length];
DataInputStream is = new DataInputStream(audioStream);
try {
is.readFully(samples);
}
catch (IOException ex) {
ex.printStackTrace();
}
// 返回样本
return samples;
}
/**
播放流
*/
public void play(InputStream source) {
//每100毫秒的音频采样
int bufferSize = format.getFrameSize() *
Math.round(format.getSampleRate() / 10);
byte[] buffer = new byte[bufferSize];
//创建line对象来执行声音的播放
SourceDataLine line;
try {
DataLine.Info info =
new DataLine.Info(SourceDataLine.class, format);
line = (SourceDataLine)AudioSystem.getLine(info);
line.open(format, bufferSize);
}catch (LineUnavailableException ex) {
ex.printStackTrace();
return;
}
// 开始自动播放
line.start();
// 拷贝数据到line对象中
try {
int numBytesRead = 0;
while (numBytesRead != -1) {
numBytesRead =
source.read(buffer, 0, buffer.length);
if (numBytesRead != -1) {
line.write(buffer, 0, numBytesRead);
}
}
}catch (IOException ex) {
ex.printStackTrace();
}
// 等待所有的数据播放完毕,然后关闭line对象。
line.drain();
line.close();
}
}
如果需要循环播出,那么修改一下上面类就可以实现该功能。
LoopingByteInputStream类
package com.funfree.arklis.engine;
import static java.lang.System.*;
import java.io.*;
/**
功能:封装ByteArrayInputStream类,用来循环播放音频文件。当停止循环播放时
呼叫close()方法
作者:技术大黍
*/
public class LoopingByteInputStream extends ByteArrayInputStream{
private boolean closed;
public LoopingByteInputStream(byte[] buffer){
super(buffer);
closed = false;
}
/**
读取长度为length的数组。如果读完数组内容,那么把下标设置为开始处,
如果关闭状态,那么返回-1.
*/
public int read(byte[] buffer, int offset, int length){
if(closed){
return -1;
}
int totalBytesRead = 0;
while(totalBytesRead < length){
int numBytesRead = super.read(buffer,offset + totalBytesRead,
length - totalBytesRead);
if(numBytesRead > 0){
totalBytesRead += numBytesRead;
}else{
reset();
}
}
return totalBytesRead;
}
/**
关闭流
*/
public void close()throws IOException{
super.close();
closed = true;
}
}
声效过滤器是简单的音频处理器,用来现有的声音样本,这种过滤器一般用来实时处理声音。所以谓声效过滤器就是常说的是数字信号处理器(digital signal processor)—用于后期声效的处理,比如吉他添加回响效果。
创建一个实时的声效过滤框架
因为声效过滤器可以让游戏更加动态效果,所以我们可平衡游戏情节和声效的效果。比如,我们可以添加打击的回响效果,或者播放一段摇滚声音等。下面我们创建一个回响过滤器和3D环绕声效过滤器。因为有多种声效过程器,所以我们可创建一个过滤器框架。在这里把这个框架定义了三种非常重要的方法:
- 过滤样本
- 获取剩下的尺寸
- 复位
SoundFilter对象可以包含状态数据,所在不同的SoundFilter对象可以用来播放不同的声音。为简化,允许SoundFilter播放16位、带符号和little-endian格式的样本。所以little endian是一种专业术语,它表示数据的字节顺序。
Little-endian表示以最低有效位来存贮16位的样本数据;big-endian表示是以最高有效位来存贮16位的样本数据。带符号是正负值的意思,比如-32768到32767数值。WAV声音文件默认使用是little-endian格式存贮的。
Java声效就像字节数据,所以我们必须转换这些字节数据为16位带符号的格式才能工作。SoundFilter类提供这种功能,两个静态的方法setSample()和getSample()方法来实现。
下面就是我们需要一种简单的方式来使用SoundFilter类来播放我们的声音文件。我们可以直接在样本数组中,但是灵活性不好,所以我们可以封装SimpleSoundPlayer类,加上LoopingByteArrayInputStream工具类,可以实现实时过滤源音乐文件,而不修改源文件。
我们说过,过滤器可以当作回音播放,实现这种效果是当原音频文件播放之后,在过滤器接着还播放声效,从而产生回音效果。不过在FilteredSoundStream类中,如果SoundFilter类还剩下数据字节,那么在read方法必须小清除这些字节数据,让它静音,最后这些动作完成之后,返回-1表示音频流读取结束。
FilteredSoundStream类
package com.funfree.arklis.sounds;
import java.io.FilterInputStream;
import java.io.InputStream;
import java.io.IOException;
import com.funfree.arklis.engine.*;
/**
功能:该类是FilterInputStream类,它用于SoundFilter来实现底层的流。参见SoundFilter
作者:技术大黍
备注:FilterInputStream包含其它的输入流,它将这些流作为基本的数据源,它可以直接传输
或者提供额外的功能。
*/
public class FilteredSoundStream extends FilterInputStream {
private static final int REMAINING_SIZE_UNKNOWN = -1;
private SoundFilter soundFilter;
private int remainingSize;
/**
使用指定的流和指定的SoundFilter对象
*/
public FilteredSoundStream(InputStream in,
SoundFilter soundFilter){
super(in);
this.soundFilter = soundFilter;
remainingSize = REMAINING_SIZE_UNKNOWN;
}
/**
重写read方法,用来过滤字节流
*/
public int read(byte[] samples, int offset, int length)throws IOException{
// 读取和过滤流中声音样本
int bytesRead = super.read(samples, offset, length);
if (bytesRead > 0) {
soundFilter.filter(samples, offset, bytesRead);
return bytesRead;
}
// 如果在声音流没有剩余的字节,那么检查过滤器是否有剩余字节(作为回音)
if (remainingSize == REMAINING_SIZE_UNKNOWN) {
remainingSize = soundFilter.getRemainingSize();
// 取整后乘以4(标准帧的大小)
remainingSize = remainingSize / 4 * 4;
}
if (remainingSize > 0) {
length = Math.min(length, remainingSize);
// 清除缓存
for (int i=offset; i<offset+length; i++) {
samples[i] = 0;
}
// 过滤剩下的字节
soundFilter.filter(samples, offset, length);
remainingSize-=length;
// 返回长度
return length;
}
else {
// 结束流
return -1;
}
}
}
创建一个实时的回音过滤器
回音表示在源音频文件播放结束之后,还有延迟的效果,图形表示如下:
- Delay--延迟
- Original Sound--源声
- First Echo--第一次回声
- Second Echo--第二次回声
因为SoundFilter类是的格式是不知的,所以它不管我们过滤的音频文件是44100Hz的采样率还是8000hz的采样率,所以我们不可简单的告诉回音过滤器确定的延迟时间。
于是,我们只能告诉回音过滤器有多省样本可以被延迟。比如让44100hz有一比二的延迟效果,于是告诉回音过滤器有44100样本被延迟。注意:因为延迟计时是从音频的开始处理计算。所以decay本身使用0或者表示延迟的样本。如果使用0表示不延迟,或者1表示回音的时间与原音频一样长。
给回音添加decay的代码如下:
short newSample = (short)(oldSample + decay * delayBuffer[delayBuffersPos]);
参见下面EchoFilter类
EchoFilter类
package com.funfree.arklis.sounds;
import com.funfree.arklis.engine.*;
/**
功能:该类是SoundFilter类,它实现了模拟回音的效果,参见类FilteredSoundStream
作者:技术大黍
*/
public class EchoFilter extends SoundFilter {
private short[] delayBuffer;
private int delayBufferPos;
private float decay;
/**
使用指定的延迟样本数,以及指定的延迟比率。<p>延迟样本数是指初始听到的延迟样本数是多少。如果一
秒的回音,那么使用单声道、44100声效以及44100延迟样本。<p>延迟值是从源样本中怎样实现回音。一个
延迟率.5值表示回音是源音的一半。
*/
public EchoFilter(int numDelaySamples, float decay) {
delayBuffer = new short[numDelaySamples];
this.decay = decay;
}
/**
获取剩余的尺寸(表示单位是字节),该样本过滤可以源声效播放完成之后开始。但是必须确保
延迟率必须小1%。
*/
public int getRemainingSize() {
float finalDecay = 0.01f;
// 计算出Math.pow(decay,x) <= finalDecay(最终延迟)
int numRemainingBuffers = (int)Math.ceil(
Math.log(finalDecay) / Math.log(decay));
int bufferSize = delayBuffer.length * 2;
return bufferSize * numRemainingBuffers;
}
/**
清除内部延迟缓存
*/
public void reset() {
for (int i=0; i<delayBuffer.length; i++) {
delayBuffer[i] = 0;
}
delayBufferPos = 0;
}
/**
过滤声效样添加一个回音效果。让样本播放出现延迟效果,该结果会被存贮在延迟缓存中,所以
可以听到多个回音效果。
*/
public void filter(byte[] samples, int offset, int length) {
for (int i=offset; i<offset+length; i+=2) {
// 更新可更新样本
short oldSample = getSample(samples, i);
short newSample = (short)(oldSample + decay *
delayBuffer[delayBufferPos]);
setSample(samples, i, newSample);
// 更新延迟缓存
delayBuffer[delayBufferPos] = newSample;
delayBufferPos++;
if (delayBufferPos == delayBuffer.length) {
delayBufferPos = 0;
}
}
}
}
当然还要参见SimpleSoundPlayer类的源代码。其中.6f表示60%的延迟效果。下面我们来怎样封装3D声效。
模拟3D环绕效果
3D声效又叫做方位听(directional hearing),它可以给玩家创建一个3维立体的体验游戏声音效果。作为3D声效实现时通有的功能如下:
- 距离渐远时声音会随之变小,反之会逐渐增大
- 单声道扬声器会在左喇叭播放,如果声源在右喇叭播放,那么我们的右耳朵会听到,3D声效可以实现四喇叭的声音播放效果
- 可以创建室内的回响效果
- 可以实现多普勒(Doppler)声效
3D声效不会应用于3D的游戏中,也可以运用于2D游戏中。下面我们实现3D过滤器,理论就是依据Pythagorean Theorem:
该类是根据Sprite对象移动位置来确定声效,通过控制过滤之后的回音效果来实现3D声效。
Filter3d类
package com.funfree.arklis.sounds;
import com.funfree.arklis.graphic.Sprite;
import com.funfree.arklis.engine.*;
/**
功能:书写一个3D过滤器,该类继承SoundFilter类,用来创建3D声效。
实现效果会根据距离来改变声音的大小。参见FilteredSoundStream类
作者:Arkliszeng
*/
public class Filter3d extends SoundFilter {
// 指定被修改的样本数
private static final int NUM_SHIFTING_SAMPLES = 500;
private Sprite source;//数据源
private Sprite listener;//监听者
private int maxDistance;//最大距离
private float lastVolume;//最大音量
/**
创建一个Filter3D对象,创建时需要指定数据源对象和监听者对象(妖怪),在该过滤器运行时
小怪的位置可以改变。
*/
public Filter3d(Sprite source, Sprite listener,
int maxDistance)
{
this.source = source;
this.listener = listener;
this.maxDistance = maxDistance;
this.lastVolume = 0.0f;
}
/**
根据距离来过滤声音
*/
public void filter(byte[] samples, int offset, int length) {
if (source == null || listener == null) {
// 如果没有过滤的,那么什么都不做
return;
}
// 否则计算监听者的到声音源的距离
float dx = (source.getX() - listener.getX());
float dy = (source.getY() - listener.getY());
float distance = (float)Math.sqrt(dx * dx + dy * dy);
// 设置音量从0(表示没有声音)
float newVolume = (maxDistance - distance) / maxDistance;
if (newVolume <= 0) {
newVolume = 0;
}
// 设置样本的大小
int shift = 0;
for (int i = offset; i < offset + length; i += 2) {
float volume = newVolume;
// 把最后的音量转换成新的音量
if (shift < NUM_SHIFTING_SAMPLES) {
volume = lastVolume + (newVolume - lastVolume) *
shift / NUM_SHIFTING_SAMPLES;
shift++;
}
// 修改样本的音量
short oldSample = getSample(samples, i);
short newSample = (short)(oldSample * volume);
setSample(samples, i, newSample);
}
lastVolume = newVolume;
}
}
注意:在GameCore类的lazilyExit()方法。不过该方法使用使用了多线程,所以处理它时需要小心,下面我们会书写一个SoundManager来处理这些系统级问题。该类实现了SimpleSoundPlayer类的相似的功能。SoundManager类有一个内部类SoundPlayer,它用来完成拷贝声音数据到Line对象中。SoundPlayer实现Runnable接口,所以它可以被作为一个任务线程,在线程池中使用。
另外SoundPlayer与SimpleSoundPlayer不同之处是,如果SoundManager处理暂停状态,那么它会停止拷贝数据,SoundPlayer会呼叫wait()方法暂停线程,直到等待SoundManager通过所有线程,它处理非暂停状态为止。
Thread-Local Variable—是本地线程变量,我们希望SoundManager可以保障每个线程有自己的Line对象和字节缓存,那么我们可重复使用它们,而不需要每次播放时创建新的对象。为从线程池中获取自己的line和字节缓存,我们可使用thread-local变量来实现。
因为本地变量是表示本地代码块,所以thread-local变量对于每个线程不同的值在该示例中,SoundManager类有localLine和localBuffer两个本地线程就是,每个线程可以有自己的line和字节缓存对象,而其它的线程不可以访问本地线程的变量。
本地线程变量使用类ThreadLocal来创建。为让本地变量工作,我们需要修改一个ThreadPool类,我们需要有一种方式来创建本地线程变量,创建的时机是一个线程启动,然后在线程死掉时清理这些本地线程变量。为达到这样的效果,需要在ThreadPool中有如下代码:
public void run(){
threadStarted(); //标识该线程已经启动
while(!isInterrupted()){
Runnable task = null; //获取一个运行的任务
try{
task = getTask();
}catch(InterruptedException e){ }
//如果getTask()返回null或者停止
if(task == null){
break; //跳出循环体
}
//否则运行该任务,并且不让异常抛出
try{
task.run();
}catch(Throwable t){
uncuaghtException(this,t);
}
}
threadStopped();//标识该线程结束
}
本地线程变量
在ThreadPool类中threadStarted()和threadStopped()方法不做为,但是在SoundManager类是有用的。前者会用来创建一个新的Line对象和一个新的缓存对象,然后被添加到本地线程对象中;在后者方法中Line对象会被关闭与清理掉。就SoundManager类来说,除了提供暂停播放功能之外,该类还提供非常简便的方法来播放声音功能。
播放音乐
虽然背景音乐不是每个游戏都播放,但是它中游戏中是非常重要的。因为音乐可调整心情,同时音乐也可以表示游戏的剧情的发展方向,比如一个玩家与一个Boss打斗时的音乐会比较激烈。当我们确定使用什么样的音乐之后,那么游戏中怎样获取音乐呢?它主要有三各方式:
- 从CD的音轨获取
- 播放压缩的MP3或者Ogg音乐文件
- 播放MIDI音乐文件
第一种方式是可以实现好的音质,并且容易实现它的缺陷是CD非常占空间,30MB的空间只能播放三分钟的音乐,如果想播放四首三分钟的音乐至少会占120MB的空间。第二种方式是播放压缩文件MP3和Ogg格式文件,它的缺陷是解压缩文件时会非常占CPU的处理时间。
解决方案是使用专门的Java解压器,www.javazoom.net网站可以下载这些解压器。第三种方式MIDI方式除了有样本之外,还有指令,所以它是混成的,文件非常小,缺陷是音质会失真。为解决这个问题,我们需要使用JDK的soundback来解决它。