Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 168 additions & 47 deletions Codecs/Codec.ImaAdpcm/ImaAdpcmCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,8 @@
namespace Codec.ImaAdpcm;

/// <summary>
/// IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) decoder.
/// Each 4-bit nibble encodes the magnitude + sign of the delta between samples; the
/// step size walks up and down a 89-entry log-spaced table based on the previous
/// nibble. Used by WAV format code 0x0011 with a block layout:
/// <list type="bullet">
/// <item>Per-channel 4-byte header: int16 predictor, int8 step-index, 1 reserved byte.</item>
/// <item>For mono: remaining <c>blockAlign - 4</c> bytes are nibble pairs (LSN first).</item>
/// <item>For stereo: headers are interleaved per channel (4 bytes L, 4 bytes R), then
/// nibbles are interleaved 4 bytes per channel (8 samples each).</item>
/// </list>
/// IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) codec.
/// Supports the Microsoft/Intel WAV block layout and Apple/QuickTime <c>ima4</c> packets.
/// </summary>
public static class ImaAdpcmCodec {

Expand All @@ -27,17 +19,18 @@ public static class ImaAdpcmCodec {

private static readonly int[] IndexAdjust = [-1, -1, -1, -1, 2, 4, 6, 8];

private const int WavHeaderBytes = 4;
private const int QuickTimePacketBytes = 34;
private const int QuickTimeSamplesPerPacket = 64;

/// <summary>
/// Decodes IMA ADPCM data to one PCM buffer per channel. Each output buffer holds
/// <c>((blockAlign/channels - 4) * 2 + 1)</c> samples per block.
/// </summary>
public static short[][] Decode(ReadOnlySpan<byte> adpcm, int blockAlign, int channels) {
if (channels is < 1 or > 2)
throw new ArgumentException("IMA ADPCM in WAV supports 1 or 2 channels.", nameof(channels));
if (blockAlign < 4 * channels)
throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign));
ValidateWavLayout(blockAlign, channels);

var samplesPerBlock = (blockAlign - 4 * channels) * 2 / channels + 1;
var samplesPerBlock = (blockAlign - WavHeaderBytes * channels) * 2 / channels + 1;
var blockCount = adpcm.Length / blockAlign;
var output = new short[channels][];
for (var c = 0; c < channels; ++c) output[c] = new short[blockCount * samplesPerBlock];
Expand All @@ -49,18 +42,15 @@ public static short[][] Decode(ReadOnlySpan<byte> adpcm, int blockAlign, int cha
var blockStart = b * blockAlign;
var outStart = b * samplesPerBlock;

// Per-channel 4-byte headers at block start.
for (var c = 0; c < channels; ++c) {
var h = blockStart + c * 4;
var h = blockStart + c * WavHeaderBytes;
predictor[c] = BinaryPrimitives.ReadInt16LittleEndian(adpcm.Slice(h, 2));
index[c] = adpcm[h + 2];
if (index[c] > 88) index[c] = 88;
if (index[c] < 0) index[c] = 0;
index[c] = Math.Min((int)adpcm[h + 2], 88);
output[c][outStart] = (short)predictor[c];
}

var dataStart = blockStart + 4 * channels;
var dataLen = blockAlign - 4 * channels;
var dataStart = blockStart + WavHeaderBytes * channels;
var dataLen = blockAlign - WavHeaderBytes * channels;

if (channels == 1) {
for (var i = 0; i < dataLen; ++i) {
Expand All @@ -69,7 +59,6 @@ public static short[][] Decode(ReadOnlySpan<byte> adpcm, int blockAlign, int cha
output[0][outStart + 2 + i * 2] = DecodeNibble((byte)(byteVal >> 4), ref predictor[0], ref index[0]);
}
} else {
// Stereo: 4-byte groups alternate channels (L,R,L,R…). Each group yields 8 samples.
var groups = dataLen / 8;
for (var g = 0; g < groups; ++g) {
for (var c = 0; c < 2; ++c) {
Expand All @@ -87,43 +76,98 @@ public static short[][] Decode(ReadOnlySpan<byte> adpcm, int blockAlign, int cha
return output;
}

/// <summary>
/// Encodes one or two equal-length PCM16 channel buffers to Microsoft/Intel IMA ADPCM WAV blocks.
/// The final block is padded with the last reconstructed sample so the raw coded stream remains
/// block-aligned; a container can retain the exact source sample count in its own metadata.
/// </summary>
public static byte[] Encode(IReadOnlyList<short[]> pcm, int blockAlign) {
ArgumentNullException.ThrowIfNull(pcm);
var channels = pcm.Count;
ValidateWavLayout(blockAlign, channels);
if (pcm.Any(static c => c is null))
throw new ArgumentException("PCM channel buffers cannot be null.", nameof(pcm));

var sampleCount = pcm[0].Length;
if (pcm.Any(c => c.Length != sampleCount))
throw new ArgumentException("All PCM channel buffers must have the same sample count.", nameof(pcm));
if (sampleCount == 0)
return [];

var dataLen = blockAlign - WavHeaderBytes * channels;
var samplesPerBlock = dataLen * 2 / channels + 1;
var blockCount = (sampleCount + samplesPerBlock - 1) / samplesPerBlock;
var output = new byte[blockCount * blockAlign];

Span<int> predictor = stackalloc int[channels];
Span<int> index = stackalloc int[channels];

for (var b = 0; b < blockCount; ++b) {
var blockStart = b * blockAlign;
var baseSample = b * samplesPerBlock;

for (var c = 0; c < channels; ++c) {
var first = pcm[c][Math.Min(baseSample, sampleCount - 1)];
var second = baseSample + 1 < sampleCount ? pcm[c][baseSample + 1] : first;
predictor[c] = first;
index[c] = StartIndexFor(Math.Abs(second - first));
var h = blockStart + c * WavHeaderBytes;
BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(h, 2), first);
output[h + 2] = (byte)index[c];
output[h + 3] = 0;
}

var dataStart = blockStart + WavHeaderBytes * channels;
if (channels == 1) {
for (var i = 0; i < dataLen; ++i) {
var sampleIdx = baseSample + 1 + i * 2;
var low = EncodeNibble(Sample(pcm[0], sampleIdx, predictor[0]), ref predictor[0], ref index[0]);
var high = EncodeNibble(Sample(pcm[0], sampleIdx + 1, predictor[0]), ref predictor[0], ref index[0]);
output[dataStart + i] = (byte)((high << 4) | low);
}
} else {
var groups = dataLen / 8;
for (var g = 0; g < groups; ++g) {
for (var c = 0; c < 2; ++c) {
var gs = dataStart + g * 8 + c * 4;
for (var i = 0; i < 4; ++i) {
var sampleIdx = baseSample + 1 + g * 8 + i * 2;
var low = EncodeNibble(Sample(pcm[c], sampleIdx, predictor[c]), ref predictor[c], ref index[c]);
var high = EncodeNibble(Sample(pcm[c], sampleIdx + 1, predictor[c]), ref predictor[c], ref index[c]);
output[gs + i] = (byte)((high << 4) | low);
}
}
}
}
}

return output;
}

/// <summary>
/// Decodes the Apple/QuickTime <c>ima4</c> packet variant (as carried by AIFC) into
/// one PCM buffer per channel. The data is a sequence of fixed 34-byte packets that
/// round-robin through the channels (ch0, ch1, …, ch0, …). Each packet is:
/// <list type="bullet">
/// <item>a 2-byte big-endian preamble: the top 9 bits are the signed initial
/// predictor (<c>(short)(preamble &amp; 0xFF80)</c>) and the low 7 bits are the
/// initial step index (clamped to ≤ 88);</item>
/// <item>32 data bytes = 64 nibbles, low nibble first within each byte, decoded with
/// the standard IMA step tables.</item>
/// </list>
/// Every packet therefore yields exactly 64 samples for its channel. Unlike the WAV
/// block layout the packet does <b>not</b> emit the predictor itself as a sample.
/// one PCM buffer per channel. Packets are 34 bytes and round-robin through channels.
/// </summary>
public static short[][] DecodeQuickTime(ReadOnlySpan<byte> data, int channels) {
if (channels < 1)
throw new ArgumentException("QuickTime IMA ADPCM needs at least one channel.", nameof(channels));

const int packetBytes = 34;
const int samplesPerPacket = 64;
var packetCount = data.Length / packetBytes;
var packetCount = data.Length / QuickTimePacketBytes;
var packetsPerChannel = packetCount / channels;

var output = new short[channels][];
for (var c = 0; c < channels; ++c)
output[c] = new short[packetsPerChannel * samplesPerPacket];
output[c] = new short[packetsPerChannel * QuickTimeSamplesPerPacket];

for (var p = 0; p < packetsPerChannel * channels; ++p) {
var channel = p % channels;
var packet = data.Slice(p * packetBytes, packetBytes);
var packet = data.Slice(p * QuickTimePacketBytes, QuickTimePacketBytes);

var preamble = BinaryPrimitives.ReadUInt16BigEndian(packet);
var predictor = (int)(short)(preamble & 0xFF80);
var index = preamble & 0x007F;
if (index > 88) index = 88;
var index = Math.Min(preamble & 0x007F, 88);

var outBase = (p / channels) * samplesPerPacket;
var outBase = (p / channels) * QuickTimeSamplesPerPacket;
for (var i = 0; i < 32; ++i) {
var byteVal = packet[2 + i];
output[channel][outBase + i * 2] = DecodeNibble((byte)(byteVal & 0x0F), ref predictor, ref index);
Expand All @@ -134,6 +178,86 @@ public static short[][] DecodeQuickTime(ReadOnlySpan<byte> data, int channels) {
return output;
}

/// <summary>
/// Encodes equal-length PCM16 channel buffers to Apple/QuickTime <c>ima4</c> packets.
/// A final partial packet is padded with the last reconstructed sample. Packets are emitted
/// in the channel-round-robin order used by AIFC/QuickTime.
/// </summary>
public static byte[] EncodeQuickTime(IReadOnlyList<short[]> pcm) {
ArgumentNullException.ThrowIfNull(pcm);
var channels = pcm.Count;
if (channels < 1)
throw new ArgumentException("QuickTime IMA ADPCM needs at least one channel.", nameof(pcm));
if (pcm.Any(static c => c is null))
throw new ArgumentException("PCM channel buffers cannot be null.", nameof(pcm));

var sampleCount = pcm[0].Length;
if (pcm.Any(c => c.Length != sampleCount))
throw new ArgumentException("All PCM channel buffers must have the same sample count.", nameof(pcm));
if (sampleCount == 0)
return [];

var packetsPerChannel = (sampleCount + QuickTimeSamplesPerPacket - 1) / QuickTimeSamplesPerPacket;
var output = new byte[packetsPerChannel * channels * QuickTimePacketBytes];

for (var packetIndex = 0; packetIndex < packetsPerChannel; ++packetIndex) {
var baseSample = packetIndex * QuickTimeSamplesPerPacket;
for (var c = 0; c < channels; ++c) {
var packetOffset = (packetIndex * channels + c) * QuickTimePacketBytes;
var first = pcm[c][Math.Min(baseSample, sampleCount - 1)];
var predictor = (int)(short)(first & ~0x7F);
var second = baseSample + 1 < sampleCount ? pcm[c][baseSample + 1] : first;
var index = StartIndexFor(Math.Max(Math.Abs(first - predictor), Math.Abs(second - first)));
var preamble = (ushort)(((ushort)predictor & 0xFF80) | index);
BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(packetOffset, 2), preamble);

for (var i = 0; i < 32; ++i) {
var sampleIdx = baseSample + i * 2;
var low = EncodeNibble(Sample(pcm[c], sampleIdx, predictor), ref predictor, ref index);
var high = EncodeNibble(Sample(pcm[c], sampleIdx + 1, predictor), ref predictor, ref index);
output[packetOffset + 2 + i] = (byte)((high << 4) | low);
}
}
}

return output;
}

private static void ValidateWavLayout(int blockAlign, int channels) {
if (channels is < 1 or > 2)
throw new ArgumentException("IMA ADPCM in WAV supports 1 or 2 channels.", nameof(channels));
if (blockAlign < WavHeaderBytes * channels)
throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign));
var dataLen = blockAlign - WavHeaderBytes * channels;
if (channels == 2 && dataLen % 8 != 0)
throw new ArgumentException("Stereo IMA ADPCM block data must contain complete 4-byte groups per channel.", nameof(blockAlign));
}

private static short Sample(short[] pcm, int index, int fallback)
=> index < pcm.Length ? pcm[index] : (short)fallback;

private static int StartIndexFor(int delta) {
var i = 0;
while (i < StepTable.Length - 1 && StepTable[i] < delta)
++i;
return i;
}

private static byte EncodeNibble(int sample, ref int predictor, ref int index) {
var step = StepTable[index];
var delta = sample - predictor;
byte nibble = 0;
if (delta < 0) {
nibble = 8;
delta = -delta;
}
if (delta >= step) { nibble |= 4; delta -= step; }
if (delta >= step >> 1) { nibble |= 2; delta -= step >> 1; }
if (delta >= step >> 2) nibble |= 1;
DecodeNibble(nibble, ref predictor, ref index);
return nibble;
}

private static short DecodeNibble(byte nibble, ref int predictor, ref int index) {
var step = StepTable[index];
var diff = step >> 3;
Expand All @@ -142,11 +266,8 @@ private static short DecodeNibble(byte nibble, ref int predictor, ref int index)
if ((nibble & 4) != 0) diff += step;
if ((nibble & 8) != 0) predictor -= diff;
else predictor += diff;
if (predictor > 32767) predictor = 32767;
else if (predictor < -32768) predictor = -32768;
index += IndexAdjust[nibble & 0x07];
if (index < 0) index = 0;
else if (index > 88) index = 88;
predictor = Math.Clamp(predictor, short.MinValue, short.MaxValue);
index = Math.Clamp(index + IndexAdjust[nibble & 0x07], 0, 88);
return (short)predictor;
}
}
49 changes: 49 additions & 0 deletions Codecs/Codec.Midi/MidiWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#pragma warning disable CS1591
using System.Buffers.Binary;

namespace Codec.Midi;

/// <summary>Standard MIDI File emitter for already-encoded <c>MTrk</c> payloads.</summary>
public static class MidiWriter {

/// <summary>
/// Builds an SMF from raw <c>MTrk</c> payloads. Format 0 requires exactly one track;
/// formats 1 and 2 may contain multiple tracks. Event bytes are preserved verbatim.
/// </summary>
public static byte[] BuildFile(IReadOnlyList<byte[]> trackBodies, int division, int format = 1) {
ArgumentNullException.ThrowIfNull(trackBodies);
if (format is < 0 or > 2)
throw new ArgumentOutOfRangeException(nameof(format), "SMF format must be 0, 1, or 2.");
if (trackBodies.Count == 0)
throw new ArgumentException("An SMF must contain at least one track.", nameof(trackBodies));
if (format == 0 && trackBodies.Count != 1)
throw new ArgumentException("SMF format 0 requires exactly one track.", nameof(trackBodies));
if (trackBodies.Count > ushort.MaxValue)
throw new ArgumentException("SMF track count exceeds the 16-bit header field.", nameof(trackBodies));
if (division is < short.MinValue or > short.MaxValue)
throw new ArgumentOutOfRangeException(nameof(division));
if (trackBodies.Any(static track => track is null))
throw new ArgumentException("Track payloads cannot be null.", nameof(trackBodies));

using var output = new MemoryStream();
output.Write("MThd"u8);
Span<byte> size = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(size, 6);
output.Write(size);

Span<byte> header = stackalloc byte[6];
BinaryPrimitives.WriteUInt16BigEndian(header, (ushort)format);
BinaryPrimitives.WriteUInt16BigEndian(header[2..], (ushort)trackBodies.Count);
BinaryPrimitives.WriteUInt16BigEndian(header[4..], unchecked((ushort)(short)division));
output.Write(header);

foreach (var track in trackBodies) {
output.Write("MTrk"u8);
BinaryPrimitives.WriteInt32BigEndian(size, track.Length);
output.Write(size);
output.Write(track);
}

return output.ToArray();
}
}
Loading
Loading