Last active
October 29, 2023 07:59
-
-
Save Yamayamada0924/d3fe20835d2e6bb696c82007b5e6de26 to your computer and use it in GitHub Desktop.
WebGLでインタラクティブミュージック縦の遷移・横の遷移を行うためのComponent, イントロ付きループにも対応
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEngine.Audio; | |
namespace InteractiveMusic | |
{ | |
/// <summary> | |
/// # MixMusic | |
/// WebGLでインタラクティブミュージック縦の遷移・横の遷移を行うためのComponent | |
/// イントロ付きループにも対応している | |
/// | |
/// ## 準備(縦の遷移) | |
/// musicClipsにAudioClipを2つ以上設定する | |
/// 更にmodesにこのAudioClipのボリュームの組み合わせを設定する | |
/// 各modeのvolumesの数はmusicClipsの数と同じにする必要がある | |
/// modesの数は2以上にする必要がある | |
/// WholeLoopをtrueにする(ただし縦横同時の遷移でない場合) | |
/// | |
/// ## 準備(イントロ付きループや横の遷移) | |
/// 注意:縦の遷移よりも難しいです | |
/// musicClipsにAudioClipを1つ以上設定する | |
/// 更にloopsにループポイントを設定する | |
/// loopsのループポイントはサンプル単位で設定する必要がある | |
/// イントロ付きループはループポイントを1つ設定する | |
/// 横の遷移はループポイントを2つ以上設定する | |
/// 横の遷移はループポイントの切り替えや再生サンプル位置のスキップで横の遷移を実現する | |
/// 参考 -> サンプル数を計算するEditor拡張 https://gist.github.com/Yamayamada0924/0c74ec48dfe8ca6eadf0fb5cecc7bf23 | |
/// | |
/// ## 使い方 | |
/// Prepareを呼んだ後にPlayを呼ぶことで再生する | |
/// Prepareには完了まで数フレームかかるのであらかじめ呼んでおくことが望ましい | |
/// prepareOnAwakeをtrueにすることでAwake時にPrepareを呼ぶことができる | |
/// ChangeModeを呼ぶことであらかじめ設定したボリュームの組み合わせに変更できる | |
/// ChangeTargetLoopを呼ぶことで有効なループポイントを切り替えることが出来る | |
/// Skipを呼ぶことで再生サンプル位置をスキップすることが出来る(マイナスも可能) | |
/// WholeLoopは楽曲全体のループで、縦の遷移のみの場合はtrueを、横の遷移もある場合はfalseを推奨する | |
/// | |
/// ## 縦の遷移を行う場合 | |
/// 同じ長さで楽器パートが違うAudioClipを複数用意して、musicClipsに設定する | |
/// 例えばメロディのみのAudioClipと打楽器のAudioClipを用意する | |
/// modesの1つ目はvolumesの0番目を1.0に、1番目を0.0にする | |
/// modesの2つ目はvolumesの0番目を1.0と1番目を1.0にする | |
/// ChangeMode(0)を呼ぶとメロディのみのAudioClipが再生され、ChangeMode(1)を呼ぶとメロディと打楽器のAudioClipが再生される | |
/// | |
/// ## 横の遷移を行う場合 | |
/// イントロ -> パートAループ -> (条件を満たしたら遷移) -> パートBループ を作る場合 | |
/// 以下のように並んでいる単一のwavファイルを用意する | |
/// | イントロ | パートAループ | パートAループ | パートBループ | パートBループ(オーバーラン用なので短くて良い) | | |
/// イントロのサンプル数をX、パートAのサンプル数をY、パートBのサンプル数をZとした場合 | |
/// loopsの1つ目はloopStartSampleをX、loopEndSampleをX+Yにする | |
/// loopsの2つ目はloopStartSampleをX+Y+Y、loopEndSampleをX+Y+Y+Zにする | |
/// 最初はイントロが再生された後にパートAループでループが行われる(このときは1つ目のパートAループでループしている) | |
/// Skip(Y)とChangeTargetLoop(1)をパートAループ中に呼ぶと2つ目のパートAループに移動しつつ、1つ目のパートBループでループするようになる | |
/// 結果としてパートAからパートBに横の遷移が行われる | |
/// パートAループ中かどうかはGetTimeSamplesを使い、Xより大きい値が返ってくるかで判定可能 | |
/// | |
/// ## 注意事項 | |
/// ループポイントを書き込んだwavファイルを用意してもWebGLではループポイントが無視され、全体がループする | |
/// AudioClip はロードタイプを `Decompress On Load` にして `Preload Audio Data` をオフにする設定を推奨する | |
/// | |
/// ## 仕組み | |
/// 最初に一瞬再生することで読み込みを予め行い、その後に再生することで2つ以上のAudioClipをほぼ同じタイミングで再生する | |
/// 片方のAudioClipを無音で再生しておくことで、そのボリュームの変化で縦の遷移を実現している | |
/// WebGLでもtimeSamplesを使用したループはある程度高い精度で動くようで、そのtimeSamplesのループポイントの切り替えで横の遷移を実現している | |
/// | |
/// </summary> | |
public class MixMusic : MonoBehaviour | |
{ | |
[SerializeField, Range(0.0f, 1.0f)] private float volume; | |
[SerializeField] private List<AudioClip> musicClips; | |
[SerializeField] private AudioMixerGroup output; | |
[SerializeField, Tooltip("全体のループ")] private bool wholeLoop; | |
[SerializeField] private bool prepareOnAwake; | |
[SerializeField] private bool playOnAwake; | |
[System.SerializableAttribute] | |
private struct ModeParam | |
{ | |
[SerializeField] private string modeName; | |
public string ModeName => modeName; | |
[SerializeField, Range(0.0f, 1.0f)] private List<float> volumes; | |
public IReadOnlyList<float> Volumes => volumes; | |
[SerializeField] private float modeChangeTime; | |
public float ModeChangeTime => this.modeChangeTime; | |
} | |
[SerializeField] List<ModeParam> modes = new List<ModeParam>(); | |
[System.SerializableAttribute] | |
private struct LoopParam | |
{ | |
[SerializeField] private string loopName; | |
public string LoopName => loopName; | |
[SerializeField] private int loopStartSample; | |
public int LoopStartSample => loopStartSample; | |
[SerializeField] private int loopEndSample; | |
public int LoopEndSample => loopEndSample; | |
} | |
[SerializeField, Tooltip("一部分のループ")] List<LoopParam> loops = new List<LoopParam>(); | |
private readonly List<AudioSource> _audioSources = new List<AudioSource>(); | |
private bool _prepared; | |
private bool _waitPrepare; | |
private bool _reservedPlay; | |
private float _inputVolume; | |
private int _mode; | |
private int _nextMode; | |
private readonly List<float> _modeChangeVolumes = new List<float>(); | |
private float _modeChangeTime; | |
private int _targetLoop; | |
private const int WarningLoopEndSample = 44100 / 60 * 4;// 4フレーム分のサンプル以下しか余裕がないとき警告を出す | |
private void Awake() | |
{ | |
_prepared = false; | |
_waitPrepare = false; | |
_reservedPlay = false; | |
_inputVolume = 1.0f; | |
_mode = 0; | |
_nextMode = 0; | |
_modeChangeTime = 0.0f; | |
_targetLoop = 0; | |
CreateAudioSource(); | |
if (playOnAwake) | |
{ | |
Play(); | |
} | |
else if (prepareOnAwake) | |
{ | |
Prepare(); | |
} | |
#if DEBUG | |
if(musicClips.Count == 0) | |
{ | |
Debug.LogWarning($"{nameof(musicClips)} が設定されていません。"); | |
} | |
foreach (var modeParam in modes) | |
{ | |
if(modeParam.Volumes.Count < musicClips.Count) | |
{ | |
Debug.LogWarning( | |
$"{nameof(modes)} {modeParam.ModeName} の volumes が足りていません。足りないボリュームは 0 として扱われます。"); | |
} | |
if(modeParam.Volumes.Count > musicClips.Count) | |
{ | |
Debug.LogWarning( | |
$"{nameof(modes)} {modeParam.ModeName} の volumes が多いです。"); | |
} | |
} | |
foreach (var loop in loops) | |
{ | |
if(loop.LoopStartSample > loop.LoopEndSample) | |
{ | |
Debug.LogWarning($"{nameof(loops)} {loop.LoopName} のループ開始位置がループ終了位置より後ろになっています。"); | |
} | |
if(loop.LoopStartSample + WarningLoopEndSample > loop.LoopEndSample) | |
{ | |
Debug.LogWarning($"{nameof(loops)} {loop.LoopName} のループ間隔が非常に短いです、正しくループできないかもしれません。"); | |
} | |
} | |
if(playOnAwake && loops.Count > 0) | |
{ | |
Debug.LogWarning($"{nameof(loops)} はplayOnAwakeがtrueのときは正常に動作しない可能性が高いです。"); | |
} | |
#endif | |
} | |
private void CreateAudioSource() | |
{ | |
if (musicClips.Count == 0) | |
{ | |
Debug.LogWarning($"{nameof(musicClips)} が設定されていません。"); | |
return; | |
} | |
#if DEBUG | |
var samples = musicClips[0].samples; | |
#endif | |
foreach (var musicClip in musicClips) | |
{ | |
var audioSource = gameObject.AddComponent<AudioSource>(); | |
audioSource.clip = musicClip; | |
audioSource.outputAudioMixerGroup = output; | |
audioSource.loop = wholeLoop; | |
audioSource.volume = 0.0f; | |
_audioSources.Add(audioSource); | |
_modeChangeVolumes.Add(0.0f); | |
#if DEBUG | |
if(musicClip.samples != samples) | |
{ | |
Debug.LogWarning($"{nameof(musicClips)} のsample数(音楽の長さ)が一致しません。"); | |
} | |
foreach (var loop in loops) | |
{ | |
if(musicClip.samples < loop.LoopEndSample) | |
{ | |
Debug.LogWarning($"{nameof(loops)} {loop.LoopName} のループ終了位置が長さを超えています。"); | |
} | |
if(musicClip.samples + WarningLoopEndSample < loop.LoopEndSample) | |
{ | |
Debug.LogWarning($"{nameof(loops)} {loop.LoopName} のループ終了位置の後ろが非常に短いです、オーバーランにより正しくループできないかもしれません。"); | |
} | |
} | |
#endif | |
} | |
} | |
private void Update() | |
{ | |
if (_waitPrepare) | |
{ | |
var isAllPlaying = true; | |
foreach (var audioSource in _audioSources) | |
{ | |
if( !audioSource.isPlaying ) | |
{ | |
isAllPlaying = false; | |
} | |
} | |
if (isAllPlaying) | |
{ | |
foreach (var audioSource in _audioSources) | |
{ | |
audioSource.Stop(); | |
} | |
_waitPrepare = false; | |
_prepared = true; | |
} | |
return; | |
} | |
if(_reservedPlay) | |
{ | |
_reservedPlay = false; | |
for (var index = 0; index < _audioSources.Count; index++) | |
{ | |
var audioSource = _audioSources[index]; | |
audioSource.volume = volume * _inputVolume * GetModeVolume(_mode, index); | |
audioSource.timeSamples = 0; | |
audioSource.Play(); | |
} | |
return; | |
} | |
if(_mode != _nextMode) | |
{ | |
_modeChangeTime -= Time.deltaTime; | |
if(_modeChangeTime <= 0.0f) | |
{ | |
_mode = _nextMode; | |
_modeChangeTime = 0.0f; | |
for (var index = 0; index < _audioSources.Count; index++) | |
{ | |
var audioSource = _audioSources[index]; | |
audioSource.volume = volume * _inputVolume * GetModeVolume(_mode, index); | |
} | |
} | |
else | |
{ | |
var t = _modeChangeTime / GetModeChangeTime(_nextMode); | |
for (var index = 0; index < _audioSources.Count; index++) | |
{ | |
var audioSource = _audioSources[index]; | |
audioSource.volume = volume * _inputVolume * Mathf.Lerp(GetModeVolume(_nextMode, index), _modeChangeVolumes[index], t); | |
} | |
} | |
} | |
if(_audioSources.Count > 0 && loops.Count > 0 && _audioSources[0].isPlaying) | |
{ | |
if(_audioSources[0].timeSamples >= loops[_targetLoop].LoopEndSample) | |
{ | |
var offset = _audioSources[0].timeSamples - loops[_targetLoop].LoopEndSample; | |
for (var index = 0; index < _audioSources.Count; index++) | |
{ | |
var audioSource = _audioSources[index]; | |
audioSource.timeSamples = loops[_targetLoop].LoopStartSample + offset; | |
} | |
} | |
} | |
} | |
public void Play( int mode = 0, float inputVolume = 1.0f) | |
{ | |
_reservedPlay = true; | |
_inputVolume = inputVolume; | |
_mode = _nextMode = mode; | |
if(!_prepared) | |
{ | |
Prepare(); | |
} | |
} | |
public void Stop() | |
{ | |
_reservedPlay = false; | |
_modeChangeTime = 0.0f; | |
_mode = _nextMode = 0; | |
_targetLoop = 0; | |
foreach (var audioSource in _audioSources) | |
{ | |
audioSource.Stop(); | |
} | |
} | |
public void ChangeMode(string modeName) | |
{ | |
for (int i = 0; i < modes.Count; i++) | |
{ | |
if (modes[i].ModeName == modeName) | |
{ | |
ChangeMode(i); | |
return; | |
} | |
} | |
Debug.LogWarning($"mode {modeName} は見つかりませんでした。"); | |
} | |
public void ChangeMode(int mode) | |
{ | |
_mode = _nextMode; | |
_nextMode = mode; | |
_modeChangeTime = GetModeChangeTime(_nextMode); | |
for (int i = 0; i < _audioSources.Count; i++) | |
{ | |
_modeChangeVolumes[i] = _audioSources[i].volume; | |
} | |
} | |
public void ChangeTargetLoop(string loopName) | |
{ | |
for (int i = 0; i < loops.Count; i++) | |
{ | |
if (loops[i].LoopName == loopName) | |
{ | |
ChangeTargetLoop(i); | |
return; | |
} | |
} | |
Debug.LogWarning($"loop {loopName} は見つかりませんでした。"); | |
} | |
public void ChangeTargetLoop(int targetLoop) | |
{ | |
if(_targetLoop < 0 || _targetLoop >= loops.Count) | |
{ | |
Debug.LogWarning($"ループ {targetLoop} は範囲外です。"); | |
return; | |
} | |
_targetLoop = targetLoop; | |
} | |
public void Skip(int samples) | |
{ | |
foreach (var audioSource in _audioSources) | |
{ | |
audioSource.timeSamples += samples; | |
} | |
} | |
public int GetTimeSamples() | |
{ | |
if(_audioSources.Count == 0) | |
{ | |
return 0; | |
} | |
return _audioSources[0].timeSamples; | |
} | |
public void Prepare() | |
{ | |
if(_prepared) | |
{ | |
return; | |
} | |
if(_waitPrepare) | |
{ | |
return; | |
} | |
foreach (var audioSource in _audioSources) | |
{ | |
audioSource.volume = 0.0f; | |
audioSource.Play(); | |
} | |
_waitPrepare = true; | |
} | |
public void SetVolume(float inputVolume) | |
{ | |
_inputVolume = inputVolume; | |
if(_mode != _nextMode) | |
{ | |
// Updateですぐに変わるので何もしない | |
} | |
else | |
{ | |
for (var index = 0; index < _audioSources.Count; index++) | |
{ | |
var audioSource = _audioSources[index]; | |
audioSource.volume = volume * _inputVolume * GetModeVolume(_mode, index); | |
} | |
} | |
} | |
private float GetModeVolume(int mode, int index) | |
{ | |
if(modes.Count == 0) | |
{ | |
return 1.0f; | |
} | |
if (mode < 0 || mode >= modes.Count) | |
{ | |
Debug.LogWarning($"モード {mode} は範囲外です。"); | |
return 0.0f; | |
} | |
var modeParam = modes[mode]; | |
if (index < 0 || index >= modeParam.Volumes.Count) | |
{ | |
return 0.0f; | |
} | |
return modeParam.Volumes[index]; | |
} | |
private float GetModeChangeTime(int mode) | |
{ | |
if(modes.Count == 0) | |
{ | |
return 0.0f; | |
} | |
if (mode < 0 || mode >= modes.Count) | |
{ | |
Debug.LogWarning($"モード {mode} は範囲外です。"); | |
return 0.0f; | |
} | |
var modeParam = modes[mode]; | |
return modeParam.ModeChangeTime; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment