Skip to content
Open
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
6 changes: 4 additions & 2 deletions SirenSharp.Tests/AwcDat54CrossCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ public void EveryDat54WaveReferenceResolvesToAnAwcStream()
{
using var dir = new TempDir();
// Mixed case + multiple sirens - the exact shape that used to break.
var wail = WavFixtures.MonoPcm16(dir.File("wail.wav"), seconds: 1.0, freq: 500);
var yelp = WavFixtures.MonoPcm16(dir.File("yelp.wav"), seconds: 1.0, freq: 800);
// Fixtures named to match each Sound's prepared file ({Name}.wav) - the backend is
// fed directly here, without the sanitizer that would normally write those files.
var wail = WavFixtures.MonoPcm16(dir.File("Wail.wav"), seconds: 1.0, freq: 500);
var yelp = WavFixtures.MonoPcm16(dir.File("Yelp_02.wav"), seconds: 1.0, freq: 800);

var soundSet = new SoundSet("police");
soundSet.AddSound(new Sound(wail) { Name = "Wail" });
Expand Down
41 changes: 37 additions & 4 deletions SirenSharp.Tests/NativeAwcBackendTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ public void NativeOutput_MatchesCodeWalkerOutput_StreamForStream()
using var dir = new TempDir();
// Long enough to force a peak chunk (>8192 samples) plus a short clip and mixed case.
var wail = WavFixtures.MonoPcm16(dir.File("wail.wav"), seconds: 1.5, freq: 550);
var yelp = WavFixtures.MonoPcm16(dir.File("yelp.wav"), seconds: 1.0, freq: 900);
var horn = WavFixtures.MonoPcm16(dir.File("horn.wav"), seconds: 0.1, freq: 300);
// Fixtures are named to match each Sound's prepared file ({Name}.wav), since these
// tests feed the backend directly rather than through the sanitizer.
var yelp = WavFixtures.MonoPcm16(dir.File("yelp_02.wav"), seconds: 1.0, freq: 900);
var horn = WavFixtures.MonoPcm16(dir.File("AirHorn.wav"), seconds: 0.1, freq: 300);

var soundSet = new SoundSet("police");
soundSet.AddSound(new Sound(wail) { Name = "Wail" });
soundSet.AddSound(new Sound(yelp) { Name = "yelp_02" });
soundSet.AddSound(new Sound(horn) { Name = "AirHorn" });

var native = new NativeAwcBuildBackend(new AwcVerifier()).BuildAwc(soundSet, dir.Path);
var native = new NativeAwcBuildBackend().BuildAwc(soundSet, dir.Path);
var codewalker = new CodeWalkerAwcBuildBackend(new AwcVerifier()).BuildAwc(soundSet, dir.Path);
Assert.True(native.Success, native.Error?.Message);
Assert.True(codewalker.Success, codewalker.Error?.Message);
Expand Down Expand Up @@ -73,13 +75,44 @@ public void NativeOutput_VerifiesHealthy()
var soundSet = new SoundSet("police");
soundSet.AddSound(new Sound(wail) { Name = "Wail" });

var native = new NativeAwcBuildBackend(new AwcVerifier()).BuildAwc(soundSet, dir.Path);
var native = new NativeAwcBuildBackend().BuildAwc(soundSet, dir.Path);
Assert.True(native.Success, native.Error?.Message);

var awcPath = dir.File("police.awc");
File.WriteAllBytes(awcPath, native.Data);
var verification = new AwcVerifier().Verify("police", awcPath);
Assert.True(verification.IsHealthy, verification.Summary);
}

[Fact]
public void NativeValidator_VerifiesOwnOutput_NoCodeWalker()
{
using var dir = new TempDir();
var wail = WavFixtures.MonoPcm16(dir.File("wail.wav"), seconds: 1.5, freq: 550);
var soundSet = new SoundSet("police");
soundSet.AddSound(new Sound(wail) { Name = "Wail" });

var build = new NativeAwcBuildBackend().BuildAwc(soundSet, dir.Path);
Assert.True(build.Success, build.Error?.Message);
var awcPath = dir.File("police.awc");
File.WriteAllBytes(awcPath, build.Data);

var v = NativeAwcValidator.Verify("police", awcPath);
Assert.True(v.IsHealthy, v.Summary);
Assert.Equal(1, v.StreamCount);
Assert.All(v.Streams, s => Assert.False(s.IsSilent));
}

[Fact]
public void NativeValidator_FlagsTinyFile()
{
using var dir = new TempDir();
var path = dir.File("bad.awc");
File.WriteAllBytes(path, new byte[100]);

var v = NativeAwcValidator.Verify("x", path);
Assert.False(v.IsHealthy);
Assert.False(string.IsNullOrEmpty(v.ErrorMessage));
}
}
}
26 changes: 26 additions & 0 deletions SirenSharp.Tests/PreflightServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ public void EmptyProject_IsBlockingError()
Assert.Contains(report.Items, d => d.Code == DiagnosticCodes.ProjectEmpty);
}

[Fact]
public void AwcNamesSharingFirst8Chars_WarnCollision()
{
using var dir = new TempDir();
var project = new Project("demo", dir.File("demo.ssproj"));
// Distinct names, but identical in the first 8 chars -> same wavepack in-game.
project.SoundSets.Add(new SoundSet("policecar1"));
project.SoundSets.Add(new SoundSet("policecar2"));

var report = Preflight.Inspect(project);
Assert.Contains(report.Warnings, d => d.Code == DiagnosticCodes.AwcNameCollision);
}

[Fact]
public void AwcNamesDifferingWithin8Chars_NoCollision()
{
using var dir = new TempDir();
var project = new Project("demo", dir.File("demo.ssproj"));
project.SoundSets.Add(new SoundSet("lspd"));
project.SoundSets.Add(new SoundSet("bcso"));
project.SoundSets.Add(new SoundSet("fire_dept")); // 9 chars but unique in 8 -> fine

var report = Preflight.Inspect(project);
Assert.DoesNotContain(report.Items, d => d.Code == DiagnosticCodes.AwcNameCollision);
}

[Fact]
public void DuplicateSoundsetNames_AreBlockingError()
{
Expand Down
2 changes: 1 addition & 1 deletion SirenSharp.Tests/ResourceGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ private static GenericFiveMExporter BuildGenerator()
return new GenericFiveMExporter(
packBuilder,
new CodeWalkerAwcBuildBackend(verifier),
new Services.Backends.Native.NativeAwcBuildBackend(verifier));
new Services.Backends.Native.NativeAwcBuildBackend());
}

[Fact]
Expand Down
32 changes: 32 additions & 0 deletions SirenSharp.Tests/WavSanitizerTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using NAudio.Wave;
using SirenSharp.Services;
using Xunit;

Expand Down Expand Up @@ -58,6 +59,37 @@ public void AlreadyCompatible_RewritesWithoutConversionFlag()
Assert.True(info!.IsCompatible);
}

[Fact]
public void Trim_CutsToInAndOutPoints()
{
using var dir = new TempDir();
var input = WavFixtures.MonoPcm16(dir.File("two.wav"), seconds: 2.0);
var output = dir.File("out.wav");

var result = sanitizer.Sanitize(input, output, trimStartSeconds: 0.5, trimEndSeconds: 1.5);

Assert.True(result.Success, result.Error);
Assert.True(result.WasConverted);
Assert.Contains(result.Changes, c => c.Contains("trimmed"));

using var reader = new WaveFileReader(output);
Assert.InRange(reader.TotalTime.TotalSeconds, 0.95, 1.05);
}

[Fact]
public void Trim_StartOnly_KeepsRemainderToEnd()
{
using var dir = new TempDir();
var input = WavFixtures.MonoPcm16(dir.File("two.wav"), seconds: 2.0);
var output = dir.File("out.wav");

var result = sanitizer.Sanitize(input, output, trimStartSeconds: 0.5);

Assert.True(result.Success, result.Error);
using var reader = new WaveFileReader(output);
Assert.InRange(reader.TotalTime.TotalSeconds, 1.45, 1.55);
}

[Fact]
public void JunkFile_FailsGracefully()
{
Expand Down
1 change: 1 addition & 0 deletions SirenSharp/Models/Diagnostic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public static class DiagnosticCodes
public const string AwcDuplicateName = "AWC.DUPLICATE_NAME";
public const string AwcInvalidName = "AWC.INVALID_NAME";
public const string AwcNoSirens = "AWC.NO_SIRENS";
public const string AwcNameCollision = "AWC.NAME_COLLISION";
public const string SirenDuplicateName = "SIREN.DUPLICATE_NAME";
public const string SirenInvalidName = "SIREN.INVALID_NAME";
public const string SirenMissingFile = "SIREN.MISSING_FILE";
Expand Down
69 changes: 67 additions & 2 deletions SirenSharp/Models/Sound.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public partial class Sound : ObservableObject
private string formatStatus = string.Empty;
private bool needsConversion;
private SoundFormatState formatState = SoundFormatState.Missing;
private double trimStartSeconds;
private double trimEndSeconds;

public string Name
{
Expand All @@ -40,6 +42,50 @@ public string AudioPath
}
}

/// <summary>Trim in-point in seconds from the start of the source. 0 = from the beginning.</summary>
public double TrimStartSeconds
{
get => trimStartSeconds;
set
{
if (SetProperty(ref trimStartSeconds, value))
{
OnPropertyChanged(nameof(IsTrimmed));
OnPropertyChanged(nameof(TrimDisplayText));
}
}
}

/// <summary>Trim out-point in seconds. 0 (or past the end) = play to the end.</summary>
public double TrimEndSeconds
{
get => trimEndSeconds;
set
{
if (SetProperty(ref trimEndSeconds, value))
{
OnPropertyChanged(nameof(IsTrimmed));
OnPropertyChanged(nameof(TrimDisplayText));
}
}
}

[XmlIgnore]
public bool IsTrimmed => TrimStartSeconds > 0 || TrimEndSeconds > 0;

/// <summary>Human-readable trim range; an unset out-point reads "end" rather than 0.00s.</summary>
[XmlIgnore]
public string TrimDisplayText =>
$"{TrimStartSeconds:0.00}s – {(TrimEndSeconds > TrimStartSeconds ? $"{TrimEndSeconds:0.00}s" : "end")}";

/// <summary>Scratch filename for the sanitized WAV the build pipeline writes and reads,
/// regardless of the source extension (MP3/OGG sources still become a .wav here).</summary>
[XmlIgnore]
public string PreparedFileName => $"{Name}.wav";

[XmlIgnore]
public double LengthSeconds => length.TotalSeconds;

[XmlIgnore]
public int Samples
{
Expand All @@ -58,7 +104,11 @@ public int SampleRate
public TimeSpan Length
{
get => length;
set => SetProperty(ref length, value);
set
{
if (SetProperty(ref length, value))
OnPropertyChanged(nameof(LengthSeconds));
}
}

public string FileName => string.IsNullOrWhiteSpace(AudioPath) ? string.Empty : new FileInfo(AudioPath).Name;
Expand Down Expand Up @@ -117,7 +167,13 @@ private void UpdateFilePath(string filePath, WavFormatAnalyzer? analyzer = null)
return;
}

using (var wfr = new WaveFileReader(filePath))
if (!AudioReaderFactory.IsSupported(filePath))
{
FormatStatus = "Unsupported file type";
return;
}

using (var wfr = AudioReaderFactory.Open(filePath))
{
Length = wfr.TotalTime;
SampleRate = wfr.WaveFormat.SampleRate;
Expand All @@ -126,6 +182,15 @@ private void UpdateFilePath(string filePath, WavFormatAnalyzer? analyzer = null)
Samples = other > 0 ? len / other : 0;
}

// Clamp any trim carried over from a previous source so stale in/out points
// can't sit past the end of the new clip.
var clipSeconds = length.TotalSeconds;
if (clipSeconds > 0)
{
if (trimStartSeconds >= clipSeconds) TrimStartSeconds = 0;
if (trimEndSeconds > clipSeconds) TrimEndSeconds = 0;
}

Size = new FileInfo(filePath).Length;

analyzer ??= new WavFormatAnalyzer();
Expand Down
5 changes: 3 additions & 2 deletions SirenSharp/Services/AudioPackBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ public bool Build(

foreach (var sound in soundSet.Sounds)
{
var destPath = Path.Combine(awcDir.FullName, sound.FileName);
var sanitize = wavSanitizer.Sanitize(sound.AudioPath, destPath);
var destPath = Path.Combine(awcDir.FullName, sound.PreparedFileName);
var sanitize = wavSanitizer.Sanitize(
sound.AudioPath, destPath, sound.TrimStartSeconds, sound.TrimEndSeconds);

if (!sanitize.Success)
{
Expand Down
46 changes: 46 additions & 0 deletions SirenSharp/Services/AudioReaderFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using NAudio.Vorbis;
using NAudio.Wave;

namespace SirenSharp.Services
{
/// <summary>
/// Opens any supported source audio file as a <see cref="WaveStream"/> for reading,
/// picking the right NAudio reader per extension (WAV/MP3/AIFF/WMA, and Vorbis for OGG).
/// This is the single place that decides how a file is decoded, so the analyzer, the
/// sanitizer, and the Sound metadata all accept the same formats.
/// </summary>
public static class AudioReaderFactory
{
/// <summary>Extensions accepted for import (lower-case, with leading dot).</summary>
public static readonly string[] SupportedExtensions = { ".wav", ".mp3", ".ogg", ".aiff", ".aif", ".wma" };

public static bool IsSupported(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return Array.IndexOf(SupportedExtensions, ext) >= 0;
}

/// <summary>Open-file dialog filter string covering all supported formats.</summary>
public static string FileDialogFilter =>
"Audio Files (*.wav;*.mp3;*.ogg;*.aiff;*.wma)|*.wav;*.mp3;*.ogg;*.aiff;*.aif;*.wma|All Files (*.*)|*.*";

/// <summary>
/// Opens the file with its true decoded format (NOT normalised to float), so the
/// analyzer can tell a clean 16-bit PCM WAV from one that needs converting.
/// </summary>
public static WaveStream Open(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".wav" => new WaveFileReader(path),
".ogg" => new VorbisWaveReader(path),
".mp3" => new Mp3FileReader(path),
".aiff" or ".aif" => new AiffFileReader(path),
".wma" => new MediaFoundationReader(path),
// Don't guess at unknown containers (the file dialog has an "All Files" option).
_ => throw new NotSupportedException($"Unsupported audio format '{ext}'."),
};
}
}
}
2 changes: 1 addition & 1 deletion SirenSharp/Services/Backends/CodeWalkerAwcBuildBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public AwcBuildResult BuildAwc(SoundSet soundSet, string preparedWavDirectory)
var streams = new List<AwcStream>();
foreach (var sound in soundSet.Sounds)
{
var wavPath = Path.Combine(preparedWavDirectory, sound.FileName);
var wavPath = Path.Combine(preparedWavDirectory, sound.PreparedFileName);
var wav = File.ReadAllBytes(wavPath);

var stream = new AwcStream(awc)
Expand Down
15 changes: 4 additions & 11 deletions SirenSharp/Services/Backends/Native/NativeAwcBuildBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,11 @@ namespace SirenSharp.Services.Backends.Native
{
/// <summary>
/// Experimental AWC backend that writes the binary itself (see <see cref="NativeAwcWriter"/>)
/// with no CodeWalker dependency for the encode. Verification still uses the shared
/// verifier for now. Marked experimental until validated against known-good banks in-game.
/// and verifies it with <see cref="NativeAwcValidator"/> - no CodeWalker dependency on
/// either the encode or the verify path. Marked experimental pending wider in-game use.
/// </summary>
public sealed class NativeAwcBuildBackend : IAwcBuildBackend
{
private readonly AwcVerifier awcVerifier;

public NativeAwcBuildBackend(AwcVerifier awcVerifier)
{
this.awcVerifier = awcVerifier;
}

public string Name => "Native";

public bool IsExperimental => true;
Expand All @@ -27,7 +20,7 @@ public AwcBuildResult BuildAwc(SoundSet soundSet, string preparedWavDirectory)
var waves = new List<NativeAwcWriter.Wave>();
foreach (var sound in soundSet.Sounds)
{
var wavPath = Path.Combine(preparedWavDirectory, sound.FileName);
var wavPath = Path.Combine(preparedWavDirectory, sound.PreparedFileName);
var (rate, pcm) = ReadMonoPcm16(File.ReadAllBytes(wavPath), sound.Name);
waves.Add(new NativeAwcWriter.Wave
{
Expand Down Expand Up @@ -56,7 +49,7 @@ public AwcBuildResult BuildAwc(SoundSet soundSet, string preparedWavDirectory)
}

public AwcVerificationResult Verify(string soundSetName, string awcFilePath)
=> awcVerifier.Verify(soundSetName, awcFilePath);
=> NativeAwcValidator.Verify(soundSetName, awcFilePath);

// Minimal RIFF/WAVE reader for the sanitized mono 16-bit PCM the pipeline produces.
private static (ushort sampleRate, byte[] pcm) ReadMonoPcm16(byte[] wav, string name)
Expand Down
Loading
Loading