【Java】WAVファイルを省メモリで再生

2018年12月26日

前回、Javaでwavファイルの再生する方法を記事にしました。

この記事で紹介したClipインターフェースを用いた方法では、ファイルの内容をメモリにすべて展開した状態となってしまいます。

実際に大きなファイルを指定した場合と、小さいファイルを指定した場合で比較してみました。

1MBのwavファイル 19MBのwavファイル

どうでしょうか?

メモリ使用量がファイルの容量分だけ変わっています。

無駄にメモリを消費するとPCの動作が遅くなってしまったり、OutOfMemoryErrorの原因になりかねません。

OutOfMemoryErrorとはJavaプログラムの中でもかなり致命的なエラーであり、プログラムが動作しなくなります。

これでは大きいファイルを読み込めなくなってしまうかもしれません。
(最近の大容量メモリを積んだPCではどうってことないかもしれませんがw)

そこで、今回はこれに対処する方法を紹介します。

SourceDataLineを使用した省メモリなストリーミング再生

前回のClipと同じように作成するのですが、今回はSourceDataLineを使用します。

SourceDataLineでの再生はファイルを少しずつ読み込んで実行するストリーミング形式であるため、メモリにやさしい処理となります。

ただし、手動で読み込んで、音声バッファを流し込む処理を記述する必要がある為、少し手間がかかります。

AudioInputStreamを取得出来たらデータを読み込んでSourceDataLineに流し込むだけです。

全て読み込み終わったらcloseして終了してください。

今回使用する音声データです。

それでは、サンプルをご覧ください。

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
 
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;
 
public class Test {
	public static void main(String[] args) throws InterruptedException {
		File path = new File("sample.wav");
 
 		//指定されたURLのオーディオ入力ストリームを取得
		try (AudioInputStream ais = AudioSystem.getAudioInputStream(path)) {
 
			//ファイルの形式取得
			AudioFormat af = ais.getFormat();
 
			//単一のオーディオ形式を含む指定した情報からデータラインの情報オブジェクトを構築
			DataLine.Info dataLine = new DataLine.Info(SourceDataLine.class,af);
 
			//指定された Line.Info オブジェクトの記述に一致するラインを取得
			SourceDataLine s = (SourceDataLine)AudioSystem.getLine(dataLine);
 
			//再生準備完了
			s.open();
			
			//ラインの処理を開始
			s.start();
			
			//読み込みサイズ
			byte[] data = new byte[s.getBufferSize()];
			
			//読み込んだサイズ
			int size = -1;
 
			//再生処理のループ
			while(true) {
				//オーディオデータの読み込み
				size = ais.read(data);
				if ( size == -1 ) {
					//すべて読み込んだら終了
					break;
				}
				//ラインにオーディオデータの書き込み
				s.write(data, 0, size);
			}
			
			//残ったバッファをすべて再生するまで待つ
			s.drain();
 
			//ライン停止
			s.stop();
 
			//リソース解放
			s.close();
 
		} catch (MalformedURLException e) {
			e.printStackTrace();
		} catch (UnsupportedAudioFileException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} catch (LineUnavailableException e) {
			e.printStackTrace();
		}
	}
}

これで大きなファイルを指定してもメモリ使用量は、ほぼ変わりません。

わかりにくいかもしれませんが、writeメソッドでデータを書き込むと内部で音声データの再生をしてくれます。

SourceDataLineで自由に再生位置の指定

SourceDataLineにはループや再生位置の指定などClipにあった便利なメソッドがありませんが、AudioInputStreamの読み込み位置を変更すれば、ループ再生や再生位置の変更なども可能です。

再生位置の指定はファイルの読み込み位置をイメージすると良いです。

また、音量の変更など、各種コントロールもClipと同じように利用可能です。

ここでは応用例として、再生開始位置の変更、再生終了したら一かいだけループ再生、音量のフェードアウトを一度に実装してみます。

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
 
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.UnsupportedAudioFileException;
 
public class Test {
	public static void main(String[] args) throws InterruptedException {
		File path = new File("sample.wav");
 
		try {
			//指定されたURLのオーディオ入力ストリームを取得
			AudioInputStream ais = AudioSystem.getAudioInputStream(path);
 
			//ファイルの形式取得
			AudioFormat af = ais.getFormat();
 
			//単一のオーディオ形式を含む指定した情報からデータラインの情報オブジェクトを構築
			DataLine.Info dataLine = new DataLine.Info(SourceDataLine.class,af);
 
			//指定された Line.Info オブジェクトの記述に一致するラインを取得
			SourceDataLine s = (SourceDataLine)AudioSystem.getLine(dataLine);
 
			//再生準備完了
			s.open();
			
			//ラインの処理を開始
			s.start();
			
			//読み込みサイズ
			byte[] data = new byte[s.getBufferSize()];
			
			//ループ回数
			int loopCounta = 0;
			
			//初期再生位置の指定
			ais.skip((int)af.getSampleRate() * af.getSampleSizeInBits() * af.getChannels() / 8 * 3);
			
			//読み込んだサイズ
			int size = -1;
			
			//再生処理のループ
			for(int i = 20;;i--) {
				//オーディオデータの読み込み
				size = ais.read(data);
				if ( size == -1 ) {
					ais.close();
					if ( loopCounta >= 1 ) {
						//既に一回ループされていたら終了
						break;
					} else {
						//読み込み位置をファイルの始点に戻してループさせます。
						ais = AudioSystem.getAudioInputStream(path);
						loopCounta++;
						continue;
					}
				}
				//ラインにオーディオデータの書き込み
				s.write(data, 0, size);
				
				if ( i >= 0 ) {
					//音量の変更
					FloatControl ctrl = (FloatControl)s.getControl(FloatControl.Type.MASTER_GAIN);
					ctrl.setValue((float)Math.log10((float)i / 20)*20);
				}
			}
			
			//残ったバッファをすべて再生するまで待つ
			s.drain();
 
			//ライン停止
			s.stop();
 
			//リソース解放
			s.close();
 
		} catch (MalformedURLException e) {
			e.printStackTrace();
		} catch (UnsupportedAudioFileException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} catch (LineUnavailableException e) {
			e.printStackTrace();
		}
	}
}
実行結果

処理内容を一部説明します。



初期再生位置の指定

ais.skip((int)af.getSampleRate() * af.getSampleSizeInBits() * af.getChannels() / 8 * 3);

初期再生位置の指定は音声データの開始から3秒後の位置に設定しています。再生位置はAudioInputStreamのskipメソッドにバイト単位のサイズで指定しなければなりません。そのため、AudioFormatから1秒分のbit数をまず計算します。計算はサンプルレート*1サンプル当たりのbit数*チャンネル数です。これで1秒分のbitサイズが計算できるので8で割るとbyteに変換できます。あとは時間で乗算すればOKです。

ループ再生

ais = AudioSystem.getAudioInputStream(path);
loopCounta++;
continue;

単純にファイルを読み込みなおして始点に戻しているだけです。readメソッドが-1を返す時、読み込みの終了を示すのでそこでファイルを読み直しています。本当はmark、resetで実装するつもりでしたが、サポートされていなくて使えませんでした。

音量指定

FloatControl ctrl = (FloatControl)s.getControl(FloatControl.Type.MASTER_GAIN);
ctrl.setValue((float)Math.log10((float)i / 20)*20);

フェードアウトに関してはClipの時と同じです。引数にはデシベルを指定するのですが、デシベル単位での指定は人間には設定しずらいのでlog10(比率)*20で計算しています。これが一般的なようです。例えば二倍の音量にしたい場合は比率に2を入れればいいだけなのでわかりやすいですよね?でもデシベルで言うと0デシベルが1倍で6デシベルがなぜか2倍の音量なのです。setValue(0f)と指定しても音はなります。普通は音が消えそうですが0デシベルは無音じゃないみたい。はぁ~logってこういう時に使うんだなぁ~


話がそれましたが、今回の応用の内容が理解できれば省メモリで音声データを再生する方法とは別に、バイナリ操作についての理解が少し深まったといえると思います。

これは音声に限らず他のバイナリデータの操作全般で役に立ちます。