[GUI] Added Atrac9 audio decoder and improved GUI with audio preview and controller support (#64)

* [GUI] Added Atrac9 audio decoder and improved GUI with audio preview and controller support

* fix: package.lock.json for SharpEmu.CLI to match the other projects

* fix: packages.lock.json file to include new dependencies for GUI improvements

* rollForward: "disable"
This commit is contained in:
Berk
2026-07-11 19:14:08 +03:00
committed by GitHub
parent f43f7cde9c
commit 79a7437cd8
37 changed files with 4601 additions and 77 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Alex Barney
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -15,3 +15,9 @@ path = [
precedence = "aggregate"
SPDX-FileCopyrightText = "SharpEmu Emulator Project"
SPDX-License-Identifier = "GPL-2.0-or-later"
[[annotations]]
path = "src/SharpEmu.GUI/Atrac9/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2018 Alex Barney"
SPDX-License-Identifier = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestFeature"
"rollForward": "disable"
}
}
+43
View File
@@ -60,6 +60,7 @@ internal static partial class Program
// The executable uses the GUI subsystem, so CLI mode has to connect
// itself to a console before the first write.
EnsureCliConsole();
UseUtf8ConsoleOutput();
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -162,6 +163,48 @@ internal static partial class Program
RebindStdHandleToConsole(STD_ERROR_HANDLE);
}
/// <summary>
/// Makes console writes UTF-8 so the GUI's pipe reader (and any modern
/// terminal) decodes non-ASCII text correctly. Without this, redirected
/// output falls back to the OS ANSI code page and characters like "—"
/// arrive mangled.
/// </summary>
private static void UseUtf8ConsoleOutput()
{
try
{
// Also recreates the redirected Console.Out/Error writers with
// the new encoding.
Console.OutputEncoding = Encoding.UTF8;
return;
}
catch (Exception)
{
// No attached console (GUI-subsystem child with piped output):
// wrap the raw handles instead.
}
if (Console.IsOutputRedirected)
{
Console.SetOut(new StreamWriter(
Console.OpenStandardOutput(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true,
});
}
if (Console.IsErrorRedirected)
{
Console.SetError(new StreamWriter(
Console.OpenStandardError(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true,
});
}
}
private static void RebindStdHandleToConsole(int stdHandle)
{
if (IsHandleValid(GetStdHandle(stdHandle)) || GetConsoleWindow() == 0)
+5 -4
View File
@@ -4,9 +4,9 @@
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "4Iw41e2h7I4t70SJcX2GCmbyKJIlA273Cfm9RJMM050/3VBejGAG1KcthP5Z2L6SQcbfbf6BhNWO26+ZG+GzMg=="
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
@@ -211,6 +211,7 @@
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Logging": "[1.0.0, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
@@ -484,4 +485,4 @@
}
}
}
}
}
+120
View File
@@ -0,0 +1,120 @@
#nullable disable
using System.IO;
using LibAtrac9.Utilities;
namespace LibAtrac9
{
/// <summary>
/// Stores the configuration data needed to decode or encode an ATRAC9 stream.
/// </summary>
public class Atrac9Config
{
/// <summary>
/// The 4-byte ATRAC9 configuration data.
/// </summary>
public byte[] ConfigData { get; }
/// <summary>
/// A 4-bit value specifying one of 16 sample rates.
/// </summary>
public int SampleRateIndex { get; }
/// <summary>
/// A 3-bit value specifying one of 6 substream channel mappings.
/// </summary>
public int ChannelConfigIndex { get; }
/// <summary>
/// An 11-bit value containing the average size of a single frame.
/// </summary>
public int FrameBytes { get; }
/// <summary>
/// A 2-bit value indicating how many frames are in each superframe.
/// </summary>
public int SuperframeIndex { get; }
/// <summary>
/// The channel mapping used by the ATRAC9 stream.
/// </summary>
public ChannelConfig ChannelConfig { get; }
/// <summary>
/// The total number of channels in the ATRAC9 stream.
/// </summary>
public int ChannelCount { get; }
/// <summary>
/// The sample rate of the ATRAC9 stream.
/// </summary>
public int SampleRate { get; }
/// <summary>
/// Indicates whether the ATRAC9 stream has a <see cref="SampleRateIndex"/> of 8 or above.
/// </summary>
public bool HighSampleRate { get; }
/// <summary>
/// The number of frames in each superframe.
/// </summary>
public int FramesPerSuperframe { get; }
/// <summary>
/// The number of samples in one frame as an exponent of 2.
/// <see cref="FrameSamples"/> = 2^<see cref="FrameSamplesPower"/>.
/// </summary>
public int FrameSamplesPower { get; }
/// <summary>
/// The number of samples in one frame.
/// </summary>
public int FrameSamples { get; }
/// <summary>
/// The number of bytes in one superframe.
/// </summary>
public int SuperframeBytes { get; }
/// <summary>
/// The number of samples in one superframe.
/// </summary>
public int SuperframeSamples { get; }
/// <summary>
/// Reads ATRAC9 configuration data and calculates the stream parameters from it.
/// </summary>
/// <param name="configData">The processed ATRAC9 configuration.</param>
public Atrac9Config(byte[] configData)
{
if (configData == null || configData.Length != 4)
{
throw new InvalidDataException("Config data must be 4 bytes long");
}
ReadConfigData(configData, out int a, out int b, out int c, out int d);
SampleRateIndex = a;
ChannelConfigIndex = b;
FrameBytes = c;
SuperframeIndex = d;
ConfigData = configData;
FramesPerSuperframe = 1 << SuperframeIndex;
SuperframeBytes = FrameBytes << SuperframeIndex;
ChannelConfig = Tables.ChannelConfig[ChannelConfigIndex];
ChannelCount = ChannelConfig.ChannelCount;
SampleRate = Tables.SampleRates[SampleRateIndex];
HighSampleRate = SampleRateIndex > 7;
FrameSamplesPower = Tables.SamplingRateIndexToFrameSamplesPower[SampleRateIndex];
FrameSamples = 1 << FrameSamplesPower;
SuperframeSamples = FrameSamples * FramesPerSuperframe;
}
private static void ReadConfigData(byte[] configData, out int sampleRateIndex, out int channelConfigIndex, out int frameBytes, out int superframeIndex)
{
var reader = new BitReader(configData);
int header = reader.ReadInt(8);
sampleRateIndex = reader.ReadInt(4);
channelConfigIndex = reader.ReadInt(3);
int validationBit = reader.ReadInt(1);
frameBytes = reader.ReadInt(11) + 1;
superframeIndex = reader.ReadInt(2);
if (header != 0xFE || validationBit != 0)
{
throw new InvalidDataException("ATRAC9 Config Data is invalid");
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
#nullable disable
using System;
using LibAtrac9.Utilities;
namespace LibAtrac9
{
/// <summary>
/// Decodes an ATRAC9 stream into 16-bit PCM.
/// </summary>
public class Atrac9Decoder
{
/// <summary>
/// The config data for the current ATRAC9 stream.
/// </summary>
public Atrac9Config Config { get; private set; }
private Frame Frame { get; set; }
private BitReader Reader { get; set; }
private bool _initialized;
/// <summary>
/// Sets up the decoder to decode an ATRAC9 stream based on the information in <paramref name="configData"/>.
/// </summary>
/// <param name="configData">A 4-byte value containing information about the ATRAC9 stream.</param>
public void Initialize(byte[] configData)
{
Config = new Atrac9Config(configData);
Frame = new Frame(Config);
Reader = new BitReader(null);
_initialized = true;
}
/// <summary>
/// Decodes one superframe of ATRAC9 data.
/// </summary>
/// <param name="atrac9Data">The ATRAC9 data to decode. The array must be at least
/// <see cref="Config"/>.<see cref="Atrac9Config.SuperframeBytes"/> bytes long.</param>
/// <param name="pcmOut">A buffer that the decoded PCM data will be placed in.
/// The array must have dimensions of at least [<see cref="Config"/>.<see cref="Atrac9Config.ChannelCount"/>]
/// [<see cref="Config"/>.<see cref="Atrac9Config.SuperframeSamples"/>].</param>
public void Decode(byte[] atrac9Data, short[][] pcmOut)
{
if (!_initialized) throw new InvalidOperationException("Decoder must be initialized before decoding.");
ValidateDecodeBuffers(atrac9Data, pcmOut);
Reader.SetBuffer(atrac9Data);
DecodeSuperFrame(pcmOut);
}
private void ValidateDecodeBuffers(byte[] atrac9Buffer, short[][] pcmBuffer)
{
if (atrac9Buffer == null) throw new ArgumentNullException(nameof(atrac9Buffer));
if (pcmBuffer == null) throw new ArgumentNullException(nameof(pcmBuffer));
if (atrac9Buffer.Length < Config.SuperframeBytes)
{
throw new ArgumentException("ATRAC9 buffer is too small");
}
if (pcmBuffer.Length < Config.ChannelCount)
{
throw new ArgumentException("PCM buffer is too small");
}
for (int i = 0; i < Config.ChannelCount; i++)
{
if (pcmBuffer[i]?.Length < Config.SuperframeSamples)
{
throw new ArgumentException("PCM buffer is too small");
}
}
}
private void DecodeSuperFrame(short[][] pcmOut)
{
for (int i = 0; i < Config.FramesPerSuperframe; i++)
{
Frame.FrameIndex = i;
DecodeFrame(Reader, Frame);
PcmFloatToShort(pcmOut, i * Config.FrameSamples);
Reader.AlignPosition(8);
}
}
private void PcmFloatToShort(short[][] pcmOut, int start)
{
int endSample = start + Config.FrameSamples;
int channelNum = 0;
foreach (Block block in Frame.Blocks)
{
foreach (Channel channel in block.Channels)
{
double[] pcmSrc = channel.Pcm;
short[] pcmDest = pcmOut[channelNum++];
for (int d = 0, s = start; s < endSample; d++, s++)
{
double sample = pcmSrc[d];
// Not using Math.Round because it's ~20x slower on 64-bit
int roundedSample = (int)Math.Floor(sample + 0.5);
pcmDest[s] = Helpers.Clamp16(roundedSample);
}
}
}
}
private static void DecodeFrame(BitReader reader, Frame frame)
{
Unpack.UnpackFrame(reader, frame);
foreach (Block block in frame.Blocks)
{
Quantization.DequantizeSpectra(block);
Stereo.ApplyIntensityStereo(block);
Quantization.ScaleSpectrum(block);
BandExtension.ApplyBandExtension(block);
ImdctBlock(block);
}
}
private static void ImdctBlock(Block block)
{
foreach (Channel channel in block.Channels)
{
channel.Mdct.RunImdct(channel.Spectra, channel.Pcm);
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
#nullable disable
namespace LibAtrac9
{
/// <summary>
/// An Xorshift RNG used by the ATRAC9 codec
/// </summary>
internal class Atrac9Rng
{
private ushort _stateA;
private ushort _stateB;
private ushort _stateC;
private ushort _stateD;
public Atrac9Rng(ushort seed)
{
int startValue = 0x4D93 * (seed ^ (seed >> 14));
_stateA = (ushort)(3 - startValue);
_stateB = (ushort)(2 - startValue);
_stateC = (ushort)(1 - startValue);
_stateD = (ushort)(0 - startValue);
}
public ushort Next()
{
ushort t = (ushort)(_stateD ^ (_stateD << 5));
_stateD = _stateC;
_stateC = _stateB;
_stateB = _stateA;
_stateA = (ushort)(t ^ _stateA ^ ((t ^ (_stateA >> 5)) >> 4));
return _stateA;
}
}
}
+373
View File
@@ -0,0 +1,373 @@
#nullable disable
using System;
namespace LibAtrac9
{
internal static class BandExtension
{
public static void ApplyBandExtension(Block block)
{
if (!block.BandExtensionEnabled || !block.HasExtensionData) return;
foreach (Channel channel in block.Channels)
{
ApplyBandExtensionChannel(channel);
}
}
private static void ApplyBandExtensionChannel(Channel channel)
{
int groupAUnit = channel.Block.QuantizationUnitCount;
int[] scaleFactors = channel.ScaleFactors;
double[] spectra = channel.Spectra;
double[] scales = channel.BexScales;
int[] values = channel.BexValues;
GetBexBandInfo(out int bandCount, out int groupBUnit, out int groupCUnit, groupAUnit);
int totalUnits = Math.Max(groupCUnit, 22);
int groupABin = Tables.QuantUnitToCoeffIndex[groupAUnit];
int groupBBin = Tables.QuantUnitToCoeffIndex[groupBUnit];
int groupCBin = Tables.QuantUnitToCoeffIndex[groupCUnit];
int totalBins = Tables.QuantUnitToCoeffIndex[totalUnits];
FillHighFrequencies(spectra, groupABin, groupBBin, groupCBin, totalBins);
switch (channel.BexMode)
{
case 0:
int bexQuantUnits = totalUnits - groupAUnit;
switch (bandCount)
{
case 3:
scales[0] = BexMode0Bands3[0][values[0]];
scales[1] = BexMode0Bands3[1][values[0]];
scales[2] = BexMode0Bands3[2][values[1]];
scales[3] = BexMode0Bands3[3][values[2]];
scales[4] = BexMode0Bands3[4][values[3]];
break;
case 4:
scales[0] = BexMode0Bands4[0][values[0]];
scales[1] = BexMode0Bands4[1][values[0]];
scales[2] = BexMode0Bands4[2][values[1]];
scales[3] = BexMode0Bands4[3][values[2]];
scales[4] = BexMode0Bands4[4][values[3]];
break;
case 5:
scales[0] = BexMode0Bands5[0][values[0]];
scales[1] = BexMode0Bands5[1][values[1]];
scales[2] = BexMode0Bands5[2][values[1]];
break;
}
scales[bexQuantUnits - 1] = Tables.SpectrumScale[scaleFactors[groupAUnit]];
AddNoiseToSpectrum(channel, Tables.QuantUnitToCoeffIndex[totalUnits - 1],
Tables.QuantUnitToCoeffCount[totalUnits - 1]);
ScaleBexQuantUnits(spectra, scales, groupAUnit, totalUnits);
break;
case 1:
for (int i = groupAUnit; i < totalUnits; i++)
{
scales[i - groupAUnit] = Tables.SpectrumScale[scaleFactors[i]];
}
AddNoiseToSpectrum(channel, groupABin, totalBins - groupABin);
ScaleBexQuantUnits(spectra, scales, groupAUnit, totalUnits);
break;
case 2:
double groupAScale2 = BexMode2Scale[values[0]];
double groupBScale2 = BexMode2Scale[values[1]];
for (int i = groupABin; i < groupBBin; i++)
{
spectra[i] *= groupAScale2;
}
for (int i = groupBBin; i < groupCBin; i++)
{
spectra[i] *= groupBScale2;
}
return;
case 3:
double rate = Math.Pow(2, BexMode3Rate[values[1]]);
double scale = BexMode3Initial[values[0]];
for (int i = groupABin; i < totalBins; i++)
{
scale *= rate;
spectra[i] *= scale;
}
return;
case 4:
double mult = BexMode4Multiplier[values[0]];
double groupAScale4 = 0.7079468 * mult;
double groupBScale4 = 0.5011902 * mult;
double groupCScale4 = 0.3548279 * mult;
for (int i = groupABin; i < groupBBin; i++)
{
spectra[i] *= groupAScale4;
}
for (int i = groupBBin; i < groupCBin; i++)
{
spectra[i] *= groupBScale4;
}
for (int i = groupCBin; i < totalBins; i++)
{
spectra[i] *= groupCScale4;
}
return;
}
}
private static void ScaleBexQuantUnits(double[] spectra, double[] scales, int startUnit, int totalUnits)
{
for (int i = startUnit; i < totalUnits; i++)
{
for (int k = Tables.QuantUnitToCoeffIndex[i]; k < Tables.QuantUnitToCoeffIndex[i + 1]; k++)
{
spectra[k] *= scales[i - startUnit];
}
}
}
private static void FillHighFrequencies(double[] spectra, int groupABin, int groupBBin, int groupCBin, int totalBins)
{
for (int i = 0; i < groupBBin - groupABin; i++)
{
spectra[groupABin + i] = spectra[groupABin - i - 1];
}
for (int i = 0; i < groupCBin - groupBBin; i++)
{
spectra[groupBBin + i] = spectra[groupBBin - i - 1];
}
for (int i = 0; i < totalBins - groupCBin; i++)
{
spectra[groupCBin + i] = spectra[groupCBin - i - 1];
}
}
private static void AddNoiseToSpectrum(Channel channel, int index, int count)
{
if (channel.Rng == null)
{
int[] sf = channel.ScaleFactors;
ushort seed = (ushort)(543 * (sf[8] + sf[12] + sf[15] + 1));
channel.Rng = new Atrac9Rng(seed);
}
for (int i = 0; i < count; i++)
{
channel.Spectra[i + index] = channel.Rng.Next() / 65535.0 * 2.0 - 1.0;
}
}
public static void GetBexBandInfo(out int bandCount, out int groupAUnit, out int groupBUnit, int quantUnits)
{
groupAUnit = BexGroupInfo[quantUnits - 13][0];
groupBUnit = BexGroupInfo[quantUnits - 13][1];
bandCount = BexGroupInfo[quantUnits - 13][2];
}
public static readonly byte[][] BexGroupInfo =
{
new byte[] {16, 21, 0},
new byte[] {18, 22, 1},
new byte[] {20, 22, 2},
new byte[] {21, 22, 3},
new byte[] {21, 22, 3},
new byte[] {23, 24, 4},
new byte[] {23, 24, 4},
new byte[] {24, 24, 5}
};
// [mode][bands]
public static readonly byte[][] BexEncodedValueCounts =
{
new byte[] {0, 0, 0, 4, 4, 2},
new byte[] {0, 0, 0, 0, 0, 0},
new byte[] {0, 0, 0, 2, 2, 1},
new byte[] {0, 0, 0, 2, 2, 2},
new byte[] {1, 1, 1, 0, 0, 0}
};
// [mode][bands][valueIndex]
public static readonly byte[][][] BexDataLengths =
{
new[] {
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {5, 4, 3, 3},
new byte[] {4, 4, 3, 4},
new byte[] {4, 5, 0, 0}
}, new[] {
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0}
}, new[] {
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {6, 6, 0, 0},
new byte[] {6, 6, 0, 0},
new byte[] {6, 0, 0, 0}
}, new[] {
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {4, 4, 0, 0},
new byte[] {4, 4, 0, 0},
new byte[] {4, 4, 0, 0}
}, new[] {
new byte[] {3, 0, 0, 0},
new byte[] {3, 0, 0, 0},
new byte[] {3, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0},
new byte[] {0, 0, 0, 0}
}
};
public static readonly double[][] BexMode0Bands3 =
{
new[] {
0.000000e+0, 1.988220e-1, 2.514343e-1, 2.960510e-1,
3.263550e-1, 3.771362e-1, 3.786926e-1, 4.540405e-1,
4.877625e-1, 5.262451e-1, 5.447083e-1, 5.737000e-1,
6.212158e-1, 6.222839e-1, 6.560974e-1, 6.896667e-1,
7.555542e-1, 7.677917e-1, 7.918091e-1, 7.971497e-1,
8.188171e-1, 8.446045e-1, 9.790649e-1, 9.822083e-1,
9.846191e-1, 9.859314e-1, 9.863586e-1, 9.863892e-1,
9.873352e-1, 9.881287e-1, 9.898682e-1, 9.913330e-1
}, new[] {
0.000000e+0, 9.982910e-1, 7.592773e-2, 7.179565e-1,
9.851379e-1, 5.340271e-1, 9.013672e-1, 6.349182e-1,
7.226257e-1, 1.948547e-1, 7.628174e-1, 9.873657e-1,
8.112183e-1, 2.715454e-1, 9.734192e-1, 1.443787e-1,
4.640198e-1, 3.249207e-1, 3.790894e-1, 8.276367e-2,
5.954590e-1, 2.864380e-1, 9.806824e-1, 7.929077e-1,
6.292114e-1, 4.887085e-1, 2.905273e-1, 1.301880e-1,
3.140869e-1, 5.482483e-1, 4.210815e-1, 1.182861e-1
}, new[] {
0.000000e+0, 3.155518e-2, 8.581543e-2, 1.364746e-1,
1.858826e-1, 2.368469e-1, 2.888184e-1, 3.432617e-1,
4.012451e-1, 4.623108e-1, 5.271301e-1, 5.954895e-1,
6.681213e-1, 7.448425e-1, 8.245239e-1, 9.097290e-1
}, new[] {
0.000000e+0, 4.418945e-2, 1.303711e-1, 2.273560e-1,
3.395996e-1, 4.735718e-1, 6.267090e-1, 8.003845e-1
}, new[] {
0.000000e+0, 2.804565e-2, 9.683228e-2, 1.849976e-1,
3.005981e-1, 4.470520e-1, 6.168518e-1, 8.007813e-1
}
};
public static readonly double[][] BexMode0Bands4 =
{
new[] {
0.000000e+0, 2.708740e-1, 3.479614e-1, 3.578186e-1,
5.083618e-1, 5.299072e-1, 5.819092e-1, 6.381836e-1,
7.276917e-1, 7.595520e-1, 7.878723e-1, 9.707336e-1,
9.713135e-1, 9.736023e-1, 9.759827e-1, 9.832458e-1
}, new[] {
0.000000e+0, 2.330627e-1, 5.891418e-1, 7.170410e-1,
2.036438e-1, 1.613464e-1, 6.668701e-1, 9.481201e-1,
9.769897e-1, 5.111694e-1, 3.522644e-1, 8.209534e-1,
2.933960e-1, 9.757690e-1, 5.289917e-1, 4.372253e-1
}, new[] {
0.000000e+0, 4.360962e-2, 1.056519e-1, 1.590576e-1,
2.078857e-1, 2.572937e-1, 3.082581e-1, 3.616028e-1,
4.191589e-1, 4.792175e-1, 5.438538e-1, 6.125183e-1,
6.841125e-1, 7.589417e-1, 8.365173e-1, 9.148254e-1
}, new[] {
0.000000e+0, 4.074097e-2, 1.164551e-1, 2.077026e-1,
3.184509e-1, 4.532166e-1, 6.124268e-1, 7.932129e-1
}, new[] {
0.000000e+0, 8.880615e-3, 2.932739e-2, 5.593872e-2,
8.825684e-2, 1.259155e-1, 1.721497e-1, 2.270813e-1,
2.901611e-1, 3.579712e-1, 4.334106e-1, 5.147095e-1,
6.023254e-1, 6.956177e-1, 7.952881e-1, 8.977356e-1
}
};
public static readonly double[][] BexMode0Bands5 =
{
new[] {
0.000000e+0, 7.379150e-2, 1.806335e-1, 2.687073e-1,
3.407898e-1, 4.047546e-1, 4.621887e-1, 5.168762e-1,
5.703125e-1, 6.237488e-1, 6.763611e-1, 7.288208e-1,
7.808533e-1, 8.337708e-1, 8.874512e-1, 9.418030e-1
}, new[] {
0.000000e+0, 7.980347e-2, 1.615295e-1, 1.665649e-1,
1.822205e-1, 2.185669e-1, 2.292175e-1, 2.456665e-1,
2.666321e-1, 3.306580e-1, 3.330688e-1, 3.765259e-1,
4.085083e-1, 4.400024e-1, 4.407654e-1, 4.817505e-1,
4.924011e-1, 5.320740e-1, 5.893860e-1, 6.131287e-1,
6.212463e-1, 6.278076e-1, 6.308899e-1, 7.660828e-1,
7.850647e-1, 7.910461e-1, 7.929382e-1, 8.038330e-1,
9.834900e-1, 9.846191e-1, 9.852295e-1, 9.862671e-1
}, new[] {
0.000000e+0, 6.084290e-1, 3.672791e-1, 3.151855e-1,
1.488953e-1, 2.571716e-1, 5.103455e-1, 3.311157e-1,
5.426025e-2, 4.254456e-1, 7.998352e-1, 7.873230e-1,
5.418701e-1, 2.925110e-1, 8.468628e-2, 1.410522e-1,
9.819641e-1, 9.609070e-1, 3.530884e-2, 9.729004e-2,
5.758362e-1, 9.941711e-1, 7.215576e-1, 7.183228e-1,
2.028809e-1, 9.588623e-2, 2.032166e-1, 1.338806e-1,
5.003357e-1, 1.874390e-1, 9.804993e-1, 1.107788e-1
}
};
public static readonly double[] BexMode2Scale =
{
4.272461e-4, 1.312256e-3, 2.441406e-3, 3.692627e-3,
4.913330e-3, 6.134033e-3, 7.507324e-3, 8.972168e-3,
1.049805e-2, 1.223755e-2, 1.406860e-2, 1.599121e-2,
1.800537e-2, 2.026367e-2, 2.264404e-2, 2.517700e-2,
2.792358e-2, 3.073120e-2, 3.344727e-2, 3.631592e-2,
3.952026e-2, 4.275513e-2, 4.608154e-2, 4.968262e-2,
5.355835e-2, 5.783081e-2, 6.195068e-2, 6.677246e-2,
7.196045e-2, 7.745361e-2, 8.319092e-2, 8.993530e-2,
9.759521e-2, 1.056213e-1, 1.138916e-1, 1.236267e-1,
1.348267e-1, 1.470337e-1, 1.603394e-1, 1.755676e-1,
1.905823e-1, 2.071228e-1, 2.245178e-1, 2.444153e-1,
2.658997e-1, 2.897644e-1, 3.146057e-1, 3.450012e-1,
3.766174e-1, 4.122620e-1, 4.505615e-1, 4.893799e-1,
5.305481e-1, 5.731201e-1, 6.157837e-1, 6.580811e-1,
6.985168e-1, 7.435303e-1, 7.865906e-1, 8.302612e-1,
8.718567e-1, 9.125671e-1, 9.575806e-1, 9.996643e-1
};
public static readonly double[] BexMode3Initial =
{
3.491211e-1, 5.371094e-1, 6.782227e-1, 7.910156e-1,
9.057617e-1, 1.024902e+0, 1.156250e+0, 1.290527e+0,
1.458984e+0, 1.664551e+0, 1.929688e+0, 2.278320e+0,
2.831543e+0, 3.659180e+0, 5.257813e+0, 8.373047e+0
};
public static readonly double[] BexMode3Rate =
{
-2.913818e-1, -2.541504e-1, -1.664429e-1, -1.476440e-1,
-1.342163e-1, -1.220703e-1, -1.117554e-1, -1.026611e-1,
-9.436035e-2, -8.483887e-2, -7.476807e-2, -6.304932e-2,
-4.492188e-2, -2.447510e-2, +1.831055e-4, +4.174805e-2
};
public static readonly double[] BexMode4Multiplier =
{
3.610229e-2, 1.260681e-1, 2.227478e-1, 3.338318e-1,
4.662170e-1, 6.221313e-1, 7.989197e-1, 9.939575e-1
};
}
}
+141
View File
@@ -0,0 +1,141 @@
#nullable disable
using System;
namespace LibAtrac9
{
internal static class BitAllocation
{
public static void CreateGradient(Block block)
{
int valueCount = block.GradientEndValue - block.GradientStartValue;
int unitCount = block.GradientEndUnit - block.GradientStartUnit;
for (int i = 0; i < block.GradientEndUnit; i++)
{
block.Gradient[i] = block.GradientStartValue;
}
for (int i = block.GradientEndUnit; i <= block.QuantizationUnitCount; i++)
{
block.Gradient[i] = block.GradientEndValue;
}
if (unitCount <= 0) return;
if (valueCount == 0) return;
byte[] curve = Tables.GradientCurves[unitCount - 1];
if (valueCount <= 0)
{
double scale = (-valueCount - 1) / 31.0;
int baseVal = block.GradientStartValue - 1;
for (int i = block.GradientStartUnit; i < block.GradientEndUnit; i++)
{
block.Gradient[i] = baseVal - (int)(curve[i - block.GradientStartUnit] * scale);
}
}
else
{
double scale = (valueCount - 1) / 31.0;
int baseVal = block.GradientStartValue + 1;
for (int i = block.GradientStartUnit; i < block.GradientEndUnit; i++)
{
block.Gradient[i] = baseVal + (int)(curve[i - block.GradientStartUnit] * scale);
}
}
}
public static void CalculateMask(Channel channel)
{
Array.Clear(channel.PrecisionMask, 0, channel.PrecisionMask.Length);
for (int i = 1; i < channel.Block.QuantizationUnitCount; i++)
{
int delta = channel.ScaleFactors[i] - channel.ScaleFactors[i - 1];
if (delta > 1)
{
channel.PrecisionMask[i] += Math.Min(delta - 1, 5);
}
else if (delta < -1)
{
channel.PrecisionMask[i - 1] += Math.Min(delta * -1 - 1, 5);
}
}
}
public static void CalculatePrecisions(Channel channel)
{
Block block = channel.Block;
if (block.GradientMode != 0)
{
for (int i = 0; i < block.QuantizationUnitCount; i++)
{
channel.Precisions[i] = channel.ScaleFactors[i] + channel.PrecisionMask[i] - block.Gradient[i];
if (channel.Precisions[i] > 0)
{
switch (block.GradientMode)
{
case 1:
channel.Precisions[i] /= 2;
break;
case 2:
channel.Precisions[i] = 3 * channel.Precisions[i] / 8;
break;
case 3:
channel.Precisions[i] /= 4;
break;
}
}
}
}
else
{
for (int i = 0; i < block.QuantizationUnitCount; i++)
{
channel.Precisions[i] = channel.ScaleFactors[i] - block.Gradient[i];
}
}
for (int i = 0; i < block.QuantizationUnitCount; i++)
{
if (channel.Precisions[i] < 1)
{
channel.Precisions[i] = 1;
}
}
for (int i = 0; i < block.GradientBoundary; i++)
{
channel.Precisions[i]++;
}
for (int i = 0; i < block.QuantizationUnitCount; i++)
{
channel.PrecisionsFine[i] = 0;
if (channel.Precisions[i] > 15)
{
channel.PrecisionsFine[i] = channel.Precisions[i] - 15;
channel.Precisions[i] = 15;
}
}
}
public static byte[][] GenerateGradientCurves()
{
byte[] main =
{
01, 01, 01, 01, 02, 02, 02, 02, 03, 03, 03, 04, 04, 05, 05, 06, 07, 08, 09, 10, 11, 12, 13, 15,
16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 27, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30
};
var curves = new byte[main.Length][];
for (int length = 1; length <= main.Length; length++)
{
curves[length - 1] = new byte[length];
for (int i = 0; i < length; i++)
{
curves[length - 1][i] = main[i * main.Length / length];
}
}
return curves;
}
}
}
+92
View File
@@ -0,0 +1,92 @@
#nullable disable
namespace LibAtrac9
{
internal class Block
{
public Atrac9Config Config { get; }
public BlockType BlockType { get; }
public int BlockIndex { get; }
public Frame Frame { get; }
public Channel[] Channels { get; }
public int ChannelCount { get; }
public bool FirstInSuperframe { get; set; }
public bool ReuseBandParams { get; set; }
public int BandCount { get; set; }
public int StereoBand { get; set; }
public int ExtensionBand { get; set; }
public int QuantizationUnitCount { get; set; }
public int StereoQuantizationUnit { get; set; }
public int ExtensionUnit { get; set; }
public int QuantizationUnitsPrev { get; set; }
public int[] Gradient { get; } = new int[31];
public int GradientMode { get; set; }
public int GradientStartUnit { get; set; }
public int GradientStartValue { get; set; }
public int GradientEndUnit { get; set; }
public int GradientEndValue { get; set; }
public int GradientBoundary { get; set; }
public int PrimaryChannelIndex { get; set; }
public int[] JointStereoSigns { get; } = new int[30];
public bool HasJointStereoSigns { get; set; }
public Channel PrimaryChannel => Channels[PrimaryChannelIndex == 0 ? 0 : 1];
public Channel SecondaryChannel => Channels[PrimaryChannelIndex == 0 ? 1 : 0];
public bool BandExtensionEnabled { get; set; }
public bool HasExtensionData { get; set; }
public int BexDataLength { get; set; }
public int BexMode { get; set; }
public Block(Frame parentFrame, int blockIndex)
{
Frame = parentFrame;
BlockIndex = blockIndex;
Config = parentFrame.Config;
BlockType = Config.ChannelConfig.BlockTypes[blockIndex];
ChannelCount = BlockTypeToChannelCount(BlockType);
Channels = new Channel[ChannelCount];
for (int i = 0; i < ChannelCount; i++)
{
Channels[i] = new Channel(this, i);
}
}
public static int BlockTypeToChannelCount(BlockType blockType)
{
switch (blockType)
{
case BlockType.Mono:
return 1;
case BlockType.Stereo:
return 2;
case BlockType.LFE:
return 1;
default:
return 0;
}
}
}
/// <summary>
/// An ATRAC9 block (substream) type
/// </summary>
public enum BlockType
{
/// <summary>
/// Mono ATRAC9 block
/// </summary>
Mono = 0,
/// <summary>
/// Stereo ATRAC9 block
/// </summary>
Stereo = 1,
/// <summary>
/// Low-frequency effects ATRAC9 block
/// </summary>
LFE = 2
}
}
+49
View File
@@ -0,0 +1,49 @@
#nullable disable
using LibAtrac9.Utilities;
namespace LibAtrac9
{
internal class Channel
{
public Atrac9Config Config { get; }
public int ChannelIndex { get; }
public bool IsPrimary => Block.PrimaryChannelIndex == ChannelIndex;
public Block Block { get; }
public Mdct Mdct { get; }
public double[] Pcm { get; } = new double[256];
public double[] Spectra { get; } = new double[256];
public int CodedQuantUnits { get; set; }
public int ScaleFactorCodingMode { get; set; }
public int[] ScaleFactors { get; } = new int[31];
public int[] ScaleFactorsPrev { get; } = new int[31];
public int[] Precisions { get; } = new int[30];
public int[] PrecisionsFine { get; } = new int[30];
public int[] PrecisionMask { get; } = new int[30];
public int[] SpectraValuesBuffer { get; } = new int[16];
public int[] CodebookSet { get; } = new int[30];
public int[] QuantizedSpectra { get; } = new int[256];
public int[] QuantizedSpectraFine { get; } = new int[256];
public int BexMode { get; set; }
public int BexValueCount { get; set; }
public int[] BexValues { get; } = new int[4];
public double[] BexScales { get; } = new double[6];
public Atrac9Rng Rng { get; set; }
public Channel(Block parentBlock, int channelIndex)
{
Block = parentBlock;
ChannelIndex = channelIndex;
Config = parentBlock.Config;
Mdct = new Mdct(Config.FrameSamplesPower, Tables.ImdctWindow[Config.FrameSamplesPower - 6]);
}
public void UpdateCodedUnits() =>
CodedQuantUnits = IsPrimary ? Block.QuantizationUnitCount : Block.StereoQuantizationUnit;
}
}
+34
View File
@@ -0,0 +1,34 @@
#nullable disable
namespace LibAtrac9
{
/// <summary>
/// Describes the channel mapping for an ATRAC9 stream
/// </summary>
public class ChannelConfig
{
internal ChannelConfig(params BlockType[] blockTypes)
{
BlockCount = blockTypes.Length;
BlockTypes = blockTypes;
foreach (BlockType type in blockTypes)
{
ChannelCount += Block.BlockTypeToChannelCount(type);
}
}
/// <summary>
/// The number of blocks or substreams in the ATRAC9 stream
/// </summary>
public int BlockCount { get; }
/// <summary>
/// The type of each block or substream in the ATRAC9 stream
/// </summary>
public BlockType[] BlockTypes { get; }
/// <summary>
/// The number of channels in the ATRAC9 stream
/// </summary>
public int ChannelCount { get; }
}
}
+21
View File
@@ -0,0 +1,21 @@
#nullable disable
namespace LibAtrac9
{
internal class Frame
{
public Atrac9Config Config { get; }
public int FrameIndex { get; set; }
public Block[] Blocks { get; }
public Frame(Atrac9Config config)
{
Config = config;
Blocks = new Block[config.ChannelConfig.BlockCount];
for (int i = 0; i < config.ChannelConfig.BlockCount; i++)
{
Blocks[i] = new Block(this, i);
}
}
}
}
@@ -0,0 +1,63 @@
#nullable disable
using System;
using LibAtrac9.Utilities;
namespace LibAtrac9
{
internal class HuffmanCodebook
{
public HuffmanCodebook(short[] codes, byte[] bits, byte valueCountPower)
{
Codes = codes;
Bits = bits;
if (Codes == null || Bits == null) return;
ValueCount = 1 << valueCountPower;
ValueCountPower = valueCountPower;
ValueBits = Helpers.Log2(codes.Length) >> valueCountPower;
ValueMax = 1 << ValueBits;
int max = 0;
foreach (byte bitSize in bits)
{
max = Math.Max(max, bitSize);
}
MaxBitSize = max;
Lookup = CreateLookupTable();
}
private byte[] CreateLookupTable()
{
if (Codes == null || Bits == null) return null;
int tableSize = 1 << MaxBitSize;
var dest = new byte[tableSize];
for (int i = 0; i < Bits.Length; i++)
{
if (Bits[i] == 0) continue;
int unusedBits = MaxBitSize - Bits[i];
int start = Codes[i] << unusedBits;
int length = 1 << unusedBits;
int end = start + length;
for (int j = start; j < end; j++)
{
dest[j] = (byte)i;
}
}
return dest;
}
public short[] Codes { get; }
public byte[] Bits { get; }
public byte[] Lookup { get; }
public int ValueCount { get; }
public int ValueCountPower { get; }
public int ValueBits { get; }
public int ValueMax { get; }
public int MaxBitSize { get; }
}
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Alex Barney
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
#nullable disable
using System;
namespace LibAtrac9
{
internal static class Quantization
{
public static void DequantizeSpectra(Block block)
{
foreach (Channel channel in block.Channels)
{
Array.Clear(channel.Spectra, 0, channel.Spectra.Length);
for (int i = 0; i < channel.CodedQuantUnits; i++)
{
DequantizeQuantUnit(channel, i);
}
}
}
private static void DequantizeQuantUnit(Channel channel, int band)
{
int subBandIndex = Tables.QuantUnitToCoeffIndex[band];
int subBandCount = Tables.QuantUnitToCoeffCount[band];
double stepSize = Tables.QuantizerStepSize[channel.Precisions[band]];
double stepSizeFine = Tables.QuantizerFineStepSize[channel.PrecisionsFine[band]];
for (int sb = 0; sb < subBandCount; sb++)
{
double coarse = channel.QuantizedSpectra[subBandIndex + sb] * stepSize;
double fine = channel.QuantizedSpectraFine[subBandIndex + sb] * stepSizeFine;
channel.Spectra[subBandIndex + sb] = coarse + fine;
}
}
public static void ScaleSpectrum(Block block)
{
foreach (Channel channel in block.Channels)
{
ScaleSpectrum(channel);
}
}
private static void ScaleSpectrum(Channel channel)
{
int quantUnitCount = channel.Block.QuantizationUnitCount;
double[] spectra = channel.Spectra;
for (int i = 0; i < quantUnitCount; i++)
{
for (int sb = Tables.QuantUnitToCoeffIndex[i]; sb < Tables.QuantUnitToCoeffIndex[i + 1]; sb++)
{
spectra[sb] *= Tables.SpectrumScale[channel.ScaleFactors[i]];
}
}
}
}
}
+172
View File
@@ -0,0 +1,172 @@
#nullable disable
using System;
using System.IO;
using LibAtrac9.Utilities;
namespace LibAtrac9
{
internal static class ScaleFactors
{
public static void Read(BitReader reader, Channel channel)
{
Array.Clear(channel.ScaleFactors, 0, channel.ScaleFactors.Length);
channel.ScaleFactorCodingMode = reader.ReadInt(2);
if (channel.ChannelIndex == 0)
{
switch (channel.ScaleFactorCodingMode)
{
case 0:
ReadVlcDeltaOffset(reader, channel);
break;
case 1:
ReadClcOffset(reader, channel);
break;
case 2:
if (channel.Block.FirstInSuperframe) throw new InvalidDataException();
ReadVlcDistanceToBaseline(reader, channel, channel.ScaleFactorsPrev, channel.Block.QuantizationUnitsPrev);
break;
case 3:
if (channel.Block.FirstInSuperframe) throw new InvalidDataException();
ReadVlcDeltaOffsetWithBaseline(reader, channel, channel.ScaleFactorsPrev, channel.Block.QuantizationUnitsPrev);
break;
}
}
else
{
switch (channel.ScaleFactorCodingMode)
{
case 0:
ReadVlcDeltaOffset(reader, channel);
break;
case 1:
ReadVlcDistanceToBaseline(reader, channel, channel.Block.Channels[0].ScaleFactors, channel.Block.ExtensionUnit);
break;
case 2:
ReadVlcDeltaOffsetWithBaseline(reader, channel, channel.Block.Channels[0].ScaleFactors, channel.Block.ExtensionUnit);
break;
case 3:
if (channel.Block.FirstInSuperframe) throw new InvalidDataException();
ReadVlcDistanceToBaseline(reader, channel, channel.ScaleFactorsPrev, channel.Block.QuantizationUnitsPrev);
break;
}
}
for (int i = 0; i < channel.Block.ExtensionUnit; i++)
{
if (channel.ScaleFactors[i] < 0 || channel.ScaleFactors[i] > 31)
{
throw new InvalidDataException("Scale factor values are out of range.");
}
}
Array.Copy(channel.ScaleFactors, channel.ScaleFactorsPrev, channel.ScaleFactors.Length);
}
private static void ReadClcOffset(BitReader reader, Channel channel)
{
const int maxBits = 5;
int[] sf = channel.ScaleFactors;
int bitLength = reader.ReadInt(2) + 2;
int baseValue = bitLength < maxBits ? reader.ReadInt(maxBits) : 0;
for (int i = 0; i < channel.Block.ExtensionUnit; i++)
{
sf[i] = reader.ReadInt(bitLength) + baseValue;
}
}
private static void ReadVlcDeltaOffset(BitReader reader, Channel channel)
{
int weightIndex = reader.ReadInt(3);
byte[] weights = ScaleFactorWeights[weightIndex];
int[] sf = channel.ScaleFactors;
int baseValue = reader.ReadInt(5);
int bitLength = reader.ReadInt(2) + 3;
HuffmanCodebook codebook = Tables.HuffmanScaleFactorsUnsigned[bitLength];
sf[0] = reader.ReadInt(bitLength);
for (int i = 1; i < channel.Block.ExtensionUnit; i++)
{
int delta = Unpack.ReadHuffmanValue(codebook, reader);
sf[i] = (sf[i - 1] + delta) & (codebook.ValueMax - 1);
}
for (int i = 0; i < channel.Block.ExtensionUnit; i++)
{
sf[i] += baseValue - weights[i];
}
}
private static void ReadVlcDistanceToBaseline(BitReader reader, Channel channel, int[] baseline, int baselineLength)
{
int[] sf = channel.ScaleFactors;
int bitLength = reader.ReadInt(2) + 2;
HuffmanCodebook codebook = Tables.HuffmanScaleFactorsSigned[bitLength];
int unitCount = Math.Min(channel.Block.ExtensionUnit, baselineLength);
for (int i = 0; i < unitCount; i++)
{
int distance = Unpack.ReadHuffmanValue(codebook, reader, true);
sf[i] = (baseline[i] + distance) & 31;
}
for (int i = unitCount; i < channel.Block.ExtensionUnit; i++)
{
sf[i] = reader.ReadInt(5);
}
}
private static void ReadVlcDeltaOffsetWithBaseline(BitReader reader, Channel channel, int[] baseline, int baselineLength)
{
int[] sf = channel.ScaleFactors;
int baseValue = reader.ReadOffsetBinary(5, BitReader.OffsetBias.Negative);
int bitLength = reader.ReadInt(2) + 1;
HuffmanCodebook codebook = Tables.HuffmanScaleFactorsUnsigned[bitLength];
int unitCount = Math.Min(channel.Block.ExtensionUnit, baselineLength);
sf[0] = reader.ReadInt(bitLength);
for (int i = 1; i < unitCount; i++)
{
int delta = Unpack.ReadHuffmanValue(codebook, reader);
sf[i] = (sf[i - 1] + delta) & (codebook.ValueMax - 1);
}
for (int i = 0; i < unitCount; i++)
{
sf[i] += baseValue + baseline[i];
}
for (int i = unitCount; i < channel.Block.ExtensionUnit; i++)
{
sf[i] = reader.ReadInt(5);
}
}
public static readonly byte[][] ScaleFactorWeights =
{
new byte[] {
0, 0, 0, 1, 1, 2, 2, 2, 2, 2, 2, 3, 2, 3, 3, 4, 4, 4, 4, 4, 4, 5, 5, 6, 6, 7, 7, 8, 10, 12, 12, 12
}, new byte[] {
3, 2, 2, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 7, 10, 10, 10
}, new byte[] {
0, 2, 4, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 8, 9, 12, 12, 12
}, new byte[] {
0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 6, 7, 8, 8, 10, 11, 11, 12, 13, 13, 13, 13
}, new byte[] {
0, 2, 2, 3, 3, 4, 4, 5, 4, 5, 5, 5, 5, 6, 7, 8, 8, 8, 8, 9, 9, 9, 10, 10, 11, 12, 12, 13, 13, 14, 14, 14
}, new byte[] {
1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 4, 4, 5, 6, 7, 7, 9, 11, 11, 11
}, new byte[] {
0, 5, 8, 10, 11, 11, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 12,
12, 13, 15, 15, 15
}, new byte[] {
0, 2, 3, 4, 5, 6, 6, 7, 7, 8, 8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 13, 13,
15, 15, 15
}
};
}
}
+34
View File
@@ -0,0 +1,34 @@
#nullable disable
namespace LibAtrac9
{
internal static class Stereo
{
public static void ApplyIntensityStereo(Block block)
{
if (block.BlockType != BlockType.Stereo) return;
int totalUnits = block.QuantizationUnitCount;
int stereoUnits = block.StereoQuantizationUnit;
if (stereoUnits >= totalUnits) return;
Channel source = block.PrimaryChannel;
Channel dest = block.SecondaryChannel;
for (int i = stereoUnits; i < totalUnits; i++)
{
int sign = block.JointStereoSigns[i];
for (int sb = Tables.QuantUnitToCoeffIndex[i]; sb < Tables.QuantUnitToCoeffIndex[i + 1]; sb++)
{
if (sign > 0)
{
dest.Spectra[sb] = -source.Spectra[sb];
}
else
{
dest.Spectra[sb] = source.Spectra[sb];
}
}
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
#nullable disable
using System;
using static LibAtrac9.HuffmanCodebooks;
namespace LibAtrac9
{
internal static class Tables
{
public static int MaxHuffPrecision(bool highSampleRate) => highSampleRate ? 1 : 7;
public static int MinBandCount(bool highSampleRate) => highSampleRate ? 1 : 3;
public static int MaxExtensionBand(bool highSampleRate) => highSampleRate ? 16 : 18;
public static readonly int[] SampleRates =
{
11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000,
44100, 48000, 64000, 88200, 96000, 128000, 176400, 192000
};
public static readonly byte[] SamplingRateIndexToFrameSamplesPower = { 6, 6, 7, 7, 7, 8, 8, 8, 6, 6, 7, 7, 7, 8, 8, 8 };
// From sampling rate index
public static readonly byte[] MaxBandCount = { 8, 8, 12, 12, 12, 18, 18, 18, 8, 8, 12, 12, 12, 16, 16, 16 };
public static readonly byte[] BandToQuantUnitCount = { 0, 4, 8, 10, 12, 13, 14, 15, 16, 18, 20, 21, 22, 23, 24, 25, 26, 28, 30 };
public static readonly byte[] QuantUnitToCoeffCount =
{
02, 02, 02, 02, 02, 02, 02, 02, 04, 04, 04, 04, 08, 08, 08,
08, 08, 08, 08, 08, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16
};
public static readonly short[] QuantUnitToCoeffIndex =
{
0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48, 56,
64, 72, 80, 88, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256
};
public static readonly byte[] QuantUnitToCodebookIndex =
{
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2,
2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3
};
public static readonly ChannelConfig[] ChannelConfig =
{
new ChannelConfig(BlockType.Mono),
new ChannelConfig(BlockType.Mono, BlockType.Mono),
new ChannelConfig(BlockType.Stereo),
new ChannelConfig(BlockType.Stereo, BlockType.Mono, BlockType.LFE, BlockType.Stereo),
new ChannelConfig(BlockType.Stereo, BlockType.Mono, BlockType.LFE, BlockType.Stereo, BlockType.Stereo),
new ChannelConfig(BlockType.Stereo, BlockType.Stereo)
};
public static readonly HuffmanCodebook[] HuffmanScaleFactorsUnsigned =
GenerateHuffmanCodebooks(HuffmanScaleFactorsACodes, HuffmanScaleFactorsABits, HuffmanScaleFactorsGroupSizes);
public static readonly HuffmanCodebook[] HuffmanScaleFactorsSigned =
GenerateHuffmanCodebooks(HuffmanScaleFactorsBCodes, HuffmanScaleFactorsBBits, HuffmanScaleFactorsGroupSizes);
public static readonly HuffmanCodebook[][][] HuffmanSpectrum =
{
GenerateHuffmanCodebooks(HuffmanSpectrumACodes, HuffmanSpectrumABits, HuffmanSpectrumAGroupSizes),
GenerateHuffmanCodebooks(HuffmanSpectrumBCodes, HuffmanSpectrumBBits, HuffmanSpectrumBGroupSizes)
};
public static readonly double[][] ImdctWindow = { GenerateImdctWindow(6), GenerateImdctWindow(7), GenerateImdctWindow(8) };
public static readonly double[] SpectrumScale = Generate(32, SpectrumScaleFunction);
public static readonly double[] QuantizerStepSize = Generate(16, QuantizerStepSizeFunction);
public static readonly double[] QuantizerFineStepSize = Generate(16, QuantizerFineStepSizeFunction);
public static readonly byte[][] GradientCurves = BitAllocation.GenerateGradientCurves();
private static double QuantizerStepSizeFunction(int x) => 2.0 / ((1 << (x + 1)) - 1);
private static double QuantizerFineStepSizeFunction(int x) => QuantizerStepSizeFunction(x) / ushort.MaxValue;
private static double SpectrumScaleFunction(int x) => Math.Pow(2, x - 15);
private static double[] GenerateImdctWindow(int frameSizePower)
{
int frameSize = 1 << frameSizePower;
var output = new double[frameSize];
double[] a1 = GenerateMdctWindow(frameSizePower);
for (int i = 0; i < frameSize; i++)
{
output[i] = a1[i] / (a1[frameSize - 1 - i] * a1[frameSize - 1 - i] + a1[i] * a1[i]);
}
return output;
}
private static double[] GenerateMdctWindow(int frameSizePower)
{
int frameSize = 1 << frameSizePower;
var output = new double[frameSize];
for (int i = 0; i < frameSize; i++)
{
output[i] = (Math.Sin(((i + 0.5) / frameSize - 0.5) * Math.PI) + 1.0) * 0.5;
}
return output;
}
private static T[] Generate<T>(int count, Func<int, T> elementGenerator)
{
var table = new T[count];
for (int i = 0; i < count; i++)
{
table[i] = elementGenerator(i);
}
return table;
}
}
}
+426
View File
@@ -0,0 +1,426 @@
#nullable disable
using System;
using System.IO;
using LibAtrac9.Utilities;
namespace LibAtrac9
{
internal static class Unpack
{
public static void UnpackFrame(BitReader reader, Frame frame)
{
foreach (Block block in frame.Blocks)
{
UnpackBlock(reader, block);
}
}
private static void UnpackBlock(BitReader reader, Block block)
{
ReadBlockHeader(reader, block);
if (block.BlockType == BlockType.LFE)
{
UnpackLfeBlock(reader, block);
}
else
{
UnpackStandardBlock(reader, block);
}
reader.AlignPosition(8);
}
private static void ReadBlockHeader(BitReader reader, Block block)
{
bool firstInSuperframe = block.Frame.FrameIndex == 0;
block.FirstInSuperframe = !reader.ReadBool();
block.ReuseBandParams = reader.ReadBool();
if (block.FirstInSuperframe != firstInSuperframe)
{
throw new InvalidDataException();
}
if (firstInSuperframe && block.ReuseBandParams && block.BlockType != BlockType.LFE)
{
throw new InvalidDataException();
}
}
private static void UnpackStandardBlock(BitReader reader, Block block)
{
Channel[] channels = block.Channels;
if (!block.ReuseBandParams)
{
ReadBandParams(reader, block);
}
ReadGradientParams(reader, block);
BitAllocation.CreateGradient(block);
ReadStereoParams(reader, block);
ReadExtensionParams(reader, block);
foreach (Channel channel in channels)
{
channel.UpdateCodedUnits();
ScaleFactors.Read(reader, channel);
BitAllocation.CalculateMask(channel);
BitAllocation.CalculatePrecisions(channel);
CalculateSpectrumCodebookIndex(channel);
ReadSpectra(reader, channel);
ReadSpectraFine(reader, channel);
}
block.QuantizationUnitsPrev = block.BandExtensionEnabled ? block.ExtensionUnit : block.QuantizationUnitCount;
}
private static void ReadBandParams(BitReader reader, Block block)
{
int minBandCount = Tables.MinBandCount(block.Config.HighSampleRate);
int maxExtensionBand = Tables.MaxExtensionBand(block.Config.HighSampleRate);
block.BandCount = reader.ReadInt(4);
block.BandCount += minBandCount;
block.QuantizationUnitCount = Tables.BandToQuantUnitCount[block.BandCount];
if (block.BandCount < minBandCount || block.BandCount >
Tables.MaxBandCount[block.Config.SampleRateIndex])
{
return;
}
if (block.BlockType == BlockType.Stereo)
{
block.StereoBand = reader.ReadInt(4);
block.StereoBand += minBandCount;
block.StereoQuantizationUnit = Tables.BandToQuantUnitCount[block.StereoBand];
}
else
{
block.StereoBand = block.BandCount;
}
block.BandExtensionEnabled = reader.ReadBool();
if (block.BandExtensionEnabled)
{
block.ExtensionBand = reader.ReadInt(4);
block.ExtensionBand += minBandCount;
if (block.ExtensionBand < block.BandCount || block.ExtensionBand > maxExtensionBand)
{
throw new InvalidDataException();
}
block.ExtensionUnit = Tables.BandToQuantUnitCount[block.ExtensionBand];
}
else
{
block.ExtensionBand = block.BandCount;
block.ExtensionUnit = block.QuantizationUnitCount;
}
}
private static void ReadGradientParams(BitReader reader, Block block)
{
block.GradientMode = reader.ReadInt(2);
if (block.GradientMode > 0)
{
block.GradientEndUnit = 31;
block.GradientEndValue = 31;
block.GradientStartUnit = reader.ReadInt(5);
block.GradientStartValue = reader.ReadInt(5);
}
else
{
block.GradientStartUnit = reader.ReadInt(6);
block.GradientEndUnit = reader.ReadInt(6) + 1;
block.GradientStartValue = reader.ReadInt(5);
block.GradientEndValue = reader.ReadInt(5);
}
block.GradientBoundary = reader.ReadInt(4);
if (block.GradientBoundary > block.QuantizationUnitCount)
{
throw new InvalidDataException();
}
if (block.GradientStartUnit < 1 || block.GradientStartUnit >= 48)
{
throw new InvalidDataException();
}
if (block.GradientEndUnit < 1 || block.GradientEndUnit >= 48)
{
throw new InvalidDataException();
}
if (block.GradientStartUnit > block.GradientEndUnit)
{
throw new InvalidDataException();
}
if (block.GradientStartValue < 0 || block.GradientStartValue >= 32)
{
throw new InvalidDataException();
}
if (block.GradientEndValue < 0 || block.GradientEndValue >= 32)
{
throw new InvalidDataException();
}
}
private static void ReadStereoParams(BitReader reader, Block block)
{
if (block.BlockType != BlockType.Stereo) return;
block.PrimaryChannelIndex = reader.ReadInt(1);
block.HasJointStereoSigns = reader.ReadBool();
if (block.HasJointStereoSigns)
{
for (int i = block.StereoQuantizationUnit; i < block.QuantizationUnitCount; i++)
{
block.JointStereoSigns[i] = reader.ReadInt(1);
}
}
else
{
Array.Clear(block.JointStereoSigns, 0, block.JointStereoSigns.Length);
}
}
private static void ReadExtensionParams(BitReader reader, Block block)
{
// ReSharper disable once RedundantAssignment
int bexBand = 0;
if (block.BandExtensionEnabled)
{
BandExtension.GetBexBandInfo(out bexBand, out _, out _, block.QuantizationUnitCount);
if (block.BlockType == BlockType.Stereo)
{
ReadHeader(block.Channels[1]);
}
else
{
reader.Position += 1;
}
}
block.HasExtensionData = reader.ReadBool();
if (!block.HasExtensionData) return;
if (!block.BandExtensionEnabled)
{
block.BexMode = reader.ReadInt(2);
block.BexDataLength = reader.ReadInt(5);
reader.Position += block.BexDataLength;
return;
}
ReadHeader(block.Channels[0]);
block.BexDataLength = reader.ReadInt(5);
if (block.BexDataLength <= 0) return;
int bexDataEnd = reader.Position + block.BexDataLength;
ReadData(block.Channels[0]);
if (block.BlockType == BlockType.Stereo)
{
ReadData(block.Channels[1]);
}
// Make sure we didn't read too many bits
if (reader.Position > bexDataEnd)
{
throw new InvalidDataException();
}
void ReadHeader(Channel channel)
{
int bexMode = reader.ReadInt(2);
channel.BexMode = bexBand > 2 ? bexMode : 4;
channel.BexValueCount = BandExtension.BexEncodedValueCounts[channel.BexMode][bexBand];
}
void ReadData(Channel channel)
{
for (int i = 0; i < channel.BexValueCount; i++)
{
int dataLength = BandExtension.BexDataLengths[channel.BexMode][bexBand][i];
channel.BexValues[i] = reader.ReadInt(dataLength);
}
}
}
private static void CalculateSpectrumCodebookIndex(Channel channel)
{
Array.Clear(channel.CodebookSet, 0, channel.CodebookSet.Length);
int quantUnits = channel.CodedQuantUnits;
int[] sf = channel.ScaleFactors;
if (quantUnits <= 1) return;
if (channel.Config.HighSampleRate) return;
// Temporarily setting this value allows for simpler code by
// making the last value a non-special case.
int originalScaleTmp = sf[quantUnits];
sf[quantUnits] = sf[quantUnits - 1];
int avg = 0;
if (quantUnits > 12)
{
for (int i = 0; i < 12; i++)
{
avg += sf[i];
}
avg = (avg + 6) / 12;
}
for (int i = 8; i < quantUnits; i++)
{
int prevSf = sf[i - 1];
int nextSf = sf[i + 1];
int minSf = Math.Min(prevSf, nextSf);
if (sf[i] - minSf >= 3 || sf[i] - prevSf + sf[i] - nextSf >= 3)
{
channel.CodebookSet[i] = 1;
}
}
for (int i = 12; i < quantUnits; i++)
{
if (channel.CodebookSet[i] == 0)
{
int minSf = Math.Min(sf[i - 1], sf[i + 1]);
if (sf[i] - minSf >= 2 && sf[i] >= avg - (Tables.QuantUnitToCoeffCount[i] == 16 ? 1 : 0))
{
channel.CodebookSet[i] = 1;
}
}
}
sf[quantUnits] = originalScaleTmp;
}
private static void ReadSpectra(BitReader reader, Channel channel)
{
int[] values = channel.SpectraValuesBuffer;
Array.Clear(channel.QuantizedSpectra, 0, channel.QuantizedSpectra.Length);
int maxHuffPrecision = Tables.MaxHuffPrecision(channel.Config.HighSampleRate);
for (int i = 0; i < channel.CodedQuantUnits; i++)
{
int subbandCount = Tables.QuantUnitToCoeffCount[i];
int precision = channel.Precisions[i] + 1;
if (precision <= maxHuffPrecision)
{
HuffmanCodebook huff = Tables.HuffmanSpectrum[channel.CodebookSet[i]][precision][Tables.QuantUnitToCodebookIndex[i]];
int groupCount = subbandCount >> huff.ValueCountPower;
for (int j = 0; j < groupCount; j++)
{
values[j] = ReadHuffmanValue(huff, reader);
}
DecodeHuffmanValues(channel.QuantizedSpectra, Tables.QuantUnitToCoeffIndex[i], subbandCount, huff, values);
}
else
{
int subbandIndex = Tables.QuantUnitToCoeffIndex[i];
for (int j = subbandIndex; j < Tables.QuantUnitToCoeffIndex[i + 1]; j++)
{
channel.QuantizedSpectra[j] = reader.ReadSignedInt(precision);
}
}
}
}
private static void ReadSpectraFine(BitReader reader, Channel channel)
{
Array.Clear(channel.QuantizedSpectraFine, 0, channel.QuantizedSpectraFine.Length);
for (int i = 0; i < channel.CodedQuantUnits; i++)
{
if (channel.PrecisionsFine[i] > 0)
{
int overflowBits = channel.PrecisionsFine[i] + 1;
int startSubband = Tables.QuantUnitToCoeffIndex[i];
int endSubband = Tables.QuantUnitToCoeffIndex[i + 1];
for (int j = startSubband; j < endSubband; j++)
{
channel.QuantizedSpectraFine[j] = reader.ReadSignedInt(overflowBits);
}
}
}
}
private static void DecodeHuffmanValues(int[] spectrum, int index, int bandCount, HuffmanCodebook huff, int[] values)
{
int valueCount = bandCount >> huff.ValueCountPower;
int mask = (1 << huff.ValueBits) - 1;
for (int i = 0; i < valueCount; i++)
{
int value = values[i];
for (int j = 0; j < huff.ValueCount; j++)
{
spectrum[index++] = Bit.SignExtend32(value & mask, huff.ValueBits);
value >>= huff.ValueBits;
}
}
}
public static int ReadHuffmanValue(HuffmanCodebook huff, BitReader reader, bool signed = false)
{
int code = reader.PeekInt(huff.MaxBitSize);
byte value = huff.Lookup[code];
int bits = huff.Bits[value];
reader.Position += bits;
return signed ? Bit.SignExtend32(value, huff.ValueBits) : value;
}
private static void UnpackLfeBlock(BitReader reader, Block block)
{
Channel channel = block.Channels[0];
block.QuantizationUnitCount = 2;
DecodeLfeScaleFactors(reader, channel);
CalculateLfePrecision(channel);
channel.CodedQuantUnits = block.QuantizationUnitCount;
ReadLfeSpectra(reader, channel);
}
private static void DecodeLfeScaleFactors(BitReader reader, Channel channel)
{
Array.Clear(channel.ScaleFactors, 0, channel.ScaleFactors.Length);
for (int i = 0; i < channel.Block.QuantizationUnitCount; i++)
{
channel.ScaleFactors[i] = reader.ReadInt(5);
}
}
private static void CalculateLfePrecision(Channel channel)
{
Block block = channel.Block;
int precision = block.ReuseBandParams ? 8 : 4;
for (int i = 0; i < block.QuantizationUnitCount; i++)
{
channel.Precisions[i] = precision;
channel.PrecisionsFine[i] = 0;
}
}
private static void ReadLfeSpectra(BitReader reader, Channel channel)
{
Array.Clear(channel.QuantizedSpectra, 0, channel.QuantizedSpectra.Length);
for (int i = 0; i < channel.CodedQuantUnits; i++)
{
if (channel.Precisions[i] <= 0) continue;
int precision = channel.Precisions[i] + 1;
for (int j = Tables.QuantUnitToCoeffIndex[i]; j < Tables.QuantUnitToCoeffIndex[i + 1]; j++)
{
channel.QuantizedSpectra[j] = reader.ReadSignedInt(precision);
}
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
#nullable disable
namespace LibAtrac9.Utilities
{
internal static class Bit
{
private static uint BitReverse32(uint value)
{
value = ((value & 0xaaaaaaaa) >> 1) | ((value & 0x55555555) << 1);
value = ((value & 0xcccccccc) >> 2) | ((value & 0x33333333) << 2);
value = ((value & 0xf0f0f0f0) >> 4) | ((value & 0x0f0f0f0f) << 4);
value = ((value & 0xff00ff00) >> 8) | ((value & 0x00ff00ff) << 8);
return (value >> 16) | (value << 16);
}
private static uint BitReverse32(uint value, int bitCount) => BitReverse32(value) >> (32 - bitCount);
public static int BitReverse32(int value, int bitCount) => (int) BitReverse32((uint) value, bitCount);
public static int SignExtend32(int value, int bits)
{
int shift = 8 * sizeof(int) - bits;
return (value << shift) >> shift;
}
}
}
@@ -0,0 +1,133 @@
#nullable disable
using System;
using System.Diagnostics;
namespace LibAtrac9.Utilities
{
internal class BitReader
{
private byte[] Buffer { get; set; }
private int LengthBits { get; set; }
public int Position { get; set; }
private int Remaining => LengthBits - Position;
public BitReader(byte[] buffer) => SetBuffer(buffer);
public void SetBuffer(byte[] buffer)
{
Buffer = buffer;
LengthBits = Buffer?.Length * 8 ?? 0;
Position = 0;
}
public int ReadInt(int bitCount)
{
int value = PeekInt(bitCount);
Position += bitCount;
return value;
}
public int ReadSignedInt(int bitCount)
{
int value = PeekInt(bitCount);
Position += bitCount;
return Bit.SignExtend32(value, bitCount);
}
public bool ReadBool() => ReadInt(1) == 1;
public int ReadOffsetBinary(int bitCount, OffsetBias bias)
{
int offset = (1 << (bitCount - 1)) - (int)bias;
int value = PeekInt(bitCount) - offset;
Position += bitCount;
return value;
}
public void AlignPosition(int multiple)
{
Position = Helpers.GetNextMultiple(Position, multiple);
}
public int PeekInt(int bitCount)
{
Debug.Assert(bitCount >= 0 && bitCount <= 32);
if (bitCount > Remaining)
{
if (Position >= LengthBits) return 0;
int extraBits = bitCount - Remaining;
return PeekIntFallback(Remaining) << extraBits;
}
int byteIndex = Position / 8;
int bitIndex = Position % 8;
if (bitCount <= 9 && Remaining >= 16)
{
int value = Buffer[byteIndex] << 8 | Buffer[byteIndex + 1];
value &= 0xFFFF >> bitIndex;
value >>= 16 - bitCount - bitIndex;
return value;
}
if (bitCount <= 17 && Remaining >= 24)
{
int value = Buffer[byteIndex] << 16 | Buffer[byteIndex + 1] << 8 | Buffer[byteIndex + 2];
value &= 0xFFFFFF >> bitIndex;
value >>= 24 - bitCount - bitIndex;
return value;
}
if (bitCount <= 25 && Remaining >= 32)
{
int value = Buffer[byteIndex] << 24 | Buffer[byteIndex + 1] << 16 | Buffer[byteIndex + 2] << 8 | Buffer[byteIndex + 3];
value &= (int)(0xFFFFFFFF >> bitIndex);
value >>= 32 - bitCount - bitIndex;
return value;
}
return PeekIntFallback(bitCount);
}
private int PeekIntFallback(int bitCount)
{
int value = 0;
int byteIndex = Position / 8;
int bitIndex = Position % 8;
while (bitCount > 0)
{
if (bitIndex >= 8)
{
bitIndex = 0;
byteIndex++;
}
int bitsToRead = Math.Min(bitCount, 8 - bitIndex);
int mask = 0xFF >> bitIndex;
int currentByte = (mask & Buffer[byteIndex]) >> (8 - bitIndex - bitsToRead);
value = (value << bitsToRead) | currentByte;
bitIndex += bitsToRead;
bitCount -= bitsToRead;
}
return value;
}
/// <summary>
/// Specifies the bias of an offset binary value. A positive bias can represent one more
/// positive value than negative value, and a negative bias can represent one more
/// negative value than positive value.
/// </summary>
/// <remarks>Example:
/// A 4-bit offset binary value with a positive bias can store
/// the values 8 through -7 inclusive.
/// A 4-bit offset binary value with a positive bias can store
/// the values 7 through -8 inclusive.</remarks>
public enum OffsetBias
{
Negative = 0
}
}
}
@@ -0,0 +1,51 @@
#nullable disable
using System.Runtime.CompilerServices;
namespace LibAtrac9.Utilities
{
internal static class Helpers
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static short Clamp16(int value)
{
if (value > short.MaxValue)
return short.MaxValue;
if (value < short.MinValue)
return short.MinValue;
return (short)value;
}
public static int GetNextMultiple(int value, int multiple)
{
if (multiple <= 0)
return value;
if (value % multiple == 0)
return value;
return value + multiple - value % multiple;
}
/// <summary>
/// Returns the floor of the base 2 logarithm of a specified number.
/// </summary>
/// <param name="value">The number whose logarithm is to be found.</param>
/// <returns>The floor of the base 2 logarithm of <paramref name="value"/>.</returns>
public static int Log2(int value)
{
value |= value >> 1;
value |= value >> 2;
value |= value >> 4;
value |= value >> 8;
value |= value >> 16;
return MultiplyDeBruijnBitPosition[(uint)(value * 0x07C4ACDDU) >> 27];
}
private static readonly int[] MultiplyDeBruijnBitPosition =
{
0, 9, 1, 10, 13, 21, 2, 29, 11, 14, 16, 18, 22, 25, 3, 30,
8, 12, 20, 28, 15, 17, 24, 7, 19, 27, 23, 6, 26, 5, 4, 31
};
}
}
+178
View File
@@ -0,0 +1,178 @@
#nullable disable
using System;
using System.Collections.Generic;
namespace LibAtrac9.Utilities
{
internal class Mdct
{
private int MdctBits { get; }
private int MdctSize { get; }
private double Scale { get; }
private static readonly object TableLock = new object();
private static int _tableBits = -1;
private static readonly List<double[]> SinTables = new List<double[]>();
private static readonly List<double[]> CosTables = new List<double[]>();
private static readonly List<int[]> ShuffleTables = new List<int[]>();
private readonly double[] _imdctPrevious;
private readonly double[] _imdctWindow;
private readonly double[] _scratchMdct;
private readonly double[] _scratchDct;
public Mdct(int mdctBits, double[] window, double scale = 1)
{
SetTables(mdctBits);
MdctBits = mdctBits;
MdctSize = 1 << mdctBits;
Scale = scale;
if (window.Length < MdctSize)
{
throw new ArgumentException("Window must be as long as the MDCT size.", nameof(window));
}
_imdctPrevious = new double[MdctSize];
_scratchMdct = new double[MdctSize];
_scratchDct = new double[MdctSize];
_imdctWindow = window;
}
private static void SetTables(int maxBits)
{
lock (TableLock)
{
if (maxBits > _tableBits)
{
for (int i = _tableBits + 1; i <= maxBits; i++)
{
GenerateTrigTables(i, out double[] sin, out double[] cos);
SinTables.Add(sin);
CosTables.Add(cos);
ShuffleTables.Add(GenerateShuffleTable(i));
}
_tableBits = maxBits;
}
}
}
public void RunImdct(double[] input, double[] output)
{
if (input.Length < MdctSize)
{
throw new ArgumentException("Input must be as long as the MDCT size.", nameof(input));
}
if (output.Length < MdctSize)
{
throw new ArgumentException("Output must be as long as the MDCT size.", nameof(output));
}
int size = MdctSize;
int half = size / 2;
double[] dctOut = _scratchMdct;
Dct4(input, dctOut);
for (int i = 0; i < half; i++)
{
output[i] = _imdctWindow[i] * dctOut[i + half] + _imdctPrevious[i];
output[i + half] = _imdctWindow[i + half] * -dctOut[size - 1 - i] - _imdctPrevious[i + half];
_imdctPrevious[i] = _imdctWindow[size - 1 - i] * -dctOut[half - i - 1];
_imdctPrevious[i + half] = _imdctWindow[half - i - 1] * dctOut[i];
}
}
/// <summary>
/// Does a Type-4 DCT.
/// </summary>
/// <param name="input">The input array containing the time or frequency-domain samples</param>
/// <param name="output">The output array that will contain the transformed time or frequency-domain samples</param>
private void Dct4(double[] input, double[] output)
{
int[] shuffleTable = ShuffleTables[MdctBits];
double[] sinTable = SinTables[MdctBits];
double[] cosTable = CosTables[MdctBits];
double[] dctTemp = _scratchDct;
int size = MdctSize;
int lastIndex = size - 1;
int halfSize = size / 2;
for (int i = 0; i < halfSize; i++)
{
int i2 = i * 2;
double a = input[i2];
double b = input[lastIndex - i2];
double sin = sinTable[i];
double cos = cosTable[i];
dctTemp[i2] = a * cos + b * sin;
dctTemp[i2 + 1] = a * sin - b * cos;
}
int stageCount = MdctBits - 1;
for (int stage = 0; stage < stageCount; stage++)
{
int blockCount = 1 << stage;
int blockSizeBits = stageCount - stage;
int blockHalfSizeBits = blockSizeBits - 1;
int blockSize = 1 << blockSizeBits;
int blockHalfSize = 1 << blockHalfSizeBits;
sinTable = SinTables[blockHalfSizeBits];
cosTable = CosTables[blockHalfSizeBits];
for (int block = 0; block < blockCount; block++)
{
for (int i = 0; i < blockHalfSize; i++)
{
int frontPos = (block * blockSize + i) * 2;
int backPos = frontPos + blockSize;
double a = dctTemp[frontPos] - dctTemp[backPos];
double b = dctTemp[frontPos + 1] - dctTemp[backPos + 1];
double sin = sinTable[i];
double cos = cosTable[i];
dctTemp[frontPos] += dctTemp[backPos];
dctTemp[frontPos + 1] += dctTemp[backPos + 1];
dctTemp[backPos] = a * cos + b * sin;
dctTemp[backPos + 1] = a * sin - b * cos;
}
}
}
for (int i = 0; i < MdctSize; i++)
{
output[i] = dctTemp[shuffleTable[i]] * Scale;
}
}
internal static void GenerateTrigTables(int sizeBits, out double[] sin, out double[] cos)
{
int size = 1 << sizeBits;
sin = new double[size];
cos = new double[size];
for (int i = 0; i < size; i++)
{
double value = Math.PI * (4 * i + 1) / (4 * size);
sin[i] = Math.Sin(value);
cos[i] = Math.Cos(value);
}
}
internal static int[] GenerateShuffleTable(int sizeBits)
{
int size = 1 << sizeBits;
var table = new int[size];
for (int i = 0; i < size; i++)
{
table[i] = Bit.BitReverse32(i ^ (i / 2), sizeBits);
}
return table;
}
}
}
+6
View File
@@ -23,6 +23,12 @@ public sealed class GuiSettings
public bool StrictDynlibResolution { get; set; }
/// <summary>Mirror emulator output to user/logs/&lt;titleId&gt;-&lt;timestamp&gt;.log.</summary>
public bool LogToFile { get; set; }
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true;
public string? EmulatorPath { get; set; }
// The emulator is portable and keeps its data next to the executable;
+9 -1
View File
@@ -244,10 +244,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
Value="0" FormatString="0" CornerRadius="8" />
</StackPanel>
<StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log to file" />
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Title music" />
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
</StackPanel>
</WrapPanel>
</StackPanel>
</Border>
+204 -7
View File
@@ -16,6 +16,7 @@ using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Libs.Pad;
using SharpEmu.Logging;
namespace SharpEmu.GUI;
@@ -38,6 +39,8 @@ public partial class MainWindow : Window
private GuiSettings _settings = new();
private EmulatorProcess? _emulator;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private bool _isRunning;
private int _autoScrollTicks;
@@ -84,6 +87,7 @@ public partial class MainWindow : Window
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
@@ -98,6 +102,7 @@ public partial class MainWindow : Window
Closing += (_, _) => OnWindowClosing();
DualSenseReader.EnsureStarted();
XInputReader.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
@@ -110,7 +115,8 @@ public partial class MainWindow : Window
private void PollGamepad()
{
if (!DualSenseReader.TryGetState(out var pad))
// DualSense wins when both are connected; XInput covers Xbox pads.
if (!DualSenseReader.TryGetState(out var pad) && !XInputReader.TryGetState(out pad))
{
_previousPadButtons = 0;
return;
@@ -216,10 +222,15 @@ public partial class MainWindow : Window
private async Task OnOpenedAsync()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
if (version is not null)
{
VersionText.Text = $"v{version.ToString(3)}";
}
var display = version is not null ? $"v{version.ToString(3)}" : "v0.0.1";
display += BuildInfo.CommitSha is null
? " · dev"
: BuildInfo.IsOfficialRelease
? $" · {BuildInfo.CommitSha}"
: $" · UNOFFICIAL {BuildInfo.CommitSha}";
VersionText.Text = display;
Title = $"SharpEmu {display}";
ToolTip.SetTip(VersionText, BuildInfo.Banner);
_settings = GuiSettings.Load();
ApplySettingsToControls();
@@ -233,7 +244,9 @@ public partial class MainWindow : Window
_settings.Save();
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_sndPreview.Stop();
_emulator?.Dispose();
DropFileLog();
}
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
@@ -260,6 +273,8 @@ public partial class MainWindow : Window
};
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
}
private void ReadControlsIntoSettings()
@@ -267,6 +282,8 @@ public partial class MainWindow : Window
_settings.LogLevel = SelectedLogLevel();
_settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
_settings.LogToFile = LogToFileToggle.IsChecked == true;
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
}
private string SelectedLogLevel()
@@ -778,6 +795,7 @@ public partial class MainWindow : Window
SelectedGamePath.Text = game.Path;
SelectedCoverPanel.DataContext = game;
_ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game);
}
else
{
@@ -785,11 +803,66 @@ public partial class MainWindow : Window
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
SelectedCoverPanel.DataContext = null;
_ = UpdateBackdropAsync(null);
_sndPreview.Stop();
}
UpdateRunButtons();
}
/// <summary>
/// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// home screen style. Silent while a game is running or when disabled
/// in the options.
/// </summary>
private void PlaySelectedGamePreview(GameEntry game)
{
if (_isRunning || !_settings.PlayTitleMusic)
{
return;
}
var directory = Path.GetDirectoryName(game.Path);
var sndPath = directory is null ? null : Path.Combine(directory, "sce_sys", "snd0.at9");
if (sndPath is not null && File.Exists(sndPath))
{
_sndPreview.Play(sndPath);
}
else
{
_sndPreview.Stop();
}
}
private void OnTitleMusicToggled()
{
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
if (!_settings.PlayTitleMusic)
{
_sndPreview.Stop();
}
else if (GameList.SelectedItem is GameEntry game)
{
PlaySelectedGamePreview(game);
}
}
/// <summary>Pauses the preview music while the window is minimized.</summary>
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
{
if (WindowState == WindowState.Minimized)
{
_sndPreview.Pause();
}
else
{
_sndPreview.Resume();
}
}
}
/// <summary>
/// Fades the window backdrop to the selected game's key art. The image
/// decodes off the UI thread and is cached on the entry; a newer
@@ -855,11 +928,11 @@ public partial class MainWindow : Window
{
if (GameList.SelectedItem is GameEntry game)
{
Launch(game.Path, game.Name);
Launch(game.Path, game.Name, game.TitleId);
}
}
private void Launch(string ebootPath, string displayName)
private void Launch(string ebootPath, string displayName, string? titleId = null)
{
if (_isRunning)
{
@@ -876,6 +949,7 @@ public partial class MainWindow : Window
}
}
_sndPreview.Stop();
ReadControlsIntoSettings();
_settings.Save();
@@ -898,6 +972,23 @@ public partial class MainWindow : Window
_consoleLines.Clear();
ConsoleToggle.IsChecked = true;
// Mirror everything the console pane shows into a log file for the
// duration of the run, regardless of the emulator's log level.
DropFileLog();
if (_settings.LogToFile && BuildLogFilePath(titleId) is { } logFilePath)
{
try
{
_fileLog = new StreamWriter(logFilePath, append: false);
AppendConsoleLine($"Log file: {logFilePath}", DimLineBrush);
}
catch (Exception ex)
{
AppendConsoleLine($"Could not open the log file: {ex.Message}", WarningLineBrush);
}
}
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
var emulator = new EmulatorProcess();
@@ -912,6 +1003,7 @@ public partial class MainWindow : Window
{
emulator.Dispose();
AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush);
DropFileLog();
return;
}
@@ -923,6 +1015,37 @@ public partial class MainWindow : Window
UpdateRunButtons();
}
/// <summary>
/// Builds "user/logs/&lt;titleId&gt;-&lt;timestamp&gt;.log" next to the emulator
/// executable, following the same portable-data convention as savedata.
/// </summary>
private string? BuildLogFilePath(string? titleId)
{
try
{
var exeDirectory = Path.GetDirectoryName(_emulatorExePath);
if (string.IsNullOrEmpty(exeDirectory))
{
return null;
}
var logsDirectory = Path.Combine(exeDirectory, "user", "logs");
Directory.CreateDirectory(logsDirectory);
var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
foreach (var invalid in Path.GetInvalidFileNameChars())
{
id = id.Replace(invalid, '_');
}
return Path.Combine(logsDirectory, $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}.log");
}
catch (Exception)
{
return null; // unwritable location: launch continues without a log file
}
}
private void OnEmulatorExited(int exitCode)
{
FlushPendingConsoleLines();
@@ -941,6 +1064,7 @@ public partial class MainWindow : Window
};
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush);
CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
@@ -967,9 +1091,12 @@ public partial class MainWindow : Window
var incoming = new List<LogLine>();
while (_pendingLines.TryDequeue(out var pending))
{
WriteFileLog(pending.Line);
incoming.Add(new LogLine(pending.Line, BrushForLine(pending.Line)));
}
FlushFileLog();
if (incoming.Count >= MaxConsoleLines)
{
// A burst larger than the cap: keep only the newest lines.
@@ -998,11 +1125,81 @@ public partial class MainWindow : Window
private void AppendConsoleLine(string text, IBrush brush)
{
WriteFileLog(text);
FlushFileLog();
_consoleLines.Add(new LogLine(text, brush));
_autoScrollTicks = 3;
MaybeAutoScroll();
}
// ---- Console-to-file mirroring ----
private void WriteFileLog(string text)
{
if (_fileLog is not { } writer)
{
return;
}
try
{
writer.Write('[');
writer.Write(DateTime.Now.ToString("HH:mm:ss.fff"));
writer.Write("] ");
writer.WriteLine(text);
}
catch (Exception)
{
DropFileLog(); // unwritable (disk full, etc.): stop mirroring
}
}
private void FlushFileLog()
{
try
{
_fileLog?.Flush();
}
catch (Exception)
{
DropFileLog();
}
}
private void DropFileLog()
{
var writer = _fileLog;
_fileLog = null;
try
{
writer?.Dispose();
}
catch (Exception)
{
}
}
/// <summary>
/// The pipe reader threads can deliver a final burst after the exit
/// event, so the file stays open for one more flush cycle.
/// </summary>
private void CloseFileLogSoon()
{
if (_fileLog is not { } writer)
{
return;
}
DispatcherTimer.RunOnce(() =>
{
if (ReferenceEquals(_fileLog, writer))
{
FlushPendingConsoleLines();
DropFileLog();
}
}, TimeSpan.FromMilliseconds(400));
}
private void MaybeAutoScroll()
{
// ScrollToEnd is applied over a few flush-timer ticks because the
+12 -3
View File
@@ -12,6 +12,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Version>0.0.1</Version>
</PropertyGroup>
<!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. -->
<ItemGroup>
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
@@ -24,12 +30,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup>
<!-- The DualSense HID reader is shared with the emulator's pad HLE. It is
dependency-free, so it is compiled in directly rather than pulling a
reference to all of SharpEmu.Libs into the launcher. -->
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's pad HLE. They are dependency-free, so they are
compiled in directly rather than pulling a reference to all of
SharpEmu.Libs into the launcher. -->
<ItemGroup>
<Compile Include="..\SharpEmu.Libs\Pad\PadState.cs" Link="Input/PadState.cs" />
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
<Compile Include="..\SharpEmu.Libs\Pad\XInputReader.cs" Link="Input/XInputReader.cs" />
</ItemGroup>
</Project>
+301
View File
@@ -0,0 +1,301 @@
// 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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Starts looping the given snd0.at9 after a short debounce.</summary>
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();
}
}
/// <summary>
/// Silences playback but keeps the decoded track ready, so
/// <see cref="Resume"/> can restart it (winmm cannot truly pause).
/// </summary>
public void Pause()
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (_sync)
{
if (!_playing)
{
return;
}
_ = PlaySound(0, 0, 0);
_playing = false;
_paused = true;
}
}
/// <summary>Restarts the track silenced by <see cref="Pause"/>.</summary>
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");
/// <summary>
/// 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.
/// </summary>
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);
}
+3
View File
@@ -180,6 +180,9 @@
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"sharpemu.logging": {
"type": "Project"
}
}
}
+25 -58
View File
@@ -5,21 +5,6 @@ using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Snapshot of the DualSense state, already translated to ORBIS pad
/// conventions (SCE_PAD_BUTTON bits; sticks 0..255 with 128 centered;
/// triggers 0..255).
/// </summary>
internal readonly record struct DualSenseState(
bool Connected,
uint Buttons,
byte LeftX,
byte LeftY,
byte RightX,
byte RightY,
byte L2,
byte R2);
/// <summary>
/// Reads a DualSense controller over raw HID on a background thread.
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
@@ -31,26 +16,8 @@ internal static class DualSenseReader
private const ushort DualSenseProductId = 0x0CE6;
private const ushort DualSenseEdgeProductId = 0x0DF2;
// SCE_PAD_BUTTON bit values.
private const uint ButtonL3 = 0x0002;
private const uint ButtonR3 = 0x0004;
private const uint ButtonOptions = 0x0008;
private const uint ButtonUp = 0x0010;
private const uint ButtonRight = 0x0020;
private const uint ButtonDown = 0x0040;
private const uint ButtonLeft = 0x0080;
private const uint ButtonL2 = 0x0100;
private const uint ButtonR2 = 0x0200;
private const uint ButtonL1 = 0x0400;
private const uint ButtonR1 = 0x0800;
private const uint ButtonTriangle = 0x1000;
private const uint ButtonCircle = 0x2000;
private const uint ButtonCross = 0x4000;
private const uint ButtonSquare = 0x8000;
private const uint ButtonTouchPad = 0x100000;
private static readonly object Gate = new();
private static DualSenseState _state;
private static PadState _state;
private static bool _started;
// Output (rumble/lightbar) state, all guarded by Gate.
@@ -92,7 +59,7 @@ internal static class DualSenseReader
}
}
internal static bool TryGetState(out DualSenseState state)
internal static bool TryGetState(out PadState state)
{
lock (Gate)
{
@@ -102,7 +69,7 @@ internal static class DualSenseReader
return state.Connected;
}
private static void SetState(in DualSenseState state)
private static void SetState(in PadState state)
{
lock (Gate)
{
@@ -399,7 +366,7 @@ internal static class DualSenseReader
return null;
}
private static bool TryParseReport(ReadOnlySpan<byte> report, out DualSenseState state)
private static bool TryParseReport(ReadOnlySpan<byte> report, out PadState state)
{
// USB: report id 0x01, payload starts at [1].
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
@@ -429,21 +396,21 @@ internal static class DualSenseReader
var buttons2 = report[offset + 9];
uint buttons = 0;
buttons |= (buttons0 & 0x10) != 0 ? ButtonSquare : 0;
buttons |= (buttons0 & 0x20) != 0 ? ButtonCross : 0;
buttons |= (buttons0 & 0x40) != 0 ? ButtonCircle : 0;
buttons |= (buttons0 & 0x80) != 0 ? ButtonTriangle : 0;
buttons |= (buttons0 & 0x10) != 0 ? OrbisPadButton.Square : 0;
buttons |= (buttons0 & 0x20) != 0 ? OrbisPadButton.Cross : 0;
buttons |= (buttons0 & 0x40) != 0 ? OrbisPadButton.Circle : 0;
buttons |= (buttons0 & 0x80) != 0 ? OrbisPadButton.Triangle : 0;
buttons |= HatToButtons(buttons0 & 0x0F);
buttons |= (buttons1 & 0x01) != 0 ? ButtonL1 : 0;
buttons |= (buttons1 & 0x02) != 0 ? ButtonR1 : 0;
buttons |= (buttons1 & 0x04) != 0 ? ButtonL2 : 0;
buttons |= (buttons1 & 0x08) != 0 ? ButtonR2 : 0;
buttons |= (buttons1 & 0x20) != 0 ? ButtonOptions : 0;
buttons |= (buttons1 & 0x40) != 0 ? ButtonL3 : 0;
buttons |= (buttons1 & 0x80) != 0 ? ButtonR3 : 0;
buttons |= (buttons2 & 0x02) != 0 ? ButtonTouchPad : 0;
buttons |= (buttons1 & 0x01) != 0 ? OrbisPadButton.L1 : 0;
buttons |= (buttons1 & 0x02) != 0 ? OrbisPadButton.R1 : 0;
buttons |= (buttons1 & 0x04) != 0 ? OrbisPadButton.L2 : 0;
buttons |= (buttons1 & 0x08) != 0 ? OrbisPadButton.R2 : 0;
buttons |= (buttons1 & 0x20) != 0 ? OrbisPadButton.Options : 0;
buttons |= (buttons1 & 0x40) != 0 ? OrbisPadButton.L3 : 0;
buttons |= (buttons1 & 0x80) != 0 ? OrbisPadButton.R3 : 0;
buttons |= (buttons2 & 0x02) != 0 ? OrbisPadButton.TouchPad : 0;
state = new DualSenseState(
state = new PadState(
Connected: true,
Buttons: buttons,
LeftX: leftX,
@@ -457,14 +424,14 @@ internal static class DualSenseReader
private static uint HatToButtons(int hat) => hat switch
{
0 => ButtonUp,
1 => ButtonUp | ButtonRight,
2 => ButtonRight,
3 => ButtonRight | ButtonDown,
4 => ButtonDown,
5 => ButtonDown | ButtonLeft,
6 => ButtonLeft,
7 => ButtonLeft | ButtonUp,
0 => OrbisPadButton.Up,
1 => OrbisPadButton.Up | OrbisPadButton.Right,
2 => OrbisPadButton.Right,
3 => OrbisPadButton.Right | OrbisPadButton.Down,
4 => OrbisPadButton.Down,
5 => OrbisPadButton.Down | OrbisPadButton.Left,
6 => OrbisPadButton.Left,
7 => OrbisPadButton.Left | OrbisPadButton.Up,
_ => 0,
};
}
+17 -1
View File
@@ -31,6 +31,7 @@ public static class PadExports
{
_initialized = true;
DualSenseReader.EnsureStarted();
XInputReader.EnsureStarted();
return ctx.SetReturn(0);
}
@@ -61,9 +62,12 @@ public static class PadExports
}
DualSenseReader.EnsureStarted();
XInputReader.EnsureStarted();
Console.Error.WriteLine(DualSenseReader.TryGetState(out _)
? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)."
: "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense will be used automatically when plugged in.");
: XInputReader.TryGetState(out _)
? "[LOADER][INFO] Controls: Xbox controller connected (keyboard fallback also active)."
: "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in.");
return ctx.SetReturn(PrimaryPadHandle);
}
@@ -201,6 +205,7 @@ public static class PadExports
}
DualSenseReader.SetRumble(parameter[0], parameter[1]);
XInputReader.SetRumble(parameter[0], parameter[1]);
return ctx.SetReturn(0);
}
@@ -277,6 +282,17 @@ public static class PadExports
r2 = Math.Max(r2, pad.R2);
}
if (XInputReader.TryGetState(out var xpad))
{
buttons |= xpad.Buttons;
leftX = MergeAxis(xpad.LeftX, leftX);
leftY = MergeAxis(xpad.LeftY, leftY);
rightX = MergeAxis(xpad.RightX, rightX);
rightY = MergeAxis(xpad.RightY, rightY);
l2 = Math.Max(l2, xpad.L2);
r2 = Math.Max(r2, xpad.R2);
}
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
data[0x04] = leftX;
data[0x05] = leftY;
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Snapshot of a physical controller's state, already translated to ORBIS
/// pad conventions (SCE_PAD_BUTTON bits; sticks 0..255 with 128 centered
/// and Y growing downward; triggers 0..255).
/// </summary>
internal readonly record struct PadState(
bool Connected,
uint Buttons,
byte LeftX,
byte LeftY,
byte RightX,
byte RightY,
byte L2,
byte R2);
/// <summary>SCE_PAD_BUTTON bit values.</summary>
internal static class OrbisPadButton
{
internal const uint L3 = 0x0002;
internal const uint R3 = 0x0004;
internal const uint Options = 0x0008;
internal const uint Up = 0x0010;
internal const uint Right = 0x0020;
internal const uint Down = 0x0040;
internal const uint Left = 0x0080;
internal const uint L2 = 0x0100;
internal const uint R2 = 0x0200;
internal const uint L1 = 0x0400;
internal const uint R1 = 0x0800;
internal const uint Triangle = 0x1000;
internal const uint Circle = 0x2000;
internal const uint Cross = 0x4000;
internal const uint Square = 0x8000;
internal const uint TouchPad = 0x100000;
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
/// the Windows XInput API on a background thread, translated to the same
/// ORBIS pad conventions as <see cref="DualSenseReader"/>. Supports rumble
/// and hot-plug retry; the first connected slot (of four) is used.
/// </summary>
internal static class XInputReader
{
private const uint ErrorSuccess = 0;
private const int SlotCount = 4;
private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD
// XINPUT_GAMEPAD wButtons bit values.
private const ushort XinputDpadUp = 0x0001;
private const ushort XinputDpadDown = 0x0002;
private const ushort XinputDpadLeft = 0x0004;
private const ushort XinputDpadRight = 0x0008;
private const ushort XinputStart = 0x0010;
private const ushort XinputBack = 0x0020;
private const ushort XinputLeftThumb = 0x0040;
private const ushort XinputRightThumb = 0x0080;
private const ushort XinputLeftShoulder = 0x0100;
private const ushort XinputRightShoulder = 0x0200;
private const ushort XinputA = 0x1000;
private const ushort XinputB = 0x2000;
private const ushort XinputX = 0x4000;
private const ushort XinputY = 0x8000;
private static readonly object Gate = new();
private static PadState _state;
private static bool _started;
private static int _slot = -1; // connected XInput user index, -1 when none
private static byte _motorLeft;
private static byte _motorRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted()
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "XInputReader",
};
thread.Start();
}
}
internal static bool TryGetState(out PadState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in PadState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendRumbleLocked();
}
}
private static void SendRumbleLocked()
{
if (_slot < 0)
{
return; // resent on connect
}
var vibration = new XInputVibration
{
LeftMotorSpeed = (ushort)(_motorLeft * 257), // 0..255 -> 0..65535
RightMotorSpeed = (ushort)(_motorRight * 257),
};
_ = XInputSetState((uint)_slot, ref vibration);
}
private static void ReadLoop()
{
try
{
while (true)
{
var slot = FindConnectedSlot();
if (slot < 0)
{
SetState(default);
Thread.Sleep(1000);
continue;
}
lock (Gate)
{
_slot = slot;
SendRumbleLocked();
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected.");
while (XInputGetState((uint)slot, out var state) == ErrorSuccess)
{
SetState(Translate(state.Gamepad));
Thread.Sleep(8);
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected.");
lock (Gate)
{
_slot = -1;
_motorLeft = 0;
_motorRight = 0;
_state = default;
}
Thread.Sleep(1000);
}
}
catch (DllNotFoundException)
{
// XInput unavailable on this system; leave the reader disconnected.
}
catch (EntryPointNotFoundException)
{
}
}
private static int FindConnectedSlot()
{
for (var index = 0; index < SlotCount; index++)
{
if (XInputGetState((uint)index, out _) == ErrorSuccess)
{
return index;
}
}
return -1;
}
private static PadState Translate(in XInputGamepad pad)
{
uint buttons = 0;
buttons |= (pad.Buttons & XinputDpadUp) != 0 ? OrbisPadButton.Up : 0;
buttons |= (pad.Buttons & XinputDpadDown) != 0 ? OrbisPadButton.Down : 0;
buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? OrbisPadButton.Left : 0;
buttons |= (pad.Buttons & XinputDpadRight) != 0 ? OrbisPadButton.Right : 0;
buttons |= (pad.Buttons & XinputStart) != 0 ? OrbisPadButton.Options : 0;
buttons |= (pad.Buttons & XinputBack) != 0 ? OrbisPadButton.TouchPad : 0;
buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? OrbisPadButton.L3 : 0;
buttons |= (pad.Buttons & XinputRightThumb) != 0 ? OrbisPadButton.R3 : 0;
buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? OrbisPadButton.L1 : 0;
buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? OrbisPadButton.R1 : 0;
buttons |= (pad.Buttons & XinputA) != 0 ? OrbisPadButton.Cross : 0;
buttons |= (pad.Buttons & XinputB) != 0 ? OrbisPadButton.Circle : 0;
buttons |= (pad.Buttons & XinputX) != 0 ? OrbisPadButton.Square : 0;
buttons |= (pad.Buttons & XinputY) != 0 ? OrbisPadButton.Triangle : 0;
buttons |= pad.LeftTrigger > TriggerThreshold ? OrbisPadButton.L2 : 0;
buttons |= pad.RightTrigger > TriggerThreshold ? OrbisPadButton.R2 : 0;
return new PadState(
Connected: true,
Buttons: buttons,
LeftX: AxisToByte(pad.ThumbLX),
LeftY: AxisToByteInverted(pad.ThumbLY),
RightX: AxisToByte(pad.ThumbRX),
RightY: AxisToByteInverted(pad.ThumbRY),
L2: pad.LeftTrigger,
R2: pad.RightTrigger);
}
private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8);
// XInput Y grows upward, ORBIS pads report Y growing downward.
private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8));
[StructLayout(LayoutKind.Sequential)]
private struct XInputGamepad
{
public ushort Buttons;
public byte LeftTrigger;
public byte RightTrigger;
public short ThumbLX;
public short ThumbLY;
public short ThumbRX;
public short ThumbRY;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputState
{
public uint PacketNumber;
public XInputGamepad Gamepad;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputVibration
{
public ushort LeftMotorSpeed;
public ushort RightMotorSpeed;
}
// xinput1_4.dll ships with Windows 8 and later.
[DllImport("xinput1_4.dll")]
private static extern uint XInputGetState(uint userIndex, out XInputState state);
[DllImport("xinput1_4.dll")]
private static extern uint XInputSetState(uint userIndex, ref XInputVibration vibration);
}
+42 -2
View File
@@ -12,8 +12,14 @@ public static class SharpEmuLog
new(StringComparer.Ordinal);
private static readonly object ConfigurationSync = new();
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
private static bool _fileCapturesAllLevels;
private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment();
/// <summary>
/// Entries below this level are dropped. When a SHARPEMU_LOG_FILE sink is
/// active it only limits the console — the file receives every level.
/// <see cref="LogLevel.None"/> disables logging entirely, file included.
/// </summary>
public static LogLevel MinimumLevel
{
get => _minimumLevel;
@@ -40,6 +46,11 @@ public static class SharpEmuLog
return;
}
// A replacement sink is not the environment-configured
// console+file pair, so the minimum level applies globally
// again.
_fileCapturesAllLevels = false;
if (_sink is IDisposable disposable)
{
try
@@ -122,7 +133,14 @@ public static class SharpEmuLog
internal static bool IsEnabled(LogLevel level)
{
var minimum = _minimumLevel;
return minimum != LogLevel.None && level >= minimum;
if (minimum == LogLevel.None)
{
return false;
}
// With a file sink capturing all levels, the console filter is
// applied per-sink instead of here.
return _fileCapturesAllLevels || level >= minimum;
}
internal static void Write(
@@ -187,7 +205,10 @@ public static class SharpEmuLog
try
{
var fileSink = new FileLogSink(logFilePath, append: true, includeTimestamp: true);
return new CompositeLogSink(consoleSink, fileSink);
// The file gets every level; the configured minimum only
// limits what reaches the console.
_fileCapturesAllLevels = true;
return new CompositeLogSink(new MinimumLevelFilterSink(consoleSink), fileSink);
}
catch (Exception ex)
{
@@ -199,6 +220,25 @@ public static class SharpEmuLog
return consoleSink;
}
/// <summary>
/// Forwards only entries at or above <see cref="MinimumLevel"/>. Wraps
/// the console sink when a file sink captures all levels.
/// </summary>
private sealed class MinimumLevelFilterSink : ISharpEmuLogSink
{
private readonly ISharpEmuLogSink _inner;
internal MinimumLevelFilterSink(ISharpEmuLogSink inner) => _inner = inner;
public void Write(in LogEntry entry)
{
if (entry.Level >= _minimumLevel)
{
_inner.Write(in entry);
}
}
}
private static bool IsTrueLike(string? text)
{
if (string.IsNullOrWhiteSpace(text))