diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs new file mode 100644 index 00000000..4ff7a1d5 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs @@ -0,0 +1,629 @@ +using System.Buffers.Binary; +using System.Text; +using Testably.Abstractions.Testing; +using WheelWizard.CustomMusic; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Audio.Codecs; +using WheelWizard.CustomMusic.Audio.Dsp; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Audio.Tags; +using WheelWizard.CustomMusic.Catalog; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicAudioTests +{ + private const int Rate = 32000; + + [Fact] + public void DspAdpcm_RoundTrip_KeepsTheSignal() + { + var pcm = ToPcm16(Tone(Rate * 2, Rate, [(440, 0.3), (1320, 0.1)])); + + var encoded = DspAdpcm.Encode(pcm); + + Assert.Equal(DspAdpcm.SampleCountToByteCount(pcm.Length), encoded.Adpcm.Length); + Assert.Equal(encoded.Decoded, DspAdpcm.Decode(encoded.Adpcm, encoded.Coefficients, pcm.Length)); + Assert.True(Snr(pcm, encoded.Decoded) > 25, "4-bit ADPCM should keep tones well above 25 dB SNR"); + } + + [Fact] + public void DspAdpcm_SilenceEncodes() + { + var encoded = DspAdpcm.Encode(new short[10_000]); + + Assert.All(encoded.Decoded, sample => Assert.Equal(0, sample)); + } + + [Theory] + [InlineData(14, 8)] + [InlineData(15, 10)] + [InlineData(28, 16)] + [InlineData(14336, 8192)] + public void DspAdpcm_SampleCountToByteCount(int samples, int bytes) => Assert.Equal(bytes, DspAdpcm.SampleCountToByteCount(samples)); + + [Fact] + public void Brstm_StoresLoopAndStreamInfo_AndDecodesToTheEncodedAudio() + { + var length = Brstm.SamplesPerBlock * 3 + 1234; // several blocks plus a short, padded last block + var left = ToPcm16(Tone(length, Rate, [(330, 0.25)])); + var right = ToPcm16(Tone(length, Rate, [(495, 0.25)])); + var loopStart = Brstm.SamplesPerBlock * 2; + + var file = BrstmWriter.Write(new BrstmStream { Channels = [left, right], SampleRate = Rate, Looping = true, LoopStart = loopStart }); + var info = BrstmReader.ReadInfo(file); + var decoded = BrstmReader.Decode(file); + + Assert.Equal("RSTM", Encoding.ASCII.GetString(file, 0, 4)); + Assert.Equal(file.Length, BinaryPrimitives.ReadInt32BigEndian(file.AsSpan(8))); + Assert.True(info.Looping); + Assert.Equal(loopStart, info.LoopStart); + Assert.Equal(length, info.SampleCount); + Assert.Equal(2, info.ChannelCount); + Assert.Equal(1, info.TrackCount); + Assert.Equal(Rate, info.SampleRate); + Assert.Equal(new LoopRegion(loopStart, length), decoded.Loop); + var expectedRight = DspAdpcm.Encode(right).Decoded; + for (var i = 0; i < length; i += 97) + Assert.Equal(expectedRight[i], (short)Math.Round(decoded.Channels[1][i] * 32768)); + } + + [Fact] + public void Brstm_PulsarVolumeByteIsWrittenAndRead() + { + var file = BrstmWriter.Write(new BrstmStream { Channels = [new short[5000]], PulsarVolume = 0x40 }); + + Assert.Equal(0x40, file[Brstm.PulsarVolumeOffset]); + Assert.InRange(BrstmReader.ReadInfo(file).PulsarGain, 0.50, 0.51); + } + + [Fact] + public void Brstm_TracksAreReadFromTheHeader() + { + var tone = ToPcm16(Tone(32000, 32000, [(440, 0.2)])); + var file = BrstmWriter.Write(new BrstmStream { Channels = [tone, tone, tone, tone, tone] }); + + var tracks = BrstmReader.ReadTracks(file); + + Assert.Equal(3, tracks.Count); + Assert.Equal([0, 1], tracks[0].Channels); + Assert.Equal([2, 3], tracks[1].Channels); + Assert.Equal([4], tracks[2].Channels); + Assert.All(tracks, track => Assert.Equal(0x7F, track.Volume)); + } + + [Fact] + public void Brstm_IsHeardTheWayTheGamePlaysItsTracks() + { + // Two stereo tracks with the same music, as custom track BRSTMs often have. + var song = ToPcm16(Tone(32000 * 3, 32000, [(440, 0.2), (660, 0.1)])); + var file = BrstmWriter.Write(new BrstmStream { Channels = [song, song, song, song] }); + + var race = BrstmReader.DecodeAsPlayed(file, allTracks: false); + var menu = BrstmReader.DecodeAsPlayed(file, allTracks: true); + var everyChannel = LoudnessMeter.Measure(BrstmReader.Decode(file)).IntegratedLufs; + var firstTrack = LoudnessMeter.Measure(race).IntegratedLufs; + var allTracks = LoudnessMeter.Measure(menu).IntegratedLufs; + + Assert.Equal(2, race.ChannelCount); + Assert.Equal(2, menu.ChannelCount); + // Counting all four channels as separate speakers overstates a race file by 3 dB... + Assert.InRange(everyChannel - firstTrack, 2.9, 3.1); + // ...and understates menu music, whose identical tracks add up to twice the amplitude (+6 dB). + Assert.InRange(allTracks - firstTrack, 5.9, 6.1); + } + + [Fact] + public async Task AudioFileDecoder_KeepsTheBrstmTracksTheSlotPlays() + { + // A race BRSTM with an alternative mix in its second track: only the first track belongs in a race song. + var main = ToPcm16(Tone(Rate, Rate, [(440, 0.3)])); + var alternative = ToPcm16(Tone(Rate, Rate, [(1200, 0.3)])); + var file = BrstmWriter.Write(new BrstmStream { Channels = [main, main, alternative, alternative] }); + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + + var race = await AudioFileDecoder.DecodeAsync(fs, file, "/music/n_Circuit32_n.brstm"); + var menu = await AudioFileDecoder.DecodeAsync(fs, file, "/music/title_bg.brstm", allBrstmTracks: true); + + var firstTrack = BrstmReader.Decode(file).Channels[0]; + Assert.Equal(2, race.ChannelCount); + Assert.Equal(firstTrack, race.Channels[0]); + Assert.Equal(BrstmReader.DecodeAsPlayed(file, allTracks: true).Channels[0], menu.Channels[0]); + } + + [Theory] + [InlineData("mpeg", true)] + [InlineData("id3+mpeg", true)] + [InlineData("id3+padding+mpeg", true)] + [InlineData("id3+adts", false)] + [InlineData("adts", false)] + [InlineData("junk+mpeg", false)] + public void Mp3Reader_RecognisesMpegAudioByItsFirstFrame(string layout, bool isMp3) + { + byte[] id3 = [(byte)'I', (byte)'D', (byte)'3', 3, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0]; + byte[] mpeg = [0xFF, 0xFB, 0x90, 0x64, 0, 0, 0, 0]; // MPEG-1 Layer III + byte[] adts = [0xFF, 0xF1, 0x50, 0x80, 0, 0, 0, 0]; // AAC + var data = layout switch + { + "mpeg" => mpeg, + "id3+mpeg" => [.. id3, .. mpeg], + "id3+padding+mpeg" => [.. id3, 0, 0, 0, .. mpeg], + "id3+adts" => [.. id3, .. adts], + "adts" => adts, + _ => [1, 2, 3, .. mpeg], + }; + + Assert.Equal(isMp3, Mp3Reader.LooksLikeMp3(data)); + } + + [Fact] + public void LoudnessMeter_MeasuresAMinus20DbfsStereoSineAsMinus20Lufs() + { + // BS.1770: a 1 kHz sine at -20 dBFS (peak) in both channels reads -20 LUFS. + var tone = Tone(48000 * 5, 48000, [(1000, 0.1)]); + + var result = LoudnessMeter.Measure([tone, tone], 48000); + + Assert.InRange(result.IntegratedLufs, -20.1, -19.9); + Assert.InRange(result.SamplePeak, 0.099, 0.101); + } + + [Fact] + public void LoudnessMeter_ReportsSilence() => Assert.True(LoudnessMeter.Measure([new float[48000 * 2]], 48000).IsSilent); + + [Fact] + public void Resampler_KeepsTonesAndRemovesWhatCannotBeRepresented() + { + var kept = Resampler.Resample(Tone(44100 * 2, 44100, [(5000, 0.5)]), 44100, Rate); + var aliased = Resampler.Resample(Tone(44100 * 2, 44100, [(19000, 0.5)]), 44100, Rate); + + Assert.InRange(kept.Length, Rate * 2 - 2, Rate * 2); + Assert.InRange(Rms(kept, 2000), 0.35, 0.36); // 0.5 / sqrt(2) + Assert.True(Rms(aliased, 2000) < 0.001, "content above 16 kHz must not fold back"); + } + + [Fact] + public void Converter_MatchesTheTargetLoudness_AndLoopsSeamlessly() + { + var song = Song(44100, seconds: 20); + + var result = MusicConverter.Convert(song, new ConversionOptions { TargetLufs = -18, CreateFinalLap = true }); + var info = BrstmReader.ReadInfo(result.Normal); + var audio = BrstmReader.Decode(result.Normal); + + Assert.InRange(LoudnessMeter.Measure(audio).IntegratedLufs, -18.6, -17.4); + Assert.True(info.Looping); + Assert.Equal(0, info.LoopStart % Brstm.SamplesPerBlock); + Assert.Equal(Rate, info.SampleRate); + Assert.Equal(2, info.ChannelCount); + // Jumping from the last sample back to the loop start must be as smooth as the music itself. + foreach (var channel in audio.Channels) + Assert.True(Math.Abs(channel[info.LoopStart] - channel[info.SampleCount - 1]) < 0.05); + } + + [Theory] + [InlineData(FinalLapStyle.Faster, MusicLoudness.FinalLapPitchSpeed)] + [InlineData(FinalLapStyle.FasterKeepPitch, MusicLoudness.FinalLapTempoSpeed)] + [InlineData(FinalLapStyle.Unchanged, 1.0)] + public void Converter_FinalLapVersionPlaysFaster(FinalLapStyle style, double speed) + { + var result = MusicConverter.Convert(Song(32000, seconds: 12), new ConversionOptions { CreateFinalLap = true, FinalLapStyle = style }); + var normal = BrstmReader.ReadInfo(result.Normal); + var final = BrstmReader.ReadInfo(result.FinalLap!); + + var ratio = (normal.SampleCount - normal.LoopStart) / (double)(final.SampleCount - final.LoopStart); + Assert.InRange(ratio, speed - 0.01, speed + 0.01); + Assert.True(final.Looping); + } + + [Fact] + public void Converter_JinglesPlayOnce() + { + var result = MusicConverter.Convert(Song(48000, seconds: 4), new ConversionOptions { Loop = false }); + + Assert.False(BrstmReader.ReadInfo(result.Normal).Looping); + Assert.Null(result.FinalLap); + } + + [Fact] + public void Converter_KeepsLoopPointsFromTheFile() + { + var song = Song(32000, seconds: 10); + song.Loop = new LoopRegion(32000 * 3, 32000 * 9); + + var result = MusicConverter.Convert(song, new ConversionOptions()); + var info = BrstmReader.ReadInfo(result.Normal); + + Assert.True(result.Report.Loop == LoopKind.FromFile); + // The loop start moves forward onto a block boundary and the end moves with it: same loop length. + Assert.Equal(32000 * 6, info.SampleCount - info.LoopStart); + } + + [Theory] + [InlineData(0)] + [InlineData(0x40)] + [InlineData(0xFF)] + public void Converter_WritesTheRequestedVolumeByteToBothFiles(int volume) + { + var result = MusicConverter.Convert(Song(32000, seconds: 8), new ConversionOptions { CreateFinalLap = true, PulsarVolume = (byte)volume }); + + var expected = (byte)Math.Min(volume, 0x7F); // Retro Rewind ignores anything above 0x7F + Assert.Equal(expected, BrstmReader.ReadInfo(result.Normal).PulsarVolume); + Assert.Equal(expected, BrstmReader.ReadInfo(result.FinalLap!).PulsarVolume); + } + + [Fact] + public void Converter_VolumeOffsetMakesTheSongLouder() + { + var song = Song(32000, seconds: 10); + var plain = MusicConverter.Convert(song, new ConversionOptions { TargetLufs = -20 }); + var louder = MusicConverter.Convert(song, new ConversionOptions { TargetLufs = -20, VolumeOffsetDb = 4 }); + + var difference = LoudnessMeter.Measure(BrstmReader.Decode(louder.Normal)).IntegratedLufs - LoudnessMeter.Measure(BrstmReader.Decode(plain.Normal)).IntegratedLufs; + Assert.InRange(difference, 3.6, 4.4); + } + + [Fact] + public void Converter_RaisesTheVolumeByteBeforeLimiting() + { + // Asking for more level than the peaks allow: with a volume byte to work with, the byte goes up instead + // of the limiter squashing the song. + var withByte = MusicConverter.Convert(Song(32000, seconds: 10), new ConversionOptions { TargetLufs = -8, PulsarVolume = 0x40 }); + var withoutByte = MusicConverter.Convert(Song(32000, seconds: 10), new ConversionOptions { TargetLufs = -8 }); + + Assert.True(withByte.Report.PulsarVolume > 0x40); + Assert.Equal(withByte.Report.PulsarVolume, BrstmReader.ReadInfo(withByte.Normal).PulsarVolume); + Assert.True(withByte.Report.LimiterReductionDb < withoutByte.Report.LimiterReductionDb); + Assert.Equal(0, withoutByte.Report.PulsarVolume); + // In game: file loudness plus the byte's gain lands on the target (-8 LUFS at byte 0x40). + var inGame = withByte.Report.OutputLufs + MusicLoudness.VolumeByteDb(withByte.Report.PulsarVolume) - MusicLoudness.VolumeByteDb(0x40); + Assert.InRange(inGame, -8.8, -7.2); + } + + [Fact] + public void Converter_ReportsTheLoudnessItReached() + { + var result = MusicConverter.Convert(Song(32000, seconds: 10), new ConversionOptions { TargetLufs = -18 }); + + Assert.InRange(result.Report.OutputLufs, -18.6, -17.4); + var decoded = LoudnessMeter.Measure(BrstmReader.Decode(result.Normal)).IntegratedLufs; + Assert.True(Math.Abs(result.Report.OutputLufs - decoded) < 0.2, $"report {result.Report.OutputLufs:0.00} vs file {decoded:0.00}"); + } + + [Fact] + public void Converter_IgnoresLoopPointsShorterThanASecond() + { + var song = Song(32000, seconds: 10); + song.Loop = new LoopRegion(32000 * 10 - 500, 32000 * 10); // would buzz on a 15 ms fragment + + var result = MusicConverter.Convert(song, new ConversionOptions()); + + Assert.False(result.Report.Loop == LoopKind.FromFile); + var info = BrstmReader.ReadInfo(result.Normal); + Assert.True(info.SampleCount - info.LoopStart > 32000 * 5); + } + + [Fact] + public void Converter_SurvivesDamagedSamples() + { + var song = Song(32000, seconds: 5); + song.Channels[0][1000] = float.NaN; + song.Channels[1][2000] = float.PositiveInfinity; + + var result = MusicConverter.Convert(song, new ConversionOptions { TargetLufs = -18 }); + + Assert.InRange(result.Report.OutputLufs, -18.6, -17.4); + } + + [Fact] + public void Converter_RejectsSongsTooShortToLoop() => + Assert.Throws(() => MusicConverter.Convert(Song(32000, seconds: 0.5), new ConversionOptions())); + + [Fact] + public void Converter_ConvertsShortJingles() + { + var result = MusicConverter.Convert(Song(32000, seconds: 0.3), new ConversionOptions { Loop = false, TargetLufs = -18 }); + + Assert.False(BrstmReader.ReadInfo(result.Normal).Looping); + Assert.InRange(result.Report.OutputLufs, -19, -17); + } + + [Fact] + public void LoudnessMeter_MeasuresClipsShorterThanOneBlock() + { + // 200 ms is shorter than BS.1770's 400 ms block; the ungated mean is used instead of "silent". + var tone = Tone(48000 / 5, 48000, [(1000, 0.1)]); + + var result = LoudnessMeter.Measure([tone, tone], 48000); + + Assert.InRange(result.IntegratedLufs, -20.3, -19.7); + } + + [Fact] + public void PeakLimiter_HoldsPeaksUnderTheCeilingAndLeavesQuietPassagesAlone() + { + var length = 32000 * 3; + var left = new float[length]; + for (var i = 0; i < length; i++) + left[i] = (float)(0.2 * Math.Sin(2 * Math.PI * 440 * i / 32000)); + for (var i = 40000; i < 40100; i++) + left[i] = 1.5f; // a burst well over full scale + var buffer = new AudioBuffer([left, (float[])left.Clone()], 32000); + + var reduction = PeakLimiter.Apply(buffer, ceilingDb: -1); + + var ceiling = (float)Math.Pow(10, -1 / 20.0); + Assert.All(buffer.Channels, channel => Assert.True(channel.Max(sample => Math.Abs(sample)) <= ceiling + 1e-4f)); + Assert.InRange(reduction, 4.0, 5.0); // 1.5 down to 0.891 + // Far from the burst the signal is untouched. + Assert.Equal(0.2 * Math.Sin(2 * Math.PI * 440 * 10 / 32000), buffer.Channels[0][10], 5); + Assert.Equal(0.2 * Math.Sin(2 * Math.PI * 440 * 90000 / 32000), buffer.Channels[0][90000], 4); + } + + [Fact] + public void FfmpegMetadata_IsParsedWithEscapesAndStreamFallback() + { + var metadata = new AudioMetadata { Artist = "Already known" }; + + FfmpegReader.ApplyFfmetadata(";FFMETADATA1\ntitle=A \\= B\\;C\nARTIST=Ignored\n[STREAM]\ntitle=Stream title\nalbum=Stream Album\n", metadata); + + Assert.Equal("A = B;C", metadata.Title); + Assert.Equal("Already known", metadata.Artist); + Assert.Equal("Stream Album", metadata.Album); + } + + [Fact] + public void FolderArt_IsOnlyBorrowedByAlbumTracks() + { + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + fs.Directory.CreateDirectory("/music"); + fs.File.WriteAllBytes("/music/cover.jpg", [0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4, 5, 6]); + fs.File.WriteAllBytes("/music/My Song.png", [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0]); + + Assert.Null(FolderArtFinder.Find(fs, "/music/Download.webm", allowAlbumArt: false)); + Assert.Equal("image/jpeg", FolderArtFinder.Find(fs, "/music/Track 01.mp3", allowAlbumArt: true)?.MimeType); + Assert.Equal("image/png", FolderArtFinder.Find(fs, "/music/My Song.webm", allowAlbumArt: false)?.MimeType); + } + + [Theory] + [InlineData(@"C:\Music\Song.MP3", "Song.MP3", "Song", ".mp3")] + [InlineData("/home/me/my.song.flac", "my.song.flac", "my.song", ".flac")] + [InlineData("noextension", "noextension", "noextension", "")] + [InlineData("/music/.hidden", ".hidden", ".hidden", ".hidden")] + public void PathText_SplitsFileNamesOnEveryPlatform(string path, string name, string baseName, string extension) + { + Assert.Equal(name, PathText.FileName(path)); + Assert.Equal(baseName, PathText.FileNameWithoutExtension(path)); + Assert.Equal(extension, PathText.Extension(path)); + } + + [Theory] + [InlineData("rMC1", true)] + [InlineData("3DS Toad Circuit", true)] + [InlineData("a/b", false)] + [InlineData("what?", false)] + [InlineData("trailing.", false)] + [InlineData("..", false)] + [InlineData("", false)] + public void PathText_KnowsValidFileNames(string name, bool valid) => Assert.Equal(valid, PathText.IsValidFileName(name)); + + [Fact] + public void LoopFinder_FindsTheRepeatAndSplicesItWithoutAJump() + { + // Intro, then verse and chorus twice, then an outro: the loop should jump from the second verse-chorus + // back to the first, exactly one cycle (32 s) long. + var rate = 32000; + float[] song = [.. Section(1, 6), .. Section(2, 16), .. Section(3, 16), .. Section(2, 16), .. Section(3, 16), .. Section(4, 8)]; + var audio = new AudioBuffer([song, (float[])song.Clone()], rate); + + var loop = LoopFinder.Find(audio); + + Assert.NotNull(loop); + Assert.InRange((loop.Value.End - loop.Value.Start) / (double)rate, 31.99, 32.01); + Assert.True(loop.Value.Similarity > LoopFinder.MinSimilarity); + var looped = LoopFinder.Apply(audio, loop.Value); + var channel = looped.Channels[0]; + Assert.Equal(new LoopRegion(loop.Value.Start, loop.Value.End), looped.Loop); + Assert.True(Math.Abs(channel[^1] - channel[loop.Value.Start - 1]) < 0.01, "the last sample leads into the loop start"); + + float[] Section(int id, double seconds) + { + var random = new Random(id); + var notes = Enumerable.Range(0, 32).Select(_ => 110 * Math.Pow(2, random.Next(24) / 12.0)).ToArray(); + return Enumerable + .Range(0, (int)(seconds * rate)) + .Select(i => + { + var t = i / (double)rate; + var note = notes[(int)(t * 2) % notes.Length]; + return (float)(0.25 * Math.Exp(-(t % 0.5) * 4) * Math.Sin(2 * Math.PI * note * t)); + }) + .ToArray(); + } + } + + [Fact] + public void LoopFinder_GivesUpOnMusicThatNeverRepeats() + { + var random = new Random(3); + var noise = Enumerable.Range(0, 32000 * 40).Select(i => (float)((random.NextDouble() - 0.5) * (0.2 + 0.15 * Math.Sin(i / 9000.0)) * Math.Sin(i / (200.0 + i / 20000.0)))).ToArray(); + + Assert.Null(LoopFinder.Find(new AudioBuffer([noise], 32000))); + } + + [Theory] + [InlineData("SNES Rainbow Road", "rainbow road")] + [InlineData("Rainbow Road", "rainbow road")] + [InlineData("Wii U Rainbow Road v2", "rainbow road")] + [InlineData("3DS Rainbow Road (r3RR)", "rainbow road")] + [InlineData("Luigi Circuit / Mario Circuit", "luigi circuit")] + [InlineData("N64 Luigi Raceway (Night)", "luigi raceway")] + [InlineData("DS", "ds")] + public void TrackNames_GroupTheSameCourseAcrossConsoles(string name, string family) => Assert.Equal(family, TrackNames.FamilyKey(name)); + + [Fact] + public void Converter_RejectsSilence() => + Assert.Throws(() => MusicConverter.Convert(new AudioBuffer([new float[96000], new float[96000]], 48000), new ConversionOptions())); + + [Fact] + public void WavReader_ReadsSamplesAndSmplLoop() + { + var samples = new short[] { 0, 1000, -1000, short.MaxValue, short.MinValue, 42 }; + var wav = BuildWav(samples, loopStart: 1, loopEndInclusive: 4); + + Assert.True(WavReader.IsWav(wav)); + var audio = WavReader.Read(wav); + + Assert.Equal(44100, audio.SampleRate); + Assert.Equal(samples.Length, audio.SampleCount); + Assert.Equal(1000 / 32768f, audio.Channels[0][1], 5); + Assert.Equal(new LoopRegion(1, 5), audio.Loop); + } + + [Fact] + public void Id3v2Reader_ReadsTitleArtistAndFrontCover() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3, 4, 5, 6 }; + var tag = BuildId3([TextFrame("TIT2", "Rainbow Road"), TextFrame("TPE1", "Wheel Wizard"), PictureFrame(jpeg)]); + + var metadata = Id3v2Reader.Read(tag); + + Assert.Equal(tag.Length, Id3v2Reader.GetTagSize(tag)); + Assert.Equal("Rainbow Road", metadata.Title); + Assert.Equal("Wheel Wizard", metadata.Artist); + Assert.Equal("image/jpeg", metadata.Cover?.MimeType); + Assert.Equal(jpeg, metadata.Cover?.Data); + } + + [Fact] + public void VorbisComments_ReadTagsAndGameLoopPoints() + { + var metadata = VorbisComments.FromComments(["TITLE=Coconut Mall", "artist=Someone", "LOOPSTART=44100", "LOOPLENGTH=88200"]); + + Assert.Equal("Coconut Mall", metadata.Title); + Assert.Equal("Someone", metadata.Artist); + Assert.Equal(new LoopRegion(44100, 132300), metadata.ResolveLoopTags(500_000)); + } + + [Fact] + public void CoverArt_IsRecognisedByItsSignature() + { + Assert.Equal("image/png", CoverArt.FromBytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0])?.MimeType); + Assert.Null(CoverArt.FromBytes([1, 2, 3, 4, 5, 6, 7, 8, 9], "application/octet-stream")); + } + + #region Helpers + + private static float[] Tone(int length, int rate, (double Frequency, double Amplitude)[] partials) + { + var output = new float[length]; + for (var i = 0; i < length; i++) + { + double value = 0; + foreach (var (frequency, amplitude) in partials) + value += amplitude * Math.Sin(2 * Math.PI * frequency * i / rate); + output[i] = (float)value; + } + + return output; + } + + /// A few seconds of chords, a bass pulse and noise bursts: enough structure to look like music. + private static AudioBuffer Song(int rate, double seconds) + { + var random = new Random(1234); + var length = (int)(rate * seconds); + var left = new float[length]; + var right = new float[length]; + double[] roots = [220, 174.61, 261.63, 196]; + for (var i = 0; i < length; i++) + { + var t = i / (double)rate; + var root = roots[(int)(t / 2) % roots.Length]; + var envelope = Math.Exp(-(t % 2) * 0.8); + var chord = 0.12 * envelope * (Math.Sin(2 * Math.PI * root * t) + Math.Sin(2 * Math.PI * root * 1.26 * t) + Math.Sin(2 * Math.PI * root * 1.5 * t)); + var bass = t % 0.5 < 0.2 ? 0.25 * Math.Sin(2 * Math.PI * 55 * t) : 0; + var noise = t % 0.25 < 0.03 ? (random.NextDouble() - 0.5) * 0.1 : 0; + left[i] = (float)(chord + bass + noise); + right[i] = (float)(chord * 0.9 + bass + noise); + } + + return new AudioBuffer([left, right], rate); + } + + private static short[] ToPcm16(float[] samples) => samples.Select(s => (short)Math.Clamp(Math.Round(s * 32767), short.MinValue, short.MaxValue)).ToArray(); + + private static double Snr(short[] reference, short[] test) + { + double signal = 0, + noise = 0; + for (var i = 0; i < reference.Length; i++) + { + signal += (double)reference[i] * reference[i]; + noise += Math.Pow(reference[i] - test[i], 2); + } + + return 10 * Math.Log10(signal / Math.Max(noise, 1)); + } + + private static double Rms(float[] samples, int skip) => Math.Sqrt(samples.Skip(skip).Take(samples.Length - 2 * skip).Average(s => (double)s * s)); + + private static byte[] BuildWav(short[] samples, int loopStart, int loopEndInclusive) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write("RIFF"u8); + writer.Write(0); + writer.Write("WAVE"u8); + writer.Write("fmt "u8); + writer.Write(16); + writer.Write((short)1); + writer.Write((short)1); + writer.Write(44100); + writer.Write(44100 * 2); + writer.Write((short)2); + writer.Write((short)16); + writer.Write("data"u8); + writer.Write(samples.Length * 2); + foreach (var sample in samples) + writer.Write(sample); + writer.Write("smpl"u8); + writer.Write(36 + 24); + writer.Write(new byte[28]); + writer.Write(1); // loop count + writer.Write(0); // sampler data + writer.Write(0); // cue id + writer.Write(0); // loop type + writer.Write(loopStart); + writer.Write(loopEndInclusive); + writer.Write(0); + writer.Write(0); + writer.Flush(); + var bytes = stream.ToArray(); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(4), bytes.Length - 8); + return bytes; + } + + private static byte[] TextFrame(string id, string text) => Frame(id, [3, .. Encoding.UTF8.GetBytes(text)]); + + private static byte[] PictureFrame(byte[] image) => Frame("APIC", [0, .. "image/jpeg"u8, 0, 3, 0, .. image]); + + private static byte[] Frame(string id, byte[] body) + { + var frame = new byte[10 + body.Length]; + Encoding.ASCII.GetBytes(id, frame.AsSpan(0, 4)); + BinaryPrimitives.WriteInt32BigEndian(frame.AsSpan(4), body.Length); // ID3v2.3 sizes are plain big endian + body.CopyTo(frame, 10); + return frame; + } + + private static byte[] BuildId3(byte[][] frames) + { + var body = frames.SelectMany(frame => frame).ToArray(); + var size = body.Length; + byte[] header = [(byte)'I', (byte)'D', (byte)'3', 3, 0, 0, (byte)((size >> 21) & 0x7F), (byte)((size >> 14) & 0x7F), (byte)((size >> 7) & 0x7F), (byte)(size & 0x7F)]; + return [.. header, .. body]; + } + + #endregion +} diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicCatalogTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicCatalogTests.cs new file mode 100644 index 00000000..47a90394 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicCatalogTests.cs @@ -0,0 +1,132 @@ +using System.Buffers.Binary; +using System.Text; +using WheelWizard.CustomMusic.Catalog; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicCatalogTests +{ + [Fact] + public void GameCatalog_ListsEveryCourseArenaMenuAndJingleOnce() + { + var slots = GameMusicCatalog.Build(); + + Assert.Equal(slots.Count, slots.Select(slot => slot.Key).Distinct().Count()); + // 32 courses where Luigi Circuit and Mario Circuit share music, plus Galaxy Colosseum. + Assert.Equal(32, slots.Count(slot => slot.Category == MusicCategory.Course)); + Assert.Equal(10, slots.Count(slot => slot.Category == MusicCategory.Arena)); + Assert.All(slots.Where(slot => slot.Category is MusicCategory.Course or MusicCategory.Arena && slot.CourseId != GameMusicCatalog.GalaxyColosseumCourseId), slot => Assert.True(slot.HasFinalLap)); + Assert.Equal(slots.OrderBy(slot => slot.Order).Select(slot => slot.Key), slots.Select(slot => slot.Key)); + } + + [Fact] + public void GameCatalog_UsesTheGamesExactFileNames() + { + var slots = GameMusicCatalog.Build().ToDictionary(slot => slot.Key); + + var luigi = slots["game:n_circuit32"]; + Assert.Equal("Luigi Circuit / Mario Circuit", luigi.Name); + Assert.Equal(["n_Circuit32_n.brstm"], luigi.FileNames); + Assert.Equal(["n_Circuit32_f.brstm"], luigi.FinalLapFileNames); + Assert.Equal(["n_Kinoko_F.brstm"], slots["game:n_kinoko"].FinalLapFileNames); + + var waiting = slots["game:o_wi-fi_waiting32"]; + Assert.Equal(MusicCategory.Menu, waiting.Category); + Assert.Equal(["o_Wi-Fi_waiting32.brstm"], waiting.FileNames); + Assert.False(waiting.HasFinalLap); + + Assert.False(slots["game:o_start32_fan"].Loops); + Assert.Equal(["wifi_lobby_bg.brstm"], slots["menu:wifi_lobby_bg"].FileNames); + } + + [Theory] + [InlineData(0x08, "game:n_circuit32")] + [InlineData(0x00, "game:n_circuit32")] + [InlineData(0x02, "game:n_kinoko")] + [InlineData(GameMusicCatalog.GalaxyColosseumCourseId, "game:n_bossmi_32")] + [InlineData(0x7F, null)] + public void GameCatalog_FindsTheMusicOfACourseId(int courseId, string? key) => Assert.Equal(key, GameMusicCatalog.KeyForCourseId(courseId)); + + [Theory] + [InlineData("n_Circuit32_n", "n_Circuit32")] + [InlineData("STRM_N_KOOPA_F", "STRM_N_KOOPA")] + [InlineData("title_bg", "title_bg")] + public void GameCatalog_StripsLapSuffixes(string name, string expected) => Assert.Equal(expected, GameMusicCatalog.StripLapSuffix(name)); + + [Fact] + public void PulsarConfigReader_ReadsCupsTracksVariantsAndFileNames() + { + var config = new PulsarConfigBuilder() + .Cup("SNES Cup") + .Track(0x08, 0x18, "SNES Mario Circuit 1", version: "v2") + .Track(0x01, 0x08, "SNES Donut Plains 1") + .Track(0x02, 0x1A, "SNES Ghost Valley 1", variants: [(0x02, 0x1A, "SNES Ghost Valley 1 (Night)")]) + .Track(0x04, 0x0F, "SNES Bowser Castle 1") + .Cup("N64 Cup") // an odd cup count is stored rounded up to an even number + .Track(0x00, 0x1C, "N64 Luigi Raceway") + .Track(0x05, 0x05, "N64 Moo Moo Farm") + .Track(0x06, 0x06, "N64 Koopa Troopa Beach") + .Track(0x07, 0x07, "N64 Kalimari Desert") + .Cup("GBA Cup") + .Track(0x08, 0x08, "GBA Peach Circuit") + .Track(0x09, 0x09, "GBA Riverside Park") + .Track(0x0A, 0x0A, "GBA Cheese Land") + .Track(0x0B, 0x0B, "GBA Sky Garden") + .File(0, "rMC1") + .File(2, "rGV1") + .File((1 << 12) + 2, "rGV1Night") + .File(4, "nLR") + .Build(); + + var result = PulsarConfigReader.Read(config); + + Assert.Equal(["SNES Cup", "N64 Cup", "GBA Cup"], result.CupNames); + Assert.Equal(12, result.Tracks.Count); + + var mc1 = result.Tracks[0]; + Assert.Equal("SNES Mario Circuit 1 v2", mc1.Name); // text around colour codes is kept, the codes themselves are dropped + Assert.Equal("rMC1", mc1.FileName); + Assert.Equal(0x18, mc1.MusicSlot); + Assert.Null(result.Tracks[1].FileName); + + var ghostValley = result.Tracks[2]; + Assert.Equal("SNES Ghost Valley 1 (all versions)", ghostValley.Name); + var night = Assert.Single(ghostValley.Variants); + Assert.Equal("SNES Ghost Valley 1 (Night)", night.Name); + Assert.Equal("rGV1Night", night.FileName); + Assert.Equal(0x1A, night.MusicSlot); + + Assert.Equal(1, result.Tracks[4].CupIndex); + Assert.Equal("GBA Sky Garden", result.Tracks[11].Name); + Assert.Empty(result.Tracks[11].Variants); + } + + [Fact] + public void PulsarConfigReader_KeepsNamesThatAreSplitByColourCodes() + { + var config = new PulsarConfigBuilder() + .Cup("3DS Cup") + .Track(0x08, 0x08, "\u00013DS\u0001 Toad Circuit") + .Track(0x01, 0x01, "\u00013DS\u0001 Rainbow Road") + .Track(0x02, 0x02, "3DS Music Park") + .Track(0x03, 0x03, "3DS Piranha Plant Slide") + .Build(); + + var names = PulsarConfigReader.Read(config).Tracks.Select(track => track.Name); + + Assert.Equal(["3DS Toad Circuit", "3DS Rainbow Road", "3DS Music Park", "3DS Piranha Plant Slide"], names); + } + + [Fact] + public void PulsarConfigReader_RejectsOtherFiles() => Assert.Throws(() => PulsarConfigReader.Read(Encoding.ASCII.GetBytes("RSTM and some more bytes here...."))); + + [Fact] + public void PulsarConfigReader_FileSectionKeepsOnlyTheFileName() + { + var files = PulsarConfigReader.ReadFileSection(Encoding.UTF8.GetBytes("FILE\n1A=uMKS|expert1|expert2\n1001A=uMKSv2|a\nbroken line\n0?cupicon\n")); + + Assert.Equal("uMKS", files[0x1A]); + Assert.Equal("uMKSv2", files[0x1001A]); + Assert.Equal(2, files.Count); + } +} diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicInstallerTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicInstallerTests.cs new file mode 100644 index 00000000..c84cfa82 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicInstallerTests.cs @@ -0,0 +1,153 @@ +using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; +using WheelWizard.Models.Mods; +using WheelWizard.Mods; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicInstallerTests +{ + private readonly MockFileSystem _fs = new(); + private readonly IModPaths _paths; + private readonly IModManager _mods = Substitute.For(); + private readonly ObservableCollection _modList = []; + private readonly CustomMusicInstaller _installer; + + public CustomMusicInstallerTests() + { + var location = Substitute.For(); + location.DirectoryPath.Returns(Path.Combine(Path.GetTempPath(), "custom-music-tests", Guid.NewGuid().ToString("N"))); + _paths = new ModPaths(location, _fs); + _fs.Directory.CreateDirectory(_paths.RootFolderPath); + + _mods.Mods.Returns(_modList); + _mods.ReloadAsync().Returns(Ok()); + _mods.SaveModsAsync().Returns(Ok()); + _mods.GetLowestActivePriority().Returns(_ => _modList.Count == 0 ? 0 : _modList.Min(mod => mod.Priority)); + _mods.AddModAsync(Arg.Any()).Returns(call => + { + _modList.Add(call.Arg()); + return Ok(); + }); + _mods.DeleteModAsync(Arg.Any()).Returns(call => + { + _modList.Remove(call.Arg()); + return Ok(); + }); + + _installer = new CustomMusicInstaller(_mods, _paths, _fs, NullLogger.Instance); + } + + private string ModFolder => _paths.GetModDirectoryPath(CustomMusicInstaller.ModTitle); + + [Fact] + public async Task Apply_CreatesAnEnabledTopPriorityPatchWithEveryFileTheGameLooksFor() + { + _modList.Add(new Mod { Title = "Music Pack", Priority = 3, IsEnabled = true }); + var slots = GameMusicCatalog.Build().ToDictionary(slot => slot.Key); + var luigi = Install(slots["game:n_circuit32"], "normal", "final"); + var waiting = Install(slots["game:o_wi-fi_waiting32"], "waiting", null); + + var result = await _installer.ApplyAsync([luigi, waiting]); + + Assert.True(result.IsSuccess); + var mod = Assert.Single(_modList, m => m.Title == CustomMusicInstaller.ModTitle); + Assert.True(mod.IsEnabled); + Assert.Equal(2, mod.Priority); // beats the music pack (lower number wins) + Assert.Equal(CustomMusicModState.Enabled, _installer.GetState()); + Assert.Equal("normal", Read("n_Circuit32_n.brstm")); + Assert.Equal("final", Read("n_Circuit32_f.brstm")); + Assert.Equal("waiting", Read("o_Wi-Fi_waiting32.brstm")); + } + + [Fact] + public async Task Apply_UsesTheNormalSongForTheFinalLapWhenThereIsNoFinalLapVersion() + { + var slot = GameMusicCatalog.Build().First(s => s.Key == "game:n_kinoko"); + + await _installer.ApplyAsync([Install(slot, "normal", null)]); + + Assert.Equal("normal", Read("n_Kinoko_F.brstm")); + } + + [Fact] + public async Task Apply_RemovesMusicThatWasResetAndKeepsThePatchInfo() + { + var slots = GameMusicCatalog.Build().ToDictionary(slot => slot.Key); + await _installer.ApplyAsync([Install(slots["game:n_circuit32"], "a", "b"), Install(slots["game:n_farm"], "c", "d")]); + _fs.File.WriteAllText(_fs.Path.Combine(ModFolder, "Custom Music.ini"), "[Mod]"); + + await _installer.ApplyAsync([Install(slots["game:n_farm"], "c", "d")]); + + Assert.False(_fs.File.Exists(_fs.Path.Combine(ModFolder, "n_Circuit32_n.brstm"))); + Assert.True(_fs.File.Exists(_fs.Path.Combine(ModFolder, "n_Farm_n.brstm"))); + Assert.True(_fs.File.Exists(_fs.Path.Combine(ModFolder, "Custom Music.ini"))); + } + + [Fact] + public async Task Apply_DeletesThePatchWhenNoMusicIsLeft() + { + var slot = GameMusicCatalog.Build()[0]; + await _installer.ApplyAsync([Install(slot, "a", "b")]); + + Assert.True((await _installer.ApplyAsync([])).IsSuccess); + + Assert.DoesNotContain(_modList, mod => mod.Title == CustomMusicInstaller.ModTitle); + Assert.Equal(CustomMusicModState.NotCreated, _installer.GetState()); + } + + [Fact] + public async Task Apply_TurnsADisabledPatchBackOn() + { + var mod = new Mod { Title = CustomMusicInstaller.ModTitle, Priority = 0, IsEnabled = false }; + _modList.Add(mod); + Assert.Equal(CustomMusicModState.Disabled, _installer.GetState()); + + await _installer.ApplyAsync([Install(GameMusicCatalog.Build()[0], "a", "b")]); + + Assert.True(mod.IsEnabled); + await _mods.Received(1).SaveModsAsync(); + } + + [Fact] + public async Task FindConflicts_ReportsHigherPriorityPatchesWithTheSameMusicFile() + { + await _installer.ApplyAsync([Install(GameMusicCatalog.Build()[0], "ours", "ours")]); + var ours = _modList.Single(); + ours.Priority = 5; + _modList.Add(new Mod { Title = "Winner", Priority = 1, IsEnabled = true }); + _modList.Add(new Mod { Title = "Loser", Priority = 9, IsEnabled = true }); + foreach (var title in new[] { "Winner", "Loser" }) + { + var folder = _fs.Path.Combine(_paths.GetModDirectoryPath(title), "strm"); + _fs.Directory.CreateDirectory(folder); + _fs.File.WriteAllText(_fs.Path.Combine(folder, "N_CIRCUIT32_N.brstm"), title); + } + + var conflict = Assert.Single(_installer.FindConflicts()); + + Assert.Equal("Winner", conflict.ModTitle); + } + + private InstalledMusic Install(MusicSlot slot, string normal, string? final) + { + var folder = _fs.Path.Combine(Path.GetTempPath(), "converted", Guid.NewGuid().ToString("N")); + _fs.Directory.CreateDirectory(folder); + var normalPath = _fs.Path.Combine(folder, "normal.brstm"); + _fs.File.WriteAllText(normalPath, normal); + string? finalPath = null; + if (final != null) + { + finalPath = _fs.Path.Combine(folder, "final.brstm"); + _fs.File.WriteAllText(finalPath, final); + } + + return new InstalledMusic(slot, normalPath, finalPath); + } + + private string Read(string fileName) => _fs.File.ReadAllText(_fs.Path.Combine(ModFolder, fileName)); +} diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicLibraryTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicLibraryTests.cs new file mode 100644 index 00000000..cc0533a0 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicLibraryTests.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Library; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicLibraryTests +{ + private readonly MockFileSystem _fs = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + private readonly CustomMusicLibrary _library; + + public CustomMusicLibraryTests() + { + var location = Substitute.For(); + location.DirectoryPath.Returns("/appdata"); + _library = new CustomMusicLibrary(_fs, location, NullLogger.Instance); + } + + [Fact] + public void SavedEntriesComeBack_AndEditedOrBrokenOnesAreDropped() + { + var (good, goodFolder) = _library.CreateStorage(); + _fs.File.WriteAllText(_fs.Path.Combine(goodFolder, "normal.brstm"), "x"); + var (escape, escapeFolder) = _library.CreateStorage(); + _fs.File.WriteAllText(_fs.Path.Combine(escapeFolder, "normal.brstm"), "x"); + var data = new CustomMusicLibraryData + { + VolumeOffsetDb = 3, + Entries = + [ + Entry("game:a", good), + Entry("game:b", escape) with { NormalFile = "../../../outside.brstm" }, + Entry("game:c", "../../etc"), + Entry("game:d", Guid.NewGuid().ToString("N")), // converted files are gone + ], + }; + + Assert.True(_library.Save(data).IsSuccess); + var loaded = _library.Load(); + + Assert.Equal(["game:a"], loaded.Entries.Select(entry => entry.Key)); + Assert.Equal(3, loaded.VolumeOffsetDb); + } + + [Fact] + public void UnusedConversionsAreCleanedUp() + { + var (keep, keepFolder) = _library.CreateStorage(); + var (_, dropFolder) = _library.CreateStorage(); + + _library.DeleteUnused([Entry("game:a", keep)]); + + Assert.True(_fs.Directory.Exists(keepFolder)); + Assert.False(_fs.Directory.Exists(dropFolder)); + } + + [Fact] + public void MusicPacksAreExtractedFlat_AndRemovedWhenNoSongUsesThem() + { + _fs.Directory.CreateDirectory("/downloads"); + using (var zip = new System.IO.Compression.ZipArchive(_fs.File.Create("/downloads/My Pack.zip"), System.IO.Compression.ZipArchiveMode.Create)) + { + foreach (var name in new[] { "sound/strm/n_Circuit32_n.brstm", "sound/strm/n_Circuit32_f.brstm", "../evil.mp3", "readme.txt", "covers/art.png" }) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open()); + writer.Write("data"); + } + } + + var extracted = _library.ExtractPack("/downloads/My Pack.zip"); + + Assert.True(extracted.IsSuccess); + Assert.Equal(["evil.mp3", "n_Circuit32_f.brstm", "n_Circuit32_n.brstm"], extracted.Value.Select(path => _fs.Path.GetFileName(path)).Order()); + var folder = _fs.Path.GetDirectoryName(extracted.Value[0])!; + Assert.StartsWith("/appdata/CustomMusic/Packs/My Pack-", folder); + + var (storage, storageFolder) = _library.CreateStorage(); + _library.DeleteUnused([Entry("game:a", storage) with { SourcePath = extracted.Value[0] }]); + Assert.True(_fs.Directory.Exists(folder)); + _library.DeleteUnused([]); + Assert.False(_fs.Directory.Exists(folder)); + Assert.False(_fs.Directory.Exists(storageFolder)); + } + + [Theory] + [InlineData(-18.0, 0, -18.0, 0, 0.0)] // matched, no volume bytes + [InlineData(-12.0, 0, -18.0, 0, 6.0)] // louder file + [InlineData(-18.0, 0x7F, -18.0, 0x40, 5.95)] // same file level, byte raised from 0x40 to full + public void LoudnessVersusOriginal_CountsFileLevelAndVolumeBytes(double output, int volume, double reference, int referenceVolume, double expected) + { + var entry = Entry("game:a", "abc") with + { + OutputLufs = output, + PulsarVolume = (byte)volume, + ReferenceLufs = reference, + ReferenceVolume = (byte)referenceVolume, + }; + + Assert.Equal(expected, entry.LoudnessVersusOriginalDb!.Value, 1); + } + + [Fact] + public void LoudnessVersusOriginal_IsUnknownForOlderEntries() => Assert.Null(Entry("game:a", "abc").LoudnessVersusOriginalDb); + + private static CustomMusicEntry Entry(string key, string storageId) => + new() + { + Key = key, + StorageId = storageId, + SourcePath = "/music/song.mp3", + FinalLapStyle = FinalLapStyle.Faster, + }; +} diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicPlaybackTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicPlaybackTests.cs new file mode 100644 index 00000000..a421553c --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicPlaybackTests.cs @@ -0,0 +1,125 @@ +using System.Buffers.Binary; +using System.Text; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Playback; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicPlaybackTests +{ + [Fact] + public void PreviewClip_BuildsAStereoWavFromAnyPosition() + { + var clip = new PreviewClip([.. Enumerable.Range(0, 3200).Select(i => (short)i)], 1600); // 1600 frames = 1 second + + var wav = clip.ToWav(TimeSpan.FromSeconds(0.5)); + + Assert.Equal("RIFF", Encoding.ASCII.GetString(wav, 0, 4)); + Assert.Equal("WAVE", Encoding.ASCII.GetString(wav, 8, 4)); + Assert.Equal(2, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22))); + Assert.Equal(1600, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(24))); + Assert.Equal(800 * 4, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(40))); + Assert.Equal(44 + 800 * 4, wav.Length); + Assert.Equal(1600, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(44))); // first sample of frame 800 + Assert.Equal(1.0, clip.Duration.TotalSeconds, 3); + } + + [Fact] + public void PreviewClip_PlaysABrstmWithTheVolumeByteApplied() + { + var tone = Enumerable.Range(0, 32000).Select(i => (short)(8000 * Math.Sin(2 * Math.PI * 440 * i / 32000))).ToArray(); + var quiet = BrstmWriter.Write(new BrstmStream { Channels = [tone, tone], PulsarVolume = 0x40 }); + var full = BrstmWriter.Write(new BrstmStream { Channels = [tone, tone] }); + + var quietClip = PreviewClip.FromBrstm(quiet); + var fullClip = PreviewClip.FromBrstm(full); + + Assert.Equal(32000, quietClip.SampleRate); + Assert.InRange(quietClip.Duration.TotalSeconds, 0.99, 1.01); + // 0x40 / 0x7F is about -6 dB. + Assert.InRange(Peak(quietClip) / (double)Peak(fullClip), 0.49, 0.52); + } + + [Fact] + public void Player_PlaysPausesSeeksAndStops() + { + var output = new RecordingOutput(); + using var player = new MusicPreviewPlayer(output); + var clip = new PreviewClip(new short[32000 * 2 * 10], 32000); // 10 seconds + + player.Play(clip); + Assert.Equal(PreviewState.Playing, player.State); + Assert.Equal(10, player.Duration.TotalSeconds, 3); + Assert.Single(output.Started); + + player.Seek(TimeSpan.FromSeconds(5)); + Assert.Equal(2, output.Started.Count); + Assert.InRange(output.Started[1].Length, 44 + 32000 * 4 * 5 - 4, 44 + 32000 * 4 * 5 + 4); + Assert.InRange(player.Position.TotalSeconds, 5.0, 5.5); + + player.Pause(); + var paused = player.Position; + Assert.Equal(PreviewState.Paused, player.State); + Assert.Equal(1, output.Stops); + Thread.Sleep(150); + Assert.Equal(paused, player.Position); + + player.Resume(); + Assert.Equal(PreviewState.Playing, player.State); + Assert.Equal(3, output.Started.Count); + + player.Stop(); + Assert.Equal(PreviewState.Stopped, player.State); + Assert.Equal(TimeSpan.Zero, player.Position); + } + + [Fact] + public void Player_StopsByItselfWhenTheSongEnds() + { + var output = new RecordingOutput(); + using var player = new MusicPreviewPlayer(output); + var changes = 0; + player.Changed += (_, _) => Interlocked.Increment(ref changes); + + player.Play(new PreviewClip(new short[32000 * 2 / 5], 32000)); // 0.2 seconds + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (player.State != PreviewState.Stopped && DateTime.UtcNow < deadline) + Thread.Sleep(20); + Assert.Equal(PreviewState.Stopped, player.State); + Assert.True(changes >= 2, "start and end are both reported"); + } + + [Fact] + public void Player_ReportsAnOutputThatCannotPlay() + { + var output = new RecordingOutput { Fail = true }; + using var player = new MusicPreviewPlayer(output); + + Assert.Throws(() => player.Play(new PreviewClip(new short[100], 32000))); + + Assert.Equal(PreviewState.Stopped, player.State); + } + + private static int Peak(PreviewClip clip) => clip.Samples.Max(sample => Math.Abs((int)sample)); + + private sealed class RecordingOutput : IAudioOutput + { + public List Started { get; } = []; + public int Stops { get; private set; } + public bool Fail { get; init; } + public bool IsAvailable => true; + + public void Start(byte[] wav) + { + if (Fail) + throw new IOException("no sound card"); + Started.Add(wav); + } + + public void Stop() => Stops++; + + public void Dispose() { } + } +} diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicReferenceTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicReferenceTests.cs new file mode 100644 index 00000000..65d17ef9 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicReferenceTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomDistributions; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Audio.Dsp; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared; + +namespace WheelWizard.Test.Features.CustomMusic; + +public class CustomMusicReferenceTests +{ + private const string GamePath = "/game.iso"; + + private readonly MockFileSystem _fs = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + private readonly IGameDiscMusicReader _disc = Substitute.For(); + private readonly CustomMusicLibrary _library; + private readonly MusicReferenceService _references; + + public CustomMusicReferenceTests() + { + var location = Substitute.For(); + location.DirectoryPath.Returns("/appdata"); + _library = new CustomMusicLibrary(_fs, location, NullLogger.Instance); + + var settings = Substitute.For(); + var gameLocation = new WhWzSetting("GameLocation", GamePath); + settings.GAME_LOCATION.Returns(gameLocation); + settings.Get(gameLocation).Returns(_ => (string)gameLocation.Get()); + _fs.File.WriteAllText(GamePath, "disc"); + + _references = new MusicReferenceService(_fs, _library, _disc, settings, NullLogger.Instance); + } + + [Fact] + public async Task RetroRewindFile_GivesItsOwnLoudnessAndVolumeByte() + { + _fs.Directory.CreateDirectory("/rr"); + _fs.File.WriteAllBytes("/rr/rMC1_n.brstm", Brstm(amplitude: 8000, volume: 0x50)); + var slot = Slot() with { RetroRewindFilePath = "/rr/rMC1_n.brstm" }; + + var reference = await _references.GetReferenceAsync(slot); + + Assert.Equal(MusicReferenceSource.RetroRewind, reference.Source); + Assert.Equal(0x50, reference.PulsarVolume); + Assert.True(reference.Loops ?? false); + // The file's own level: the volume byte is copied to replacements instead of being folded into the number. + var expected = LoudnessMeter.Measure(BrstmReader.Decode(Brstm(8000, 0x50))).IntegratedLufs; + Assert.Equal(expected, reference.Lufs, 2); + } + + [Fact] + public async Task MultiTrackOriginals_AreMeasuredAsTheGamePlaysThem() + { + _fs.Directory.CreateDirectory("/rr"); + var channel = Enumerable.Range(0, 32000 * 2).Select(i => (short)(6000 * Math.Sin(2 * Math.PI * 440 * i / 32000))).ToArray(); + _fs.File.WriteAllBytes("/rr/double.brstm", BrstmWriter.Write(new BrstmStream { Channels = [channel, channel, channel, channel], Looping = true })); + _fs.File.WriteAllBytes("/rr/single.brstm", BrstmWriter.Write(new BrstmStream { Channels = [channel, channel], Looping = true })); + + var single = await _references.GetReferenceAsync(Slot() with { RetroRewindFilePath = "/rr/single.brstm" }); + var race = await _references.GetReferenceAsync(Slot() with { Key = "rr:double", RetroRewindFilePath = "/rr/double.brstm" }); + var menu = await _references.GetReferenceAsync(Slot() with { Key = "menu:title_bg", RetroRewindFilePath = "/rr/double.brstm" }); + + Assert.Equal(single.Lufs, race.Lufs, 1); // only the first track is heard in a race + Assert.InRange(menu.Lufs - single.Lufs, 5.9, 6.1); // menu music plays every track at once + } + + [Fact] + public async Task OriginalOnTheDisc_IsMeasuredAndRemembered() + { + _disc.ReadStreamFile(GamePath, "n_Circuit32_n.brstm").Returns(Brstm(amplitude: 4000, volume: 0)); + var slot = Slot() with { GameFileName = "n_Circuit32_n.brstm" }; + + var first = await _references.GetReferenceAsync(slot); + var second = await _references.GetReferenceAsync(slot); + + Assert.Equal(MusicReferenceSource.GameDisc, first.Source); + Assert.Equal(0, first.PulsarVolume); + Assert.Equal(first, second); + _disc.Received(1).ReadStreamFile(GamePath, "n_Circuit32_n.brstm"); // the second answer came from the cache + } + + [Fact] + public async Task UnreadableDisc_FallsBackToAnEstimateAndSaysWhy() + { + _disc.ReadStreamFile(GamePath, "n_Circuit32_n.brstm").Returns(new OperationError { Message = "keys are missing" }); + var slot = Slot() with { GameFileName = "n_Circuit32_n.brstm" }; + + var reference = await _references.GetReferenceAsync(slot); + + Assert.Equal(MusicReferenceSource.Estimate, reference.Source); + Assert.Equal(MusicLoudness.DefaultTargetLufs, reference.Lufs); + Assert.Equal("keys are missing", reference.Problem); + } + + [Fact] + public async Task MusicWithoutAnOriginalFile_IsAnEstimateWithoutAProblem() + { + var reference = await _references.GetReferenceAsync(Slot()); + + Assert.Equal(MusicReferenceSource.Estimate, reference.Source); + Assert.Null(reference.Problem); + } + + [Fact] + public async Task SeveralTracksWithTheSameName_AreToldApartByFileName() + { + var config = new PulsarConfigBuilder() + .Cup("3DS Cup") + .Track(0x08, 0x08, "3DS") + .Track(0x01, 0x01, "3DS") + .Track(0x02, 0x02, "3DS") + .Track(0x03, 0x03, "Unique Track") + .File(0, "dsA") + .File(1, "dsB") + .File(2, "dsC") + .File(3, "uniq") + .Build(); + _fs.Directory.CreateDirectory("/wheelwizard/RetroRewind6/Binaries"); + _fs.File.WriteAllBytes("/wheelwizard/RetroRewind6/Binaries/ConfigRT.pul", config); + var paths = Substitute.For(); + paths.RootFolderPath.Returns("/wheelwizard"); + paths.RetroRewindFolderPath.Returns("/wheelwizard/RetroRewind6"); + paths.XmlFilePath.Returns("/wheelwizard/riivolution/RetroRewind6.xml"); + + var catalog = await new MusicCatalogService(_fs, paths, NullLogger.Instance).LoadAsync(); + + var names = catalog.Slots.Where(slot => slot.Category == MusicCategory.RetroRewindTrack).Select(slot => slot.Name).ToList(); + Assert.Equal(["3DS (dsA)", "3DS (dsB)", "3DS (dsC)", "Unique Track"], names.Take(4).ToList()); + Assert.All(catalog.Slots.Where(slot => slot.Category == MusicCategory.RetroRewindTrack).Take(4), slot => Assert.Contains(" · ", slot.Detail)); + } + + private static MusicSlot Slot() => + new() + { + Key = "test", + Name = "Test", + Category = MusicCategory.Course, + Group = "Test", + Order = 1, + FileNames = ["test_n.brstm"], + }; + + private static byte[] Brstm(short amplitude, byte volume) + { + var channel = Enumerable.Range(0, 32000 * 2).Select(i => (short)(amplitude * Math.Sin(2 * Math.PI * 440 * i / 32000))).ToArray(); + return BrstmWriter.Write( + new BrstmStream + { + Channels = [channel, channel], + Looping = true, + LoopStart = 0, + PulsarVolume = volume == 0 ? null : volume, + } + ); + } +} diff --git a/WheelWizard.Test/Features/CustomMusic/PulsarConfigBuilder.cs b/WheelWizard.Test/Features/CustomMusic/PulsarConfigBuilder.cs new file mode 100644 index 00000000..55ed98e3 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/PulsarConfigBuilder.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; +using System.Text; + +namespace WheelWizard.Test.Features.CustomMusic; + +/// Writes a minimal Pulsar config (PULS header, CUPS, BMG names and FILE block) like Pulsar's creator tool. +internal sealed class PulsarConfigBuilder +{ + private readonly List _cups = []; + private readonly List<(byte Slot, byte Music, string Name, string? Version, (byte Slot, byte Music, string Name)[] Variants)> _tracks = []; + private readonly Dictionary _files = []; + + public PulsarConfigBuilder Cup(string name) + { + _cups.Add(name); + return this; + } + + public PulsarConfigBuilder Track(byte slot, byte music, string name, string? version = null, (byte, byte, string)[]? variants = null) + { + _tracks.Add((slot, music, name, version, variants ?? [])); + return this; + } + + public PulsarConfigBuilder File(int id, string fileName) + { + _files[id] = fileName; + return this; + } + + public byte[] Build() + { + var storedCups = _cups.Count + _cups.Count % 2; + var trackTable = new List(); + for (var i = 0; i < storedCups * 4; i++) + { + var track = _tracks[i % _tracks.Count]; + trackTable.AddRange([track.Slot, track.Music, .. U16(i < _tracks.Count ? track.Variants.Length : 0), .. U32(0x1234)]); + } + + var variants = _tracks.SelectMany(track => track.Variants).SelectMany(v => new[] { v.Slot, v.Music }).ToArray(); + byte[] cupsBody = [.. U16(_cups.Count), 2, 0, .. new byte[8], .. S32(variants.Length / 2), .. trackTable, .. variants, .. new byte[storedCups * 8]]; + byte[] cups = [.. "CUPS"u8, .. U32(3), .. U32(12 + cupsBody.Length), .. cupsBody]; + + var messages = new List<(uint Id, byte[] Text)>(); + for (var i = 0; i < _cups.Count; i++) + messages.Add((0x10000u + (uint)i, Utf16(_cups[i]))); + for (var i = 0; i < _tracks.Count; i++) + { + var track = _tracks[i]; + messages.Add((0x20000u + (uint)i, track.Version == null ? Utf16(track.Name) : [.. Utf16(track.Name), .. ColourEscape, .. Utf16(" " + track.Version), .. ColourEscape])); + if (track.Variants.Length == 0) + continue; + messages.Add((0x20000u + (8u << 12) + (uint)i, Utf16(track.Name + " (all versions)"))); + for (var v = 0; v < track.Variants.Length; v++) + messages.Add((0x20000u + ((uint)(v + 1) << 12) + (uint)i, Utf16(track.Variants[v].Name))); + } + + var fileText = "FILE\n" + string.Concat(_files.Select(file => $"{file.Key:X}={file.Value}|e1|e2|e3|e4\n")) + "0?cupicon\n"; + + const int headerLength = 0x24; + byte[] info = [.. "INFO"u8, .. U32(1), .. U32(12 + 0x30), .. new byte[0x30]]; + var cupsOffset = headerLength + info.Length; + var bmgOffset = cupsOffset + cups.Length; + var header = new byte[headerLength]; + "PULS"u8.CopyTo(header); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), 3); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(8), headerLength); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0x0C), cupsOffset); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(0x10), bmgOffset); + return [.. header, .. info, .. cups, .. Bmg(messages), .. Encoding.UTF8.GetBytes(fileText)]; + } + + private static readonly byte[] ColourEscape = [0, 0x1A, 8, 0, 0, 3, 0, 0]; + + private static byte[] Bmg(List<(uint Id, byte[] Text)> messages) + { + var text = new List { 0, 0 }; + var offsets = new List(); + foreach (var message in messages) + { + offsets.Add(text.Count); + text.AddRange([.. message.Text, 0, 0]); + } + + var inf = Block("INF1", [.. U16(messages.Count), .. U16(8), .. U32(0), .. offsets.SelectMany(offset => U32(offset).Concat(U32(0)))]); + var dat = Block("DAT1", [.. text]); + var mid = Block("MID1", [.. U16(messages.Count), 0x10, 0, .. U32(0), .. messages.SelectMany(message => U32((int)message.Id))]); + byte[] body = [.. inf, .. dat, .. mid]; + byte[] header = [.. "MESGbmg1"u8, .. U32(0x20 + body.Length), .. U32(3), 2, .. new byte[15]]; + return [.. header, .. body]; + } + + private static byte[] Block(string magic, byte[] body) + { + var length = (8 + body.Length + 0x1F) & ~0x1F; + var block = new byte[length]; + Encoding.ASCII.GetBytes(magic, block.AsSpan(0, 4)); + BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(4), length); + body.CopyTo(block, 8); + return block; + } + + /// UTF-16 text; a \u0001 in the text becomes a colour escape code. + private static byte[] Utf16(string text) => text.Split('\u0001').Select(part => Encoding.BigEndianUnicode.GetBytes(part)).Aggregate((a, b) => [.. a, .. ColourEscape, .. b]); + + private static byte[] U16(int value) => [(byte)(value >> 8), (byte)value]; + + private static byte[] U32(int value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; + + private static byte[] S32(int value) => U32(value); +} diff --git a/WheelWizard/Documentation/CustomMusic.md b/WheelWizard/Documentation/CustomMusic.md new file mode 100644 index 00000000..ec6e27a5 --- /dev/null +++ b/WheelWizard/Documentation/CustomMusic.md @@ -0,0 +1,152 @@ +# Custom Music + +The **Custom Music** page (sidebar, under Patches) lists every piece of music Retro Rewind can play. Drop a song on a row +(or use the row's menu) and it replaces that music. Changes are only written to the game when you press **Save**. + +## How music works in Mario Kart Wii and Retro Rewind + +| What | Where it comes from | +| --- | --- | +| Original course and arena music | `sound/strm/*.brstm` on the disc. One file per course, plus a separate final lap file (`n_Circuit32_n.brstm` and `n_Circuit32_f.brstm`). Capitalisation varies (`n_Kinoko_F`, `STRM_N_KOOPA_N`), and the game wants the exact names. | +| Menus, results, fanfares, credits | Also BRSTM streams in `sound/strm` (`o_Wi-Fi_waiting32`, `o_FanfareGP1_32`, ...). | +| Retro Rewind tracks | Pulsar looks for `strm/_n.brstm` (then `_N`), using the creator file name from `Binaries/Config*.pul`. The final lap uses `_final` or `_F`. Tracks without their own music use the music slot of an original course. | +| Retro Rewind menus | Pulsar's own `title_bg`, `offline_bg`, `wifi_globe_bg` and `wifi_lobby_bg`. | +| Sound effects, voices, sequenced jingles | Packed in `revo_kart.brsar`. The BRSAR also keeps a table of the streams, with each one's size and channel count. | +| "My Stuff" style overrides | Retro Rewind loads loose files from `RetroRewind6/Patches` and matches them by **file name only**, so a `n_Circuit32_n.brstm` in there replaces Luigi Circuit's music. Wheel Wizard copies every enabled patch from the Patches page into that folder when it launches the game. | + +**Lap 3 speed-up.** Retro Rewind's *Music speedup* setting (in its sound settings) chooses between two behaviours: + +- **Off** (the default): on the final lap the game switches to the final lap file (`_f`/`_F`, or `_final` for Retro + Rewind tracks). If a Retro Rewind track only has an `_n` file, that file plays 6% faster and higher (pitch ×1.06). +- **On**: the game keeps playing the normal file and raises its pitch by about 6% over the first seconds of the final + lap. The final lap file is not used. + +Galaxy Colosseum always uses the speed-up. Custom Music writes both files, so either setting works. You choose how the +final lap file sounds in the page's settings (gear icon): + +- **Faster**: like Retro Rewind does it, 6% faster and higher. +- **Faster, same pitch**: 8% faster tempo with the key unchanged. +- **Unchanged**: the same file for every lap. + +**The BrawlBox "silence halfway through a lap" bug.** The original game trusts the stream size and channel count in +`revo_kart.brsar`. A replacement BRSTM that is longer, or has fewer channels, than the entry says made the game read past +the end of the file and stop the music. Retro Rewind's Pulsar engine patches both checks at runtime (`BRSARPatches.cpp`), +so a loose BRSTM can have any length. Custom Music also writes standard 2-channel, 32 kHz DSP-ADPCM files with +block-aligned loops, the same layout as Nintendo's files and VGAudio's. + +## What happens to a dropped song + +1. **Decode.** MP3, WAV, FLAC, OGG, AIFF and BRSTM are decoded by built-in code. M4A/AAC, Opus, WMA and similar formats + are decoded too when FFmpeg is installed on the PC; Wheel Wizard never downloads FFmpeg itself. +2. **Tags.** The title, artist and cover art are read from ID3, Vorbis comments, FLAC pictures and MP4 tags; for + formats only FFmpeg understands (Opus, WebM, WMA, ...) FFmpeg reads them. A picture with the song's own name next + to it (`song.jpg`, as yt-dlp saves thumbnails) is used too. Generic album art (`cover.jpg`, `folder.jpg`, ...) is + only used for songs tagged with an album, so a loose download never borrows another album's cover. Rows with custom + music get a coloured background and show the cover. +3. **Loudness matching.** The music being replaced is measured with ITU-R BS.1770 (integrated LUFS), either from + Retro Rewind's files or, for the original tracks, from the game disc image chosen in Wheel Wizard. The new song is + set to the same loudness, with a −1 dBFS peak limiter so loud masters do not clip. + Retro Rewind also reads a **volume byte** from the header of every music file (offset 0x3F, 1–127) and multiplies + the music's volume by byte / 127 (no byte = full volume). A replacement gets the original's byte, so both play at + the same level. When the song needs more level than its peaks allow (a quiet original with a low byte, or + *Louder*), the byte is raised first, up to 127, and only the rest is left to the limiter, so the song keeps its + dynamics. If the original has no byte, it already plays at full volume and nothing can be raised. + The original is measured the way the game plays it: in races only the file's first track is heard (further tracks + are alternatives such as Koopa Cape's underwater mix), while Retro Rewind plays its own menu music with every + track at full volume, so those tracks add up. Everything else in the game's volume chain (the sound archive's + volume for that music, the background music player, the per-track volumes the game sets) applies to the + original and the replacement alike. + When the original cannot be measured (no game disc set, unreadable disc image), the target is −16 LUFS, a typical + level for game music. The row then says "volume estimated", and its tooltip says why. + *Louder* / *Quieter* (±2 dB) in the row menu convert the song again; clicks made during a conversion add up. The + row shows the adjustment, and the tooltip shows the loudness reached. A song whose peaks are already at the limit + cannot get louder without distorting; the page says so instead of silently squashing it further. WiiCompiled has + its own *Music* volume slider in its settings, which scales all music. +4. **Looping.** Loop points in the file are kept: WAV `smpl` chunks, `LOOPSTART`/`LOOPLENGTH` tags, or a BRSTM's own + loop. Without them, Wheel Wizard looks for a loop in the music itself: most songs repeat a section, so playback can + jump from a late repeat back to an earlier one without anyone hearing the cut. + - The song is cut into 64 ms frames, each described by its energy in 24 frequency bands (log-spaced, ~60 Hz up). + - For every loop length of at least 12 seconds, each frame is compared with the frame that far earlier (cosine + similarity of the band shapes, minus a penalty per dB of level difference so a fade-out never matches the full + version, with silence never matching). The jump point needs about 3 seconds of matching music on both sides. + - Of the jump points that match well enough (similarity ≥ 0.95), longer loops and later jumps win, so as much of + the song as possible is heard before it repeats. + - The frame-level guess is refined to the exact sample by cross-correlating the waveforms around both points, and + the last 30 ms before the jump are blended into the audio before the loop start so the waveform continues + without a click. + + Songs that do not repeat cleanly (or are too short) fall back to a crossfade: the ending fades into the opening and + the loop restarts right after that opening. Either way the loop start is then moved onto a BRSTM block boundary + (the audio that follows is appended to the loop end, so the loop is unchanged) because the game can only jump to + the start of a block. Loop points shorter than a second are ignored (the game would repeat a tiny fragment as a + buzz). Jingles such as start fanfares play once. The row's tooltip shows where the loop came from and where it + starts and ends. +5. **Encoding.** The song is resampled to 32 kHz and encoded to DSP-ADPCM BRSTM. + +## Previewing music + +Every row with a playable original has a play button, so you can hear what a track is before replacing it (useful when +several tracks have similar names). The original comes from Retro Rewind's own files, or from the game disc image for +the original game's music; the row menu also has *Play your song* for the converted replacement, exactly as it will be +installed. A player bar at the bottom shows progress (click it to jump to a point), pause and stop. + +Playback uses the operating system directly: Windows' `PlaySound`, `afplay` on macOS, and on Linux the first of +`paplay`, `pw-play`, `aplay`, `ffplay`, `play` or `mpv` that is installed. Music is stopped when you leave the page. + +Tracks whose names look alike show their file name next to the description, and get it +appended to the name when the names are identical. + +## Same course, several consoles + +Retro Rewind has many versions of some courses ("SNES Rainbow Road", "N64 Rainbow Road", "Rainbow Road", ...). Rows +whose names match once the console tag, version suffix (`v2`) and notes in brackets are removed form a family, and the +row menu offers *Use for all N Rainbow Road tracks*: the row's song (or a newly picked one) is converted for every +member, each matched to the loudness of its own original. + +## Music packs + +Music packs are usually zips of BRSTM files named after the game's music (`n_Circuit32_n.brstm`, ...). + +- **Installed as a patch.** A pack added on the Patches page (and enabled) shows on the rows it replaces ("Music from + "), and the play button plays the pack's version. When two packs provide the same file, the one with the + higher priority is shown, as in game. A song chosen here still wins, because the Custom Music patch has the highest + priority. +- **Dropped on the page.** Dropping a pack's zip (or a folder) on the page extracts its audio files into the library and + matches them to their tracks by file name. BRSTMs keep their loop points and are loudness-matched like any song; one + with several tracks is decoded the way the row plays it (the first track in races, every track mixed for Retro + Rewind's menu music). Archives over 4 GB are refused. + +Files extracted straight into Retro Rewind's own `Patches` folder are not seen: Wheel Wizard replaces that folder's +contents with the enabled patches when it launches the game, so packs should be added on the Patches page. + +**Remove all custom music** (gear menu) puts every row back to its original music; like every change, it only takes +effect when saved. + +## Saving + +Pressing **Save** writes the converted files into a patch called **Custom Music** on the Patches page: + +- The patch is created if needed, gets the highest priority so it beats music packs, and is switched back on if it was + disabled. +- Songs that were reset are removed from it, and the patch is deleted when no custom music is left. +- If another enabled patch with a higher priority provides the same file, the page warns about the conflict. + +The library (which song belongs to which row, plus the converted files) is stored in Wheel Wizard's data folder under +`CustomMusic/`, so nothing is lost when Retro Rewind updates. + +## Development + +Run from source with `dotnet run --project WheelWizard`, and the tests with `dotnet test`; the custom music tests are in +`WheelWizard.Test/Features/CustomMusic`. Nothing in the feature is platform specific apart from preview playback (see +above): file names are compared case-insensitively, paths are built with the file system abstraction, and FFmpeg is +looked up on `PATH` (plus Homebrew's folders on macOS). + +## Code map + +| Folder | Contents | +| --- | --- | +| `Features/CustomMusic/Audio` | Decoders, tag readers, loudness meter, resampler, limiter, DSP-ADPCM encoder, BRSTM reader and writer, loop finder, `MusicConverter` | +| `Features/CustomMusic/Catalog` | Original game music list, Pulsar config and BMG reader, Retro Rewind track discovery, course families | +| `Features/CustomMusic/Library` | Saved songs (`library.json`), reference loudness, game disc reader, music pack import, the installer that writes the patch | +| `Features/CustomMusic/Playback` | Preview player and the per-platform audio output | +| `Features/CustomMusic/ViewModels`, `Views` | The page | diff --git a/WheelWizard/Features/CustomMusic/Audio/AudioBuffer.cs b/WheelWizard/Features/CustomMusic/Audio/AudioBuffer.cs new file mode 100644 index 00000000..6a06dc03 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/AudioBuffer.cs @@ -0,0 +1,118 @@ +namespace WheelWizard.CustomMusic.Audio; + +/// +/// Decoded audio in planar float form (one array per channel, nominal range -1..1), +/// plus everything we learned about the file while decoding it. +/// +public sealed class AudioBuffer +{ + public AudioBuffer(float[][] channels, int sampleRate) + { + if (channels.Length == 0) + throw new ArgumentException("Audio needs at least one channel.", nameof(channels)); + if (sampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sampleRate)); + + var length = channels[0].Length; + if (channels.Any(channel => channel.Length != length)) + throw new ArgumentException("All channels must have the same length.", nameof(channels)); + + Channels = channels; + SampleRate = sampleRate; + } + + public float[][] Channels { get; } + public int SampleRate { get; } + public int ChannelCount => Channels.Length; + public int SampleCount => Channels[0].Length; + public TimeSpan Duration => TimeSpan.FromSeconds(SampleCount / (double)SampleRate); + + /// Loop region in samples, when the source file declared one (WAV smpl, LOOPSTART tags, BRSTM...). + public LoopRegion? Loop { get; set; } + + public AudioMetadata Metadata { get; set; } = new(); +} + +/// A loop that plays up to (excluding) forever. +public readonly record struct LoopRegion(int Start, int End) +{ + public int Length => End - Start; + public bool IsValid(int sampleCount) => Start >= 0 && End > Start && End <= sampleCount; +} + +public sealed class AudioMetadata +{ + public string? Title { get; set; } + public string? Artist { get; set; } + public string? Album { get; set; } + public CoverArt? Cover { get; set; } + + /// Raw loop hints from tags (LOOPSTART/LOOPLENGTH/LOOPEND), in source samples. + public long? LoopStartTag { get; set; } + public long? LoopLengthTag { get; set; } + public long? LoopEndTag { get; set; } + + /// Applies loop tags the way most game-audio tools read them. + public LoopRegion? ResolveLoopTags(int sampleCount) + { + if (LoopStartTag is not { } start || start < 0 || start >= sampleCount) + return null; + + long end; + if (LoopLengthTag is { } length && length > 0) + end = start + length; + else if (LoopEndTag is { } loopEnd && loopEnd > start) + end = loopEnd; + else + end = sampleCount; + + end = Math.Min(end, sampleCount); + return end > start ? new LoopRegion((int)start, (int)end) : null; + } + + public void MergeMissing(AudioMetadata other) + { + Title ??= other.Title; + Artist ??= other.Artist; + Album ??= other.Album; + Cover ??= other.Cover; + LoopStartTag ??= other.LoopStartTag; + LoopLengthTag ??= other.LoopLengthTag; + LoopEndTag ??= other.LoopEndTag; + } +} + +/// Embedded picture bytes, normally JPEG or PNG. +public sealed record CoverArt(byte[] Data, string MimeType) +{ + public string FileExtension => + MimeType.ToLowerInvariant() switch + { + "image/png" => ".png", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "image/webp" => ".webp", + _ => ".jpg", + }; + + /// Recognises the image by its signature, ignoring whatever MIME type the tag claims. + public static CoverArt? FromBytes(byte[]? data, string? declaredMime = null) + { + if (data is null || data.Length < 8) + return null; + + if (data[0] == 0xFF && data[1] == 0xD8) + return new(data, "image/jpeg"); + if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) + return new(data, "image/png"); + if (data[0] == 'G' && data[1] == 'I' && data[2] == 'F') + return new(data, "image/gif"); + if (data[0] == 'B' && data[1] == 'M') + return new(data, "image/bmp"); + if (data.Length > 12 && data[0] == 'R' && data[1] == 'I' && data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P') + return new(data, "image/webp"); + + // Unknown signature: only trust the tag if it says it is an image at all. + return declaredMime?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true ? new(data, declaredMime) : null; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/AudioFileDecoder.cs b/WheelWizard/Features/CustomMusic/Audio/AudioFileDecoder.cs new file mode 100644 index 00000000..caf8ce8f --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/AudioFileDecoder.cs @@ -0,0 +1,155 @@ +using System.IO.Abstractions; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Audio.Tags; + +namespace WheelWizard.CustomMusic.Audio; + +/// Detects an audio file's format from its contents and decodes it. +public static class AudioFileDecoder +{ + /// Formats decoded without any external tools. + public static readonly string[] NativeExtensions = [".mp3", ".wav", ".wave", ".flac", ".ogg", ".oga", ".aif", ".aiff", ".aifc", ".brstm"]; + + /// Formats we hand to FFmpeg when the user has it installed. + public static readonly string[] FfmpegExtensions = [".m4a", ".mp4", ".aac", ".opus", ".wma", ".alac", ".webm", ".mka", ".ac3", ".mp2"]; + + public static IEnumerable AllExtensions => NativeExtensions.Concat(FfmpegExtensions); + + /// Largest file we are willing to load into memory (a 10 minute 24-bit/96 kHz WAV is ~350 MB). + public const long MaxFileSize = 512L * 1024 * 1024; + + public static bool IsSupportedExtension(string path) + { + var extension = PathText.Extension(path); + return NativeExtensions.Contains(extension) || FfmpegExtensions.Contains(extension); + } + + /// The file system the song lives on. + /// The song file. + /// For BRSTM files: mix every track, as Retro Rewind does for its menu music, instead of + /// keeping only the first track, which is what races play. + /// Stops decoding. + public static async Task DecodeAsync(IFileSystem fileSystem, string path, bool allBrstmTracks = false, CancellationToken cancellationToken = default) + { + var info = fileSystem.FileInfo.New(path); + if (!info.Exists) + throw new FileNotFoundException("The audio file could not be found.", path); + if (info.Length > MaxFileSize) + throw new InvalidDataException("This audio file is too large to convert."); + + var bytes = await fileSystem.File.ReadAllBytesAsync(path, cancellationToken); + var buffer = await DecodeAsync(fileSystem, bytes, path, allBrstmTracks, cancellationToken); + + // Generic folder art (cover.jpg, ...) only belongs to songs that are part of a tagged album; a loose + // download in a shared folder would otherwise pick up some other album's cover. + buffer.Metadata.Cover ??= FolderArtFinder.Find(fileSystem, path, allowAlbumArt: !string.IsNullOrWhiteSpace(buffer.Metadata.Album)); + return buffer; + } + + /// Used to find FFmpeg for formats without a managed decoder. + /// The whole audio file. + /// Where the file is, for FFmpeg (which reads the file itself). + /// For BRSTM files: mix every track instead of keeping only the first. + /// Stops decoding. + public static async Task DecodeAsync( + IFileSystem fileSystem, + byte[] bytes, + string pathForFallback, + bool allBrstmTracks = false, + CancellationToken cancellationToken = default + ) + { + AudioBuffer buffer; + if (WavReader.IsWav(bytes)) + buffer = await Run(fileSystem, () => WavReader.Read(bytes), pathForFallback, cancellationToken); + else if (AiffReader.IsAiff(bytes)) + buffer = await Run(fileSystem, () => AiffReader.Read(bytes), pathForFallback, cancellationToken); + else if (FlacReader.IsFlac(bytes)) + buffer = await Task.Run(() => FlacReader.Read(bytes), cancellationToken); + else if (OggVorbisReader.IsVorbis(bytes)) + buffer = await Task.Run(() => OggVorbisReader.Read(bytes, cancellationToken), cancellationToken); + else if (BrstmReader.IsBrstm(bytes)) + buffer = await Task.Run(() => BrstmReader.DecodeAsPlayed(bytes, allBrstmTracks), cancellationToken); + else if (Mp4TagReader.IsMp4(bytes)) + { + buffer = await FfmpegReader.ReadAsync(fileSystem, pathForFallback, cancellationToken); + using var stream = new MemoryStream(bytes, writable: false); + buffer.Metadata = Mp4TagReader.Read(stream); + } + else if (OggVorbisReader.IsOgg(bytes)) + { + // Opus, Ogg FLAC or Speex: no managed decoder; FFmpeg decodes and reads the tags. + buffer = await FfmpegReader.ReadAsync(fileSystem, pathForFallback, cancellationToken); + buffer.Metadata = await FfmpegReader.ReadMetadataAsync(fileSystem, pathForFallback, cancellationToken); + } + else if (Mp3Reader.LooksLikeMp3(bytes)) + buffer = await Task.Run(() => Mp3Reader.Read(bytes, cancellationToken), cancellationToken); + else + { + buffer = await FfmpegReader.ReadAsync(fileSystem, pathForFallback, cancellationToken); + buffer.Metadata = await FfmpegReader.ReadMetadataAsync(fileSystem, pathForFallback, cancellationToken); + } + + if (buffer.Loop == null && buffer.Metadata.ResolveLoopTags(buffer.SampleCount) is { } loop) + buffer.Loop = loop; + + return buffer; + } + + /// Runs a managed reader, falling back to FFmpeg for compressed payloads inside WAV/AIFF containers. + private static async Task Run(IFileSystem fileSystem, Func read, string path, CancellationToken cancellationToken) + { + try + { + return await Task.Run(read, cancellationToken); + } + catch (NotSupportedException) when (FfmpegReader.IsAvailable(fileSystem)) + { + return await FfmpegReader.ReadAsync(fileSystem, path, cancellationToken); + } + } +} + +/// +/// Finds artwork stored next to the song: a picture with the song's own name (song.jpg, as yt-dlp and many +/// rippers save it), or, for songs from an album, the usual album art names (cover.jpg, folder.png, ...). +/// +public static class FolderArtFinder +{ + private static readonly string[] Names = ["cover", "folder", "front", "album", "albumart", "albumartsmall", "artwork"]; + private static readonly string[] Extensions = [".jpg", ".jpeg", ".png", ".webp"]; + + /// The file system the song lives on. + /// The song file. + /// Whether generic album art names may be used (only when the song is tagged with an album). + public static CoverArt? Find(IFileSystem fileSystem, string audioPath, bool allowAlbumArt) + { + try + { + var directory = fileSystem.Path.GetDirectoryName(audioPath); + if (string.IsNullOrEmpty(directory) || !fileSystem.Directory.Exists(directory)) + return null; + + var baseName = PathText.FileNameWithoutExtension(audioPath); + var candidates = fileSystem + .Directory.EnumerateFiles(directory) + .Where(file => Extensions.Contains(PathText.Extension(file))) + .Select(file => (File: file, Name: PathText.FileNameWithoutExtension(file).ToLowerInvariant())) + .ToList(); + + // A picture named exactly like the song wins, then (for album tracks) the usual album art names. + var match = candidates.FirstOrDefault(c => c.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase)).File; + if (match == null && allowAlbumArt) + match = Names.Select(name => candidates.FirstOrDefault(c => c.Name == name).File).FirstOrDefault(file => file != null); + if (match == null) + return null; + + var info = fileSystem.FileInfo.New(match); + return info.Length is > 0 and < 20 * 1024 * 1024 ? CoverArt.FromBytes(fileSystem.File.ReadAllBytes(match)) : null; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Codecs/DspAdpcm.cs b/WheelWizard/Features/CustomMusic/Audio/Codecs/DspAdpcm.cs new file mode 100644 index 00000000..64ad9016 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Codecs/DspAdpcm.cs @@ -0,0 +1,668 @@ +// The coefficient search and frame encoder below follow Nintendo's DSPADPCM algorithm as +// reverse engineered by Jack Andersen (gc-dspadpcm-encode) and ported to C# by Alex Barney +// in VGAudio (https://github.com/Thealexbarney/VGAudio), MIT License, Copyright (c) 2016 Alex Barney. +// It is the same encoder BrawlBox-era tools and VGAudio use, so the output matches what the Wii +// DSP and Mario Kart Wii's stream player expect. + +namespace WheelWizard.CustomMusic.Audio.Codecs; + +/// Nintendo GameCube/Wii 4-bit DSP-ADPCM ("GC ADPCM"), as used inside BRSTM files. +public static class DspAdpcm +{ + public const int BytesPerFrame = 8; + public const int SamplesPerFrame = 14; + public const int NibblesPerFrame = 16; + + public static int SampleCountToNibbleCount(int sampleCount) + { + var frames = sampleCount / SamplesPerFrame; + var extraSamples = sampleCount % SamplesPerFrame; + var extraNibbles = extraSamples == 0 ? 0 : extraSamples + 2; + return NibblesPerFrame * frames + extraNibbles; + } + + public static int SampleCountToByteCount(int sampleCount) => (SampleCountToNibbleCount(sampleCount) + 1) / 2; + + public static int NibbleCountToSampleCount(int nibbleCount) + { + var frames = nibbleCount / NibblesPerFrame; + var extraNibbles = nibbleCount % NibblesPerFrame; + var extraSamples = extraNibbles < 2 ? 0 : extraNibbles - 2; + return SamplesPerFrame * frames + extraSamples; + } + + public static int ByteCountToSampleCount(int byteCount) => NibbleCountToSampleCount(byteCount * 2); + + /// The predictor/scale header byte of the frame that contains . + public static byte GetPredictorScale(byte[] adpcm, int sample) => adpcm[sample / SamplesPerFrame * BytesPerFrame]; + + #region Decoding + + public static short[] Decode(ReadOnlySpan adpcm, short[] coefs, int sampleCount, short history1 = 0, short history2 = 0) + { + var pcm = new short[sampleCount]; + int hist1 = history1; + int hist2 = history2; + var frameCount = (sampleCount + SamplesPerFrame - 1) / SamplesPerFrame; + var outIndex = 0; + + for (var frame = 0; frame < frameCount; frame++) + { + var frameOffset = frame * BytesPerFrame; + if (frameOffset >= adpcm.Length) + break; + + var header = adpcm[frameOffset]; + var scale = 1 << (header & 0xF); + var predictor = (header >> 4) & 0x7; + int coef1 = coefs[predictor * 2]; + int coef2 = coefs[predictor * 2 + 1]; + var samplesInFrame = Math.Min(SamplesPerFrame, sampleCount - outIndex); + + for (var s = 0; s < samplesInFrame; s++) + { + var byteIndex = frameOffset + 1 + s / 2; + if (byteIndex >= adpcm.Length) + return pcm; + + int nibble = (s & 1) == 0 ? adpcm[byteIndex] >> 4 : adpcm[byteIndex] & 0xF; + nibble = nibble >= 8 ? nibble - 16 : nibble; + + var sample = ((nibble * scale) << 11) + 1024 + coef1 * hist1 + coef2 * hist2; + var value = Clamp16(sample >> 11); + pcm[outIndex++] = (short)value; + hist2 = hist1; + hist1 = value; + } + } + + return pcm; + } + + #endregion + + #region Encoding + + public sealed class EncodedChannel + { + public required short[] Coefficients { get; init; } + public required byte[] Adpcm { get; init; } + + /// What the Wii will hear: the ADPCM data decoded again. Used for loop contexts and seek tables. + public required short[] Decoded { get; init; } + + public byte InitialPredictorScale => Adpcm.Length > 0 ? Adpcm[0] : (byte)0; + } + + public static EncodedChannel Encode(short[] pcm) + { + var coefs = CalculateCoefficients(pcm); + var adpcm = Encode(pcm, coefs); + var decoded = Decode(adpcm, coefs, pcm.Length); + return new() + { + Coefficients = coefs, + Adpcm = adpcm, + Decoded = decoded, + }; + } + + public static byte[] Encode(short[] pcm, short[] coefs) + { + var sampleCount = pcm.Length; + var adpcm = new byte[SampleCountToByteCount(sampleCount)]; + var pcmBuffer = new short[2 + SamplesPerFrame]; + var adpcmBuffer = new byte[BytesPerFrame]; + var buffers = new EncodeBuffers(); + var frameCount = (sampleCount + SamplesPerFrame - 1) / SamplesPerFrame; + + for (var frame = 0; frame < frameCount; frame++) + { + var samplesToCopy = Math.Min(sampleCount - frame * SamplesPerFrame, SamplesPerFrame); + Array.Copy(pcm, frame * SamplesPerFrame, pcmBuffer, 2, samplesToCopy); + Array.Clear(pcmBuffer, 2 + samplesToCopy, SamplesPerFrame - samplesToCopy); + + EncodeFrame(pcmBuffer, SamplesPerFrame, adpcmBuffer, coefs, buffers); + Array.Copy(adpcmBuffer, 0, adpcm, frame * BytesPerFrame, SampleCountToByteCount(samplesToCopy)); + + pcmBuffer[0] = pcmBuffer[14]; + pcmBuffer[1] = pcmBuffer[15]; + } + + return adpcm; + } + + private static void EncodeFrame(short[] pcmInOut, int sampleCount, byte[] adpcmOut, short[] coefsIn, EncodeBuffers b) + { + for (var i = 0; i < 8; i++) + { + b.Coefs[i][0] = coefsIn[i * 2]; + b.Coefs[i][1] = coefsIn[i * 2 + 1]; + } + + for (var i = 0; i < 8; i++) + EncodeWithCoefficient(pcmInOut, sampleCount, b.Coefs[i], b.PcmOut[i], b.AdpcmOut[i], out b.Scale[i], out b.TotalDistance[i]); + + var bestCoef = 0; + var min = double.MaxValue; + for (var i = 0; i < 8; i++) + { + if (b.TotalDistance[i] < min) + { + min = b.TotalDistance[i]; + bestCoef = i; + } + } + + for (var s = 0; s < sampleCount; s++) + pcmInOut[s + 2] = (short)b.PcmOut[bestCoef][s + 2]; + + adpcmOut[0] = CombineNibbles(bestCoef, b.Scale[bestCoef]); + + for (var s = sampleCount; s < 14; s++) + b.AdpcmOut[bestCoef][s] = 0; + + for (var i = 0; i < 7; i++) + adpcmOut[i + 1] = CombineNibbles(b.AdpcmOut[bestCoef][i * 2], b.AdpcmOut[bestCoef][i * 2 + 1]); + } + + private static void EncodeWithCoefficient( + short[] pcmIn, + int sampleCount, + short[] coefs, + int[] pcmOut, + int[] adpcmOut, + out int scalePower, + out double totalDistance + ) + { + int maxOverflow; + var maxDistance = 0; + + pcmOut[0] = pcmIn[0]; + pcmOut[1] = pcmIn[1]; + + // Encode the frame with a scale of 1 to find the largest prediction error. + for (var s = 0; s < sampleCount; s++) + { + int inputSample = pcmIn[s + 2]; + var predictedSample = (pcmIn[s] * coefs[1] + pcmIn[s + 1] * coefs[0]) / 2048; + var distance = Clamp16(inputSample - predictedSample); + if (Math.Abs(distance) > Math.Abs(maxDistance)) + maxDistance = distance; + } + + scalePower = 0; + while (scalePower <= 12 && (maxDistance > 7 || maxDistance < -8)) + { + maxDistance /= 2; + scalePower++; + } + + scalePower = scalePower <= 1 ? -1 : scalePower - 2; + + // Increase the scale until every residual fits into a nibble. + do + { + scalePower++; + var scale = (1 << scalePower) * 2048; + totalDistance = 0; + maxOverflow = 0; + + for (var s = 0; s < sampleCount; s++) + { + var inputSample = pcmIn[s + 2] * 2048; + var predictedSample = pcmOut[s] * coefs[1] + pcmOut[s + 1] * coefs[0]; + var distance = inputSample - predictedSample; + // The official encoder rounds through float this way; matching it keeps output identical. + var unclamped = + distance > 0 + ? (int)((double)((float)distance / scale) + 0.4999999f) + : (int)((double)((float)distance / scale) - 0.4999999f); + + var adpcmSample = Clamp4(unclamped); + if (adpcmSample != unclamped) + { + var overflow = Math.Abs(unclamped - adpcmSample); + if (overflow > maxOverflow) + maxOverflow = overflow; + } + + adpcmOut[s] = adpcmSample; + + var decodedDistance = adpcmSample * scale; + var correctedSample = predictedSample + decodedDistance; + var scaledSample = (correctedSample + 1024) >> 11; + pcmOut[s + 2] = Clamp16(scaledSample); + + double actualDistance = pcmIn[s + 2] - pcmOut[s + 2]; + totalDistance += actualDistance * actualDistance; + } + + for (var x = maxOverflow + 8; x > 256; x >>= 1) + { + if (++scalePower >= 12) + scalePower = 11; + } + } while (scalePower < 12 && maxOverflow > 1); + } + + private sealed class EncodeBuffers + { + public short[][] Coefs { get; } = new short[8][]; + public int[][] PcmOut { get; } = new int[8][]; + public int[][] AdpcmOut { get; } = new int[8][]; + public int[] Scale { get; } = new int[8]; + public double[] TotalDistance { get; } = new double[8]; + + public EncodeBuffers() + { + for (var i = 0; i < 8; i++) + { + PcmOut[i] = new int[16]; + AdpcmOut[i] = new int[14]; + Coefs[i] = new short[2]; + } + } + } + + #endregion + + #region Coefficients + + /// Finds the 8 predictor coefficient pairs that best describe the whole channel. + public static short[] CalculateCoefficients(short[] source) + { + var frameCount = (source.Length + SamplesPerFrame - 1) / SamplesPerFrame; + var pcmHistBuffer = new short[28]; + var coefs = new short[16]; + var vec1 = new double[3]; + var vec2 = new double[3]; + var buffer = new double[3]; + var mtx = NewMatrix(); + var vecIdxs = new int[3]; + var records = new double[Math.Max(1, frameCount * 2), 3]; + var recordCount = 0; + var vecBest = new double[8][]; + for (var i = 0; i < vecBest.Length; i++) + vecBest[i] = new double[3]; + + for (int sample = 0, remaining = source.Length; sample < source.Length; sample += 14, remaining -= 14) + { + Array.Clear(pcmHistBuffer, 14, 14); + Array.Copy(source, sample, pcmHistBuffer, 14, Math.Min(14, remaining)); + + InnerProductMerge(vec1, pcmHistBuffer); + if (Math.Abs(vec1[0]) > 10.0) + { + OuterProductMerge(mtx, pcmHistBuffer); + if (!AnalyzeRanges(mtx, vecIdxs, buffer)) + { + BidirectionalFilter(mtx, vecIdxs, vec1); + if (!QuadraticMerge(vec1)) + { + FinishRecord(vec1, records, recordCount); + recordCount++; + } + } + } + + Array.Copy(pcmHistBuffer, 14, pcmHistBuffer, 0, 14); + } + + vec1[0] = 1.0; + vec1[1] = 0.0; + vec1[2] = 0.0; + + for (var z = 0; z < recordCount; z++) + { + MatrixFilter(records, z, vecBest[0], mtx); + for (var y = 1; y <= 2; y++) + vec1[y] += vecBest[0][y]; + } + + // Silent input produces no records; keep the coefficients at zero instead of dividing by zero. + if (recordCount > 0) + { + for (var y = 1; y <= 2; y++) + vec1[y] /= recordCount; + } + + MergeFinishRecord(vec1, vecBest[0]); + + var exp = 1; + for (var w = 0; w < 3; ) + { + vec2[0] = 0.0; + vec2[1] = -1.0; + vec2[2] = 0.0; + for (var i = 0; i < exp; i++) + { + for (var y = 0; y <= 2; y++) + vecBest[exp + i][y] = 0.01 * vec2[y] + vecBest[i][y]; + } + + ++w; + exp = 1 << w; + FilterRecords(vecBest, exp, records, recordCount); + } + + for (var z = 0; z < 8; z++) + { + coefs[z * 2] = ToCoefficient(-vecBest[z][1] * 2048.0); + coefs[z * 2 + 1] = ToCoefficient(-vecBest[z][2] * 2048.0); + } + + return coefs; + } + + private static short ToCoefficient(double d) + { + if (double.IsNaN(d)) + return 0; + if (d > 0.0) + return d > short.MaxValue ? short.MaxValue : (short)Math.Round(d); + return d < short.MinValue ? short.MinValue : (short)Math.Round(d); + } + + private static double[][] NewMatrix() + { + var mtx = new double[3][]; + for (var i = 0; i < mtx.Length; i++) + mtx[i] = new double[3]; + return mtx; + } + + private static void InnerProductMerge(double[] vecOut, short[] pcmBuf) + { + for (var i = 0; i <= 2; i++) + { + vecOut[i] = 0.0f; + for (var x = 0; x < 14; x++) + vecOut[i] -= pcmBuf[14 + x - i] * pcmBuf[14 + x]; + } + } + + private static void OuterProductMerge(double[][] mtxOut, short[] pcmBuf) + { + for (var x = 1; x <= 2; x++) + { + for (var y = 1; y <= 2; y++) + { + mtxOut[x][y] = 0.0; + for (var z = 0; z < 14; z++) + mtxOut[x][y] += pcmBuf[14 + z - x] * pcmBuf[14 + z - y]; + } + } + } + + private static bool AnalyzeRanges(double[][] mtx, int[] vecIdxsOut, double[] recips) + { + double val, + tmp; + + for (var x = 1; x <= 2; x++) + { + val = Math.Max(Math.Abs(mtx[x][1]), Math.Abs(mtx[x][2])); + if (val < double.Epsilon) + return true; + + recips[x] = 1.0 / val; + } + + var maxIndex = 0; + for (var i = 1; i <= 2; i++) + { + for (var x = 1; x < i; x++) + { + tmp = mtx[x][i]; + for (var y = 1; y < x; y++) + tmp -= mtx[x][y] * mtx[y][i]; + mtx[x][i] = tmp; + } + + val = 0.0; + for (var x = i; x <= 2; x++) + { + tmp = mtx[x][i]; + for (var y = 1; y < i; y++) + tmp -= mtx[x][y] * mtx[y][i]; + + mtx[x][i] = tmp; + tmp = Math.Abs(tmp) * recips[x]; + if (tmp >= val) + { + val = tmp; + maxIndex = x; + } + } + + if (maxIndex != i) + { + for (var y = 1; y <= 2; y++) + { + tmp = mtx[maxIndex][y]; + mtx[maxIndex][y] = mtx[i][y]; + mtx[i][y] = tmp; + } + + recips[maxIndex] = recips[i]; + } + + vecIdxsOut[i] = maxIndex; + + if (i != 2) + { + tmp = 1.0 / mtx[i][i]; + for (var x = i + 1; x <= 2; x++) + mtx[x][i] *= tmp; + } + } + + var min = 1.0e10; + var max = 0.0; + for (var i = 1; i <= 2; i++) + { + tmp = Math.Abs(mtx[i][i]); + if (tmp < min) + min = tmp; + if (tmp > max) + max = tmp; + } + + return min / max < 1.0e-10; + } + + private static void BidirectionalFilter(double[][] mtx, int[] vecIdxs, double[] vecOut) + { + double tmp; + + for (int i = 1, x = 0; i <= 2; i++) + { + var index = vecIdxs[i]; + tmp = vecOut[index]; + vecOut[index] = vecOut[i]; + if (x != 0) + { + for (var y = x; y <= i - 1; y++) + tmp -= vecOut[y] * mtx[i][y]; + } + else if (tmp != 0.0) + { + x = i; + } + + vecOut[i] = tmp; + } + + for (var i = 2; i > 0; i--) + { + tmp = vecOut[i]; + for (var y = i + 1; y <= 2; y++) + tmp -= vecOut[y] * mtx[i][y]; + vecOut[i] = tmp / mtx[i][i]; + } + + vecOut[0] = 1.0; + } + + private static bool QuadraticMerge(double[] inOutVec) + { + var v2 = inOutVec[2]; + var tmp = 1.0 - v2 * v2; + + if (tmp == 0.0) + return true; + + var v0 = (inOutVec[0] - v2 * v2) / tmp; + var v1 = (inOutVec[1] - inOutVec[1] * v2) / tmp; + + inOutVec[0] = v0; + inOutVec[1] = v1; + + return Math.Abs(v1) > 1.0; + } + + private static void ClampRecord(double[] inR) + { + for (var z = 1; z <= 2; z++) + { + if (inR[z] >= 1.0) + inR[z] = 0.9999999999; + else if (inR[z] <= -1.0) + inR[z] = -0.9999999999; + } + } + + private static void FinishRecord(double[] inR, double[,] outR, int row) + { + ClampRecord(inR); + outR[row, 0] = 1.0; + outR[row, 1] = inR[2] * inR[1] + inR[1]; + outR[row, 2] = inR[2]; + } + + private static void FinishRecord(double[] inR, double[] outR) + { + ClampRecord(inR); + outR[0] = 1.0; + outR[1] = inR[2] * inR[1] + inR[1]; + outR[2] = inR[2]; + } + + private static void MatrixFilter(double[,] src, int row, double[] dst, double[][] mtx) + { + mtx[2][0] = 1.0; + for (var i = 1; i <= 2; i++) + mtx[2][i] = -src[row, i]; + + for (var i = 2; i > 0; i--) + { + var val = 1.0 - mtx[i][i] * mtx[i][i]; + for (var y = 1; y <= i; y++) + mtx[i - 1][y] = (mtx[i][i] * mtx[i][y] + mtx[i][y]) / val; + } + + dst[0] = 1.0; + for (var i = 1; i <= 2; i++) + { + dst[i] = 0.0; + for (var y = 1; y <= i; y++) + dst[i] += mtx[i][y] * dst[i - y]; + } + } + + private static void MergeFinishRecord(double[] src, double[] dst) + { + var tmp = new double[3]; + var val = src[0]; + + dst[0] = 1.0; + for (var i = 1; i <= 2; i++) + { + var v2 = 0.0; + for (var y = 1; y < i; y++) + v2 += dst[y] * src[i - y]; + + dst[i] = val > 0.0 ? -(v2 + src[i]) / val : 0.0; + tmp[i] = dst[i]; + + for (var y = 1; y < i; y++) + dst[y] += dst[i] * dst[i - y]; + + val *= 1.0 - dst[i] * dst[i]; + } + + FinishRecord(tmp, dst); + } + + private static double ContrastVectors(double[] source1, double[,] source2, int row) + { + var val = (source2[row, 2] * source2[row, 1] + -source2[row, 1]) / (1.0 - source2[row, 2] * source2[row, 2]); + var val1 = source1[0] * source1[0] + source1[1] * source1[1] + source1[2] * source1[2]; + var val2 = source1[0] * source1[1] + source1[1] * source1[2]; + var val3 = source1[0] * source1[2]; + return val1 + 2.0 * val * val2 + 2.0 * (-source2[row, 1] * val + -source2[row, 2]) * val3; + } + + private static void FilterRecords(double[][] vecBest, int exp, double[,] records, int recordCount) + { + var bufferList = new double[8][]; + for (var i = 0; i < bufferList.Length; i++) + bufferList[i] = new double[3]; + + var mtx = NewMatrix(); + var buffer1 = new int[8]; + var buffer2 = new double[3]; + + for (var x = 0; x < 2; x++) + { + for (var y = 0; y < exp; y++) + { + buffer1[y] = 0; + for (var i = 0; i <= 2; i++) + bufferList[y][i] = 0.0; + } + + for (var z = 0; z < recordCount; z++) + { + var index = 0; + var value = 1.0e30; + for (var i = 0; i < exp; i++) + { + var tempVal = ContrastVectors(vecBest[i], records, z); + if (tempVal < value) + { + value = tempVal; + index = i; + } + } + + buffer1[index]++; + MatrixFilter(records, z, buffer2, mtx); + for (var i = 0; i <= 2; i++) + bufferList[index][i] += buffer2[i]; + } + + for (var i = 0; i < exp; i++) + { + if (buffer1[i] > 0) + { + for (var y = 0; y <= 2; y++) + bufferList[i][y] /= buffer1[i]; + } + } + + for (var i = 0; i < exp; i++) + MergeFinishRecord(bufferList[i], vecBest[i]); + } + } + + #endregion + + private static byte CombineNibbles(int high, int low) => (byte)((high << 4) | (low & 0xF)); + + private static int Clamp16(int value) => value > short.MaxValue ? short.MaxValue : value < short.MinValue ? short.MinValue : value; + + private static int Clamp4(int value) => value > 7 ? 7 : value < -8 ? -8 : value; +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/AudioOps.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/AudioOps.cs new file mode 100644 index 00000000..ddadca74 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/AudioOps.cs @@ -0,0 +1,282 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// Small, self-contained processing steps used by the conversion pipeline. +public static class AudioOps +{ + /// Mixes any channel layout to stereo (mono is duplicated; surround is folded down ITU-style). + public static AudioBuffer ToStereo(AudioBuffer input) + { + if (input.ChannelCount == 2) + return input; + + var length = input.SampleCount; + var left = new float[length]; + var right = new float[length]; + var ch = input.Channels; + + switch (input.ChannelCount) + { + case 1: + Array.Copy(ch[0], left, length); + Array.Copy(ch[0], right, length); + break; + case 6: + case 8: + { + // L R C LFE Ls Rs (+ Lb Rb): centre and surrounds at -3 dB, LFE dropped. + const float minus3Db = 0.7071f; + for (var i = 0; i < length; i++) + { + var centre = ch[2][i] * minus3Db; + var leftSurround = ch[4][i] * minus3Db + (input.ChannelCount == 8 ? ch[6][i] * minus3Db : 0); + var rightSurround = ch[5][i] * minus3Db + (input.ChannelCount == 8 ? ch[7][i] * minus3Db : 0); + left[i] = ch[0][i] + centre + leftSurround; + right[i] = ch[1][i] + centre + rightSurround; + } + + break; + } + default: + // Unknown layouts: alternate channels between the two sides. + for (var c = 0; c < input.ChannelCount; c++) + { + var target = c % 2 == 0 ? left : right; + for (var i = 0; i < length; i++) + target[i] += ch[c][i]; + } + + var perSide = (input.ChannelCount + 1) / 2f; + for (var i = 0; i < length; i++) + { + left[i] /= perSide; + right[i] /= perSide; + } + + break; + } + + return new AudioBuffer([left, right], input.SampleRate) { Metadata = input.Metadata, Loop = input.Loop }; + } + + /// + /// Removes leading and trailing near-silence (below ), keeping a few + /// milliseconds so attacks are not clipped. Loop points are shifted along. + /// + public static AudioBuffer TrimSilence(AudioBuffer input, double thresholdDb = -60, double keepSeconds = 0.01) + { + var threshold = (float)Math.Pow(10, thresholdDb / 20); + var length = input.SampleCount; + var first = 0; + while (first < length && !IsAudible(input.Channels, first, threshold)) + first++; + var last = length - 1; + while (last > first && !IsAudible(input.Channels, last, threshold)) + last--; + + if (first >= length) + return input; // fully silent: leave it alone, the caller reports it + + var keep = (int)(input.SampleRate * keepSeconds); + first = Math.Max(0, first - keep); + last = Math.Min(length - 1, last + keep); + + // Never trim into a declared loop. + if (input.Loop is { } loop) + { + first = Math.Min(first, loop.Start); + last = Math.Max(last, loop.End - 1); + } + + if (first == 0 && last == length - 1) + return input; + + var newLength = last - first + 1; + var channels = input.Channels.Select(channel => channel.AsSpan(first, newLength).ToArray()).ToArray(); + var result = new AudioBuffer(channels, input.SampleRate) { Metadata = input.Metadata }; + if (input.Loop is { } region) + result.Loop = new LoopRegion(region.Start - first, region.End - first); + return result; + } + + private static bool IsAudible(float[][] channels, int index, float threshold) + { + foreach (var channel in channels) + { + if (Math.Abs(channel[index]) > threshold) + return true; + } + + return false; + } + + /// Replaces NaN and infinite samples (from damaged files) with silence; returns how many were replaced. + public static int ReplaceNonFinite(AudioBuffer buffer) + { + var replaced = 0; + foreach (var channel in buffer.Channels) + { + for (var i = 0; i < channel.Length; i++) + { + if (!float.IsFinite(channel[i])) + { + channel[i] = 0; + replaced++; + } + } + } + + return replaced; + } + + public static void ApplyGain(AudioBuffer buffer, double gainDb) + { + var gain = (float)Math.Pow(10, gainDb / 20); + foreach (var channel in buffer.Channels) + { + for (var i = 0; i < channel.Length; i++) + channel[i] *= gain; + } + } + + /// + /// Makes a song without loop points loop seamlessly: the last fade into + /// the opening, and the loop restarts right after that opening section. The first play-through is untouched. + /// + public static AudioBuffer CreateCrossfadeLoop(AudioBuffer input, double crossfadeSeconds) + { + var length = input.SampleCount; + var fade = (int)(input.SampleRate * crossfadeSeconds); + // Short songs get a proportionally shorter crossfade (at most a quarter of the song). + fade = Math.Min(fade, length / 4); + if (fade < input.SampleRate / 20) + { + input.Loop = new LoopRegion(0, length); + return input; + } + + var channels = new float[input.ChannelCount][]; + for (var c = 0; c < input.ChannelCount; c++) + { + var source = input.Channels[c]; + var output = (float[])source.Clone(); + var tailStart = length - fade; + for (var i = 0; i < fade; i++) + { + // Equal-power crossfade keeps the level steady across uncorrelated material. + var t = (i + 0.5) / fade; + var fadeOut = (float)Math.Cos(t * Math.PI / 2); + var fadeIn = (float)Math.Sin(t * Math.PI / 2); + output[tailStart + i] = source[tailStart + i] * fadeOut + source[i] * fadeIn; + } + + channels[c] = output; + } + + // After the crossfade the head has already played its first `fade` samples, so continue from there. + return new AudioBuffer(channels, input.SampleRate) { Metadata = input.Metadata, Loop = new LoopRegion(fade, length) }; + } + + /// + /// Moves the loop start forward onto a multiple of (the BRSTM block size), + /// extending the end with the audio that follows the original loop start so the loop stays seamless. + /// + public static AudioBuffer AlignLoopStart(AudioBuffer input, int alignment) + { + if (input.Loop is not { } loop || alignment <= 1) + return input; + + var shift = (alignment - loop.Start % alignment) % alignment; + // The loop ends playback, so everything after the loop end is dropped either way. + if (shift == 0) + { + if (loop.End == input.SampleCount) + return input; + var trimmed = input.Channels.Select(channel => channel.AsSpan(0, loop.End).ToArray()).ToArray(); + return new AudioBuffer(trimmed, input.SampleRate) { Metadata = input.Metadata, Loop = loop }; + } + + var newEnd = loop.End + shift; + var channels = new float[input.ChannelCount][]; + for (var c = 0; c < input.ChannelCount; c++) + { + var source = input.Channels[c]; + var output = new float[newEnd]; + Array.Copy(source, output, loop.End); + // Samples after the loop end are what plays after jumping back: loop.Start, loop.Start + 1, ... + for (var i = 0; i < shift; i++) + output[loop.End + i] = source[loop.Start + i % loop.Length]; + channels[c] = output; + } + + return new AudioBuffer(channels, input.SampleRate) { Metadata = input.Metadata, Loop = new LoopRegion(loop.Start + shift, newEnd) }; + } + + /// + /// Appends what plays after the loop jumps back (loop start onward) behind the loop end, so filters + /// and resamplers see continuous audio across the loop seam. The loop region itself is unchanged and + /// drops the extra audio again. + /// + public static AudioBuffer ExtendPastLoopEnd(AudioBuffer input, int extraSamples) + { + if (input.Loop is not { } loop || extraSamples <= 0) + return input; + + var newLength = loop.End + extraSamples; + var channels = new float[input.ChannelCount][]; + for (var c = 0; c < input.ChannelCount; c++) + { + var source = input.Channels[c]; + var output = new float[newLength]; + Array.Copy(source, output, loop.End); + for (var i = 0; i < extraSamples; i++) + output[loop.End + i] = source[loop.Start + i % loop.Length]; + channels[c] = output; + } + + return new AudioBuffer(channels, input.SampleRate) { Metadata = input.Metadata, Loop = loop }; + } + + /// Keeps at most , fading out the cut so it does not click. + public static AudioBuffer Truncate(AudioBuffer input, int maxSamples, double fadeSeconds = 2.0) + { + if (input.SampleCount <= maxSamples) + return input; + + var channels = input.Channels.Select(channel => channel.AsSpan(0, maxSamples).ToArray()).ToArray(); + var result = new AudioBuffer(channels, input.SampleRate) { Metadata = input.Metadata }; + if (input.Loop is { } loop && loop.Start < maxSamples - input.SampleRate) + result.Loop = new LoopRegion(loop.Start, Math.Min(loop.End, maxSamples)); + else + FadeOutTail(result, fadeSeconds); + return result; + } + + /// Converts to 16-bit with TPDF dither (and hard clipping as a final safety net). + public static short[] ToPcm16(float[] samples, int seed) + { + var random = new Random(seed); + var output = new short[samples.Length]; + for (var i = 0; i < samples.Length; i++) + { + var dither = (random.NextDouble() - random.NextDouble()) / 32768.0; + var value = Math.Round((samples[i] + dither) * 32767.0); + output[i] = (short)Math.Clamp(value, short.MinValue, short.MaxValue); + } + + return output; + } + + /// Short fade-out at the end of non-looping jingles so they never end on a click. + public static void FadeOutTail(AudioBuffer buffer, double seconds) + { + var length = Math.Min(buffer.SampleCount, (int)(buffer.SampleRate * seconds)); + if (length <= 1) + return; + foreach (var channel in buffer.Channels) + { + var start = channel.Length - length; + for (var i = 0; i < length; i++) + channel[start + i] *= 1f - (i + 1f) / length; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/LoopFinder.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/LoopFinder.cs new file mode 100644 index 00000000..d26a0bfc --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/LoopFinder.cs @@ -0,0 +1,293 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// A loop found in a song: playback runs to and jumps back to . +/// How alike the music around both points is (1 = identical). +public readonly record struct LoopMatch(int Start, int End, double Similarity); + +/// +/// Finds a seamless loop in a song that has no loop points, the way a person editing it would: most songs repeat +/// a section (a chorus, a whole verse-chorus cycle), so playback can jump from a late repeat back to an earlier one +/// and nobody hears the cut. +/// +/// 1. The song is cut into 64 ms frames, each described by its loudness in 24 frequency bands.
+/// 2. For every jump length, the frames are compared with the frames that same distance earlier; the place where +/// several seconds on both sides of the jump match best wins. Long loops and late jump points are preferred.
+/// 3. The frame-level guess is refined to the exact sample by cross-correlating the waveforms. +///
+///
+public static class LoopFinder +{ + private const int FrameSize = 2048; + private const int BandCount = 24; + private const double MaxAnalysisSeconds = 10 * 60; + + /// Frames quieter than this (silence, the tail of a fade) carry no usable shape. + private const float SilenceDb = -50; + + /// Similarity lost per dB of level difference between two frames. + private const float LevelCostPerDb = 0.03f; + + /// Average similarity (cosine of the band spectra) the music must reach on both sides of the jump. + public const double MinSimilarity = 0.95; + + /// The song (any channel count). + /// Shortest loop worth using; shorter repeats would get tiring in game. + /// How much music around the jump must match. + /// Stops the search. + /// The best loop, or null when the song does not repeat cleanly enough. + public static LoopMatch? Find(AudioBuffer audio, double minLoopSeconds = 12, double matchSeconds = 6, CancellationToken cancellationToken = default) + { + var mono = MixToMono(audio, (int)Math.Min(audio.SampleCount, MaxAnalysisSeconds * audio.SampleRate)); + var frames = Features(mono, out var levels); + var frameCount = frames.Length; + var half = Math.Max(2, (int)(matchSeconds * audio.SampleRate / FrameSize / 2)); + var window = 2 * half; + var minLag = Math.Max(window, (int)(minLoopSeconds * audio.SampleRate / FrameSize)); + if (frameCount < minLag + window) + return null; + + var similarity = new float[frameCount]; + var bestScore = double.NegativeInfinity; + int bestEnd = 0, + bestLag = 0; + double bestMean = 0; + for (var lag = minLag; lag + half < frameCount - half; lag++) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var t = lag; t < frameCount; t++) + similarity[t] = Similarity(frames, levels, t, t - lag); + + // The jump point needs `half` frames of matching music before it and after it (the song continues + // past the jump, and that continuation is what the earlier section has to sound like). + double sum = 0; + for (var t = lag; t < lag + window; t++) + sum += similarity[t]; + for (var end = lag + half; end + half <= frameCount; end++) + { + if (end > lag + half) + sum += similarity[end + half - 1] - similarity[end - half - 1]; + var mean = sum / window; + if (mean < MinSimilarity) + continue; + // Prefer long loops (less repetition) and late jumps (more of the song is heard). + var score = mean + 0.04 * lag / frameCount + 0.02 * end / frameCount; + if (score > bestScore) + { + bestScore = score; + bestMean = mean; + bestEnd = end; + bestLag = lag; + } + } + } + + if (bestLag == 0) + return null; + + var loopEnd = bestEnd * FrameSize; + var start = Refine(mono, loopEnd, (bestEnd - bestLag) * FrameSize); + return start is { } exact && loopEnd - exact >= minLoopSeconds * audio.SampleRate * 0.9 ? new LoopMatch(exact, loopEnd, bestMean) : null; + } + + /// + /// Cuts the song at the loop end and blends the last few milliseconds into what precedes the loop start, so the + /// jump continues the waveform without a click. + /// + public static AudioBuffer Apply(AudioBuffer audio, LoopMatch loop, double blendSeconds = 0.03) + { + var blend = Math.Min((int)(audio.SampleRate * blendSeconds), loop.Start); + var channels = new float[audio.ChannelCount][]; + for (var c = 0; c < audio.ChannelCount; c++) + { + var source = audio.Channels[c]; + var output = source.AsSpan(0, loop.End).ToArray(); + for (var i = 0; i < blend; i++) + { + // The two sides are near-identical by construction, so a linear blend keeps the level steady. + var weight = (i + 0.5f) / blend; + var at = loop.End - blend + i; + output[at] = source[at] * (1 - weight) + source[loop.Start - blend + i] * weight; + } + + channels[c] = output; + } + + return new AudioBuffer(channels, audio.SampleRate) { Metadata = audio.Metadata, Loop = new LoopRegion(loop.Start, loop.End) }; + } + + private static float[] MixToMono(AudioBuffer audio, int length) + { + var mono = new float[length]; + foreach (var channel in audio.Channels) + { + for (var i = 0; i < length; i++) + mono[i] += channel[i]; + } + + var scale = 1f / audio.ChannelCount; + for (var i = 0; i < length; i++) + mono[i] *= scale; + return mono; + } + + /// Per frame: log energy in bands spaced evenly on a log-frequency scale, centred and normalised to unit length. + private static float[][] Features(float[] mono, out float[] levels) + { + var frameCount = mono.Length / FrameSize; + var features = new float[frameCount][]; + levels = new float[frameCount]; + + // Band edges in FFT bins: roughly 60 Hz to the top of the spectrum, evenly spaced in log frequency. + var bins = FrameSize / 2; + var edges = new int[BandCount + 1]; + for (var b = 0; b <= BandCount; b++) + edges[b] = Math.Clamp((int)Math.Round(4 * Math.Pow(bins / 4.0, b / (double)BandCount)), 1, bins); + + var window = new float[FrameSize]; + for (var i = 0; i < FrameSize; i++) + window[i] = (float)(0.5 - 0.5 * Math.Cos(2 * Math.PI * i / (FrameSize - 1))); + + var real = new double[FrameSize]; + var imaginary = new double[FrameSize]; + var bands = new double[BandCount]; + for (var f = 0; f < frameCount; f++) + { + double energy = 0; + for (var i = 0; i < FrameSize; i++) + { + var x = mono[f * FrameSize + i]; + energy += x * x; + real[i] = x * window[i]; + imaginary[i] = 0; + } + + levels[f] = (float)(10 * Math.Log10(energy / FrameSize + 1e-12)); + Fft(real, imaginary); + + for (var b = 0; b < BandCount; b++) + { + double sum = 0; + for (var k = edges[b]; k < Math.Max(edges[b] + 1, edges[b + 1]); k++) + sum += real[k] * real[k] + imaginary[k] * imaginary[k]; + bands[b] = Math.Log10(sum + 1e-10); + } + + var mean = bands.Average(); + double norm = 0; + for (var b = 0; b < BandCount; b++) + { + bands[b] -= mean; + norm += bands[b] * bands[b]; + } + + norm = Math.Sqrt(norm); + var vector = new float[BandCount]; + if (norm > 1e-9) + { + for (var b = 0; b < BandCount; b++) + vector[b] = (float)(bands[b] / norm); + } + + features[f] = vector; + } + + return features; + } + + /// + /// Spectral shape (cosine) minus a cost for any level difference, so a fade-out or a quieter repeat does not + /// pass for the same music; silent frames never match. + /// + private static float Similarity(float[][] frames, float[] levels, int a, int b) + { + if (levels[a] < SilenceDb || levels[b] < SilenceDb) + return 0f; + var dot = 0f; + var x = frames[a]; + var y = frames[b]; + for (var i = 0; i < x.Length; i++) + dot += x[i] * y[i]; + return dot - LevelCostPerDb * Math.Abs(levels[a] - levels[b]); + } + + /// The loop start (near ) whose surrounding waveform best matches the audio around . + private static int? Refine(float[] mono, int end, int roughStart) + { + const int span = 1024; + if (end - span < 0 || end + span > mono.Length) + return null; + + double targetEnergy = 0; + for (var i = -span; i < span; i++) + targetEnergy += (double)mono[end + i] * mono[end + i]; + + var best = double.NegativeInfinity; + int? bestStart = null; + for (var candidate = roughStart - FrameSize; candidate <= roughStart + FrameSize; candidate++) + { + if (candidate - span < 0 || candidate + span > mono.Length || candidate >= end) + continue; + double cross = 0, + energy = 0; + for (var i = -span; i < span; i++) + { + var x = mono[candidate + i]; + cross += x * mono[end + i]; + energy += x * x; + } + + var correlation = cross / Math.Sqrt(Math.Max(energy * targetEnergy, 1e-20)); + if (correlation > best) + { + best = correlation; + bestStart = candidate; + } + } + + return bestStart; + } + + /// In-place iterative radix-2 FFT (the length must be a power of two). + private static void Fft(double[] real, double[] imaginary) + { + var n = real.Length; + for (int i = 1, j = 0; i < n; i++) + { + var bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) + j ^= bit; + j ^= bit; + if (i < j) + { + (real[i], real[j]) = (real[j], real[i]); + (imaginary[i], imaginary[j]) = (imaginary[j], imaginary[i]); + } + } + + for (var length = 2; length <= n; length <<= 1) + { + var angle = -2 * Math.PI / length; + double stepReal = Math.Cos(angle), + stepImaginary = Math.Sin(angle); + for (var i = 0; i < n; i += length) + { + double wReal = 1, + wImaginary = 0; + for (var k = 0; k < length / 2; k++) + { + var a = i + k; + var b = a + length / 2; + var tReal = real[b] * wReal - imaginary[b] * wImaginary; + var tImaginary = real[b] * wImaginary + imaginary[b] * wReal; + real[b] = real[a] - tReal; + imaginary[b] = imaginary[a] - tImaginary; + real[a] += tReal; + imaginary[a] += tImaginary; + var nextReal = wReal * stepReal - wImaginary * stepImaginary; + wImaginary = wReal * stepImaginary + wImaginary * stepReal; + wReal = nextReal; + } + } + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/LoudnessMeter.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/LoudnessMeter.cs new file mode 100644 index 00000000..f02144c0 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/LoudnessMeter.cs @@ -0,0 +1,200 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// Result of a loudness measurement. +/// ITU-R BS.1770-4 / EBU R128 integrated loudness (gated), in LUFS. +/// Largest absolute sample value (1.0 = full scale). +public readonly record struct LoudnessResult(double IntegratedLufs, double SamplePeak) +{ + public double SamplePeakDb => SamplePeak <= 0 ? double.NegativeInfinity : 20 * Math.Log10(SamplePeak); + public bool IsSilent => double.IsNegativeInfinity(IntegratedLufs) || IntegratedLufs < -69.9; +} + +/// +/// ITU-R BS.1770-4 integrated loudness: K-weighting, 400 ms blocks with 75% overlap, +/// an absolute gate at -70 LUFS and a relative gate 10 LU below the ungated level. +/// +public static class LoudnessMeter +{ + private const double AbsoluteGate = -70.0; + private const double RelativeGate = -10.0; + + public static LoudnessResult Measure(AudioBuffer buffer) => Measure(buffer.Channels, buffer.SampleRate); + + public static LoudnessResult Measure(float[][] channels, int sampleRate, int start = 0, int length = -1) + { + var total = channels[0].Length; + if (length < 0) + length = total - start; + length = Math.Clamp(length, 0, total - start); + + var blockSize = (int)Math.Round(sampleRate * 0.4); + var hop = Math.Max(1, blockSize / 4); + var hopCount = length / hop; + if (blockSize <= 0 || length <= 0) + return new LoudnessResult(double.NegativeInfinity, Peak(channels, start, length)); + + // BS.1770 needs at least one 400 ms block. Shorter clips (a quick jingle) get the ungated mean + // loudness of the whole clip instead of being reported as silent. + if (hopCount < 4) + return MeasureShort(channels, sampleRate, start, length); + + // Sum of squared K-weighted samples per hop (100 ms) for every channel; blocks are 4 hops. + var hopEnergy = new double[hopCount]; + var peak = 0.0; + foreach (var channel in channels) + { + var filter = KWeighting.Create(sampleRate); + for (var h = 0; h < hopCount; h++) + { + double energy = 0; + var offset = start + h * hop; + for (var i = 0; i < hop; i++) + { + var x = channel[offset + i]; + var abs = Math.Abs(x); + if (abs > peak) + peak = abs; + var y = filter.Process(x); + energy += y * y; + } + + // L/R/C weight 1.0; surround weights do not apply to the stereo we produce. + hopEnergy[h] += energy; + } + } + + var blockCount = hopCount - 3; + if (blockCount <= 0) + return new LoudnessResult(double.NegativeInfinity, peak); + + var blockLoudness = new double[blockCount]; + var blockPower = new double[blockCount]; + var samplesPerBlock = hop * 4.0; + for (var b = 0; b < blockCount; b++) + { + var power = (hopEnergy[b] + hopEnergy[b + 1] + hopEnergy[b + 2] + hopEnergy[b + 3]) / samplesPerBlock; + blockPower[b] = power; + blockLoudness[b] = PowerToLufs(power); + } + + // Absolute gate + double sum = 0; + var count = 0; + for (var b = 0; b < blockCount; b++) + { + if (blockLoudness[b] > AbsoluteGate) + { + sum += blockPower[b]; + count++; + } + } + + if (count == 0) + return new LoudnessResult(double.NegativeInfinity, peak); + + // Relative gate + var relativeThreshold = PowerToLufs(sum / count) + RelativeGate; + sum = 0; + count = 0; + for (var b = 0; b < blockCount; b++) + { + if (blockLoudness[b] > AbsoluteGate && blockLoudness[b] > relativeThreshold) + { + sum += blockPower[b]; + count++; + } + } + + return count == 0 ? new LoudnessResult(double.NegativeInfinity, peak) : new LoudnessResult(PowerToLufs(sum / count), peak); + } + + private static LoudnessResult MeasureShort(float[][] channels, int sampleRate, int start, int length) + { + double energy = 0; + foreach (var channel in channels) + { + var filter = KWeighting.Create(sampleRate); + for (var i = start; i < start + length; i++) + { + var y = filter.Process(channel[i]); + energy += y * y; + } + } + + var lufs = PowerToLufs(energy / length); + return new LoudnessResult(lufs > AbsoluteGate ? lufs : double.NegativeInfinity, Peak(channels, start, length)); + } + + private static double PowerToLufs(double power) => power <= 0 ? double.NegativeInfinity : -0.691 + 10 * Math.Log10(power); + + private static double Peak(float[][] channels, int start, int length) + { + var peak = 0.0; + foreach (var channel in channels) + { + for (var i = start; i < start + length; i++) + peak = Math.Max(peak, Math.Abs(channel[i])); + } + + return peak; + } + + /// The two-stage K-weighting pre-filter, derived for any sample rate (same as libebur128/pyloudnorm). + private sealed class KWeighting + { + private readonly Biquad _shelf; + private readonly Biquad _highPass; + + private KWeighting(Biquad shelf, Biquad highPass) + { + _shelf = shelf; + _highPass = highPass; + } + + public static KWeighting Create(int sampleRate) + { + // Stage 1: high shelf modelling the acoustic effect of the head. + { + const double f0 = 1681.974450955533; + const double g = 3.999843853973347; + const double q = 0.7071752369554196; + var k = Math.Tan(Math.PI * f0 / sampleRate); + var vh = Math.Pow(10.0, g / 20.0); + var vb = Math.Pow(vh, 0.4996667741545416); + var a0 = 1.0 + k / q + k * k; + var shelf = new Biquad( + (vh + vb * k / q + k * k) / a0, + 2.0 * (k * k - vh) / a0, + (vh - vb * k / q + k * k) / a0, + 2.0 * (k * k - 1.0) / a0, + (1.0 - k / q + k * k) / a0 + ); + + // Stage 2: RLB high-pass. + const double f1 = 38.13547087602444; + const double q1 = 0.5003270373238773; + var k1 = Math.Tan(Math.PI * f1 / sampleRate); + var a01 = 1.0 + k1 / q1 + k1 * k1; + var highPass = new Biquad(1.0, -2.0, 1.0, 2.0 * (k1 * k1 - 1.0) / a01, (1.0 - k1 / q1 + k1 * k1) / a01); + return new KWeighting(shelf, highPass); + } + } + + public double Process(double x) => _highPass.Process(_shelf.Process(x)); + } + + private sealed class Biquad(double b0, double b1, double b2, double a1, double a2) + { + private double _z1; + private double _z2; + + public double Process(double x) + { + // Transposed direct form II + var y = b0 * x + _z1; + _z1 = b1 * x - a1 * y + _z2; + _z2 = b2 * x - a2 * y; + return y; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/PeakLimiter.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/PeakLimiter.cs new file mode 100644 index 00000000..4c9deea6 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/PeakLimiter.cs @@ -0,0 +1,107 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// +/// Offline, stereo-linked look-ahead peak limiter. It only acts on the few peaks that would clip after +/// loudness matching, so quiet songs can be raised to the target without distortion. +/// +public static class PeakLimiter +{ + /// The largest gain reduction applied, in dB (0 when nothing was limited). + public static double Apply(AudioBuffer buffer, double ceilingDb = -1.0, double lookAheadSeconds = 0.005, double releaseSeconds = 0.12) + { + var length = buffer.SampleCount; + if (length == 0) + return 0; + + // Gain each sample needs on its own to stay under the ceiling (1 = untouched). + var ceiling = (float)Math.Pow(10, ceilingDb / 20); + var required = new float[length]; + var anyOver = false; + for (var i = 0; i < length; i++) + { + var peak = 0f; + foreach (var channel in buffer.Channels) + peak = Math.Max(peak, Math.Abs(channel[i])); + required[i] = peak > ceiling ? ceiling / peak : 1f; + anyOver |= peak > ceiling; + } + + if (!anyOver) + return 0; + + var lookAhead = Math.Max(1, (int)(buffer.SampleRate * lookAheadSeconds)); + + // 1) Minimum over a symmetric window of +/- lookAhead: gain is already down when the peak arrives. + var minimum = SlidingMinimum(required, lookAhead); + + // 2) Average over +/- lookAhead/2: a smooth ramp that still never exceeds the required gain, because + // every averaged value already includes the peak's own requirement. A running sum keeps it O(n) + // without another full-length array. + // 3) Slow release so the gain does not pump between peaks. + // The gains are written back into `required`, which is no longer needed. + var half = Math.Max(1, lookAhead / 2); + var releaseAlpha = 1 - Math.Exp(-1.0 / (buffer.SampleRate * releaseSeconds)); + double windowSum = 0; + var windowFrom = 0; + var windowTo = -1; + var gain = 1.0; + var maxReduction = 1.0; + for (var i = 0; i < length; i++) + { + var to = Math.Min(length - 1, i + half); + while (windowTo < to) + windowSum += minimum[++windowTo]; + var from = Math.Max(0, i - half); + while (windowFrom < from) + windowSum -= minimum[windowFrom++]; + + var smooth = windowSum / (windowTo - windowFrom + 1); + gain = smooth < gain ? smooth : gain + (smooth - gain) * releaseAlpha; + required[i] = (float)gain; + maxReduction = Math.Min(maxReduction, gain); + } + + foreach (var channel in buffer.Channels) + { + for (var i = 0; i < length; i++) + channel[i] *= required[i]; + } + + return -20 * Math.Log10(maxReduction); + } + + /// Minimum of every window [i - radius, i + radius], with a monotonic queue that only spans one window. + private static float[] SlidingMinimum(float[] values, int radius) + { + var length = values.Length; + var result = new float[length]; + var capacity = 2 * radius + 2; + var queue = new int[capacity]; // ring buffer of indices with increasing values + int head = 0, + count = 0; + var next = 0; + + for (var i = 0; i < length; i++) + { + // Push everything up to i + radius, dropping queued values that can never be the minimum again. + var limit = Math.Min(length - 1, i + radius); + while (next <= limit) + { + while (count > 0 && values[queue[(head + count - 1) % capacity]] >= values[next]) + count--; + queue[(head + count) % capacity] = next++; + count++; + } + + while (queue[head] < i - radius) + { + head = (head + 1) % capacity; + count--; + } + + result[i] = values[queue[head]]; + } + + return result; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/Resampler.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/Resampler.cs new file mode 100644 index 00000000..2eecb580 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/Resampler.cs @@ -0,0 +1,145 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// +/// Band-limited (Kaiser windowed sinc) sample rate converter. Used to bring songs to the 32 kHz +/// Mario Kart Wii streams use, and to make the sped-up final lap version. +/// +public static class Resampler +{ + private const int TapsPerSide = 32; + private const int Phases = 512; + private const double KaiserBeta = 9.0; + + /// + /// Plays back times faster while producing samples at + /// . With speed 1 this is a plain sample rate conversion. + /// + public static float[] Resample(float[] input, int inputRate, int outputRate, double speed = 1.0) + { + if (input.Length == 0) + return []; + if (speed <= 0) + throw new ArgumentOutOfRangeException(nameof(speed)); + + // How far we advance through the input for every output sample. + var step = inputRate * speed / outputRate; + if (Math.Abs(step - 1.0) < 1e-9) + return (float[])input.Clone(); + + var outputLength = (int)Math.Floor(input.Length / step); + var output = new float[outputLength]; + + // When decimating, the filter cutoff has to move down to the new Nyquist frequency. + var cutoff = Math.Min(1.0, 1.0 / step) * 0.97; + var table = BuildTable(cutoff, out var tapsPerSide); + var tableWidth = tapsPerSide * 2; + + for (var i = 0; i < outputLength; i++) + { + var position = i * step; + var index = (int)Math.Floor(position); + var fraction = position - index; + var phaseExact = fraction * Phases; + var phase = (int)phaseExact; + var phaseBlend = (float)(phaseExact - phase); + + var rowA = phase * tableWidth; + var rowB = (phase + 1) * tableWidth; + double sum = 0; + var first = index - tapsPerSide + 1; + for (var t = 0; t < tableWidth; t++) + { + var sampleIndex = first + t; + if ((uint)sampleIndex >= (uint)input.Length) + continue; + var weight = table[rowA + t] + (table[rowB + t] - table[rowA + t]) * phaseBlend; + sum += input[sampleIndex] * weight; + } + + output[i] = (float)sum; + } + + return output; + } + + public static AudioBuffer Resample(AudioBuffer buffer, int outputRate, double speed = 1.0) + { + var channels = new float[buffer.ChannelCount][]; + Parallel.For(0, buffer.ChannelCount, c => channels[c] = Resample(buffer.Channels[c], buffer.SampleRate, outputRate, speed)); + + var ratio = outputRate / (buffer.SampleRate * speed); + var result = new AudioBuffer(channels, outputRate) { Metadata = buffer.Metadata }; + if (buffer.Loop is { } loop) + { + var start = (int)Math.Round(loop.Start * ratio); + var end = Math.Min(result.SampleCount, (int)Math.Round(loop.End * ratio)); + var region = new LoopRegion(start, end); + if (region.IsValid(result.SampleCount)) + result.Loop = region; + } + + return result; + } + + private static readonly Dictionary TableCache = new(); + + private static float[] BuildTable(double cutoff, out int tapsPerSide) + { + // Widen the kernel when the cutoff drops so the transition band stays equally steep. + tapsPerSide = (int)Math.Ceiling(TapsPerSide / cutoff); + var key = (int)Math.Round(cutoff * 100000); + lock (TableCache) + { + if (TableCache.TryGetValue(key, out var cached)) + return cached; + } + + var width = tapsPerSide * 2; + // One extra phase row so interpolation between the last phase and the next tap stays in bounds. + var table = new float[(Phases + 1) * width]; + var denominator = BesselI0(KaiserBeta); + for (var phase = 0; phase <= Phases; phase++) + { + var fraction = phase / (double)Phases; + double norm = 0; + for (var t = 0; t < width; t++) + { + // Tap t multiplies input[index - tapsPerSide + 1 + t]; its distance from the read position: + var x = t - tapsPerSide + 1 - fraction; + var sinc = Math.Abs(x) < 1e-12 ? 1.0 : Math.Sin(Math.PI * x * cutoff) / (Math.PI * x * cutoff); + var ratio = x / tapsPerSide; + var window = Math.Abs(ratio) >= 1 ? 0 : BesselI0(KaiserBeta * Math.Sqrt(1 - ratio * ratio)) / denominator; + var value = sinc * window * cutoff; + table[phase * width + t] = (float)value; + norm += value; + } + + // Normalise each phase to unity DC gain. + if (norm > 1e-9) + { + for (var t = 0; t < width; t++) + table[phase * width + t] = (float)(table[phase * width + t] / norm); + } + } + + lock (TableCache) + TableCache[key] = table; + return table; + } + + private static double BesselI0(double x) + { + double sum = 1, + term = 1; + var halfX = x / 2; + for (var k = 1; k < 50; k++) + { + term *= halfX / k * (halfX / k); + sum += term; + if (term < sum * 1e-12) + break; + } + + return sum; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Dsp/TimeStretcher.cs b/WheelWizard/Features/CustomMusic/Audio/Dsp/TimeStretcher.cs new file mode 100644 index 00000000..8b261d1c --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Dsp/TimeStretcher.cs @@ -0,0 +1,136 @@ +namespace WheelWizard.CustomMusic.Audio.Dsp; + +/// +/// WSOLA time stretching: changes tempo without changing pitch. Used for the "faster, same pitch" +/// final lap style, which sounds closest to the original game's final lap tracks. +/// +public static class TimeStretcher +{ + public static AudioBuffer ChangeTempo(AudioBuffer input, double speed) + { + if (Math.Abs(speed - 1.0) < 1e-6 || input.SampleCount == 0) + return input; + + var sampleRate = input.SampleRate; + var frameSize = Math.Max(256, (int)(sampleRate * 0.046)) & ~1; + var synthesisHop = frameSize / 2; + var analysisHop = synthesisHop * speed; + var tolerance = (int)(sampleRate * 0.012); + + var length = input.SampleCount; + var outputLength = (int)(length / speed); + var frameCount = outputLength / synthesisHop; + var window = new float[frameSize]; + for (var i = 0; i < frameSize; i++) + window[i] = (float)(0.5 - 0.5 * Math.Cos(2 * Math.PI * i / frameSize)); + + // A mono guide signal for finding the best splice points; both channels use the same offsets. + var guide = new float[length]; + foreach (var channel in input.Channels) + { + for (var i = 0; i < length; i++) + guide[i] += channel[i]; + } + + var outputs = input.Channels.Select(_ => new float[outputLength + frameSize]).ToArray(); + var normalisation = new float[outputLength + frameSize]; + var previousPosition = 0; + + for (var k = 0; k < frameCount; k++) + { + var nominal = (int)(k * analysisHop); + var position = nominal; + if (k > 0) + { + // The ideal continuation of the previous frame, compared against nearby candidates. + var natural = previousPosition + synthesisHop; + position = FindBestOffset(guide, natural, nominal, tolerance, synthesisHop); + } + + position = Math.Clamp(position, 0, Math.Max(0, length - frameSize)); + var outputStart = k * synthesisHop; + for (var c = 0; c < outputs.Length; c++) + { + var source = input.Channels[c]; + var target = outputs[c]; + for (var i = 0; i < frameSize && position + i < length; i++) + target[outputStart + i] += source[position + i] * window[i]; + } + + for (var i = 0; i < frameSize; i++) + normalisation[outputStart + i] += window[i]; + + previousPosition = position; + } + + var channels = new float[outputs.Length][]; + for (var c = 0; c < outputs.Length; c++) + { + var result = new float[outputLength]; + for (var i = 0; i < outputLength; i++) + result[i] = normalisation[i] > 1e-3f ? outputs[c][i] / normalisation[i] : outputs[c][i]; + channels[c] = result; + } + + var stretched = new AudioBuffer(channels, sampleRate) { Metadata = input.Metadata }; + if (input.Loop is { } loop) + { + var region = new LoopRegion((int)(loop.Start / speed), Math.Min(outputLength, (int)(loop.End / speed))); + if (region.IsValid(outputLength)) + stretched.Loop = region; + } + + return stretched; + } + + private static int FindBestOffset(float[] guide, int natural, int nominal, int tolerance, int compareLength) + { + var length = guide.Length; + if (natural < 0 || natural + compareLength >= length) + return nominal; + + // Coarse search on every 4th offset and sample, then refine around the winner. + var best = nominal; + var bestScore = double.NegativeInfinity; + for (var offset = -tolerance; offset <= tolerance; offset += 4) + { + var score = Correlate(guide, natural, nominal + offset, compareLength, 4); + if (score > bestScore) + { + bestScore = score; + best = nominal + offset; + } + } + + var coarse = best; + bestScore = double.NegativeInfinity; + for (var offset = -4; offset <= 4; offset++) + { + var score = Correlate(guide, natural, coarse + offset, compareLength, 1); + if (score > bestScore) + { + bestScore = score; + best = coarse + offset; + } + } + + return best; + } + + private static double Correlate(float[] guide, int a, int b, int length, int step) + { + if (b < 0 || b + length >= guide.Length) + return double.NegativeInfinity; + + // Normalised by the candidate's energy so loud passages do not win just for being loud. + double sum = 0; + double energy = 1e-9; + for (var i = 0; i < length; i += step) + { + sum += guide[a + i] * guide[b + i]; + energy += guide[b + i] * guide[b + i]; + } + + return sum / Math.Sqrt(energy); + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Formats/Brstm.cs b/WheelWizard/Features/CustomMusic/Audio/Formats/Brstm.cs new file mode 100644 index 00000000..f767ac3b --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/Brstm.cs @@ -0,0 +1,518 @@ +using System.Buffers.Binary; +using System.Text; +using WheelWizard.CustomMusic.Audio.Codecs; + +namespace WheelWizard.CustomMusic.Audio.Formats; + +/// Constants shared by the BRSTM reader and writer (NintendoWare for Revolution stream files). +public static class Brstm +{ + /// Bytes per channel in one interleave block; every retail Mario Kart Wii BRSTM uses 0x2000. + public const int InterleaveBytes = 0x2000; + + /// Samples per interleave block for DSP-ADPCM (0x2000 bytes / 8 bytes * 14 samples). + public const int SamplesPerBlock = InterleaveBytes / DspAdpcm.BytesPerFrame * DspAdpcm.SamplesPerFrame; + + /// Offset of the volume byte Pulsar (and CTGP) read from a BRSTM header; 0 means "unchanged". + public const int PulsarVolumeOffset = 0x3F; + + public const byte CodecPcm8 = 0; + public const byte CodecPcm16 = 1; + public const byte CodecDspAdpcm = 2; + + internal const int OffsetMarker = 0x01000000; + internal const int OffsetMarkerTrackV2 = 0x01010000; +} + +/// A stereo-or-mono stream ready to be written as a BRSTM. +public sealed class BrstmStream +{ + public required short[][] Channels { get; init; } + public int SampleRate { get; init; } = 32000; + public bool Looping { get; init; } + public int LoopStart { get; init; } + + /// Optional Pulsar volume byte (1..127). Null leaves the header byte at 0. + public byte? PulsarVolume { get; init; } +} + +public static class BrstmWriter +{ + public static byte[] Write(BrstmStream stream) + { + var channelCount = stream.Channels.Length; + if (channelCount is < 1 or > 16) + throw new ArgumentException("A BRSTM needs between 1 and 16 channels.", nameof(stream)); + + var sampleCount = stream.Channels[0].Length; + if (sampleCount == 0 || stream.Channels.Any(c => c.Length != sampleCount)) + throw new ArgumentException("All channels need the same, non-zero length.", nameof(stream)); + if (stream.Looping && (stream.LoopStart < 0 || stream.LoopStart >= sampleCount)) + throw new ArgumentOutOfRangeException(nameof(stream), "The loop start must fall inside the audio."); + if (stream.SampleRate is <= 0 or > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(stream), "BRSTM stores the sample rate as 16 bits."); + + var encoded = new DspAdpcm.EncodedChannel[channelCount]; + Parallel.For(0, channelCount, i => encoded[i] = DspAdpcm.Encode(stream.Channels[i])); + + return Write(stream, encoded); + } + + internal static byte[] Write(BrstmStream stream, DspAdpcm.EncodedChannel[] encoded) + { + var channelCount = encoded.Length; + var sampleCount = stream.Channels[0].Length; + // Mono gets one track; everything else is paired into stereo tracks like the retail files. + var trackCount = (channelCount + 1) / 2; + + var samplesPerBlock = Brstm.SamplesPerBlock; + var blockCount = (sampleCount + samplesPerBlock - 1) / samplesPerBlock; + var lastBlockSamples = sampleCount - (blockCount - 1) * samplesPerBlock; + var lastBlockBytes = DspAdpcm.SampleCountToByteCount(lastBlockSamples); + var lastBlockPadded = Align(lastBlockBytes, 0x20); + var channelDataSize = Align(DspAdpcm.SampleCountToByteCount(sampleCount), 0x20); + + const int headTableSize = 8 * 3; + const int head1Size = 0x34; + const int trackInfoSize = 0x0C; + const int channelInfoSize = 0x38; + var head2Size = 4 + 8 * trackCount + trackInfoSize * trackCount; + var head3Size = 4 + 8 * channelCount + channelInfoSize * channelCount; + var headSize = Align(8 + headTableSize + head1Size + head2Size + head3Size, 0x20); + + var seekEntryCount = (sampleCount + samplesPerBlock - 1) / samplesPerBlock; + var adpcSize = Align(8 + seekEntryCount * channelCount * 4, 0x20); + const int headOffset = 0x40; + var adpcOffset = headOffset + headSize; + var dataOffset = adpcOffset + adpcSize; + var dataSize = 0x20 + channelDataSize * channelCount; + var fileSize = dataOffset + dataSize; + + var file = new byte[fileSize]; + var span = file.AsSpan(); + + // RSTM header + WriteAscii(span, 0x00, "RSTM"); + WriteU16(span, 0x04, 0xFEFF); + WriteU16(span, 0x06, 0x0100); + WriteS32(span, 0x08, fileSize); + WriteU16(span, 0x0C, 0x40); + WriteU16(span, 0x0E, 2); + WriteS32(span, 0x10, headOffset); + WriteS32(span, 0x14, headSize); + WriteS32(span, 0x18, adpcOffset); + WriteS32(span, 0x1C, adpcSize); + WriteS32(span, 0x20, dataOffset); + WriteS32(span, 0x24, dataSize); + if (stream.PulsarVolume is { } volume && volume > 0) + file[Brstm.PulsarVolumeOffset] = Math.Min(volume, (byte)0x7F); + + // HEAD chunk + var head = span[headOffset..]; + WriteAscii(head, 0x00, "HEAD"); + WriteS32(head, 0x04, headSize); + WriteS32(head, 0x08, Brstm.OffsetMarker); + WriteS32(head, 0x0C, headTableSize); + WriteS32(head, 0x10, Brstm.OffsetMarker); + WriteS32(head, 0x14, headTableSize + head1Size); + WriteS32(head, 0x18, Brstm.OffsetMarker); + WriteS32(head, 0x1C, headTableSize + head1Size + head2Size); + + // Offsets inside HEAD are relative to HEAD + 8. + var body = head[8..]; + + // Part 1: stream info + var p1 = body[headTableSize..]; + p1[0x00] = Brstm.CodecDspAdpcm; + p1[0x01] = (byte)(stream.Looping ? 1 : 0); + p1[0x02] = (byte)channelCount; + WriteU16(p1, 0x04, (ushort)stream.SampleRate); + WriteS32(p1, 0x08, stream.Looping ? stream.LoopStart : 0); + WriteS32(p1, 0x0C, sampleCount); + WriteS32(p1, 0x10, dataOffset + 0x20); + WriteS32(p1, 0x14, blockCount); + WriteS32(p1, 0x18, Brstm.InterleaveBytes); + WriteS32(p1, 0x1C, samplesPerBlock); + WriteS32(p1, 0x20, lastBlockBytes); + WriteS32(p1, 0x24, lastBlockSamples); + WriteS32(p1, 0x28, lastBlockPadded); + WriteS32(p1, 0x2C, samplesPerBlock); + WriteS32(p1, 0x30, 4); + + // Part 2: tracks (the "standard" layout with volume/pan that retail files use) + var p2Offset = headTableSize + head1Size; + var p2 = body[p2Offset..]; + p2[0x00] = (byte)trackCount; + p2[0x01] = 1; + var trackInfoBase = p2Offset + 4 + 8 * trackCount; + for (var t = 0; t < trackCount; t++) + { + WriteS32(p2, 4 + t * 8, Brstm.OffsetMarkerTrackV2); + WriteS32(p2, 8 + t * 8, trackInfoBase + t * trackInfoSize); + + var info = body[(trackInfoBase + t * trackInfoSize)..]; + var left = t * 2; + var channelsInTrack = left + 1 < channelCount ? 2 : 1; + info[0x00] = 0x7F; // volume + info[0x01] = 0x40; // centred pan + info[0x08] = (byte)channelsInTrack; + info[0x09] = (byte)left; + info[0x0A] = (byte)(channelsInTrack == 2 ? left + 1 : 0); + } + + // Part 3: channels with their ADPCM coefficients and start/loop contexts + var p3Offset = p2Offset + head2Size; + var p3 = body[p3Offset..]; + p3[0x00] = (byte)channelCount; + var channelInfoBase = p3Offset + 4 + 8 * channelCount; + for (var c = 0; c < channelCount; c++) + { + var infoOffset = channelInfoBase + c * channelInfoSize; + WriteS32(p3, 4 + c * 8, Brstm.OffsetMarker); + WriteS32(p3, 8 + c * 8, infoOffset); + + var channel = encoded[c]; + var info = body[infoOffset..]; + WriteS32(info, 0x00, Brstm.OffsetMarker); + WriteS32(info, 0x04, infoOffset + 8); + for (var i = 0; i < 16; i++) + WriteS16(info, 0x08 + i * 2, channel.Coefficients[i]); + + WriteU16(info, 0x28, 0); // gain + WriteU16(info, 0x2A, channel.InitialPredictorScale); + WriteS16(info, 0x2C, 0); + WriteS16(info, 0x2E, 0); + + if (stream.Looping) + { + WriteU16(info, 0x30, DspAdpcm.GetPredictorScale(channel.Adpcm, stream.LoopStart)); + WriteS16(info, 0x32, stream.LoopStart >= 1 ? channel.Decoded[stream.LoopStart - 1] : (short)0); + WriteS16(info, 0x34, stream.LoopStart >= 2 ? channel.Decoded[stream.LoopStart - 2] : (short)0); + } + else + { + WriteU16(info, 0x30, channel.InitialPredictorScale); + } + } + + // ADPC chunk: decoder history at the start of every block, so the game can seek/loop mid-stream. + var adpc = span[adpcOffset..]; + WriteAscii(adpc, 0x00, "ADPC"); + WriteS32(adpc, 0x04, adpcSize); + for (var entry = 0; entry < seekEntryCount; entry++) + { + var sampleIndex = entry * samplesPerBlock; + for (var c = 0; c < channelCount; c++) + { + var decoded = encoded[c].Decoded; + var hist1 = entry == 0 ? (short)0 : decoded[sampleIndex - 1]; + var hist2 = entry == 0 ? (short)0 : decoded[sampleIndex - 2]; + var at = 8 + (entry * channelCount + c) * 4; + WriteS16(adpc, at, hist1); + WriteS16(adpc, at + 2, hist2); + } + } + + // DATA chunk: blocks interleaved per channel; the last block is padded to 0x20 per channel. + var data = span[dataOffset..]; + WriteAscii(data, 0x00, "DATA"); + WriteS32(data, 0x04, dataSize); + WriteS32(data, 0x08, 0x18); + var audio = data[0x20..]; + for (var block = 0; block < blockCount; block++) + { + var isLast = block == blockCount - 1; + var blockStride = isLast ? lastBlockPadded : Brstm.InterleaveBytes; + var bytesInBlock = isLast ? lastBlockBytes : Brstm.InterleaveBytes; + var blockBase = block * Brstm.InterleaveBytes * channelCount; + for (var c = 0; c < channelCount; c++) + { + var source = encoded[c].Adpcm.AsSpan(block * Brstm.InterleaveBytes, bytesInBlock); + source.CopyTo(audio[(blockBase + c * blockStride)..]); + } + } + + return file; + } + + private static int Align(int value, int multiple) => (value + multiple - 1) / multiple * multiple; + + private static void WriteAscii(Span span, int offset, string text) => Encoding.ASCII.GetBytes(text, span[offset..]); + + private static void WriteU16(Span span, int offset, int value) => + BinaryPrimitives.WriteUInt16BigEndian(span[offset..], (ushort)value); + + private static void WriteS16(Span span, int offset, short value) => BinaryPrimitives.WriteInt16BigEndian(span[offset..], value); + + private static void WriteS32(Span span, int offset, int value) => BinaryPrimitives.WriteInt32BigEndian(span[offset..], value); +} + +/// What a BRSTM header says about the stream. +public sealed record BrstmInfo( + int Codec, + bool Looping, + int ChannelCount, + int SampleRate, + int LoopStart, + int SampleCount, + int TrackCount, + byte PulsarVolume +) +{ + public TimeSpan Duration => TimeSpan.FromSeconds(SampleCount / (double)Math.Max(1, SampleRate)); + + /// The gain Pulsar applies through header byte 0x3F (1.0 when the byte is 0). + public double PulsarGain => PulsarVolume == 0 ? 1.0 : Math.Min((int)PulsarVolume, 0x7F) / 127.0; +} + +public static class BrstmReader +{ + public static bool IsBrstm(ReadOnlySpan header) => + header.Length >= 4 && header[0] == 'R' && header[1] == 'S' && header[2] == 'T' && header[3] == 'M'; + + public static BrstmInfo ReadInfo(ReadOnlySpan file) + { + var layout = ReadLayout(file); + return layout.Info; + } + + /// Decodes a BRSTM into planar 16-bit PCM. Supports DSP-ADPCM, PCM16 and PCM8 streams. + public static AudioBuffer Decode(ReadOnlySpan file) + { + var layout = ReadLayout(file); + var info = layout.Info; + var channelCount = info.ChannelCount; + var sampleCount = info.SampleCount; + var channels = new float[channelCount][]; + + var bytesPerChannel = info.Codec switch + { + Brstm.CodecDspAdpcm => DspAdpcm.SampleCountToByteCount(sampleCount), + Brstm.CodecPcm16 => sampleCount * 2, + _ => sampleCount, + }; + + for (var c = 0; c < channelCount; c++) + { + var raw = Deinterleave(file, layout, c, bytesPerChannel); + var output = new float[sampleCount]; + switch (info.Codec) + { + case Brstm.CodecDspAdpcm: + { + var pcm = DspAdpcm.Decode(raw, layout.Coefficients[c], sampleCount, layout.History1[c], layout.History2[c]); + for (var i = 0; i < sampleCount; i++) + output[i] = pcm[i] / 32768f; + break; + } + case Brstm.CodecPcm16: + for (var i = 0; i < sampleCount && i * 2 + 1 < raw.Length; i++) + output[i] = BinaryPrimitives.ReadInt16BigEndian(raw.AsSpan(i * 2)) / 32768f; + break; + default: + for (var i = 0; i < sampleCount && i < raw.Length; i++) + output[i] = (sbyte)raw[i] / 128f; + break; + } + + channels[c] = output; + } + + var buffer = new AudioBuffer(channels, info.SampleRate); + if (info.Looping && info.LoopStart < sampleCount) + buffer.Loop = new LoopRegion(info.LoopStart, sampleCount); + return buffer; + } + + /// One track of a BRSTM: its channels (one or two) and its volume byte (127 = full). + public sealed record Track(int[] Channels, byte Volume); + + /// + /// The tracks a BRSTM declares. Custom track music often carries the song in more than one track (copies, + /// or alternative versions such as an underwater mix), and the game decides which tracks are heard. + /// Files without a usable track table are treated as consecutive stereo pairs. + /// + public static IReadOnlyList ReadTracks(ReadOnlySpan file) + { + var info = ReadInfo(file); + var tracks = new List(); + try + { + var headOffset = BinaryPrimitives.ReadInt32BigEndian(file[0x10..]); + var body = headOffset + 8; + var part2 = body + BinaryPrimitives.ReadInt32BigEndian(file[(headOffset + 0x14)..]); + int count = file[part2]; + for (var t = 0; t < count; t++) + { + var marker = BinaryPrimitives.ReadUInt32BigEndian(file[(part2 + 4 + t * 8)..]); + var offset = body + BinaryPrimitives.ReadInt32BigEndian(file[(part2 + 8 + t * 8)..]); + // Marker 0x0101xxxx: volume, pan, padding, then the channel list at 0x08. 0x0100xxxx: channel list at 0x00. + var extended = ((marker >> 16) & 0xFF) == 1; + var volume = extended ? file[offset] : (byte)0x7F; + var listOffset = extended ? offset + 8 : offset; + int channels = file[listOffset]; + if (channels is < 1 or > 2) + throw new InvalidDataException("Unexpected channel count in a BRSTM track."); + var indices = new int[channels]; + for (var c = 0; c < channels; c++) + { + indices[c] = file[listOffset + 1 + c]; + if (indices[c] >= info.ChannelCount) + throw new InvalidDataException("A BRSTM track refers to a missing channel."); + } + + tracks.Add(new Track(indices, volume)); + } + } + catch (Exception exception) when (exception is InvalidDataException or ArgumentOutOfRangeException or IndexOutOfRangeException) + { + tracks.Clear(); + } + + if (tracks.Count == 0) + { + for (var c = 0; c < info.ChannelCount; c += 2) + tracks.Add(new Track(c + 1 < info.ChannelCount ? [c, c + 1] : [c], 0x7F)); + } + + return tracks; + } + + /// + /// Decodes a BRSTM the way it is heard: in races the game plays the first track (further tracks are + /// alternatives it switches to, such as Koopa Cape's underwater mix); Retro Rewind's own menu music is played + /// with every track at full volume, so the tracks add up (). + /// Each track's volume byte is applied. + /// + public static AudioBuffer DecodeAsPlayed(ReadOnlySpan file, bool allTracks) + { + var decoded = Decode(file); + var tracks = ReadTracks(file); + var heard = allTracks ? tracks : tracks.Take(1).ToList(); + if (heard.Count == 1 && heard[0].Volume >= 0x7F && heard[0].Channels.Length == decoded.ChannelCount) + return decoded; // the common single-track file: nothing to mix + + var length = decoded.SampleCount; + var stereo = heard.Any(track => track.Channels.Length == 2); + var left = new float[length]; + var right = stereo ? new float[length] : left; + foreach (var track in heard) + { + var gain = Math.Min((int)track.Volume, 0x7F) / 127f; + var trackLeft = decoded.Channels[track.Channels[0]]; + var trackRight = decoded.Channels[track.Channels[^1]]; + for (var i = 0; i < length; i++) + { + left[i] += trackLeft[i] * gain; + if (stereo) + right[i] += trackRight[i] * gain; + } + } + + return new AudioBuffer(stereo ? [left, right] : [left], decoded.SampleRate) { Loop = decoded.Loop }; + } + + private static byte[] Deinterleave(ReadOnlySpan file, Layout layout, int channel, int bytesPerChannel) + { + var output = new byte[bytesPerChannel]; + var channelCount = layout.Info.ChannelCount; + var written = 0; + for (var block = 0; block < layout.BlockCount && written < bytesPerChannel; block++) + { + var isLast = block == layout.BlockCount - 1; + var stride = isLast ? layout.LastBlockPaddedSize : layout.BlockSize; + var size = isLast ? layout.LastBlockSize : layout.BlockSize; + var start = layout.AudioOffset + block * layout.BlockSize * channelCount + channel * stride; + var take = Math.Min(Math.Min(size, bytesPerChannel - written), Math.Max(0, file.Length - start)); + if (take <= 0) + break; + file.Slice(start, take).CopyTo(output.AsSpan(written)); + written += take; + } + + return output; + } + + private sealed record Layout( + BrstmInfo Info, + int AudioOffset, + int BlockCount, + int BlockSize, + int LastBlockSize, + int LastBlockPaddedSize, + short[][] Coefficients, + short[] History1, + short[] History2 + ); + + private static Layout ReadLayout(ReadOnlySpan file) + { + if (file.Length < 0x40 || !IsBrstm(file)) + throw new InvalidDataException("This is not a BRSTM file."); + if (BinaryPrimitives.ReadUInt16BigEndian(file[4..]) != 0xFEFF) + throw new InvalidDataException("Only big-endian (Wii) BRSTM files are supported."); + + var headOffset = BinaryPrimitives.ReadInt32BigEndian(file[0x10..]); + if (headOffset < 0 || headOffset + 0x20 > file.Length || Encoding.ASCII.GetString(file.Slice(headOffset, 4)) != "HEAD") + throw new InvalidDataException("The BRSTM HEAD chunk is missing."); + + var body = headOffset + 8; + var part1 = body + BinaryPrimitives.ReadInt32BigEndian(file[(headOffset + 0x0C)..]); + var part2 = body + BinaryPrimitives.ReadInt32BigEndian(file[(headOffset + 0x14)..]); + var part3 = body + BinaryPrimitives.ReadInt32BigEndian(file[(headOffset + 0x1C)..]); + if (part1 + 0x34 > file.Length || part3 + 4 > file.Length) + throw new InvalidDataException("The BRSTM header is truncated."); + + int codec = file[part1]; + var looping = file[part1 + 1] != 0; + int channelCount = file[part1 + 2]; + int sampleRate = BinaryPrimitives.ReadUInt16BigEndian(file[(part1 + 4)..]); + var loopStart = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x08)..]); + var sampleCount = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x0C)..]); + var audioOffset = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x10)..]); + var blockCount = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x14)..]); + var blockSize = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x18)..]); + var lastBlockSize = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x20)..]); + var lastBlockPadded = BinaryPrimitives.ReadInt32BigEndian(file[(part1 + 0x28)..]); + int trackCount = part2 < file.Length ? file[part2] : 0; + + if (codec is not (Brstm.CodecDspAdpcm or Brstm.CodecPcm16 or Brstm.CodecPcm8)) + throw new InvalidDataException($"Unsupported BRSTM codec {codec}."); + if (channelCount < 1 || sampleCount < 0 || blockCount < 1 || blockSize <= 0 || sampleRate <= 0) + throw new InvalidDataException("The BRSTM header has invalid stream values."); + + var coefficients = new short[channelCount][]; + var history1 = new short[channelCount]; + var history2 = new short[channelCount]; + if (codec == Brstm.CodecDspAdpcm) + { + for (var c = 0; c < channelCount; c++) + { + var entry = part3 + 4 + c * 8; + var infoRef = body + BinaryPrimitives.ReadInt32BigEndian(file[(entry + 4)..]); + var adpcmRef = body + BinaryPrimitives.ReadInt32BigEndian(file[(infoRef + 4)..]); + if (adpcmRef + 0x30 > file.Length) + throw new InvalidDataException("The BRSTM channel table is truncated."); + + var coefs = new short[16]; + for (var i = 0; i < 16; i++) + coefs[i] = BinaryPrimitives.ReadInt16BigEndian(file[(adpcmRef + i * 2)..]); + coefficients[c] = coefs; + history1[c] = BinaryPrimitives.ReadInt16BigEndian(file[(adpcmRef + 0x24)..]); + history2[c] = BinaryPrimitives.ReadInt16BigEndian(file[(adpcmRef + 0x26)..]); + } + } + + var info = new BrstmInfo( + codec, + looping, + channelCount, + sampleRate, + loopStart, + sampleCount, + trackCount, + file[Brstm.PulsarVolumeOffset] + ); + return new(info, audioOffset, blockCount, blockSize, lastBlockSize, lastBlockPadded, coefficients, history1, history2); + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Formats/CompressedReaders.cs b/WheelWizard/Features/CustomMusic/Audio/Formats/CompressedReaders.cs new file mode 100644 index 00000000..96906a60 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/CompressedReaders.cs @@ -0,0 +1,116 @@ +using NLayer; +using NVorbis; +using WheelWizard.CustomMusic.Audio.Tags; + +namespace WheelWizard.CustomMusic.Audio.Formats; + +/// MP3 through NLayer (fully managed), tags through our own ID3v2 reader. +public static class Mp3Reader +{ + /// + /// An MPEG audio frame (after any ID3v2 tag and padding). ID3v2 tags alone prove nothing: AAC (ADTS) files carry + /// them too, and their frame sync has layer bits 00, so they go to FFmpeg instead. + /// + public static bool LooksLikeMp3(ReadOnlySpan data) + { + // Untagged files must start with a frame. After a tag, the first frame sync decides (tags can be followed + // by padding or a little junk). + var start = Id3v2Reader.GetTagSize(data); + var end = Math.Min(data.Length - 1, start == 0 ? 1 : start + 8 * 1024); + for (var i = start; i < end; i++) + { + // MPEG audio frame sync: 11 set bits. Layer bits 00 mean AAC (ADTS), which FFmpeg decodes. + if (data[i] == 0xFF && (data[i + 1] & 0xE0) == 0xE0) + return (data[i + 1] & 0x06) != 0; + } + + return false; + } + + public static AudioBuffer Read(byte[] file, CancellationToken cancellationToken = default) + { + var metadata = Id3v2Reader.Read(file); + using var stream = new MemoryStream(file, writable: false); + using var mpeg = new MpegFile(stream); + + var channels = Math.Max(1, mpeg.Channels); + var sampleRate = mpeg.SampleRate; + var planar = new List[channels]; + for (var c = 0; c < channels; c++) + planar[c] = new List(sampleRate * 60); + + var buffer = new float[4096 * channels]; + int read; + while ((read = mpeg.ReadSamples(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var i = 0; i + channels <= read; i += channels) + { + for (var c = 0; c < channels; c++) + planar[c].Add(buffer[i + c]); + } + } + + if (sampleRate <= 0 || planar[0].Count == 0) + throw new InvalidDataException("The MP3 file contains no decodable audio."); + + // NLayer already trims the encoder delay and padding from LAME/Info and iTunSMPB tags (gapless playback). + var channelsOut = planar.Select(list => list.ToArray()).ToArray(); + return new AudioBuffer(channelsOut, sampleRate) { Metadata = metadata }; + } +} + +/// Ogg Vorbis through NVorbis (fully managed). +public static class OggVorbisReader +{ + public static bool IsOgg(ReadOnlySpan data) => data.Length >= 4 && data[0] == 'O' && data[1] == 'g' && data[2] == 'g' && data[3] == 'S'; + + /// True when the first Ogg logical stream carries Vorbis (as opposed to Opus, FLAC or Speex). + public static bool IsVorbis(ReadOnlySpan data) + { + if (!IsOgg(data) || data.Length < 28) + return false; + var segments = data[26]; + var packetStart = 27 + segments; + return data.Length >= packetStart + 7 + && data[packetStart] == 0x01 + && data[packetStart + 1] == 'v' + && data[packetStart + 2] == 'o' + && data[packetStart + 3] == 'r' + && data[packetStart + 4] == 'b' + && data[packetStart + 5] == 'i' + && data[packetStart + 6] == 's'; + } + + public static AudioBuffer Read(byte[] file, CancellationToken cancellationToken = default) + { + using var stream = new MemoryStream(file, writable: false); + using var vorbis = new VorbisReader(stream, closeOnDispose: false); + + var channels = vorbis.Channels; + var sampleRate = vorbis.SampleRate; + var expected = vorbis.TotalSamples > 0 ? (int)Math.Min(vorbis.TotalSamples, int.MaxValue / 2) : sampleRate * 60; + var planar = new List[channels]; + for (var c = 0; c < channels; c++) + planar[c] = new List(expected); + + var buffer = new float[4096 * channels]; + int read; + while ((read = vorbis.ReadSamples(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var i = 0; i + channels <= read; i += channels) + { + for (var c = 0; c < channels; c++) + planar[c].Add(buffer[i + c]); + } + } + + if (planar[0].Count == 0) + throw new InvalidDataException("The Ogg file contains no decodable audio."); + + var comments = vorbis.Tags.All.SelectMany(pair => pair.Value.Select(value => $"{pair.Key}={value}")); + var metadata = VorbisComments.FromComments(comments); + return new AudioBuffer(planar.Select(list => list.ToArray()).ToArray(), sampleRate) { Metadata = metadata }; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Formats/FfmpegReader.cs b/WheelWizard/Features/CustomMusic/Audio/Formats/FfmpegReader.cs new file mode 100644 index 00000000..7d3e146a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/FfmpegReader.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using System.IO.Abstractions; +using System.Runtime.InteropServices; + +namespace WheelWizard.CustomMusic.Audio.Formats; + +/// +/// Optional fallback for formats without a managed decoder (M4A/AAC, Opus, WMA, ALAC, ...). +/// It only works when the user has FFmpeg installed; Wheel Wizard never downloads it. +/// +public static class FfmpegReader +{ + private const int OutputSampleRate = 48000; + + /// FFmpeg next to Wheel Wizard, on PATH, or (on macOS) in Homebrew's folders. + public static string? FindExecutable(IFileSystem fileSystem) + { + var name = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ffmpeg.exe" : "ffmpeg"; + var candidates = new List { fileSystem.Path.Combine(AppContext.BaseDirectory, name) }; + + var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + candidates.AddRange( + pathVariable + .Split(fileSystem.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => fileSystem.Path.Combine(directory.Trim('"'), name)) + ); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + candidates.AddRange(["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"]); + + return candidates.FirstOrDefault(fileSystem.File.Exists); + } + + public static bool IsAvailable(IFileSystem fileSystem) => FindExecutable(fileSystem) != null; + + public static async Task ReadAsync(IFileSystem fileSystem, string path, CancellationToken cancellationToken = default) + { + var executable = + FindExecutable(fileSystem) ?? throw new NotSupportedException("This audio format needs FFmpeg. Install FFmpeg, or convert the song to MP3, FLAC, OGG or WAV."); + + string[] arguments = ["-v", "error", "-nostdin", "-i", path, "-vn", "-ac", "2", "-ar", OutputSampleRate.ToString(), "-f", "f32le", "-acodec", "pcm_f32le", "pipe:1"]; + var (exitCode, output, errors) = await RunAsync(executable, arguments, cancellationToken); + if (exitCode != 0 || output.Count < 8) + { + // FFmpeg's own error output is long and technical; keep only its last line for the log/tooltip. + var detail = errors.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault(); + throw new InvalidDataException(detail == null ? "FFmpeg could not decode this file." : $"FFmpeg could not decode this file ({detail})."); + } + + var frames = output.Count / 8; + var left = new float[frames]; + var right = new float[frames]; + var samples = MemoryMarshal.Cast(output.AsSpan(0, frames * 8)); + for (var i = 0; i < frames; i++) + { + left[i] = samples[i * 2]; + right[i] = samples[i * 2 + 1]; + } + + return new AudioBuffer([left, right], OutputSampleRate); + } + + /// + /// Title, artist, album and embedded cover art of a file only FFmpeg understands (Opus, WebM, WMA, ...). + /// Never throws: missing FFmpeg or unreadable tags just give empty metadata. + /// + public static async Task ReadMetadataAsync(IFileSystem fileSystem, string path, CancellationToken cancellationToken = default) + { + var metadata = new AudioMetadata(); + if (FindExecutable(fileSystem) is not { } executable) + return metadata; + + try + { + // Container tags first (Matroska/WebM, MP4, ...); Ogg keeps its tags on the audio stream instead. + string[][] sources = + [ + ["-v", "error", "-nostdin", "-i", path, "-f", "ffmetadata", "pipe:1"], + ["-v", "error", "-nostdin", "-i", path, "-map_metadata", "0:s:a:0", "-f", "ffmetadata", "pipe:1"], + ]; + foreach (var arguments in sources) + { + if (metadata.Title != null && metadata.Artist != null) + break; + var (exitCode, output, _) = await RunAsync(executable, arguments, cancellationToken); + if (exitCode == 0) + ApplyFfmetadata(System.Text.Encoding.UTF8.GetString(output.AsSpan()), metadata); + } + + // An attached picture (Opus/FLAC pictures, MP4 covr, ...) shows up as a video stream; copy its first frame. + string[] coverArguments = ["-v", "error", "-nostdin", "-i", path, "-map", "0:v:0?", "-frames:v", "1", "-c:v", "copy", "-f", "image2pipe", "pipe:1"]; + var (coverExit, cover, _) = await RunAsync(executable, coverArguments, cancellationToken); + if (coverExit == 0 && cover.Count is > 0 and < 20 * 1024 * 1024) + metadata.Cover = CoverArt.FromBytes(cover.ToArray()); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + // Tags are a nice-to-have; the song still converts without them. + } + + return metadata; + } + + /// + /// Reads FFmpeg's "ffmetadata" text, filling only what does not have yet. + /// Keys before any [STREAM] section win. Values escape '=', ';', '#', '\' and line breaks with a backslash. + /// + public static void ApplyFfmetadata(string text, AudioMetadata metadata) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + var lines = text.Replace("\r\n", "\n").Split('\n'); + var builder = new System.Text.StringBuilder(); + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index]; + if (line.Length == 0 || line[0] is ';' or '#' or '[') + continue; + + // A trailing (unescaped) backslash continues the value on the next line. + builder.Clear(); + builder.Append(line); + while (EndsWithEscape(builder) && index + 1 < lines.Length) + { + builder.Length--; + builder.Append('\n').Append(lines[++index]); + } + + var entry = builder.ToString(); + var equals = IndexOfUnescaped(entry, '='); + if (equals <= 0) + continue; + var key = Unescape(entry[..equals]).Trim(); + var value = Unescape(entry[(equals + 1)..]).Trim(); + if (value.Length > 0) + values.TryAdd(key, value); // the first (global) occurrence wins + } + + metadata.Title ??= values.GetValueOrDefault("title"); + metadata.Artist ??= values.GetValueOrDefault("artist") ?? values.GetValueOrDefault("album_artist"); + metadata.Album ??= values.GetValueOrDefault("album"); + + static bool EndsWithEscape(System.Text.StringBuilder value) + { + var slashes = 0; + for (var i = value.Length - 1; i >= 0 && value[i] == '\\'; i--) + slashes++; + return slashes % 2 == 1; + } + } + + private static int IndexOfUnescaped(string text, char wanted) + { + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\\') + i++; + else if (text[i] == wanted) + return i; + } + + return -1; + } + + private static string Unescape(string text) + { + if (!text.Contains('\\')) + return text; + var builder = new System.Text.StringBuilder(text.Length); + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\\' && i + 1 < text.Length) + i++; + builder.Append(text[i]); + } + + return builder.ToString(); + } + + /// The exit code, everything written to standard output (without copying the buffer), and standard error. + private static async Task<(int ExitCode, ArraySegment Output, string Errors)> RunAsync( + string executable, + IEnumerable arguments, + CancellationToken cancellationToken + ) + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("FFmpeg could not be started."); + await using var registration = cancellationToken.Register(() => + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Already exited. + } + }); + + using var output = new MemoryStream(); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.StandardOutput.BaseStream.CopyToAsync(output, cancellationToken); + await process.WaitForExitAsync(cancellationToken); + var errors = await errorTask; + return (process.ExitCode, new ArraySegment(output.GetBuffer(), 0, (int)output.Length), errors); + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Formats/FlacReader.cs b/WheelWizard/Features/CustomMusic/Audio/Formats/FlacReader.cs new file mode 100644 index 00000000..0bbb06c4 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/FlacReader.cs @@ -0,0 +1,509 @@ +using WheelWizard.CustomMusic.Audio.Tags; + +namespace WheelWizard.CustomMusic.Audio.Formats; + +/// A complete, dependency-free FLAC decoder (all subframe types, Rice/Rice2 coding, stereo decorrelation). +public static class FlacReader +{ + public static bool IsFlac(ReadOnlySpan data) + { + var start = Id3v2Reader.GetTagSize(data); + return data.Length >= start + 4 && data[start] == 'f' && data[start + 1] == 'L' && data[start + 2] == 'a' && data[start + 3] == 'C'; + } + + public static AudioBuffer Read(byte[] file) + { + var metadata = new AudioMetadata(); + var id3Size = Id3v2Reader.GetTagSize(file); + if (id3Size > 0) + metadata.MergeMissing(Id3v2Reader.Read(file)); + + var position = id3Size; + if (!IsFlac(file)) + throw new InvalidDataException("This is not a FLAC file."); + position += 4; + + var sampleRate = 0; + var channels = 0; + var bitsPerSample = 0; + long totalSamples = 0; + var bestPictureScore = -1; + + // Metadata blocks + var isLast = false; + while (!isLast && position + 4 <= file.Length) + { + var header = file[position]; + isLast = (header & 0x80) != 0; + var type = header & 0x7F; + var length = (file[position + 1] << 16) | (file[position + 2] << 8) | file[position + 3]; + position += 4; + if (position + length > file.Length) + throw new InvalidDataException("A FLAC metadata block is truncated."); + + var block = file.AsSpan(position, length); + switch (type) + { + case 0: // STREAMINFO + sampleRate = (block[10] << 12) | (block[11] << 4) | (block[12] >> 4); + channels = ((block[12] >> 1) & 0x7) + 1; + bitsPerSample = (((block[12] & 1) << 4) | (block[13] >> 4)) + 1; + totalSamples = ((long)(block[13] & 0xF) << 32) | ((long)block[14] << 24) | ((long)block[15] << 16) | ((long)block[16] << 8) | block[17]; + break; + case 4: // VORBIS_COMMENT + metadata.MergeMissing(VorbisComments.ParseBlock(block)); + break; + case 6: // PICTURE + { + var picture = VorbisComments.ParsePicture(block, out var pictureType); + var score = pictureType == 3 ? 2 : pictureType == 0 ? 1 : 0; + if (picture != null && score > bestPictureScore) + { + bestPictureScore = score; + metadata.Cover = picture; + } + + break; + } + } + + position += length; + } + + if (sampleRate <= 0 || channels <= 0) + throw new InvalidDataException("The FLAC file has no STREAMINFO block."); + + var capacity = totalSamples > 0 ? (int)Math.Min(totalSamples, int.MaxValue / 2) : sampleRate * 60; + var output = new List[channels]; + for (var c = 0; c < channels; c++) + output[c] = new List(capacity); + + var reader = new BitReader(file, position); + var decoder = new FrameDecoder(channels, bitsPerSample); + while (reader.BytePosition + 2 < file.Length) + { + if (!reader.SeekToFrameSync()) + break; + + var frameStart = reader.BytePosition; + try + { + var frame = decoder.DecodeFrame(reader); + var scale = 1.0 / (1L << (frame.BitsPerSample - 1)); + for (var c = 0; c < channels; c++) + { + var samples = frame.Samples[Math.Min(c, frame.Samples.Length - 1)]; + var list = output[c]; + for (var i = 0; i < frame.BlockSize; i++) + list.Add((float)(samples[i] * scale)); + } + } + catch (InvalidDataException) + { + // Corrupt frame: resynchronise one byte later instead of giving up on the whole song. + reader.SetBytePosition(frameStart + 1); + } + + if (totalSamples > 0 && output[0].Count >= totalSamples) + break; + } + + var buffers = new float[channels][]; + for (var c = 0; c < channels; c++) + { + var count = totalSamples > 0 ? (int)Math.Min(totalSamples, output[c].Count) : output[c].Count; + buffers[c] = output[c].GetRange(0, count).ToArray(); + } + + if (buffers[0].Length == 0) + throw new InvalidDataException("The FLAC file contains no audio frames."); + + return new AudioBuffer(buffers, sampleRate) { Metadata = metadata }; + } + + private sealed record DecodedFrame(long[][] Samples, int BlockSize, int BitsPerSample); + + private sealed class FrameDecoder(int streamChannels, int streamBitsPerSample) + { + private long[][] _buffers = []; + + public DecodedFrame DecodeFrame(BitReader reader) + { + // Frame header + var sync = reader.ReadBits(15); + if (sync != 0x7FFC) + throw new InvalidDataException("Lost FLAC frame sync."); + reader.ReadBits(1); // blocking strategy + var blockSizeCode = (int)reader.ReadBits(4); + var sampleRateCode = (int)reader.ReadBits(4); + var channelAssignment = (int)reader.ReadBits(4); + var sampleSizeCode = (int)reader.ReadBits(3); + if (reader.ReadBits(1) != 0) + throw new InvalidDataException("Reserved FLAC header bit is set."); + + ReadUtf8Number(reader); + + var blockSize = blockSizeCode switch + { + 1 => 192, + >= 2 and <= 5 => 576 << (blockSizeCode - 2), + 6 => (int)reader.ReadBits(8) + 1, + 7 => (int)reader.ReadBits(16) + 1, + >= 8 => 256 << (blockSizeCode - 8), + _ => throw new InvalidDataException("Reserved FLAC block size."), + }; + + switch (sampleRateCode) + { + case 12: + reader.ReadBits(8); + break; + case 13: + case 14: + reader.ReadBits(16); + break; + case 15: + throw new InvalidDataException("Invalid FLAC sample rate code."); + } + + var bitsPerSample = sampleSizeCode switch + { + 0 => streamBitsPerSample, + 1 => 8, + 2 => 12, + 4 => 16, + 5 => 20, + 6 => 24, + 7 => 32, + _ => throw new InvalidDataException("Reserved FLAC sample size."), + }; + + reader.ReadBits(8); // CRC-8 of the header + + var channelCount = channelAssignment <= 7 ? channelAssignment + 1 : 2; + if (channelAssignment > 10) + throw new InvalidDataException("Reserved FLAC channel assignment."); + if (channelCount != streamChannels) + throw new InvalidDataException("FLAC frame channel count does not match the stream."); + + EnsureBuffers(channelCount, blockSize); + for (var c = 0; c < channelCount; c++) + { + // The side channel carries one extra bit. + var extra = (channelAssignment == 8 && c == 1) || (channelAssignment == 9 && c == 0) || (channelAssignment == 10 && c == 1) ? 1 : 0; + DecodeSubframe(reader, _buffers[c], blockSize, bitsPerSample + extra); + } + + Decorrelate(channelAssignment, blockSize); + + reader.AlignToByte(); + reader.ReadBits(16); // CRC-16 of the frame + + return new DecodedFrame(_buffers, blockSize, bitsPerSample); + } + + private void EnsureBuffers(int channels, int blockSize) + { + if (_buffers.Length != channels || _buffers[0].Length < blockSize) + { + _buffers = new long[channels][]; + for (var c = 0; c < channels; c++) + _buffers[c] = new long[Math.Max(blockSize, 4096)]; + } + } + + private void Decorrelate(int assignment, int blockSize) + { + var a = _buffers[0]; + var b = _buffers.Length > 1 ? _buffers[1] : a; + switch (assignment) + { + case 8: // left/side + for (var i = 0; i < blockSize; i++) + b[i] = a[i] - b[i]; + break; + case 9: // side/right + for (var i = 0; i < blockSize; i++) + a[i] += b[i]; + break; + case 10: // mid/side + for (var i = 0; i < blockSize; i++) + { + var side = b[i]; + var mid = (a[i] << 1) | (side & 1); + a[i] = (mid + side) >> 1; + b[i] = (mid - side) >> 1; + } + + break; + } + } + + private static void DecodeSubframe(BitReader reader, long[] output, int blockSize, int bitsPerSample) + { + if (reader.ReadBits(1) != 0) + throw new InvalidDataException("FLAC subframe padding bit is set."); + + var type = (int)reader.ReadBits(6); + var wastedBits = 0; + if (reader.ReadBits(1) == 1) + { + wastedBits = 1; + while (reader.ReadBits(1) == 0) + wastedBits++; + } + + var bits = bitsPerSample - wastedBits; + if (type == 0) + { + var value = reader.ReadSignedBits(bits); + Array.Fill(output, value, 0, blockSize); + } + else if (type == 1) + { + for (var i = 0; i < blockSize; i++) + output[i] = reader.ReadSignedBits(bits); + } + else if (type is >= 8 and <= 12) + { + var order = type - 8; + for (var i = 0; i < order; i++) + output[i] = reader.ReadSignedBits(bits); + ReadResidual(reader, output, blockSize, order); + RestoreFixed(output, blockSize, order); + } + else if (type >= 32) + { + var order = type - 31; + for (var i = 0; i < order; i++) + output[i] = reader.ReadSignedBits(bits); + + var precision = (int)reader.ReadBits(4) + 1; + if (precision == 16) + throw new InvalidDataException("Invalid FLAC LPC precision."); + var shift = (int)reader.ReadSignedBits(5); + var coefficients = new long[order]; + for (var i = 0; i < order; i++) + coefficients[i] = reader.ReadSignedBits(precision); + + ReadResidual(reader, output, blockSize, order); + RestoreLpc(output, blockSize, coefficients, Math.Max(0, shift)); + } + else + { + throw new InvalidDataException($"Reserved FLAC subframe type {type}."); + } + + if (wastedBits > 0) + { + for (var i = 0; i < blockSize; i++) + output[i] <<= wastedBits; + } + } + + private static void ReadResidual(BitReader reader, long[] output, int blockSize, int predictorOrder) + { + var method = (int)reader.ReadBits(2); + if (method > 1) + throw new InvalidDataException("Reserved FLAC residual coding method."); + + var parameterBits = method == 0 ? 4 : 5; + var escapeCode = method == 0 ? 0xF : 0x1F; + var partitionOrder = (int)reader.ReadBits(4); + var partitions = 1 << partitionOrder; + var index = predictorOrder; + + for (var p = 0; p < partitions; p++) + { + var count = (blockSize >> partitionOrder) - (p == 0 ? predictorOrder : 0); + if (count < 0 || index + count > blockSize) + throw new InvalidDataException("Invalid FLAC partition size."); + + var parameter = (int)reader.ReadBits(parameterBits); + if (parameter == escapeCode) + { + var rawBits = (int)reader.ReadBits(5); + for (var i = 0; i < count; i++) + output[index++] = rawBits == 0 ? 0 : reader.ReadSignedBits(rawBits); + } + else + { + for (var i = 0; i < count; i++) + output[index++] = reader.ReadRice(parameter); + } + } + } + + private static void RestoreFixed(long[] s, int blockSize, int order) + { + switch (order) + { + case 1: + for (var i = 1; i < blockSize; i++) + s[i] += s[i - 1]; + break; + case 2: + for (var i = 2; i < blockSize; i++) + s[i] += 2 * s[i - 1] - s[i - 2]; + break; + case 3: + for (var i = 3; i < blockSize; i++) + s[i] += 3 * s[i - 1] - 3 * s[i - 2] + s[i - 3]; + break; + case 4: + for (var i = 4; i < blockSize; i++) + s[i] += 4 * s[i - 1] - 6 * s[i - 2] + 4 * s[i - 3] - s[i - 4]; + break; + } + } + + private static void RestoreLpc(long[] s, int blockSize, long[] coefficients, int shift) + { + var order = coefficients.Length; + for (var i = order; i < blockSize; i++) + { + long sum = 0; + for (var j = 0; j < order; j++) + sum += coefficients[j] * s[i - j - 1]; + s[i] += sum >> shift; + } + } + + private static void ReadUtf8Number(BitReader reader) + { + var first = (int)reader.ReadBits(8); + var extraBytes = first switch + { + < 0x80 => 0, + >= 0xC0 and < 0xE0 => 1, + >= 0xE0 and < 0xF0 => 2, + >= 0xF0 and < 0xF8 => 3, + >= 0xF8 and < 0xFC => 4, + >= 0xFC and < 0xFE => 5, + 0xFE => 6, + _ => throw new InvalidDataException("Invalid FLAC frame number."), + }; + for (var i = 0; i < extraBytes; i++) + { + if ((reader.ReadBits(8) & 0xC0) != 0x80) + throw new InvalidDataException("Invalid FLAC frame number."); + } + } + } + + private sealed class BitReader(byte[] data, int startByte) + { + private int _bytePosition = startByte; + private ulong _cache; + private int _cacheBits; + + public int BytePosition => _bytePosition - _cacheBits / 8; + + public void SetBytePosition(int position) + { + _bytePosition = position; + _cache = 0; + _cacheBits = 0; + } + + /// Moves to the next 0xFFF8/0xFFF9 frame sync code (byte aligned). + public bool SeekToFrameSync() + { + AlignToByte(); + var position = BytePosition; + while (position + 1 < data.Length) + { + if (data[position] == 0xFF && (data[position + 1] & 0xFE) == 0xF8) + { + SetBytePosition(position); + return true; + } + + position++; + } + + return false; + } + + public void AlignToByte() + { + var drop = _cacheBits % 8; + _cache <<= drop; + _cacheBits -= drop; + } + + private void Refill() + { + while (_cacheBits <= 56) + { + ulong next = _bytePosition < data.Length ? data[_bytePosition] : 0UL; + if (_bytePosition >= data.Length + 8) + throw new InvalidDataException("Unexpected end of FLAC data."); + _bytePosition++; + _cache |= next << (56 - _cacheBits); + _cacheBits += 8; + } + } + + public ulong ReadBits(int count) + { + if (count == 0) + return 0; + if (count > 32) + return (ReadBits(count - 32) << 32) | ReadBits(32); + + if (_cacheBits < count) + Refill(); + var value = _cache >> (64 - count); + _cache <<= count; + _cacheBits -= count; + return value; + } + + public long ReadSignedBits(int count) + { + if (count == 0) + return 0; + var value = (long)ReadBits(count); + return (value << (64 - count)) >> (64 - count); + } + + public long ReadRice(int parameter) + { + long quotient = 0; + while (true) + { + if (_cacheBits == 0) + Refill(); + if (_cache == 0) + { + quotient += _cacheBits; + _cacheBits = 0; + if (quotient > 1L << 32) + throw new InvalidDataException("Corrupt FLAC Rice code."); + continue; + } + + var zeros = System.Numerics.BitOperations.LeadingZeroCount(_cache); + if (zeros >= _cacheBits) + { + quotient += _cacheBits; + _cache = 0; + _cacheBits = 0; + continue; + } + + quotient += zeros; + // Shifting a ulong by 64 is a no-op in C#, so a terminator in the very last bit needs care. + var consumed = zeros + 1; + _cache = consumed >= 64 ? 0 : _cache << consumed; + _cacheBits -= consumed; + break; + } + + var remainder = (long)ReadBits(parameter); + var value = (quotient << parameter) | remainder; + return (value >> 1) ^ -(value & 1); + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Formats/PcmContainerReaders.cs b/WheelWizard/Features/CustomMusic/Audio/Formats/PcmContainerReaders.cs new file mode 100644 index 00000000..a80c7f26 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/PcmContainerReaders.cs @@ -0,0 +1,305 @@ +using System.Buffers.Binary; +using System.Text; +using WheelWizard.CustomMusic.Audio.Tags; + +namespace WheelWizard.CustomMusic.Audio.Formats; + +/// Reads uncompressed WAV (PCM / IEEE float / WAVE_FORMAT_EXTENSIBLE, RIFF and RF64). +public static class WavReader +{ + private const int FormatPcm = 1; + private const int FormatFloat = 3; + private const int FormatExtensible = 0xFFFE; + + public static bool IsWav(ReadOnlySpan header) => + header.Length >= 12 + && (Ascii(header, 0) == "RIFF" || Ascii(header, 0) == "RF64") + && Ascii(header, 8) == "WAVE"; + + public static AudioBuffer Read(ReadOnlySpan file) + { + if (!IsWav(file)) + throw new InvalidDataException("This is not a WAV file."); + + int formatTag = 0, + channels = 0, + sampleRate = 0, + bitsPerSample = 0, + blockAlign = 0; + var dataOffset = -1; + long dataLength = 0; + long rf64DataSize = -1; + LoopRegion? loop = null; + var metadata = new AudioMetadata(); + + var position = 12; + while (position + 8 <= file.Length) + { + var id = Ascii(file, position); + long size = BinaryPrimitives.ReadUInt32LittleEndian(file[(position + 4)..]); + var body = position + 8; + + if (id == "ds64" && size >= 16) + rf64DataSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(file[(body + 8)..]); + + if (id == "data") + { + if (size == 0xFFFFFFFF && rf64DataSize >= 0) + size = rf64DataSize; + dataOffset = body; + dataLength = Math.Min(size, file.Length - body); + } + else if (body + size > file.Length) + { + break; + } + else + { + var chunk = file.Slice(body, (int)size); + switch (id) + { + case "fmt ": + formatTag = BinaryPrimitives.ReadUInt16LittleEndian(chunk); + channels = BinaryPrimitives.ReadUInt16LittleEndian(chunk[2..]); + sampleRate = (int)BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..]); + blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(chunk[12..]); + bitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(chunk[14..]); + if (formatTag == FormatExtensible && chunk.Length >= 40) + formatTag = BinaryPrimitives.ReadUInt16LittleEndian(chunk[24..]); // first two bytes of the sub-format GUID + break; + case "smpl" when chunk.Length >= 36 + 24: + { + var loopCount = BinaryPrimitives.ReadUInt32LittleEndian(chunk[28..]); + if (loopCount > 0) + { + var start = (int)BinaryPrimitives.ReadUInt32LittleEndian(chunk[(36 + 8)..]); + var end = (int)BinaryPrimitives.ReadUInt32LittleEndian(chunk[(36 + 12)..]); + // smpl end points are inclusive. + loop = new LoopRegion(start, end + 1); + } + + break; + } + case "LIST" when chunk.Length >= 4 && Ascii(chunk, 0) == "INFO": + ReadInfoList(chunk[4..], metadata); + break; + case "id3 ": + case "ID3 ": + metadata.MergeMissing(Id3v2Reader.Read(chunk)); + break; + } + } + + position = body + (int)Math.Min(size, int.MaxValue - body); + if ((size & 1) == 1) + position++; // chunks are word aligned + if (id == "data" && position > file.Length) + break; + } + + if (dataOffset < 0 || channels <= 0 || sampleRate <= 0) + throw new InvalidDataException("The WAV file has no audio data."); + + var buffer = new AudioBuffer(DecodeSamples(file.Slice(dataOffset, (int)dataLength), formatTag, channels, bitsPerSample, blockAlign, false), sampleRate) + { + Metadata = metadata, + }; + if (loop is { } region && region.IsValid(buffer.SampleCount)) + buffer.Loop = region; + return buffer; + } + + internal static float[][] DecodeSamples( + ReadOnlySpan data, + int formatTag, + int channels, + int bitsPerSample, + int blockAlign, + bool bigEndian + ) + { + if (formatTag is not (FormatPcm or FormatFloat)) + throw new NotSupportedException($"WAV format 0x{formatTag:X4} is compressed; it needs FFmpeg to decode."); + + var bytesPerSample = (bitsPerSample + 7) / 8; + if (blockAlign <= 0) + blockAlign = bytesPerSample * channels; + if (bytesPerSample is < 1 or > 8 || blockAlign < bytesPerSample * channels) + throw new InvalidDataException("The WAV file has an invalid sample layout."); + + var frames = data.Length / blockAlign; + var output = new float[channels][]; + for (var c = 0; c < channels; c++) + output[c] = new float[frames]; + + for (var f = 0; f < frames; f++) + { + var frame = data.Slice(f * blockAlign, blockAlign); + for (var c = 0; c < channels; c++) + { + var sample = frame.Slice(c * bytesPerSample, bytesPerSample); + output[c][f] = formatTag == FormatFloat ? ReadFloat(sample, bigEndian) : ReadInteger(sample, bigEndian); + } + } + + return output; + } + + private static float ReadFloat(ReadOnlySpan sample, bool bigEndian) => + sample.Length switch + { + 4 => bigEndian ? BinaryPrimitives.ReadSingleBigEndian(sample) : BinaryPrimitives.ReadSingleLittleEndian(sample), + 8 => (float)(bigEndian ? BinaryPrimitives.ReadDoubleBigEndian(sample) : BinaryPrimitives.ReadDoubleLittleEndian(sample)), + _ => 0f, + }; + + private static float ReadInteger(ReadOnlySpan sample, bool bigEndian) + { + // 8-bit WAV is unsigned; everything else is signed two's complement. + if (sample.Length == 1) + return bigEndian ? (sbyte)sample[0] / 128f : (sample[0] - 128) / 128f; + + long value = 0; + if (bigEndian) + { + for (var i = 0; i < sample.Length; i++) + value = (value << 8) | sample[i]; + } + else + { + for (var i = sample.Length - 1; i >= 0; i--) + value = (value << 8) | sample[i]; + } + + var bits = sample.Length * 8; + value = (value << (64 - bits)) >> (64 - bits); // sign extend + return (float)(value / Math.Pow(2, bits - 1)); + } + + private static void ReadInfoList(ReadOnlySpan list, AudioMetadata metadata) + { + var position = 0; + while (position + 8 <= list.Length) + { + var id = Ascii(list, position); + var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(list[(position + 4)..]); + if (size < 0 || position + 8 + size > list.Length) + break; + var text = Encoding.UTF8.GetString(list.Slice(position + 8, size)).Trim('\0', ' '); + if (text.Length > 0) + { + switch (id) + { + case "INAM": + metadata.Title ??= text; + break; + case "IART": + metadata.Artist ??= text; + break; + case "IPRD": + metadata.Album ??= text; + break; + } + } + + position += 8 + size + (size & 1); + } + } + + internal static string Ascii(ReadOnlySpan data, int offset) => + offset + 4 <= data.Length ? Encoding.ASCII.GetString(data.Slice(offset, 4)) : string.Empty; +} + +/// Reads uncompressed AIFF and AIFF-C (NONE, sowt, fl32, fl64). +public static class AiffReader +{ + public static bool IsAiff(ReadOnlySpan header) => + header.Length >= 12 && WavReader.Ascii(header, 0) == "FORM" && WavReader.Ascii(header, 8) is "AIFF" or "AIFC"; + + public static AudioBuffer Read(ReadOnlySpan file) + { + if (!IsAiff(file)) + throw new InvalidDataException("This is not an AIFF file."); + + int channels = 0, + bits = 0, + sampleRate = 0; + long frames = 0; + var compression = "NONE"; + var soundOffset = -1; + var soundLength = 0; + var metadata = new AudioMetadata(); + + var position = 12; + while (position + 8 <= file.Length) + { + var id = WavReader.Ascii(file, position); + var size = (int)Math.Min(BinaryPrimitives.ReadUInt32BigEndian(file[(position + 4)..]), int.MaxValue); + var body = position + 8; + var available = Math.Min(size, file.Length - body); + var chunk = file.Slice(body, Math.Max(0, available)); + + switch (id) + { + case "COMM" when chunk.Length >= 18: + channels = BinaryPrimitives.ReadInt16BigEndian(chunk); + frames = BinaryPrimitives.ReadUInt32BigEndian(chunk[2..]); + bits = BinaryPrimitives.ReadInt16BigEndian(chunk[6..]); + sampleRate = (int)Math.Round(ReadExtended(chunk.Slice(8, 10))); + if (chunk.Length >= 22) + compression = WavReader.Ascii(chunk, 18); + break; + case "SSND" when chunk.Length >= 8: + var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(chunk); + soundOffset = body + 8 + offset; + soundLength = Math.Max(0, available - 8 - offset); + break; + case "NAME": + metadata.Title ??= Encoding.Latin1.GetString(chunk).Trim('\0', ' '); + break; + case "AUTH": + metadata.Artist ??= Encoding.Latin1.GetString(chunk).Trim('\0', ' '); + break; + case "ID3 ": + case "id3 ": + metadata.MergeMissing(Id3v2Reader.Read(chunk)); + break; + } + + position = body + size + (size & 1); + } + + if (soundOffset < 0 || channels <= 0 || sampleRate <= 0) + throw new InvalidDataException("The AIFF file has no audio data."); + + var (formatTag, bigEndian) = compression switch + { + "NONE" or "twos" => (1, true), + "sowt" => (1, false), + "fl32" or "FL32" => (3, true), + "fl64" or "FL64" => (3, true), + _ => throw new NotSupportedException($"AIFF-C compression '{compression}' needs FFmpeg to decode."), + }; + if (compression is "fl64" or "FL64") + bits = 64; + else if (compression is "fl32" or "FL32") + bits = 32; + + var bytesPerSample = (bits + 7) / 8; + var blockAlign = bytesPerSample * channels; + var length = (int)Math.Min(soundLength, frames * blockAlign); + var samples = WavReader.DecodeSamples(file.Slice(soundOffset, length), formatTag, channels, bits, blockAlign, bigEndian); + return new AudioBuffer(samples, sampleRate) { Metadata = metadata }; + } + + /// Decodes the 80-bit IEEE 754 extended precision number AIFF uses for its sample rate. + private static double ReadExtended(ReadOnlySpan bytes) + { + var exponent = ((bytes[0] & 0x7F) << 8) | bytes[1]; + var mantissa = BinaryPrimitives.ReadUInt64BigEndian(bytes[2..]); + if (exponent == 0 && mantissa == 0) + return 0; + var value = mantissa * Math.Pow(2, exponent - 16383 - 63); + return (bytes[0] & 0x80) != 0 ? -value : value; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/MusicConverter.cs b/WheelWizard/Features/CustomMusic/Audio/MusicConverter.cs new file mode 100644 index 00000000..c6975b3a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/MusicConverter.cs @@ -0,0 +1,278 @@ +using WheelWizard.CustomMusic.Audio.Dsp; +using WheelWizard.CustomMusic.Audio.Formats; + +namespace WheelWizard.CustomMusic.Audio; + +/// How the final lap version of a course song is made. +public enum FinalLapStyle +{ + /// Speed and pitch +6%: exactly what Retro Rewind's own "music speed-up" does to the normal song. + Faster, + + /// Tempo +8% with the pitch kept, closest to the original game's final lap tracks. + FasterKeepPitch, + + /// The final lap plays the same song as laps 1 and 2. + Unchanged, +} + +/// Where a converted song's loop comes from. +public enum LoopKind +{ + /// Plays once (jingles and fanfares). + None, + + /// Loop points stored in the file (WAV smpl chunk, LOOPSTART tags, BRSTM). + FromFile, + + /// A repeat found in the music itself (). + Found, + + /// The ending crossfades into the opening. + Crossfade, +} + +public sealed record ConversionOptions +{ + /// Mario Kart Wii streams are 32 kHz. + public int SampleRate { get; init; } = 32000; + + /// Whether the result loops (course and menu music) or plays once (fanfares and jingles). + public bool Loop { get; init; } = true; + + /// Also produce the final lap file. + public bool CreateFinalLap { get; init; } + + public FinalLapStyle FinalLapStyle { get; init; } = FinalLapStyle.Faster; + + /// Integrated loudness the song is matched to, normally that of the music it replaces. + public double TargetLufs { get; init; } = MusicLoudness.DefaultTargetLufs; + + /// User adjustment on top of the matched loudness. + public double VolumeOffsetDb { get; init; } + + /// + /// The volume byte (1..127) of the music being replaced, or 0 for none. Retro Rewind reads it from the BRSTM + /// header when the music starts and multiplies the sound's volume by byte / 127 (0 means full volume), so a + /// replacement carries the same byte. When the song needs more level than its peaks allow, the byte is raised + /// (up to 127) instead of limiting the song harder; without a byte the music already plays at full volume. + /// + public byte PulsarVolume { get; init; } + + /// Search the music for a seamless loop when the file has no loop points (otherwise crossfade). + public bool FindLoop { get; init; } = true; + + /// Length of the crossfade that makes songs without loop points loop smoothly. + public double CrossfadeSeconds { get; init; } = 2.5; + + public double MaxDurationSeconds { get; init; } = 15 * 60; +} + +/// Loudness of the song as it was dropped in. +/// Requested loudness of the converted file (the original's loudness plus the volume adjustment). +/// Loudness of the converted file. Lower than the target when the limiter had to hold the peaks back. +/// Volume byte written to the BRSTM header (0 = none). +public sealed record ConversionReport( + double SourceLufs, + double TargetLufs, + double AppliedGainDb, + double LimiterReductionDb, + TimeSpan Duration, + LoopKind Loop, + int LoopStartSample, + int SampleCount, + double OutputLufs, + byte PulsarVolume +); + +public sealed record ConversionResult(byte[] Normal, byte[]? FinalLap, ConversionReport Report); + +public static class MusicLoudness +{ + /// + /// Fallback when the original music could not be measured: a typical level for game music (loud enough + /// to sit over engine noise and effects). Real measurements of the replaced music always take priority. + /// + public const double DefaultTargetLufs = -16.0; + + public const double FinalLapPitchSpeed = 1.06; + public const double FinalLapTempoSpeed = 1.08; + + /// Highest sample peak a converted file may have, leaving room for DSP-ADPCM's small overshoots. + public const double PeakCeilingDb = -1.0; + + /// Largest volume byte Retro Rewind accepts; it means "full volume". + public const byte MaxPulsarVolume = 0x7F; + + /// Loop points shorter than this are treated as broken; the game would repeat a tiny fragment as a buzz. + public const double MinLoopSeconds = 1.0; + + /// Gain of a volume byte in dB (0 = no byte, which means the sound archive's volume). + public static double VolumeByteDb(byte volume) => volume == 0 ? 0 : 20 * Math.Log10(Math.Min(volume, MaxPulsarVolume) / (double)MaxPulsarVolume); +} + +public static class MusicConverter +{ + public static ConversionResult Convert( + AudioBuffer source, + ConversionOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + progress?.Report(0.05); + // Damaged files can decode to NaN or infinity, which would poison every measurement below. + AudioOps.ReplaceNonFinite(source); + + // Loop points from the file (WAV smpl, LOOPSTART tags, BRSTM) are used as-is; otherwise we build a loop. + var hasSourceLoop = + options.Loop + && source.Loop is { } sourceLoop + && sourceLoop.IsValid(source.SampleCount) + && sourceLoop.Length >= source.SampleRate * MusicLoudness.MinLoopSeconds; + if (!hasSourceLoop) + source.Loop = null; + + var stereo = AudioOps.ToStereo(source); + var trimmed = AudioOps.TrimSilence(stereo); + cancellationToken.ThrowIfCancellationRequested(); + + // Give the resampler real audio to look at across the loop seam. + var extended = AudioOps.ExtendPastLoopEnd(trimmed, trimmed.SampleRate / 10); + var audio = Resampler.Resample(extended, options.SampleRate); + audio = AudioOps.Truncate(audio, (int)(options.MaxDurationSeconds * options.SampleRate)); + if (options.Loop && audio.SampleCount < options.SampleRate * MusicLoudness.MinLoopSeconds) + throw new InvalidDataException("This song is too short to loop; use a song of at least a second."); + progress?.Report(0.3); + cancellationToken.ThrowIfCancellationRequested(); + + // Loudness matching (measured on the part that actually plays, before any loop seam work). + var measureLength = audio.Loop?.End ?? audio.SampleCount; + var loudness = LoudnessMeter.Measure(audio.Channels, audio.SampleRate, 0, measureLength); + if (loudness.IsSilent) + throw new InvalidDataException("This song is silent, so there is nothing to convert."); + + var gainDb = options.TargetLufs - loudness.IntegratedLufs + options.VolumeOffsetDb; + var pulsarVolume = Math.Min(options.PulsarVolume, MusicLoudness.MaxPulsarVolume); + if (pulsarVolume > 0 && loudness.SamplePeak > 0) + { + // Peaks that would go over the ceiling are handled by raising the volume byte first (the game then + // plays the file louder) and only the rest by the limiter, so the song keeps its dynamics. + var excessDb = 20 * Math.Log10(loudness.SamplePeak) + gainDb - MusicLoudness.PeakCeilingDb; + if (excessDb > 0) + { + var raised = (byte)Math.Min(MusicLoudness.MaxPulsarVolume, Math.Floor(pulsarVolume * Math.Pow(10, excessDb / 20))); + if (raised > pulsarVolume) + { + gainDb -= MusicLoudness.VolumeByteDb(raised) - MusicLoudness.VolumeByteDb(pulsarVolume); + pulsarVolume = raised; + } + } + } + + AudioOps.ApplyGain(audio, gainDb); + var limiterReduction = PeakLimiter.Apply(audio, MusicLoudness.PeakCeilingDb); + progress?.Report(0.45); + cancellationToken.ThrowIfCancellationRequested(); + + var loopKind = !options.Loop ? LoopKind.None + : hasSourceLoop ? LoopKind.FromFile + : LoopKind.Crossfade; + if (options.Loop && audio.Loop == null) + { + var beforeLoop = LoudnessMeter.Measure(audio.Channels, audio.SampleRate); + // A repeat in the music gives a loop nobody can hear; a crossfade is the fallback. + if (options.FindLoop && LoopFinder.Find(audio, cancellationToken: cancellationToken) is { } found) + { + audio = LoopFinder.Apply(audio, found); + loopKind = LoopKind.Found; + } + else + { + audio = AudioOps.CreateCrossfadeLoop(audio, options.CrossfadeSeconds); + } + + // Cutting the song (or an equal-power crossfade over a held note) shifts its loudness a little. Undo only + // that change: comparing with the target instead would re-add whatever the limiter took off. + var looped = LoudnessMeter.Measure(audio.Channels, audio.SampleRate, 0, audio.Loop?.End ?? audio.SampleCount); + var correction = beforeLoop.IntegratedLufs - looped.IntegratedLufs; + if (!looped.IsSilent && !beforeLoop.IsSilent && Math.Abs(correction) > 0.2) + { + AudioOps.ApplyGain(audio, correction); + gainDb += correction; + limiterReduction = Math.Max(limiterReduction, PeakLimiter.Apply(audio, MusicLoudness.PeakCeilingDb)); + } + } + else if (!options.Loop) + { + AudioOps.FadeOutTail(audio, 0.01); + } + + var normalBuffer = options.Loop ? AudioOps.AlignLoopStart(audio, Brstm.SamplesPerBlock) : audio; + AudioBuffer? finalBuffer = null; + if (options.CreateFinalLap && options.FinalLapStyle != FinalLapStyle.Unchanged) + { + finalBuffer = options.FinalLapStyle == FinalLapStyle.FasterKeepPitch + ? MakeFinalLap(audio, buffer => TimeStretcher.ChangeTempo(buffer, MusicLoudness.FinalLapTempoSpeed)) + : MakeFinalLap(audio, buffer => Resampler.Resample(buffer, buffer.SampleRate, MusicLoudness.FinalLapPitchSpeed)); + } + + progress?.Report(0.6); + cancellationToken.ThrowIfCancellationRequested(); + + // The DSP-ADPCM encode is the slow part; both versions encode at the same time. + byte[] normal = []; + byte[]? finalLap = null; + Parallel.Invoke( + () => normal = Encode(normalBuffer, pulsarVolume), + () => + { + if (finalBuffer != null) + finalLap = Encode(finalBuffer, pulsarVolume); + } + ); + if (options.CreateFinalLap && options.FinalLapStyle == FinalLapStyle.Unchanged) + finalLap = normal; + progress?.Report(1.0); + + var output = LoudnessMeter.Measure(normalBuffer.Channels, normalBuffer.SampleRate, 0, normalBuffer.Loop?.End ?? normalBuffer.SampleCount); + var report = new ConversionReport( + loudness.IntegratedLufs, + options.TargetLufs + options.VolumeOffsetDb, + gainDb, + limiterReduction, + TimeSpan.FromSeconds(normalBuffer.SampleCount / (double)normalBuffer.SampleRate), + loopKind, + normalBuffer.Loop?.Start ?? 0, + normalBuffer.SampleCount, + output.IntegratedLufs, + pulsarVolume + ); + return new ConversionResult(normal, finalLap, report); + } + + private static AudioBuffer MakeFinalLap(AudioBuffer looped, Func speedUp) + { + var extended = AudioOps.ExtendPastLoopEnd(looped, looped.SampleRate / 5); + var faster = speedUp(extended); + return faster.Loop != null ? AudioOps.AlignLoopStart(faster, Brstm.SamplesPerBlock) : faster; + } + + private static byte[] Encode(AudioBuffer buffer, byte pulsarVolume) + { + var pcm = new short[buffer.ChannelCount][]; + for (var c = 0; c < buffer.ChannelCount; c++) + pcm[c] = AudioOps.ToPcm16(buffer.Channels[c], seed: 0x5EED + c); + + return BrstmWriter.Write( + new BrstmStream + { + Channels = pcm, + SampleRate = buffer.SampleRate, + Looping = buffer.Loop != null, + LoopStart = buffer.Loop?.Start ?? 0, + PulsarVolume = pulsarVolume == 0 ? null : Math.Min(pulsarVolume, MusicLoudness.MaxPulsarVolume), + } + ); + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Tags/Id3v2Reader.cs b/WheelWizard/Features/CustomMusic/Audio/Tags/Id3v2Reader.cs new file mode 100644 index 00000000..9047f805 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Tags/Id3v2Reader.cs @@ -0,0 +1,272 @@ +using System.Text; + +namespace WheelWizard.CustomMusic.Audio.Tags; + +/// Reads title, artist, album, cover art and loop hints from ID3v2.2/2.3/2.4 tags. +public static class Id3v2Reader +{ + /// Size of the ID3v2 tag at the start of (header + footer), or 0 if there is none. + public static int GetTagSize(ReadOnlySpan data) + { + if (data.Length < 10 || data[0] != 'I' || data[1] != 'D' || data[2] != '3') + return 0; + + var size = SyncSafe(data.Slice(6, 4)); + var hasFooter = (data[5] & 0x10) != 0; + return 10 + size + (hasFooter ? 10 : 0); + } + + public static AudioMetadata Read(ReadOnlySpan data) + { + var metadata = new AudioMetadata(); + var tagSize = GetTagSize(data); + if (tagSize == 0 || tagSize > data.Length) + return metadata; + + int version = data[3]; + var flags = data[5]; + var body = data.Slice(10, tagSize - 10 - ((flags & 0x10) != 0 ? 10 : 0)).ToArray(); + + // Tag-level unsynchronisation (mostly v2.2/2.3): undo the 0xFF 0x00 escaping first. + if ((flags & 0x80) != 0 && version < 4) + body = RemoveUnsynchronisation(body); + + var position = 0; + if ((flags & 0x40) != 0 && version >= 3 && body.Length >= 4) + { + // Skip the extended header. + var extendedSize = version == 4 ? SyncSafe(body.AsSpan(0, 4)) : ReadBigEndian(body, 0, 4) + 4; + position = Math.Clamp(extendedSize, 0, body.Length); + } + + var idLength = version == 2 ? 3 : 4; + var headerLength = version == 2 ? 6 : 10; + var bestPictureScore = -1; + + while (position + headerLength <= body.Length) + { + if (body[position] == 0) + break; // padding + + var id = Encoding.ASCII.GetString(body, position, idLength); + int frameSize; + var frameFlags = 0; + if (version == 2) + frameSize = ReadBigEndian(body, position + 3, 3); + else if (version == 4) + { + frameSize = SyncSafe(body.AsSpan(position + 4, 4)); + frameFlags = (body[position + 8] << 8) | body[position + 9]; + } + else + { + frameSize = ReadBigEndian(body, position + 4, 4); + frameFlags = (body[position + 8] << 8) | body[position + 9]; + } + + position += headerLength; + if (frameSize <= 0 || position + frameSize > body.Length) + break; + + var frame = body.AsSpan(position, frameSize).ToArray(); + position += frameSize; + + // v2.4 frame flags: unsynchronisation (0x0002) and data length indicator (0x0001). + if (version == 4) + { + var offset = 0; + if ((frameFlags & 0x0001) != 0 && frame.Length >= 4) + offset = 4; + if ((frameFlags & 0x0002) != 0) + frame = RemoveUnsynchronisation(frame[offset..]); + else if (offset > 0) + frame = frame[offset..]; + } + + // Compressed or encrypted frames are not worth supporting here. + if (version == 3 && (frameFlags & 0x00C0) != 0) + continue; + if (version == 4 && (frameFlags & 0x000C) != 0) + continue; + + switch (id) + { + case "TIT2": + case "TT2": + metadata.Title ??= ReadText(frame); + break; + case "TPE1": + case "TP1": + metadata.Artist ??= ReadText(frame); + break; + case "TPE2": + case "TP2": + // Album artist is a decent fallback when there is no track artist. + metadata.Artist ??= ReadText(frame); + break; + case "TALB": + case "TAL": + metadata.Album ??= ReadText(frame); + break; + case "TXXX": + case "TXX": + ReadUserText(frame, metadata); + break; + case "APIC": + case "PIC": + { + var picture = ReadPicture(frame, id == "PIC", out var pictureType); + if (picture == null) + break; + // Prefer the front cover (type 3), then "other" (0), then anything. + var score = pictureType == 3 ? 2 : pictureType == 0 ? 1 : 0; + if (score > bestPictureScore) + { + bestPictureScore = score; + metadata.Cover = picture; + } + + break; + } + } + } + + return metadata; + } + + private static void ReadUserText(byte[] frame, AudioMetadata metadata) + { + if (frame.Length < 2) + return; + + var encoding = frame[0]; + var (description, next) = ReadTerminated(frame, 1, encoding); + var value = Decode(frame.AsSpan(next), encoding).Trim('\0', ' '); + if (!long.TryParse(value, out var number)) + return; + + switch (description.Trim().ToUpperInvariant()) + { + case "LOOPSTART": + case "LOOP_START": + metadata.LoopStartTag = number; + break; + case "LOOPLENGTH": + case "LOOP_LENGTH": + metadata.LoopLengthTag = number; + break; + case "LOOPEND": + case "LOOP_END": + metadata.LoopEndTag = number; + break; + } + } + + private static CoverArt? ReadPicture(byte[] frame, bool isV22, out int pictureType) + { + pictureType = -1; + if (frame.Length < 4) + return null; + + var encoding = frame[0]; + string mime; + int position; + if (isV22) + { + // v2.2 uses a 3-character image format instead of a MIME type. + var format = Encoding.ASCII.GetString(frame, 1, 3).ToUpperInvariant(); + mime = format == "PNG" ? "image/png" : "image/jpeg"; + position = 4; + } + else + { + var mimeEnd = Array.IndexOf(frame, (byte)0, 1); + if (mimeEnd < 0) + return null; + mime = Encoding.Latin1.GetString(frame, 1, mimeEnd - 1); + position = mimeEnd + 1; + } + + if (position >= frame.Length) + return null; + + pictureType = frame[position]; + var (_, dataStart) = ReadTerminated(frame, position + 1, encoding); + if (dataStart >= frame.Length) + return null; + + return CoverArt.FromBytes(frame[dataStart..], mime); + } + + private static string? ReadText(byte[] frame) + { + if (frame.Length < 2) + return null; + + var text = Decode(frame.AsSpan(1), frame[0]); + // Multiple values are NUL separated; the first one is what players show. + var first = text.Split('\0', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + return string.IsNullOrWhiteSpace(first) ? null : first; + } + + private static (string Text, int Next) ReadTerminated(byte[] data, int start, byte encoding) + { + var wide = encoding is 1 or 2; + var position = start; + if (wide) + { + while (position + 1 < data.Length && (data[position] != 0 || data[position + 1] != 0)) + position += 2; + var text = Decode(data.AsSpan(start, Math.Max(0, position - start)), encoding); + return (text, Math.Min(data.Length, position + 2)); + } + + while (position < data.Length && data[position] != 0) + position++; + var narrow = Decode(data.AsSpan(start, position - start), encoding); + return (narrow, Math.Min(data.Length, position + 1)); + } + + private static string Decode(ReadOnlySpan data, byte encoding) + { + return encoding switch + { + 1 => DecodeUtf16WithBom(data), + 2 => Encoding.BigEndianUnicode.GetString(data), + 3 => Encoding.UTF8.GetString(data), + _ => Encoding.Latin1.GetString(data), + }; + } + + private static string DecodeUtf16WithBom(ReadOnlySpan data) + { + if (data.Length >= 2 && data[0] == 0xFE && data[1] == 0xFF) + return Encoding.BigEndianUnicode.GetString(data[2..]); + if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0xFE) + return Encoding.Unicode.GetString(data[2..]); + return Encoding.Unicode.GetString(data); + } + + private static byte[] RemoveUnsynchronisation(byte[] data) + { + var output = new List(data.Length); + for (var i = 0; i < data.Length; i++) + { + output.Add(data[i]); + if (data[i] == 0xFF && i + 1 < data.Length && data[i + 1] == 0x00) + i++; + } + + return output.ToArray(); + } + + private static int SyncSafe(ReadOnlySpan bytes) => (bytes[0] & 0x7F) << 21 | (bytes[1] & 0x7F) << 14 | (bytes[2] & 0x7F) << 7 | (bytes[3] & 0x7F); + + private static int ReadBigEndian(byte[] data, int offset, int length) + { + var value = 0; + for (var i = 0; i < length; i++) + value = (value << 8) | data[offset + i]; + return value; + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Tags/Mp4TagReader.cs b/WheelWizard/Features/CustomMusic/Audio/Tags/Mp4TagReader.cs new file mode 100644 index 00000000..1c08c03a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Tags/Mp4TagReader.cs @@ -0,0 +1,143 @@ +using System.Buffers.Binary; +using System.Text; + +namespace WheelWizard.CustomMusic.Audio.Tags; + +/// Reads iTunes-style metadata (moov/udta/meta/ilst) from MP4/M4A files: title, artist, album and cover art. +public static class Mp4TagReader +{ + public static bool IsMp4(ReadOnlySpan header) => + header.Length >= 12 && header[4] == 'f' && header[5] == 't' && header[6] == 'y' && header[7] == 'p'; + + public static AudioMetadata Read(Stream stream) + { + var metadata = new AudioMetadata(); + try + { + var moov = FindTopLevelAtom(stream, "moov"); + if (moov == null) + return metadata; + + var ilst = FindPath(moov, ["udta", "meta", "ilst"]); + if (ilst == null) + return metadata; + + foreach (var (type, payload) in Children(ilst, 0, ilst.Length)) + { + var data = Children(payload, 0, payload.Length).FirstOrDefault(child => child.Type == "data").Payload; + if (data == null || data.Length < 8) + continue; + + var value = data.AsSpan(8); + switch (type) + { + case "©nam": + metadata.Title ??= Encoding.UTF8.GetString(value).Trim('\0', ' '); + break; + case "©ART": + case "aART": + metadata.Artist ??= Encoding.UTF8.GetString(value).Trim('\0', ' '); + break; + case "©alb": + metadata.Album ??= Encoding.UTF8.GetString(value).Trim('\0', ' '); + break; + case "covr": + metadata.Cover ??= CoverArt.FromBytes(value.ToArray()); + break; + } + } + } + catch (Exception exception) when (exception is IOException or ArgumentException or EndOfStreamException) + { + // Broken metadata should never stop the audio from importing. + } + + return metadata; + } + + private static byte[]? FindTopLevelAtom(Stream stream, string wanted) + { + stream.Position = 0; + Span header = stackalloc byte[16]; + while (stream.Position + 8 <= stream.Length) + { + var start = stream.Position; + if (stream.Read(header[..8]) < 8) + return null; + + long size = BinaryPrimitives.ReadUInt32BigEndian(header); + var type = Encoding.Latin1.GetString(header.Slice(4, 4)); + var headerSize = 8; + if (size == 1) + { + if (stream.Read(header.Slice(8, 8)) < 8) + return null; + size = (long)BinaryPrimitives.ReadUInt64BigEndian(header[8..]); + headerSize = 16; + } + else if (size == 0) + { + size = stream.Length - start; + } + + if (size < headerSize || start + size > stream.Length) + return null; + + if (type == wanted) + { + // moov is small (a few MB at most, mostly the cover art), so reading it whole is fine. + if (size > 64 * 1024 * 1024) + return null; + var payload = new byte[size - headerSize]; + stream.ReadExactly(payload); + return payload; + } + + stream.Position = start + size; + } + + return null; + } + + private static byte[]? FindPath(byte[] parent, string[] path) + { + var current = parent; + foreach (var name in path) + { + var child = Children(current, 0, current.Length).FirstOrDefault(atom => atom.Type == name).Payload; + if (child == null) + return null; + + // "meta" is a full box: 4 bytes of version/flags precede its children. + current = name == "meta" && child.Length >= 4 ? child[4..] : child; + } + + return current; + } + + private static IEnumerable<(string Type, byte[] Payload)> Children(byte[] data, int start, int end) + { + var position = start; + while (position + 8 <= end) + { + long size = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(position)); + var type = Encoding.Latin1.GetString(data, position + 4, 4); + var headerSize = 8; + if (size == 1 && position + 16 <= end) + { + size = (long)BinaryPrimitives.ReadUInt64BigEndian(data.AsSpan(position + 8)); + headerSize = 16; + } + else if (size == 0) + { + size = end - position; + } + + if (size < headerSize || position + size > end) + yield break; + + yield return (type, data.AsSpan(position + headerSize, (int)size - headerSize).ToArray()); + position += (int)size; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Audio/Tags/VorbisComments.cs b/WheelWizard/Features/CustomMusic/Audio/Tags/VorbisComments.cs new file mode 100644 index 00000000..6bde4ffd --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Tags/VorbisComments.cs @@ -0,0 +1,138 @@ +using System.Buffers.Binary; +using System.Text; + +namespace WheelWizard.CustomMusic.Audio.Tags; + +/// Vorbis comments (used by FLAC, Ogg Vorbis and Opus) and the FLAC PICTURE structure they embed. +public static class VorbisComments +{ + /// Parses a Vorbis comment block (vendor string + list of KEY=value) in little endian. + public static AudioMetadata ParseBlock(ReadOnlySpan block) + { + var comments = new List(); + try + { + var position = 0; + var vendorLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(block[position..]); + position += 4 + vendorLength; + var count = (int)BinaryPrimitives.ReadUInt32LittleEndian(block[position..]); + position += 4; + for (var i = 0; i < count && position + 4 <= block.Length; i++) + { + var length = (int)BinaryPrimitives.ReadUInt32LittleEndian(block[position..]); + position += 4; + if (length < 0 || position + length > block.Length) + break; + comments.Add(Encoding.UTF8.GetString(block.Slice(position, length))); + position += length; + } + } + catch (ArgumentOutOfRangeException) + { + // Truncated block: keep whatever was read. + } + + return FromComments(comments); + } + + public static AudioMetadata FromComments(IEnumerable comments) + { + var metadata = new AudioMetadata(); + foreach (var comment in comments) + { + var separator = comment.IndexOf('='); + if (separator <= 0) + continue; + + var key = comment[..separator].Trim().ToUpperInvariant(); + var value = comment[(separator + 1)..].Trim(); + if (value.Length == 0) + continue; + + switch (key) + { + case "TITLE": + metadata.Title ??= value; + break; + case "ARTIST": + metadata.Artist ??= value; + break; + case "ALBUMARTIST": + case "ALBUM ARTIST": + metadata.Artist ??= value; + break; + case "ALBUM": + metadata.Album ??= value; + break; + case "LOOPSTART": + case "LOOP_START": + if (long.TryParse(value, out var start)) + metadata.LoopStartTag ??= start; + break; + case "LOOPLENGTH": + case "LOOP_LENGTH": + if (long.TryParse(value, out var length)) + metadata.LoopLengthTag ??= length; + break; + case "LOOPEND": + case "LOOP_END": + if (long.TryParse(value, out var end)) + metadata.LoopEndTag ??= end; + break; + case "METADATA_BLOCK_PICTURE": + if (metadata.Cover == null && TryFromBase64(value, out var pictureBlock)) + metadata.Cover = ParsePicture(pictureBlock, out _); + break; + case "COVERART": + // Legacy, pre-standard way of embedding art: plain base64 image bytes. + if (metadata.Cover == null && TryFromBase64(value, out var image)) + metadata.Cover = CoverArt.FromBytes(image); + break; + } + } + + return metadata; + } + + /// Parses a FLAC PICTURE block (big endian), as stored in FLAC metadata or base64 in Vorbis comments. + public static CoverArt? ParsePicture(ReadOnlySpan block, out int pictureType) + { + pictureType = -1; + try + { + var position = 0; + pictureType = (int)BinaryPrimitives.ReadUInt32BigEndian(block[position..]); + position += 4; + var mimeLength = (int)BinaryPrimitives.ReadUInt32BigEndian(block[position..]); + position += 4; + var mime = Encoding.ASCII.GetString(block.Slice(position, mimeLength)); + position += mimeLength; + var descriptionLength = (int)BinaryPrimitives.ReadUInt32BigEndian(block[position..]); + position += 4 + descriptionLength; + position += 16; // width, height, depth, colour count + var dataLength = (int)BinaryPrimitives.ReadUInt32BigEndian(block[position..]); + position += 4; + if (dataLength <= 0 || position + dataLength > block.Length) + return null; + return CoverArt.FromBytes(block.Slice(position, dataLength).ToArray(), mime); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + private static bool TryFromBase64(string value, out byte[] bytes) + { + try + { + bytes = Convert.FromBase64String(value); + return true; + } + catch (FormatException) + { + bytes = []; + return false; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Catalog/GameMusicCatalog.cs b/WheelWizard/Features/CustomMusic/Catalog/GameMusicCatalog.cs new file mode 100644 index 00000000..f8ba4fa1 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Catalog/GameMusicCatalog.cs @@ -0,0 +1,210 @@ +namespace WheelWizard.CustomMusic.Catalog; + +/// +/// Every piece of streamed music in Mario Kart Wii, plus the menu music files Retro Rewind reads. +/// The file names (including their inconsistent capitalisation, e.g. n_Kinoko_F vs n_Daisy32_f) were +/// verified against the external file table in the retail revo_kart.brsar. +/// +public static class GameMusicCatalog +{ + public const int RetroRewindOrderBase = 0; + public const int CourseOrderBase = 100_000; + public const int ArenaOrderBase = 110_000; + public const int MenuOrderBase = 120_000; + public const int JingleOrderBase = 130_000; + + private sealed record CourseMusic(string Name, string Cup, int CourseId, string Normal, string Final, string? AlsoUsedBy = null); + + private static readonly CourseMusic[] Courses = + [ + new("Luigi Circuit", "Mushroom Cup", 0x08, "n_Circuit32_n", "n_Circuit32_f", "Mario Circuit"), + new("Moo Moo Meadows", "Mushroom Cup", 0x01, "n_Farm_n", "n_Farm_F"), + new("Mushroom Gorge", "Mushroom Cup", 0x02, "n_Kinoko_n", "n_Kinoko_F"), + new("Toad's Factory", "Mushroom Cup", 0x04, "STRM_N_FACTORY_N", "STRM_N_FACTORY_F"), + new("Coconut Mall", "Flower Cup", 0x05, "n_Shopping32_n", "n_Shopping32_f"), + new("DK Summit", "Flower Cup", 0x06, "n_Snowboard32_n", "n_Snowboard32_f"), + new("Wario's Gold Mine", "Flower Cup", 0x07, "STRM_N_TRUCK_N", "STRM_N_TRUCK_F"), + new("Daisy Circuit", "Star Cup", 0x09, "n_Daisy32_n", "n_Daisy32_f"), + new("Koopa Cape", "Star Cup", 0x0F, "STRM_N_WATER_N", "STRM_N_WATER_F"), + new("Maple Treeway", "Star Cup", 0x0B, "n_maple_n", "n_maple_F"), + new("Grumble Volcano", "Star Cup", 0x03, "n_Volcano32_n", "n_Volcano32_f"), + new("Dry Dry Ruins", "Special Cup", 0x0E, "STRM_N_DESERT_N", "STRM_N_DESERT_F"), + new("Moonview Highway", "Special Cup", 0x0A, "STRM_N_RIDGEHIGHWAY_N", "STRM_N_RIDGEHIGHWAY_F"), + new("Bowser's Castle", "Special Cup", 0x0C, "STRM_N_KOOPA_N", "STRM_N_KOOPA_F"), + new("Rainbow Road", "Special Cup", 0x0D, "n_Rainbow32_n", "n_Rainbow32_f"), + new("GCN Peach Beach", "Shell Cup", 0x10, "r_GC_Beach32_n", "r_GC_Beach32_f"), + new("DS Yoshi Falls", "Shell Cup", 0x14, "r_DS_Jungle32_n", "r_DS_Jungle32_f"), + new("SNES Ghost Valley 2", "Shell Cup", 0x19, "r_SFC_Obake32_n", "r_SFC_Obake32_f"), + new("N64 Mario Raceway", "Shell Cup", 0x1A, "r_64_Circuit32_n", "r_64_Circuit32_f"), + new("N64 Sherbet Land", "Banana Cup", 0x1B, "r_64_Sherbet32_n", "r_64_Sherbet32_f"), + new("GBA Shy Guy Beach", "Banana Cup", 0x1F, "r_AGB_Beach32_n", "r_AGB_Beach32_f"), + new("DS Delfino Square", "Banana Cup", 0x17, "r_DS_Town32_n", "r_DS_Town32_f"), + new("GCN Waluigi Stadium", "Banana Cup", 0x12, "r_GC_Stadium32_n", "r_GC_Stadium32_f"), + new("DS Desert Hills", "Leaf Cup", 0x15, "r_DS_Desert32_n", "r_DS_Desert32_f"), + new("GBA Bowser Castle 3", "Leaf Cup", 0x1E, "r_AGB_Kuppa32_n", "r_AGB_Kuppa32_f"), + new("N64 DK's Jungle Parkway", "Leaf Cup", 0x1D, "r_64_Jungle32_n", "r_64_Jungle32_f"), + new("GCN Mario Circuit", "Leaf Cup", 0x11, "r_GC_Circuit32_n", "r_GC_Circuit32_f"), + new("SNES Mario Circuit 3", "Lightning Cup", 0x18, "r_SFC_Circuit32_n", "r_SFC_Circuit32_f"), + new("DS Peach Gardens", "Lightning Cup", 0x16, "r_DS_Garden32_n", "r_DS_Garden32_f"), + new("GCN DK Mountain", "Lightning Cup", 0x13, "r_GC_Mountain32_n", "r_GC_Mountain32_f"), + new("N64 Bowser's Castle", "Lightning Cup", 0x1C, "r_64_Kuppa32_n", "r_64_Kuppa32_f"), + ]; + + private static readonly CourseMusic[] Arenas = + [ + new("Block Plaza", "Wii Arenas", 0x21, "n_block_n", "n_block_F"), + new("Delfino Pier", "Wii Arenas", 0x20, "n_venice_n", "n_venice_F"), + new("Chain Chomp Roulette", "Wii Arenas", 0x22, "n_casino_n", "n_casino_F"), + new("Funky Stadium", "Wii Arenas", 0x23, "n_skate_n", "n_skate_F"), + new("Thwomp Desert", "Wii Arenas", 0x24, "n_ryuusa_n", "n_ryuusa_F"), + new("GCN Cookie Land", "Retro Arenas", 0x25, "r_GC_Battle32_n", "r_GC_Battle32_F"), + new("DS Twilight House", "Retro Arenas", 0x26, "r_ds_battle_n", "r_ds_battle_F"), + new("SNES Battle Course 4", "Retro Arenas", 0x27, "r_sfc_battle_n", "r_sfc_battle_F"), + new("GBA Battle Course 3", "Retro Arenas", 0x28, "r_agb_battle_n", "r_agb_battle_F"), + new("N64 Skyscraper", "Retro Arenas", 0x29, "r_64_battle_n", "r_64_battle_F"), + ]; + + /// Course id of Galaxy Colosseum, whose music has no final lap file. + public const int GalaxyColosseumCourseId = 0x36; + + private sealed record SingleMusic(string Key, string Name, string Group, string File, bool Loops, string? Detail = null, bool IsGameFile = true); + + private static readonly SingleMusic[] Menus = + [ + new("title_bg", "Title screen", "Menus", "title_bg", true, "Retro Rewind title screen music", IsGameFile: false), + new("offline_bg", "Offline menus", "Menus", "offline_bg", true, "Single player and local multiplayer menus", IsGameFile: false), + new("wifi_globe_bg", "Online menus", "Menus", "wifi_globe_bg", true, "Online menu and globe screens", IsGameFile: false), + new("wifi_lobby_bg", "Online lobby", "Menus", "wifi_lobby_bg", true, "Online rooms and track voting", IsGameFile: false), + new("o_Wi-Fi_waiting32", "Waiting to join a race", "Menus", "o_Wi-Fi_waiting32", true, "Also plays while watching ghost replays"), + new("o_Option_32", "Options menu", "Menus", "o_Option_32", true), + new("o_Ending_PartA_32", "Credits", "Menus", "o_Ending_PartA_32", true), + ]; + + private static readonly SingleMusic[] Jingles = + [ + new("o_Crs_In_Fan", "Course intro", "Race", "o_Crs_In_Fan", true, "Plays during the course flyover"), + new("o_Crs_In_Fan_Wifi", "Online course intro", "Race", "o_Crs_In_Fan_Wifi", true), + new("o_Crs_In_Fan_battle", "Battle intro", "Race", "o_Crs_In_Fan_battle", true), + new("o_Crs_In_Fan_mission", "Mission intro", "Race", "o_Crs_In_Fan_mission", true), + new("o_Start32_fan", "Race start fanfare", "Race", "o_Start32_fan", false), + new("o_Start2_32_fan", "Time Trial start fanfare", "Race", "o_Start2_32_fan", false), + new("o_FanfareGP1_32", "1st place: fanfare and results", "Results", "o_FanfareGP1_32", true), + new("o_FanfareGP1_only32", "1st place: fanfare only", "Results", "o_FanfareGP1_only32", false), + new("o_FanfareGP2_32", "2nd to 5th place: fanfare and results", "Results", "o_FanfareGP2_32", true), + new("o_FanfareGP2_only32", "2nd to 5th place: fanfare only", "Results", "o_FanfareGP2_only32", false), + new("o_FanfareGPdame_32", "6th place or lower: fanfare and results", "Results", "o_FanfareGPdame_32", true), + new("o_FanfareGPdame_only32", "6th place or lower: fanfare only", "Results", "o_FanfareGPdame_only32", false), + new("o_FanfareTA1st_32", "Battle win: fanfare and results", "Results", "o_FanfareTA1st_32", true), + new("o_FanfareTA1st_only32", "Battle win: fanfare only", "Results", "o_FanfareTA1st_only32", false), + new("o_FanfareBTdraw_32", "Battle draw", "Results", "o_FanfareBTdraw_32", true), + new("o_FanfareMIwin_32", "Victory: fanfare and results", "Results", "o_FanfareMIwin_32", true, "Mission win; Retro Rewind also uses it in Elimination"), + new("o_FanfareMIwin_only32", "Victory: fanfare only", "Results", "o_FanfareMIwin_only32", false), + new("o_FanfareMIlose_32", "Defeat: fanfare and results", "Results", "o_FanfareMIlose_32", true), + new("o_FanfareMIlose_only32", "Defeat: fanfare only", "Results", "o_FanfareMIlose_only32", false), + new("o_FanfareMIWinBoss_32", "Boss victory", "Results", "o_FanfareMIWinBoss_32", true), + new("o_hyousyou_winningrun", "Trophy ceremony", "Results", "o_hyousyou_winningrun", true), + new("o_hyousyou_normal", "Trophy ceremony (short)", "Results", "o_hyousyou_normal", true), + new("o_hyousyou_cut", "Trophy ceremony (shorter)", "Results", "o_hyousyou_cut", true), + new("o_hyousyou_lose", "Ceremony without a trophy", "Results", "o_hyousyou_lose", true), + new("strm_me", "Unlock jingle", "Results", "strm_me", false, "New character, kart or cup unlocked"), + ]; + + public static string KeyForGameFile(string baseName) => "game:" + baseName.ToLowerInvariant(); + + /// The slot key holding the music for an original course id (used for Retro Rewind music slots). + public static string? KeyForCourseId(int courseId) + { + if (courseId == 0x00) + courseId = 0x08; // Mario Circuit shares Luigi Circuit's music + if (courseId == GalaxyColosseumCourseId) + return KeyForGameFile("n_BossMI_32"); + var course = Courses.Concat(Arenas).FirstOrDefault(c => c.CourseId == courseId); + return course == null ? null : KeyForGameFile(StripLapSuffix(course.Normal)); + } + + /// Display name of the original course with the given id. + public static string? CourseName(int courseId) + { + if (courseId == 0x00) + return "Mario Circuit"; + if (courseId == GalaxyColosseumCourseId) + return "Galaxy Colosseum"; + return Courses.Concat(Arenas).FirstOrDefault(c => c.CourseId == courseId)?.Name; + } + + public static IReadOnlyList Build() + { + var slots = new List(); + var order = CourseOrderBase; + foreach (var course in Courses) + { + var name = course.AlsoUsedBy == null ? course.Name : $"{course.Name} / {course.AlsoUsedBy}"; + slots.Add(CourseSlot(course, name, MusicCategory.Course, order++, course.AlsoUsedBy == null ? null : $"Shared by {course.Name} and {course.AlsoUsedBy}")); + } + + slots.Add( + new MusicSlot + { + Key = KeyForGameFile("n_BossMI_32"), + Name = "Galaxy Colosseum", + Category = MusicCategory.Course, + Group = "Other courses", + Order = order, + FileNames = ["n_BossMI_32.brstm"], + GameFileName = "n_BossMI_32.brstm", + CourseId = GalaxyColosseumCourseId, + Detail = "Its final lap always speeds up instead of switching files", + SearchText = "n_BossMI_32", + } + ); + + order = ArenaOrderBase; + foreach (var arena in Arenas) + slots.Add(CourseSlot(arena, arena.Name, MusicCategory.Arena, order++, null)); + + order = MenuOrderBase; + foreach (var menu in Menus) + slots.Add(SingleSlot(menu, MusicCategory.Menu, order++)); + + order = JingleOrderBase; + foreach (var jingle in Jingles) + slots.Add(SingleSlot(jingle, MusicCategory.Jingle, order++)); + + return slots; + } + + private static MusicSlot CourseSlot(CourseMusic course, string name, MusicCategory category, int order, string? detail) => + new() + { + Key = KeyForGameFile(StripLapSuffix(course.Normal)), + Name = name, + Category = category, + Group = course.Cup, + Order = order, + FileNames = [course.Normal + ".brstm"], + FinalLapFileNames = [course.Final + ".brstm"], + GameFileName = course.Normal + ".brstm", + CourseId = course.CourseId, + Detail = detail, + SearchText = $"{course.Normal} {course.Final} {course.AlsoUsedBy}", + }; + + private static MusicSlot SingleSlot(SingleMusic music, MusicCategory category, int order) => + new() + { + Key = music.IsGameFile ? KeyForGameFile(music.File) : "menu:" + music.Key.ToLowerInvariant(), + Name = music.Name, + Category = category, + Group = music.Group, + Order = order, + FileNames = [music.File + ".brstm"], + Loops = music.Loops, + GameFileName = music.IsGameFile ? music.File + ".brstm" : null, + Detail = music.Detail, + SearchText = music.File, + }; + + /// "n_Circuit32_n" -> "n_Circuit32", "STRM_N_KOOPA_N" -> "STRM_N_KOOPA". + public static string StripLapSuffix(string name) => + name.EndsWith("_n", StringComparison.OrdinalIgnoreCase) || name.EndsWith("_f", StringComparison.OrdinalIgnoreCase) ? name[..^2] : name; +} diff --git a/WheelWizard/Features/CustomMusic/Catalog/MusicCatalogService.cs b/WheelWizard/Features/CustomMusic/Catalog/MusicCatalogService.cs new file mode 100644 index 00000000..05c319a2 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Catalog/MusicCatalogService.cs @@ -0,0 +1,237 @@ +using System.IO.Abstractions; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomDistributions; + +namespace WheelWizard.CustomMusic.Catalog; + +public interface IMusicCatalogService +{ + /// Builds the list of replaceable music: the installed Retro Rewind tracks plus the original game's music. + Task LoadAsync(CancellationToken cancellationToken = default); +} + +public sealed class MusicCatalogService(IFileSystem fileSystem, ICustomDistributionPaths paths, ILogger logger) + : IMusicCatalogService +{ + private static readonly string[] ConfigFiles = ["ConfigRT.pul", "ConfigCT.pul", "ConfigBT.pul"]; + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.Run(() => Load(cancellationToken), cancellationToken); + + private MusicCatalog Load(CancellationToken cancellationToken) + { + var gameSlots = GameMusicCatalog.Build(); + var state = RetroRewindCatalogState.NotInstalled; + var retroRewindSlots = new List(); + var shippedMusic = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + if (fileSystem.Directory.Exists(paths.RetroRewindFolderPath)) + { + shippedMusic = IndexRetroRewindMusic(); + var gameFiles = gameSlots.Where(slot => slot.GameFileName != null).ToDictionary(slot => slot.Key, slot => slot.GameFileName!); + retroRewindSlots = BuildRetroRewindSlots(shippedMusic, gameFiles, cancellationToken); + state = retroRewindSlots.Count > 0 ? RetroRewindCatalogState.Loaded : RetroRewindCatalogState.Unreadable; + } + } + // A damaged config can also make the binary readers run off the end of the data. + catch (Exception exception) + when (exception is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or IndexOutOfRangeException) + { + logger.LogWarning(exception, "Could not read the Retro Rewind track list for custom music."); + state = RetroRewindCatalogState.Unreadable; + } + + // How many Retro Rewind tracks fall back to each original song (they have no music of their own). + var slotUsage = retroRewindSlots + .Where(slot => slot.RetroRewindFilePath == null && slot.CourseId is { } id && GameMusicCatalog.KeyForCourseId(id) != null) + .GroupBy(slot => GameMusicCatalog.KeyForCourseId(slot.CourseId!.Value)!) + .ToDictionary(group => group.Key, group => group.Count()); + + var finalGameSlots = gameSlots + .Select(slot => + { + var updated = slot; + if (slotUsage.TryGetValue(slot.Key, out var users)) + { + var usage = $"Also plays on {users} Retro Rewind {(users == 1 ? "track" : "tracks")}"; + updated = updated with { Detail = updated.Detail == null ? usage : $"{updated.Detail}. {usage}" }; + } + + // Retro Rewind may ship its own versions of menu music (title_bg.brstm, ...). + var shipped = slot.FileNames.Select(name => shippedMusic.GetValueOrDefault(name)).FirstOrDefault(path => path != null); + return shipped == null ? updated : updated with { RetroRewindFilePath = shipped }; + }) + .ToList(); + + return new MusicCatalog(retroRewindSlots.Concat(finalGameSlots).ToList(), state); + } + + private List BuildRetroRewindSlots( + Dictionary shippedMusic, + Dictionary gameFiles, + CancellationToken cancellationToken + ) + { + var binaries = fileSystem.Path.Combine(paths.RetroRewindFolderPath, "Binaries"); + var configs = ConfigFiles.Select(name => fileSystem.Path.Combine(binaries, name)).Where(fileSystem.File.Exists).ToList(); + if (configs.Count == 0) + { + var single = fileSystem.Path.Combine(binaries, "Config.pul"); + if (fileSystem.File.Exists(single)) + configs.Add(single); + } + + var slots = new List(); + var globalOffset = 0; + foreach (var configPath in configs) + { + cancellationToken.ThrowIfCancellationRequested(); + var config = PulsarConfigReader.Read(fileSystem.File.ReadAllBytes(configPath)); + var isBattle = fileSystem.Path.GetFileName(configPath).Equals("ConfigBT.pul", StringComparison.OrdinalIgnoreCase); + var configKey = fileSystem.Path.GetFileNameWithoutExtension(configPath).ToLowerInvariant(); + + foreach (var track in config.Tracks) + { + var globalIndex = globalOffset + track.LocalIndex; + var slot = BuildTrackSlot(track, config, globalIndex, isBattle, configKey, shippedMusic, gameFiles); + if (slot != null) + slots.Add(slot); + } + + globalOffset += config.CupCount * 4; + } + + // Keys must be unique; duplicated creator names would otherwise collide. + var unique = slots.GroupBy(slot => slot.Key).Select(group => group.First()).ToList(); + + // Tracks that share a name get their file name appended so they can be told apart. + var duplicated = unique.GroupBy(slot => slot.Name, StringComparer.OrdinalIgnoreCase).Where(group => group.Count() > 1).Select(group => group.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + return unique + .Select(slot => duplicated.Contains(slot.Name) ? slot with { Name = $"{slot.Name} ({StripSuffix(slot.FileNames[0])})" } : slot) + .ToList(); + } + + private MusicSlot? BuildTrackSlot( + PulsarTrack track, + PulsarConfig config, + int globalIndex, + bool isBattle, + string configKey, + Dictionary shippedMusic, + Dictionary gameFiles + ) + { + // Retro Rewind looks for strm/_n.brstm, trying the creator file name, the track name and + // finally the track index (see BRSTMExpansion.cpp in Retro Rewind's Pulsar). Our file only has to + // win the first lookup, so it is written under the first usable name. + var candidates = new[] { track.FileName, track.Name, globalIndex.ToString() } + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var baseName = candidates.FirstOrDefault(IsUsableFileName); + if (baseName == null) + return null; + + var shipped = candidates.Select(name => shippedMusic.GetValueOrDefault($"{name}_n.brstm")).FirstOrDefault(path => path != null); + + var normalNames = new List { $"{baseName}_n.brstm" }; + var finalNames = new List { $"{baseName}_final.brstm" }; + foreach (var variant in track.Variants.Where(v => v.FileName != null && IsUsableFileName(v.FileName))) + { + normalNames.Add($"{variant.FileName}_n.brstm"); + finalNames.Add($"{variant.FileName}_final.brstm"); + } + + // Some tracks switch to new music at checkpoints (name_n2, name_n3, ...); replace those parts too. + for (var part = 2; part <= 9; part++) + { + if (candidates.Any(name => shippedMusic.ContainsKey($"{name}_n{part}.brstm"))) + normalNames.Add($"{baseName}_n{part}.brstm"); + } + + var name = track.Name ?? track.FileName ?? $"Track {globalIndex}"; + var cupName = track.CupIndex < config.CupNames.Count ? config.CupNames[track.CupIndex] : $"Cup {track.CupIndex + 1}"; + var musicCourse = GameMusicCatalog.CourseName(track.MusicSlot); + var detail = shipped != null + ? "Retro Rewind music" + : musicCourse != null + ? $"Uses {musicCourse} music" + : null; + + // The file name tells apart tracks whose names look alike. + detail = detail == null ? baseName : $"{detail} · {baseName}"; + + string? gameFile = null; + if (shipped == null && GameMusicCatalog.KeyForCourseId(track.MusicSlot) is { } slotKey) + gameFile = gameFiles.GetValueOrDefault(slotKey); + + return new MusicSlot + { + Key = "rr:" + (track.FileName ?? $"{configKey}:{track.LocalIndex}").ToLowerInvariant(), + Name = name, + Category = isBattle ? MusicCategory.RetroRewindArena : MusicCategory.RetroRewindTrack, + Group = cupName, + Order = GameMusicCatalog.RetroRewindOrderBase + globalIndex, + FileNames = normalNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + FinalLapFileNames = finalNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + RetroRewindFilePath = shipped, + GameFileName = gameFile, + CourseId = track.MusicSlot, + Detail = detail, + SearchText = string.Join(' ', candidates.Append(cupName)), + }; + } + + private static string StripSuffix(string fileName) => fileName.EndsWith("_n.brstm", StringComparison.OrdinalIgnoreCase) ? fileName[..^8] : fileName; + + // The music is written as _n.brstm, so the name must be a valid file name everywhere. + private static bool IsUsableFileName(string name) => name.Length <= 48 && PathText.IsValidFileName(name); + + /// Every BRSTM Retro Rewind maps onto /sound/strm, indexed by file name. + private Dictionary IndexRetroRewindMusic() + { + var folders = new List + { + fileSystem.Path.Combine(paths.RetroRewindFolderPath, "strm"), + fileSystem.Path.Combine(paths.RetroRewindFolderPath, "CTBRSTM"), + }; + + try + { + if (fileSystem.File.Exists(paths.XmlFilePath)) + { + var document = XDocument.Parse(fileSystem.File.ReadAllText(paths.XmlFilePath)); + foreach (var folder in document.Descendants().Where(element => element.Name.LocalName == "folder")) + { + var disc = folder.Attribute("disc")?.Value.Trim('/') ?? string.Empty; + var external = folder.Attribute("external")?.Value; + if (external == null || !disc.Equals("sound/strm", StringComparison.OrdinalIgnoreCase)) + continue; + + var relative = external.Replace("{$pack}", "RetroRewind6").TrimStart('/', '\\').Replace('/', fileSystem.Path.DirectorySeparatorChar); + folders.Add(fileSystem.Path.Combine(paths.RootFolderPath, relative)); + } + } + } + catch (Exception exception) when (exception is System.Xml.XmlException or IOException) + { + logger.LogDebug(exception, "Could not read the Retro Rewind XML while looking for music folders."); + } + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var folder in folders.Distinct(StringComparer.OrdinalIgnoreCase).Where(fileSystem.Directory.Exists)) + { + // Match the extension ourselves: wildcard patterns are case sensitive on Linux. + foreach (var file in fileSystem.Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)) + { + if (file.EndsWith(".brstm", StringComparison.OrdinalIgnoreCase)) + index.TryAdd(fileSystem.Path.GetFileName(file), file); + } + } + + return index; + } +} diff --git a/WheelWizard/Features/CustomMusic/Catalog/MusicSlot.cs b/WheelWizard/Features/CustomMusic/Catalog/MusicSlot.cs new file mode 100644 index 00000000..bd556c33 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Catalog/MusicSlot.cs @@ -0,0 +1,87 @@ +namespace WheelWizard.CustomMusic.Catalog; + +public enum MusicCategory +{ + /// A Retro Rewind race track (retro and custom tracks from ConfigRT/ConfigCT). + RetroRewindTrack, + + /// A Retro Rewind battle arena (ConfigBT). + RetroRewindArena, + + /// One of the original 32 courses (or Galaxy Colosseum). + Course, + + /// One of the original 10 battle arenas. + Arena, + + /// Menu background music. + Menu, + + /// Intros, fanfares, results and other jingles. + Jingle, +} + +/// +/// One piece of music the player can replace: what it is called, which files the game loads for it, +/// and where the original can be found for loudness matching. +/// +public sealed record MusicSlot +{ + /// Stable identifier, used to remember customisations between sessions and Retro Rewind updates. + public required string Key { get; init; } + + public required string Name { get; init; } + public required MusicCategory Category { get; init; } + + /// Section heading for the default (cup) order, e.g. "Mushroom Cup". + public required string Group { get; init; } + + /// Position in the default order (cup order, then track order). + public required int Order { get; init; } + + /// File names (no folders) that receive the normal version. + public required IReadOnlyList FileNames { get; init; } + + /// File names that receive the final lap version; empty when the music has no final lap. + public IReadOnlyList FinalLapFileNames { get; init; } = []; + + /// Whether the replacement should loop (courses and menus) or play once (jingles). + public bool Loops { get; init; } = true; + + /// The original game's file (in /sound/strm) whose loudness the replacement is matched to. + public string? GameFileName { get; init; } + + /// A BRSTM shipped by Retro Rewind that currently plays here, used for loudness matching. + public string? RetroRewindFilePath { get; init; } + + /// Short explanation of what currently plays here (e.g. "Uses Luigi Circuit's music"). + public string? Detail { get; init; } + + /// The original course this slot belongs to (Mario Kart Wii course id), when there is one. + public int? CourseId { get; init; } + + /// Extra search terms (file names, alternative names). + public string SearchText { get; init; } = string.Empty; + + public bool HasFinalLap => FinalLapFileNames.Count > 0; + + /// + /// Retro Rewind's own menu music is played through Koopa Cape's sound with every track of the file at full + /// volume, so all tracks are heard at once; everything else is heard through its first track. + /// + public bool PlaysAllTracks => Key.StartsWith("menu:", StringComparison.Ordinal); + + public IEnumerable AllFileNames => FileNames.Concat(FinalLapFileNames); +} + +public sealed record MusicCatalog(IReadOnlyList Slots, RetroRewindCatalogState RetroRewind) +{ + public static MusicCatalog Empty { get; } = new([], RetroRewindCatalogState.NotInstalled); +} + +public enum RetroRewindCatalogState +{ + Loaded, + NotInstalled, + Unreadable, +} diff --git a/WheelWizard/Features/CustomMusic/Catalog/PulsarConfigReader.cs b/WheelWizard/Features/CustomMusic/Catalog/PulsarConfigReader.cs new file mode 100644 index 00000000..4a00d693 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Catalog/PulsarConfigReader.cs @@ -0,0 +1,246 @@ +using System.Buffers.Binary; +using System.Text; + +namespace WheelWizard.CustomMusic.Catalog; + +/// A track defined in a Pulsar config file. +public sealed record PulsarTrack( + int LocalIndex, + int CupIndex, + byte Slot, + byte MusicSlot, + string? Name, + string? FileName, + IReadOnlyList Variants +); + +public sealed record PulsarVariant(int VariantIndex, byte Slot, byte MusicSlot, string? Name, string? FileName); + +public sealed record PulsarConfig(IReadOnlyList CupNames, IReadOnlyList Tracks) +{ + public int CupCount => CupNames.Count; +} + +/// +/// Reads Pulsar's Binaries/Config*.pul: the CUPS section (tracks, music slots, variants), the embedded BMG +/// (track and cup names) and the FILE text block after it (the creator file names Retro Rewind uses to find +/// a track's music: strm/<file name>_n.brstm). +/// +public static class PulsarConfigReader +{ + private const uint PulsMagic = 0x50554C53; // "PULS" + private const uint CupsMagic = 0x43555053; // "CUPS" + private const int BmgCups = 0x10000; + private const int BmgTracks = 0x20000; + + public static PulsarConfig Read(byte[] file) + { + if (file.Length < 0x20 || BinaryPrimitives.ReadUInt32BigEndian(file) != PulsMagic) + throw new InvalidDataException("This is not a Pulsar config file."); + + var cupsOffset = BinaryPrimitives.ReadInt32BigEndian(file.AsSpan(0x0C)); + var bmgOffset = BinaryPrimitives.ReadInt32BigEndian(file.AsSpan(0x10)); + if (cupsOffset <= 0 || cupsOffset + 0x1C > file.Length) + throw new InvalidDataException("The Pulsar config has no CUPS section."); + if (BinaryPrimitives.ReadUInt32BigEndian(file.AsSpan(cupsOffset)) != CupsMagic) + throw new InvalidDataException("The Pulsar CUPS section is invalid."); + + var cupCount = BinaryPrimitives.ReadUInt16BigEndian(file.AsSpan(cupsOffset + 0x0C)); + var totalVariants = BinaryPrimitives.ReadInt32BigEndian(file.AsSpan(cupsOffset + 0x18)); + var storedCups = cupCount % 2 == 1 ? cupCount + 1 : cupCount; + var trackCount = cupCount * 4; + var tracksStart = cupsOffset + 0x1C; + var variantsStart = tracksStart + storedCups * 4 * 8; + if (variantsStart + Math.Max(0, totalVariants) * 2 > file.Length) + throw new InvalidDataException("The Pulsar track table is truncated."); + + var messages = new Dictionary(); + var fileNames = new Dictionary(); + if (bmgOffset > 0 && bmgOffset + 0x20 <= file.Length && BmgReader.IsBmg(file.AsSpan(bmgOffset))) + { + var bmgLength = (int)Math.Min(BinaryPrimitives.ReadUInt32BigEndian(file.AsSpan(bmgOffset + 8)), (uint)(file.Length - bmgOffset)); + messages = BmgReader.Read(file.AsSpan(bmgOffset, bmgLength)); + fileNames = ReadFileSection(file.AsSpan(bmgOffset + bmgLength)); + } + + var cupNames = new List(cupCount); + for (var cup = 0; cup < cupCount; cup++) + cupNames.Add(messages.TryGetValue((uint)(BmgCups + cup), out var cupName) ? CleanName(cupName) : $"Cup {cup + 1}"); + + var tracks = new List(trackCount); + var variantCursor = 0; + for (var i = 0; i < trackCount; i++) + { + var entry = tracksStart + i * 8; + var slot = file[entry]; + var musicSlot = file[entry + 1]; + var variantCount = BinaryPrimitives.ReadUInt16BigEndian(file.AsSpan(entry + 2)); + + var variants = new List(variantCount); + for (var v = 1; v <= variantCount && variantCursor < totalVariants; v++, variantCursor++) + { + var variantEntry = variantsStart + variantCursor * 2; + variants.Add( + new PulsarVariant( + v, + file[variantEntry], + file[variantEntry + 1], + messages.TryGetValue((uint)(BmgTracks + (v << 12) + i), out var variantName) ? CleanName(variantName) : null, + fileNames.GetValueOrDefault((v << 12) + i) + ) + ); + } + + // Tracks with variants have a shared "common name" at variant index 8. + string? name = null; + if (variantCount > 0 && messages.TryGetValue((uint)(BmgTracks + (8 << 12) + i), out var common)) + name = CleanName(common); + if (string.IsNullOrWhiteSpace(name) && messages.TryGetValue((uint)(BmgTracks + i), out var trackName)) + name = CleanName(trackName); + + tracks.Add(new PulsarTrack(i, i / 4, slot, musicSlot, string.IsNullOrWhiteSpace(name) ? null : name, fileNames.GetValueOrDefault(i), variants)); + } + + return new PulsarConfig(cupNames, tracks); + } + + /// "FILE" followed by lines like "1A=uMKS|expert1|expert2..."; keys are (variant << 12) | track. + public static Dictionary ReadFileSection(ReadOnlySpan data) + { + var result = new Dictionary(); + if (data.Length < 4) + return result; + + var text = Encoding.UTF8.GetString(data).TrimStart('', '\0'); + if (!text.StartsWith("FILE", StringComparison.Ordinal)) + return result; + + foreach (var rawLine in text.Split('\n').Skip(1)) + { + var line = rawLine.Trim('\r', ' ', '\t', '\0'); + var equals = line.IndexOf('='); + if (equals <= 0) + continue; + + var key = line[..equals].Trim(); + var value = line[(equals + 1)..]; + var pipe = value.IndexOf('|'); + if (pipe < 0) + continue; + value = value[..pipe].Trim(); + if (value.Length == 0) + continue; + + if (int.TryParse(key, System.Globalization.NumberStyles.HexNumber, null, out var id)) + result[id] = value; + } + + return result; + } + + private static string CleanName(string name) => name.Trim(); +} + +/// Minimal reader for Nintendo BMG message files (UTF-16 with INF1/DAT1/MID1 blocks). +public static class BmgReader +{ + public static bool IsBmg(ReadOnlySpan data) => data.Length >= 8 && Encoding.ASCII.GetString(data[..8]) == "MESGbmg1"; + + /// Returns message id -> text, with escape sequences (colours, icons) removed. + public static Dictionary Read(ReadOnlySpan data) + { + var result = new Dictionary(); + if (!IsBmg(data)) + return result; + + var encoding = data[0x10]; + var blockCount = BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]); + int inf = -1, + dat = -1, + mid = -1; + var position = 0x20; + for (var block = 0; block < blockCount && position + 8 <= data.Length; block++) + { + var magic = Encoding.ASCII.GetString(data.Slice(position, 4)); + var size = (int)BinaryPrimitives.ReadUInt32BigEndian(data[(position + 4)..]); + if (size < 8) + break; + switch (magic) + { + case "INF1": + inf = position; + break; + case "DAT1": + dat = position; + break; + case "MID1": + mid = position; + break; + } + + position += size; + } + + if (inf < 0 || dat < 0) + return result; + + var count = BinaryPrimitives.ReadUInt16BigEndian(data[(inf + 8)..]); + var entrySize = BinaryPrimitives.ReadUInt16BigEndian(data[(inf + 10)..]); + var midCount = mid >= 0 ? BinaryPrimitives.ReadUInt16BigEndian(data[(mid + 8)..]) : 0; + var textStart = dat + 8; + + for (var i = 0; i < count; i++) + { + var entry = inf + 0x10 + i * entrySize; + if (entry + 4 > data.Length) + break; + var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(data[entry..]); + var id = mid >= 0 && i < midCount ? BinaryPrimitives.ReadUInt32BigEndian(data[(mid + 0x10 + i * 4)..]) : (uint)i; + var text = ReadString(data, textStart + offset, encoding); + if (text != null) + result[id] = text; + } + + return result; + } + + private static string? ReadString(ReadOnlySpan data, int start, byte encoding) + { + if (start < 0 || start >= data.Length) + return null; + + var builder = new StringBuilder(); + if (encoding is 2 or 0) + { + var position = start; + while (position + 1 < data.Length) + { + var c = (char)BinaryPrimitives.ReadUInt16BigEndian(data[position..]); + if (c == '\0') + break; + if (c == '\u001A') + { + // Escape: 0x1A, then the total escape length in bytes (including the 0x1A and length byte). + if (position + 2 >= data.Length) + break; + var length = data[position + 2]; + // Colour and icon escapes are dropped but the text around them is kept: names like + // "3DS Toad Circuit" or "Track v2" must not be cut at the first escape. + position += Math.Max(4, length + (length % 2)); + continue; + } + + builder.Append(c); + position += 2; + } + } + else + { + var end = data[start..].IndexOf((byte)0); + var slice = end < 0 ? data[start..] : data.Slice(start, end); + builder.Append(encoding == 4 ? Encoding.UTF8.GetString(slice) : Encoding.Latin1.GetString(slice)); + } + + return System.Text.RegularExpressions.Regex.Replace(builder.ToString(), @"\s+", " ").Trim(); + } +} diff --git a/WheelWizard/Features/CustomMusic/Catalog/TrackNames.cs b/WheelWizard/Features/CustomMusic/Catalog/TrackNames.cs new file mode 100644 index 00000000..d05b8a16 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Catalog/TrackNames.cs @@ -0,0 +1,46 @@ +namespace WheelWizard.CustomMusic.Catalog; + +/// Recognises the same course across consoles: "SNES Rainbow Road", "N64 Rainbow Road" and "Rainbow Road" are one family. +public static class TrackNames +{ + // Console tags as Retro Rewind writes them in front of retro track names, longest first. + private static readonly string[] ConsolePrefixes = ["wii u ", "switch ", "tour ", "snes ", "n64 ", "gba ", "gcn ", "3ds ", "ds ", "wii ", "gc ", "gb "]; + + /// + /// The course name without console tag, version suffix or bracketed note, in lower case + /// ("SNES Rainbow Road v2 (rSRR)" -> "rainbow road"), or null when nothing is left. + /// + public static string? FamilyKey(string name) => DisplayFamily(name)?.ToLowerInvariant(); + + /// Like but keeping the original capitalisation ("Rainbow Road"). + public static string? DisplayFamily(string name) + { + var text = name.Trim(); + // "Luigi Circuit / Mario Circuit" shares music between two courses; the first names the family. + var slash = text.IndexOf(" / ", StringComparison.Ordinal); + if (slash > 0) + text = text[..slash]; + + // Bracketed notes: "(Night)", "(all versions)", or the file name added to tell duplicates apart. + var bracket = text.IndexOf(" (", StringComparison.Ordinal); + if (bracket > 0) + text = text[..bracket]; + + // Version suffix: "Track v2", "Track v1.1". + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + if (words.Count > 1 && words[^1].Length > 1 && words[^1][0] is 'v' or 'V' && words[^1][1..].All(c => char.IsDigit(c) || c == '.')) + words.RemoveAt(words.Count - 1); + text = string.Join(' ', words); + + foreach (var prefix in ConsolePrefixes) + { + if (text.Length > prefix.Length && text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + text = text[prefix.Length..]; + break; + } + } + + return text.Length == 0 ? null : text; + } +} diff --git a/WheelWizard/Features/CustomMusic/CustomMusicExtensions.cs b/WheelWizard/Features/CustomMusic/CustomMusicExtensions.cs new file mode 100644 index 00000000..0eb50d15 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/CustomMusicExtensions.cs @@ -0,0 +1,24 @@ +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; +using WheelWizard.CustomMusic.Playback; +using WheelWizard.CustomMusic.ViewModels; + +namespace WheelWizard.CustomMusic; + +public static class CustomMusicExtensions +{ + public static IServiceCollection AddCustomMusic(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => AudioOutput.Create(provider.GetRequiredService())); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + return services; + } +} diff --git a/WheelWizard/Features/CustomMusic/CustomMusicService.cs b/WheelWizard/Features/CustomMusic/CustomMusicService.cs new file mode 100644 index 00000000..f564e1eb --- /dev/null +++ b/WheelWizard/Features/CustomMusic/CustomMusicService.cs @@ -0,0 +1,275 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; + +namespace WheelWizard.CustomMusic; + +/// The custom music workflow: list slots, convert songs, and save them into the game. +public interface ICustomMusicService +{ + Task LoadCatalogAsync(CancellationToken cancellationToken = default); + + CustomMusicLibraryData LoadLibrary(); + + /// Persists view preferences (sort order, final lap style, volume adjustment) without touching saved music. + void SavePreferences(MusicSortMode sortMode, FinalLapStyle finalLapStyle, double volumeOffsetDb); + + /// Converts into BRSTM(s) for . Nothing is installed until . + Task> ConvertAsync( + MusicSlot slot, + string sourcePath, + FinalLapStyle finalLapStyle, + double volumeOffsetDb = 0, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// Installs into the game (via the "Custom Music" patch) and remembers them. + Task SaveAsync(IReadOnlyCollection entries, IReadOnlyCollection catalog, CancellationToken cancellationToken = default); + + /// + /// Deletes converted files and extracted music packs that no saved song uses. Only safe while no unsaved + /// conversion is wanted: those files are deleted too. + /// + void DeleteUnsavedFiles(); + + string? GetCoverPath(CustomMusicEntry entry); + + CustomMusicModState GetModState(); + + Task EnableModAsync(); + + IReadOnlyList FindConflicts(); + + /// + IReadOnlyDictionary FindPackMusic(); + + /// + /// Extracts the songs in a music pack (.zip) into the library, so they can be matched to tracks and converted. + /// Returns the extracted files. + /// + Task>> ExtractPackAsync(string zipPath, CancellationToken cancellationToken = default); +} + +public sealed class CustomMusicService( + IMusicCatalogService catalog, + ICustomMusicLibrary library, + IMusicReferenceService references, + ICustomMusicInstaller installer, + IFileSystem fileSystem, + ILogger logger +) : ICustomMusicService +{ + // library.json is read-modify-written by preference changes and saves, which can overlap. + private readonly object _libraryLock = new(); + + public Task LoadCatalogAsync(CancellationToken cancellationToken = default) => catalog.LoadAsync(cancellationToken); + + public CustomMusicLibraryData LoadLibrary() + { + lock (_libraryLock) + { + var data = library.Load(); + // Leftovers of conversions that were never saved (e.g. the app closed with unsaved changes). + library.DeleteUnused(data.Entries); + return data; + } + } + + public void SavePreferences(MusicSortMode sortMode, FinalLapStyle finalLapStyle, double volumeOffsetDb) + { + lock (_libraryLock) + { + var data = library.Load() with { SortMode = sortMode, FinalLapStyle = finalLapStyle, VolumeOffsetDb = volumeOffsetDb }; + var result = library.Save(data); + if (result.IsFailure) + logger.LogWarning("Could not save custom music preferences: {Message}", result.Error.Message); + } + } + + public async Task> ConvertAsync( + MusicSlot slot, + string sourcePath, + FinalLapStyle finalLapStyle, + double volumeOffsetDb = 0, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + string? storageFolder = null; + try + { + if (!fileSystem.File.Exists(sourcePath)) + return Fail(t("custom_music.error.file_missing")); + + progress?.Report(0.02); + var reference = await references.GetReferenceAsync(slot, cancellationToken); + + var audio = await AudioFileDecoder.DecodeAsync(fileSystem, sourcePath, slot.PlaysAllTracks, cancellationToken); + progress?.Report(0.25); + + var options = new ConversionOptions + { + Loop = reference.Loops ?? slot.Loops, + CreateFinalLap = slot.HasFinalLap, + FinalLapStyle = finalLapStyle, + // Match the loudness of the original file and carry its volume byte, so both play at the same level. + TargetLufs = reference.Lufs, + VolumeOffsetDb = volumeOffsetDb, + PulsarVolume = reference.PulsarVolume, + }; + + var conversionProgress = progress == null ? null : new Progress(value => progress.Report(0.25 + value * 0.73)); + var result = await Task.Run(() => MusicConverter.Convert(audio, options, conversionProgress, cancellationToken), cancellationToken); + + var (storageId, folder) = library.CreateStorage(); + storageFolder = folder; + await fileSystem.File.WriteAllBytesAsync(fileSystem.Path.Combine(folder, "normal.brstm"), result.Normal, cancellationToken); + string? finalFile = null; + if (result.FinalLap != null && !ReferenceEquals(result.FinalLap, result.Normal)) + { + finalFile = "final.brstm"; + await fileSystem.File.WriteAllBytesAsync(fileSystem.Path.Combine(folder, finalFile), result.FinalLap, cancellationToken); + } + + string? coverFile = null; + if (audio.Metadata.Cover is { } cover) + { + coverFile = "cover" + cover.FileExtension; + await fileSystem.File.WriteAllBytesAsync(fileSystem.Path.Combine(folder, coverFile), cover.Data, cancellationToken); + } + + progress?.Report(1.0); + return new CustomMusicEntry + { + Key = slot.Key, + StorageId = storageId, + SourcePath = sourcePath, + Title = audio.Metadata.Title, + Artist = audio.Metadata.Artist, + Album = audio.Metadata.Album, + CoverFile = coverFile, + AddedUtc = DateTime.UtcNow, + SourceLufs = result.Report.SourceLufs, + TargetLufs = result.Report.TargetLufs, + VolumeOffsetDb = volumeOffsetDb, + ReferenceSource = reference.Source, + ReferenceNote = reference.Source == MusicReferenceSource.Estimate ? reference.Problem : null, + ReferenceLufs = reference.Lufs, + ReferenceVolume = reference.PulsarVolume, + OutputLufs = result.Report.OutputLufs, + PulsarVolume = result.Report.PulsarVolume, + FinalLapStyle = finalLapStyle, + DurationSeconds = result.Report.Duration.TotalSeconds, + Loop = result.Report.Loop, + LoopStartSeconds = result.Report.LoopStartSample / (double)options.SampleRate, + LoopEndSeconds = result.Report.Loop == LoopKind.None ? 0 : result.Report.SampleCount / (double)options.SampleRate, + NormalFile = "normal.brstm", + FinalLapFile = finalFile, + }; + } + catch (OperationCanceledException) + { + DeleteQuietly(storageFolder); + return Fail(t("custom_music.error.cancelled")); + } + catch (NotSupportedException exception) + { + DeleteQuietly(storageFolder); + return Fail(exception.Message); + } + catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException or OutOfMemoryException) + { + DeleteQuietly(storageFolder); + logger.LogWarning(exception, "Converting {Source} for {Slot} failed.", sourcePath, slot.Key); + return new OperationError { Message = exception.Message, Exception = exception }; + } + catch (Exception exception) + { + // Third-party decoders throw a variety of exceptions on damaged files. + DeleteQuietly(storageFolder); + logger.LogError(exception, "Unexpected error converting {Source}.", sourcePath); + return new OperationError { Message = t("custom_music.error.unreadable"), Exception = exception }; + } + } + + public async Task SaveAsync( + IReadOnlyCollection entries, + IReadOnlyCollection catalog, + CancellationToken cancellationToken = default + ) + { + var slots = catalog.ToDictionary(slot => slot.Key); + var install = new List(); + foreach (var entry in entries) + { + // Entries for tracks that are no longer in Retro Rewind stay remembered but are not installed. + if (!slots.TryGetValue(entry.Key, out var slot)) + continue; + + var folder = library.GetStorageFolder(entry.StorageId); + var normal = fileSystem.Path.Combine(folder, entry.NormalFile); + var final = entry.FinalLapFile == null ? null : fileSystem.Path.Combine(folder, entry.FinalLapFile); + install.Add(new InstalledMusic(slot, normal, final)); + } + + var applyResult = await installer.ApplyAsync(install, cancellationToken); + if (applyResult.IsFailure) + return applyResult; + + lock (_libraryLock) + { + var current = library.Load(); + var saveResult = library.Save(current with { Entries = entries.ToList() }); + if (saveResult.IsFailure) + return saveResult; + + // Unused files are not deleted here: a song converted while saving is not in `entries` yet. + // The page calls DeleteUnsavedFiles once nothing is pending. + } + + return Ok(); + } + + public void DeleteUnsavedFiles() + { + lock (_libraryLock) + library.DeleteUnused(library.Load().Entries); + } + + public string? GetCoverPath(CustomMusicEntry entry) + { + if (entry.CoverFile == null) + return null; + var path = fileSystem.Path.Combine(library.GetStorageFolder(entry.StorageId), entry.CoverFile); + return fileSystem.File.Exists(path) ? path : null; + } + + public CustomMusicModState GetModState() => installer.GetState(); + + public Task EnableModAsync() => installer.EnableAsync(); + + public IReadOnlyList FindConflicts() => installer.FindConflicts(); + + public IReadOnlyDictionary FindPackMusic() => installer.FindPackMusic(); + + public Task>> ExtractPackAsync(string zipPath, CancellationToken cancellationToken = default) => + Task.Run(() => library.ExtractPack(zipPath, cancellationToken), cancellationToken); + + private void DeleteQuietly(string? folder) + { + if (folder == null) + return; + try + { + if (fileSystem.Directory.Exists(folder)) + fileSystem.Directory.Delete(folder, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogDebug(exception, "Could not clean up {Folder}.", folder); + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Library/CustomMusicInstaller.cs b/WheelWizard/Features/CustomMusic/Library/CustomMusicInstaller.cs new file mode 100644 index 00000000..1164f71f --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Library/CustomMusicInstaller.cs @@ -0,0 +1,217 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.Models.Mods; +using WheelWizard.Mods; + +namespace WheelWizard.CustomMusic.Library; + +/// A converted song and the slot it replaces. +public sealed record InstalledMusic(MusicSlot Slot, string NormalPath, string? FinalLapPath); + +public enum CustomMusicModState +{ + /// No custom music has been saved yet. + NotCreated, + Enabled, + + /// The player switched the "Custom Music" entry off on the Patches page. + Disabled, +} + +/// A file of ours that another enabled patch with higher priority also provides (theirs wins). +public sealed record MusicConflict(string FileName, string ModTitle); + +/// A music file another enabled patch (a music pack) puts in the game. +public sealed record PackMusic(string ModTitle, string Path); + +public interface ICustomMusicInstaller +{ + CustomMusicModState GetState(); + + /// + /// Writes every converted song into the "Custom Music" patch (creating and enabling it when needed) and removes + /// files of songs that were reset. Wheel Wizard copies enabled patches into Retro Rewind's Patches folder at launch. + /// + Task ApplyAsync(IReadOnlyCollection music, CancellationToken cancellationToken = default); + + Task EnableAsync(); + + /// Other enabled patches that would override some of our music files. + IReadOnlyList FindConflicts(); + + /// + /// Music files that other enabled patches (music packs) put in the game, by file name (case-insensitive), + /// resolved the way they are copied at launch: when two packs have the same file, the higher priority wins. + /// + IReadOnlyDictionary FindPackMusic(); +} + +public sealed class CustomMusicInstaller(IModManager mods, IModPaths modPaths, IFileSystem fileSystem, ILogger logger) + : ICustomMusicInstaller +{ + public const string ModTitle = "Custom Music"; + private const string ModAuthor = "Wheel Wizard"; + + public CustomMusicModState GetState() + { + var mod = FindMod(); + return mod == null ? CustomMusicModState.NotCreated + : mod.IsEnabled ? CustomMusicModState.Enabled + : CustomMusicModState.Disabled; + } + + public async Task ApplyAsync(IReadOnlyCollection music, CancellationToken cancellationToken = default) + { + var mod = await FindOrLoadModAsync(); + + if (music.Count == 0) + { + // Nothing customised any more: remove the patch entirely instead of leaving an empty one behind. + return mod == null ? Ok() : await mods.DeleteModAsync(mod); + } + + if (mod == null) + { + mod = new Mod + { + Title = ModTitle, + Author = ModAuthor, + ModID = -1, + IsEnabled = true, + // Lowest number = highest priority: per-track choices made here should beat music packs. + Priority = mods.Mods.Count == 0 ? 0 : mods.GetLowestActivePriority() - 1, + }; + var addResult = await mods.AddModAsync(mod); + if (addResult.IsFailure) + return addResult; + } + else if (!mod.IsEnabled) + { + mod.IsEnabled = true; + var saveResult = await mods.SaveModsAsync(); + if (saveResult.IsFailure) + return saveResult; + } + + var folder = modPaths.GetModDirectoryPath(mod.Title); + return await Task.Run(() => WriteFiles(folder, mod.Title, music, cancellationToken), cancellationToken); + } + + public async Task EnableAsync() + { + var mod = await FindOrLoadModAsync(); + if (mod == null || mod.IsEnabled) + return Ok(); + mod.IsEnabled = true; + return await mods.SaveModsAsync(); + } + + public IReadOnlyList FindConflicts() + { + var mod = FindMod(); + if (mod == null || !mod.IsEnabled) + return []; + + var ourFiles = ListBrstmFiles(modPaths.GetModDirectoryPath(mod.Title)).Select(fileSystem.Path.GetFileName).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (ourFiles.Count == 0) + return []; + + var conflicts = new List(); + // Lower priority numbers are copied last and win (see ModsLaunchService). + foreach (var other in mods.Mods.Where(m => m.IsEnabled && !ReferenceEquals(m, mod) && m.Priority < mod.Priority)) + { + foreach (var file in ListBrstmFiles(modPaths.GetModDirectoryPath(other.Title))) + { + var name = fileSystem.Path.GetFileName(file); + if (ourFiles.Contains(name)) + conflicts.Add(new MusicConflict(name, other.Title)); + } + } + + return conflicts; + } + + public IReadOnlyDictionary FindPackMusic() + { + var ours = FindMod(); + var music = new Dictionary(StringComparer.OrdinalIgnoreCase); + // The list runs from highest to lowest priority; going backwards lets higher priorities overwrite. + foreach (var mod in mods.Mods.Reverse().Where(m => m.IsEnabled && !ReferenceEquals(m, ours))) + { + foreach (var file in ListBrstmFiles(modPaths.GetModDirectoryPath(mod.Title))) + music[fileSystem.Path.GetFileName(file)] = new PackMusic(mod.Title, file); + } + + return music; + } + + private OperationResult WriteFiles(string folder, string title, IReadOnlyCollection music, CancellationToken cancellationToken) + { + try + { + fileSystem.Directory.CreateDirectory(folder); + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in music) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var name in item.Slot.FileNames) + Copy(item.NormalPath, name); + foreach (var name in item.Slot.FinalLapFileNames) + Copy(item.FinalLapPath ?? item.NormalPath, name); + } + + // Remove music for slots that were reset (anything we did not just write). + foreach (var file in fileSystem.Directory.EnumerateFiles(folder)) + { + var name = fileSystem.Path.GetFileName(file); + if (name.Equals($"{title}.ini", StringComparison.OrdinalIgnoreCase) || written.Contains(name)) + continue; + fileSystem.File.Delete(file); + } + + return Ok(); + + void Copy(string source, string name) + { + // The Patches system matches by file name only, so the music must sit directly in the patch folder. + if (name.IndexOfAny(fileSystem.Path.GetInvalidFileNameChars()) >= 0) + return; + fileSystem.File.Copy(source, fileSystem.Path.Combine(folder, name), overwrite: true); + written.Add(name); + } + } + catch (OperationCanceledException) + { + return Fail("Saving custom music was cancelled."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogError(exception, "Writing custom music files failed."); + return new OperationError { Message = $"Could not write the custom music files: {exception.Message}", Exception = exception }; + } + } + + private IEnumerable ListBrstmFiles(string folder) => + fileSystem.Directory.Exists(folder) + ? fileSystem.Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(file => file.EndsWith(".brstm", StringComparison.OrdinalIgnoreCase)) + : []; + + private Mod? FindMod() => + mods.Mods.FirstOrDefault(mod => mod.Title.Equals(ModTitle, StringComparison.OrdinalIgnoreCase)) + ?? mods.Mods.FirstOrDefault(mod => mod.Author == ModAuthor && mod.ModID == -1 && mod.Title.Contains("Music", StringComparison.OrdinalIgnoreCase)); + + private async Task FindOrLoadModAsync() + { + var mod = FindMod(); + if (mod != null) + return mod; + + // The Patches page loads the list lazily; make sure we are not looking at an empty, unloaded list. + var reload = await mods.ReloadAsync(); + if (reload.IsFailure) + logger.LogWarning("Could not reload patches while saving custom music: {Message}", reload.Error.Message); + return FindMod(); + } +} diff --git a/WheelWizard/Features/CustomMusic/Library/CustomMusicLibrary.cs b/WheelWizard/Features/CustomMusic/Library/CustomMusicLibrary.cs new file mode 100644 index 00000000..b4fb6908 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Library/CustomMusicLibrary.cs @@ -0,0 +1,292 @@ +using System.IO.Abstractions; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using WheelWizard.ApplicationData; +using WheelWizard.CustomMusic.Audio; + +namespace WheelWizard.CustomMusic.Library; + +public enum MusicSortMode +{ + /// Cup order, the way tracks appear in game. + Default, + Alphabetical, + + /// Customised music first. + CustomFirst, + + /// Grouped by where the music plays: menus and lobby, races, battle arenas, then jingles. + ByType, +} + +public enum MusicReferenceSource +{ + RetroRewind, + GameDisc, + Estimate, +} + +/// A song the player assigned to a music slot, and the converted BRSTM files made from it. +public sealed record CustomMusicEntry +{ + public required string Key { get; init; } + + /// Folder (inside the library) holding the converted files and cover art. + public required string StorageId { get; init; } + + public required string SourcePath { get; init; } + public string? Title { get; init; } + public string? Artist { get; init; } + public string? Album { get; init; } + + /// Cover art file name inside the storage folder, if the song had any. + public string? CoverFile { get; init; } + + public DateTime AddedUtc { get; init; } = DateTime.UtcNow; + + public double SourceLufs { get; init; } + public double TargetLufs { get; init; } + public double VolumeOffsetDb { get; init; } + + /// Loudness of the original music file (null when it was not recorded). + public double? ReferenceLufs { get; init; } + + /// Volume byte of the original music file (0 = none). + public byte ReferenceVolume { get; init; } + + /// Loudness of the converted file (null when it was not recorded). + public double? OutputLufs { get; init; } + public MusicReferenceSource ReferenceSource { get; init; } + + /// Why the original music could not be measured (only set when is an estimate). + public string? ReferenceNote { get; init; } + + /// The volume byte written to the BRSTM header (0 = none). It starts at the original's byte and can be higher. + public byte PulsarVolume { get; init; } + + public FinalLapStyle FinalLapStyle { get; init; } + public double DurationSeconds { get; init; } + public LoopKind Loop { get; init; } + + /// Where the loop starts and ends in the converted song, in seconds (0 when it does not loop). + public double LoopStartSeconds { get; init; } + + public double LoopEndSeconds { get; init; } + + public string NormalFile { get; init; } = "normal.brstm"; + public string? FinalLapFile { get; init; } + + [JsonIgnore] + public string DisplayTitle => !string.IsNullOrWhiteSpace(Title) ? Title! : PathText.FileNameWithoutExtension(SourcePath); + + /// + /// How much louder (positive) or quieter the song plays in game than the original, in dB, counting the volume + /// bytes of both. Null when either loudness is unknown. + /// + [JsonIgnore] + public double? LoudnessVersusOriginalDb => + OutputLufs is { } output && ReferenceLufs is { } reference && double.IsFinite(output) && double.IsFinite(reference) + ? output + MusicLoudness.VolumeByteDb(PulsarVolume) - (reference + MusicLoudness.VolumeByteDb(ReferenceVolume)) + : null; +} + +public sealed record CustomMusicLibraryData +{ + public int Version { get; init; } = 1; + public MusicSortMode SortMode { get; init; } = MusicSortMode.Default; + public FinalLapStyle FinalLapStyle { get; init; } = FinalLapStyle.Faster; + + /// Volume adjustment (dB) applied to songs added from now on. + public double VolumeOffsetDb { get; init; } + + public List Entries { get; init; } = []; +} + +public interface ICustomMusicLibrary +{ + string RootFolderPath { get; } + + CustomMusicLibraryData Load(); + + OperationResult Save(CustomMusicLibraryData data); + + /// A fresh, empty folder for a new conversion. + (string StorageId, string FolderPath) CreateStorage(); + + string GetStorageFolder(string storageId); + + /// + /// Removes converted files and extracted music packs that none of refers to. + /// + void DeleteUnused(IEnumerable entriesInUse); + + /// Extracts the audio files of a music pack (.zip) into the library and returns their paths. + OperationResult> ExtractPack(string zipPath, CancellationToken cancellationToken = default); +} + +public sealed class CustomMusicLibrary(IFileSystem fileSystem, IApplicationDataLocation applicationData, ILogger logger) + : ICustomMusicLibrary +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public string RootFolderPath => fileSystem.Path.Combine(applicationData.DirectoryPath, "CustomMusic"); + private string LibraryFilePath => fileSystem.Path.Combine(RootFolderPath, "library.json"); + private string StorageRootPath => fileSystem.Path.Combine(RootFolderPath, "Converted"); + private string PacksRootPath => fileSystem.Path.Combine(RootFolderPath, "Packs"); + + /// Largest music pack accepted, so a wrong archive cannot fill the disk. + private const long MaxPackBytes = 4L * 1024 * 1024 * 1024; + + public CustomMusicLibraryData Load() + { + try + { + if (!fileSystem.File.Exists(LibraryFilePath)) + return new(); + + var data = JsonSerializer.Deserialize(fileSystem.File.ReadAllText(LibraryFilePath), JsonOptions) ?? new(); + // Drop entries that were edited into something unsafe, or whose converted files went missing. + var valid = data.Entries.Where(entry => + IsValidStorageId(entry.StorageId) + && IsPlainFileName(entry.NormalFile) + && (entry.FinalLapFile == null || IsPlainFileName(entry.FinalLapFile)) + && (entry.CoverFile == null || IsPlainFileName(entry.CoverFile)) + && fileSystem.File.Exists(fileSystem.Path.Combine(GetStorageFolder(entry.StorageId), entry.NormalFile)) + ); + return data with { Entries = valid.ToList() }; + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) + { + logger.LogError(exception, "Could not read the custom music library; starting with an empty one."); + return new(); + } + } + + public OperationResult Save(CustomMusicLibraryData data) + { + try + { + fileSystem.Directory.CreateDirectory(RootFolderPath); + var temporary = LibraryFilePath + ".tmp"; + fileSystem.File.WriteAllText(temporary, JsonSerializer.Serialize(data, JsonOptions)); + fileSystem.File.Move(temporary, LibraryFilePath, overwrite: true); + return Ok(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return new OperationError { Message = $"Could not save the custom music library: {exception.Message}", Exception = exception }; + } + } + + public (string StorageId, string FolderPath) CreateStorage() + { + var id = Guid.NewGuid().ToString("N"); + var folder = GetStorageFolder(id); + fileSystem.Directory.CreateDirectory(folder); + return (id, folder); + } + + public string GetStorageFolder(string storageId) + { + // Storage ids are our own GUIDs; refuse anything that could escape the library folder. + if (!IsValidStorageId(storageId)) + throw new ArgumentException("Invalid storage id.", nameof(storageId)); + return fileSystem.Path.Combine(StorageRootPath, storageId); + } + + private static bool IsValidStorageId(string? storageId) => !string.IsNullOrEmpty(storageId) && storageId.All(char.IsAsciiLetterOrDigit); + + private static bool IsPlainFileName(string? name) => name != null && PathText.IsValidFileName(name); + + public void DeleteUnused(IEnumerable entriesInUse) + { + var entries = entriesInUse.ToList(); + var storage = entries.Select(entry => entry.StorageId).ToHashSet(StringComparer.OrdinalIgnoreCase); + DeleteFoldersExcept(StorageRootPath, folder => storage.Contains(fileSystem.Path.GetFileName(folder))); + + // A pack folder stays while any song in use was taken from it. + var sources = entries.Select(entry => fileSystem.Path.GetFullPath(entry.SourcePath)).ToList(); + DeleteFoldersExcept( + PacksRootPath, + folder => + { + var prefix = fileSystem.Path.GetFullPath(folder) + fileSystem.Path.DirectorySeparatorChar; + return sources.Any(source => source.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + ); + } + + public OperationResult> ExtractPack(string zipPath, CancellationToken cancellationToken = default) + { + var folder = fileSystem.Path.Combine(PacksRootPath, $"{PathText.FileNameWithoutExtension(zipPath)}-{Guid.NewGuid():N}"[..^24]); + try + { + using var stream = fileSystem.File.OpenRead(zipPath); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var extracted = new List(); + long total = 0; + fileSystem.Directory.CreateDirectory(folder); + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + // Packs mirror the game's folders (sound/strm/...); only the file name matters for matching, and + // flattening it also keeps entries like "../x" from leaving the folder. + var name = PathText.FileName(entry.FullName); + if (!AudioFileDecoder.IsSupportedExtension(name) || !PathText.IsValidFileName(name) || entry.Length > AudioFileDecoder.MaxFileSize) + continue; + total += entry.Length; + if (total > MaxPackBytes) + break; + + var target = fileSystem.Path.Combine(folder, name); + if (fileSystem.File.Exists(target)) + continue; // the same song in two folders of the pack + using (var input = entry.Open()) + using (var output = fileSystem.File.Create(target)) + input.CopyTo(output); + extracted.Add(target); + } + + if (extracted.Count == 0) + fileSystem.Directory.Delete(folder, recursive: true); + return extracted; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + DeleteQuietly(folder); + logger.LogWarning(exception, "Could not extract the music pack {Pack}.", zipPath); + return new OperationError { Message = t("custom_music.error.pack", new { message = exception.Message }), Exception = exception }; + } + } + + private void DeleteFoldersExcept(string root, Func keep) + { + if (!fileSystem.Directory.Exists(root)) + return; + foreach (var folder in fileSystem.Directory.EnumerateDirectories(root)) + { + if (!keep(folder)) + DeleteQuietly(folder); + } + } + + private void DeleteQuietly(string folder) + { + try + { + if (fileSystem.Directory.Exists(folder)) + fileSystem.Directory.Delete(folder, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogDebug(exception, "Could not remove unused custom music files in {Folder}.", folder); + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Library/GameDiscMusicReader.cs b/WheelWizard/Features/CustomMusic/Library/GameDiscMusicReader.cs new file mode 100644 index 00000000..039a405a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Library/GameDiscMusicReader.cs @@ -0,0 +1,62 @@ +using System.IO.Abstractions; +using RVZSharp.Blobs; +using RVZSharp.Files; +using RVZSharp.Interfaces; +using RVZSharp.Wii; + +namespace WheelWizard.CustomMusic.Library; + +/// +/// Reads the original BRSTM files from the player's own Mario Kart Wii disc image (ISO, WBFS, RVZ, ...), +/// so replacements can be matched to the loudness of the exact music they replace. +/// +public interface IGameDiscMusicReader +{ + /// The bytes of /sound/strm/, or an error saying why the disc could not provide it. + OperationResult ReadStreamFile(string gamePath, string fileName); +} + +public sealed class GameDiscMusicReader(IFileSystem fileSystem) : IGameDiscMusicReader +{ + private const uint DataPartitionType = 0; + + public OperationResult ReadStreamFile(string gamePath, string fileName) + { + if (string.IsNullOrWhiteSpace(gamePath)) + return new OperationError { Message = t("custom_music.disc.not_set") }; + if (!fileSystem.File.Exists(gamePath)) + return new OperationError { Message = t("custom_music.disc.missing") }; + + Stream? stream = null; + IBlobReader? disc = null; + try + { + stream = fileSystem.File.OpenRead(gamePath); + disc = Blob.Open(stream, gamePath, leaveOpen: false); + stream = null; // the blob reader owns it now + + var partitions = WiiVolume.GetPartitions(disc); + using var files = partitions.Count == 0 + ? DiscFileSystem.Open(disc) + : DiscFileSystem.Open(disc, partitions.FirstOrDefault(p => p.Type == DataPartitionType, partitions[0])); + + var entry = files.Find($"sound/strm/{fileName}"); + if (entry == null || entry.IsDirectory || entry.Size <= 0 || entry.Size > 64 * 1024 * 1024) + return new OperationError { Message = t("custom_music.disc.no_file", new { file = fileName }) }; + + using var output = new MemoryStream((int)entry.Size); + files.CopyFileTo(entry, output); + return output.ToArray(); + } + catch (Exception exception) + { + // Unsupported, encrypted with unknown keys, or damaged images: the caller falls back to an estimate. + return new OperationError { Message = t("custom_music.disc.unreadable", new { message = exception.Message }), Exception = exception }; + } + finally + { + disc?.Dispose(); + stream?.Dispose(); + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Library/MusicReferenceService.cs b/WheelWizard/Features/CustomMusic/Library/MusicReferenceService.cs new file mode 100644 index 00000000..64eb201b --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Library/MusicReferenceService.cs @@ -0,0 +1,196 @@ +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Audio.Dsp; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.Settings; + +namespace WheelWizard.CustomMusic.Library; + +/// +/// The music being replaced: how loud the file itself is (, before any volume byte), whether it +/// loops, and the volume byte Retro Rewind applies to it (0 = none). A replacement is matched to the loudness and +/// gets the same byte, so both play at the same level. says why the original could not +/// be measured when is . +/// +public sealed record MusicReference(double Lufs, bool? Loops, MusicReferenceSource Source, byte PulsarVolume = 0, string? Problem = null); + +public interface IMusicReferenceService +{ + /// + /// Measures the music that currently plays in : Retro Rewind's own BRSTM when it + /// ships one, otherwise the original file on the player's game disc. Results are cached on disk. + /// + Task GetReferenceAsync(MusicSlot slot, CancellationToken cancellationToken = default); +} + +public sealed class MusicReferenceService( + IFileSystem fileSystem, + ICustomMusicLibrary library, + IGameDiscMusicReader discReader, + ISettingsManager settings, + ILogger logger +) : IMusicReferenceService +{ + private sealed record CachedReference(double Lufs, bool Loops, MusicReferenceSource Source, byte PulsarVolume = 0); + + private readonly SemaphoreSlim _lock = new(1, 1); + private Dictionary? _cache; + + private string CachePath => fileSystem.Path.Combine(library.RootFolderPath, "reference-loudness.json"); + + public async Task GetReferenceAsync(MusicSlot slot, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + var cache = LoadCache(); + string? problem = null; + + if (slot.RetroRewindFilePath is { } shipped && fileSystem.File.Exists(shipped)) + { + var key = Identity("rr", shipped, shipped + Tracks(slot)); + if (cache.TryGetValue(key, out var cached)) + return From(cached); + + var measured = await Task.Run(() => Measure(fileSystem.File.ReadAllBytes(shipped), slot.PlaysAllTracks), cancellationToken); + if (measured is { } result) + return Remember(cache, key, result, MusicReferenceSource.RetroRewind); + problem = t("custom_music.reference_problem.unreadable_file", new { file = fileSystem.Path.GetFileName(shipped) }); + } + + var gamePath = settings.Get(settings.GAME_LOCATION); + if (slot.GameFileName is { } gameFile) + { + if (string.IsNullOrWhiteSpace(gamePath) || !fileSystem.File.Exists(gamePath)) + { + problem ??= t("custom_music.disc.not_set"); + } + else + { + var key = Identity("disc", gamePath, gameFile + Tracks(slot)); + if (cache.TryGetValue(key, out var cached)) + return From(cached); + + var bytes = await Task.Run(() => discReader.ReadStreamFile(gamePath, gameFile), cancellationToken); + if (bytes.IsFailure) + { + logger.LogWarning("Could not read {File} from the game disc: {Message}", gameFile, bytes.Error.Message); + problem ??= bytes.Error.Message; + } + else if (await Task.Run(() => Measure(bytes.Value, slot.PlaysAllTracks), cancellationToken) is { } result) + { + return Remember(cache, key, result, MusicReferenceSource.GameDisc); + } + else + { + problem ??= t("custom_music.reference_problem.unreadable_file", new { file = gameFile }); + } + } + } + + return new(EstimateFrom(cache), null, MusicReferenceSource.Estimate, 0, problem); + } + finally + { + _lock.Release(); + } + } + + /// + /// For music we cannot measure (menu music is sequenced, not streamed), use the typical loudness of the + /// music we did measure, so menus end up as loud as the courses. + /// + private static double EstimateFrom(Dictionary cache) + { + var measured = cache + .Where(pair => pair.Value.Source != MusicReferenceSource.Estimate) + .Select(pair => pair.Value.Lufs) + .Order() + .ToList(); + return measured.Count >= 3 ? measured[measured.Count / 2] : MusicLoudness.DefaultTargetLufs; + } + + /// Prefix of every cache key. Changing how music is measured means changing it, so stale results are ignored. + private const string CacheVersion = "v1|"; + + /// The same file measures differently depending on which of its tracks are heard, so that is part of the key. + private static string Tracks(MusicSlot slot) => slot.PlaysAllTracks ? "|all-tracks" : "|first-track"; + + private static MusicReference From(CachedReference cached) => new(cached.Lufs, cached.Loops, cached.Source, cached.PulsarVolume); + + private (double Lufs, bool Loops, byte PulsarVolume)? Measure(byte[] brstm, bool allTracks) + { + try + { + var info = BrstmReader.ReadInfo(brstm); + // Measure what is heard: a file can carry extra tracks the game keeps silent (or plays on top). + var audio = BrstmReader.DecodeAsPlayed(brstm, allTracks); + var loudness = LoudnessMeter.Measure(audio); + if (loudness.IsSilent) + return null; + // The file's own loudness. Pulsar multiplies the sound's volume by header byte 0x3F / 127 while it + // plays; instead of folding that into the number, replacements get the same byte (see MusicReference). + return (loudness.IntegratedLufs, info.Looping, info.PulsarVolume); + } + catch (Exception exception) when (exception is InvalidDataException or IndexOutOfRangeException or ArgumentException) + { + logger.LogDebug(exception, "Could not measure a reference BRSTM."); + return null; + } + } + + private MusicReference Remember( + Dictionary cache, + string key, + (double Lufs, bool Loops, byte PulsarVolume) measured, + MusicReferenceSource source + ) + { + cache[key] = new CachedReference(measured.Lufs, measured.Loops, source, measured.PulsarVolume); + try + { + fileSystem.Directory.CreateDirectory(library.RootFolderPath); + fileSystem.File.WriteAllText(CachePath, JsonSerializer.Serialize(cache)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogDebug(exception, "Could not store the reference loudness cache."); + } + + return new(measured.Lufs, measured.Loops, source, measured.PulsarVolume); + } + + private Dictionary LoadCache() + { + if (_cache != null) + return _cache; + + try + { + if (fileSystem.File.Exists(CachePath)) + { + // Results measured another way (another CacheVersion) are dropped and measured again. + _cache = JsonSerializer + .Deserialize>(fileSystem.File.ReadAllText(CachePath)) + ?.Where(pair => pair.Key.StartsWith(CacheVersion, StringComparison.Ordinal)) + .ToDictionary(); + } + } + catch (Exception exception) when (exception is JsonException or IOException) + { + logger.LogDebug(exception, "Ignoring an unreadable reference loudness cache."); + } + + return _cache ??= new(); + } + + /// A cache key that changes whenever the source file changes (new Retro Rewind version, other game dump). + private string Identity(string kind, string containerPath, string item) + { + var info = fileSystem.FileInfo.New(containerPath); + return $"{CacheVersion}{kind}|{containerPath}|{info.Length}|{info.LastWriteTimeUtc.Ticks}|{item}".ToLowerInvariant(); + } +} diff --git a/WheelWizard/Features/CustomMusic/PathText.cs b/WheelWizard/Features/CustomMusic/PathText.cs new file mode 100644 index 00000000..1ae0816a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/PathText.cs @@ -0,0 +1,41 @@ +namespace WheelWizard.CustomMusic; + +/// +/// File-name text helpers for display and matching. They work on the text alone, accept both '/' and '\' on every +/// platform, and never touch the disk. +/// +public static class PathText +{ + private static readonly char[] Separators = ['/', '\\']; + + /// Characters Windows does not allow in file names (the strictest platform we write for). + private static readonly char[] InvalidFileNameChars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + + /// "C:\Music\Song.mp3" -> "Song.mp3". + public static string FileName(string path) => path[(path.LastIndexOfAny(Separators) + 1)..]; + + /// "C:\Music\Song.mp3" -> "Song". + public static string FileNameWithoutExtension(string path) + { + var name = FileName(path); + var dot = name.LastIndexOf('.'); + return dot > 0 ? name[..dot] : name; + } + + /// "C:\Music\Song.MP3" -> ".mp3" (lower case), or "" when there is none. + public static string Extension(string path) + { + var name = FileName(path); + var dot = name.LastIndexOf('.'); + return dot >= 0 && dot < name.Length - 1 ? name[dot..].ToLowerInvariant() : string.Empty; + } + + /// Whether can be used as a file name on Windows, macOS and Linux. + public static bool IsValidFileName(string name) => + name.Length > 0 + && name.IndexOfAny(InvalidFileNameChars) < 0 + && name.All(c => c >= ' ') + && name is not ("." or "..") + && !name.EndsWith(' ') + && !name.EndsWith('.'); +} diff --git a/WheelWizard/Features/CustomMusic/Playback/AudioOutput.cs b/WheelWizard/Features/CustomMusic/Playback/AudioOutput.cs new file mode 100644 index 00000000..d37f5dab --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Playback/AudioOutput.cs @@ -0,0 +1,210 @@ +using System.Diagnostics; +using System.IO.Abstractions; +using System.Runtime.InteropServices; + +namespace WheelWizard.CustomMusic.Playback; + +/// Plays one WAV file at a time through the operating system. No third-party audio library is needed. +public interface IAudioOutput : IDisposable +{ + /// False when this computer has no way to play audio (for example a Linux system without any player installed). + bool IsAvailable { get; } + + /// Starts playing immediately, replacing whatever was playing. + void Start(byte[] wav); + + void Stop(); +} + +public static class AudioOutput +{ + public static IAudioOutput Create(IFileSystem fileSystem) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new WindowsAudioOutput() : new ProcessAudioOutput(fileSystem); + + /// Finds an executable on PATH (and the usual extra folders on macOS). + internal static string? FindExecutable(IFileSystem fileSystem, string name) + { + var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? name + ".exe" : name; + var candidates = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(fileSystem.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => fileSystem.Path.Combine(directory.Trim('"'), fileName)) + .ToList(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + candidates.AddRange(["/usr/bin/" + fileName, "/opt/homebrew/bin/" + fileName, "/usr/local/bin/" + fileName]); + return candidates.FirstOrDefault(fileSystem.File.Exists); + } +} + +/// Windows: the multimedia PlaySound API plays a WAV straight from memory. +internal sealed class WindowsAudioOutput : IAudioOutput +{ + private const uint SndAsync = 0x0001; + private const uint SndNoDefault = 0x0002; + private const uint SndMemory = 0x0004; + + [DllImport("winmm.dll", EntryPoint = "PlaySoundW", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PlaySound(IntPtr sound, IntPtr module, uint flags); + + private readonly object _gate = new(); + + // Windows keeps reading the buffer while the sound plays, so it stays allocated until playback stops. + private IntPtr _buffer; + + public bool IsAvailable => true; + + public void Start(byte[] wav) + { + lock (_gate) + { + var next = Marshal.AllocHGlobal(wav.Length); + try + { + Marshal.Copy(wav, 0, next, wav.Length); + // Starting a new sound stops the previous one first, after which its buffer can go. + if (!PlaySound(next, IntPtr.Zero, SndMemory | SndAsync | SndNoDefault)) + throw new IOException("Windows could not play the audio."); + } + catch + { + Marshal.FreeHGlobal(next); + throw; + } + + Release(); + _buffer = next; + } + } + + public void Stop() + { + lock (_gate) + { + PlaySound(IntPtr.Zero, IntPtr.Zero, 0); + Release(); + } + } + + private void Release() + { + if (_buffer == IntPtr.Zero) + return; + Marshal.FreeHGlobal(_buffer); + _buffer = IntPtr.Zero; + } + + public void Dispose() => Stop(); +} + +/// macOS and Linux: hands a temporary WAV file to the system's command line player. +internal sealed class ProcessAudioOutput(IFileSystem fileSystem) : IAudioOutput +{ + private static readonly (string Name, string[] Arguments)[] Players = + [ + ("afplay", []), + ("paplay", []), + ("pw-play", []), + ("aplay", ["-q"]), + ("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"]), + ("play", ["-q"]), + ("mpv", ["--no-video", "--really-quiet"]), + ]; + + private readonly object _gate = new(); + private readonly Lazy<(string Path, string[] Arguments)?> _player = new(() => FindPlayer(fileSystem)); + private Process? _process; + private string? _file; + + public bool IsAvailable => _player.Value != null; + + public void Start(byte[] wav) + { + var player = _player.Value ?? throw new NotSupportedException("No audio player was found. Install PulseAudio, PipeWire, ALSA or FFmpeg to preview music."); + lock (_gate) + { + Stop(); + var file = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), $"wheelwizard-preview-{Guid.NewGuid():N}.wav"); + fileSystem.File.WriteAllBytes(file, wav); + + var startInfo = new ProcessStartInfo + { + FileName = player.Path, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in player.Arguments) + startInfo.ArgumentList.Add(argument); + startInfo.ArgumentList.Add(file); + + try + { + var process = Process.Start(startInfo) ?? throw new IOException("The audio player could not be started."); + process.EnableRaisingEvents = true; + process.Exited += (_, _) => DeleteQuietly(file); + // Nobody reads the player's output; draining it keeps it from blocking. + process.OutputDataReceived += (_, _) => { }; + process.ErrorDataReceived += (_, _) => { }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + _process = process; + _file = file; + } + catch + { + DeleteQuietly(file); + throw; + } + } + } + + public void Stop() + { + lock (_gate) + { + var process = _process; + var file = _file; + _process = null; + _file = null; + try + { + if (process is { HasExited: false }) + process.Kill(entireProcessTree: true); + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Already finished. + } + + process?.Dispose(); + if (file != null) + DeleteQuietly(file); + } + } + + private static (string Path, string[] Arguments)? FindPlayer(IFileSystem fileSystem) + { + foreach (var (name, arguments) in Players) + { + if (AudioOutput.FindExecutable(fileSystem, name) is { } path) + return (path, arguments); + } + + return null; + } + + private void DeleteQuietly(string file) + { + try + { + fileSystem.File.Delete(file); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A temp file left behind is harmless. + } + } + + public void Dispose() => Stop(); +} diff --git a/WheelWizard/Features/CustomMusic/Playback/MusicPreviewPlayer.cs b/WheelWizard/Features/CustomMusic/Playback/MusicPreviewPlayer.cs new file mode 100644 index 00000000..de1567e8 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Playback/MusicPreviewPlayer.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; + +namespace WheelWizard.CustomMusic.Playback; + +public enum PreviewState +{ + Stopped, + Playing, + Paused, +} + +/// Plays a with pause and seek on top of a simple "play this file" output. +public interface IMusicPreviewPlayer : IDisposable +{ + bool IsAvailable { get; } + PreviewState State { get; } + TimeSpan Position { get; } + TimeSpan Duration { get; } + + /// Raised when the state changes and about every 100 ms while playing. May be raised from any thread. + event EventHandler? Changed; + + void Play(PreviewClip clip); + void Pause(); + void Resume(); + void Stop(); + void Seek(TimeSpan position); +} + +public sealed class MusicPreviewPlayer(IAudioOutput output) : IMusicPreviewPlayer +{ + private readonly object _gate = new(); + private readonly Stopwatch _clock = new(); + private Timer? _timer; + private PreviewClip? _clip; + private TimeSpan _offset; + private PreviewState _state; + + public bool IsAvailable => output.IsAvailable; + + public PreviewState State + { + get + { + lock (_gate) + return _state; + } + } + + public TimeSpan Duration + { + get + { + lock (_gate) + return _clip?.Duration ?? TimeSpan.Zero; + } + } + + public TimeSpan Position + { + get + { + lock (_gate) + return CurrentPosition(); + } + } + + public event EventHandler? Changed; + + public void Play(PreviewClip clip) + { + lock (_gate) + { + _clip = clip; + StartAt(TimeSpan.Zero); + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Pause() + { + lock (_gate) + { + if (_state != PreviewState.Playing) + return; + _offset = CurrentPosition(); + _clock.Reset(); + output.Stop(); + StopTimer(); + _state = PreviewState.Paused; + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Resume() + { + lock (_gate) + { + if (_state != PreviewState.Paused || _clip == null) + return; + StartAt(_offset); + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Stop() + { + lock (_gate) + { + if (_state == PreviewState.Stopped) + return; + StopLocked(); + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Seek(TimeSpan position) + { + lock (_gate) + { + if (_clip == null || _state == PreviewState.Stopped) + return; + var target = TimeSpan.FromSeconds(Math.Clamp(position.TotalSeconds, 0, Math.Max(0, _clip.Duration.TotalSeconds - 0.25))); + if (_state == PreviewState.Playing) + StartAt(target); + else + _offset = target; + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + private TimeSpan CurrentPosition() + { + var position = _state == PreviewState.Playing ? _offset + _clock.Elapsed : _offset; + var duration = _clip?.Duration ?? TimeSpan.Zero; + return position > duration ? duration : position; + } + + private void StartAt(TimeSpan start) + { + try + { + output.Start(_clip!.ToWav(start)); + } + catch + { + StopLocked(); + throw; + } + + _offset = start; + _clock.Restart(); + _state = PreviewState.Playing; + _timer ??= new Timer(_ => Tick(), null, Timeout.Infinite, Timeout.Infinite); + _timer.Change(100, 100); + } + + private void StopLocked() + { + output.Stop(); + StopTimer(); + _clock.Reset(); + _offset = TimeSpan.Zero; + _state = PreviewState.Stopped; + } + + private void StopTimer() => _timer?.Change(Timeout.Infinite, Timeout.Infinite); + + private void Tick() + { + lock (_gate) + { + if (_state != PreviewState.Playing) + return; + if (CurrentPosition() >= (_clip?.Duration ?? TimeSpan.Zero)) + StopLocked(); + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + lock (_gate) + { + StopLocked(); + _timer?.Dispose(); + _timer = null; + } + + output.Dispose(); + } +} diff --git a/WheelWizard/Features/CustomMusic/Playback/MusicPreviewService.cs b/WheelWizard/Features/CustomMusic/Playback/MusicPreviewService.cs new file mode 100644 index 00000000..c6aab507 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Playback/MusicPreviewService.cs @@ -0,0 +1,133 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomMusic.Audio.Formats; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; +using WheelWizard.Settings; + +namespace WheelWizard.CustomMusic.Playback; + +/// Loads music for the preview player: the original music of a slot, or a song converted for it. +public interface IMusicPreviewService +{ + /// Whether the original music of is a file that can be played (some menu music is not). + bool HasOriginal(MusicSlot slot); + + /// The music that plays in without any custom song: Retro Rewind's file, or the file on the game disc. + Task> LoadOriginalAsync(MusicSlot slot, CancellationToken cancellationToken = default); + + /// A BRSTM file on disk, such as a music pack's version of a track, played like plays it. + Task> LoadFileAsync(string path, MusicSlot slot, CancellationToken cancellationToken = default); + + /// The converted version of a song, exactly as it will be installed. + Task> LoadCustomAsync(CustomMusicEntry entry, CancellationToken cancellationToken = default); +} + +public sealed class MusicPreviewService( + IFileSystem fileSystem, + ISettingsManager settings, + IGameDiscMusicReader discReader, + ICustomMusicLibrary library, + ILogger logger +) : IMusicPreviewService +{ + // Reading an original from the disc image takes a moment, so the last clips stay around for replaying or + // switching back and forth. A decoded three-minute song is about 23 MB, so only a couple are kept. + private const int CachedClips = 2; + + private readonly object _cacheGate = new(); + private readonly List<(string Key, PreviewClip Clip)> _cache = []; + + public bool HasOriginal(MusicSlot slot) => slot.RetroRewindFilePath != null || slot.GameFileName != null; + + public Task> LoadOriginalAsync(MusicSlot slot, CancellationToken cancellationToken = default) => + Task.Run(() => LoadOriginal(slot), cancellationToken); + + public Task> LoadCustomAsync(CustomMusicEntry entry, CancellationToken cancellationToken = default) => + Task.Run(() => LoadCustom(entry), cancellationToken); + + public Task> LoadFileAsync(string path, MusicSlot slot, CancellationToken cancellationToken = default) => + Task.Run( + () => + { + if (!fileSystem.File.Exists(path)) + return (OperationResult)new OperationError { Message = t("custom_music.preview.song_missing") }; + var info = fileSystem.FileInfo.New(path); + return Decode( + $"file|{path}|{info.Length}|{info.LastWriteTimeUtc.Ticks}|{slot.PlaysAllTracks}", + () => fileSystem.File.ReadAllBytes(path), + slot.PlaysAllTracks + ); + }, + cancellationToken + ); + + private OperationResult LoadOriginal(MusicSlot slot) + { + if (slot.RetroRewindFilePath is { } shipped && fileSystem.File.Exists(shipped)) + { + var info = fileSystem.FileInfo.New(shipped); + return Decode($"rr|{shipped}|{info.Length}|{info.LastWriteTimeUtc.Ticks}", () => fileSystem.File.ReadAllBytes(shipped), slot.PlaysAllTracks); + } + + if (slot.GameFileName is not { } gameFile) + return new OperationError { Message = t("custom_music.preview.no_original") }; + + var gamePath = settings.Get(settings.GAME_LOCATION); + if (string.IsNullOrWhiteSpace(gamePath) || !fileSystem.File.Exists(gamePath)) + return new OperationError { Message = t("custom_music.preview.needs_disc") }; + + var disc = fileSystem.FileInfo.New(gamePath); + return Decode( + $"disc|{gamePath}|{disc.Length}|{disc.LastWriteTimeUtc.Ticks}|{gameFile}", + () => + { + var bytes = discReader.ReadStreamFile(gamePath, gameFile); + return bytes.IsFailure ? throw new InvalidDataException(bytes.Error.Message) : bytes.Value; + }, + slot.PlaysAllTracks + ); + } + + private OperationResult LoadCustom(CustomMusicEntry entry) + { + var path = fileSystem.Path.Combine(library.GetStorageFolder(entry.StorageId), entry.NormalFile); + if (!fileSystem.File.Exists(path)) + return new OperationError { Message = t("custom_music.preview.song_missing") }; + // Custom songs are re-converted when changed, so the storage id identifies the file. + return Decode($"custom|{entry.StorageId}|{entry.NormalFile}", () => fileSystem.File.ReadAllBytes(path)); + } + + private OperationResult Decode(string key, Func read, bool allTracks = false) + { + lock (_cacheGate) + { + var hit = _cache.FindIndex(item => item.Key == key); + if (hit >= 0) + { + var item = _cache[hit]; + _cache.RemoveAt(hit); + _cache.Add(item); // most recently used last + return item.Clip; + } + } + + try + { + var clip = PreviewClip.FromBrstm(read(), allTracks); + lock (_cacheGate) + { + _cache.Add((key, clip)); + if (_cache.Count > CachedClips) + _cache.RemoveAt(0); + } + + return clip; + } + catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException or IndexOutOfRangeException or ArgumentException) + { + logger.LogWarning(exception, "Could not load music for the preview."); + return new OperationError { Message = t("custom_music.preview.failed", new { message = exception.Message }), Exception = exception }; + } + } +} diff --git a/WheelWizard/Features/CustomMusic/Playback/PreviewClip.cs b/WheelWizard/Features/CustomMusic/Playback/PreviewClip.cs new file mode 100644 index 00000000..59cf88c6 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Playback/PreviewClip.cs @@ -0,0 +1,82 @@ +using System.Buffers.Binary; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Audio.Dsp; +using WheelWizard.CustomMusic.Audio.Formats; + +namespace WheelWizard.CustomMusic.Playback; + +/// Music ready to be played: 16-bit stereo, interleaved. +public sealed class PreviewClip +{ + private const int HeaderSize = 44; + + public PreviewClip(short[] interleavedSamples, int sampleRate) + { + if (interleavedSamples.Length % 2 != 0) + throw new ArgumentException("Samples must be interleaved stereo.", nameof(interleavedSamples)); + Samples = interleavedSamples; + SampleRate = sampleRate; + } + + public short[] Samples { get; } + public int SampleRate { get; } + public int FrameCount => Samples.Length / 2; + public TimeSpan Duration => TimeSpan.FromSeconds(FrameCount / (double)SampleRate); + + /// + /// Decodes a BRSTM the way the game plays it, including the volume byte Retro Rewind applies, so previews of the + /// original and of a replacement are as loud relative to each other as they are in game. + /// + /// The BRSTM file. + /// Play every track at once (Retro Rewind's menu music) instead of only the first. + public static PreviewClip FromBrstm(byte[] brstm, bool allTracks = false) + { + var info = BrstmReader.ReadInfo(brstm); + return FromAudio(BrstmReader.DecodeAsPlayed(brstm, allTracks), info.PulsarGain); + } + + public static PreviewClip FromAudio(AudioBuffer audio, double gain = 1.0) + { + var stereo = AudioOps.ToStereo(audio); + var samples = new short[stereo.SampleCount * 2]; + var left = stereo.Channels[0]; + var right = stereo.Channels[1]; + var scale = (float)(gain * short.MaxValue); + for (var i = 0; i < stereo.SampleCount; i++) + { + samples[i * 2] = ToShort(left[i] * scale); + samples[i * 2 + 1] = ToShort(right[i] * scale); + } + + return new PreviewClip(samples, stereo.SampleRate); + + static short ToShort(float value) => (short)Math.Clamp(MathF.Round(value), short.MinValue, short.MaxValue); + } + + /// A WAV file with the audio from onwards. + public byte[] ToWav(TimeSpan start) + { + var firstFrame = (int)Math.Clamp(start.TotalSeconds * SampleRate, 0, Math.Max(0, FrameCount - 1)); + var frames = FrameCount - firstFrame; + var dataBytes = frames * 4; + var wav = new byte[HeaderSize + dataBytes]; + var span = wav.AsSpan(); + "RIFF"u8.CopyTo(span); + BinaryPrimitives.WriteInt32LittleEndian(span[4..], 36 + dataBytes); + "WAVEfmt "u8.CopyTo(span[8..]); + BinaryPrimitives.WriteInt32LittleEndian(span[16..], 16); + BinaryPrimitives.WriteInt16LittleEndian(span[20..], 1); // PCM + BinaryPrimitives.WriteInt16LittleEndian(span[22..], 2); + BinaryPrimitives.WriteInt32LittleEndian(span[24..], SampleRate); + BinaryPrimitives.WriteInt32LittleEndian(span[28..], SampleRate * 4); + BinaryPrimitives.WriteInt16LittleEndian(span[32..], 4); + BinaryPrimitives.WriteInt16LittleEndian(span[34..], 16); + "data"u8.CopyTo(span[36..]); + BinaryPrimitives.WriteInt32LittleEndian(span[40..], dataBytes); + + var target = span[HeaderSize..]; + for (var i = 0; i < frames * 2; i++) + BinaryPrimitives.WriteInt16LittleEndian(target[(i * 2)..], Samples[firstFrame * 2 + i]); + return wav; + } +} diff --git a/WheelWizard/Features/CustomMusic/ViewModels/BulkObservableCollection.cs b/WheelWizard/Features/CustomMusic/ViewModels/BulkObservableCollection.cs new file mode 100644 index 00000000..ef66594a --- /dev/null +++ b/WheelWizard/Features/CustomMusic/ViewModels/BulkObservableCollection.cs @@ -0,0 +1,22 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace WheelWizard.CustomMusic.ViewModels; + +/// An whose contents can be replaced with a single change notification. +public sealed class BulkObservableCollection : ObservableCollection +{ + /// Replaces everything at once and raises one . + public void ReplaceAll(IEnumerable items) + { + CheckReentrancy(); + Items.Clear(); + foreach (var item in items) + Items.Add(item); + + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } +} diff --git a/WheelWizard/Features/CustomMusic/ViewModels/CustomMusicViewModel.cs b/WheelWizard/Features/CustomMusic/ViewModels/CustomMusicViewModel.cs new file mode 100644 index 00000000..a41ceb25 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/ViewModels/CustomMusicViewModel.cs @@ -0,0 +1,973 @@ +using System.ComponentModel; +using System.IO.Abstractions; +using System.Runtime.CompilerServices; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; +using WheelWizard.CustomMusic.Playback; + +namespace WheelWizard.CustomMusic.ViewModels; + +public sealed class SortOption(MusicSortMode mode, string label) +{ + public MusicSortMode Mode { get; } = mode; + + public override string ToString() => label; +} + +public sealed class FinalLapOption(FinalLapStyle style, string label) +{ + public FinalLapStyle Style { get; } = style; + + public override string ToString() => label; +} + +public sealed class VolumeOption(double decibels, string label) +{ + public double Decibels { get; } = decibels; + + public override string ToString() => label; +} + +/// What happened when a song was made louder or quieter. +public enum VolumeChange +{ + /// Nothing to adjust (no custom song), or a newer adjustment replaced this one. + None, + Changed, + + /// The song was asked to get louder but its peaks already hold it back; it would only distort. + AtLimit, +} + +/// +/// State and workflow of the Custom Music page: the slot list, conversions, previews and saving. +/// The page calls when it is shown and when it goes away. +/// (Not on purpose: the container would keep every transient disposable alive.) +/// +public sealed class CustomMusicViewModel( + ICustomMusicService service, + IMusicPreviewService preview, + IMusicPreviewPlayer player, + IFileSystem fileSystem +) : INotifyPropertyChanged +{ + /// A conversion in progress: the song and the volume it is being converted at. + private sealed record ConversionRequest(CancellationTokenSource Cancellation, string Path, double VolumeOffsetDb); + + /// One step of the "Louder" / "Quieter" row actions. + public const double VolumeStepDb = 2; + + private const double MaxVolumeOffsetDb = 18; + private const double MinVolumeOffsetDb = -12; + + // Conversions are CPU and memory heavy; a couple at a time keeps the app responsive. + private readonly SemaphoreSlim _conversionSlots = new(2, 2); + private CancellationTokenSource _lifetime = new(); + private readonly List _items = []; + private readonly Dictionary _conversions = []; + private bool _loaded; + private MusicCatalog _catalog = MusicCatalog.Empty; + private List _orphanedEntries = []; + private string _searchText = string.Empty; + private SortOption _selectedSort = null!; + private FinalLapOption _selectedFinalLap = null!; + private VolumeOption _selectedVolume = null!; + private MusicSlotItem? _previewItem; + private bool _previewIsCustom; + private int _previewRequest; + private bool _playerHooked; + private bool _isLoading = true; + private bool _isSaving; + private CustomMusicModState _modState; + private string? _conflictText; + private bool _preferencesReady; + + public BulkObservableCollection Rows { get; } = []; + + public IReadOnlyList SortOptions { get; } = + [ + new(MusicSortMode.Default, t("custom_music.sort.default")), + new(MusicSortMode.ByType, t("custom_music.sort.by_type")), + new(MusicSortMode.CustomFirst, t("custom_music.sort.custom_first")), + new(MusicSortMode.Alphabetical, t("custom_music.sort.alphabetical")), + ]; + + public IReadOnlyList FinalLapOptions { get; } = + [ + new(FinalLapStyle.Faster, t("custom_music.final_lap.faster")), + new(FinalLapStyle.FasterKeepPitch, t("custom_music.final_lap.faster_keep_pitch")), + new(FinalLapStyle.Unchanged, t("custom_music.final_lap.unchanged")), + ]; + + public IReadOnlyList VolumeOptions { get; } = + [ + new(-3, t("custom_music.volume.quieter")), + new(0, t("custom_music.volume.same")), + new(3, t("custom_music.volume.louder")), + new(6, t("custom_music.volume.much_louder")), + new(9, t("custom_music.volume.loudest")), + ]; + + public SortOption SelectedSort + { + get => _selectedSort; + set + { + if (value == null || ReferenceEquals(_selectedSort, value)) + return; + _selectedSort = value; + Notify(); + RefreshRows(); + PersistPreferences(); + } + } + + public FinalLapOption SelectedFinalLap + { + get => _selectedFinalLap; + set + { + if (value == null || ReferenceEquals(_selectedFinalLap, value)) + return; + _selectedFinalLap = value; + Notify(); + PersistPreferences(); + } + } + + public VolumeOption SelectedVolume + { + get => _selectedVolume; + set + { + if (value == null || ReferenceEquals(_selectedVolume, value)) + return; + _selectedVolume = value; + Notify(); + PersistPreferences(); + } + } + + public string SearchText + { + get => _searchText; + set + { + if (_searchText == value) + return; + _searchText = value ?? string.Empty; + Notify(); + RefreshRows(); + } + } + + public bool IsLoading + { + get => _isLoading; + private set + { + _isLoading = value; + Notify(); + NotifyState(); + } + } + + public bool IsSaving + { + get => _isSaving; + private set + { + _isSaving = value; + Notify(); + NotifyState(); + } + } + + public int CustomCount => _items.Count(item => item.IsCustom); + public bool HasCustomMusic => CustomCount > 0; + public int PendingCount => _items.Count(item => item.IsPending); + public bool HasPendingChanges => PendingCount > 0; + public bool IsConverting => _items.Any(item => item.IsProcessing); + public bool CanSave => HasPendingChanges && !IsConverting && !IsSaving && !IsLoading; + public string PendingText => t("custom_music.pending", PendingCount); + public bool HasNoResults => !IsLoading && Rows.Count == 0; + + public bool IsRetroRewindMissing => !IsLoading && _catalog.RetroRewind == RetroRewindCatalogState.NotInstalled; + public bool IsRetroRewindUnreadable => !IsLoading && _catalog.RetroRewind == RetroRewindCatalogState.Unreadable; + public bool IsModDisabled => _modState == CustomMusicModState.Disabled; + public string? ConflictText + { + get => _conflictText; + private set + { + _conflictText = value; + Notify(); + Notify(nameof(HasConflicts)); + } + } + public bool HasConflicts => ConflictText != null; + + public string Summary => + IsLoading ? t("custom_music.loading") + : _catalog.RetroRewind == RetroRewindCatalogState.Loaded ? t("custom_music.summary", new { tracks = _items.Count(i => i.Slot.Category is MusicCategory.RetroRewindTrack or MusicCategory.RetroRewindArena), total = _items.Count }) + : t("custom_music.summary_game_only", new { total = _items.Count }); + + /// Loads the list the first time the page is shown (and again after ). + public Task ActivateAsync() + { + if (_loaded) + return Task.CompletedTask; + _loaded = true; + if (_lifetime.IsCancellationRequested) + { + _lifetime.Dispose(); + _lifetime = new CancellationTokenSource(); + } + + return LoadAsync(); + } + + /// The page went away: stop the preview and any conversions, and let go of cover images. + public void Deactivate() + { + _loaded = false; + if (_playerHooked) + player.Changed -= Player_Changed; + _playerHooked = false; + _previewRequest++; + player.Stop(); + if (_previewItem != null) + _previewItem.PreviewState = ItemPreviewState.None; + _previewItem = null; + + _lifetime.Cancel(); + foreach (var running in _conversions.Values) + running.Cancellation.Cancel(); + _conversions.Clear(); + foreach (var item in _items) + { + item.PropertyChanged -= Item_PropertyChanged; + item.IsProcessing = false; + item.Cover = null; + } + } + + public async Task LoadAsync() + { + IsLoading = true; + try + { + var library = await Task.Run(service.LoadLibrary); + _catalog = await service.LoadCatalogAsync(_lifetime.Token); + + _preferencesReady = false; + SelectedSort = SortOptions.FirstOrDefault(option => option.Mode == library.SortMode) ?? SortOptions[0]; + SelectedFinalLap = FinalLapOptions.FirstOrDefault(option => option.Style == library.FinalLapStyle) ?? FinalLapOptions[0]; + SelectedVolume = VolumeOptions.OrderBy(option => Math.Abs(option.Decibels - library.VolumeOffsetDb)).First(); + _preferencesReady = true; + + foreach (var item in _items) + { + item.PropertyChanged -= Item_PropertyChanged; + item.Cover = null; + } + _items.Clear(); + var entries = library.Entries.ToDictionary(entry => entry.Key, StringComparer.OrdinalIgnoreCase); + foreach (var slot in _catalog.Slots) + { + var item = new MusicSlotItem(slot) { CanPreviewOriginal = preview.HasOriginal(slot) }; + if (entries.Remove(slot.Key, out var entry)) + { + item.Saved = entry; + item.Current = entry; + item.Cover = LoadCover(entry); + } + + item.PropertyChanged += Item_PropertyChanged; + _items.Add(item); + } + + AssignFamilies(); + await RefreshPacksAsync(); + + // Songs for tracks Retro Rewind no longer has are kept, so they come back if the track returns. + _orphanedEntries = entries.Values.ToList(); + RefreshModState(); + } + catch (OperationCanceledException) + { + // Page closed while loading. + } + finally + { + IsLoading = false; + RefreshRows(); + } + } + + /// + /// Converts for ; it stays pending until saved. + /// defaults to the volume chosen in the options. + /// + public async Task AssignAsync(MusicSlotItem item, string path, double? volumeOffsetDb = null) + { + if (!AudioFileDecoder.IsSupportedExtension(path)) + { + var message = t("custom_music.error.unsupported", new { extension = PathText.Extension(path) }); + item.Error = message; + return Fail(message); + } + + CancelConversion(item); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + var volume = volumeOffsetDb ?? SelectedVolume.Decibels; + var request = new ConversionRequest(cancellation, path, volume); + _conversions[item] = request; + item.TargetVolumeOffsetDb = volume; + item.Error = null; + item.Progress = 0; + item.IsProcessing = true; + + try + { + await _conversionSlots.WaitAsync(cancellation.Token); + try + { + var progress = new Progress(value => item.Progress = value); + var result = await service.ConvertAsync(item.Slot, path, SelectedFinalLap.Style, volume, progress, cancellation.Token); + if (cancellation.IsCancellationRequested) + return Fail(t("custom_music.error.cancelled")); + if (result.IsFailure) + { + item.Error = t("custom_music.error.prefix", new { message = result.Error.Message }); + return result.Error; + } + + StopPreviewOf(item, customOnly: true); + item.Current = result.Value; + item.Cover = LoadCover(result.Value); + return Ok(); + } + finally + { + _conversionSlots.Release(); + } + } + catch (OperationCanceledException) + { + return Fail(t("custom_music.error.cancelled")); + } + finally + { + if (_conversions.TryGetValue(item, out var current) && ReferenceEquals(current, request)) + { + _conversions.Remove(item); + item.IsProcessing = false; + item.TargetVolumeOffsetDb = null; + } + + cancellation.Dispose(); + RefreshRowsIfSortedByChanges(); + } + } + + /// Matches dropped files to slots by name (e.g. an existing "n_Circuit32_n.brstm" pack). + public IReadOnlyList<(MusicSlotItem Item, string Path)> MatchByFileName(IEnumerable paths) + { + var matches = new List<(MusicSlotItem, string)>(); + foreach (var path in paths) + { + var name = PathText.FileName(path); + var item = _items.FirstOrDefault(i => i.Slot.FileNames.Any(file => file.Equals(name, StringComparison.OrdinalIgnoreCase))); + if (item != null && matches.All(match => match.Item1 != item)) + matches.Add((item, path)); + } + + return matches; + } + + /// + /// Makes the song of louder or quieter by converting its source file again. + /// Clicks during a conversion add up: the running conversion restarts with the combined adjustment. + /// + public async Task> AdjustVolumeAsync(MusicSlotItem item, double deltaDb) + { + // A conversion already running (a new song, or an earlier click) is the one to adjust. + string? path; + double currentOffset; + if (_conversions.TryGetValue(item, out var running)) + { + path = running.Path; + currentOffset = running.VolumeOffsetDb; + } + else if (item.Current is { } entry) + { + path = entry.SourcePath; + currentOffset = entry.VolumeOffsetDb; + } + else + { + return VolumeChange.None; + } + + if (!fileSystem.File.Exists(path)) + { + var message = t("custom_music.error.file_missing"); + item.Error = message; + return new OperationError { Message = message }; + } + + var offset = Math.Clamp(currentOffset + deltaDb, MinVolumeOffsetDb, MaxVolumeOffsetDb); + if (Math.Abs(offset - currentOffset) < 0.01) + return deltaDb > 0 ? VolumeChange.AtLimit : VolumeChange.None; + + var before = item.Current?.LoudnessVersusOriginalDb; + var result = await AssignAsync(item, path, offset); + // A newer click restarted the conversion; that call reports the outcome. + if (_conversions.ContainsKey(item)) + return VolumeChange.None; + if (result.IsFailure) + return result.Error; + + // The peaks may already be at the ceiling; then "louder" only squashes the song. + var after = item.Current?.LoudnessVersusOriginalDb; + if (deltaDb > 0 && before is { } was && after is { } now && now - was < 0.5) + return VolumeChange.AtLimit; + return VolumeChange.Changed; + } + + /// + /// Converts one song for every row in 's family (all the Rainbow Roads, say). Each track + /// is matched to its own original's loudness. + /// + public async Task AssignToFamilyAsync(MusicSlotItem item, string path, double? volumeOffsetDb = null) + { + var results = await Task.WhenAll(item.Family.Select(member => AssignAsync(member, path, volumeOffsetDb))); + var failures = results.Count(result => result.IsFailure); + return failures == 0 ? Ok() : Fail(t("custom_music.error.family", new { failed = failures, amount = results.Length })); + } + + /// + /// Turns dropped files and folders into songs to match by file name: folders are searched, and music packs + /// (.zip) are extracted into the library first. + /// + public async Task>> ExpandDropAsync(IEnumerable paths) + { + var songs = new List(); + foreach (var path in paths) + { + if (fileSystem.Directory.Exists(path)) + { + try + { + // Folders the user may not open (system folders inside a drive, say) are skipped. + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + songs.AddRange( + await Task.Run(() => fileSystem.Directory.EnumerateFiles(path, "*", options).Where(AudioFileDecoder.IsSupportedExtension).ToList(), _lifetime.Token) + ); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return new OperationError { Message = t("custom_music.error.folder", new { message = exception.Message }), Exception = exception }; + } + } + else if (PathText.Extension(path) == ".zip") + { + var extracted = await service.ExtractPackAsync(path, _lifetime.Token); + if (extracted.IsFailure) + return extracted.Error; + songs.AddRange(extracted.Value); + } + else + { + songs.Add(path); + } + } + + return songs; + } + + /// Puts every row back to its original music (pending until saved). + public void ResetAll() + { + foreach (var item in _items.Where(item => item.IsCustom || item.IsProcessing).ToList()) + ResetToOriginal(item); + NotifyState(); + } + + /// Back to the original game / Retro Rewind music (after saving). + public void ResetToOriginal(MusicSlotItem item) + { + StopPreviewOf(item, customOnly: true); + CancelConversion(item); + item.Error = null; + item.Current = null; + item.Cover = null; + RefreshRowsIfSortedByChanges(); + } + + /// Forgets an unsaved change to this slot. + public void UndoChange(MusicSlotItem item) + { + StopPreviewOf(item, customOnly: true); + CancelConversion(item); + item.Error = null; + item.Current = item.Saved; + item.Cover = item.Saved == null ? null : LoadCover(item.Saved); + RefreshRowsIfSortedByChanges(); + } + + public async Task SaveAsync() + { + if (!CanSave) + return Ok(); + + IsSaving = true; + try + { + // Songs can still be dropped while saving; those changes stay pending. + var snapshot = _items.Select(item => (Item: item, Entry: item.Current)).ToList(); + var entries = snapshot.Where(pair => pair.Entry != null).Select(pair => pair.Entry!).Concat(_orphanedEntries).ToList(); + var result = await service.SaveAsync(entries, _catalog.Slots.ToList(), _lifetime.Token); + if (result.IsFailure) + return result; + + foreach (var (item, entry) in snapshot) + { + item.Saved = entry; + if (ReferenceEquals(item.Current, entry)) + item.Error = null; + } + + // Songs that were replaced or reset are no longer needed, unless a change made meanwhile still is. + if (!_items.Any(item => item.IsPending || item.IsProcessing)) + service.DeleteUnsavedFiles(); + + RefreshModState(); + return Ok(); + } + finally + { + IsSaving = false; + NotifyState(); + } + } + + public void DiscardChanges() + { + foreach (var item in _items.ToList()) + { + if (item.IsPending || item.IsProcessing || item.Error != null) + UndoChange(item); + } + + service.DeleteUnsavedFiles(); + NotifyState(); + } + + public async Task EnableModAsync() + { + var result = await service.EnableModAsync(); + RefreshModState(); + return result; + } + + #region Preview + + public bool IsPreviewAvailable => player.IsAvailable; + public bool IsPlayerVisible => _previewItem != null; + public bool IsPlaying => player.State == PreviewState.Playing; + public bool IsPreviewLoading => _previewItem?.PreviewState == ItemPreviewState.Loading; + public string NowPlayingTitle => _previewItem?.Name ?? string.Empty; + + public string NowPlayingKind => + _previewItem == null ? string.Empty + : IsPreviewLoading ? t("custom_music.preview.loading") + : _previewIsCustom ? t("custom_music.preview.your_song") + : _previewItem.Pack is { } pack ? t("custom_music.preview.pack", new { pack = pack.ModTitle }) + : t("custom_music.preview.original"); + + public double PlaybackFraction => player.Duration > TimeSpan.Zero ? Math.Clamp(player.Position / player.Duration, 0, 1) : 0; + public string PlaybackTime => player.Duration > TimeSpan.Zero ? $"{Format(player.Position)} / {Format(player.Duration)}" : string.Empty; + + /// + /// Plays the original music of , or the song converted for it (). + /// Playing something else, or the same thing again, replaces what is playing. + /// + public async Task PreviewAsync(MusicSlotItem item, bool custom) + { + if (!player.IsAvailable) + return Fail(t("custom_music.preview.unavailable")); + if (custom && item.Current == null) + return Ok(); + + HookPlayer(); + ClearPreview(); + var request = ++_previewRequest; + _previewItem = item; + _previewIsCustom = custom; + item.PreviewState = ItemPreviewState.Loading; + NotifyPlayback(); + + try + { + var loaded = custom ? await preview.LoadCustomAsync(item.Current!, _lifetime.Token) + : item.Pack is { } pack ? await preview.LoadFileAsync(pack.Path, item.Slot, _lifetime.Token) + : await preview.LoadOriginalAsync(item.Slot, _lifetime.Token); + if (request != _previewRequest) + return Ok(); // something else was started meanwhile + if (loaded.IsFailure) + { + ClearPreview(); + return loaded.Error; + } + + player.Play(loaded.Value); + item.PreviewState = ItemPreviewState.Playing; + NotifyPlayback(); + return Ok(); + } + catch (OperationCanceledException) + { + return Ok(); // page closed + } + catch (Exception exception) when (exception is IOException or NotSupportedException or System.ComponentModel.Win32Exception or InvalidOperationException) + { + if (request == _previewRequest) + ClearPreview(); + return new OperationError { Message = t("custom_music.preview.failed", new { message = exception.Message }), Exception = exception }; + } + } + + public void StopPreview() => ClearPreview(); + + public void TogglePause() + { + if (_previewItem == null) + return; + if (player.State == PreviewState.Playing) + player.Pause(); + else if (player.State == PreviewState.Paused) + player.Resume(); + } + + /// Jumps to a position in the song; is 0 (start) to 1 (end). + public void SeekTo(double fraction) + { + if (_previewItem != null && player.Duration > TimeSpan.Zero) + player.Seek(player.Duration * Math.Clamp(fraction, 0, 1)); + } + + private void StopPreviewOf(MusicSlotItem item, bool customOnly) + { + if (ReferenceEquals(_previewItem, item) && (!customOnly || _previewIsCustom)) + ClearPreview(); + } + + private void ClearPreview() + { + _previewRequest++; + player.Stop(); + var item = _previewItem; + _previewItem = null; + if (item != null) + item.PreviewState = ItemPreviewState.None; + NotifyPlayback(); + } + + private void HookPlayer() + { + if (_playerHooked) + return; + _playerHooked = true; + player.Changed += Player_Changed; + } + + private void Player_Changed(object? sender, EventArgs e) => Dispatcher.UIThread.Post(OnPlayerChanged); + + private void OnPlayerChanged() + { + if (_previewItem == null) + return; + + // The song reached its end. + if (player.State == PreviewState.Stopped && !IsPreviewLoading) + { + ClearPreview(); + return; + } + + if (!IsPreviewLoading) + { + _previewItem.PreviewState = player.State == PreviewState.Paused ? ItemPreviewState.Paused : ItemPreviewState.Playing; + } + + NotifyPlayback(); + } + + private void NotifyPlayback() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(NotifyPlayback); + return; + } + + Notify(nameof(IsPlayerVisible)); + Notify(nameof(IsPlaying)); + Notify(nameof(IsPreviewLoading)); + Notify(nameof(NowPlayingTitle)); + Notify(nameof(NowPlayingKind)); + Notify(nameof(PlaybackFraction)); + Notify(nameof(PlaybackTime)); + } + + private static string Format(TimeSpan time) => $"{(int)time.TotalMinutes}:{time.Seconds:00}"; + + #endregion + + private void RefreshModState() + { + _modState = service.GetModState(); + var conflicts = service.FindConflicts(); + ConflictText = + conflicts.Count == 0 + ? null + : t( + "custom_music.conflicts", + new { mods = string.Join(", ", conflicts.Select(conflict => conflict.ModTitle).Distinct()), amount = conflicts.Count } + ); + Notify(nameof(IsModDisabled)); + } + + private void CancelConversion(MusicSlotItem item) + { + if (_conversions.Remove(item, out var running)) + { + running.Cancellation.Cancel(); + item.IsProcessing = false; + item.TargetVolumeOffsetDb = null; + } + } + + public bool SongFileExists(string path) => fileSystem.File.Exists(path); + + /// The folder holding the song file of , or null when it is gone. + public string? SourceFolderOf(MusicSlotItem item) + { + if (item.Current is not { } entry) + return null; + var folder = fileSystem.Path.GetDirectoryName(entry.SourcePath); + return !string.IsNullOrEmpty(folder) && fileSystem.Directory.Exists(folder) ? folder : null; + } + + /// Links rows that are the same course on different consoles, so one song can go to all of them. + private void AssignFamilies() + { + var families = _items + .Where(item => item.Slot.Category is MusicCategory.RetroRewindTrack or MusicCategory.Course or MusicCategory.RetroRewindArena or MusicCategory.Arena) + .GroupBy(item => TrackNames.FamilyKey(item.Slot.Name) ?? item.Slot.Key) + .Where(group => group.Count() > 1); + foreach (var family in families) + { + var members = family.OrderBy(item => item.Slot.Order).ToList(); + var name = TrackNames.DisplayFamily(members[0].Slot.Name) ?? members[0].Name; + foreach (var member in members) + { + member.Family = members; + member.FamilyName = name; + } + } + } + + /// Notes which rows get their music from a music pack installed on the Patches page. + private async Task RefreshPacksAsync() + { + var packs = await Task.Run(service.FindPackMusic); + foreach (var item in _items) + item.Pack = item.Slot.FileNames.Select(name => packs.GetValueOrDefault(name)).FirstOrDefault(pack => pack != null); + } + + private Bitmap? LoadCover(CustomMusicEntry entry) + { + var path = service.GetCoverPath(entry); + if (path == null) + return null; + try + { + using var stream = fileSystem.File.OpenRead(path); + return Bitmap.DecodeToWidth(stream, 96); + } + catch (Exception) + { + // Unusual or broken artwork just falls back to the music note. + return null; + } + } + + private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Changes to Current, Saved or IsProcessing refresh every row property at once, Current included; + // reacting to that one name avoids recomputing the page state three times per change. + if (e.PropertyName == nameof(MusicSlotItem.Current)) + NotifyState(); + } + + private void RefreshRowsIfSortedByChanges() + { + if (SelectedSort?.Mode == MusicSortMode.CustomFirst) + RefreshRows(); + } + + public void RefreshRows() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(RefreshRows); + return; + } + + var visible = _items.Where(item => item.Matches(SearchText)).ToList(); + var mode = SelectedSort?.Mode ?? MusicSortMode.Default; + var rows = new List(visible.Count + 64); + + switch (mode) + { + case MusicSortMode.Alphabetical: + rows.AddRange(visible.OrderBy(item => item.SortName, StringComparer.CurrentCultureIgnoreCase)); + break; + case MusicSortMode.CustomFirst: + { + var custom = visible.Where(item => item.IsCustom || item.IsProcessing).OrderBy(item => item.SortName, StringComparer.CurrentCultureIgnoreCase).ToList(); + var original = visible.Where(item => !item.IsCustom && !item.IsProcessing).OrderBy(item => item.Slot.Order).ToList(); + if (custom.Count > 0) + { + rows.Add(new MusicGroupHeader(t("custom_music.group.customised", custom.Count), true)); + rows.AddRange(custom); + } + + if (original.Count > 0) + { + rows.Add(new MusicGroupHeader(t("custom_music.group.original"), rows.Count == 0)); + rows.AddRange(original); + } + + break; + } + case MusicSortMode.ByType: + { + // Menu and lobby music first: it is a handful of rows that otherwise sit below every race track. + var groups = visible + .GroupBy(item => TypeGroup(item.Slot.Category)) + .OrderBy(group => group.Key) + .Select(group => (Title: TypeGroupTitle(group.Key), Items: group.OrderBy(item => item.Slot.Order).ToList())); + foreach (var (title, items) in groups) + { + rows.Add(new MusicGroupHeader(t(title, items.Count), rows.Count == 0)); + rows.AddRange(items); + } + + break; + } + default: + { + string? lastHeader = null; + foreach (var item in visible.OrderBy(item => item.Slot.Order)) + { + var header = GroupTitle(item.Slot); + if (header != lastHeader) + { + rows.Add(new MusicGroupHeader(header, rows.Count == 0)); + lastHeader = header; + } + + rows.Add(item); + } + + break; + } + } + + // One reset instead of hundreds of single adds keeps typing in the search box smooth. + Rows.ReplaceAll(rows); + Notify(nameof(HasNoResults)); + } + + private enum MusicTypeGroup + { + Menus, + Races, + Arenas, + Jingles, + } + + private static MusicTypeGroup TypeGroup(MusicCategory category) => + category switch + { + MusicCategory.Menu => MusicTypeGroup.Menus, + MusicCategory.RetroRewindTrack or MusicCategory.Course => MusicTypeGroup.Races, + MusicCategory.RetroRewindArena or MusicCategory.Arena => MusicTypeGroup.Arenas, + _ => MusicTypeGroup.Jingles, + }; + + private static string TypeGroupTitle(MusicTypeGroup group) => + group switch + { + MusicTypeGroup.Menus => "custom_music.group.menus", + MusicTypeGroup.Races => "custom_music.group.races", + MusicTypeGroup.Arenas => "custom_music.group.arenas", + _ => "custom_music.group.jingles", + }; + + private static string GroupTitle(MusicSlot slot) + { + var category = slot.Category switch + { + MusicCategory.RetroRewindTrack => t("custom_music.category.retro_rewind"), + MusicCategory.RetroRewindArena => t("custom_music.category.retro_rewind_arenas"), + MusicCategory.Course => t("custom_music.category.original_courses"), + MusicCategory.Arena => t("custom_music.category.original_arenas"), + MusicCategory.Menu => t("custom_music.category.menus"), + _ => t("custom_music.category.jingles"), + }; + return slot.Category is MusicCategory.Menu ? category : $"{category} · {slot.Group}"; + } + + private void PersistPreferences() + { + if (!_preferencesReady || _selectedSort == null || _selectedFinalLap == null || _selectedVolume == null) + return; + var sort = _selectedSort.Mode; + var finalLap = _selectedFinalLap.Style; + var volume = _selectedVolume.Decibels; + _ = Task.Run(() => service.SavePreferences(sort, finalLap, volume)); + } + + private void NotifyState() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(NotifyState); + return; + } + + Notify(nameof(CustomCount)); + Notify(nameof(HasCustomMusic)); + Notify(nameof(PendingCount)); + Notify(nameof(HasPendingChanges)); + Notify(nameof(IsConverting)); + Notify(nameof(CanSave)); + Notify(nameof(PendingText)); + Notify(nameof(Summary)); + Notify(nameof(IsRetroRewindMissing)); + Notify(nameof(IsRetroRewindUnreadable)); + Notify(nameof(HasNoResults)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void Notify([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/WheelWizard/Features/CustomMusic/ViewModels/MusicSlotItem.cs b/WheelWizard/Features/CustomMusic/ViewModels/MusicSlotItem.cs new file mode 100644 index 00000000..7c8b4597 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/ViewModels/MusicSlotItem.cs @@ -0,0 +1,326 @@ +using System.ComponentModel; +using Avalonia.Media.Imaging; +using WheelWizard.CustomMusic.Audio; +using WheelWizard.CustomMusic.Catalog; +using WheelWizard.CustomMusic.Library; + +namespace WheelWizard.CustomMusic.ViewModels; + +/// A section heading in the music list (a cup, "Menus", ...). +public sealed class MusicGroupHeader(string title, bool isFirst) +{ + public string Title { get; } = title; + + /// The first heading sits flush with the top of the list. + public bool IsFirst { get; } = isFirst; +} + +public enum ItemPreviewState +{ + None, + + /// The music is being read (the original may come out of the disc image). + Loading, + Playing, + Paused, +} + +/// One row in the custom music list. +public sealed class MusicSlotItem(MusicSlot slot) : INotifyPropertyChanged +{ + private CustomMusicEntry? _saved; + private CustomMusicEntry? _current; + private bool _isProcessing; + private double _progress; + private string? _error; + private Bitmap? _cover; + private bool _isDropTarget; + private ItemPreviewState _previewState; + private double? _targetVolumeOffsetDb; + private PackMusic? _pack; + private bool _canPreviewOriginal; + + public MusicSlot Slot { get; private set; } = slot; + public string Name => Slot.Name; + + /// What is installed in the game right now. + public CustomMusicEntry? Saved + { + get => _saved; + set => Set(ref _saved, value); + } + + /// What will be installed after saving (null = the original music). + public CustomMusicEntry? Current + { + get => _current; + set => Set(ref _current, value); + } + + public bool IsProcessing + { + get => _isProcessing; + set => Set(ref _isProcessing, value); + } + + public double Progress + { + get => _progress; + set + { + // Raised many times per conversion: only the properties that show it change. + if (_progress.Equals(value)) + return; + _progress = value; + Raise(nameof(Progress), nameof(Subtitle)); + } + } + + public string? Error + { + get => _error; + set => Set(ref _error, value); + } + + public Bitmap? Cover + { + get => _cover; + set + { + if (ReferenceEquals(_cover, value)) + return; + _cover?.Dispose(); + _cover = value; + Changed(); + } + } + + /// Highlighted while a file is dragged over this row. + public bool IsDropTarget + { + get => _isDropTarget; + set + { + if (_isDropTarget == value) + return; + _isDropTarget = value; + Raise(nameof(IsDropTarget)); + } + } + + /// Other rows for the same course on other consoles ("SNES Rainbow Road", "Rainbow Road", ...), this one included. + public IReadOnlyList Family { get; set; } = []; + + /// The course name shared by ("Rainbow Road"). + public string FamilyName { get; set; } = string.Empty; + + public bool HasFamily => Family.Count > 1; + + public string FamilyMenuText => HasFamily ? t("custom_music.menu.use_for_family", new { amount = Family.Count, name = FamilyName }) : string.Empty; + + /// The music pack (another enabled patch) that provides this music when no custom song is set. + public PackMusic? Pack + { + get => _pack; + set => Set(ref _pack, value); + } + + /// The volume adjustment a running conversion uses (shown while converting), or null. + public double? TargetVolumeOffsetDb + { + get => _targetVolumeOffsetDb; + set => Set(ref _targetVolumeOffsetDb, value); + } + + /// Whether this row is playing in the preview player (either the original or the custom song). + public ItemPreviewState PreviewState + { + get => _previewState; + set + { + if (_previewState == value) + return; + _previewState = value; + Raise(nameof(PreviewState), nameof(IsPreviewing), nameof(ShowPlayButton)); + } + } + + /// Whether the original music of this row is a file that can be previewed. + public bool CanPreviewOriginal + { + get => _canPreviewOriginal; + set => Set(ref _canPreviewOriginal, value); + } + + public bool IsPreviewing => PreviewState != ItemPreviewState.None; + public bool ShowPlayButton => (CanPreviewOriginal || Pack != null) && !IsPreviewing; + + public bool IsCustom => Current != null; + public bool IsPending => Saved?.StorageId != Current?.StorageId; + public bool HasError => Error != null && !IsProcessing; + public bool HasCover => Cover != null && !IsProcessing; + + /// The default music-note tile: the original music, or a custom song without artwork. + public bool ShowNoteIcon => !IsProcessing && Cover == null; + + public string Subtitle + { + get + { + if (IsProcessing) + { + var converting = t("custom_music.converting", new { percent = (int)Math.Round(Progress * 100) }); + return TargetVolumeOffsetDb is { } target && Math.Abs(target) >= 0.05 ? $"{converting} · {FormatDb(target)}" : converting; + } + if (Error != null) + return Error; + if (Current is { } entry) + { + var artist = string.IsNullOrWhiteSpace(entry.Artist) ? null : entry.Artist; + var text = artist == null ? $"♪ {entry.DisplayTitle}" : $"♪ {entry.DisplayTitle} — {artist}"; + if (Math.Abs(entry.VolumeOffsetDb) >= 0.05) + text = $"{text} · {FormatDb(entry.VolumeOffsetDb)}"; + // The song's peaks held it below the level asked for: say so on the row, not only in the tooltip. + if (entry.LoudnessVersusOriginalDb is { } reached && entry.VolumeOffsetDb - reached > 1.5) + text = $"{text} · {t("custom_music.volume_short", new { amount = (entry.VolumeOffsetDb - reached).ToString("0.#", System.Globalization.CultureInfo.CurrentCulture) })}"; + // The volume could not be matched to the original, so it is a guess; say so. + return entry.ReferenceNote == null ? text : $"{text} · {t("custom_music.volume_estimated")}"; + } + + if (Pack != null) + return t("custom_music.from_pack", new { pack = Pack.ModTitle }); + return Slot.Detail ?? Slot.FileNames.FirstOrDefault() ?? string.Empty; + } + } + + /// Hover details: which files are written and how loud the song was made. + public string Details + { + get + { + var lines = new List { string.Join(", ", Slot.FileNames.Take(3)) + (Slot.FileNames.Count > 3 ? ", …" : string.Empty) }; + if (Slot.HasFinalLap) + lines.Add(t("custom_music.details.final_lap", new { file = Slot.FinalLapFileNames[0] })); + if (Current is { } entry) + { + lines.Add(t("custom_music.details.source", new { file = PathText.FileName(entry.SourcePath) })); + var reference = entry.ReferenceSource switch + { + MusicReferenceSource.RetroRewind => t("custom_music.reference.retro_rewind"), + MusicReferenceSource.GameDisc => t("custom_music.reference.game_disc"), + _ => t("custom_music.reference.estimate"), + }; + lines.Add( + t( + "custom_music.details.loudness", + new + { + source = entry.SourceLufs.ToString("0.0"), + target = (entry.OutputLufs ?? entry.TargetLufs).ToString("0.0"), + original = (entry.ReferenceLufs ?? entry.TargetLufs - entry.VolumeOffsetDb).ToString("0.0"), + reference, + } + ) + ); + if (entry.PulsarVolume > 0) + lines.Add(t("custom_music.details.volume_byte", new { volume = entry.PulsarVolume, original = entry.ReferenceVolume })); + if (Math.Abs(entry.VolumeOffsetDb) >= 0.05) + { + lines.Add(t("custom_music.details.volume_offset", new { amount = FormatDb(entry.VolumeOffsetDb) })); + // Louder than the peaks allow is not possible without distortion; show what was reached. + if (entry.LoudnessVersusOriginalDb is { } reached && entry.VolumeOffsetDb - reached > 1.0) + lines.Add(t("custom_music.details.volume_limited", new { amount = FormatDb(reached) })); + } + if (entry.ReferenceNote != null) + lines.Add(t("custom_music.details.reference_problem", new { message = entry.ReferenceNote })); + var loop = entry.Loop switch + { + LoopKind.FromFile => "custom_music.details.loop_file", + LoopKind.Found => "custom_music.details.loop_found", + LoopKind.Crossfade => "custom_music.details.loop_crossfade", + _ => null, + }; + if (loop != null) + lines.Add(t(loop, new { start = FormatTime(entry.LoopStartSeconds), end = FormatTime(entry.LoopEndSeconds) })); + } + + return string.Join(Environment.NewLine, lines); + } + } + + public string SortName => Name; + + private static string FormatTime(double seconds) => TimeSpan.FromSeconds(seconds).ToString(seconds >= 3600 ? @"h\:mm\:ss" : @"m\:ss"); + + private static string FormatDb(double decibels) => decibels.ToString("+0.#;-0.#", System.Globalization.CultureInfo.CurrentCulture) + " dB"; + + public bool Matches(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return true; + return Contains(Name) || Contains(Slot.Group) || Contains(Slot.SearchText) || Contains(Current?.Title) || Contains(Current?.Artist); + + bool Contains(string? text) => text?.Contains(query, StringComparison.OrdinalIgnoreCase) == true; + } + + public void UpdateSlot(MusicSlot slot) + { + Slot = slot; + Changed(); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void Set(ref T field, T value) + { + if (EqualityComparer.Default.Equals(field, value)) + return; + field = value; + Changed(); + } + + private static readonly string[] AllProperties = + [ + nameof(Slot), + nameof(Name), + nameof(Saved), + nameof(Current), + nameof(IsProcessing), + nameof(Progress), + nameof(Error), + nameof(Cover), + nameof(IsDropTarget), + nameof(TargetVolumeOffsetDb), + nameof(Pack), + nameof(PreviewState), + nameof(CanPreviewOriginal), + nameof(IsPreviewing), + nameof(ShowPlayButton), + nameof(IsCustom), + nameof(IsPending), + nameof(HasError), + nameof(HasCover), + nameof(ShowNoteIcon), + nameof(Subtitle), + nameof(Details), + ]; + + private void Raise(params string[] properties) + { + var handler = PropertyChanged; + if (handler == null) + return; + foreach (var property in properties) + handler(this, new PropertyChangedEventArgs(property)); + } + + /// Most properties derive from each other; refreshing them all keeps the bindings simple. + private void Changed() + { + var handler = PropertyChanged; + if (handler == null) + return; + foreach (var property in AllProperties) + handler(this, new PropertyChangedEventArgs(property)); + } +} diff --git a/WheelWizard/Features/CustomMusic/Views/CustomMusicPage.axaml b/WheelWizard/Features/CustomMusic/Views/CustomMusicPage.axaml new file mode 100644 index 00000000..d10f4afe --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Views/CustomMusicPage.axaml @@ -0,0 +1,409 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +