diff --git a/SirenSharp.Tests/AwcDat54CrossCheckTests.cs b/SirenSharp.Tests/AwcDat54CrossCheckTests.cs
index 070abd6..8800d17 100644
--- a/SirenSharp.Tests/AwcDat54CrossCheckTests.cs
+++ b/SirenSharp.Tests/AwcDat54CrossCheckTests.cs
@@ -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" });
diff --git a/SirenSharp.Tests/NativeAwcBackendTests.cs b/SirenSharp.Tests/NativeAwcBackendTests.cs
index 49b1660..6ed9a7e 100644
--- a/SirenSharp.Tests/NativeAwcBackendTests.cs
+++ b/SirenSharp.Tests/NativeAwcBackendTests.cs
@@ -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);
@@ -73,7 +75,7 @@ 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");
@@ -81,5 +83,36 @@ public void NativeOutput_VerifiesHealthy()
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));
+ }
}
}
diff --git a/SirenSharp.Tests/PreflightServiceTests.cs b/SirenSharp.Tests/PreflightServiceTests.cs
index 9f9796a..af9fc76 100644
--- a/SirenSharp.Tests/PreflightServiceTests.cs
+++ b/SirenSharp.Tests/PreflightServiceTests.cs
@@ -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()
{
diff --git a/SirenSharp.Tests/ResourceGenerationTests.cs b/SirenSharp.Tests/ResourceGenerationTests.cs
index 3c8c1b8..8e55673 100644
--- a/SirenSharp.Tests/ResourceGenerationTests.cs
+++ b/SirenSharp.Tests/ResourceGenerationTests.cs
@@ -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]
diff --git a/SirenSharp.Tests/WavSanitizerTests.cs b/SirenSharp.Tests/WavSanitizerTests.cs
index 3f4d4c2..f157041 100644
--- a/SirenSharp.Tests/WavSanitizerTests.cs
+++ b/SirenSharp.Tests/WavSanitizerTests.cs
@@ -1,3 +1,4 @@
+using NAudio.Wave;
using SirenSharp.Services;
using Xunit;
@@ -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()
{
diff --git a/SirenSharp/Models/Diagnostic.cs b/SirenSharp/Models/Diagnostic.cs
index 1c952ab..251b373 100644
--- a/SirenSharp/Models/Diagnostic.cs
+++ b/SirenSharp/Models/Diagnostic.cs
@@ -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";
diff --git a/SirenSharp/Models/Sound.cs b/SirenSharp/Models/Sound.cs
index 4e521db..4c2adfc 100644
--- a/SirenSharp/Models/Sound.cs
+++ b/SirenSharp/Models/Sound.cs
@@ -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
{
@@ -40,6 +42,50 @@ public string AudioPath
}
}
+ /// Trim in-point in seconds from the start of the source. 0 = from the beginning.
+ public double TrimStartSeconds
+ {
+ get => trimStartSeconds;
+ set
+ {
+ if (SetProperty(ref trimStartSeconds, value))
+ {
+ OnPropertyChanged(nameof(IsTrimmed));
+ OnPropertyChanged(nameof(TrimDisplayText));
+ }
+ }
+ }
+
+ /// Trim out-point in seconds. 0 (or past the end) = play to the end.
+ 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;
+
+ /// Human-readable trim range; an unset out-point reads "end" rather than 0.00s.
+ [XmlIgnore]
+ public string TrimDisplayText =>
+ $"{TrimStartSeconds:0.00}s – {(TrimEndSeconds > TrimStartSeconds ? $"{TrimEndSeconds:0.00}s" : "end")}";
+
+ /// 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).
+ [XmlIgnore]
+ public string PreparedFileName => $"{Name}.wav";
+
+ [XmlIgnore]
+ public double LengthSeconds => length.TotalSeconds;
+
[XmlIgnore]
public int Samples
{
@@ -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;
@@ -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;
@@ -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();
diff --git a/SirenSharp/Services/AudioPackBuilder.cs b/SirenSharp/Services/AudioPackBuilder.cs
index 1f165fe..b9fe42c 100644
--- a/SirenSharp/Services/AudioPackBuilder.cs
+++ b/SirenSharp/Services/AudioPackBuilder.cs
@@ -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)
{
diff --git a/SirenSharp/Services/AudioReaderFactory.cs b/SirenSharp/Services/AudioReaderFactory.cs
new file mode 100644
index 0000000..3b1215f
--- /dev/null
+++ b/SirenSharp/Services/AudioReaderFactory.cs
@@ -0,0 +1,46 @@
+using NAudio.Vorbis;
+using NAudio.Wave;
+
+namespace SirenSharp.Services
+{
+ ///
+ /// Opens any supported source audio file as a 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.
+ ///
+ public static class AudioReaderFactory
+ {
+ /// Extensions accepted for import (lower-case, with leading dot).
+ 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;
+ }
+
+ /// Open-file dialog filter string covering all supported formats.
+ public static string FileDialogFilter =>
+ "Audio Files (*.wav;*.mp3;*.ogg;*.aiff;*.wma)|*.wav;*.mp3;*.ogg;*.aiff;*.aif;*.wma|All Files (*.*)|*.*";
+
+ ///
+ /// 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.
+ ///
+ 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}'."),
+ };
+ }
+ }
+}
diff --git a/SirenSharp/Services/Backends/CodeWalkerAwcBuildBackend.cs b/SirenSharp/Services/Backends/CodeWalkerAwcBuildBackend.cs
index 30fa13e..d01c602 100644
--- a/SirenSharp/Services/Backends/CodeWalkerAwcBuildBackend.cs
+++ b/SirenSharp/Services/Backends/CodeWalkerAwcBuildBackend.cs
@@ -41,7 +41,7 @@ public AwcBuildResult BuildAwc(SoundSet soundSet, string preparedWavDirectory)
var streams = new List();
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)
diff --git a/SirenSharp/Services/Backends/Native/NativeAwcBuildBackend.cs b/SirenSharp/Services/Backends/Native/NativeAwcBuildBackend.cs
index 4daf78e..7b82f48 100644
--- a/SirenSharp/Services/Backends/Native/NativeAwcBuildBackend.cs
+++ b/SirenSharp/Services/Backends/Native/NativeAwcBuildBackend.cs
@@ -4,18 +4,11 @@ namespace SirenSharp.Services.Backends.Native
{
///
/// Experimental AWC backend that writes the binary itself (see )
- /// 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 - no CodeWalker dependency on
+ /// either the encode or the verify path. Marked experimental pending wider in-game use.
///
public sealed class NativeAwcBuildBackend : IAwcBuildBackend
{
- private readonly AwcVerifier awcVerifier;
-
- public NativeAwcBuildBackend(AwcVerifier awcVerifier)
- {
- this.awcVerifier = awcVerifier;
- }
-
public string Name => "Native";
public bool IsExperimental => true;
@@ -27,7 +20,7 @@ public AwcBuildResult BuildAwc(SoundSet soundSet, string preparedWavDirectory)
var waves = new List();
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
{
@@ -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)
diff --git a/SirenSharp/Services/Backends/Native/NativeAwcValidator.cs b/SirenSharp/Services/Backends/Native/NativeAwcValidator.cs
new file mode 100644
index 0000000..9f27931
--- /dev/null
+++ b/SirenSharp/Services/Backends/Native/NativeAwcValidator.cs
@@ -0,0 +1,145 @@
+using SirenSharp.Models;
+
+namespace SirenSharp.Services.Backends.Native
+{
+ ///
+ /// Reads an AWC back and validates it without CodeWalker, so the native backend can
+ /// verify its own output end to end. Parses the header/stream/chunk tables (see
+ /// ) and checks each stream is non-empty, PCM, and not silent.
+ ///
+ public static class NativeAwcValidator
+ {
+ private const uint Magic = 0x54414441;
+ private const byte ChunkFormat = 0xFA;
+ private const byte ChunkData = 0x55;
+ private const int MinHealthyBytes = 2048;
+
+ public static AwcVerificationResult Verify(string soundSetName, string awcFilePath)
+ {
+ var result = new AwcVerificationResult { SoundSetName = soundSetName, FilePath = awcFilePath };
+
+ if (!File.Exists(awcFilePath))
+ {
+ result.ErrorMessage = "AWC file was not created.";
+ return result;
+ }
+
+ var bytes = File.ReadAllBytes(awcFilePath);
+ result.FileSizeBytes = bytes.Length;
+
+ if (bytes.Length < MinHealthyBytes)
+ {
+ result.ErrorMessage = $"AWC is only {bytes.Length} bytes - likely silent or broken.";
+ return result;
+ }
+
+ try
+ {
+ if (BitConverter.ToUInt32(bytes, 0) != Magic)
+ {
+ result.ErrorMessage = "Not an AWC (bad magic).";
+ return result;
+ }
+
+ var flags = BitConverter.ToUInt16(bytes, 6);
+ var streamCount = BitConverter.ToInt32(bytes, 8);
+ result.StreamCount = streamCount;
+ if (streamCount <= 0)
+ {
+ result.ErrorMessage = "AWC contains no audio streams.";
+ return result;
+ }
+
+ var chunkIndices = (flags & 1) == 1;
+ var pos = 16 + (chunkIndices ? streamCount * 2 : 0);
+
+ // Stream infos: chunk count packed in the top 3 bits.
+ var chunkCounts = new int[streamCount];
+ var ids = new uint[streamCount];
+ for (int i = 0; i < streamCount; i++)
+ {
+ var raw = BitConverter.ToUInt32(bytes, pos);
+ pos += 4;
+ ids[i] = raw & 0x1FFFFFFF;
+ chunkCounts[i] = (int)(raw >> 29);
+ }
+
+ for (int i = 0; i < streamCount; i++)
+ {
+ int dataOffset = 0, dataSize = 0, rate = 0;
+ uint samples = 0;
+ var sawFormat = false;
+
+ for (int j = 0; j < chunkCounts[i]; j++)
+ {
+ var raw = BitConverter.ToUInt64(bytes, pos);
+ pos += 8;
+ var type = (byte)(raw >> 56);
+ var size = (int)((raw >> 28) & 0x0FFFFFFF);
+ var offset = (int)(raw & 0x0FFFFFFF);
+
+ if (type == ChunkFormat)
+ {
+ samples = BitConverter.ToUInt32(bytes, offset);
+ rate = BitConverter.ToUInt16(bytes, offset + 8);
+ sawFormat = true;
+ }
+ else if (type == ChunkData)
+ {
+ dataOffset = offset;
+ dataSize = size;
+ }
+ }
+
+ if (!sawFormat || dataSize == 0)
+ {
+ result.ErrorMessage = $"Stream {ids[i]:X} is missing format or data.";
+ return result;
+ }
+
+ var streamResult = new AwcStreamVerification
+ {
+ Name = $"0x{ids[i]:X}",
+ DataBytes = dataSize,
+ SampleRate = rate,
+ SampleCount = samples,
+ IsSilent = dataSize < 256 || IsMostlySilent(bytes, dataOffset, dataSize),
+ };
+ result.Streams.Add(streamResult);
+ }
+
+ if (result.Streams.All(s => s.IsSilent))
+ {
+ result.ErrorMessage = "All streams appear silent or have no PCM data.";
+ return result;
+ }
+ if (result.Streams.Any(s => s.IsSilent))
+ {
+ var silent = string.Join(", ", result.Streams.Where(s => s.IsSilent).Select(s => s.Name));
+ result.ErrorMessage = $"Some streams appear silent: {silent}";
+ return result;
+ }
+
+ result.IsHealthy = true;
+ return result;
+ }
+ catch (Exception ex)
+ {
+ result.ErrorMessage = ex.Message;
+ return result;
+ }
+ }
+
+ private static bool IsMostlySilent(byte[] data, int offset, int size)
+ {
+ var sampleCount = Math.Min(size / 2, 8000);
+ var loud = 0;
+ for (int i = 0; i < sampleCount; i++)
+ {
+ var smp = BitConverter.ToInt16(data, offset + i * 2);
+ if (smp != 0) loud++;
+ }
+ return loud < sampleCount * 0.01;
+ }
+ }
+}
diff --git a/SirenSharp/Services/Preflight/PreflightChecks.cs b/SirenSharp/Services/Preflight/PreflightChecks.cs
index 4fd8378..3e2bc79 100644
--- a/SirenSharp/Services/Preflight/PreflightChecks.cs
+++ b/SirenSharp/Services/Preflight/PreflightChecks.cs
@@ -33,6 +33,26 @@ public IEnumerable Inspect(Project project)
DiagnosticCodes.AwcDuplicateName,
name);
}
+
+ // The game identifies wavepacks (AWCs) by the first 8 characters of the name only
+ // (RAGE truncates them; see FiveM's PatchAudioWavePackOverlay 8-char match). Two AWCs
+ // whose names match in the first 8 chars resolve to the same bank, so only one loads
+ // and the other plays silent in-game - even though both names look distinct here.
+ var prefixCollisions = project.SoundSets
+ .Where(s => !string.IsNullOrEmpty(s.Name))
+ .GroupBy(s => s.Name.ToLowerInvariant().Substring(0, Math.Min(8, s.Name.Length)))
+ .Where(g => g.Select(s => s.Name).Distinct().Count() > 1);
+
+ foreach (var group in prefixCollisions)
+ {
+ var names = string.Join(", ", group.Select(s => s.Name).Distinct());
+ yield return new Diagnostic(
+ DiagnosticSeverity.Warning,
+ $"AWCs {names} share the same first 8 characters ('{group.Key}'). The game uses only the first 8 characters to identify a wavepack, so these resolve to the same bank - only one will load and the others will be silent in-game.",
+ DiagnosticCodes.AwcNameCollision,
+ group.Key,
+ "Make the first 8 characters of each AWC name unique.");
+ }
}
}
diff --git a/SirenSharp/Services/WavFormatAnalyzer.cs b/SirenSharp/Services/WavFormatAnalyzer.cs
index 750aeda..b44d213 100644
--- a/SirenSharp/Services/WavFormatAnalyzer.cs
+++ b/SirenSharp/Services/WavFormatAnalyzer.cs
@@ -23,7 +23,7 @@ public bool TryAnalyze(string filePath, out WavFormatInfo? info, out string? err
try
{
- using var reader = new WaveFileReader(filePath);
+ using var reader = AudioReaderFactory.Open(filePath);
var format = reader.WaveFormat;
info = new WavFormatInfo
{
diff --git a/SirenSharp/Services/WavSanitizer.cs b/SirenSharp/Services/WavSanitizer.cs
index c659609..0b47be3 100644
--- a/SirenSharp/Services/WavSanitizer.cs
+++ b/SirenSharp/Services/WavSanitizer.cs
@@ -21,7 +21,8 @@ public WavSanitizer(WavFormatAnalyzer formatAnalyzer)
this.formatAnalyzer = formatAnalyzer;
}
- public WavSanitizeResult Sanitize(string inputPath, string outputPath)
+ public WavSanitizeResult Sanitize(string inputPath, string outputPath,
+ double trimStartSeconds = 0, double trimEndSeconds = 0)
{
var result = new WavSanitizeResult();
@@ -36,8 +37,8 @@ public WavSanitizeResult Sanitize(string inputPath, string outputPath)
var targetFormat = new WaveFormat(before!.SampleRate, 16, 1);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
- using var reader = new AudioFileReader(inputPath);
- ISampleProvider samples = reader;
+ using var reader = AudioReaderFactory.Open(inputPath);
+ ISampleProvider samples = reader.ToSampleProvider();
if (reader.WaveFormat.Channels > 1)
{
@@ -59,6 +60,20 @@ public WavSanitizeResult Sanitize(string inputPath, string outputPath)
result.WasConverted = true;
}
+ // Apply trim last, on the mono float stream, so in/out points are exact
+ // regardless of source format. trimEnd <= trimStart means "play to the end".
+ if (trimStartSeconds > 0 || trimEndSeconds > trimStartSeconds)
+ {
+ var offset = new OffsetSampleProvider(samples);
+ if (trimStartSeconds > 0)
+ offset.SkipOver = TimeSpan.FromSeconds(trimStartSeconds);
+ if (trimEndSeconds > trimStartSeconds)
+ offset.Take = TimeSpan.FromSeconds(trimEndSeconds - trimStartSeconds);
+ samples = offset;
+ result.Changes.Add($"trimmed to {trimStartSeconds:0.##}s-{(trimEndSeconds > trimStartSeconds ? trimEndSeconds.ToString("0.##") : "end")}s");
+ result.WasConverted = true;
+ }
+
using var writer = new WaveFileWriter(outputPath, targetFormat);
var buffer = new float[targetFormat.SampleRate * targetFormat.Channels];
int read;
diff --git a/SirenSharp/SirenSharp.csproj b/SirenSharp/SirenSharp.csproj
index bdfd205..f21ad55 100644
--- a/SirenSharp/SirenSharp.csproj
+++ b/SirenSharp/SirenSharp.csproj
@@ -6,7 +6,7 @@
enable
true
sirensharp.ico
- 0.5.1
+ 0.6.0
enable
en
@@ -19,6 +19,7 @@
+
diff --git a/SirenSharp/ViewModels/MainViewModel.cs b/SirenSharp/ViewModels/MainViewModel.cs
index ad4b2fe..38c7bee 100644
--- a/SirenSharp/ViewModels/MainViewModel.cs
+++ b/SirenSharp/ViewModels/MainViewModel.cs
@@ -592,10 +592,10 @@ private void ImportWav()
{
var ofd = new OpenFileDialog
{
- Filter = "Wave File (*.wav)|*.wav",
+ Filter = AudioReaderFactory.FileDialogFilter,
RestoreDirectory = true,
Multiselect = true,
- Title = "Select WAV files"
+ Title = "Select audio files"
};
if (ofd.ShowDialog() == true)
@@ -631,7 +631,7 @@ private void BrowseSiren()
if (CurrentSiren == null) return;
var ofd = new OpenFileDialog
{
- Filter = "Wave File (*.wav)|*.wav",
+ Filter = AudioReaderFactory.FileDialogFilter,
RestoreDirectory = true
};
@@ -643,14 +643,6 @@ private void GenerateResource()
{
if (Project == null) return;
- if (Project.SoundSets.Count > 7)
- {
- if (!MessageDialog.Confirm("Soundset limit",
- "This project has more than 7 soundsets. FiveM may only support 7 concurrent audio banks.\n\nContinue anyway?",
- "Continue", "Cancel", MessageDialogKind.Warning))
- return;
- }
-
var vm = serviceProvider.GetRequiredService();
vm.ResourceName = Project.ProjectName.ToLower().Replace(" ", "_");
vm.DlcName = string.IsNullOrWhiteSpace(Project.DLCName) ? vm.DlcName : Project.DLCName;
diff --git a/SirenSharp/Views/MainWindow.xaml b/SirenSharp/Views/MainWindow.xaml
index 74f4c89..9282c34 100644
--- a/SirenSharp/Views/MainWindow.xaml
+++ b/SirenSharp/Views/MainWindow.xaml
@@ -93,7 +93,7 @@
-
@@ -175,7 +175,7 @@
-
@@ -193,7 +193,7 @@
-
@@ -329,6 +329,21 @@
+
+
+
+
+
+
+
diff --git a/SirenSharp/Views/MainWindow.xaml.cs b/SirenSharp/Views/MainWindow.xaml.cs
index b61160c..c2b655f 100644
--- a/SirenSharp/Views/MainWindow.xaml.cs
+++ b/SirenSharp/Views/MainWindow.xaml.cs
@@ -27,12 +27,21 @@ private void OnDragOver(object sender, DragEventArgs e)
e.Handled = true;
}
+ private void OnResetTrim(object sender, RoutedEventArgs e)
+ {
+ if (ViewModel?.CurrentSiren is { } siren)
+ {
+ siren.TrimStartSeconds = 0;
+ siren.TrimEndSeconds = 0;
+ }
+ }
+
private void OnDrop(object sender, DragEventArgs e)
{
if (e.Data.GetData(DataFormats.FileDrop) is not string[] files) return;
- var wavFiles = files.Where(f => f.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)).ToArray();
- if (wavFiles.Length == 0) return;
- ViewModel?.ImportWavFiles(wavFiles);
+ var audioFiles = files.Where(SirenSharp.Services.AudioReaderFactory.IsSupported).ToArray();
+ if (audioFiles.Length == 0) return;
+ ViewModel?.ImportWavFiles(audioFiles);
}
}
}
diff --git a/SirenSharp/Views/WaveformTrimControl.xaml b/SirenSharp/Views/WaveformTrimControl.xaml
new file mode 100644
index 0000000..26f63af
--- /dev/null
+++ b/SirenSharp/Views/WaveformTrimControl.xaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SirenSharp/Views/WaveformTrimControl.xaml.cs b/SirenSharp/Views/WaveformTrimControl.xaml.cs
new file mode 100644
index 0000000..6f62159
--- /dev/null
+++ b/SirenSharp/Views/WaveformTrimControl.xaml.cs
@@ -0,0 +1,171 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Media;
+using NAudio.Wave;
+using SirenSharp.Services;
+
+namespace SirenSharp.Views
+{
+ ///
+ /// Draws a clip's waveform and lets the user drag in/out handles to set the trim points.
+ /// Binds two-way to TrimStartSeconds/TrimEndSeconds (0 end = "to the end of the clip").
+ ///
+ public partial class WaveformTrimControl : UserControl
+ {
+ private float[] peaks = System.Array.Empty(); // mono abs-peak per source frame
+
+ public WaveformTrimControl() => InitializeComponent();
+
+ public static readonly DependencyProperty AudioPathProperty = DependencyProperty.Register(
+ nameof(AudioPath), typeof(string), typeof(WaveformTrimControl),
+ new PropertyMetadata(null, (d, _) => ((WaveformTrimControl)d).LoadPeaks()));
+
+ public static readonly DependencyProperty DurationSecondsProperty = DependencyProperty.Register(
+ nameof(DurationSeconds), typeof(double), typeof(WaveformTrimControl),
+ new PropertyMetadata(0.0, (d, _) => ((WaveformTrimControl)d).Redraw()));
+
+ public static readonly DependencyProperty TrimStartSecondsProperty = DependencyProperty.Register(
+ nameof(TrimStartSeconds), typeof(double), typeof(WaveformTrimControl),
+ new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
+ (d, _) => ((WaveformTrimControl)d).UpdateHandles()));
+
+ public static readonly DependencyProperty TrimEndSecondsProperty = DependencyProperty.Register(
+ nameof(TrimEndSeconds), typeof(double), typeof(WaveformTrimControl),
+ new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
+ (d, _) => ((WaveformTrimControl)d).UpdateHandles()));
+
+ public string? AudioPath { get => (string?)GetValue(AudioPathProperty); set => SetValue(AudioPathProperty, value); }
+ public double DurationSeconds { get => (double)GetValue(DurationSecondsProperty); set => SetValue(DurationSecondsProperty, value); }
+ public double TrimStartSeconds { get => (double)GetValue(TrimStartSecondsProperty); set => SetValue(TrimStartSecondsProperty, value); }
+ public double TrimEndSeconds { get => (double)GetValue(TrimEndSecondsProperty); set => SetValue(TrimEndSecondsProperty, value); }
+
+ private double EffectiveEnd => TrimEndSeconds > 0 && TrimEndSeconds <= DurationSeconds ? TrimEndSeconds : DurationSeconds;
+
+ private async void LoadPeaks()
+ {
+ var path = AudioPath;
+ peaks = System.Array.Empty();
+
+ if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
+ {
+ Redraw();
+ return;
+ }
+
+ try
+ {
+ peaks = await System.Threading.Tasks.Task.Run(() => ReadPeaks(path));
+ }
+ catch
+ {
+ peaks = System.Array.Empty();
+ }
+ Redraw();
+ }
+
+ private static float[] ReadPeaks(string path)
+ {
+ using var reader = AudioReaderFactory.Open(path);
+ var sp = reader.ToSampleProvider();
+ var channels = reader.WaveFormat.Channels;
+
+ var result = new System.Collections.Generic.List();
+ var buffer = new float[reader.WaveFormat.SampleRate * channels];
+ int read;
+ while ((read = sp.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ for (int i = 0; i + channels <= read; i += channels)
+ {
+ float peak = 0;
+ for (int c = 0; c < channels; c++) peak = System.Math.Max(peak, System.Math.Abs(buffer[i + c]));
+ result.Add(peak);
+ }
+ }
+ return result.ToArray();
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ Redraw();
+ }
+
+ private void Redraw()
+ {
+ DrawWave();
+ UpdateHandles();
+ }
+
+ private void DrawWave()
+ {
+ var w = Host.ActualWidth;
+ var h = Host.ActualHeight;
+ if (w < 1 || h < 1 || peaks.Length == 0)
+ {
+ WavePath.Data = null;
+ return;
+ }
+
+ var mid = h / 2;
+ var columns = (int)w;
+ var perCol = (double)peaks.Length / columns;
+ var geo = new StreamGeometry();
+ using (var ctx = geo.Open())
+ {
+ for (int x = 0; x < columns; x++)
+ {
+ var from = (int)(x * perCol);
+ var to = System.Math.Min(peaks.Length, (int)((x + 1) * perCol));
+ float p = 0;
+ for (int i = from; i < to; i++) p = System.Math.Max(p, peaks[i]);
+ var half = p * (mid - 2);
+ ctx.BeginFigure(new Point(x, mid - half), false, false);
+ ctx.LineTo(new Point(x, mid + half), true, false);
+ }
+ }
+ geo.Freeze();
+ WavePath.Data = geo;
+ }
+
+ private void UpdateHandles()
+ {
+ var w = Host.ActualWidth;
+ var h = Host.ActualHeight;
+ if (w < 1 || DurationSeconds <= 0) return;
+
+ var pxPerSec = w / DurationSeconds;
+ var startX = TrimStartSeconds * pxPerSec;
+ var endX = EffectiveEnd * pxPerSec;
+
+ StartThumb.Height = h;
+ EndThumb.Height = h;
+ Canvas.SetLeft(StartThumb, startX - StartThumb.Width / 2);
+ Canvas.SetLeft(EndThumb, endX - EndThumb.Width / 2);
+
+ LeftShade.Height = h;
+ LeftShade.Width = System.Math.Max(0, startX);
+ RightShade.Height = h;
+ Canvas.SetLeft(RightShade, endX);
+ RightShade.Width = System.Math.Max(0, w - endX);
+ }
+
+ private void StartThumb_DragDelta(object sender, DragDeltaEventArgs e)
+ {
+ if (Host.ActualWidth < 1 || DurationSeconds <= 0) return;
+ var deltaSec = e.HorizontalChange / Host.ActualWidth * DurationSeconds;
+ var newStart = Clamp(TrimStartSeconds + deltaSec, 0, EffectiveEnd - 0.02);
+ TrimStartSeconds = newStart;
+ }
+
+ private void EndThumb_DragDelta(object sender, DragDeltaEventArgs e)
+ {
+ if (Host.ActualWidth < 1 || DurationSeconds <= 0) return;
+ var deltaSec = e.HorizontalChange / Host.ActualWidth * DurationSeconds;
+ var newEnd = Clamp(EffectiveEnd + deltaSec, TrimStartSeconds + 0.02, DurationSeconds);
+ // Snap to "no end trim" when dragged to the far right.
+ TrimEndSeconds = newEnd >= DurationSeconds - 0.02 ? 0 : newEnd;
+ }
+
+ private static double Clamp(double v, double lo, double hi) => v < lo ? lo : (v > hi ? hi : v);
+ }
+}