Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/SoundType.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand All @@ -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)
{
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/SoundType.Audio/AudioEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
14 changes: 4 additions & 10 deletions src/SoundType.Audio/IAudioOutputDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ namespace SoundType.Audio;

public interface IAudioOutputDevice : IDisposable
{
event EventHandler<StoppedEventArgs>? PlaybackStopped;

PlaybackState PlaybackState { get; }

void Init(ISampleProvider provider);
Expand All @@ -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<StoppedEventArgs>? PlaybackStopped
{
add => output.PlaybackStopped += value;
remove => output.PlaybackStopped -= value;
}

public PlaybackState PlaybackState => output.PlaybackState;

public void Init(ISampleProvider provider) => output.Init(provider);
Expand Down
161 changes: 161 additions & 0 deletions src/SoundType.Audio/SoundPackAudioProcessor.cs
Original file line number Diff line number Diff line change
@@ -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<float> 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<LoadedSoundSample> samples)
{
List<LoadedSoundSample> decodedSamples = samples
.Where(sample => sample.DecodedSamples.Length > 0)
.ToList();
if (decodedSamples.Count == 0)
{
return;
}

List<double> 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);
}
}
}
78 changes: 11 additions & 67 deletions src/SoundType.Audio/SoundPackLoader.cs
Original file line number Diff line number Diff line change
@@ -1,13 +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 static readonly WaveFormat PlaybackWaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2);
private static readonly IReadOnlyDictionary<string, SoundSampleFormat> SupportedSampleFormats =
new Dictionary<string, SoundSampleFormat>(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -62,7 +59,7 @@ public IReadOnlyList<SoundPackMetadata> DiscoverPacks(string packsRoot)
}
}

public SoundPackValidationResult Validate(SoundPackMetadata? metadata)
public SoundPackValidationResult Validate(SoundPackMetadata? metadata, bool analyzeAudioQuality = false)
{
SoundPackValidationResult result = new();
if (metadata is null)
Expand Down Expand Up @@ -90,7 +87,7 @@ public SoundPackValidationResult Validate(SoundPackMetadata? metadata)
{
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;
Expand All @@ -100,6 +97,12 @@ public SoundPackValidationResult Validate(SoundPackMetadata? metadata)
if (!File.Exists(absolutePath))
{
result.Errors.Add($"{group}: {relativePath} was not found.");
continue;
}

if (analyzeAudioQuality)
{
SoundPackAudioProcessor.AddQualityWarning(result, group, relativePath, absolutePath, format);
}
}
}
Expand All @@ -123,6 +126,7 @@ public LoadedSoundPack Load(SoundPackMetadata metadata)
.ToList();
}

SoundPackAudioProcessor.NormalizePackSamples(samples.Values.SelectMany(groupSamples => groupSamples));
return new LoadedSoundPack(metadata, samples);
}

Expand All @@ -131,68 +135,8 @@ private static LoadedSoundSample LoadSample(string folderPath, string relativePa
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<float> 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 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) =>
Expand Down
1 change: 1 addition & 0 deletions src/SoundType.Core/Models/SoundPackValidationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ public sealed class SoundPackValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
}
Loading
Loading