「Siv3D Advent Calendar 2015」24日目の記事です。
今回は以前私がSiv3Dを使って作った音系アプリと、その時に使った音処理についてご紹介させて頂きます。
#アプリ紹介「 TYPL 」
どんなアプリなのか一言で言いますと、「タイピングに合わせて音楽が再生されるタイピング練習ソフト」です(動画1、動画2)。
従来の音楽を利用したタイピング練習と言えば、再生される音楽に合わせて素早く歌詞をタイピングし、入力が間に合わなかったとしても強制的に音楽は進んでしまうというものが一般的でした。
このソフトでは逆に『タイピングに合わせて音楽を再生』することで、「どこで入力が間に合っていないのか?」を目と耳でフィードバックしつつ、練習に対するモチベーションを高める効果を狙ってみました。また、多少の入力ミスを許容することで、文字入力でありながらも楽器演奏のような体験(?)を目指しました。
このアプリの肝となる部分の1つが『文字入力に合わせた音楽のタイムストレッチ再生』です。
アプリを作った当時はSiv3Dのバージョンが「January 2015」で、DynamicSoundが実装される前でした(Siv3D June 2015 v2)。そのとき「どうやって無理やり“タイムストレッチ風”の音楽再生を実装したのか」というのをご紹介したいと思います。
#“リアルタイムタイムストレッチ風”処理の実装
注意:ここで紹介する手法はあくまでも**“タイムストレッチ風”**処理であり、実際の音楽情報処理におけるタイムストレッチの手法とは多少異なる部分があると思います。ちゃんとしたリアルタイムタイムストレッチを実装したいという方はDynamicSoundをご使用になるか、音楽情報処理の論文等をご参照下さい。
##PlaySoundクラス
タイムストレッチの方法としてはコチラやコチラを参考にさせて頂き、次のようなクラスを作ってみました(関数は一部省略してあります)。
# pragma once
# include <Siv3D.hpp>
class PlaySound
{
Sound check;
Sound sound[2];
TimerMillisec tmElapsed;
TimerMicrosec tmSound;
bool crossFade = false;
int count = -1;
double time = 0;
const double fadeTime = 20.0;
public:
// Set用
void Initialize(String path);
void Update(double pos, double ratio);
void FixRatio();
void ChangeRatio(double pos);
void SetPosSec(double time);
// Get用
double SoundTime(); // 再生位置
double RealTime(); // 実時間
bool IsPlaying();
void Start();
};
Soundをクロスフェード用に2つ、再生位置の確認用に1つ用意してあります。また、実時間での経過時間を測るためのTimerMillisecと、クロスフェード用のTimerMicrosecを持たせてみました。
メンバ関数としては初期化、フレーム毎のアップデート等の“Set用”の関数と、再生位置や実時間を得る“Get用”の関数を用意しています。
##メンバ関数
まずは“Get用”の関数から
# include <Siv3D.hpp>
# include "PlaySound.h"
double PlaySound::SoundTime()
{
return static_cast<double>(check.posSec);
}
double PlaySound::RealTime()
{
return (double)tmElapsed.elapsed() / 1000.0;
}
bool PlaySound::IsPlaying()
{
return check.isPlaying;
}
void PlaySound::Start()
{
sound[0].stop();
sound[1].stop();
check.stop();
sound[0].play();
sound[1].play();
check.play();
tmElapsed.start();
tmSound.start();
}
次に、“Set用”の関数
# include <Siv3D.hpp>
# include "PlaySound.h"
void PlaySound::Initialize(String path)
{
check.release();
sound[0].release();
sound[1].release();
check = Sound(path);
check.setVolume(0.0);
sound[0] = Sound(path);
sound[1] = Sound(path);
tmElapsed.reset();
tmSound.reset();
count = -1;
}
void PlaySound::Update(double pos, double ratio)
{
if (check.isPlaying) {
if (ratio == 1.0 && !crossFade) {
FixRatio();
} else {
ChangeRatio(Max(0.0, pos), ratio);
}
}
}
void PlaySound::FixRatio()
{
if (sound[0].volume > sound[1].volume) {
sound[0].setVolume(1.0);
sound[1].setVolume(0.0);
count = -1;
} else {
sound[0].setVolume(0.0);
sound[1].setVolume(1.0);
count = 1;
}
}
void PlaySound::ChangeRatio(double pos)
{
count += 1;
if ((count % 4) % 2 == 0) {
crossFade = true;
int i = 1;
if (count % 4 < 2) {
i = 0;
}
sound[1 - i].setPosSec(pos);
check.setPosSec(pos);
time = 0;
double s = (double)tmSound.elapsed() / 1000.0;
while (time < fadeTime / 2.0) {
time = (double)tmSound.elapsed() / 1000 - s;
sound[i].setVolume(cos(Pi / 2.0 * time / fadeTime));
sound[1 - i].setVolume(sin(Pi / 2.0 * time / fadeTime));
}
} else {
crossFade = false;
int i = 1;
if (count % 4 < 2) {
i = 0;
}
check.setPosSec(pos);
double time2 = time;
double s = (double)tmSound.elapsed() / 1000.0;
while (time < fadeTime) {
time = time2 + (double)tmSound.elapsed() / 1000 - s;
sound[i].setVolume(cos(Pi / 2.0 * time / fadeTime));
sound[1 - i].setVolume(sin(Pi / 2.0 * time / fadeTime));
}
}
}
void PlaySound::SetPosSec(double time)
{
sound[0].setPosSec(time);
sound[1].setPosSec(time);
check.setPosSec(time);
}
Updateでは、再生速度に合わせて2つのメンバ関数を使い分けるようにしています。FixRatioは再生速度1倍の場合、ChangeRatioはそれ以外の場合です。
ChangeRatioは2つのSoundのクロスフェード処理を行っており、ここではサインカーブを用いたクロスフェードを2フレームに分割して行っています。分割した理由としては、他の処理を実装していった場合に1フレームだけだと充分なフェード時間が確保できていないように感じたからです。これについては、Soundのフェードをうまく利用することで音質を向上させることができるかもしれません。
##使い方
# include <Siv3D.hpp>
# include "PlaySound.h"
void Main()
{
PlaySound sound;
double ratio = 1.0;
double now = 0;
double before = 0;
if (auto open = Dialog::GetOpenSound()) {
sound.Initialize(open.value());
} else {
System::Exit();
}
while (System::Update())
{
if (!sound.IsPlaying()) {
sound.Start();
}
if (Input::KeyRight.clicked) {
sound.SetPosSec(sound.SoundTime() + 10);
}
if (Input::KeyLeft.clicked) {
sound.SetPosSec(sound.SoundTime() - 10);
}
if (Input::KeyUp.clicked) {
if (ratio < 1.3) {
ratio = ratio + 0.1;
}
}
if (Input::KeyDown.clicked) {
if (ratio >= 0.6) {
ratio = ratio - 0.1;
}
}
now = sound.RealTime();
double deltaTime = now - before;
before = now;
double soundPos = sound.SoundTime() + deltaTime * (ratio - 1);
sound.Update(soundPos, ratio);
}
}
この例では、左右矢印で再生位置のジャンプ、上下矢印で再生速度の変更を行っています。毎フレームに経過時間を測り、その値から次の再生位置を計算することで再生速度の調節を行っています。
##メリット・デメリット
■メリット
・setPosSecが使用できる
・再生位置と再生速度を連続関数上に当てはめて制御したい場合に管理しやすい
■デメリット
・音質が悪い(他の処理が増えるとより顕著)
・再生速度が正確でない(おそらく・・・)
例えば、ある瞬間に「今から実時間○○秒間で△△秒間先の再生位置まで再生したい」という再生位置の時間変化を表す連続関数 f に対して、再生位置 f(⊿t) を簡単に設定することが出来ます。
今回紹介したアプリを例にすると・・・
入力が基準時間よりも早かった場合、実時間 t 秒間で再生位置の変化量が同じ t 秒となるような無理関数、
基準時間を過ぎても入力が無かった場合、ある再生位置に収束するような有利関数
にそれぞれ当てはめて制御をしています。
こうすることによって、早い文字入力が連続した場合は曲の再生速度が平均的に速くなり、遅い入力が続いた場合は遅くすることが出来ます。ちなみにプレイ画面の左下は、これらの連続関数を微分した再生速度の時間変化を表示しています。
一方で、1フレーム当たりの処理が増えると経過時間の誤差が増えて、音質の劣化が激しくなると同時に、設定した再生速度が正確に反映されなくなると考えられます。この辺りは、デジタル信号処理とDynamicSound、Easingを上手く組み合わせることによって改善の余地があるのではないかと思っています。
#まとめ
今回はSiv3Dを利用した音系アプリと、“タイムストレッチ風”処理の実装をご紹介させて頂きました。実際のタイムストレッチとは多少異なる手法だと思いますが、用途に合わせて改良しながら実装してみるのも面白いと思います。今後Siv3Dの機能充実に合わせて、音を使ったゲームやアプリの開発もさらに盛んになっていくことを期待しております。
明日は@whojinnさんの記事です。宜しくお願い致します。