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); + } +}