// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System.Buffers.Binary; using System.Runtime.InteropServices; using System.Text; using LibAtrac9; namespace SharpEmu.GUI; /// /// Loops a game's sce_sys/snd0.at9 preview music while the game is selected /// in the library, like the console home screen. The ATRAC9 stream is decoded /// to WAV on a background task (vendored LibAtrac9); playback uses winmm's /// PlaySound, so this is Windows-only and a no-op elsewhere. /// internal sealed class SndPreviewPlayer { private const uint SND_ASYNC = 0x0001; private const uint SND_NODEFAULT = 0x0002; private const uint SND_MEMORY = 0x0004; private const uint SND_LOOP = 0x0008; private readonly object _sync = new(); private int _generation; private GCHandle _pinnedWav; private bool _playing; private bool _paused; private string? _cachedPath; private byte[]? _cachedWav; /// Starts looping the given snd0.at9 after a short debounce. public void Play(string at9Path) { if (!OperatingSystem.IsWindows()) { return; } int generation; lock (_sync) { generation = ++_generation; } _ = Task.Run(async () => { // Debounce so skimming through the library does not decode (or // start) a preview per tile. await Task.Delay(300).ConfigureAwait(false); byte[]? wav; lock (_sync) { if (generation != _generation) { return; } wav = string.Equals(_cachedPath, at9Path, StringComparison.OrdinalIgnoreCase) ? _cachedWav : null; } if (wav is null) { try { wav = DecodeAt9ToWav(File.ReadAllBytes(at9Path)); } catch (Exception) { return; // corrupt or unsupported preview: stay silent } } lock (_sync) { if (generation != _generation) { return; } _cachedPath = at9Path; _cachedWav = wav; StopLocked(); // The WAV image must stay pinned while winmm plays from it. _pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned); _playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT); if (!_playing) { _pinnedWav.Free(); } } }); } public void Stop() { if (!OperatingSystem.IsWindows()) { return; } lock (_sync) { _generation++; StopLocked(); } } /// /// Silences playback but keeps the decoded track ready, so /// can restart it (winmm cannot truly pause). /// public void Pause() { if (!OperatingSystem.IsWindows()) { return; } lock (_sync) { if (!_playing) { return; } _ = PlaySound(0, 0, 0); _playing = false; _paused = true; } } /// Restarts the track silenced by . public void Resume() { if (!OperatingSystem.IsWindows()) { return; } lock (_sync) { if (!_paused || !_pinnedWav.IsAllocated) { return; } _paused = false; _playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT); } } private void StopLocked() { _ = PlaySound(0, 0, 0); _playing = false; _paused = false; if (_pinnedWav.IsAllocated) { _pinnedWav.Free(); } } private static readonly Guid Atrac9SubFormat = new("47E142D2-36BA-4D8D-88FC-61654F8C836C"); /// /// Decodes a Sony AT9 (RIFF-wrapped ATRAC9) file to a PCM16 WAV image. /// Layout per Sony's container: an extensible fmt chunk whose extension /// carries the 4-byte codec config, a fact chunk with the sample count /// and encoder delay, and superframes in the data chunk. /// private static byte[] DecodeAt9ToWav(byte[] file) { using var reader = new BinaryReader(new MemoryStream(file), Encoding.ASCII); if (Encoding.ASCII.GetString(reader.ReadBytes(4)) != "RIFF") { throw new InvalidDataException("Not a RIFF file."); } reader.BaseStream.Position += 4; // riff size if (Encoding.ASCII.GetString(reader.ReadBytes(4)) != "WAVE") { throw new InvalidDataException("Not a WAVE file."); } byte[]? configData = null; var sampleCount = 0; var encoderDelay = 0; var dataOffset = -1; var dataSize = 0; while (reader.BaseStream.Position + 8 <= reader.BaseStream.Length) { var chunkId = Encoding.ASCII.GetString(reader.ReadBytes(4)); var chunkSize = reader.ReadInt32(); var chunkStart = reader.BaseStream.Position; switch (chunkId) { case "fmt ": var formatTag = reader.ReadUInt16(); reader.BaseStream.Position = chunkStart + 24; // to SubFormat GUID var subFormat = new Guid(reader.ReadBytes(16)); if (formatTag != 0xFFFE || subFormat != Atrac9SubFormat) { throw new InvalidDataException("Not an ATRAC9 stream."); } reader.BaseStream.Position += 4; // version info configData = reader.ReadBytes(4); break; case "fact": sampleCount = reader.ReadInt32(); reader.BaseStream.Position += 4; // input overlap delay encoderDelay = reader.ReadInt32(); break; case "data": dataOffset = (int)chunkStart; dataSize = chunkSize; break; } reader.BaseStream.Position = chunkStart + chunkSize + (chunkSize & 1); } if (configData is null || sampleCount <= 0 || dataOffset < 0) { throw new InvalidDataException("Missing fmt, fact, or data chunk."); } var decoder = new Atrac9Decoder(); decoder.Initialize(configData); var config = decoder.Config; var superframeCount = (sampleCount + encoderDelay + config.SuperframeSamples - 1) / config.SuperframeSamples; superframeCount = Math.Min(superframeCount, dataSize / config.SuperframeBytes); var channels = config.ChannelCount; var pcmBuffer = new short[channels][]; for (var i = 0; i < channels; i++) { pcmBuffer[i] = new short[config.SuperframeSamples]; } var wav = new byte[44 + (sampleCount * channels * 2)]; WriteWavHeader(wav, channels, config.SampleRate, sampleCount); var superframe = new byte[config.SuperframeBytes]; var decodedIndex = 0L; // per-channel, includes the encoder delay var written = 0; for (var f = 0; f < superframeCount && written < sampleCount; f++) { Buffer.BlockCopy(file, dataOffset + (f * config.SuperframeBytes), superframe, 0, config.SuperframeBytes); decoder.Decode(superframe, pcmBuffer); for (var s = 0; s < config.SuperframeSamples && written < sampleCount; s++) { if (decodedIndex++ < encoderDelay) { continue; } var sampleOffset = 44 + ((long)written * channels * 2); for (var ch = 0; ch < channels; ch++) { BinaryPrimitives.WriteInt16LittleEndian( wav.AsSpan((int)(sampleOffset + (ch * 2))), pcmBuffer[ch][s]); } written++; } } return wav; } private static void WriteWavHeader(byte[] wav, int channels, int sampleRate, int sampleCount) { var span = wav.AsSpan(); Encoding.ASCII.GetBytes("RIFF").CopyTo(span); BinaryPrimitives.WriteInt32LittleEndian(span[4..], wav.Length - 8); Encoding.ASCII.GetBytes("WAVE").CopyTo(span[8..]); Encoding.ASCII.GetBytes("fmt ").CopyTo(span[12..]); BinaryPrimitives.WriteInt32LittleEndian(span[16..], 16); BinaryPrimitives.WriteInt16LittleEndian(span[20..], 1); // PCM BinaryPrimitives.WriteInt16LittleEndian(span[22..], (short)channels); BinaryPrimitives.WriteInt32LittleEndian(span[24..], sampleRate); BinaryPrimitives.WriteInt32LittleEndian(span[28..], sampleRate * channels * 2); BinaryPrimitives.WriteInt16LittleEndian(span[32..], (short)(channels * 2)); BinaryPrimitives.WriteInt16LittleEndian(span[34..], 16); // bits per sample Encoding.ASCII.GetBytes("data").CopyTo(span[36..]); BinaryPrimitives.WriteInt32LittleEndian(span[40..], sampleCount * channels * 2); } [DllImport("winmm.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PlaySound(nint sound, nint module, uint flags); }