From 0796634d7209dea8057b21689725755ad28300dd Mon Sep 17 00:00:00 2001 From: Mercury Date: Fri, 22 May 2026 17:23:00 -0400 Subject: [PATCH 1/2] Normalize sound pack loudness --- src/SoundType.App/MainWindow.xaml.cs | 12 ++- src/SoundType.Audio/SoundPackLoader.cs | 101 +++++++++++++++++- .../Models/SoundPackValidationResult.cs | 1 + src/SoundType.Tests/PackValidatorTests.cs | 31 ++++++ src/SoundType.Tests/SoundPackTests.cs | 53 +++++++++ .../PackValidatorCommand.cs | 9 +- 6 files changed, 201 insertions(+), 6 deletions(-) diff --git a/src/SoundType.App/MainWindow.xaml.cs b/src/SoundType.App/MainWindow.xaml.cs index ce7045e..540c8e2 100644 --- a/src/SoundType.App/MainWindow.xaml.cs +++ b/src/SoundType.App/MainWindow.xaml.cs @@ -1553,7 +1553,7 @@ private async void ImportSoundPack_Click(object sender, RoutedEventArgs e) SoundPackMetadata metadata = TryImportPack(dialog.FileName, overwrite: false); await ReloadPacksAndSelectAsync(metadata.Id); PackValidationText.Foreground = (MediaBrush)FindResource("MutedTextBrush"); - PackValidationText.Text = $"Imported {metadata.Name}."; + PackValidationText.Text = BuildImportResultMessage("Imported", metadata); } catch (IOException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) { @@ -1574,7 +1574,7 @@ private async void ImportSoundPack_Click(object sender, RoutedEventArgs e) SoundPackMetadata metadata = TryImportPack(dialog.FileName, overwrite: true); await ReloadPacksAndSelectAsync(metadata.Id); PackValidationText.Foreground = (MediaBrush)FindResource("MutedTextBrush"); - PackValidationText.Text = $"Replaced {metadata.Name}."; + PackValidationText.Text = BuildImportResultMessage("Replaced", metadata); } catch (Exception retryException) { @@ -1622,6 +1622,14 @@ private void ExportActivePack_Click(object sender, RoutedEventArgs e) private SoundPackMetadata TryImportPack(string archivePath, bool overwrite) => _archiveService.ImportPack(archivePath, _packsRoot, overwrite); + private string BuildImportResultMessage(string action, SoundPackMetadata metadata) + { + SoundPackValidationResult validation = _packLoader.Validate(metadata, analyzeAudioQuality: true); + return validation.Warnings.Count == 0 + ? $"{action} {metadata.Name}." + : $"{action} {metadata.Name}. Warning: {validation.Warnings[0]}"; + } + private async Task ReloadPacksAndSelectAsync(string packId) { _settings.ActiveSoundPackId = packId; diff --git a/src/SoundType.Audio/SoundPackLoader.cs b/src/SoundType.Audio/SoundPackLoader.cs index 6327570..e9f33c2 100644 --- a/src/SoundType.Audio/SoundPackLoader.cs +++ b/src/SoundType.Audio/SoundPackLoader.cs @@ -7,6 +7,11 @@ namespace SoundType.Audio; public sealed class SoundPackLoader { + private const double TargetPackMedianPeak = 0.68; + private const double MinimumNormalizationGain = 0.35; + private const double MaximumNormalizationGain = 4.0; + private const double LoudSamplePeakWarning = 0.98; + private const double QuietSamplePeakWarning = 0.08; private static readonly WaveFormat PlaybackWaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2); private static readonly IReadOnlyDictionary SupportedSampleFormats = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -62,7 +67,7 @@ public IReadOnlyList DiscoverPacks(string packsRoot) } } - public SoundPackValidationResult Validate(SoundPackMetadata? metadata) + public SoundPackValidationResult Validate(SoundPackMetadata? metadata, bool analyzeAudioQuality = false) { SoundPackValidationResult result = new(); if (metadata is null) @@ -100,6 +105,12 @@ public SoundPackValidationResult Validate(SoundPackMetadata? metadata) if (!File.Exists(absolutePath)) { result.Errors.Add($"{group}: {relativePath} was not found."); + continue; + } + + if (analyzeAudioQuality) + { + AddAudioQualityWarnings(result, group, relativePath, absolutePath); } } } @@ -123,9 +134,95 @@ public LoadedSoundPack Load(SoundPackMetadata metadata) .ToList(); } + NormalizePackSamples(samples.Values.SelectMany(groupSamples => groupSamples)); return new LoadedSoundPack(metadata, samples); } + private static void AddAudioQualityWarnings( + SoundPackValidationResult result, + string group, + string relativePath, + string absolutePath) + { + try + { + SoundSampleFormat format = GetSampleFormat(relativePath); + byte[] data = File.ReadAllBytes(absolutePath); + float[] decoded = DecodeToPlaybackFormat(format, data); + if (decoded.Length == 0) + { + result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); + return; + } + + double peak = FindPeak(decoded); + if (peak >= LoudSamplePeakWarning) + { + result.Warnings.Add($"{group}: {relativePath} is very loud and may clip before SoundType normalizes it."); + } + else if (peak <= QuietSamplePeakWarning) + { + result.Warnings.Add($"{group}: {relativePath} is very quiet and may sound inconsistent with other packs."); + } + } + catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException or InvalidOperationException) + { + result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); + } + } + + private static void NormalizePackSamples(IEnumerable samples) + { + List decodedSamples = samples + .Where(sample => sample.DecodedSamples.Length > 0) + .ToList(); + if (decodedSamples.Count == 0) + { + return; + } + + List peaks = decodedSamples + .Select(sample => FindPeak(sample.DecodedSamples)) + .Where(peak => peak > 0.0001) + .Order() + .ToList(); + if (peaks.Count == 0) + { + return; + } + + double medianPeak = peaks[peaks.Count / 2]; + double gain = Math.Clamp(TargetPackMedianPeak / medianPeak, MinimumNormalizationGain, MaximumNormalizationGain); + if (Math.Abs(gain - 1.0) < 0.001) + { + return; + } + + foreach (LoadedSoundSample sample in decodedSamples) + { + ApplyGain(sample.DecodedSamples, gain); + } + } + + private static double FindPeak(float[] samples) + { + double peak = 0; + foreach (float sample in samples) + { + peak = Math.Max(peak, Math.Abs(sample)); + } + + return peak; + } + + private static void ApplyGain(float[] samples, double gain) + { + for (int i = 0; i < samples.Length; i++) + { + samples[i] = (float)Math.Clamp(samples[i] * gain, -1.0, 1.0); + } + } + private static LoadedSoundSample LoadSample(string folderPath, string relativePath) { string absolutePath = Path.Combine(folderPath, relativePath); @@ -169,7 +266,7 @@ private static float[] DecodeToPlaybackFormat(SoundSampleFormat format, byte[] d CenterStereoSamples(trimmed); return trimmed; } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or IOException) + catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException) { return []; } diff --git a/src/SoundType.Core/Models/SoundPackValidationResult.cs b/src/SoundType.Core/Models/SoundPackValidationResult.cs index a6689ee..5cf940f 100644 --- a/src/SoundType.Core/Models/SoundPackValidationResult.cs +++ b/src/SoundType.Core/Models/SoundPackValidationResult.cs @@ -4,4 +4,5 @@ public sealed class SoundPackValidationResult { public bool IsValid => Errors.Count == 0; public List Errors { get; } = []; + public List Warnings { get; } = []; } diff --git a/src/SoundType.Tests/PackValidatorTests.cs b/src/SoundType.Tests/PackValidatorTests.cs index 6a6ee41..98950bf 100644 --- a/src/SoundType.Tests/PackValidatorTests.cs +++ b/src/SoundType.Tests/PackValidatorTests.cs @@ -61,6 +61,37 @@ public void Run_ValidatesArchiveInTemporaryFolderAndCleansItUp() Assert.Equal(string.Empty, error.ToString()); } + [Fact] + public void Run_PrintsAudioQualityWarningsWithoutFailingPack() + { + string pack = CreateTempDirectory(); + Directory.CreateDirectory(Path.Combine(pack, "normal")); + using (NAudio.Wave.WaveFileWriter writer = new( + Path.Combine(pack, "normal", "key.wav"), + NAudio.Wave.WaveFormat.CreateIeeeFloatWaveFormat(44100, 2))) + { + writer.WriteSamples([1.0f, 1.0f], 0, 2); + } + + File.WriteAllText(Path.Combine(pack, "pack.json"), """ + { + "id": "loud-pack", + "name": "Loud Pack", + "groups": { + "normal": [ "normal/key.wav" ] + } + } + """); + using StringWriter output = new(); + using StringWriter error = new(); + + int exitCode = PackValidatorCommand.Run([pack], output, error); + + Assert.Equal(0, exitCode); + Assert.Contains("Warning", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + private static string CreateValidPack(string id, string name) { string root = CreateTempDirectory(); diff --git a/src/SoundType.Tests/SoundPackTests.cs b/src/SoundType.Tests/SoundPackTests.cs index 46dfe49..c613653 100644 --- a/src/SoundType.Tests/SoundPackTests.cs +++ b/src/SoundType.Tests/SoundPackTests.cs @@ -224,6 +224,59 @@ public void Load_CentersStereoSamplesBeforePlayback() } } + [Fact] + public void Load_NormalizesPackMedianPeakBeforePlayback() + { + string root = CreatePackRoot(""" + { + "id": "quiet-pack", + "name": "Quiet Pack", + "groups": { + "normal": [ "normal/key.wav" ] + } + } + """); + string normalDir = Path.Combine(root, "normal"); + Directory.CreateDirectory(normalDir); + using (WaveFileWriter writer = new(Path.Combine(normalDir, "key.wav"), WaveFormat.CreateIeeeFloatWaveFormat(44100, 2))) + { + writer.WriteSamples([0.1f, 0.1f, 0.08f, 0.08f], 0, 4); + } + + SoundPackLoader loader = new(); + LoadedSoundPack pack = loader.Load(loader.TryLoadMetadata(root)!); + + float peak = pack.Samples["normal"][0].DecodedSamples.Select(Math.Abs).Max(); + Assert.True(peak > 0.25f); + Assert.True(peak <= 1.0f); + } + + [Fact] + public void Validate_WithAudioAnalysis_WarnsForVeryLoudSamples() + { + string root = CreatePackRoot(""" + { + "id": "loud-pack", + "name": "Loud Pack", + "groups": { + "normal": [ "normal/key.wav" ] + } + } + """); + string normalDir = Path.Combine(root, "normal"); + Directory.CreateDirectory(normalDir); + using (WaveFileWriter writer = new(Path.Combine(normalDir, "key.wav"), WaveFormat.CreateIeeeFloatWaveFormat(44100, 2))) + { + writer.WriteSamples([1.0f, 1.0f, 0.95f, 0.95f], 0, 4); + } + + SoundPackLoader loader = new(); + SoundPackValidationResult validation = loader.Validate(loader.TryLoadMetadata(root), analyzeAudioQuality: true); + + Assert.True(validation.IsValid); + Assert.Contains(validation.Warnings, warning => warning.Contains("loud", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void BuiltInSwitchPacks_HavePreviewPngArtwork() { diff --git a/tools/SoundType.PackValidator/PackValidatorCommand.cs b/tools/SoundType.PackValidator/PackValidatorCommand.cs index cc86cc7..a468a90 100644 --- a/tools/SoundType.PackValidator/PackValidatorCommand.cs +++ b/tools/SoundType.PackValidator/PackValidatorCommand.cs @@ -31,6 +31,11 @@ public static int Run(string[] args, TextWriter output, TextWriter error, string } output.WriteLine($"Valid sound pack: {metadata.Name} ({metadata.Id})"); + foreach (string warning in validation.Warnings) + { + output.WriteLine($"Warning: {warning}"); + } + return 0; } catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or UnauthorizedAccessException) @@ -44,7 +49,7 @@ public static int Run(string[] args, TextWriter output, TextWriter error, string private static ValidationOutcome ValidateFolder(string folderPath, SoundPackLoader loader) { SoundPackMetadata? metadata = loader.TryLoadMetadata(folderPath); - return new ValidationOutcome(metadata, loader.Validate(metadata)); + return new ValidationOutcome(metadata, loader.Validate(metadata, analyzeAudioQuality: true)); } private static ValidationOutcome ValidateArchive(string archivePath, SoundPackLoader loader, string? tempRoot) @@ -71,7 +76,7 @@ private static ValidationOutcome ValidateArchive(string archivePath, SoundPackLo { SoundPackArchiveService archiveService = new(loader); SoundPackMetadata metadata = archiveService.ImportPack(archivePath, importRoot, overwrite: false); - return new ValidationOutcome(metadata, loader.Validate(metadata)); + return new ValidationOutcome(metadata, loader.Validate(metadata, analyzeAudioQuality: true)); } finally { From 6cfe67070aa6e2871fcc1f0661c0c201743eaea7 Mon Sep 17 00:00:00 2001 From: Mercury Date: Fri, 22 May 2026 17:27:58 -0400 Subject: [PATCH 2/2] Clean up audio processing responsibilities --- src/SoundType.Audio/AudioEngine.cs | 8 +- src/SoundType.Audio/IAudioOutputDevice.cs | 14 +- .../SoundPackAudioProcessor.cs | 161 +++++++++++++++++ src/SoundType.Audio/SoundPackLoader.cs | 163 +----------------- src/SoundType.Tests/AudioProcessingTests.cs | 8 +- 5 files changed, 175 insertions(+), 179 deletions(-) create mode 100644 src/SoundType.Audio/SoundPackAudioProcessor.cs diff --git a/src/SoundType.Audio/AudioEngine.cs b/src/SoundType.Audio/AudioEngine.cs index 2dffb3b..0b3a124 100644 --- a/src/SoundType.Audio/AudioEngine.cs +++ b/src/SoundType.Audio/AudioEngine.cs @@ -9,8 +9,6 @@ public sealed class AudioEngine : IAsyncDisposable { private const int DefaultMaxCachedPacks = 4; private const int DefaultMaxActiveVoices = 32; - public const int OutputDesiredLatencyMs = 45; - public const int OutputBufferCount = 3; private readonly Random _random = new(); private readonly object _packLock = new(); private readonly object _mixerLock = new(); @@ -467,8 +465,10 @@ private bool EnsureOutputRunning() try { - _output.Dispose(); - _output = CreateAndStartOutput(); + IAudioOutputDevice stoppedOutput = _output; + IAudioOutputDevice replacementOutput = CreateAndStartOutput(); + _output = replacementOutput; + stoppedOutput.Dispose(); return _output.PlaybackState == PlaybackState.Playing; } catch diff --git a/src/SoundType.Audio/IAudioOutputDevice.cs b/src/SoundType.Audio/IAudioOutputDevice.cs index fd5790d..69cde10 100644 --- a/src/SoundType.Audio/IAudioOutputDevice.cs +++ b/src/SoundType.Audio/IAudioOutputDevice.cs @@ -4,8 +4,6 @@ namespace SoundType.Audio; public interface IAudioOutputDevice : IDisposable { - event EventHandler? PlaybackStopped; - PlaybackState PlaybackState { get; } void Init(ISampleProvider provider); @@ -27,18 +25,14 @@ public sealed class WaveOutAudioOutputDeviceFactory : IAudioOutputDeviceFactory public sealed class WaveOutAudioOutputDevice : IAudioOutputDevice { + private const int OutputDesiredLatencyMs = 45; + private const int OutputBufferCount = 3; private readonly WaveOutEvent output = new() { - DesiredLatency = AudioEngine.OutputDesiredLatencyMs, - NumberOfBuffers = AudioEngine.OutputBufferCount + DesiredLatency = OutputDesiredLatencyMs, + NumberOfBuffers = OutputBufferCount }; - public event EventHandler? PlaybackStopped - { - add => output.PlaybackStopped += value; - remove => output.PlaybackStopped -= value; - } - public PlaybackState PlaybackState => output.PlaybackState; public void Init(ISampleProvider provider) => output.Init(provider); diff --git a/src/SoundType.Audio/SoundPackAudioProcessor.cs b/src/SoundType.Audio/SoundPackAudioProcessor.cs new file mode 100644 index 0000000..81e0e04 --- /dev/null +++ b/src/SoundType.Audio/SoundPackAudioProcessor.cs @@ -0,0 +1,161 @@ +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using SoundType.Core.Models; + +namespace SoundType.Audio; + +internal static class SoundPackAudioProcessor +{ + private const double TargetPackMedianPeak = 0.68; + private const double MinimumNormalizationGain = 0.35; + private const double MaximumNormalizationGain = 4.0; + private const double LoudSamplePeakWarning = 0.98; + private const double QuietSamplePeakWarning = 0.08; + + public static readonly WaveFormat PlaybackWaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2); + + public static void AddQualityWarning( + SoundPackValidationResult result, + string group, + string relativePath, + string absolutePath, + SoundSampleFormat format) + { + try + { + byte[] data = File.ReadAllBytes(absolutePath); + float[] decoded = DecodeToPlaybackFormat(format, data); + if (decoded.Length == 0) + { + result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); + return; + } + + double peak = FindPeak(decoded); + if (peak >= LoudSamplePeakWarning) + { + result.Warnings.Add($"{group}: {relativePath} is very loud and may clip before SoundType normalizes it."); + } + else if (peak <= QuietSamplePeakWarning) + { + result.Warnings.Add($"{group}: {relativePath} is very quiet and may sound inconsistent with other packs."); + } + } + catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException or InvalidOperationException) + { + result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); + } + } + + public static float[] DecodeToPlaybackFormat(SoundSampleFormat format, byte[] data) + { + try + { + using MemoryStream stream = new(data, writable: false); + using WaveStream reader = format switch + { + SoundSampleFormat.Wav => new WaveFileReader(stream), + SoundSampleFormat.Mp3 => new Mp3FileReader(stream), + _ => throw new InvalidOperationException("Unsupported audio format.") + }; + + ISampleProvider provider = reader.ToSampleProvider(); + provider = EnsureStereo(provider); + if (provider.WaveFormat.SampleRate != PlaybackWaveFormat.SampleRate) + { + provider = new WdlResamplingSampleProvider(provider, PlaybackWaveFormat.SampleRate); + } + + List samples = []; + float[] buffer = new float[PlaybackWaveFormat.SampleRate / 10 * PlaybackWaveFormat.Channels]; + int read; + while ((read = provider.Read(buffer, 0, buffer.Length)) > 0) + { + for (int i = 0; i < read; i++) + { + samples.Add(buffer[i]); + } + } + + float[] trimmed = AudioSampleTrimmer.TrimSilence(samples.ToArray(), PlaybackWaveFormat.Channels); + CenterStereoSamples(trimmed); + return trimmed; + } + catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException) + { + return []; + } + } + + public static void NormalizePackSamples(IEnumerable samples) + { + List decodedSamples = samples + .Where(sample => sample.DecodedSamples.Length > 0) + .ToList(); + if (decodedSamples.Count == 0) + { + return; + } + + List peaks = decodedSamples + .Select(sample => FindPeak(sample.DecodedSamples)) + .Where(peak => peak > 0.0001) + .Order() + .ToList(); + if (peaks.Count == 0) + { + return; + } + + double medianPeak = peaks[peaks.Count / 2]; + double gain = Math.Clamp(TargetPackMedianPeak / medianPeak, MinimumNormalizationGain, MaximumNormalizationGain); + if (Math.Abs(gain - 1.0) < 0.001) + { + return; + } + + foreach (LoadedSoundSample sample in decodedSamples) + { + ApplyGain(sample.DecodedSamples, gain); + } + } + + private static ISampleProvider EnsureStereo(ISampleProvider provider) + { + return provider.WaveFormat.Channels switch + { + 1 => new MonoToStereoSampleProvider(provider), + 2 => provider, + _ => throw new InvalidOperationException("SoundType supports mono or stereo samples.") + }; + } + + private static void CenterStereoSamples(float[] samples) + { + for (int i = 0; i + 1 < samples.Length; i += PlaybackWaveFormat.Channels) + { + float centered = (samples[i] + samples[i + 1]) * 0.5f; + samples[i] = centered; + samples[i + 1] = centered; + } + } + + private static double FindPeak(float[] samples) + { + double peak = 0; + foreach (float sample in samples) + { + peak = Math.Max(peak, Math.Abs(sample)); + } + + return peak; + } + + private static void ApplyGain(float[] samples, double gain) + { + for (int i = 0; i < samples.Length; i++) + { + samples[i] = (float)Math.Clamp(samples[i] * gain, -1.0, 1.0); + } + } +} diff --git a/src/SoundType.Audio/SoundPackLoader.cs b/src/SoundType.Audio/SoundPackLoader.cs index e9f33c2..a4ccb9b 100644 --- a/src/SoundType.Audio/SoundPackLoader.cs +++ b/src/SoundType.Audio/SoundPackLoader.cs @@ -1,18 +1,10 @@ using System.Text.Json; -using NAudio.Wave; -using NAudio.Wave.SampleProviders; using SoundType.Core.Models; namespace SoundType.Audio; public sealed class SoundPackLoader { - private const double TargetPackMedianPeak = 0.68; - private const double MinimumNormalizationGain = 0.35; - private const double MaximumNormalizationGain = 4.0; - private const double LoudSamplePeakWarning = 0.98; - private const double QuietSamplePeakWarning = 0.08; - private static readonly WaveFormat PlaybackWaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2); private static readonly IReadOnlyDictionary SupportedSampleFormats = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -95,7 +87,7 @@ public SoundPackValidationResult Validate(SoundPackMetadata? metadata, bool anal { foreach (string relativePath in files) { - if (!TryGetSampleFormat(relativePath, out _)) + if (!TryGetSampleFormat(relativePath, out SoundSampleFormat format)) { result.Errors.Add($"{group}: {relativePath} has unsupported audio format. Supported formats: .wav, .mp3."); continue; @@ -110,7 +102,7 @@ public SoundPackValidationResult Validate(SoundPackMetadata? metadata, bool anal if (analyzeAudioQuality) { - AddAudioQualityWarnings(result, group, relativePath, absolutePath); + SoundPackAudioProcessor.AddQualityWarning(result, group, relativePath, absolutePath, format); } } } @@ -134,162 +126,17 @@ public LoadedSoundPack Load(SoundPackMetadata metadata) .ToList(); } - NormalizePackSamples(samples.Values.SelectMany(groupSamples => groupSamples)); + SoundPackAudioProcessor.NormalizePackSamples(samples.Values.SelectMany(groupSamples => groupSamples)); return new LoadedSoundPack(metadata, samples); } - private static void AddAudioQualityWarnings( - SoundPackValidationResult result, - string group, - string relativePath, - string absolutePath) - { - try - { - SoundSampleFormat format = GetSampleFormat(relativePath); - byte[] data = File.ReadAllBytes(absolutePath); - float[] decoded = DecodeToPlaybackFormat(format, data); - if (decoded.Length == 0) - { - result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); - return; - } - - double peak = FindPeak(decoded); - if (peak >= LoudSamplePeakWarning) - { - result.Warnings.Add($"{group}: {relativePath} is very loud and may clip before SoundType normalizes it."); - } - else if (peak <= QuietSamplePeakWarning) - { - result.Warnings.Add($"{group}: {relativePath} is very quiet and may sound inconsistent with other packs."); - } - } - catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException or InvalidOperationException) - { - result.Warnings.Add($"{group}: {relativePath} could not be decoded for loudness analysis."); - } - } - - private static void NormalizePackSamples(IEnumerable samples) - { - List decodedSamples = samples - .Where(sample => sample.DecodedSamples.Length > 0) - .ToList(); - if (decodedSamples.Count == 0) - { - return; - } - - List peaks = decodedSamples - .Select(sample => FindPeak(sample.DecodedSamples)) - .Where(peak => peak > 0.0001) - .Order() - .ToList(); - if (peaks.Count == 0) - { - return; - } - - double medianPeak = peaks[peaks.Count / 2]; - double gain = Math.Clamp(TargetPackMedianPeak / medianPeak, MinimumNormalizationGain, MaximumNormalizationGain); - if (Math.Abs(gain - 1.0) < 0.001) - { - return; - } - - foreach (LoadedSoundSample sample in decodedSamples) - { - ApplyGain(sample.DecodedSamples, gain); - } - } - - private static double FindPeak(float[] samples) - { - double peak = 0; - foreach (float sample in samples) - { - peak = Math.Max(peak, Math.Abs(sample)); - } - - return peak; - } - - private static void ApplyGain(float[] samples, double gain) - { - for (int i = 0; i < samples.Length; i++) - { - samples[i] = (float)Math.Clamp(samples[i] * gain, -1.0, 1.0); - } - } - private static LoadedSoundSample LoadSample(string folderPath, string relativePath) { string absolutePath = Path.Combine(folderPath, relativePath); SoundSampleFormat format = GetSampleFormat(relativePath); byte[] data = File.ReadAllBytes(absolutePath); - float[] decoded = DecodeToPlaybackFormat(format, data); - return new LoadedSoundSample(relativePath, format, data, decoded, PlaybackWaveFormat); - } - - private static float[] DecodeToPlaybackFormat(SoundSampleFormat format, byte[] data) - { - try - { - using MemoryStream stream = new(data, writable: false); - using WaveStream reader = format switch - { - SoundSampleFormat.Wav => new WaveFileReader(stream), - SoundSampleFormat.Mp3 => new Mp3FileReader(stream), - _ => throw new InvalidOperationException("Unsupported audio format.") - }; - - ISampleProvider provider = reader.ToSampleProvider(); - provider = EnsureStereo(provider); - if (provider.WaveFormat.SampleRate != PlaybackWaveFormat.SampleRate) - { - provider = new WdlResamplingSampleProvider(provider, PlaybackWaveFormat.SampleRate); - } - - List samples = []; - float[] buffer = new float[PlaybackWaveFormat.SampleRate / 10 * PlaybackWaveFormat.Channels]; - int read; - while ((read = provider.Read(buffer, 0, buffer.Length)) > 0) - { - for (int i = 0; i < read; i++) - { - samples.Add(buffer[i]); - } - } - - float[] trimmed = AudioSampleTrimmer.TrimSilence(samples.ToArray(), PlaybackWaveFormat.Channels); - CenterStereoSamples(trimmed); - return trimmed; - } - catch (Exception ex) when (ex is FormatException or InvalidDataException or EndOfStreamException or IOException) - { - return []; - } - } - - private static ISampleProvider EnsureStereo(ISampleProvider provider) - { - return provider.WaveFormat.Channels switch - { - 1 => new MonoToStereoSampleProvider(provider), - 2 => provider, - _ => throw new InvalidOperationException("SoundType supports mono or stereo samples.") - }; - } - - private static void CenterStereoSamples(float[] samples) - { - for (int i = 0; i + 1 < samples.Length; i += PlaybackWaveFormat.Channels) - { - float centered = (samples[i] + samples[i + 1]) * 0.5f; - samples[i] = centered; - samples[i + 1] = centered; - } + float[] decoded = SoundPackAudioProcessor.DecodeToPlaybackFormat(format, data); + return new LoadedSoundSample(relativePath, format, data, decoded, SoundPackAudioProcessor.PlaybackWaveFormat); } private static bool TryGetSampleFormat(string relativePath, out SoundSampleFormat format) => diff --git a/src/SoundType.Tests/AudioProcessingTests.cs b/src/SoundType.Tests/AudioProcessingTests.cs index 9a7318a..86ea8fa 100644 --- a/src/SoundType.Tests/AudioProcessingTests.cs +++ b/src/SoundType.Tests/AudioProcessingTests.cs @@ -392,8 +392,6 @@ public IAudioOutputDevice Create() private sealed class FakeAudioOutputDevice : IAudioOutputDevice { - public event EventHandler? PlaybackStopped; - public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped; public void Init(ISampleProvider provider) @@ -402,11 +400,7 @@ public void Init(ISampleProvider provider) public void Play() => PlaybackState = PlaybackState.Playing; - public void Stop() - { - PlaybackState = PlaybackState.Stopped; - PlaybackStopped?.Invoke(this, new StoppedEventArgs()); - } + public void Stop() => PlaybackState = PlaybackState.Stopped; public void MarkStopped() => PlaybackState = PlaybackState.Stopped;