From 50379b1bdfd43e53173758fdc3d2c8b5eb769c4a Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 13:37:29 +0900 Subject: [PATCH 01/10] add WebGL audio system pause/resume support --- .../Runtime/Core/AudioClipPlayer.WebGL.cs | 66 +++++ .../Core/AudioClipPlayer.WebGL.cs.meta | 11 + .../Runtime/Core/AudioClipPlayer.cs | 19 +- .../Core/AudioConductorWebGLBroadcaster.cs | 51 ++++ .../AudioConductorWebGLBroadcaster.cs.meta | 11 + .../Runtime/Core/Conductor.WebGL.cs | 33 +++ .../Runtime/Core/Conductor.WebGL.cs.meta | 11 + .../Runtime/Core/ConductorBehaviour.WebGL.cs | 50 ++++ .../Core/ConductorBehaviour.WebGL.cs.meta | 11 + .../Runtime/Core/ConductorBehaviour.cs | 2 +- Packages/AudioConductor/Runtime/Plugins.meta | 8 + .../AudioConductor/Runtime/Plugins/WebGL.meta | 8 + .../Plugins/WebGL/AudioConductorWebGL.jslib | 21 ++ .../WebGL/AudioConductorWebGL.jslib.meta | 32 +++ .../Editor/Core/AudioClipPlayerWebGLTests.cs | 231 ++++++++++++++++++ .../Core/AudioClipPlayerWebGLTests.cs.meta | 11 + 16 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs create mode 100644 Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs.meta create mode 100644 Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs create mode 100644 Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs.meta create mode 100644 Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs create mode 100644 Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs.meta create mode 100644 Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs create mode 100644 Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs.meta create mode 100644 Packages/AudioConductor/Runtime/Plugins.meta create mode 100644 Packages/AudioConductor/Runtime/Plugins/WebGL.meta create mode 100644 Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib create mode 100644 Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib.meta create mode 100644 Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs create mode 100644 Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs.meta diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs new file mode 100644 index 0000000..b69c8a8 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs @@ -0,0 +1,66 @@ +// -------------------------------------------------------------- +// Copyright 2026 CyberAgent, Inc. +// -------------------------------------------------------------- + +#nullable enable + +#if UNITY_WEBGL +namespace AudioConductor.Core +{ + internal sealed partial class AudioClipPlayer + { + private bool _isSystemPaused; + + // The browser suspends rAF callbacks when the tab is hidden (MDN: Page Visibility API), + // and Unity's game loop runs on rAF by default (Unity Manual: WebGL performance). + // Therefore Stop()/Pause() cannot be called while system-paused. + internal void PauseBySystem() + { + if (_isSystemPaused || IsPaused || !_isPlaybackActive) + return; + + _isSystemPaused = true; + _pauseStartTime = _dspClock.DspTime; + + if (_isLoop) + { + if (_sources[0].IsPlaying) + { + _sources[0].Pause(); + _pausedIndex = 0; + _sources[1].Stop(); + } + else if (_sources[1].IsPlaying) + { + _sources[0].Stop(); + _sources[1].Pause(); + _pausedIndex = 1; + } + + return; + } + + _sources[0].Pause(); + } + + internal void ResumeBySystem() + { + if (!_isSystemPaused) + return; + + var pausedDuration = _dspClock.DspTime - _pauseStartTime; + ShiftScheduleByPauseDuration(pausedDuration); + _isSystemPaused = false; + + if (_isLoop) + { + _sources[_pausedIndex].UnPause(); + return; + } + + _sources[0].UnPause(); + } + } +} + +#endif diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs.meta b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs.meta new file mode 100644 index 0000000..44d8f57 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7041e7cbe30694a4fa0fb40c8f76d9bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs index ced024e..617463e 100644 --- a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs @@ -13,7 +13,8 @@ namespace AudioConductor.Core { - internal sealed class AudioClipPlayer : IFadeable + // ReSharper disable once PartialTypeWithSinglePart + internal sealed partial class AudioClipPlayer : IFadeable { private const int SourceNum = 2; private const float LoopLookaheadDuration = 1.0f; @@ -61,7 +62,12 @@ internal AudioClipPlayer(IAudioSourceWrapper[] sources, IDspClock dspClock, public PlayerState State { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => IsPaused ? PlayerState.Paused + get => +#if UNITY_WEBGL + (IsPaused || _isSystemPaused) ? PlayerState.Paused +#else + IsPaused ? PlayerState.Paused +#endif : _sources[0].IsPlaying || _sources[1].IsPlaying ? PlayerState.Playing : PlayerState.Stopped; } @@ -294,7 +300,11 @@ public void ManualUpdate(float _) UpdateVolume(); - if (IsPaused) + if (IsPaused +#if UNITY_WEBGL + || _isSystemPaused +#endif + ) return; if (_dspClock.DspTime < _nextEventTime) @@ -359,6 +369,9 @@ public void ResetState() _pitchExternal = 1f; _nextPlayAudioSourceIndex = 0; IsPaused = false; +#if UNITY_WEBGL + _isSystemPaused = false; +#endif _nextEventTime = 0; _pausedIndex = 0; _pauseStartTime = 0; diff --git a/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs new file mode 100644 index 0000000..14510c5 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs @@ -0,0 +1,51 @@ +// -------------------------------------------------------------- +// Copyright 2026 CyberAgent, Inc. +// -------------------------------------------------------------- + +#nullable enable + +#if UNITY_WEBGL && !UNITY_EDITOR +using System.Collections.Generic; +using System.Runtime.InteropServices; +using AOT; + +namespace AudioConductor.Core +{ + // Owns the single visibilitychange JS listener and forwards events to all active ConductorBehaviour instances. + // Registers the listener on first instance, unregisters when the last instance is removed. + internal static class AudioConductorWebGLBroadcaster + { + [DllImport("__Internal")] + private static extern void AudioConductor_RegisterVisibilityChange(VisibilityChangeCallback callback); + + [DllImport("__Internal")] + private static extern void AudioConductor_UnregisterVisibilityChange(); + + private delegate void VisibilityChangeCallback(int isHidden); + + private static readonly List _instances = new(); + + internal static void Register(ConductorBehaviour instance) + { + if (_instances.Count == 0) + AudioConductor_RegisterVisibilityChange(OnVisibilityChangedNative); + _instances.Add(instance); + } + + internal static void Unregister(ConductorBehaviour instance) + { + _instances.Remove(instance); + if (_instances.Count == 0) + AudioConductor_UnregisterVisibilityChange(); + } + + [MonoPInvokeCallback(typeof(VisibilityChangeCallback))] + private static void OnVisibilityChangedNative(int isHidden) + { + foreach (var instance in _instances) + instance.NotifySystemPause(isHidden == 1); + } + } +} + +#endif diff --git a/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs.meta b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs.meta new file mode 100644 index 0000000..698e44d --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 743595fdc5b8549d9baf6c581259b6f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs b/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs new file mode 100644 index 0000000..9d1b97e --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs @@ -0,0 +1,33 @@ +// -------------------------------------------------------------- +// Copyright 2026 CyberAgent, Inc. +// -------------------------------------------------------------- + +#nullable enable + +#if UNITY_WEBGL +namespace AudioConductor.Core +{ + public sealed partial class Conductor + { + internal void OnSystemPause(bool pause) + { + foreach (var playback in _managedPlaybacks.Values) + { + if (pause) + playback.Player.PauseBySystem(); + else + playback.Player.ResumeBySystem(); + } + + foreach (var playback in _oneShotPlaybacks) + { + if (pause) + playback.Player.PauseBySystem(); + else + playback.Player.ResumeBySystem(); + } + } + } +} + +#endif diff --git a/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs.meta b/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs.meta new file mode 100644 index 0000000..c5afaef --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/Conductor.WebGL.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 517528d39e7be4f72a00129444bba18d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs new file mode 100644 index 0000000..f5318f8 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs @@ -0,0 +1,50 @@ +// -------------------------------------------------------------- +// Copyright 2026 CyberAgent, Inc. +// -------------------------------------------------------------- + +#nullable enable + +#if UNITY_WEBGL && !UNITY_EDITOR +using System.Runtime.InteropServices; + +namespace AudioConductor.Core +{ + internal sealed partial class ConductorBehaviour + { + [DllImport("__Internal")] + private static extern int AudioConductor_IsDocumentHidden(); + + private bool _isSystemPaused; + + private void Awake() + { + AudioConductorWebGLBroadcaster.Register(this); + } + + private void OnDestroy() + { + AudioConductorWebGLBroadcaster.Unregister(this); + } + + // Fallback for iOS where visibilitychange can be unreliable. + // Filter canvas-blur false positives by checking document.hidden: + // canvas blur → hasFocus=false but document.hidden==false → skip. + // Screen lock / tab switch → hasFocus=false and document.hidden==true → allow. + private void OnApplicationFocus(bool hasFocus) + { + if (!hasFocus && AudioConductor_IsDocumentHidden() == 0) + return; + NotifySystemPause(!hasFocus); + } + + internal void NotifySystemPause(bool pause) + { + if (_isSystemPaused == pause) + return; + _isSystemPaused = pause; + Conductor?.OnSystemPause(pause); + } + } +} + +#endif diff --git a/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs.meta b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs.meta new file mode 100644 index 0000000..97d644d --- /dev/null +++ b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.WebGL.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 137e0e650a3294575bc769714f061429 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.cs b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.cs index b61b45c..40288d7 100644 --- a/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.cs +++ b/Packages/AudioConductor/Runtime/Core/ConductorBehaviour.cs @@ -8,7 +8,7 @@ namespace AudioConductor.Core { - internal sealed class ConductorBehaviour : MonoBehaviour + internal sealed partial class ConductorBehaviour : MonoBehaviour { internal Conductor? Conductor { get; set; } diff --git a/Packages/AudioConductor/Runtime/Plugins.meta b/Packages/AudioConductor/Runtime/Plugins.meta new file mode 100644 index 0000000..67e1a5b --- /dev/null +++ b/Packages/AudioConductor/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c88ef6e73aa2149d09387ac84978a99c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Plugins/WebGL.meta b/Packages/AudioConductor/Runtime/Plugins/WebGL.meta new file mode 100644 index 0000000..f0a56f3 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Plugins/WebGL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5cb0c64a209234e21825f157d8b2cf9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib b/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib new file mode 100644 index 0000000..5b4d2ee --- /dev/null +++ b/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib @@ -0,0 +1,21 @@ +var _audioConductorListener = null; + +mergeInto(LibraryManager.library, { + AudioConductor_RegisterVisibilityChange: function (callbackPtr) { + _audioConductorListener = function () { + {{{ makeDynCall('vi', 'callbackPtr') }}}(document.hidden ? 1 : 0); + }; + document.addEventListener("visibilitychange", _audioConductorListener); + }, + + AudioConductor_UnregisterVisibilityChange: function () { + if (_audioConductorListener) { + document.removeEventListener("visibilitychange", _audioConductorListener); + _audioConductorListener = null; + } + }, + + AudioConductor_IsDocumentHidden: function () { + return document.hidden ? 1 : 0; + } +}); diff --git a/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib.meta b/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib.meta new file mode 100644 index 0000000..a007b64 --- /dev/null +++ b/Packages/AudioConductor/Runtime/Plugins/WebGL/AudioConductorWebGL.jslib.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 72bd0fcb6035f4833b6c050d635b3f93 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs new file mode 100644 index 0000000..7ed968c --- /dev/null +++ b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs @@ -0,0 +1,231 @@ +// -------------------------------------------------------------- +// Copyright 2026 CyberAgent, Inc. +// -------------------------------------------------------------- + +#nullable enable + +#if UNITY_WEBGL +using AudioConductor.Core; +using AudioConductor.Core.Enums; +using AudioConductor.Editor.Core.Tests.Fakes; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace AudioConductor.Editor.Core.Tests +{ + [TestFixture] + internal sealed class AudioClipPlayerWebGLTests + { + [SetUp] + public void SetUp() + { + _source0 = new SpyAudioSourceWrapper(); + _source1 = new SpyAudioSourceWrapper(); + _clock = new StubDspClock(); + _player = new AudioClipPlayer(new IAudioSourceWrapper[] { _source0, _source1 }, _clock, + NullLifecycle.Instance); + _clip = AudioClip.Create("test", 44100, 1, 44100, false); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(_clip); + } + + private SpyAudioSourceWrapper _source0 = null!; + private SpyAudioSourceWrapper _source1 = null!; + private StubDspClock _clock = null!; + private AudioClipPlayer _player = null!; + private AudioClip _clip = null!; + + private void SetupAndPlay(bool isLoop = false) + { + _player.Setup(null, _clip, 0, 1f, 1f, isLoop, 0, 0, _clip.samples); + _player.Play(); + } + + // --- PauseBySystem --- + + [Test] + public void PauseBySystem_WhenPlaying_SetsStatePaused() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + + _player.PauseBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + + [Test] + public void PauseBySystem_WhenAlreadySystemPaused_NoOp() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _player.PauseBySystem(); + + _player.PauseBySystem(); + + Assert.That(_source0.PauseCount, Is.EqualTo(1)); + } + + [Test] + public void PauseBySystem_WhenUserPaused_DoesNotSystemPause() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _player.Pause(); + + _player.PauseBySystem(); + + // Only the user Pause() call should have called Pause on the source + Assert.That(_source0.PauseCount, Is.EqualTo(1)); + } + + [Test] + public void PauseBySystem_WhenNotPlaying_NoOp() + { + _player.Setup(null, _clip, 0, 1f, 1f, false, 0, 0, _clip.samples); + + _player.PauseBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Stopped)); + } + + [Test] + public void PauseBySystem_Loop_Source0Playing_PausesSource0StopsSource1() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = true; + _source1.IsPlaying = false; + + _player.PauseBySystem(); + + Assert.That(_source0.PauseCount, Is.EqualTo(1)); + Assert.That(_source1.StopCount, Is.GreaterThanOrEqualTo(1)); + } + + // --- ResumeBySystem --- + + [Test] + public void ResumeBySystem_WhenNotSystemPaused_DoesNotThrow() + { + _player.Setup(null, _clip, 0, 1f, 1f, false, 0, 0, _clip.samples); + + Assert.DoesNotThrow(() => _player.ResumeBySystem()); + } + + [Test] + public void ResumeBySystem_WhenUserPaused_DoesNotUnpauseUser() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _player.Pause(); + _player.PauseBySystem(); + + _player.ResumeBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + + [Test] + public void ResumeBySystem_ShiftsScheduledEndTime() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _source0.IsPlaying = true; + var endTimeBefore = _source0.LastScheduledEndTime; + + _clock.DspTime = 1.0; + _player.PauseBySystem(); + _clock.DspTime = 3.0; + _player.ResumeBySystem(); + + Assert.That(_source0.LastScheduledEndTime, Is.EqualTo(endTimeBefore + 2.0).Within(0.0001)); + } + + [Test] + public void ResumeBySystem_Loop_Source0Paused_UnpausesSource0() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = true; + _source1.IsPlaying = false; + _player.PauseBySystem(); + + _player.ResumeBySystem(); + + Assert.That(_source0.UnPauseCount, Is.EqualTo(1)); + Assert.That(_source1.UnPauseCount, Is.EqualTo(0)); + } + + [Test] + public void ResumeBySystem_Loop_Source1Paused_UnpausesSource1() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = false; + _source1.IsPlaying = true; + _player.PauseBySystem(); + + _player.ResumeBySystem(); + + Assert.That(_source1.UnPauseCount, Is.EqualTo(1)); + Assert.That(_source0.UnPauseCount, Is.EqualTo(0)); + } + + [Test] + public void ResumeBySystem_Loop_Source1Paused_ShiftsScheduledEndTimeOnSource1() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = false; + _source1.IsPlaying = true; + var scheduledEndTime = _source0.LastScheduledEndTime; + + _clock.DspTime = 1.0; + _player.PauseBySystem(); + _clock.DspTime = 3.0; + _player.ResumeBySystem(); + + Assert.That(_source1.LastScheduledEndTime, Is.EqualTo(scheduledEndTime + 2.0).Within(0.0001)); + } + + // --- ManualUpdate interaction --- + + [Test] + public void ManualUpdate_WhenSystemPaused_DoesNotTriggerStop() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + var stopped = false; + _player.SetStopAction(() => stopped = true); + + _player.PauseBySystem(); + _clock.DspTime = 100.0; + _player.ManualUpdate(0f); + + Assert.That(stopped, Is.False); + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + + // --- ResetState --- + + [Test] + public void ResetState_ClearsSystemPaused() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _player.PauseBySystem(); + + _player.ResetState(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Stopped)); + } + } +} + +#endif diff --git a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs.meta b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs.meta new file mode 100644 index 0000000..61a077c --- /dev/null +++ b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd6be98fd4f994e91b8768c8893efe46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 08ea2883647b55d54194efe83c6beb8597e50f46 Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 14:36:41 +0900 Subject: [PATCH 02/10] fix pause/resume when called within PlayScheduleDelay window --- .../Runtime/Core/AudioClipPlayer.WebGL.cs | 36 +++++++- .../Runtime/Core/AudioClipPlayer.cs | 56 ++++++++++-- .../Tests/Editor/Core/AudioClipPlayerTests.cs | 62 +++++++++++++ .../Editor/Core/AudioClipPlayerWebGLTests.cs | 87 +++++++++++++++++++ 4 files changed, 234 insertions(+), 7 deletions(-) diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs index b69c8a8..6177da0 100644 --- a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.WebGL.cs @@ -36,11 +36,28 @@ internal void PauseBySystem() _sources[1].Pause(); _pausedIndex = 1; } + else + { + // Neither source is playing yet (within PlayScheduleDelay window). + // AudioSource.Pause() on a scheduled-but-not-yet-playing source has undefined + // behavior per Unity docs, so stop both and reschedule fresh on ResumeBySystem. + _sources[0].Stop(); + _sources[1].Stop(); + _wasStoppedBeforePlay = true; + } return; } - _sources[0].Pause(); + if (_sources[0].IsPlaying) + { + _sources[0].Pause(); + } + else + { + _sources[0].Stop(); + _wasStoppedBeforePlay = true; + } } internal void ResumeBySystem() @@ -48,6 +65,23 @@ internal void ResumeBySystem() if (!_isSystemPaused) return; + if (_wasStoppedBeforePlay) + { + _isSystemPaused = false; + if (_isPlaybackActive && !IsPaused) + { + _wasStoppedBeforePlay = false; + _nextPlayAudioSourceIndex = 0; + SchedulePlayback(_dspClock.DspTime + PlayScheduleDelay, _startSample); + } + else if (!_isPlaybackActive) + { + _wasStoppedBeforePlay = false; + } + + return; + } + var pausedDuration = _dspClock.DspTime - _pauseStartTime; ShiftScheduleByPauseDuration(pausedDuration); _isSystemPaused = false; diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs index 617463e..9c2c1b4 100644 --- a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs @@ -17,7 +17,11 @@ namespace AudioConductor.Core internal sealed partial class AudioClipPlayer : IFadeable { private const int SourceNum = 2; + private const float LoopLookaheadDuration = 1.0f; + + // https://qiita.com/tatmos/items/4c78c127291a0c3b74ed + private const float PlayScheduleDelay = 0.1f; private const int VolumeScale = 10000; private readonly IDspClock _dspClock; private readonly IAudioPlayerLifecycle _lifecycle; @@ -43,6 +47,7 @@ internal sealed partial class AudioClipPlayer : IFadeable private float _volumeCategory = 1f; private float _volumeMaster = 1f; private float _volumeRuntime; + private bool _wasStoppedBeforePlay; internal AudioClipPlayer(IAudioSourceWrapper[] sources, IDspClock dspClock, IAudioPlayerLifecycle lifecycle) @@ -64,7 +69,7 @@ public PlayerState State [MethodImpl(MethodImplOptions.AggressiveInlining)] get => #if UNITY_WEBGL - (IsPaused || _isSystemPaused) ? PlayerState.Paused + IsPaused || _isSystemPaused ? PlayerState.Paused #else IsPaused ? PlayerState.Paused #endif @@ -129,13 +134,11 @@ public void Play() _sources[0].Stop(); _sources[1].Stop(); + _wasStoppedBeforePlay = false; _isPlaybackActive = true; _sources[1].Enabled = _isLoop; - // for smooth switching of AudioSource - // https://qiita.com/tatmos/items/4c78c127291a0c3b74ed - const float delay = 0.1f; - SchedulePlayback(_dspClock.DspTime + delay, _startSample); + SchedulePlayback(_dspClock.DspTime + PlayScheduleDelay, _startSample); } public void Restart() @@ -164,12 +167,30 @@ public void Pause() _sources[1].Pause(); _pausedIndex = 1; } + else + { + // Neither source is playing yet (within PlayScheduleDelay window). + // AudioSource.Pause() on a scheduled-but-not-yet-playing source has undefined + // behavior per Unity docs, so stop both and reschedule fresh on Resume. + _sources[0].Stop(); + _sources[1].Stop(); + _wasStoppedBeforePlay = true; + } IsPaused = true; return; } - _sources[0].Pause(); + if (_sources[0].IsPlaying) + { + _sources[0].Pause(); + } + else + { + _sources[0].Stop(); + _wasStoppedBeforePlay = true; + } + IsPaused = true; } @@ -178,6 +199,27 @@ public void Resume() if (!IsPaused) return; + if (_wasStoppedBeforePlay) + { + IsPaused = false; + if (_isPlaybackActive +#if UNITY_WEBGL + && !_isSystemPaused +#endif + ) + { + _wasStoppedBeforePlay = false; + _nextPlayAudioSourceIndex = 0; + SchedulePlayback(_dspClock.DspTime + PlayScheduleDelay, _startSample); + } + else if (!_isPlaybackActive) + { + _wasStoppedBeforePlay = false; + } + + return; + } + _pauseEndTime = _dspClock.DspTime; var pausedDuration = _pauseEndTime - _pauseStartTime; ShiftScheduleByPauseDuration(pausedDuration); @@ -201,6 +243,7 @@ public void Stop() _sources[1].Stop(); _isPlaybackActive = false; + _wasStoppedBeforePlay = false; InvokeStopAction(); IsPaused = false; } @@ -369,6 +412,7 @@ public void ResetState() _pitchExternal = 1f; _nextPlayAudioSourceIndex = 0; IsPaused = false; + _wasStoppedBeforePlay = false; #if UNITY_WEBGL _isSystemPaused = false; #endif diff --git a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerTests.cs b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerTests.cs index 05df692..fbd072c 100644 --- a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerTests.cs +++ b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerTests.cs @@ -429,6 +429,22 @@ public void Pause_Loop_Source1Playing_StopsSource0PausesSource1() Assert.That(_source0.StopCount, Is.GreaterThanOrEqualTo(1)); } + [Test] + public void Pause_Loop_NeitherSourcePlaying_SetsStatePausedWithoutPausingAnySources() + { + _clock.DspTime = 0.0; + SetupPlayer(isLoop: true, endSample: _clip.samples); + _player.Play(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _source1.IsPlaying = false; + + _player.Pause(); + + Assert.That(_source0.PauseCount, Is.EqualTo(0)); + Assert.That(_source1.PauseCount, Is.EqualTo(0)); + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + [Test] public void Pause_NonLoop_PausesSource0() { @@ -441,6 +457,20 @@ public void Pause_NonLoop_PausesSource0() Assert.That(_source0.PauseCount, Is.EqualTo(1)); } + [Test] + public void Pause_NonLoop_NeitherSourcePlaying_SetsStatePausedWithoutPausingAnySources() + { + _clock.DspTime = 0.0; + SetupPlayer(isLoop: false, endSample: _clip.samples); + _player.Play(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + + _player.Pause(); + + Assert.That(_source0.PauseCount, Is.EqualTo(0)); + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + // --- Resume (loop branch) --- [Test] @@ -475,6 +505,23 @@ public void Resume_Loop_PausedSource1_UnPausesSource1() Assert.That(_source0.UnPauseCount, Is.EqualTo(0)); } + [Test] + public void Resume_Loop_WasStoppedBeforePlay_Reschedules() + { + _clock.DspTime = 0.0; + SetupPlayer(isLoop: true, endSample: _clip.samples); + _player.Play(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _source1.IsPlaying = false; + _player.Pause(); + + _player.Resume(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Playing)); + Assert.That(_source0.IsPlaying, Is.True); + Assert.That(_source1.IsPlaying, Is.False); + } + [Test] public void Resume_NonLoop_UnPausesSource0() { @@ -488,6 +535,21 @@ public void Resume_NonLoop_UnPausesSource0() Assert.That(_source0.UnPauseCount, Is.EqualTo(1)); } + [Test] + public void Resume_NonLoop_WasStoppedBeforePlay_Reschedules() + { + _clock.DspTime = 0.0; + SetupPlayer(isLoop: false, endSample: _clip.samples); + _player.Play(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _player.Pause(); + + _player.Resume(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Playing)); + Assert.That(_source0.IsPlaying, Is.True); + } + // --- SetEndAction / _onEnd fire --- [Test] diff --git a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs index 7ed968c..3c86d69 100644 --- a/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs +++ b/Packages/AudioConductor/Tests/Editor/Core/AudioClipPlayerWebGLTests.cs @@ -108,6 +108,21 @@ public void PauseBySystem_Loop_Source0Playing_PausesSource0StopsSource1() Assert.That(_source1.StopCount, Is.GreaterThanOrEqualTo(1)); } + [Test] + public void PauseBySystem_Loop_NeitherSourcePlaying_SetsStatePausedWithoutPausingAnySources() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _source1.IsPlaying = false; + + _player.PauseBySystem(); + + Assert.That(_source0.PauseCount, Is.EqualTo(0)); + Assert.That(_source1.PauseCount, Is.EqualTo(0)); + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + // --- ResumeBySystem --- [Test] @@ -194,6 +209,78 @@ public void ResumeBySystem_Loop_Source1Paused_ShiftsScheduledEndTimeOnSource1() Assert.That(_source1.LastScheduledEndTime, Is.EqualTo(scheduledEndTime + 2.0).Within(0.0001)); } + [Test] + public void ResumeBySystem_Loop_WasStoppedBeforePlay_Reschedules() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _source1.IsPlaying = false; + _player.PauseBySystem(); + + _player.ResumeBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Playing)); + Assert.That(_source0.IsPlaying, Is.True); + Assert.That(_source1.IsPlaying, Is.False); + } + + [Test] + public void ResumeBySystem_Loop_WasStoppedBeforePlay_WhenUserAlsoPaused_DoesNotReschedule() + { + _clock.DspTime = 0.0; + SetupAndPlay(true); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _source1.IsPlaying = false; + _player.PauseBySystem(); + _player.Pause(); + + _player.ResumeBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + + [Test] + public void PauseBySystem_NonLoop_NeitherSourcePlaying_SetsStatePausedWithoutPausingAnySources() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + + _player.PauseBySystem(); + + Assert.That(_source0.PauseCount, Is.EqualTo(0)); + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + + [Test] + public void ResumeBySystem_NonLoop_WasStoppedBeforePlay_Reschedules() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _player.PauseBySystem(); + + _player.ResumeBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Playing)); + Assert.That(_source0.IsPlaying, Is.True); + } + + [Test] + public void ResumeBySystem_NonLoop_WasStoppedBeforePlay_WhenUserAlsoPaused_DoesNotReschedule() + { + _clock.DspTime = 0.0; + SetupAndPlay(); + _source0.IsPlaying = false; // simulate PlayScheduleDelay window + _player.PauseBySystem(); + _player.Pause(); + + _player.ResumeBySystem(); + + Assert.That(_player.State, Is.EqualTo(PlayerState.Paused)); + } + // --- ManualUpdate interaction --- [Test] From 16ae4b14a4dc0e488426ccd9779895eda0fcdbba Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 14:52:29 +0900 Subject: [PATCH 03/10] bump version to 2.4.0 --- Packages/AudioConductor/CHANGELOG.md | 6 ++++++ Packages/AudioConductor/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/AudioConductor/CHANGELOG.md b/Packages/AudioConductor/CHANGELOG.md index e02417a..67a2953 100644 --- a/Packages/AudioConductor/CHANGELOG.md +++ b/Packages/AudioConductor/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v2.4.0 - 2026/06/09 + +- Fix Issues + - Auto-pause and resume audio when the WebGL app loses focus + - Fix pause/resume not working correctly when Pause is called immediately after Play + ## v2.3.0 - 2026/06/05 - New Features diff --git a/Packages/AudioConductor/package.json b/Packages/AudioConductor/package.json index c1e2265..ad7a667 100644 --- a/Packages/AudioConductor/package.json +++ b/Packages/AudioConductor/package.json @@ -1,6 +1,6 @@ { "name": "jp.co.cyberagent.audioconductor", - "version": "2.3.0", + "version": "2.4.0", "displayName": "Audio Conductor", "unity": "2022.3", "license": "MIT", From 1abf46b226724ab680ddd92b1cf830b1d98b41e6 Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 18:18:01 +0900 Subject: [PATCH 04/10] add referenceSampleRate to CueSheet for runtime sample position conversion --- .../Runtime/Core/AudioClipPlayer.cs | 25 +++++++++++++++---- .../Runtime/Core/Conductor.Playback.cs | 4 +-- .../Runtime/Core/Models/CueSheet.cs | 6 +++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs index 9c2c1b4..0a1e706 100644 --- a/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs +++ b/Packages/AudioConductor/Runtime/Core/AudioClipPlayer.cs @@ -96,7 +96,8 @@ public void Setup(AudioMixerGroup? audioMixerGroup, bool isLoop, int startSample, int loopStartSample, - int endSample) + int endSample, + int referenceSampleRate = 0) { if (clip == null) return; @@ -109,7 +110,6 @@ public void Setup(AudioMixerGroup? audioMixerGroup, _sources[0].Clip = clip; _sources[0].PlayOnAwake = false; _sources[0].Loop = false; - _sources[0].TimeSamples = startSample; _sources[1].OutputAudioMixerGroup = audioMixerGroup; _sources[1].Clip = clip; _sources[1].PlayOnAwake = false; @@ -119,14 +119,20 @@ public void Setup(AudioMixerGroup? audioMixerGroup, ClipSamples = clip.samples; CategoryId = categoryId; + var convertedStart = ConvertSample(startSample, referenceSampleRate, _frequency); + var convertedLoopStart = ConvertSample(loopStartSample, referenceSampleRate, _frequency); + var convertedEnd = ConvertSample(endSample, referenceSampleRate, _frequency); + + _sources[0].TimeSamples = convertedStart; + _volumeRuntime = 1f; SetPitchInternal(pitch); VolumeAsset = volume; UpdateVolume(); - _startSample = ValueRangeConst.StartSample.Clamp(startSample, ClipSamples); - _loopStartSample = ValueRangeConst.LoopStartSample.Clamp(loopStartSample, ClipSamples); - _endSample = ValueRangeConst.EndSample.Clamp(endSample, ClipSamples); + _startSample = ValueRangeConst.StartSample.Clamp(convertedStart, ClipSamples); + _loopStartSample = ValueRangeConst.LoopStartSample.Clamp(convertedLoopStart, ClipSamples); + _endSample = ValueRangeConst.EndSample.Clamp(convertedEnd, ClipSamples); } public void Play() @@ -558,5 +564,14 @@ private void InvokeStopAction() return playing0 ? _sources[0] : playing1 ? _sources[1] : null; } + + // Converts a sample position from referenceSampleRate to clipFrequency. + // When referenceFrequency is 0 (unset)or already matches clipFrequency, no conversion is applied. + private static int ConvertSample(int sample, int referenceFrequency, int clipFrequency) + { + if (referenceFrequency == 0 || referenceFrequency == clipFrequency) + return sample; + return Mathf.RoundToInt((float)sample * clipFrequency / referenceFrequency); + } } } diff --git a/Packages/AudioConductor/Runtime/Core/Conductor.Playback.cs b/Packages/AudioConductor/Runtime/Core/Conductor.Playback.cs index aed4909..c2d2954 100644 --- a/Packages/AudioConductor/Runtime/Core/Conductor.Playback.cs +++ b/Packages/AudioConductor/Runtime/Core/Conductor.Playback.cs @@ -265,7 +265,7 @@ private PlaybackHandle PlayCue(uint cueSheetId, CueSheetRegistration registratio var pitch = Calculator.CalcPitch(cueSheet, cue, track); var isLoop = options?.IsLoop == true || track.isLoop; player.Setup(category?.audioMixerGroup, track.audioClip, cue.categoryId, volume, pitch, isLoop, - track.startSample, track.loopStartSample, track.endSample); + track.startSample, track.loopStartSample, track.endSample, cueSheet.referenceSampleRate); player.Play(); if (options?.OnStop is { } onStop) player.SetStopAction(onStop); if (options?.OnEnd is { } onEnd) player.SetEndAction(onEnd); @@ -304,7 +304,7 @@ private void PlayOneShotCue(uint cueSheetId, CueSheetRegistration registration, var volume = Calculator.CalcVolume(cueSheet, cue, track); var pitch = Calculator.CalcPitch(cueSheet, cue, track); player.Setup(category?.audioMixerGroup, track.audioClip, cue.categoryId, volume, pitch, false, - track.startSample, track.loopStartSample, track.endSample); + track.startSample, track.loopStartSample, track.endSample, cueSheet.referenceSampleRate); player.Play(); if (options?.OnStop is { } onStop) player.SetStopAction(onStop); if (options?.OnEnd is { } onEnd) player.SetEndAction(onEnd); diff --git a/Packages/AudioConductor/Runtime/Core/Models/CueSheet.cs b/Packages/AudioConductor/Runtime/Core/Models/CueSheet.cs index 5bac5e4..4ce0285 100644 --- a/Packages/AudioConductor/Runtime/Core/Models/CueSheet.cs +++ b/Packages/AudioConductor/Runtime/Core/Models/CueSheet.cs @@ -48,6 +48,12 @@ public sealed class CueSheet /// public bool pitchInvert; + /// + /// The sample rate at which sample positions in this CueSheet's tracks were authored. + /// Zero means unset; no frequency conversion is applied at runtime. + /// + public int referenceSampleRate; + /// /// List of . /// From 108ae1c922c861646bd3d888da3d7fddb21bb5bf Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 18:21:43 +0900 Subject: [PATCH 05/10] prevent GC collection of visibility change delegate in WebGL --- .../Runtime/Core/AudioConductorWebGLBroadcaster.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs index 14510c5..5e787db 100644 --- a/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs +++ b/Packages/AudioConductor/Runtime/Core/AudioConductorWebGLBroadcaster.cs @@ -23,12 +23,14 @@ internal static class AudioConductorWebGLBroadcaster private delegate void VisibilityChangeCallback(int isHidden); + // Keep a rooted reference to prevent GC collection of the delegate in IL2CPP/WebGL. + private static readonly VisibilityChangeCallback _visibilityChangeCallback = OnVisibilityChangedNative; private static readonly List _instances = new(); internal static void Register(ConductorBehaviour instance) { if (_instances.Count == 0) - AudioConductor_RegisterVisibilityChange(OnVisibilityChangedNative); + AudioConductor_RegisterVisibilityChange(_visibilityChangeCallback); _instances.Add(instance); } From e34307f439dd69774ce0f5904ee8fae83d818742 Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 18:36:08 +0900 Subject: [PATCH 06/10] add referenceSampleRate migration dialog on editor startup --- .../Shared/CueSheetAssetImportChecker.cs | 77 +++++++++++++++++++ .../Localization/EnglishTranslations.cs | 22 +++++- .../Localization/JapaneseTranslations.cs | 22 +++++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/Packages/AudioConductor/Editor/Core/Tools/Shared/CueSheetAssetImportChecker.cs b/Packages/AudioConductor/Editor/Core/Tools/Shared/CueSheetAssetImportChecker.cs index 16e3427..1948826 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/Shared/CueSheetAssetImportChecker.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/Shared/CueSheetAssetImportChecker.cs @@ -9,12 +9,16 @@ using System.Linq; using AudioConductor.Core.Models; using UnityEditor; +using UnityEngine; namespace AudioConductor.Editor.Core.Tools.Shared { [InitializeOnLoad] internal class CueSheetAssetImportChecker : AssetPostprocessor { + private const string ReferenceSampleRateMigrationSkipKey = + "AudioConductor.ReferenceSampleRateMigration.Skip"; + private static readonly Dictionary CueSheetAssets; private static readonly HashSet CueSheetIds; @@ -35,6 +39,8 @@ static CueSheetAssetImportChecker() CueSheetIds.Add(asset.cueSheet.Id); MigrateCueIds(asset); } + + EditorApplication.delayCall += () => MigrateReferenceSampleRates(CueSheetAssets.Values); } private static void OnPostprocessAllAssets(string[] importedAssets, @@ -165,5 +171,76 @@ private static void MigrateCueIds(CueSheetAsset asset) EditorUtility.SetDirty(asset); AssetDatabase.SaveAssets(); } + + internal static void MigrateReferenceSampleRates(IEnumerable assets) + { + if (EditorPrefs.GetBool(ReferenceSampleRateMigrationSkipKey, false)) + return; + + var toMigrate = new List<(CueSheetAsset asset, int frequency)>(); + var inconsistentNames = new List(); + + foreach (var asset in assets) + { + if (asset.cueSheet.referenceSampleRate != 0) + continue; + + var frequencies = CollectClipFrequencies(asset.cueSheet); + if (frequencies.Count == 0) + continue; + + if (frequencies.Count == 1) + toMigrate.Add((asset, frequencies.First())); + else + inconsistentNames.Add(asset.name); + } + + foreach (var name in inconsistentNames) + Debug.LogWarning(string.Format( + Localization.Localization.Tr("migration.reference_sample_rate.inconsistent_warning"), + name)); + + if (toMigrate.Count == 0) + return; + + var names = string.Join("\n", toMigrate.Select(x => $" {x.asset.name} ({x.frequency} Hz)")); + var message = + $"{Localization.Localization.Tr("migration.reference_sample_rate.dialog_message")}\n\n{names}"; + + // 2: Don't show again + var result = EditorUtility.DisplayDialogComplex( + Localization.Localization.Tr("migration.reference_sample_rate.dialog_title"), + message, + Localization.Localization.Tr("migration.reference_sample_rate.apply"), + Localization.Localization.Tr("migration.reference_sample_rate.skip"), + Localization.Localization.Tr("migration.reference_sample_rate.dont_show_again")); + + if (result == 2) + { + EditorPrefs.SetBool(ReferenceSampleRateMigrationSkipKey, true); + return; + } + + if (result != 0) + return; + + foreach (var (asset, frequency) in toMigrate) + { + asset.cueSheet.referenceSampleRate = frequency; + EditorUtility.SetDirty(asset); + } + + AssetDatabase.SaveAssets(); + } + + internal static HashSet CollectClipFrequencies(CueSheet cueSheet) + { + var frequencies = new HashSet(); + foreach (var cue in cueSheet.cueList) + foreach (var track in cue.trackList) + if (track.audioClip != null) + frequencies.Add(track.audioClip.frequency); + return frequencies; + } } } diff --git a/Packages/AudioConductor/Editor/Localization/EnglishTranslations.cs b/Packages/AudioConductor/Editor/Localization/EnglishTranslations.cs index cfbfb11..3d0dc41 100644 --- a/Packages/AudioConductor/Editor/Localization/EnglishTranslations.cs +++ b/Packages/AudioConductor/Editor/Localization/EnglishTranslations.cs @@ -16,7 +16,10 @@ internal static class EnglishTranslations { "settings.throttle_limit", "Limit of concurrent play." }, { "settings.managed_pool_size", "Number of managed AudioClipPlayers to pre-create on construction." }, { "settings.oneshot_pool_size", "Number of one-shot AudioClipPlayers to pre-create on construction." }, - { "settings.deactivate_pooled_objects", "When enabled, pooled AudioClipPlayer GameObjects are deactivated while idle. Reduces active GameObject overhead at the cost of SetActive calls on rent/return." }, + { + "settings.deactivate_pooled_objects", + "When enabled, pooled AudioClipPlayer GameObjects are deactivated while idle. Reduces active GameObject overhead at the cost of SetActive calls on rent/return." + }, { "category.name", "Category name." }, { "category.throttle_type", "Concurrent play control type." }, { "category.throttle_limit", "Limit of concurrent play." }, @@ -115,7 +118,22 @@ internal static class EnglishTranslations { "cue_enum_definition.asset.asset", "The CueSheetAsset reference." }, { "cue_enum_definition.asset.cue_sheet_name", "Name of the CueSheet." }, { "cue_enum_definition.asset.cue_count", "Number of cues in this CueSheet." }, - { "cue_enum_definition.excluded.path_rule", "Glob pattern to auto-exclude CueSheetAssets." } + { "cue_enum_definition.excluded.path_rule", "Glob pattern to auto-exclude CueSheetAssets." }, + { + "migration.reference_sample_rate.dialog_title", + "AudioConductor Migration" + }, + { + "migration.reference_sample_rate.dialog_message", + "The following CueSheets have no referenceSampleRate set.\nApply the current clip frequencies?" + }, + { "migration.reference_sample_rate.apply", "Apply" }, + { "migration.reference_sample_rate.skip", "Skip" }, + { "migration.reference_sample_rate.dont_show_again", "Don't show again" }, + { + "migration.reference_sample_rate.inconsistent_warning", + "[AudioConductor] CueSheet '{0}' has mixed AudioClip sample rates. Set referenceSampleRate manually." + } }; } } diff --git a/Packages/AudioConductor/Editor/Localization/JapaneseTranslations.cs b/Packages/AudioConductor/Editor/Localization/JapaneseTranslations.cs index c648213..85dddd4 100644 --- a/Packages/AudioConductor/Editor/Localization/JapaneseTranslations.cs +++ b/Packages/AudioConductor/Editor/Localization/JapaneseTranslations.cs @@ -16,7 +16,10 @@ internal static class JapaneseTranslations { "settings.throttle_limit", "同時発音数の上限" }, { "settings.managed_pool_size", "構築時に事前生成するマネージド AudioClipPlayer の数" }, { "settings.oneshot_pool_size", "構築時に事前生成するワンショット AudioClipPlayer の数" }, - { "settings.deactivate_pooled_objects", "有効にすると、アイドル中のプール済み AudioClipPlayer の GameObject を非アクティブにします。アクティブな GameObject のオーバーヘッドを削減しますが、貸出/返却時に SetActive 呼び出しコストが発生します。" }, + { + "settings.deactivate_pooled_objects", + "有効にすると、アイドル中のプール済み AudioClipPlayer の GameObject を非アクティブにします。アクティブな GameObject のオーバーヘッドを削減しますが、貸出/返却時に SetActive 呼び出しコストが発生します。" + }, { "category.name", "カテゴリ名" }, { "category.throttle_type", "同時発音の制御方式" }, { "category.throttle_limit", "同時発音数の上限" }, @@ -95,7 +98,22 @@ internal static class JapaneseTranslations { "cue_enum_definition.asset.asset", "CueSheetAsset の参照" }, { "cue_enum_definition.asset.cue_sheet_name", "CueSheet の名前" }, { "cue_enum_definition.asset.cue_count", "この CueSheet の Cue 数" }, - { "cue_enum_definition.excluded.path_rule", "CueSheetAsset を自動除外する glob パターン" } + { "cue_enum_definition.excluded.path_rule", "CueSheetAsset を自動除外する glob パターン" }, + { + "migration.reference_sample_rate.dialog_title", + "AudioConductor マイグレーション" + }, + { + "migration.reference_sample_rate.dialog_message", + "以下の CueSheet は referenceSampleRate が未設定です。\n 現在のクリップ周波数を適用しますか?" + }, + { "migration.reference_sample_rate.apply", "適用" }, + { "migration.reference_sample_rate.skip", "スキップ" }, + { "migration.reference_sample_rate.dont_show_again", "次回から表示しない" }, + { + "migration.reference_sample_rate.inconsistent_warning", + "[AudioConductor] CueSheet '{0}' の AudioClip にサンプルレートの混在があります。referenceSampleRate を手動で設定してください。" + } }; } } From 0a412a13879c9f679cec8f368b0048d4fa812784 Mon Sep 17 00:00:00 2001 From: Daiki Hakamata Date: Tue, 9 Jun 2026 19:18:54 +0900 Subject: [PATCH 07/10] warn about unset referenceSampleRate in validation, build, and editor UI --- .../Models/CueSheetParameterPaneModel.cs | 44 +++++++++++++++++ .../Interfaces/ICueSheetParameterPaneModel.cs | 6 +++ .../Models/ObservableCueSheet.cs | 10 ++++ .../CueSheetParameterPanePresenter.cs | 10 ++++ .../Views/CueSheetParameterPaneView.cs | 38 +++++++++++++++ .../ReferenceSampleRateBuildPreprocessor.cs | 47 +++++++++++++++++++ ...ferenceSampleRateBuildPreprocessor.cs.meta | 11 +++++ .../Rules/ReferenceSampleRateUnsetRule.cs | 28 +++++++++++ .../ReferenceSampleRateUnsetRule.cs.meta | 11 +++++ .../Localization/EnglishTranslations.cs | 9 ++++ .../Localization/JapaneseTranslations.cs | 9 ++++ .../Uxml/CueSheetParameterPane.uxml | 5 ++ 12 files changed, 228 insertions(+) create mode 100644 Packages/AudioConductor/Editor/Core/Tools/Shared/ReferenceSampleRateBuildPreprocessor.cs create mode 100644 Packages/AudioConductor/Editor/Core/Tools/Shared/ReferenceSampleRateBuildPreprocessor.cs.meta create mode 100644 Packages/AudioConductor/Editor/Core/Tools/Validation/Rules/ReferenceSampleRateUnsetRule.cs create mode 100644 Packages/AudioConductor/Editor/Core/Tools/Validation/Rules/ReferenceSampleRateUnsetRule.cs.meta diff --git a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/CueSheetParameterPaneModel.cs b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/CueSheetParameterPaneModel.cs index 7df6d83..cfbfa2f 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/CueSheetParameterPaneModel.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/CueSheetParameterPaneModel.cs @@ -4,6 +4,8 @@ #nullable enable +using System.Collections.Generic; +using System.Linq; using AudioConductor.Core.Enums; using AudioConductor.Core.Models; using AudioConductor.Core.Shared; @@ -19,17 +21,59 @@ internal sealed class CueSheetParameterPaneModel : ICueSheetParameterPaneModel { private readonly IAssetSaveService _assetSaveService; private readonly AutoIncrementHistory _history; + private readonly CueSheet _rawCueSheet; private readonly ObservableCueSheet _target; public CueSheetParameterPaneModel([NotNull] CueSheet cueSheet, [NotNull] AutoIncrementHistory history, [NotNull] IAssetSaveService assetSaveService) { + _rawCueSheet = cueSheet; _target = new ObservableCueSheet(cueSheet); _history = history; _assetSaveService = assetSaveService; } + public IReadOnlyObservableProperty ReferenceSampleRateObservable => _target.ReferenceSampleRateObservable; + + public bool CanApplyReferenceSampleRate => CollectClipFrequencies().Count == 1; + + public void ApplyReferenceSampleRate() + { + var frequencies = CollectClipFrequencies(); + if (frequencies.Count != 1) + return; + var frequency = frequencies.First(); + var old = _target.ReferenceSampleRate; + _history.Register($"Set CueSheet {nameof(CueSheet.referenceSampleRate)} {frequency}", Redo, Undo); + + #region LocalMethods + + void Redo() + { + _target.ReferenceSampleRate = frequency; + _assetSaveService.Save(); + } + + void Undo() + { + _target.ReferenceSampleRate = old; + _assetSaveService.Save(); + } + + #endregion + } + + private HashSet CollectClipFrequencies() + { + var frequencies = new HashSet(); + foreach (var cue in _rawCueSheet.cueList) + foreach (var track in cue.trackList) + if (track.audioClip != null) + frequencies.Add(track.audioClip.frequency); + return frequencies; + } + #region Name public string Name diff --git a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/Interfaces/ICueSheetParameterPaneModel.cs b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/Interfaces/ICueSheetParameterPaneModel.cs index 3fb8dc0..af66cd1 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/Interfaces/ICueSheetParameterPaneModel.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/Interfaces/ICueSheetParameterPaneModel.cs @@ -34,5 +34,11 @@ internal interface ICueSheetParameterPaneModel bool PitchInvert { get; set; } IReadOnlyObservableProperty PitchInvertObservable { get; } + + IReadOnlyObservableProperty ReferenceSampleRateObservable { get; } + + bool CanApplyReferenceSampleRate { get; } + + void ApplyReferenceSampleRate(); } } diff --git a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/ObservableCueSheet.cs b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/ObservableCueSheet.cs index e09f1d4..c84e501 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/ObservableCueSheet.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Models/ObservableCueSheet.cs @@ -18,6 +18,7 @@ internal sealed class ObservableCueSheet private readonly ObservableProperty _name; private readonly ObservableProperty _pitch; private readonly ObservableProperty _pitchInvert; + private readonly ObservableProperty _referenceSampleRate; private readonly ObservableProperty _throttleLimit; private readonly ObservableProperty _throttleType; private readonly ObservableProperty _volume; @@ -33,11 +34,20 @@ public ObservableCueSheet([NotNull] CueSheet cueSheet) _volume = new(_cueSheet.volume); _pitch = new(_cueSheet.pitch); _pitchInvert = new(_cueSheet.pitchInvert); + _referenceSampleRate = new(_cueSheet.referenceSampleRate); // ReSharper enable ArrangeObjectCreationWhenTypeNotEvident } public string Id => _cueSheet.Id; + public int ReferenceSampleRate + { + get => _referenceSampleRate.Value; + set => _referenceSampleRate.SetValueAndNotify(_cueSheet.referenceSampleRate = value); + } + + public IReadOnlyObservableProperty ReferenceSampleRateObservable => _referenceSampleRate; + public string Name { get => _name.Value; diff --git a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Presenters/CueSheetParameterPanePresenter.cs b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Presenters/CueSheetParameterPanePresenter.cs index 6c8c643..d390386 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Presenters/CueSheetParameterPanePresenter.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Presenters/CueSheetParameterPanePresenter.cs @@ -68,6 +68,13 @@ private void Bind() _model.PitchInvertObservable .Subscribe(_view.SetPitchInvert) .DisposeWith(_bindDisposable); + _model.ReferenceSampleRateObservable + .Subscribe(value => + { + _view.SetReferenceSampleRate(value); + _view.SetApplyButtonEnabled(_model.CanApplyReferenceSampleRate); + }) + .DisposeWith(_bindDisposable); } private void Unbind() @@ -95,6 +102,9 @@ private void SetupViewEventHandlers() _view.PitchInvertChangedAsObservable .Subscribe(value => _model.PitchInvert = value) .DisposeWith(_viewEventDisposable); + _view.ApplyReferenceSampleRateAsObservable + .Subscribe(_ => _model.ApplyReferenceSampleRate()) + .DisposeWith(_viewEventDisposable); } private void CleanupViewEventHandlers() diff --git a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Views/CueSheetParameterPaneView.cs b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Views/CueSheetParameterPaneView.cs index f8207df..acc1feb 100644 --- a/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Views/CueSheetParameterPaneView.cs +++ b/Packages/AudioConductor/Editor/Core/Tools/CueSheetEditor/Views/CueSheetParameterPaneView.cs @@ -15,12 +15,16 @@ namespace AudioConductor.Editor.Core.Tools.CueSheetEditor.Views { internal sealed class CueSheetParameterPaneView : VisualElement, IDisposable { + private readonly Button _applyReferenceSampleRateButton; + private readonly Subject _applyReferenceSampleRateSubject = new(); private readonly Subject _nameChangedSubject = new(); private readonly TextField _nameField; private readonly Subject _pitchChangedSubject = new(); private readonly SliderAndFloatField _pitchField; private readonly Subject _pitchInvertChangedSubject = new(); private readonly Toggle _pitchInvertField; + private readonly IntegerField _referenceSampleRateField; + private readonly HelpBox _referenceSampleRateWarning; private readonly Subject _throttleLimitChangedSubject = new(); private readonly IntegerField _throttleLimitField; private readonly Subject _throttleTypeChangedSubject = new(); @@ -45,6 +49,13 @@ public CueSheetParameterPaneView() _pitchField.lowValue = ValueRangeConst.Pitch.Min; _pitchField.highValue = ValueRangeConst.Pitch.Max; + _referenceSampleRateField = this.Q("ReferenceSampleRate"); + _referenceSampleRateField.SetEnabled(false); + _referenceSampleRateWarning = this.Q("ReferenceSampleRateWarning"); + _referenceSampleRateWarning.SetDisplay(false); + _applyReferenceSampleRateButton = this.Q