From e272e4ccbf237b800bb3e18bddcfab1dc8c1a472 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Sun, 4 Jan 2026 20:51:51 +0000 Subject: [PATCH 1/3] [Refactor]: Abstracted WaveformVisualiser components. --- .../Commands/SetAudioFilesCommand.cs | 3 +- ...DisplayWaveformVisualiserRequestedEvent.cs | 6 +- .../Presentation/AudioEditorView.xaml | 19 +- .../AudioFilesExplorerView.xaml.cs | 20 +- .../AudioFilesExplorerViewModel.cs | 79 +-- .../WaveformRendererService.cs | 118 +++++ .../WaveformVisualisationCacheService.cs | 90 ++++ .../WaveformVisualiserHelpers.cs | 68 +-- .../WaveformVisualiserView.xaml | 155 +++--- .../WavformVisualiserViewModel.cs | 476 +++++++----------- Editors/Audio/DependencyInjectionContainer.cs | 4 + Editors/Audio/Shared/Wwise/SoundEngine.cs | 203 ++++++++ 12 files changed, 765 insertions(+), 476 deletions(-) create mode 100644 Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformRendererService.cs create mode 100644 Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualisationCacheService.cs create mode 100644 Editors/Audio/Shared/Wwise/SoundEngine.cs diff --git a/Editors/Audio/AudioEditor/Commands/SetAudioFilesCommand.cs b/Editors/Audio/AudioEditor/Commands/SetAudioFilesCommand.cs index 232369ee5..b78454c6e 100644 --- a/Editors/Audio/AudioEditor/Commands/SetAudioFilesCommand.cs +++ b/Editors/Audio/AudioEditor/Commands/SetAudioFilesCommand.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Collections.ObjectModel; using Editors.Audio.AudioEditor.Core; using Editors.Audio.AudioEditor.Events; using Editors.Audio.AudioEditor.Presentation.Shared; @@ -17,7 +16,7 @@ public class SetAudioFilesCommand(IAudioEditorStateService audioEditorStateServi private readonly IEventHub _eventHub = eventHub; private readonly IAudioRepository _audioRepository = audioRepository; - public void Execute(ObservableCollection selectedAudioFiles, bool addToExistingAudioFiles) + public void Execute(List selectedAudioFiles, bool addToExistingAudioFiles) { var usedSourceIds = new HashSet(); var audioProject = _audioEditorStateService.AudioProject; diff --git a/Editors/Audio/AudioEditor/Events/DisplayWaveformVisualiserRequestedEvent.cs b/Editors/Audio/AudioEditor/Events/DisplayWaveformVisualiserRequestedEvent.cs index 02fdc577d..c3320116a 100644 --- a/Editors/Audio/AudioEditor/Events/DisplayWaveformVisualiserRequestedEvent.cs +++ b/Editors/Audio/AudioEditor/Events/DisplayWaveformVisualiserRequestedEvent.cs @@ -1,4 +1,6 @@ -namespace Editors.Audio.AudioEditor.Events +using System.Collections.Generic; + +namespace Editors.Audio.AudioEditor.Events { - public record DisplayWaveformVisualiserRequestedEvent(string FilePath); + public record DisplayWaveformVisualiserRequestedEvent(List WavFilePaths); } diff --git a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml index 56d413ebe..3b75050e0 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml +++ b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml @@ -108,11 +108,10 @@ Height="Auto"/> - + - + + + DataContext="{Binding WaveformVisualiserViewModel}"/> diff --git a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerView.xaml.cs b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerView.xaml.cs index 3c4be5eb5..93b14b3fc 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerView.xaml.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerView.xaml.cs @@ -1,7 +1,8 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using Editors.Audio.AudioEditor.Presentation.AudioFilesExplorer; +using System.Windows.Media; +using Editors.Audio.AudioEditor.Presentation.Shared; namespace Editors.Audio.AudioEditor.Presentation.AudioFilesExplorer { @@ -16,6 +17,21 @@ public AudioFilesExplorerView() private void OnClearButtonClick(object sender, RoutedEventArgs e) => FilterTextBoxItem.Focus(); - private void OnNodeDoubleClick(object sender, MouseButtonEventArgs e) => ViewModel.PlayWav(); + private void OnNodeDoubleClick(object sender, MouseButtonEventArgs e) + { + var source = e.OriginalSource as DependencyObject; + while (source != null && source is not TreeViewItem) + source = VisualTreeHelper.GetParent(source); + + var treeViewItem = source as TreeViewItem; + if (treeViewItem?.DataContext is not AudioFilesTreeNode node) + return; + + if (node.Type == AudioFilesTreeNodeType.WavFile) + { + ViewModel.PlayWav(); + e.Handled = true; + } + } } } diff --git a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs index 23098c0de..a0a9b1b22 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs @@ -150,61 +150,57 @@ private void RefreshAudioFilesTree(PackFileContainer packFileContainer) private void OnSelectedTreeNodesChanged(object sender, NotifyCollectionChangedEventArgs e) { - SetSelectedTreeNodes(e); + var selectedWavNodes = GetSelectedWavNodes(); + var wavFilePaths = new List(); - if (SelectedTreeNodes.Count == 1) - { - var selectedNode = e.NewItems[0] as AudioFilesTreeNode; - if (selectedNode.Type == AudioFilesTreeNodeType.WavFile) - { - var selectedAudioFile = SelectedTreeNodes[0]; - _eventHub.Publish(new DisplayWaveformVisualiserRequestedEvent(selectedAudioFile.FilePath)); - } - } + foreach (var node in selectedWavNodes) + wavFilePaths.Add(node.FilePath); + + if (wavFilePaths.Count > 0) + _eventHub.Publish(new DisplayWaveformVisualiserRequestedEvent(wavFilePaths)); SetButtonEnablement(); } - private void SetSelectedTreeNodes(NotifyCollectionChangedEventArgs e) + + private List GetSelectedWavNodes() { - if (e.Action == NotifyCollectionChangedAction.Add) - { - foreach (AudioFilesTreeNode addedNode in e.NewItems) - { - if (addedNode.Type != AudioFilesTreeNodeType.WavFile) - SelectedTreeNodes.Remove(addedNode); - } - } + if (SelectedTreeNodes == null || SelectedTreeNodes.Count == 0) + return []; + + var result = new List(); + foreach (var node in SelectedTreeNodes) + if (node.Type == AudioFilesTreeNodeType.WavFile) + result.Add(node); + return result; } + private void SetButtonEnablement() { - IsPlayAudioButtonEnabled = SelectedTreeNodes.Count == 1; + var selectedWavNodes = GetSelectedWavNodes(); + IsPlayAudioButtonEnabled = selectedWavNodes.Count == 1; var selectedAudioProjectExplorerNode = _audioEditorStateService.SelectedAudioProjectExplorerNode; if (selectedAudioProjectExplorerNode == null) return; - if (SelectedTreeNodes.Count > 0) + if (selectedWavNodes.Count > 0) { if (selectedAudioProjectExplorerNode.Type == AudioProjectTreeNodeType.ActionEventType || selectedAudioProjectExplorerNode.Type == AudioProjectTreeNodeType.DialogueEvent) { IsSetAudioFilesButtonEnabled = true; - - if (_audioEditorStateService.AudioFiles.Count > 0) - IsAddAudioFilesButtonEnabled = true; - else - IsAddAudioFilesButtonEnabled = false; + IsAddAudioFilesButtonEnabled = _audioEditorStateService.AudioFiles.Count > 0; + return; } } - else - { - IsSetAudioFilesButtonEnabled = false; - IsAddAudioFilesButtonEnabled = false; - } + + IsSetAudioFilesButtonEnabled = false; + IsAddAudioFilesButtonEnabled = false; } + partial void OnFilterQueryChanged(string value) => DebounceFilterAudioFilesTreeForFilterQuery(); private void DebounceFilterAudioFilesTreeForFilterQuery() @@ -250,16 +246,29 @@ private static void ToggleNodeExpansion(AudioFilesTreeNode node, bool shouldExpa ToggleNodeExpansion(child, shouldExpand); } - [RelayCommand] public void SetAudioFiles() => _uiCommandFactory.Create().Execute(SelectedTreeNodes, false); + [RelayCommand] public void SetAudioFiles() + { + var selectedWavs = GetSelectedWavNodes(); + if (selectedWavs.Count == 0) + return; + _uiCommandFactory.Create().Execute(selectedWavs, false); + } - [RelayCommand] public void AddToAudioFiles() => _uiCommandFactory.Create().Execute(SelectedTreeNodes, true); + [RelayCommand] public void AddToAudioFiles() + { + var selectedWavs = GetSelectedWavNodes(); + if (selectedWavs.Count == 0) + return; + _uiCommandFactory.Create().Execute(selectedWavs, true); + } [RelayCommand] public void PlayWav() { - if (!IsPlayAudioButtonEnabled) + var selectedWavs = GetSelectedWavNodes(); + if (selectedWavs.Count != 1) return; - var selectedAudioFile = SelectedTreeNodes[0]; + var selectedAudioFile = selectedWavs[0]; _uiCommandFactory.Create().Execute(selectedAudioFile.FileName, selectedAudioFile.FilePath); } diff --git a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformRendererService.cs b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformRendererService.cs new file mode 100644 index 000000000..e70daba0b --- /dev/null +++ b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformRendererService.cs @@ -0,0 +1,118 @@ +using System; +using System.Drawing.Imaging; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using NAudio.Wave; +using NAudio.WaveFormRenderer; +using Shared.Core.PackFiles; +using Color = System.Drawing.Color; +using DrawingImage = System.Drawing.Image; + +namespace Editors.Audio.AudioEditor.Presentation.WaveformVisualiser +{ + public interface IWaveformRendererService + { + Task RenderAsync(string filePathKey, int targetWidth, CancellationToken cancellationToken); + } + + public sealed class WaveformRendererService(IPackFileService packFileService) : IWaveformRendererService + { + private readonly IPackFileService _packFileService = packFileService; + + public static int DefaultPixelsPerPeak { get; set; } = 2; + public static int DefaultSpacerPixels { get; set; } = 1; + + public async Task RenderAsync(string filePathKey, int targetWidth, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(filePathKey)) + throw new ArgumentNullException(nameof(filePathKey)); + + var packFile = _packFileService.FindFile(filePathKey); + var data = packFile.DataSource.ReadData(); + + var baseSettings = CreateBaseWaveformSettings(targetWidth); + var overlaySettings = CreateOverlayWaveformSettings(targetWidth); + + return await Task.Run(() => RenderWaveformFromBytes(data, packFile.Extension, baseSettings, overlaySettings), cancellationToken).ConfigureAwait(false); + } + + private static WaveformVisualisation RenderWaveformFromBytes(byte[] data, string extension, WaveFormRendererSettings baseSettings, WaveFormRendererSettings overlaySettings) + { + using var memoryStream = new MemoryStream(data, writable: false); + using var waveStream = new WaveFileReader(memoryStream); + using var alignedWaveStream = new BlockAlignReductionStream(waveStream); + + var waveFormRenderer = new WaveFormRenderer(); + + using var baseImageDrawing = waveFormRenderer.Render(alignedWaveStream, baseSettings); + alignedWaveStream.Position = 0; + using var overlayImageDrawing = waveFormRenderer.Render(alignedWaveStream, overlaySettings); + + var baseBitmap = ToBitmapImage(baseImageDrawing); + var overlayBitmap = ToBitmapImage(overlayImageDrawing); + + return WaveformVisualisation.Create(baseBitmap, overlayBitmap); + } + + private static SoundCloudBlockWaveFormSettings CreateBaseWaveformSettings(int width) + { + return new SoundCloudBlockWaveFormSettings( + Color.FromArgb(196, 230, 230, 230), // top peak + Color.FromArgb(64, 220, 220, 220), // top spacer + Color.FromArgb(196, 210, 210, 210), // bottom peak + Color.FromArgb(64, 190, 190, 190)) // bottom spacer + { + Width = width, + PixelsPerPeak = DefaultPixelsPerPeak, + SpacerPixels = DefaultSpacerPixels, + TopSpacerGradientStartColor = Color.FromArgb(64, 220, 220, 220), + BackgroundColor = Color.Transparent + }; + } + + private static SoundCloudBlockWaveFormSettings CreateOverlayWaveformSettings(int width) + { + return new SoundCloudBlockWaveFormSettings( + Color.FromArgb(255, 255, 68, 0), // top peak + Color.FromArgb(64, 255, 68, 0), // top spacer + Color.FromArgb(255, 255, 191, 153), // bottom peak + Color.FromArgb(128, 255, 191, 153)) // bottom spacer + { + Width = width, + PixelsPerPeak = DefaultPixelsPerPeak, + SpacerPixels = DefaultSpacerPixels, + TopSpacerGradientStartColor = Color.FromArgb(64, 255, 68, 0), + BackgroundColor = Color.Transparent + }; + } + + private static BitmapImage ToBitmapImage(DrawingImage drawingImage) + { + using var memoryStream = new MemoryStream(); + + try + { + drawingImage.Save(memoryStream, ImageFormat.Png); + } + catch (ArgumentNullException) + { + // Sometimes the encoder isn't initialised at the start so we delay then retry. + Thread.Sleep(50); + drawingImage.Save(memoryStream, ImageFormat.Png); + } + + memoryStream.Position = 0; + + var image = new BitmapImage(); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + image.StreamSource = memoryStream; + image.EndInit(); + image.Freeze(); + return image; + } + + } +} diff --git a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualisationCacheService.cs b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualisationCacheService.cs new file mode 100644 index 000000000..52b9fb6b3 --- /dev/null +++ b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualisationCacheService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Editors.Audio.AudioEditor.Presentation.WaveformVisualiser +{ + public interface IWaveformVisualisationCacheService + { + bool GetWaveformVisualisation(string filePath, int targetWidth, out WaveformVisualisation visualisation); + void Store(string filePath, WaveformVisualisation visualisation); + void Remove(string filePath); + Task PreloadWaveformVisualisationsAsync(IEnumerable filePaths, int targetWidth, IWaveformRendererService renderService, CancellationToken cancellationToken); + } + + public sealed class WaveformVisualisationCacheService : IWaveformVisualisationCacheService + { + private readonly ConcurrentDictionary _visualisationByFilePath = new(); + private readonly ConcurrentDictionary _preloadInProgressByFilePath = new(); + private readonly ConcurrentDictionary _removedDuringPreloadByFilePath = new(); + + public bool GetWaveformVisualisation(string filePath, int targetWidth, out WaveformVisualisation waveformVisualisation) + { + if (_visualisationByFilePath.TryGetValue(filePath, out var cached) && cached.PixelWidth == targetWidth) + { + waveformVisualisation = cached; + return true; + } + + waveformVisualisation = null; + return false; + } + + public void Store(string filePath, WaveformVisualisation visualisation) + { + _visualisationByFilePath[filePath] = visualisation; + } + + public void Remove(string filePath) + { + _removedDuringPreloadByFilePath[filePath] = 0; + _visualisationByFilePath.TryRemove(filePath, out _); + _preloadInProgressByFilePath.TryRemove(filePath, out _); + } + + public async Task PreloadWaveformVisualisationsAsync(IEnumerable filePaths, int targetWidth, IWaveformRendererService renderService, CancellationToken cancellationToken) + { + var uniqueFilePaths = (filePaths ?? []) + .Where(filePath => !string.IsNullOrWhiteSpace(filePath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(filePath => !_visualisationByFilePath.TryGetValue(filePath, out var existingfilePath) || existingfilePath.PixelWidth != targetWidth) + .Where(filePath => _preloadInProgressByFilePath.TryAdd(filePath, 0)) + .ToArray(); + + if (uniqueFilePaths.Length == 0) + return; + + var options = new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1), + CancellationToken = cancellationToken + }; + + try + { + await Parallel.ForEachAsync(uniqueFilePaths, options, async (filePath, cancellationToken) => + { + try + { + _removedDuringPreloadByFilePath.TryRemove(filePath, out _); + + var waveformVisualisation = await renderService.RenderAsync(filePath, targetWidth, cancellationToken).ConfigureAwait(false); + + if (_removedDuringPreloadByFilePath.ContainsKey(filePath)) + return; + + _visualisationByFilePath[filePath] = waveformVisualisation; + } + finally + { + _preloadInProgressByFilePath.TryRemove(filePath, out _); + } + }).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + } + } +} diff --git a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs index f8ba47604..f4fc28050 100644 --- a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs +++ b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs @@ -1,73 +1,7 @@ -using System; -using System.Drawing; -using System.Drawing.Imaging; -using System.IO; -using System.Windows.Media.Imaging; -using NAudio.Wave; -using NAudio.WaveFormRenderer; -using DrawingImage = System.Drawing.Image; - -namespace Editors.Audio.AudioEditor.Presentation.WaveformVisualiser +namespace Editors.Audio.AudioEditor.Presentation.WaveformVisualiser { public class WaveformVisualiserHelpers { - public static int DefaultPixelsPerPeak { get; set; } = 2; - public static int DefaultSpacerPixels { get; set; } = 1; - - public static SoundCloudBlockWaveFormSettings CreateBaseWaveformSettings(int width) - { - return new SoundCloudBlockWaveFormSettings( - Color.FromArgb(196, 230, 230, 230), // top peak - Color.FromArgb(64, 220, 220, 220), // top spacer - Color.FromArgb(196, 210, 210, 210), // bottom peak - Color.FromArgb(64, 190, 190, 190)) // bottom spacer - { - Width = width, - PixelsPerPeak = DefaultPixelsPerPeak, - SpacerPixels = DefaultSpacerPixels, - TopSpacerGradientStartColor = Color.FromArgb(64, 220, 220, 220), - BackgroundColor = Color.Transparent - }; - } - - public static SoundCloudBlockWaveFormSettings CreateOverlayWaveformSettings(int width) - { - return new SoundCloudBlockWaveFormSettings( - Color.FromArgb(255, 255, 68, 0), // top peak - Color.FromArgb(64, 255, 68, 0), // top spacer - Color.FromArgb(255, 255, 191, 153), // bottom peak - Color.FromArgb(128, 255, 191, 153)) // bottom spacer - { - Width = width, - PixelsPerPeak = DefaultPixelsPerPeak, - SpacerPixels = DefaultSpacerPixels, - TopSpacerGradientStartColor = Color.FromArgb(64, 255, 68, 0), - BackgroundColor = Color.Transparent - }; - } - - public static BitmapImage ToBitmapImage(DrawingImage drawingImage) - { - using var memoryStream = new MemoryStream(); - drawingImage.Save(memoryStream, ImageFormat.Png); - memoryStream.Position = 0; - - var image = new BitmapImage(); - image.BeginInit(); - image.CacheOption = BitmapCacheOption.OnLoad; - image.StreamSource = memoryStream; - image.EndInit(); - image.Freeze(); - return image; - } - public static WaveStream CreateWaveStream(Stream stream, string fileExtension) - { - return fileExtension switch - { - ".wav" => new WaveFileReader(stream), - _ => throw new NotSupportedException("File type not supported."), - }; - } } } diff --git a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserView.xaml b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserView.xaml index 7ff84309b..f19dec3fb 100644 --- a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserView.xaml +++ b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserView.xaml @@ -7,77 +7,98 @@ d:DataContext="{d:DesignInstance Type=local:WaveformVisualiserViewModel}" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> - - - - - - - + + + + + + + - - -