diff --git a/README.md b/README.md index 8f59eab5..49f44746 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,9 @@ The following prerequisites need to be installed in order to use Serial Loops: - Using the Windows graphical installer, you can simply select the devkitARM (Nintendo DS) workloads - On macOS and Linux, run `sudo skp-pacman -S nds-dev` from the terminal after installing the devkitPro pacman distribution. -Additionally, on Linux, the following are prerequisites to using the program: -* libvlc -* VLC -* libx11 - -On Ubuntu/Debian (which are the distros we test on), these can be installed in three commands: +Additionally, on Linux, you will need to install OpenAL. On Ubuntu/Debian (which are the distros we test on), it can be installed in a single command: ``` -sudo apt install libvlc-dev -sudo apt install vlc -sudo apt install libx11 +sudo apt install libopenal-dev ``` ## Bugs diff --git a/SerialLoops.sln b/SerialLoops.sln index da54d5af..71d5044f 100644 --- a/SerialLoops.sln +++ b/SerialLoops.sln @@ -9,6 +9,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialLoops.Tests", "test\S EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialLoops.Lib", "src\SerialLoops.Lib\SerialLoops.Lib.csproj", "{CC729174-57CD-499A-B6E7-1FA6F9CE255F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SerialLoops.Gtk", "src\SerialLoops.Gtk\SerialLoops.Gtk.csproj", "{EA5F17A9-CA95-43A0-BE14-33F41EDF02BB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SerialLoops.Mac", "src\SerialLoops.Mac\SerialLoops.Mac.csproj", "{2DF676C8-9917-48EA-A8CF-435CE29BB8A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SerialLoops.Wpf", "src\SerialLoops.Wpf\SerialLoops.Wpf.csproj", "{2DF0B35B-5A1C-43C7-A6BD-95D4D48FE39A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,12 +33,24 @@ Global {CC729174-57CD-499A-B6E7-1FA6F9CE255F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CC729174-57CD-499A-B6E7-1FA6F9CE255F}.Release|Any CPU.ActiveCfg = Release|Any CPU {CC729174-57CD-499A-B6E7-1FA6F9CE255F}.Release|Any CPU.Build.0 = Release|Any CPU + {EA5F17A9-CA95-43A0-BE14-33F41EDF02BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA5F17A9-CA95-43A0-BE14-33F41EDF02BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA5F17A9-CA95-43A0-BE14-33F41EDF02BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA5F17A9-CA95-43A0-BE14-33F41EDF02BB}.Release|Any CPU.Build.0 = Release|Any CPU + {2DF676C8-9917-48EA-A8CF-435CE29BB8A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DF676C8-9917-48EA-A8CF-435CE29BB8A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DF676C8-9917-48EA-A8CF-435CE29BB8A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DF676C8-9917-48EA-A8CF-435CE29BB8A8}.Release|Any CPU.Build.0 = Release|Any CPU + {2DF0B35B-5A1C-43C7-A6BD-95D4D48FE39A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DF0B35B-5A1C-43C7-A6BD-95D4D48FE39A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DF0B35B-5A1C-43C7-A6BD-95D4D48FE39A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DF0B35B-5A1C-43C7-A6BD-95D4D48FE39A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {922A1BAC-5F5E-4225-8E10-69036C42B786} SolutionGuid = {A7259B55-BEEA-4ECC-AC0A-85CC8FA1BD53} + SolutionGuid = {922A1BAC-5F5E-4225-8E10-69036C42B786} EndGlobalSection EndGlobal diff --git a/azure-pipelines-official.yml b/azure-pipelines-official.yml index 0da6f344..c99a7fb0 100644 --- a/azure-pipelines-official.yml +++ b/azure-pipelines-official.yml @@ -10,10 +10,13 @@ jobs: matrix: Linux: imageName: 'ubuntu-latest' + platformName: 'Gtk' macOS: imageName: 'macOS-latest' + platformName: 'Mac' Windows: imageName: 'windows-latest' + platformName: 'Wpf' displayName: Build & Publish pool: vmImage: $(imageName) @@ -24,7 +27,7 @@ jobs: - task: DotNetCoreCLI@2 inputs: command: 'publish' - projects: $(Build.SourcesDirectory)/src/SerialLoops/SerialLoops.csproj + projects: $(Build.SourcesDirectory)/src/SerialLoops.$(platformName)/SerialLoops.$(platformName).csproj arguments: '-c Release -o $(Build.ArtifactStagingDirectory)' publishWebProjects: false displayName: Build & Publish Serial Loops diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8f652c0a..122ec290 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,10 +9,13 @@ jobs: matrix: Linux: imageName: 'ubuntu-latest' + platformName: 'Gtk' macOS: imageName: 'macOS-latest' + platformName: 'Mac' Windows: imageName: 'windows-latest' + platformName: 'Wpf' displayName: Build & Test pool: vmImage: $(imageName) @@ -23,7 +26,7 @@ jobs: - task: DotNetCoreCLI@2 inputs: command: 'build' - projects: $(Build.SourcesDirectory)/SerialLoops.sln + projects: $(Build.SourcesDirectory)/src/SerialLoops.$(platformName)/SerialLoops.$(platformName).csproj displayName: Build solution - task: DotNetCoreCLI@2 diff --git a/src/SerialLoops.Gtk/ALWavePlayer.cs b/src/SerialLoops.Gtk/ALWavePlayer.cs new file mode 100644 index 00000000..57ba33b0 --- /dev/null +++ b/src/SerialLoops.Gtk/ALWavePlayer.cs @@ -0,0 +1,286 @@ +using NAudio.Wave; +using OpenTK.Audio.OpenAL; +using System; +using System.Threading.Tasks; + +namespace SerialLoops.Gtk +{ + // Adapted from https://gist.github.com/VisualMelon/d02edcd7c44fadcd6f5745e92c449a90 + public class ALAudioContext : IDisposable + { + public ALDevice Device { get; private set; } + public ALContext Context { get; private set; } + + public ALAudioContext() + { + Init(); + } + + private unsafe void Init() + { + Device = ALC.OpenDevice(null); + Context = ALC.CreateContext(Device, (int*)null); + ALC.MakeContextCurrent(Context); + } + + ~ALAudioContext() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (Context != ALContext.Null) + { + ALC.MakeContextCurrent(ALContext.Null); + ALC.DestroyContext(Context); + } + Context = ALContext.Null; + + if (Device != IntPtr.Zero) + { + ALC.CloseDevice(Device); + } + Device = ALDevice.Null; + } + } + + public class ALWavePlayer : IWavePlayer, IDisposable + { + private float _volume; + public float Volume + { + get => _volume; + set + { + _volume = value; + } + } + + public PlaybackState PlaybackState { get; private set; } + + public event EventHandler PlaybackStopped; + + private ALAudioContext Context { get; } + public int BufferSize { get; } + + private IWaveProvider WaveProvider; + + private int _source; + private int _nextBuffer; + private int _otherBuffer; + + private byte[] _buffer; + private Accumulator _accumulator; + + private System.Threading.ManualResetEventSlim _signaller; + + private System.Threading.CancellationTokenSource _playerCanceller; + private Task Player; + + public WaveFormat OutputWaveFormat => WaveProvider.WaveFormat; + + public ALWavePlayer(ALAudioContext context, int bufferSize) + { + Context = context; + BufferSize = bufferSize; + } + + public unsafe void Init(IWaveProvider waveProvider) + { + WaveProvider = waveProvider; + + AL.GenSources(1, ref _source); + AL.GenBuffers(1, ref _nextBuffer); + AL.GenBuffers(1, ref _otherBuffer); + + _buffer = new byte[BufferSize]; + _accumulator = new Accumulator(waveProvider, _buffer); + + _signaller = new System.Threading.ManualResetEventSlim(false); + PlaybackState = PlaybackState.Paused; + } + + public void Pause() + { + if (PlaybackState == PlaybackState.Stopped) + throw new InvalidOperationException("Stopped"); + + PlaybackState = PlaybackState.Paused; + _playerCanceller?.Cancel(); + _playerCanceller = null; + AL.SourcePause(_source); + } + + public void Play() + { + if (PlaybackState == PlaybackState.Stopped) + throw new InvalidOperationException("Stopped"); + + PlaybackState = PlaybackState.Playing; + if (_playerCanceller == null) + { + _playerCanceller = new System.Threading.CancellationTokenSource(); + Player = PlayLoop(_playerCanceller.Token).ContinueWith(PlayerStopped); + } + } + + private void PlayerStopped(Task t) + { + PlaybackStopped?.Invoke(this, new StoppedEventArgs(t?.Exception)); + } + + public void Stop() + { + if (PlaybackState == PlaybackState.Stopped) + { + throw new InvalidOperationException("Already stopped"); + } + + PlaybackState = PlaybackState.Stopped; + if (_playerCanceller != null) + { + _playerCanceller?.Cancel(); + _playerCanceller = null; + AL.SourceStop(_source); + } + else + { + PlaybackStopped?.Invoke(this, new StoppedEventArgs()); + } + } + + private async Task PlayLoop(System.Threading.CancellationToken ct) + { + AL.SourcePlay(_source); + await Task.Yield(); + + while (true) + { + AL.GetSource(_source, ALGetSourcei.BuffersQueued, out int queued); + AL.GetSource(_source, ALGetSourcei.BuffersProcessed, out int processed); + AL.GetSource(_source, ALGetSourcei.SourceState, out int state); + + if ((ALSourceState)state != ALSourceState.Playing) + { + AL.SourcePlay(_source); + } + + if (processed == 0 && queued == 2) + { + await Task.Delay(1); + continue; + } + + if (processed > 0) + { + AL.SourceUnqueueBuffers(_source, processed); + } + + var notFinished = await _accumulator.Accumulate(_buffer, ct); + + if (!notFinished) + { + return; + } + + AL.BufferData(_nextBuffer, TranslateFormat(WaveProvider.WaveFormat), _buffer, WaveProvider.WaveFormat.SampleRate); + AL.SourceQueueBuffer(_source, _nextBuffer); + await Task.Delay(10, ct); + + (_nextBuffer, _otherBuffer) = (_otherBuffer, _nextBuffer); + } + } + + ~ALWavePlayer() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + AL.DeleteSource(_source); + AL.DeleteBuffer(_nextBuffer); + AL.DeleteBuffer(_otherBuffer); + } + + public static ALFormat TranslateFormat(WaveFormat format) + { + if (format.Channels == 2) + { + if (format.BitsPerSample == 32) + { + return ALFormat.StereoFloat32Ext; + } + else if (format.BitsPerSample == 16) + { + return ALFormat.Stereo16; + } + else if (format.BitsPerSample == 8) + { + return ALFormat.Stereo8; + } + } + else if (format.Channels == 1) + { + if (format.BitsPerSample == 32) + { + return ALFormat.MonoFloat32Ext; + } + else if (format.BitsPerSample == 16) + { + return ALFormat.Mono16; + } + else if (format.BitsPerSample == 8) + { + return ALFormat.Mono8; + } + } + + throw new FormatException("Cannot translate WaveFormat."); + } + } + + public class Accumulator + { + public Accumulator(IWaveProvider provider, byte[] buffer) + { + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public IWaveProvider Provider { get; } + //private object Locker = new(); + + public async Task Accumulate(byte[] buffer, System.Threading.CancellationToken ct) + { + await Task.Yield(); + + int position = 0; + while (position < buffer.Length) + { + if (ct.IsCancellationRequested) + throw new TaskCanceledException(); + var read = Provider.Read(buffer, position, buffer.Length - position); + + if (read == 0) + return false; + + position += read; + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/SerialLoops.Gtk/Program.cs b/src/SerialLoops.Gtk/Program.cs new file mode 100644 index 00000000..62c22a59 --- /dev/null +++ b/src/SerialLoops.Gtk/Program.cs @@ -0,0 +1,17 @@ +using Eto.Forms; +using System; + +namespace SerialLoops.Gtk +{ + internal class Program + { + [STAThread] + public static void Main(string[] args) + { + var platform = new Eto.GtkSharp.Platform(); + platform.Add(() => new SoundPlayerHandler()); + + new Application(platform).Run(new MainForm()); + } + } +} diff --git a/src/SerialLoops.Gtk/SerialLoops.Gtk.csproj b/src/SerialLoops.Gtk/SerialLoops.Gtk.csproj new file mode 100644 index 00000000..8b0dd2b5 --- /dev/null +++ b/src/SerialLoops.Gtk/SerialLoops.Gtk.csproj @@ -0,0 +1,18 @@ + + + + WinExe + net6.0 + True + + + + + + + + + + + + diff --git a/src/SerialLoops.Gtk/SoundPlayerHandler.cs b/src/SerialLoops.Gtk/SoundPlayerHandler.cs new file mode 100644 index 00000000..eebcbe9a --- /dev/null +++ b/src/SerialLoops.Gtk/SoundPlayerHandler.cs @@ -0,0 +1,42 @@ +using Eto.GtkSharp.Forms; +using Gtk; +using NAudio.Wave; + +namespace SerialLoops.Gtk +{ + public class SoundPlayerHandler : GtkControl, SoundPlayer.ISoundPlayer + { + private ALWavePlayer _player; + public IWaveProvider WaveProvider { get; set; } + + public PlaybackState PlaybackState => _player.PlaybackState; + + public SoundPlayerHandler() + { + Control = new Button(); + } + + public void Initialize(IWaveProvider waveProvider) + { + WaveProvider = waveProvider; + _player = new(new(), 8192); + _player.Init(WaveProvider); + } + + public void Pause() + { + _player.Pause(); + } + + public void Play() + { + _player.Play(); + } + + public void Stop() + { + // AL has a static player, so if we stop it we'll throw errors + _player.Pause(); + } + } +} diff --git a/src/SerialLoops.Lib/Items/ScriptItem.cs b/src/SerialLoops.Lib/Items/ScriptItem.cs index fa6b99f3..a9346681 100644 --- a/src/SerialLoops.Lib/Items/ScriptItem.cs +++ b/src/SerialLoops.Lib/Items/ScriptItem.cs @@ -67,6 +67,10 @@ public void CalculateGraphEdges(Dictionary i.Name == name); + return Items.FirstOrDefault(i => i.Name == name.Split(" - ")[0]); } public static Project OpenProject(string projFile, Config config, ILogger log, IProgressTracker tracker) diff --git a/src/SerialLoops.Mac/ALWavePlayer.cs b/src/SerialLoops.Mac/ALWavePlayer.cs new file mode 100644 index 00000000..9a100bcb --- /dev/null +++ b/src/SerialLoops.Mac/ALWavePlayer.cs @@ -0,0 +1,286 @@ +using NAudio.Wave; +using OpenTK.Audio.OpenAL; +using System; +using System.Threading.Tasks; + +namespace SerialLoops.Mac +{ + // Adapted from https://gist.github.com/VisualMelon/d02edcd7c44fadcd6f5745e92c449a90 + public class ALAudioContext : IDisposable + { + public ALDevice Device { get; private set; } + public ALContext Context { get; private set; } + + public ALAudioContext() + { + Init(); + } + + private unsafe void Init() + { + Device = ALC.OpenDevice(null); + Context = ALC.CreateContext(Device, (int*)null); + ALC.MakeContextCurrent(Context); + } + + ~ALAudioContext() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (Context != ALContext.Null) + { + ALC.MakeContextCurrent(ALContext.Null); + ALC.DestroyContext(Context); + } + Context = ALContext.Null; + + if (Device != IntPtr.Zero) + { + ALC.CloseDevice(Device); + } + Device = ALDevice.Null; + } + } + + public class ALWavePlayer : IWavePlayer, IDisposable + { + private float _volume; + public float Volume + { + get => _volume; + set + { + _volume = value; + } + } + + public PlaybackState PlaybackState { get; private set; } + + public event EventHandler PlaybackStopped; + + private ALAudioContext Context { get; } + public int BufferSize { get; } + + private IWaveProvider WaveProvider; + + private int _source; + private int _nextBuffer; + private int _otherBuffer; + + private byte[] _buffer; + private Accumulator _accumulator; + + private System.Threading.ManualResetEventSlim _signaller; + + private System.Threading.CancellationTokenSource _playerCanceller; + private Task Player; + + public WaveFormat OutputWaveFormat => WaveProvider.WaveFormat; + + public ALWavePlayer(ALAudioContext context, int bufferSize) + { + Context = context; + BufferSize = bufferSize; + } + + public unsafe void Init(IWaveProvider waveProvider) + { + WaveProvider = waveProvider; + + AL.GenSources(1, ref _source); + AL.GenBuffers(1, ref _nextBuffer); + AL.GenBuffers(1, ref _otherBuffer); + + _buffer = new byte[BufferSize]; + _accumulator = new Accumulator(waveProvider, _buffer); + + _signaller = new System.Threading.ManualResetEventSlim(false); + PlaybackState = PlaybackState.Paused; + } + + public void Pause() + { + if (PlaybackState == PlaybackState.Stopped) + throw new InvalidOperationException("Stopped"); + + PlaybackState = PlaybackState.Paused; + _playerCanceller?.Cancel(); + _playerCanceller = null; + AL.SourcePause(_source); + } + + public void Play() + { + if (PlaybackState == PlaybackState.Stopped) + throw new InvalidOperationException("Stopped"); + + PlaybackState = PlaybackState.Playing; + if (_playerCanceller == null) + { + _playerCanceller = new System.Threading.CancellationTokenSource(); + Player = PlayLoop(_playerCanceller.Token).ContinueWith(PlayerStopped); + } + } + + private void PlayerStopped(Task t) + { + PlaybackStopped?.Invoke(this, new StoppedEventArgs(t?.Exception)); + } + + public void Stop() + { + if (PlaybackState == PlaybackState.Stopped) + { + throw new InvalidOperationException("Already stopped"); + } + + PlaybackState = PlaybackState.Stopped; + if (_playerCanceller != null) + { + _playerCanceller?.Cancel(); + _playerCanceller = null; + AL.SourceStop(_source); + } + else + { + PlaybackStopped?.Invoke(this, new StoppedEventArgs()); + } + } + + private async Task PlayLoop(System.Threading.CancellationToken ct) + { + AL.SourcePlay(_source); + await Task.Yield(); + + while (true) + { + AL.GetSource(_source, ALGetSourcei.BuffersQueued, out int queued); + AL.GetSource(_source, ALGetSourcei.BuffersProcessed, out int processed); + AL.GetSource(_source, ALGetSourcei.SourceState, out int state); + + if ((ALSourceState)state != ALSourceState.Playing) + { + AL.SourcePlay(_source); + } + + if (processed == 0 && queued == 2) + { + await Task.Delay(1); + continue; + } + + if (processed > 0) + { + AL.SourceUnqueueBuffers(_source, processed); + } + + var notFinished = await _accumulator.Accumulate(_buffer, ct); + + if (!notFinished) + { + return; + } + + AL.BufferData(_nextBuffer, TranslateFormat(WaveProvider.WaveFormat), _buffer, WaveProvider.WaveFormat.SampleRate); + AL.SourceQueueBuffer(_source, _nextBuffer); + await Task.Delay(10, ct); + + (_nextBuffer, _otherBuffer) = (_otherBuffer, _nextBuffer); + } + } + + ~ALWavePlayer() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + AL.DeleteSource(_source); + AL.DeleteBuffer(_nextBuffer); + AL.DeleteBuffer(_otherBuffer); + } + + public static ALFormat TranslateFormat(WaveFormat format) + { + if (format.Channels == 2) + { + if (format.BitsPerSample == 32) + { + return ALFormat.StereoFloat32Ext; + } + else if (format.BitsPerSample == 16) + { + return ALFormat.Stereo16; + } + else if (format.BitsPerSample == 8) + { + return ALFormat.Stereo8; + } + } + else if (format.Channels == 1) + { + if (format.BitsPerSample == 32) + { + return ALFormat.MonoFloat32Ext; + } + else if (format.BitsPerSample == 16) + { + return ALFormat.Mono16; + } + else if (format.BitsPerSample == 8) + { + return ALFormat.Mono8; + } + } + + throw new FormatException("Cannot translate WaveFormat."); + } + } + + public class Accumulator + { + public Accumulator(IWaveProvider provider, byte[] buffer) + { + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public IWaveProvider Provider { get; } + //private object Locker = new(); + + public async Task Accumulate(byte[] buffer, System.Threading.CancellationToken ct) + { + await Task.Yield(); + + int position = 0; + while (position < buffer.Length) + { + if (ct.IsCancellationRequested) + throw new TaskCanceledException(); + var read = Provider.Read(buffer, position, buffer.Length - position); + + if (read == 0) + return false; + + position += read; + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/SerialLoops/Info.plist b/src/SerialLoops.Mac/Info.plist similarity index 100% rename from src/SerialLoops/Info.plist rename to src/SerialLoops.Mac/Info.plist diff --git a/src/SerialLoops/MacIcon.icns b/src/SerialLoops.Mac/MacIcon.icns similarity index 100% rename from src/SerialLoops/MacIcon.icns rename to src/SerialLoops.Mac/MacIcon.icns diff --git a/src/SerialLoops.Mac/Program.cs b/src/SerialLoops.Mac/Program.cs new file mode 100644 index 00000000..89a98c06 --- /dev/null +++ b/src/SerialLoops.Mac/Program.cs @@ -0,0 +1,17 @@ +using Eto.Forms; +using System; + +namespace SerialLoops.Mac +{ + internal class Program + { + [STAThread] + public static void Main(string[] args) + { + var platform = new Eto.Mac.Platform(); + platform.Add(() => new SoundPlayerHandler()); + + new Application(platform).Run(new MainForm()); + } + } +} diff --git a/src/SerialLoops.Mac/SerialLoops.Mac.csproj b/src/SerialLoops.Mac/SerialLoops.Mac.csproj new file mode 100644 index 00000000..5ba1f6fe --- /dev/null +++ b/src/SerialLoops.Mac/SerialLoops.Mac.csproj @@ -0,0 +1,23 @@ + + + + Exe + net6.0 + osx-x64;osx-arm64 + True + + + + + + + + + + + + + + + + diff --git a/src/SerialLoops.Mac/SoundPlayerHandler.cs b/src/SerialLoops.Mac/SoundPlayerHandler.cs new file mode 100644 index 00000000..d1159cd4 --- /dev/null +++ b/src/SerialLoops.Mac/SoundPlayerHandler.cs @@ -0,0 +1,42 @@ +using Eto.Mac.Forms.Controls; +using MonoMac.AppKit; +using NAudio.Wave; + +namespace SerialLoops.Mac +{ + public class SoundPlayerHandler : MacControl, SoundPlayer.ISoundPlayer + { + private ALWavePlayer _player; + public IWaveProvider WaveProvider { get; set; } + + public PlaybackState PlaybackState => _player.PlaybackState; + + public SoundPlayerHandler() + { + Control = new NSButton(); + } + + public void Initialize(IWaveProvider waveProvider) + { + WaveProvider = waveProvider; + _player = new(new(), 8192); + _player.Init(WaveProvider); + } + + public void Pause() + { + _player.Pause(); + } + + public void Play() + { + _player.Play(); + } + + public void Stop() + { + // AL has a static player, so if we stop it we'll throw errors + _player.Pause(); + } + } +} diff --git a/src/SerialLoops.Wpf/Program.cs b/src/SerialLoops.Wpf/Program.cs new file mode 100644 index 00000000..fe52f730 --- /dev/null +++ b/src/SerialLoops.Wpf/Program.cs @@ -0,0 +1,17 @@ +using Eto.Forms; +using System; + +namespace SerialLoops.Wpf +{ + internal class Program + { + [STAThread] + public static void Main(string[] args) + { + var platform = new Eto.Wpf.Platform(); + platform.Add(() => new SoundPlayerHandler()); + + new Application(platform).Run(new MainForm()); + } + } +} diff --git a/src/SerialLoops.Wpf/SerialLoops.Wpf.csproj b/src/SerialLoops.Wpf/SerialLoops.Wpf.csproj new file mode 100644 index 00000000..1bc50688 --- /dev/null +++ b/src/SerialLoops.Wpf/SerialLoops.Wpf.csproj @@ -0,0 +1,21 @@ + + + + WinExe + net6.0-windows + WindowsIcon.ico + + + + + + + + + + + + + + + diff --git a/src/SerialLoops.Wpf/SoundPlayerHandler.cs b/src/SerialLoops.Wpf/SoundPlayerHandler.cs new file mode 100644 index 00000000..78796197 --- /dev/null +++ b/src/SerialLoops.Wpf/SoundPlayerHandler.cs @@ -0,0 +1,41 @@ +using Eto.Wpf.Forms; +using NAudio.Wave; +using System.Windows.Controls; + +namespace SerialLoops.Wpf +{ + public class SoundPlayerHandler : WpfControl, SoundPlayer.ISoundPlayer + { + private WaveOut _player; + public IWaveProvider WaveProvider { get; set; } + public PlaybackState PlaybackState => _player.PlaybackState; + + public SoundPlayerHandler() + { + Control = new Button(); + } + + public void Initialize(IWaveProvider waveProvider) + { + WaveProvider = waveProvider; + + _player = new() { DeviceNumber = -1 }; + _player.Init(WaveProvider); + } + + public void Pause() + { + _player.Pause(); + } + + public void Play() + { + _player.Play(); + } + + public void Stop() + { + _player.Stop(); + } + } +} diff --git a/src/SerialLoops/WindowsIcon.ico b/src/SerialLoops.Wpf/WindowsIcon.ico similarity index 100% rename from src/SerialLoops/WindowsIcon.ico rename to src/SerialLoops.Wpf/WindowsIcon.ico diff --git a/src/SerialLoops/Controls/SoundPlayerPanel.cs b/src/SerialLoops/Controls/SoundPlayerPanel.cs index 2fefff9e..6997cc09 100644 --- a/src/SerialLoops/Controls/SoundPlayerPanel.cs +++ b/src/SerialLoops/Controls/SoundPlayerPanel.cs @@ -1,10 +1,6 @@ using Eto.Forms; using HaruhiChokuretsuLib.Util; -using LibVLCSharp.Shared; using NAudio.Wave; -using SerialLoops.Lib.Util; -using System; -using System.IO; namespace SerialLoops.Controls { @@ -12,32 +8,17 @@ public class SoundPlayerPanel : Panel { private ILogger _log; private IWaveProvider _sound; - private MediaPlayer _player; + private SoundPlayer _player; private Button _playPauseButton; public SoundPlayerPanel(IWaveProvider sound, ILogger log) { _log = log; _sound = sound; - - MemoryStream memoryStream = new(); - WaveProviderStream waveStream = new(_sound); - WaveFileWriter writer = new(memoryStream, _sound.WaveFormat); - waveStream.CopyTo(writer); - memoryStream.Position = 0; - LibVLC libVlc; - try - { - libVlc = new(); - } - catch (VLCException exc) - { - _log.LogError($"Error instantiating VLC -- if you're using Linux, ensure you've followed the instructions on installing libvlc for your platform.\nInner exception: {exc.Message}\n\n{exc.StackTrace}"); - return; - } - StreamMediaInput mediaInput = new(memoryStream); - Media media = new(libVlc, mediaInput); - _player = new(media); + _log.Log("Attempting to initialize sound player..."); + _player = new SoundPlayer(); + _player.Initialize(_sound); + _log.Log("Sound player successfully initialized."); InitializeComponent(); } @@ -45,11 +26,9 @@ public SoundPlayerPanel(IWaveProvider sound, ILogger log) public void InitializeComponent() { _playPauseButton = new() { Text = "▶️", Font = new(Eto.Drawing.SystemFont.Default, 30.0f) }; - Slider volumeSlider = new() { Orientation = Orientation.Horizontal, MinValue = 0, MaxValue = 100, Value = 100 }; _playPauseButton.Click += PlayPauseButton_Click; - volumeSlider.ValueChanged += VolumeSlider_ValueChanged; - Content = new TableLayout(new TableRow(_playPauseButton), new TableRow(volumeSlider)); + Content = new TableLayout(new TableRow(_playPauseButton), new TableRow()); } public void Stop() @@ -59,7 +38,7 @@ public void Stop() private void PlayPauseButton_Click(object sender, System.EventArgs e) { - if (_player.IsPlaying) + if (_player.PlaybackState == PlaybackState.Playing) { _player.Pause(); _playPauseButton.Text = "▶️"; @@ -70,10 +49,5 @@ private void PlayPauseButton_Click(object sender, System.EventArgs e) _playPauseButton.Text = "⏸️"; } } - - private void VolumeSlider_ValueChanged(object sender, System.EventArgs e) - { - _player.Volume = ((Slider)sender).Value; - } } } diff --git a/src/SerialLoops/Editors/ScriptEditor.cs b/src/SerialLoops/Editors/ScriptEditor.cs index 4f11c74c..e2f3b051 100644 --- a/src/SerialLoops/Editors/ScriptEditor.cs +++ b/src/SerialLoops/Editors/ScriptEditor.cs @@ -13,6 +13,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection.Emit; using static SerialLoops.Lib.Script.ScriptItemCommand; namespace SerialLoops.Editors @@ -612,8 +613,40 @@ private void UpdatePreview() // Draw character sprites Dictionary sprites = new(); - foreach (ScriptItemCommand command in commands.Where(c => c.Verb == EventFile.CommandVerb.DIALOGUE || c.Verb == EventFile.CommandVerb.LOAD_ISOMAP)) + ScriptItemCommand previousCommand = null; + foreach (ScriptItemCommand command in commands) { + if (previousCommand?.Verb == EventFile.CommandVerb.DIALOGUE) + { + SpriteExitScriptParameter spriteExitMoveParam = (SpriteExitScriptParameter)previousCommand?.Parameters[3]; // exits/moves happen _after_ dialogue is advanced, so we check these at this point + if ((spriteExitMoveParam.ExitTransition) != SpriteExitScriptParameter.SpriteExitTransition.NO_EXIT) + { + Speaker prevSpeaker = ((DialogueScriptParameter)previousCommand.Parameters[0]).Line.Speaker; + SpriteScriptParameter previousSpriteParam = (SpriteScriptParameter)previousCommand.Parameters[1]; + short layer = ((ShortScriptParameter)previousCommand.Parameters[9]).Value; + switch (spriteExitMoveParam.ExitTransition) + { + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_LEFT_FADE_OUT: + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_RIGHT_FADE_OUT: + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_FROM_CENTER_TO_LEFT_FADE_OUT: + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_FROM_CENTER_TO_RIGHT_FADE_OUT: + case SpriteExitScriptParameter.SpriteExitTransition.FADE_OUT_CENTER: + case SpriteExitScriptParameter.SpriteExitTransition.FADE_OUT_LEFT: + sprites.Remove(prevSpeaker); + break; + + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_CENTER_TO_LEFT_AND_STAY: + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_RIGHT_TO_LEFT_AND_STAY: + sprites[prevSpeaker] = new() { Sprite = previousSpriteParam.Sprite, Positioning = new() { Position = SpritePositioning.SpritePosition.LEFT, Layer = layer } }; + break; + + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_CENTER_TO_RIGHT_AND_STAY: + case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_RIGHT_AND_STAY: + sprites[prevSpeaker] = new() { Sprite = previousSpriteParam.Sprite, Positioning = new() { Position = SpritePositioning.SpritePosition.RIGHT, Layer = layer } }; + break; + } + } + } if (command.Verb == EventFile.CommandVerb.DIALOGUE) { SpriteScriptParameter spriteParam = (SpriteScriptParameter)command.Parameters[1]; @@ -621,39 +654,13 @@ private void UpdatePreview() { Speaker speaker = ((DialogueScriptParameter)command.Parameters[0]).Line.Speaker; SpriteEntranceScriptParameter spriteEntranceParam = (SpriteEntranceScriptParameter)command.Parameters[2]; - SpriteExitScriptParameter spriteExitMoveParam = (SpriteExitScriptParameter)command.Parameters[3]; short layer = ((ShortScriptParameter)command.Parameters[9]).Value; if (!sprites.ContainsKey(speaker)) { sprites.Add(speaker, new()); } - - if (spriteExitMoveParam.ExitTransition != SpriteExitScriptParameter.SpriteExitTransition.NO_EXIT) - { - switch (spriteExitMoveParam.ExitTransition) - { - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_LEFT_FADE_OUT: - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_RIGHT_FADE_OUT: - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_FROM_CENTER_TO_LEFT_FADE_OUT: - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_FROM_CENTER_TO_RIGHT_FADE_OUT: - case SpriteExitScriptParameter.SpriteExitTransition.FADE_OUT_CENTER: - case SpriteExitScriptParameter.SpriteExitTransition.FADE_OUT_LEFT: - sprites.Remove(speaker); - break; - - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_CENTER_TO_LEFT_AND_STAY: - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_RIGHT_TO_LEFT_AND_STAY: - sprites[speaker] = new() { Sprite = spriteParam.Sprite, Positioning = new() { Position = SpritePositioning.SpritePosition.LEFT, Layer = layer } }; - break; - - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_CENTER_TO_RIGHT_AND_STAY: - case SpriteExitScriptParameter.SpriteExitTransition.SLIDE_LEFT_TO_RIGHT_AND_STAY: - sprites[speaker] = new() { Sprite = spriteParam.Sprite, Positioning = new() { Position = SpritePositioning.SpritePosition.RIGHT, Layer = layer } }; - break; - } - } - else if (spriteEntranceParam.EntranceTransition != SpriteEntranceScriptParameter.SpriteEntranceTransition.NO_TRANSITION) + if (spriteEntranceParam.EntranceTransition != SpriteEntranceScriptParameter.SpriteEntranceTransition.NO_TRANSITION) { switch (spriteEntranceParam.EntranceTransition) { @@ -680,19 +687,24 @@ private void UpdatePreview() } else { - SpritePositioning.SpritePosition position = sprites[speaker].Positioning.Position; + if (sprites[speaker].Positioning is null) + { + _log.LogWarning($"Sprite {sprites[speaker]} has null positioning data!"); + } + SpritePositioning.SpritePosition position = sprites[speaker].Positioning?.Position ?? SpritePositioning.SpritePosition.CENTER; sprites[speaker] = new() { Sprite = spriteParam.Sprite, Positioning = new() { Position = position, Layer = layer } }; } } } - else + else if (command.Verb == EventFile.CommandVerb.INVEST_START) { sprites.Clear(); } + previousCommand = command; } - foreach (PositionedSprite sprite in sprites.Values.OrderBy(p => p.Positioning.Layer)) + foreach (PositionedSprite sprite in sprites.Values.OrderByDescending(p => p.Positioning.Layer)) { SKBitmap spriteBitmap = sprite.Sprite.GetClosedMouthAnimation(_project)[0].frame; canvas.DrawBitmap(spriteBitmap, sprite.Positioning.GetSpritePosition(spriteBitmap)); diff --git a/src/SerialLoops/MainForm.eto.cs b/src/SerialLoops/MainForm.eto.cs index 122937bc..1992bb76 100644 --- a/src/SerialLoops/MainForm.eto.cs +++ b/src/SerialLoops/MainForm.eto.cs @@ -1,18 +1,15 @@ using Eto.Forms; -using HaruhiChokuretsuLib.Archive; using HaruhiChokuretsuLib.Archive.Event; using SerialLoops.Controls; using SerialLoops.Editors; using SerialLoops.Lib; using SerialLoops.Lib.Items; -using SerialLoops.Lib.Util; using SerialLoops.Utility; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; -using System.Threading.Tasks; namespace SerialLoops { diff --git a/src/SerialLoops/Program.cs b/src/SerialLoops/Program.cs deleted file mode 100644 index 85fc8f25..00000000 --- a/src/SerialLoops/Program.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Eto.Drawing; -using Eto.Forms; -using System; - -namespace SerialLoops -{ - internal class Program - { - [STAThread] - static void Main(string[] args) - { - new Application(Eto.Platform.Detect).Run(new MainForm()); - } - } -} diff --git a/src/SerialLoops/ReferenceDialog.eto.cs b/src/SerialLoops/ReferenceDialog.eto.cs index 7914b023..d321b0fd 100644 --- a/src/SerialLoops/ReferenceDialog.eto.cs +++ b/src/SerialLoops/ReferenceDialog.eto.cs @@ -79,6 +79,9 @@ private List GetReferencesTo() references.Add(scenario); } return references; + case ItemDescription.ItemType.Voice: + VoicedLineItem voicedLine = (VoicedLineItem)Item; + return Project.Items.Where(i => voicedLine.ScriptUses.Select(s => s.ScriptName).Contains(i.Name)).ToList(); default: return new List(); } diff --git a/src/SerialLoops/SerialLoops.csproj b/src/SerialLoops/SerialLoops.csproj index 73e4a371..de08e059 100644 --- a/src/SerialLoops/SerialLoops.csproj +++ b/src/SerialLoops/SerialLoops.csproj @@ -1,4 +1,4 @@ - +