diff --git a/unity-ggjj/Assets/Scripts/SceneLoading/LoopableMusicClip.cs b/unity-ggjj/Assets/Scripts/SceneLoading/LoopableMusicClip.cs index d09a778b3..d6eda7540 100644 --- a/unity-ggjj/Assets/Scripts/SceneLoading/LoopableMusicClip.cs +++ b/unity-ggjj/Assets/Scripts/SceneLoading/LoopableMusicClip.cs @@ -1,31 +1,14 @@ using System; using System.Collections; using System.IO; +using System.Runtime.CompilerServices; using UnityEngine; using UnityEngine.Networking; +[assembly: InternalsVisibleTo("PlayModeTests")] /// -/// Handles playing back .ogg files with loop markers +/// Handles playing back .ogg files with optional loop markers /// -/// -/// Music with loop markers consist of up to 3 sections: -/// 1. Intro -/// 2. Loop -/// 3. Outro (optional) -/// -/// Inside the .ogg file, the loop markers are defined as tags with the names LOOP_START and LOOP_END in the format HH:MM:SS.mmm. -/// -/// If ContinueLooping is true, a track will start with the intro and continue looping the loop section. If ContinueLooping is set to false during playback, it will play finish the current loop and then play the outro. -/// If ContinueLooping is false, a track will start with the intro, loop the loop section once, and then play the outro. -/// If no outro exists, the track will simply end after the last loop. -/// -/// Usage: -/// 1. Instantiate an instance of this class -/// 2. Call `.Initialize(pathToOGGWithinMusicDirectory)` to load an .ogg file with loop markers. -/// 3. Assign the `.Clip` property to an `AudioSource.clip` of your choice. -/// 4. Call `AudioSource.Play()` to start playback. By default, the track will loop indefinitely. -/// 5. Change the `.ContinueLooping` property to false to finish the current loop and play the outro, if it exists. -/// public class LoopableMusicClip { private readonly string _clipName; @@ -36,80 +19,44 @@ public class LoopableMusicClip private int _sampleRate; private int _channelCount; - public int TimeToSamples(double time) + /// + /// Creates a new instance of LoopableMusicClip.
+ /// Use `this.Initialize(oggFilePath)` to await and load an .ogg file. After awaiting, use the `Clip` property to get an AudioClip for playback. + ///
+ /// + /// + /// "/> + public LoopableMusicClip() + { + + } + + internal int TimeToSamples(double time) { return (int) (time * _sampleRate * _channelCount); } - public double SamplesToTime(int samples) + internal double SamplesToTime(int samples) { return (double) samples / _sampleRate / _channelCount; } + /// + /// If true (default), the track will loop indefinitely.
+ /// If false, the track will finish the current loop, optionally play an outro, if data after the LOOP_END point exists and then stop. + ///
public bool ContinueLooping { get; set; } = true; private AudioClip _clip; - public AudioClip Clip - { - get - { - if (_rawData == null || _rawData.Length == 0) - { - throw new InvalidOperationException($"Clip has not been initialized yet; call {nameof(Initialize)}(pathToOGGWithinMusicDirectory) first"); - } - if (_clip != null) - { - return _clip; - } - - _clip = AudioClip.Create("preLoop", _preLoopSampleLength + _loopSampleLength, _channelCount, _sampleRate, true, GenerateStream, SetCurrentPlaybackHead); - return _clip; - } - } - - public IEnumerator Initialize(string pathToOGGWithinMusicDirectory) - { - if (!pathToOGGWithinMusicDirectory.EndsWith(".ogg")) - { - throw new NotSupportedException("Only ogg files are supported"); - } - - using var www = UnityWebRequestMultimedia.GetAudioClip(Application.streamingAssetsPath + "/Music/" + pathToOGGWithinMusicDirectory, AudioType.OGGVORBIS); - yield return www.SendWebRequest(); - - using var vorbis = new NVorbis.VorbisReader(new MemoryStream(www.downloadHandler.data)); - - _rawData = new float[vorbis.TotalSamples * vorbis.Channels]; - vorbis.ReadSamples(_rawData, 0, (int) vorbis.TotalSamples * vorbis.Channels); - var loopStart = vorbis.Tags.GetTagSingle("LOOP_START"); - var loopEnd = vorbis.Tags.GetTagSingle("LOOP_END"); - Debug.Log("loopStart: " + loopStart); - Debug.Log("loopEnd: " + loopEnd); - var loopStartSeconds = TimeSpan.Parse(loopStart).TotalSeconds; - var loopEndSeconds = TimeSpan.Parse(loopEnd).TotalSeconds; - Debug.Log("loopStartSeconds: " + loopStartSeconds); - Debug.Log("loopEndSeconds: " + loopEndSeconds); - Debug.Log("loopSeconds: " + (loopEndSeconds - loopStartSeconds)); - _sampleRate = vorbis.SampleRate; - _channelCount = vorbis.Channels; - - _preLoopSampleLength = TimeToSamples(loopStartSeconds); - _loopSampleLength = TimeToSamples(loopEndSeconds - loopStartSeconds); - _fullTrackSampleLength = TimeToSamples(vorbis.TotalTime.TotalSeconds); - - Debug.Log("preLoopSampleLength: " + _preLoopSampleLength); - Debug.Log("loopSampleLength: " + _loopSampleLength); - Debug.Log("totalSamples: " + vorbis.TotalSamples); - } - - public void SetCurrentPlaybackHead(int position) + + internal void SetCurrentPlaybackHead(int position) { _currentPlaybackHead = position; } private int _currentPlaybackHead; - public void GenerateStream(float[] data) + internal void GenerateStream(float[] data) { int dataIndex = 0; @@ -165,4 +112,85 @@ public void GenerateStream(float[] data) Array.Clear(data, dataIndex, data.Length - dataIndex); } } + + /// + /// Retrieves an AudioClip for the .ogg file, that will loop indefinitely by default. + /// + /// + /// An .ogg file can optionally specify a section to loop inside the file's metadata:
+ /// `LOOP_START`: the start of the loop in the format`HH:MM:SSmmm
+ /// `LOOP_END`: the end of the loop in the format`HH:MM:SSmmm
+ /// If no loop markers are specified, the entire file will be looped.
+ ///
+ /// + /// Thrown when this.Initialize(oggFile) has not been called + public AudioClip Clip + { + get + { + if (_rawData == null || _rawData.Length == 0) + { + throw new InvalidOperationException($"Clip has not been initialized yet; call {nameof(Initialize)}(pathToOGGWithinMusicDirectory) first"); + } + if (_clip != null) + { + return _clip; + } + + _clip = AudioClip.Create("preLoop", _preLoopSampleLength + _loopSampleLength, _channelCount, _sampleRate, true, GenerateStream, SetCurrentPlaybackHead); + return _clip; + } + } + + /// + /// Assigns an .ogg file, reads the loop markers and prepares `Clip` + /// + /// An IEnumerator to be used with `yield return` to wait loading and processing of the .ogg file + /// Thrown when the file is not an `.ogg` file + /// + public IEnumerator Initialize(string pathToOGGWithinMusicDirectory) + { + if (!pathToOGGWithinMusicDirectory.EndsWith(".ogg")) + { + throw new NotSupportedException("Only ogg files are supported"); + } + + using var www = UnityWebRequestMultimedia.GetAudioClip(Application.streamingAssetsPath + "/Music/" + pathToOGGWithinMusicDirectory, AudioType.OGGVORBIS); + yield return www.SendWebRequest(); + + using var vorbis = new NVorbis.VorbisReader(new MemoryStream(www.downloadHandler.data)); + + _rawData = new float[vorbis.TotalSamples * vorbis.Channels]; + vorbis.ReadSamples(_rawData, 0, (int) vorbis.TotalSamples * vorbis.Channels); + _sampleRate = vorbis.SampleRate; + _channelCount = vorbis.Channels; + _fullTrackSampleLength = TimeToSamples(vorbis.TotalTime.TotalSeconds); + + var loopStart = vorbis.Tags.GetTagSingle("LOOP_START"); + var loopEnd = vorbis.Tags.GetTagSingle("LOOP_END"); + Debug.Log("loopStart: " + loopStart); + Debug.Log("loopEnd: " + loopEnd); + + if (string.IsNullOrEmpty(loopStart) || string.IsNullOrEmpty(loopEnd)) + { + _preLoopSampleLength = TimeToSamples(0); + _loopSampleLength = TimeToSamples(vorbis.TotalTime.TotalSeconds); + Debug.Log("no loop markers"); + } + else + { + var loopStartSeconds = TimeSpan.Parse(loopStart).TotalSeconds; + var loopEndSeconds = TimeSpan.Parse(loopEnd).TotalSeconds; + Debug.Log("loopStartSeconds: " + loopStartSeconds); + Debug.Log("loopEndSeconds: " + loopEndSeconds); + Debug.Log("loopSeconds: " + (loopEndSeconds - loopStartSeconds)); + _preLoopSampleLength = TimeToSamples(loopStartSeconds); + _loopSampleLength = TimeToSamples(loopEndSeconds - loopStartSeconds); + } + + Debug.Log("preLoopSampleLength: " + _preLoopSampleLength); + Debug.Log("loopSampleLength: " + _loopSampleLength); + Debug.Log("totalSamples: " + vorbis.TotalSamples); + } + } \ No newline at end of file diff --git a/unity-ggjj/Assets/StreamingAssets/Music/Tests/static.ogg b/unity-ggjj/Assets/StreamingAssets/Music/Tests/has_loopmarkers.ogg similarity index 100% rename from unity-ggjj/Assets/StreamingAssets/Music/Tests/static.ogg rename to unity-ggjj/Assets/StreamingAssets/Music/Tests/has_loopmarkers.ogg diff --git a/unity-ggjj/Assets/StreamingAssets/Music/Tests/static.ogg.meta b/unity-ggjj/Assets/StreamingAssets/Music/Tests/has_loopmarkers.ogg.meta similarity index 100% rename from unity-ggjj/Assets/StreamingAssets/Music/Tests/static.ogg.meta rename to unity-ggjj/Assets/StreamingAssets/Music/Tests/has_loopmarkers.ogg.meta diff --git a/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg b/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg new file mode 100644 index 000000000..13cae50c1 --- /dev/null +++ b/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af14934a872e2a23f63e5f6d9c3ccf59c8b9daf5965076bff3b85cf047c065cf +size 56704 diff --git a/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg.meta b/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg.meta new file mode 100644 index 000000000..24a66c9a0 --- /dev/null +++ b/unity-ggjj/Assets/StreamingAssets/Music/Tests/no_loopmarkers.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dddace506a24af74bbb7a4ff3d3ebbf8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-ggjj/Assets/Tests/PlayModeTests/Scripts/LoopableMusicClipTests.cs b/unity-ggjj/Assets/Tests/PlayModeTests/Scripts/LoopableMusicClipTests.cs index 0a96f6bb2..ac7c8e364 100644 --- a/unity-ggjj/Assets/Tests/PlayModeTests/Scripts/LoopableMusicClipTests.cs +++ b/unity-ggjj/Assets/Tests/PlayModeTests/Scripts/LoopableMusicClipTests.cs @@ -5,93 +5,129 @@ namespace Tests.PlayModeTests.Scripts { - public class PlayLoopTests + /// + /// Tests that the LoopableMusicClip class handles basic playback while respecting looping rules + /// + /// + /// + /// This class works by using two .ogg files: + /// `static.ogg`: a 3-second file with a loop marker between 0:01 and 0:02 + /// `loopless.ogg`:a 3-second file without loop markers + /// Both file aren't music, but a static signal that's set to 0.75, 0.5 and 0.25, each for 1 second. + /// The right channel is the inverse of the left channel, so the signal is always stereo. + /// 0.75: |------| | + /// L 0.50: | |------| + /// 0.25: | | |------ + /// ------------------------------------ + /// -0.25: | | |------ + /// R -0.50: | |------| + /// -0.75: |------| | + /// ------------------------------------ + /// | | | + /// 00:00 00:01 00:02 + /// + public class LoopableMusicClipTests { - private LoopableMusicClip _playLoop; + private const float TOLERANCE = 0.05f; + private const float FIRST_SECOND = 0.75f; + private const float SECOND_SECOND = 0.5f; + private const float THIRD_SECOND = 0.25f; + private const float SILENCE = 0.0f; + + private LoopableMusicClip _clipWithLoopMarkers; + private LoopableMusicClip _clipWithoutLoopMarkers; private int _second; [UnitySetUp] public IEnumerator UnitySetUp() { - _playLoop = new LoopableMusicClip(); - var initialize = _playLoop.Initialize("Tests/static.ogg"); - yield return initialize; - _second = _playLoop.TimeToSamples(1); - } + { + _clipWithLoopMarkers = new LoopableMusicClip(); + var initialize = _clipWithLoopMarkers.Initialize("Tests/has_loopmarkers.ogg"); + yield return initialize; + _second = _clipWithLoopMarkers.TimeToSamples(1); + } + + { + _clipWithoutLoopMarkers = new LoopableMusicClip(); + var initialize = _clipWithoutLoopMarkers.Initialize("Tests/no_loopmarkers.ogg"); + yield return initialize; + } + } [TearDown] public void UnityTearDown() { - _playLoop.SetCurrentPlaybackHead(0); + _clipWithLoopMarkers.SetCurrentPlaybackHead(0); } [Test] public void EnsureMP3Fails() { Assert.Throws(() => { - _playLoop.Initialize("Tests/static.mp3").MoveNext(); + _clipWithLoopMarkers.Initialize("Tests/static.mp3").MoveNext(); }); } [Test] public void Handle5SecondsWithLoop() { - var data = new float[_playLoop.TimeToSamples(5)]; - _playLoop.GenerateStream(data); + var data = new float[_clipWithLoopMarkers.TimeToSamples(5)]; + _clipWithLoopMarkers.GenerateStream(data); for (var i = 0; i < data.Length; i++) { if (i < _second) { - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.75f).Within(0.05f) : Is.EqualTo(-0.75f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(FIRST_SECOND).Within(TOLERANCE) : Is.EqualTo(-FIRST_SECOND).Within(TOLERANCE)); continue; } - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.5f).Within(0.05f) : Is.EqualTo(-0.5f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(SECOND_SECOND).Within(TOLERANCE) : Is.EqualTo(-SECOND_SECOND).Within(TOLERANCE)); } } [Test] public void HandleIntroOnly() { - var data = new float[_playLoop.TimeToSamples(1)]; - _playLoop.GenerateStream(data); + var data = new float[_clipWithLoopMarkers.TimeToSamples(1)]; + _clipWithLoopMarkers.GenerateStream(data); for (var i = 0; i < data.Length; i++) { - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.75f).Within(0.05f) : Is.EqualTo(-0.75f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(FIRST_SECOND).Within(TOLERANCE) : Is.EqualTo(-FIRST_SECOND).Within(TOLERANCE)); } } [Test] public void Handle5SecondsWithLoopAndOutro() { - _playLoop.ContinueLooping = false; + _clipWithLoopMarkers.ContinueLooping = false; - var data = new float[_playLoop.TimeToSamples(5)]; - _playLoop.GenerateStream(data); + var data = new float[_clipWithLoopMarkers.TimeToSamples(5)]; + _clipWithLoopMarkers.GenerateStream(data); for (var i = 0; i < data.Length; i++) { if (i < _second) { - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.75f).Within(0.05f) : Is.EqualTo(-0.75f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(FIRST_SECOND).Within(TOLERANCE) : Is.EqualTo(-FIRST_SECOND).Within(TOLERANCE)); continue; } if (i < _second * 2) { - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.5f).Within(0.05f) : Is.EqualTo(-0.5f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(SECOND_SECOND).Within(TOLERANCE) : Is.EqualTo(-SECOND_SECOND).Within(TOLERANCE)); continue; } if (i < _second * 3) { - Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(0.25f).Within(0.05f) : Is.EqualTo(-0.25f).Within(0.05f)); + Assert.That(data[i], i % 2 != 1 ? Is.EqualTo(THIRD_SECOND).Within(TOLERANCE) : Is.EqualTo(-THIRD_SECOND).Within(TOLERANCE)); continue; } - Assert.That(data[i], Is.EqualTo(0f).Within(0.05f)); + Assert.That(data[i], Is.EqualTo(SILENCE).Within(TOLERANCE)); } } @@ -100,10 +136,10 @@ public void TimeToSamplesAndBackwards() { const int SECONDS = 5; const int SAMPLES = 44100 * 2 * SECONDS; - var time = _playLoop.SamplesToTime(SAMPLES); + var time = _clipWithLoopMarkers.SamplesToTime(SAMPLES); Assert.That(time, Is.EqualTo(SECONDS)); - var calculatedSamples = _playLoop.TimeToSamples(time); + var calculatedSamples = _clipWithLoopMarkers.TimeToSamples(time); Assert.That(calculatedSamples, Is.EqualTo(SAMPLES)); } @@ -120,12 +156,70 @@ public void GettingClipPreInitializationFails() [Test] public void GettingClipThriceWorks() { - var clip = _playLoop.Clip; + var clip = _clipWithLoopMarkers.Clip; Assert.That(clip, Is.Not.Null); - clip = _playLoop.Clip; + clip = _clipWithLoopMarkers.Clip; Assert.That(clip, Is.Not.Null); - clip = _playLoop.Clip; + clip = _clipWithLoopMarkers.Clip; Assert.That(clip, Is.Not.Null); } + + [Test] + public void FileWithoutLoopMarkersLoopsFullFile() + { + // fetch first loop of (intro, loop, outro) and part of second loop of (intro and loop) + var dataWhileLooping = new float[_clipWithoutLoopMarkers.TimeToSamples(5)]; + _clipWithoutLoopMarkers.GenerateStream(dataWhileLooping); + + for (var i = 0; i < dataWhileLooping.Length; i++) + { + var currentSecond = i / _clipWithoutLoopMarkers.TimeToSamples(1); + switch (currentSecond) + { + case 0: + Assert.That(dataWhileLooping[i], i % 2 != 1 ? Is.EqualTo(FIRST_SECOND).Within(TOLERANCE) : Is.EqualTo(-FIRST_SECOND).Within(TOLERANCE)); + continue; + case 1: + Assert.That(dataWhileLooping[i], i % 2 != 1 ? Is.EqualTo(SECOND_SECOND).Within(TOLERANCE) : Is.EqualTo(-SECOND_SECOND).Within(TOLERANCE)); + continue; + case 2: + Assert.That(dataWhileLooping[i], i % 2 != 1 ? Is.EqualTo(THIRD_SECOND).Within(TOLERANCE) : Is.EqualTo(-THIRD_SECOND).Within(TOLERANCE)); + continue; + case 3: + Assert.That(dataWhileLooping[i], i % 2 != 1 ? Is.EqualTo(FIRST_SECOND).Within(TOLERANCE) : Is.EqualTo(-FIRST_SECOND).Within(TOLERANCE)); + continue; + case 4: + Assert.That(dataWhileLooping[i], i % 2 != 1 ? Is.EqualTo(SECOND_SECOND).Within(TOLERANCE) : Is.EqualTo(-SECOND_SECOND).Within(TOLERANCE)); + break; + } + } + } + + [Test] + public void FileWithoutLoopMarkersStopsLoopingAfterDisablingLoop() + { + // fetch intro, loop, outro, intro and loop + var dataWhileLooping = new float[_clipWithoutLoopMarkers.TimeToSamples(5)]; + _clipWithoutLoopMarkers.GenerateStream(dataWhileLooping); + + // disable looping + _clipWithoutLoopMarkers.ContinueLooping = false; + + // fetch outro, intro, loop, outro (only first outro should be heard) + var dataAfterLooping = new float[_clipWithoutLoopMarkers.TimeToSamples(4)]; + _clipWithoutLoopMarkers.GenerateStream(dataAfterLooping); + + for (var i = 0; i < dataAfterLooping.Length; i++) + { + var currentSecond = i / _clipWithoutLoopMarkers.TimeToSamples(1); + if (currentSecond == 0) + { + Assert.That(dataAfterLooping[i], i % 2 != 1 ? Is.EqualTo(THIRD_SECOND).Within(TOLERANCE) : Is.EqualTo(-THIRD_SECOND).Within(TOLERANCE)); + continue; + } + + Assert.That(dataAfterLooping[i], i % 2 != 1 ? Is.EqualTo(SILENCE).Within(TOLERANCE) : Is.EqualTo(-SILENCE).Within(TOLERANCE)); + } + } } } \ No newline at end of file diff --git a/unity-ggjj/Assets/audiotest.unity b/unity-ggjj/Assets/audiotest.unity index 1197336a2..42f375e47 100644 --- a/unity-ggjj/Assets/audiotest.unity +++ b/unity-ggjj/Assets/audiotest.unity @@ -135,7 +135,6 @@ GameObject: - component: {fileID: 1338945251} - component: {fileID: 1338945250} - component: {fileID: 1338945253} - - component: {fileID: 1338945249} m_Layer: 0 m_Name: GameObject m_TagString: Untagged @@ -143,19 +142,6 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1338945249 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1338945248} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 8e6978e948ed17b4cab0f4f80661f63b, type: 3} - m_Name: - m_EditorClassIdentifier: - source: {fileID: 1338945250} --- !u!82 &1338945250 AudioSource: m_ObjectHideFlags: 0 diff --git a/unity-ggjj/unity-ggjj.sln.DotSettings b/unity-ggjj/unity-ggjj.sln.DotSettings index 0a894cf6a..b90b2d592 100644 --- a/unity-ggjj/unity-ggjj.sln.DotSettings +++ b/unity-ggjj/unity-ggjj.sln.DotSettings @@ -14,6 +14,7 @@ True True True + True True True True