diff --git a/SerializationProvider.Tests/VstPresetTests.cs b/SerializationProvider.Tests/VstPresetTests.cs new file mode 100644 index 0000000..a552655 --- /dev/null +++ b/SerializationProvider.Tests/VstPresetTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.SerializationProvider.Tests; + +using System.IO; +using System.Text; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class VstPresetTests +{ + private const string SampleClassId = "565354416463416E6F746167666F6F62"; // 32 ASCII chars + + [TestMethod] + public void ToBytes_ProducesValidVst3Header() + { + VstPreset preset = new(SampleClassId, [1, 2, 3, 4]); + + byte[] bytes = VstPresetFile.ToBytes(preset); + + Encoding.ASCII.GetString(bytes, 0, 4).Should().Be("VST3"); + bytes.Length.Should().BeGreaterThan(48); + } + + [TestMethod] + public void RoundTrip_ComponentStateOnly_IsPreserved() + { + byte[] state = [10, 20, 30, 40, 50]; + VstPreset preset = new(SampleClassId, state); + + VstPreset decoded = VstPresetFile.FromBytes(VstPresetFile.ToBytes(preset)); + + decoded.ClassId.Should().Be(SampleClassId); + decoded.ComponentState.Should().Equal(state); + decoded.ControllerState.Should().BeNull(); + decoded.MetaInfo.Should().BeNull(); + decoded.Version.Should().Be(VstPresetFile.FormatVersion); + } + + [TestMethod] + public void RoundTrip_AllChunks_ArePreserved() + { + byte[] component = [1, 2, 3]; + byte[] controller = [9, 8, 7, 6]; + const string meta = "hello"; + VstPreset preset = new(SampleClassId, component, controller, meta); + + VstPreset decoded = VstPresetFile.FromBytes(VstPresetFile.ToBytes(preset)); + + decoded.ComponentState.Should().Equal(component); + decoded.ControllerState.Should().Equal(controller); + decoded.MetaInfo.Should().Be(meta); + } + + [TestMethod] + public void RoundTrip_EmptyComponentState_IsPreserved() + { + VstPreset preset = new(SampleClassId, []); + + VstPreset decoded = VstPresetFile.FromBytes(VstPresetFile.ToBytes(preset)); + + decoded.ComponentState.Should().BeEmpty(); + } + + [TestMethod] + public void Read_NonVstData_Throws() + { + byte[] garbage = Encoding.ASCII.GetBytes("NOPE this is not a preset file at all"); + + Action act = () => VstPresetFile.FromBytes(garbage); + + act.Should().Throw(); + } + + [TestMethod] + public void Write_NonSeekableStream_Throws() + { + VstPreset preset = new(SampleClassId, [1]); + + Action act = () => VstPresetFile.Write(new NonSeekableStream(), preset); + + act.Should().Throw(); + } + + [TestMethod] + public void Provider_RoundTrips_ThroughInnerProvider() + { + VstPresetSerializationProvider provider = new(new JsonInner(), SampleClassId); + Payload original = new("delay", 0.45); + + string serialized = provider.Serialize(original); + Payload restored = provider.Deserialize(serialized); + + provider.ProviderName.Should().Be("VST3 Preset"); + provider.ContentType.Should().Be("application/vnd.steinberg.vstpreset"); + restored.Should().Be(original); + } + + [TestMethod] + public void Provider_Output_IsBase64OfRealVstPreset() + { + VstPresetSerializationProvider provider = new(new JsonInner(), SampleClassId); + + string serialized = provider.Serialize(new Payload("gain", 1.0)); + byte[] presetBytes = Convert.FromBase64String(serialized); + + Encoding.ASCII.GetString(presetBytes, 0, 4).Should().Be("VST3"); + VstPreset decoded = VstPresetFile.FromBytes(presetBytes); + decoded.ClassId.Should().Be(SampleClassId); + } + + private sealed record Payload(string Name, double Value); + + private sealed class JsonInner : ISerializationProvider + { + public string ProviderName => "Json"; + public string ContentType => "application/json"; + public string Serialize(T obj) => System.Text.Json.JsonSerializer.Serialize(obj); + public string Serialize(object obj, Type type) => System.Text.Json.JsonSerializer.Serialize(obj, type); + public T Deserialize(string data) => System.Text.Json.JsonSerializer.Deserialize(data)!; + public object Deserialize(string data, Type type) => System.Text.Json.JsonSerializer.Deserialize(data, type)!; + public Task SerializeAsync(T obj, CancellationToken cancellationToken = default) => Task.FromResult(Serialize(obj)); + public Task SerializeAsync(object obj, Type type, CancellationToken cancellationToken = default) => Task.FromResult(Serialize(obj, type)); + public Task DeserializeAsync(string data, CancellationToken cancellationToken = default) => Task.FromResult(Deserialize(data)); + public Task DeserializeAsync(string data, Type type, CancellationToken cancellationToken = default) => Task.FromResult(Deserialize(data, type)); + } + + private sealed class NonSeekableStream : MemoryStream + { + public override bool CanSeek => false; + } +} diff --git a/SerializationProvider/VstPreset.cs b/SerializationProvider/VstPreset.cs new file mode 100644 index 0000000..d3c8e3c --- /dev/null +++ b/SerializationProvider/VstPreset.cs @@ -0,0 +1,63 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.SerializationProvider; + +using System.Diagnostics.CodeAnalysis; + +/// +/// The decoded contents of a VST3 .vstpreset file. +/// +/// +/// A VST3 preset is a small binary container that pairs a plugin's class identifier with one or more +/// opaque state blobs: the processor (component) state, an optional controller state, and optional XML +/// metadata. See for reading and writing the on-disk format. +/// +public sealed record VstPreset +{ + /// + /// Gets the plugin class identifier (the 32-character ASCII representation of the VST3 FUID). + /// + public string ClassId { get; } + + /// + /// Gets the processor (component) state blob. This is the primary plugin state. + /// + [SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "A preset state chunk is an opaque binary blob written verbatim to the file; a byte array is the natural representation.")] + public byte[] ComponentState { get; } + + /// + /// Gets the optional controller state blob, or when the preset has none. + /// + [SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "A preset state chunk is an opaque binary blob written verbatim to the file; a byte array is the natural representation.")] + public byte[]? ControllerState { get; } + + /// + /// Gets the optional metadata, typically an XML document describing the preset, or . + /// + public string? MetaInfo { get; } + + /// + /// Gets the preset format version stored in the file header. + /// + public int Version { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The plugin class identifier. + /// The processor (component) state blob. + /// The optional controller state blob. + /// The optional XML metadata. + /// The preset format version. + /// Thrown when or is null. + public VstPreset(string classId, byte[] componentState, byte[]? controllerState = null, string? metaInfo = null, int version = VstPresetFile.FormatVersion) + { + ClassId = Ensure.NotNull(classId); + ComponentState = Ensure.NotNull(componentState); + ControllerState = controllerState; + MetaInfo = metaInfo; + Version = version; + } +} diff --git a/SerializationProvider/VstPresetFile.cs b/SerializationProvider/VstPresetFile.cs new file mode 100644 index 0000000..6aa1d94 --- /dev/null +++ b/SerializationProvider/VstPresetFile.cs @@ -0,0 +1,221 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.SerializationProvider; + +using System.IO; +using System.Text; + +/// +/// Reads and writes the Steinberg VST3 .vstpreset container format. +/// +/// +/// The format is a header followed by the state blobs and a trailing chunk list: +/// +/// Header: the ASCII tag VST3, an version, a 32-byte ASCII class id, and an offset to the chunk list. +/// Data: the component state, optional controller state, and optional metadata, written back to back. +/// Chunk list: the ASCII tag List, an entry count, then for each entry a 4-byte id (Comp, Cont, Info), an offset and an size. +/// +/// All integers are little-endian, matching the reference implementation in the VST3 SDK, so files +/// written here can be loaded by hosts and host-written presets can be read back. +/// +public static class VstPresetFile +{ + /// The current preset format version. + public const int FormatVersion = 1; + + private const string HeaderTag = "VST3"; + private const string ListTag = "List"; + private const string ComponentChunkId = "Comp"; + private const string ControllerChunkId = "Cont"; + private const string MetaInfoChunkId = "Info"; + private const int ClassIdLength = 32; + + /// + /// Writes a preset to a seekable, writable stream. + /// + /// The destination stream; must support seeking and writing. + /// The preset to write. + /// Thrown when or is null. + /// Thrown when is not seekable or writable. + public static void Write(Stream stream, VstPreset preset) + { + Ensure.NotNull(stream); + Ensure.NotNull(preset); + if (!stream.CanSeek || !stream.CanWrite) + { + throw new ArgumentException("Stream must be seekable and writable.", nameof(stream)); + } + + using BinaryWriter writer = new(stream, Encoding.ASCII, leaveOpen: true); + + writer.Write(Encoding.ASCII.GetBytes(HeaderTag)); + writer.Write(preset.Version); + writer.Write(EncodeClassId(preset.ClassId)); + long listOffsetField = stream.Position; + writer.Write(0L); // Placeholder for the chunk-list offset; patched once the list position is known. + + List<(string Id, long Offset, long Size)> entries = []; + + AppendChunk(writer, stream, entries, ComponentChunkId, preset.ComponentState); + if (preset.ControllerState is not null) + { + AppendChunk(writer, stream, entries, ControllerChunkId, preset.ControllerState); + } + + if (preset.MetaInfo is not null) + { + AppendChunk(writer, stream, entries, MetaInfoChunkId, Encoding.UTF8.GetBytes(preset.MetaInfo)); + } + + long listOffset = stream.Position; + writer.Write(Encoding.ASCII.GetBytes(ListTag)); + writer.Write(entries.Count); + foreach ((string id, long offset, long size) in entries) + { + writer.Write(Encoding.ASCII.GetBytes(id)); + writer.Write(offset); + writer.Write(size); + } + + writer.Flush(); + + // Patch the header's chunk-list offset now that we know where the list landed. + stream.Seek(listOffsetField, SeekOrigin.Begin); + writer.Write(listOffset); + writer.Flush(); + stream.Seek(0, SeekOrigin.End); + } + + /// + /// Serializes a preset to a new byte array. + /// + /// The preset to serialize. + /// The encoded .vstpreset bytes. + public static byte[] ToBytes(VstPreset preset) + { + using MemoryStream stream = new(); + Write(stream, preset); + return stream.ToArray(); + } + + /// + /// Reads a preset from a seekable, readable stream. + /// + /// The source stream; must support seeking and reading. + /// The decoded . + /// Thrown when is null. + /// Thrown when is not seekable or readable. + /// Thrown when the stream is not a valid VST3 preset. + public static VstPreset Read(Stream stream) + { + Ensure.NotNull(stream); + if (!stream.CanSeek || !stream.CanRead) + { + throw new ArgumentException("Stream must be seekable and readable.", nameof(stream)); + } + + using BinaryReader reader = new(stream, Encoding.ASCII, leaveOpen: true); + + stream.Seek(0, SeekOrigin.Begin); + if (ReadTag(reader) != HeaderTag) + { + throw new InvalidDataException("Not a VST3 preset: missing 'VST3' header tag."); + } + + int version = reader.ReadInt32(); + string classId = DecodeClassId(ReadExact(reader, ClassIdLength)); + long listOffset = reader.ReadInt64(); + + stream.Seek(listOffset, SeekOrigin.Begin); + if (ReadTag(reader) != ListTag) + { + throw new InvalidDataException("Corrupt VST3 preset: missing 'List' chunk tag."); + } + + int entryCount = reader.ReadInt32(); + if (entryCount < 0) + { + throw new InvalidDataException("Corrupt VST3 preset: negative chunk count."); + } + + List<(string Id, long Offset, long Size)> entries = new(entryCount); + for (int i = 0; i < entryCount; i++) + { + string id = ReadTag(reader); + long offset = reader.ReadInt64(); + long size = reader.ReadInt64(); + entries.Add((id, offset, size)); + } + + byte[]? component = null; + byte[]? controller = null; + string? metaInfo = null; + + foreach ((string id, long offset, long size) in entries) + { + stream.Seek(offset, SeekOrigin.Begin); + byte[] data = ReadExact(reader, checked((int)size)); + switch (id) + { + case ComponentChunkId: + component = data; + break; + case ControllerChunkId: + controller = data; + break; + case MetaInfoChunkId: + metaInfo = Encoding.UTF8.GetString(data); + break; + default: + // Unknown chunk: preserved by hosts but not modelled here; ignore. + break; + } + } + + return component is null + ? throw new InvalidDataException("Corrupt VST3 preset: no component ('Comp') chunk.") + : new VstPreset(classId, component, controller, metaInfo, version); + } + + /// + /// Deserializes a preset from a byte array. + /// + /// The encoded .vstpreset bytes. + /// The decoded . + /// Thrown when is null. + public static VstPreset FromBytes(byte[] bytes) + { + Ensure.NotNull(bytes); + using MemoryStream stream = new(bytes, writable: false); + return Read(stream); + } + + private static void AppendChunk(BinaryWriter writer, Stream stream, List<(string Id, long Offset, long Size)> entries, string id, byte[] data) + { + long offset = stream.Position; + writer.Write(data); + entries.Add((id, offset, data.Length)); + } + + private static byte[] EncodeClassId(string classId) + { + byte[] buffer = new byte[ClassIdLength]; + byte[] source = Encoding.ASCII.GetBytes(classId); + Array.Copy(source, buffer, Math.Min(source.Length, ClassIdLength)); + return buffer; + } + + private static string DecodeClassId(byte[] raw) => Encoding.ASCII.GetString(raw).TrimEnd('\0'); + + private static string ReadTag(BinaryReader reader) => Encoding.ASCII.GetString(ReadExact(reader, 4)); + + private static byte[] ReadExact(BinaryReader reader, int count) + { + byte[] data = reader.ReadBytes(count); + return data.Length != count + ? throw new InvalidDataException("Corrupt VST3 preset: unexpected end of stream.") + : data; + } +} diff --git a/SerializationProvider/VstPresetSerializationProvider.cs b/SerializationProvider/VstPresetSerializationProvider.cs new file mode 100644 index 0000000..ab56d1e --- /dev/null +++ b/SerializationProvider/VstPresetSerializationProvider.cs @@ -0,0 +1,84 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.SerializationProvider; + +using System.Text; + +/// +/// An that wraps another provider and packages its output inside a +/// VST3 .vstpreset container. +/// +/// +/// The inner provider produces the logical state (for example JSON); this provider stores that state as +/// the component chunk of a tagged with a configured class id. Because the +/// contract is string-based, the binary preset is returned +/// Base64-encoded. For direct file interop — writing bytes a host can load, or reading a host-written +/// .vstpreset — use directly. +/// +/// The provider that serializes the logical state stored inside the preset. +/// The VST3 plugin class id (32-character ASCII FUID) to tag presets with. +public sealed class VstPresetSerializationProvider(ISerializationProvider innerProvider, string presetClassId) : ISerializationProvider +{ + private readonly ISerializationProvider inner = Ensure.NotNull(innerProvider); + private readonly string classId = Ensure.NotNull(presetClassId); + + /// + public string ProviderName => "VST3 Preset"; + + /// + public string ContentType => "application/vnd.steinberg.vstpreset"; + + /// + public string Serialize(T obj) => Wrap(inner.Serialize(obj)); + + /// + public string Serialize(object obj, Type type) => Wrap(inner.Serialize(obj, type)); + + /// + public T Deserialize(string data) => inner.Deserialize(Unwrap(data)); + + /// + public object Deserialize(string data, Type type) => inner.Deserialize(Unwrap(data), type); + + /// + public Task SerializeAsync(T obj, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Serialize(obj)); + } + + /// + public Task SerializeAsync(object obj, Type type, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Serialize(obj, type)); + } + + /// + public Task DeserializeAsync(string data, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Deserialize(data)); + } + + /// + public Task DeserializeAsync(string data, Type type, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Deserialize(data, type)); + } + + private string Wrap(string state) + { + VstPreset preset = new(classId, Encoding.UTF8.GetBytes(state)); + return Convert.ToBase64String(VstPresetFile.ToBytes(preset)); + } + + private static string Unwrap(string data) + { + VstPreset preset = VstPresetFile.FromBytes(Convert.FromBase64String(data)); + return Encoding.UTF8.GetString(preset.ComponentState); + } +}