はじめに
ビート同期再生をすると、音楽的に満足な自然な遷移ができるというものを紹介します。
3つのBGM切り替え再生方法を紹介
部屋が変わるとBGMが変わるといったような形で再生しています。
BPM150で10種類、長さは2小節で統一しています。
3つの方法は、それぞれ複雑さが増しますが、表現の幅も広がります。
デモ
0:00 1. ビート同期なし再生
0:20 2. ビートに合わせて再生
0:50 3. ビートの拍の位置も合わせて再生
1. ビート同期なし再生
部屋に入るタイミングでその部屋の音楽を再生します。
部屋を出ると止まります。
部屋の切り替わりタイミングが、ビートに合わなかった時、
若干、リズムが乱れる感じがします。
(ん、あれ、っというくらい)
このデモでは、フェードアウトがかかるため、それほど違和感はありません。
でもゲームをしている人に意味なく「あれっ?」って思わせるのは良くないです。
(慣れると気にならない場合も多いですが)
2. ビートに合わせて再生
部屋の切り替わりタイミングがビートに合っていない場合、
次の再生を、つぎのビートの頭から再生するようにしています。
リズムが乱れないので、ゲームプレイに支障なく聞けるものになりました。
ただ、よーく聞いてみると
部屋に入った時に、常に頭から再生しているので、
音楽的に少し不自然な感じが出てきてしまいます。
例えば
A: 1 2 3 4 _ _ _ _ _ _ _
B: _ _ _ 1 2 3 4 5 6 7 8
Aの曲が3拍目で、
Bの曲が再生開始すると、
Aの曲が突然3/4拍子になったような感じになります。
バトルとか、あえて不規則な拍子が多めであればバレません。
(が、そういう音楽になりがちにも)
このデモのデータでは、ほぼ1ビートな音楽なのと比較的テンポが速いので、それほど違和感はありません。
また、このデモではコード(和音)が1つなので、どのタイミングで切り替えても和声的には問題ないですが
コードが変わるようなデータの場合、クロスフェードで不協和音が発生します。
クロスフェードをあまりしないようにすると切り替えのつなぎ目が目立ち始めます。
3. ビートの拍の位置も合わせて再生
A: 1 2 3 4 _ _ _ _
B: _ _ _ 4 5 6 7 8
Bの曲の再生開始位置が、Aの曲と同じ拍で切り替わります。
もし、AとBの曲が同じ和音進行であれば、
途中で切り替えても不協和になりません。
また、小節としてのグルーブ(周期)感も乱れずに進行します。
とても自然に行うと、切れ目を感じさせずに切り替えることができます。
このデモでは少しクロスフェードがきつくかかっているため、切り替えポイントがわかりやすいですが、フェード時間を調整するとなじませることもできます。
プログラム上のテクニック
ここで紹介しているコードは一部なので、これだけでは動きませんが、参考になりそうな部分もあるかと思います。
1. ビート同期なし再生
部屋やイベントごとに切り替えるだけであれば、プログラムはシンプルに書けます。
TriggerEnterで再生、TriggerExitで音を止める
という形でシンプルに組めます。
シンプルということは、バグも起きにくいので、ゲーム開発ではそれなりにメリットがあります。
2. ビートに合わせて再生
ビートに合わせようとすると少しプログラムが複雑になります。
前に再生しているデータに合わせて切り替えるため
前に再生しているデータ対象が分かる形で実装しないといけません。
今回は、再生開始時に、再生中のデータがあれば、それに同期するようにしてみました。
この再生している時の情報を利用して、次の再生を少し遅延してビートの頭になるタイミングで鳴るように仕掛けています。
/// <summary>
/// 音を再生する
/// </summary>
public void PlayEnterSound()
{
// 既に再生中の場合はスキップ
if (enterAudioHandle != null && enterAudioHandle.IsPlaying &&
enterAudioHandle.AudioId == enterSound.Id) return;
// エンター音再生
if (!string.IsNullOrEmpty(enterSound.Id))
{
// グループが指定されている場合、前のデータとシンクして再生を試みる
if (syncGroupIndex > 0)
{
// 現在再生中の同じグループの音を探す
foreach (var audioTriggerZone in AudioTriggerZone.AllAudioTriggerZones)
{
if (audioTriggerZone == this) continue;
if (audioTriggerZone.syncGroupIndex == syncGroupIndex)
{
if (audioTriggerZone.EnterAudioHandle != null && audioTriggerZone.EnterAudioHandle.IsDisposed == false &&
audioTriggerZone.EnterAudioHandle.IsPlaying)
{
// Log.Info("スケジュール再生 enterId:{0} previousHandle:{1} syncGroupIndex:{2}", enterSound.Id, audioTriggerZone.EnterAudioHandle.name,
// syncGroupIndex);
enterSoundsforSchedule[0] = enterSound;
previousHandlesforSchedule[0] = audioTriggerZone.EnterAudioHandle;
// スケジュール再生
AudioHandle[] handles = AudioPool.AllAudioPools[poolIndex].SchedulePlay(
enterSoundsforSchedule,
previousHandlesforSchedule,
-1,
AudioPool.ScheduleType.NextBeat,
true, playbackSync,
this.transform.position);
// ハンドル更新
enterAudioHandle = handles[0];
return;
}
}
}
}
// 通常再生
PlayNormal();
}
}
3. ビートの拍の位置も合わせて再生
拍の情報まで合わせる場合は、前に再生しているデータが何拍目なのかを知る必要があります。
/// <summary>
/// スケジュール再生
/// </summary>
private async TaskType SchedulePlayCoroutine(AudioHandle[] handles, AudioHandle[] previousHandles,
float bpm, ScheduleType scheduleType, bool previousStop, bool playbackSync, float fade = 0.6f)
{
AudioHandle previousHandleFirst = previousHandles[0];
// 既に再生中の先頭のHandleに従う
float currentTime = previousHandleFirst.PlayingTime;
// もし止まっていた場合
if (currentTime < 0)
{
currentTime = 0;
}
// BPM指定が無い場合はデータのBPMを参照
if (bpm == -1)
{
bpm = previousHandleFirst.Bpm;
}
// データのBPMも未指定の場合は固定
if (bpm == -1)
{
bpm = 120;
}
// PlayingTime は秒単位なので、60で割って分単位にし、BPMを掛けて総拍数を計算します。
// その後、1.0fで剰余を計算することで、現在の拍の進捗 (0.0f~1.0f) を得ます。
float beatProgress = (currentTime / 60.0f * bpm) % 1.0f;
float nextBang = (1.0f - beatProgress) * (60.0f / bpm); // 残り時間分遅延
if (scheduleType == ScheduleType.Immediate)
{
nextBang = nextBang / 4.0f; // 16分音符の遅延時間
}
await TaskType.Delay(TimeSpan.FromSeconds(nextBang));
// 再生
for (int i = 0; i < handles.Length; i++)
{
AudioHandle currentHandle = handles[i];
if (currentHandle == null) continue;
if (playbackSync)
{
// 再生開始位置を同期する
float syncTime = currentTime;
float syncBeatPosition = Mathf.FloorToInt(
syncTime / 60.0f * bpm) + 1;
if (currentHandle.Bpm < 0)
{
// Bpm指定が無い場合は、前のデータの時刻にそのまま同期
currentHandle.AudioSource.time = syncTime % currentHandle.AudioSource.clip.length;
}
else
{
// Bpmがある場合ビート数が同じ位置に同期
currentHandle.AudioSource.time = (syncBeatPosition * 60.0f / currentHandle.Bpm) % currentHandle.AudioSource.clip.length;
}
// エラー回避のため、timeが後ろすぎる場合補正
currentHandle.AudioSource.time = Mathf.Min(currentHandle.AudioSource.time,
currentHandle.AudioSource.clip.length - 0.01f);
}
currentHandle.PlayWithFade(fade);
// 自動開放
currentHandle.UseAutoDispose();
}
if (previousStop)
{
for (int i = 0; i < previousHandles.Length; i++)
{
AudioHandle currentHandle = previousHandles[i];
if (currentHandle == null) continue;
if (currentHandle.IsPlaying)
{
currentHandle.Stop(0.6f);
// 自動開放
currentHandle.UseAutoDispose();
}
}
}
}
さらなる切り替え再生と課題
クロスフェードは、和声的によくても、楽器的に不自然とか発生する場合があります。
今回のデモのようなDJプレイ的な曲であれば、違和感は少ないですが、
生楽器のような演奏の場合、本来ありえない奏法(アタックなしで、途中から音が大きくなるピアノとか)は、不自然になるかもしれません。
切り替えを、楽曲で不自然にしない場合、
アウトロや、ブリッジを挟んで切り替える、
さらには、それぞれの切り替えタイミング、遷移先のコードに合わせた展開など、細かくデータを用意する必要がでてくる可能性もあります。
やりはじめると、データが膨大になり、制作面でも、実行時のメモリ的にも、仕組みの設定の手間的にも、問題が発生します。
どこまでこだわれるかは、仕組みの把握と、楽曲のアイデア、ゲームでの必要性などいろいろ考慮して実装する必要があります。
AIでなんとか
ブリッジ部分の生成とかはAIとかに、あわよくばリアルタイムで作ってもらうみたいな未来もあるかもしれません。そうなったらまた面白そうな感じもします。