From 2be12e49f643bc80279b2277f635ea06d90df6eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:11:30 +0000 Subject: [PATCH 1/2] feat: Custom Music page Adds a Custom Music page (sidebar, under Patches) that lists every piece of music Retro Rewind can play and lets players replace any of it by dropping in an audio file. - Catalog: original course, arena, menu and jingle streams (names checked against revo_kart.brsar), plus Retro Rewind tracks and arenas read from Pulsar's Config*.pul (CUPS, BMG names, creator file names), including checkpoint music parts and tracks that borrow an original course's music. - Decoding: managed MP3 (NLayer), Ogg Vorbis (NVorbis), FLAC, WAV/RF64, AIFF and BRSTM; M4A/AAC, Opus, WebM, WMA through an installed FFmpeg (never downloaded). Tags and cover art from ID3v2, Vorbis comments, FLAC pictures, MP4 atoms and FFmpeg; covers tint the row. - Conversion: 32 kHz DSP-ADPCM BRSTM with block-aligned loops. Loudness is matched (BS.1770) to the original being replaced, measured as the game plays it (first track in races, all tracks for Pulsar menu music) from Retro Rewind's files or the player's game disc image. The original's 0x3F volume byte is carried over and raised before a -1 dBFS look-ahead limiter kicks in. Louder/Quieter per row. - Loops: loop points from the file (smpl, LOOPSTART tags, BRSTM); otherwise a repeat is found in the music (band-spectrum self-similarity, refined to the sample by cross-correlation); otherwise the ending crossfades into the start. - Final lap: faster (+6% speed and pitch, like Pulsar), faster with the same pitch (WSOLA), or unchanged. - Page: search, sorts (cup order, A-Z, changed first, by type), preview player for originals, music packs and converted songs (PlaySound on Windows, afplay on macOS, paplay/pw-play/aplay/ffplay/play/mpv on Linux), one song for every console's version of a course, music pack import from .zip or folders matched by file name, packs installed on the Patches page shown per row, reset all, pending changes until Save. - Install: songs are written into a "Custom Music" patch with the highest priority, so the existing Patches pipeline copies them into RetroRewind6/ Patches; conflicts with higher-priority patches are reported. Documentation: WheelWizard/Documentation/CustomMusic.md. Tests: WheelWizard.Test/Features/CustomMusic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M4Wv3rHmcDNTKXzs6Qk9pL --- .../CustomMusic/CustomMusicAudioTests.cs | 586 +++++++++++ .../CustomMusic/CustomMusicCatalogTests.cs | 132 +++ .../CustomMusic/CustomMusicInstallerTests.cs | 153 +++ .../CustomMusic/CustomMusicLibraryTests.cs | 115 +++ .../CustomMusic/CustomMusicPlaybackTests.cs | 125 +++ .../CustomMusic/CustomMusicReferenceTests.cs | 162 +++ .../CustomMusic/PulsarConfigBuilder.cs | 112 ++ WheelWizard/Documentation/CustomMusic.md | 151 +++ .../Features/CustomMusic/Audio/AudioBuffer.cs | 118 +++ .../CustomMusic/Audio/AudioFileDecoder.cs | 143 +++ .../CustomMusic/Audio/Codecs/DspAdpcm.cs | 668 ++++++++++++ .../CustomMusic/Audio/Dsp/AudioOps.cs | 282 ++++++ .../CustomMusic/Audio/Dsp/LoopFinder.cs | 293 ++++++ .../CustomMusic/Audio/Dsp/LoudnessMeter.cs | 200 ++++ .../CustomMusic/Audio/Dsp/PeakLimiter.cs | 107 ++ .../CustomMusic/Audio/Dsp/Resampler.cs | 145 +++ .../CustomMusic/Audio/Dsp/TimeStretcher.cs | 136 +++ .../CustomMusic/Audio/Formats/Brstm.cs | 518 ++++++++++ .../Audio/Formats/CompressedReaders.cs | 161 +++ .../CustomMusic/Audio/Formats/FfmpegReader.cs | 217 ++++ .../CustomMusic/Audio/Formats/FlacReader.cs | 509 ++++++++++ .../Audio/Formats/PcmContainerReaders.cs | 305 ++++++ .../CustomMusic/Audio/MusicConverter.cs | 278 +++++ .../CustomMusic/Audio/Tags/Id3v2Reader.cs | 272 +++++ .../CustomMusic/Audio/Tags/Mp4TagReader.cs | 143 +++ .../CustomMusic/Audio/Tags/VorbisComments.cs | 138 +++ .../CustomMusic/Catalog/GameMusicCatalog.cs | 210 ++++ .../Catalog/MusicCatalogService.cs | 237 +++++ .../Features/CustomMusic/Catalog/MusicSlot.cs | 87 ++ .../CustomMusic/Catalog/PulsarConfigReader.cs | 246 +++++ .../CustomMusic/Catalog/TrackNames.cs | 46 + .../CustomMusic/CustomMusicExtensions.cs | 24 + .../CustomMusic/CustomMusicService.cs | 271 +++++ .../Library/CustomMusicInstaller.cs | 217 ++++ .../CustomMusic/Library/CustomMusicLibrary.cs | 292 ++++++ .../Library/GameDiscMusicReader.cs | 62 ++ .../Library/MusicReferenceService.cs | 196 ++++ WheelWizard/Features/CustomMusic/PathText.cs | 41 + .../CustomMusic/Playback/AudioOutput.cs | 210 ++++ .../Playback/MusicPreviewPlayer.cs | 198 ++++ .../Playback/MusicPreviewService.cs | 128 +++ .../CustomMusic/Playback/PreviewClip.cs | 82 ++ .../ViewModels/BulkObservableCollection.cs | 22 + .../ViewModels/CustomMusicViewModel.cs | 957 ++++++++++++++++++ .../CustomMusic/ViewModels/MusicSlotItem.cs | 326 ++++++ .../CustomMusic/Views/CustomMusicPage.axaml | 409 ++++++++ .../Views/CustomMusicPage.axaml.cs | 370 +++++++ WheelWizard/Resources/Languages/en.yml | 164 +++ WheelWizard/SetupExtensions.cs | 2 + WheelWizard/Views/Shell/Layout.axaml | 4 + .../Views/Styles/Resources/Icons.axaml | 7 + WheelWizard/WheelWizard.csproj | 4 + 52 files changed, 10981 insertions(+) create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicCatalogTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicInstallerTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicLibraryTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicPlaybackTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/CustomMusicReferenceTests.cs create mode 100644 WheelWizard.Test/Features/CustomMusic/PulsarConfigBuilder.cs create mode 100644 WheelWizard/Documentation/CustomMusic.md create mode 100644 WheelWizard/Features/CustomMusic/Audio/AudioBuffer.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/AudioFileDecoder.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Codecs/DspAdpcm.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/AudioOps.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/LoopFinder.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/LoudnessMeter.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/PeakLimiter.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/Resampler.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Dsp/TimeStretcher.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Formats/Brstm.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Formats/CompressedReaders.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Formats/FfmpegReader.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Formats/FlacReader.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Formats/PcmContainerReaders.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/MusicConverter.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Tags/Id3v2Reader.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Tags/Mp4TagReader.cs create mode 100644 WheelWizard/Features/CustomMusic/Audio/Tags/VorbisComments.cs create mode 100644 WheelWizard/Features/CustomMusic/Catalog/GameMusicCatalog.cs create mode 100644 WheelWizard/Features/CustomMusic/Catalog/MusicCatalogService.cs create mode 100644 WheelWizard/Features/CustomMusic/Catalog/MusicSlot.cs create mode 100644 WheelWizard/Features/CustomMusic/Catalog/PulsarConfigReader.cs create mode 100644 WheelWizard/Features/CustomMusic/Catalog/TrackNames.cs create mode 100644 WheelWizard/Features/CustomMusic/CustomMusicExtensions.cs create mode 100644 WheelWizard/Features/CustomMusic/CustomMusicService.cs create mode 100644 WheelWizard/Features/CustomMusic/Library/CustomMusicInstaller.cs create mode 100644 WheelWizard/Features/CustomMusic/Library/CustomMusicLibrary.cs create mode 100644 WheelWizard/Features/CustomMusic/Library/GameDiscMusicReader.cs create mode 100644 WheelWizard/Features/CustomMusic/Library/MusicReferenceService.cs create mode 100644 WheelWizard/Features/CustomMusic/PathText.cs create mode 100644 WheelWizard/Features/CustomMusic/Playback/AudioOutput.cs create mode 100644 WheelWizard/Features/CustomMusic/Playback/MusicPreviewPlayer.cs create mode 100644 WheelWizard/Features/CustomMusic/Playback/MusicPreviewService.cs create mode 100644 WheelWizard/Features/CustomMusic/Playback/PreviewClip.cs create mode 100644 WheelWizard/Features/CustomMusic/ViewModels/BulkObservableCollection.cs create mode 100644 WheelWizard/Features/CustomMusic/ViewModels/CustomMusicViewModel.cs create mode 100644 WheelWizard/Features/CustomMusic/ViewModels/MusicSlotItem.cs create mode 100644 WheelWizard/Features/CustomMusic/Views/CustomMusicPage.axaml create mode 100644 WheelWizard/Features/CustomMusic/Views/CustomMusicPage.axaml.cs diff --git a/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs b/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs new file mode 100644 index 00000000..ed561398 --- /dev/null +++ b/WheelWizard.Test/Features/CustomMusic/CustomMusicAudioTests.cs @@ -0,0 +1,586 @@ +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 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..c7df7065 --- /dev/null +++ b/WheelWizard/Documentation/CustomMusic.md @@ -0,0 +1,151 @@ +# 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. + 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..59dd4fe7 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/AudioFileDecoder.cs @@ -0,0 +1,143 @@ +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); + } + + public static async Task DecodeAsync(IFileSystem fileSystem, string path, 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, 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). + /// Stops decoding. + public static async Task DecodeAsync(IFileSystem fileSystem, byte[] bytes, string pathForFallback, 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.Decode(bytes), 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..9e2db3a3 --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Audio/Formats/CompressedReaders.cs @@ -0,0 +1,161 @@ +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 +{ + public static bool LooksLikeMp3(ReadOnlySpan data) + { + if (Id3v2Reader.GetTagSize(data) > 0) + return true; + // MPEG audio frame sync: 11 set bits, layer bits not 00. + return data.Length >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0 && (data[1] & 0x06) != 0; + } + + 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."); + + // Gapless playback: drop the encoder delay/padding the LAME header describes (plus the 529 sample + // decoder delay), so loop points from tags line up and there is no extra silence. + var total = planar[0].Count; + var (skipStart, skipEnd) = ReadGaplessInfo(file) is { } gapless ? (gapless.Delay + DecoderDelay, Math.Max(0, gapless.Padding - DecoderDelay)) : (0, 0); + if (skipStart + skipEnd >= total) + (skipStart, skipEnd) = (0, 0); + + var channelsOut = planar.Select(list => list.GetRange(skipStart, total - skipStart - skipEnd).ToArray()).ToArray(); + return new AudioBuffer(channelsOut, sampleRate) { Metadata = metadata }; + } + + private const int DecoderDelay = 529; + + /// Reads encoder delay and padding from the LAME/Info header in the first MPEG frame. + internal static (int Delay, int Padding)? ReadGaplessInfo(ReadOnlySpan file) + { + var position = Id3v2Reader.GetTagSize(file); + // Find the first frame sync after the tag. + while (position + 4 < file.Length && !(file[position] == 0xFF && (file[position + 1] & 0xE0) == 0xE0)) + { + position++; + if (position > 64 * 1024 + Id3v2Reader.GetTagSize(file)) + return null; + } + + if (position + 4 >= file.Length) + return null; + + var versionBits = (file[position + 1] >> 3) & 0x3; // 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5 + var channelMode = (file[position + 3] >> 6) & 0x3; // 3 = mono + var isMpeg1 = versionBits == 3; + var sideInfo = isMpeg1 ? (channelMode == 3 ? 17 : 32) : (channelMode == 3 ? 9 : 17); + var tag = position + 4 + sideInfo; + if (tag + 120 > file.Length) + return null; + + var magic = System.Text.Encoding.ASCII.GetString(file.Slice(tag, 4)); + if (magic is not ("Xing" or "Info")) + return null; + + var flags = (file[tag + 4] << 24) | (file[tag + 5] << 16) | (file[tag + 6] << 8) | file[tag + 7]; + var lame = tag + 8; + if ((flags & 1) != 0) + lame += 4; + if ((flags & 2) != 0) + lame += 4; + if ((flags & 4) != 0) + lame += 100; + if ((flags & 8) != 0) + lame += 4; + + if (lame + 24 > file.Length) + return null; + var encoder = System.Text.Encoding.ASCII.GetString(file.Slice(lame, 4)); + if (encoder is not ("LAME" or "Lavf" or "Lavc" or "GOGO")) + return null; + + var delay = (file[lame + 21] << 4) | (file[lame + 22] >> 4); + var padding = ((file[lame + 22] & 0x0F) << 8) | file[lame + 23]; + return delay is >= 0 and < 4096 && padding is >= 0 and < 4096 ? (delay, padding) : null; + } +} + +/// 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..6870d4fa --- /dev/null +++ b/WheelWizard/Features/CustomMusic/CustomMusicService.cs @@ -0,0 +1,271 @@ +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 of conversions that were never saved. + void DiscardUnsaved(); + + 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, 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; + + library.DeleteUnused(entries); + } + + return Ok(); + } + + public void DiscardUnsaved() + { + 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..67fae52c --- /dev/null +++ b/WheelWizard/Features/CustomMusic/Playback/MusicPreviewService.cs @@ -0,0 +1,128 @@ +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. + Task> LoadFileAsync(string path, 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 +{ + private const int CachedClips = 6; + + // Reading an original from the disc image takes a moment, so recently played clips are kept. + 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, 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}", () => fileSystem.File.ReadAllBytes(path)); + }, + 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..ae49a4ee --- /dev/null +++ b/WheelWizard/Features/CustomMusic/ViewModels/CustomMusicViewModel.cs @@ -0,0 +1,957 @@ +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)) + { + songs.AddRange( + await Task.Run(() => fileSystem.Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Where(AudioFileDecoder.IsSupportedExtension).ToList()) + ); + } + 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 + { + var entries = _items.Where(item => item.Current != null).Select(item => item.Current!).Concat(_orphanedEntries).ToList(); + var result = await service.SaveAsync(entries, _catalog.Slots.ToList(), _lifetime.Token); + if (result.IsFailure) + return result; + + foreach (var item in _items) + { + item.Saved = item.Current; + item.Error = null; + } + + 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.DiscardUnsaved(); + 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, _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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +