Port reusable Rust synth tooling

This commit is contained in:
Meta Vens 2026-05-08 10:34:07 +01:00
commit 3246672bbe
9 changed files with 882 additions and 15 deletions

View file

@ -22,6 +22,8 @@ dotnet test
- `FaustCompiler.CompileAsync(source, options)` writes generated backend code.
- `BuiltInScripts` carries the current Rust-side classic SFXR, 808, FM bell,
and wobble bass golf scripts so the migration has executable baggage checks.
- `SfxrParams`, `PatchScriptScoring`, `AudioAnalyzer`, and `Presets` carry the
reusable Rust-side analysis, scoring, SFXR, and preset tools.
Faust `2.85.5` supports `-lang csharp`, so the intended hot path is:
@ -36,8 +38,8 @@ await FaustCompiler.CompileAsync(
## Status
The current slice covers the modular graph surface needed by the Rust golfed
scripts, emits Faust, and validates generated Faust when the compiler is
installed. Migration coverage is tracked in
scripts, SFXR atoms, script scoring, audio comparison, presets, Faust emission,
and installed Faust validation. Migration coverage is tracked in
[`docs/migration-checklist.md`](docs/migration-checklist.md), because leaving
important things behind in the old repo would be a very efficient way to become
our own haunted house.

View file

@ -19,6 +19,11 @@ there.
- Built-in script catalog for classic SFXR, classic 808, FM bell, and wobble
bass primitive golf scripts.
- Abstract SFXR golf script using defaults and templates.
- SFXR parameter presets, mutation, shorthand atoms, and SFXR-to-patch mapping.
- Aquarium preset patches: pluck, heartbeat, voice, and SFXR named bridge.
- Patch script scoring: terse, readability, and balanced metrics.
- Audio analysis and comparison: envelope, log-mel spectrogram, spectral
features, distance metrics, and score.
- Faust source emitter and installed-compiler wrapper for C, C++, C#, and Rust
target code generation.
- Tests that parse/export all built-in primitive scripts and validate Faust
@ -26,15 +31,13 @@ there.
## Still Deliberate
- The C# repo does not yet include an audio renderer, analysis metric suite, or
exhaustive Rust-vs-Faust audio parity harness. Faust is the renderer target,
so C# should own authoring and validation first.
- SFXR preset atoms such as `sfxr preset=laser mutate_seed=...` are not carried
over yet. The current migration preserves the primitive patch construction
path, which is the part we want in the engine.
- Random mutation, parameter-space search, and readability/code-golf scoring are
still Rust-reference features until we decide which ones belong in C# tools
versus offline authoring utilities.
- The Rust sample renderer, patch player, and realtime audio-unit bridge stay in
Rust unless we decide to write a real C#/Faust runtime wrapper.
- The experimental Rust-side DSL compiler branch can stay behind as reference
machinery. The C# repo owns the script surface needed to drive Faust.
- Exhaustive Rust-renderer-vs-Faust audio parity stays with the Rust renderer.
C# can compare buffers it is handed, but it should not recreate the old
renderer just to prove the old renderer existed. That road has a sign on it.
## Next Pull

View file

@ -0,0 +1,323 @@
using System.Numerics;
namespace AquariumSynth.Dsl;
public sealed record AudioAnalysisConfig(
float SampleRate = 44100,
float GateFloor = 0.0005f,
float GateRatio = 0.03f,
int FftSize = 512,
int HopSize = 128,
int MelBandCount = 32,
float MinFrequencyHz = 40,
float MaxFrequencyHz = 16000);
public sealed record AudioFeatures(
float AttackSeconds,
float DurationSeconds,
float Peak,
float Rms,
float ZeroCrossingRate,
float SpectralCentroidHz,
float SpectralRolloffHz);
public sealed record Spectrogram(int Frames, int Bands, float[] Values)
{
public float At(int frame, int band) => Values[frame * Bands + band];
}
public sealed record AudioAnalysis(AudioFeatures Features, Spectrogram LogMelSpectrogram, float[] RmsEnvelope);
public sealed record AudioComparison(
AudioAnalysis Reference,
AudioAnalysis Candidate,
float DurationRatio,
float RmsRatio,
float ZeroCrossingRatio,
float CentroidRatio,
float EnvelopeDistance,
float LogMelDistance,
float Score);
public sealed class AudioAnalyzer
{
private readonly AudioAnalysisConfig _config;
private readonly int _fftSize;
private readonly Complex[] _fftBuffer;
private readonly float[] _spectrumBuffer;
private readonly int[] _melEdges;
public AudioAnalyzer(AudioAnalysisConfig? config = null)
{
_config = config ?? new AudioAnalysisConfig();
_fftSize = NextPowerOfTwo(Math.Max(32, _config.FftSize));
_fftBuffer = new Complex[_fftSize];
_spectrumBuffer = new float[_fftSize / 2 + 1];
_melEdges = MelBandEdges(Math.Max(1, _config.MelBandCount), _fftSize, _config.SampleRate, _config.MinFrequencyHz, _config.MaxFrequencyHz);
}
public AudioAnalysisConfig Config => _config;
public AudioAnalysis Analyze(ReadOnlySpan<float> samples)
{
var features = ExtractFeatures(samples);
var envelope = RmsEnvelope(samples, _fftSize, _config.HopSize);
var spectrogram = LogMelSpectrogram(samples);
return new AudioAnalysis(features, spectrogram, envelope);
}
public AudioComparison Compare(ReadOnlySpan<float> referenceSamples, ReadOnlySpan<float> candidateSamples) =>
FromAnalysis(Analyze(referenceSamples), Analyze(candidateSamples));
private AudioFeatures ExtractFeatures(ReadOnlySpan<float> samples)
{
var peak = 0f;
foreach (var sample in samples) peak = Math.Max(peak, Math.Abs(sample));
var gate = Math.Max(peak * _config.GateRatio, _config.GateFloor);
var first = 0;
while (first < samples.Length && Math.Abs(samples[first]) < gate) first++;
if (first >= samples.Length) first = 0;
var last = samples.Length - 1;
while (last > first && Math.Abs(samples[last]) < gate) last--;
var active = samples.Length == 0 ? ReadOnlySpan<float>.Empty : samples[first..(last + 1)];
var durationSeconds = (last - first + (samples.Length == 0 ? 0 : 1)) / Math.Max(1, _config.SampleRate);
var zeroCrossings = 0;
for (var i = 1; i < active.Length; i++)
{
if (MathF.Sign(active[i - 1]) != MathF.Sign(active[i])) zeroCrossings++;
}
var spectrum = AveragePowerSpectrum(active);
var shape = SpectralShape(spectrum, _config.SampleRate, 0.85f);
return new AudioFeatures(
first / Math.Max(1, _config.SampleRate),
durationSeconds,
peak,
MathF.Sqrt(MeanSquare(active)),
zeroCrossings / Math.Max(durationSeconds, 1 / _config.SampleRate),
shape.CentroidHz,
shape.RolloffHz);
}
private Spectrogram LogMelSpectrogram(ReadOnlySpan<float> samples)
{
var bands = Math.Max(1, _config.MelBandCount);
if (samples.IsEmpty) return new Spectrogram(1, bands, new float[bands]);
var values = new List<float>();
var frames = 0;
for (var start = 0; start < samples.Length; start += Math.Max(1, _config.HopSize))
{
WriteFramePowerSpectrum(samples, start);
for (var band = 0; band < bands; band++)
{
var left = _melEdges[band];
var center = Math.Max(_melEdges[band + 1], left + 1);
var right = Math.Max(_melEdges[band + 2], center + 1);
var energy = 0f;
var weightSum = 0f;
for (var bin = left; bin < Math.Min(right, _spectrumBuffer.Length); bin++)
{
var weight = Math.Max(0, bin <= center
? (bin - left) / (float)Math.Max(1, center - left)
: (right - bin) / (float)Math.Max(1, right - center));
energy += _spectrumBuffer[bin] * weight;
weightSum += weight;
}
values.Add(MathF.Log(energy / Math.Max(1, weightSum) + 1e-9f));
}
frames++;
}
var array = values.ToArray();
NormalizeInPlace(array);
return new Spectrogram(frames, bands, array);
}
private float[] AveragePowerSpectrum(ReadOnlySpan<float> samples)
{
var sum = new float[_fftSize / 2 + 1];
if (samples.IsEmpty) return sum;
var frames = 0f;
for (var start = 0; start < samples.Length; start += Math.Max(1, _fftSize / 2))
{
WriteFramePowerSpectrum(samples, start);
for (var i = 0; i < sum.Length; i++) sum[i] += _spectrumBuffer[i];
frames++;
}
for (var i = 0; i < sum.Length; i++) sum[i] /= frames;
return sum;
}
private void WriteFramePowerSpectrum(ReadOnlySpan<float> samples, int start)
{
for (var i = 0; i < _fftSize; i++)
{
var sample = start + i < samples.Length ? samples[start + i] : 0;
_fftBuffer[i] = new Complex(sample * Hann(i, _fftSize), 0);
}
Fft(_fftBuffer);
for (var bin = 0; bin < _spectrumBuffer.Length; bin++)
{
_spectrumBuffer[bin] = (float)(_fftBuffer[bin].Real * _fftBuffer[bin].Real + _fftBuffer[bin].Imaginary * _fftBuffer[bin].Imaginary);
}
}
public static AudioAnalysis AnalyzeAudio(ReadOnlySpan<float> samples, AudioAnalysisConfig? config = null) =>
new AudioAnalyzer(config).Analyze(samples);
public static AudioComparison CompareAudio(ReadOnlySpan<float> referenceSamples, ReadOnlySpan<float> candidateSamples, AudioAnalysisConfig? config = null) =>
new AudioAnalyzer(config).Compare(referenceSamples, candidateSamples);
private static AudioComparison FromAnalysis(AudioAnalysis reference, AudioAnalysis candidate)
{
var durationRatio = SafeRatio(candidate.Features.DurationSeconds, reference.Features.DurationSeconds);
var rmsRatio = SafeRatio(candidate.Features.Rms, reference.Features.Rms);
var zeroCrossingRatio = SafeRatio(candidate.Features.ZeroCrossingRate, reference.Features.ZeroCrossingRate);
var centroidRatio = SafeRatio(candidate.Features.SpectralCentroidHz, reference.Features.SpectralCentroidHz);
var envelopeDistance = NormalizedDistance(reference.RmsEnvelope, candidate.RmsEnvelope);
var logMelDistance = NormalizedDistance(reference.LogMelSpectrogram.Values, candidate.LogMelSpectrogram.Values);
var ratioPenalty = RatioDistance(durationRatio) + RatioDistance(rmsRatio) + RatioDistance(zeroCrossingRatio) * 0.5f + RatioDistance(centroidRatio) * 0.5f;
var score = 1 / (1 + envelopeDistance * 0.7f + logMelDistance + ratioPenalty);
return new AudioComparison(reference, candidate, durationRatio, rmsRatio, zeroCrossingRatio, centroidRatio, envelopeDistance, logMelDistance, score);
}
private static void Fft(Complex[] buffer)
{
var n = buffer.Length;
for (var i = 1; i < n; i++)
{
var bit = n >> 1;
var j = i;
while ((j & bit) != 0) j ^= bit;
j ^= bit;
if (i < j) (buffer[i], buffer[j]) = (buffer[j], buffer[i]);
}
for (var len = 2; len <= n; len <<= 1)
{
var angle = -2 * Math.PI / len;
var wLen = new Complex(Math.Cos(angle), Math.Sin(angle));
for (var i = 0; i < n; i += len)
{
var w = Complex.One;
for (var j = 0; j < len / 2; j++)
{
var u = buffer[i + j];
var v = buffer[i + j + len / 2] * w;
buffer[i + j] = u + v;
buffer[i + j + len / 2] = u - v;
w *= wLen;
}
}
}
}
private static float[] RmsEnvelope(ReadOnlySpan<float> samples, int windowSize, int hopSize)
{
windowSize = Math.Max(16, windowSize);
hopSize = Math.Max(1, hopSize);
if (samples.IsEmpty) return [0];
var envelope = new List<float>();
for (var start = 0; start < samples.Length; start += hopSize)
{
var end = Math.Min(start + windowSize, samples.Length);
envelope.Add(MathF.Sqrt(MeanSquare(samples[start..end])));
}
return envelope.ToArray();
}
private static int[] MelBandEdges(int bandCount, int fftSize, float sampleRate, float minFrequencyHz, float maxFrequencyHz)
{
var minMel = HzToMel(Math.Max(0, minFrequencyHz));
var maxMel = HzToMel(Math.Max(minFrequencyHz + 1, Math.Min(maxFrequencyHz, sampleRate * 0.5f)));
return Enumerable.Range(0, bandCount + 2)
.Select(index =>
{
var t = index / (float)(bandCount + 1);
var hz = MelToHz(minMel + (maxMel - minMel) * t);
return (int)MathF.Round(hz / Math.Max(1, sampleRate) * fftSize);
})
.ToArray();
}
private static (float CentroidHz, float RolloffHz) SpectralShape(IReadOnlyList<float> spectrum, float sampleRate, float rolloffPortion)
{
var total = spectrum.Sum();
if (total <= float.Epsilon) return (0, 0);
var weighted = 0f;
var cumulative = 0f;
var rolloff = 0f;
for (var bin = 0; bin < spectrum.Count; bin++)
{
var frequency = bin * sampleRate * 0.5f / Math.Max(1, spectrum.Count - 1);
weighted += frequency * spectrum[bin];
cumulative += spectrum[bin];
if (rolloff == 0 && cumulative >= total * rolloffPortion) rolloff = frequency;
}
return (weighted / total, rolloff);
}
private static float NormalizedDistance(IReadOnlyList<float> reference, IReadOnlyList<float> candidate)
{
var length = Math.Max(1, Math.Max(reference.Count, candidate.Count));
var error = 0f;
var scale = 0f;
for (var i = 0; i < length; i++)
{
var a = ResampledAt(reference, i, length);
var b = ResampledAt(candidate, i, length);
var delta = a - b;
error += delta * delta;
scale += a * a + b * b;
}
return MathF.Sqrt(error / Math.Max(float.Epsilon, scale));
}
private static float ResampledAt(IReadOnlyList<float> values, int index, int targetLength)
{
if (values.Count == 0) return 0;
if (values.Count == 1 || targetLength <= 1) return values[0];
var position = index * (values.Count - 1f) / (targetLength - 1);
var left = (int)MathF.Floor(position);
var right = Math.Min(left + 1, values.Count - 1);
var t = position - left;
return values[left] * (1 - t) + values[right] * t;
}
private static void NormalizeInPlace(float[] values)
{
if (values.Length == 0) return;
var mean = values.Sum() / values.Length;
var variance = values.Select(value => (value - mean) * (value - mean)).Sum() / values.Length;
var scale = MathF.Sqrt(variance).ClampMin(1e-6f);
for (var i = 0; i < values.Length; i++) values[i] = (values[i] - mean) / scale;
}
private static float MeanSquare(ReadOnlySpan<float> samples)
{
var sum = 0f;
foreach (var sample in samples) sum += sample * sample;
return sum / Math.Max(1, samples.Length);
}
private static float Hann(int index, int size) => size <= 1 ? 1 : 0.5f - 0.5f * MathF.Cos(MathF.Tau * index / (size - 1));
private static float HzToMel(float hz) => 2595 * MathF.Log10(1 + hz / 700);
private static float MelToHz(float mel) => 700 * (MathF.Pow(10, mel / 2595) - 1);
private static float SafeRatio(float candidate, float reference) => candidate / Math.Max(float.Epsilon, reference);
private static float RatioDistance(float ratio) => MathF.Abs(MathF.Log(Math.Max(float.Epsilon, ratio)));
private static int NextPowerOfTwo(int value) => 1 << (int)MathF.Ceiling(MathF.Log2(value));
}
file static class FloatExtensions
{
public static float ClampMin(this float value, float min) => Math.Max(value, min);
}

View file

@ -9,6 +9,7 @@ public static class BuiltInScripts
bus n=shimmer w=triangle hz=2 to=gain:0.08,lpf:-0.04,fmix:0.12
voice wave=sine freq=220 gain=0.12 attack=0.002 sustain=0.03 decay=0.2 vibrato=0.02 vibrato_hz=5 formants=620:90:1,1040:150:0.8 formant_mix=0.45 mods=formant:sine:2:0.12,pitch:triangle:3:0.015
voice wave=triangle freq=440 gain=0.04 attack=0 sustain=0.02 decay=0.18 lpf=0.7 hpf=0.02 mods=gain:sine:8:0.18,lpf:hold:12:0.08
sfxr preset=laser mutate_seed=9 mutate=0.01
""";
public static readonly IReadOnlyList<string> ClassicSfxrNames =

View file

@ -68,8 +68,15 @@ public static class PatchScript
public void Apply(string statement, int line)
{
var parts = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var rawCommand = parts[0];
var command = CanonicalCommand(parts[0]);
var fields = ParseFields(parts.Skip(1), line);
if (SfxrParams.Named(rawCommand) is { } namedParams)
{
AddSfxrPatch(ApplySfxrFields(namedParams, fields, line));
return;
}
switch (command)
{
case "patch":
@ -91,6 +98,9 @@ public static class PatchScript
case "control":
AddControlLane(fields, line);
break;
case "sfxr":
AddSfxrPatch(ParseSfxrCommand(fields, line));
break;
default:
throw new PatchScriptException(line, $"unknown command `{parts[0]}`");
}
@ -101,11 +111,14 @@ public static class PatchScript
var expanded = new Dictionary<string, string>(_defaults, StringComparer.OrdinalIgnoreCase);
if (TryGetAny(fields, ["use", "u"], out var templateName))
{
if (!_templates.TryGetValue(templateName, out var template))
foreach (var name in templateName.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
throw new PatchScriptException(line, $"unknown template `{templateName}`");
if (!_templates.TryGetValue(name, out var template))
{
throw new PatchScriptException(line, $"unknown template `{name}`");
}
Merge(expanded, template);
}
Merge(expanded, template);
}
Merge(expanded, Without(fields, "use", "u"));
return expanded;
@ -239,6 +252,62 @@ public static class PatchScript
GetFloat(fields, line, 0, "phase", "ph"),
GetFloat(fields, line, 0, "bias", "b"))));
}
private void AddSfxrPatch(SfxrParams parameters)
{
var mapped = parameters.ToPatch();
Voices.AddRange(mapped.Voices);
Repeat = mapped.Repeat;
Gain *= mapped.Gain;
}
private static SfxrParams ParseSfxrCommand(IReadOnlyDictionary<string, string> fields, int line)
{
var parameters = TryGetAny(fields, ["preset", "p"], out var preset)
? SfxrParams.Named(preset) ?? throw new PatchScriptException(line, $"unknown sfxr preset `{preset}`")
: new SfxrParams();
return ApplySfxrFields(parameters, fields, line);
}
private static SfxrParams ApplySfxrFields(SfxrParams parameters, IReadOnlyDictionary<string, string> fields, int line)
{
if (TryGetAny(fields, ["mutate_seed", "ms"], out var seedText))
{
if (!ulong.TryParse(seedText, out var seed))
{
throw new PatchScriptException(line, "mutate_seed must be an integer");
}
var amount = GetFloat(fields, line, 0.05f, "mutate", "m");
parameters = parameters.Mutate(seed, amount);
}
if (TryGetAny(fields, ["wave", "w"], out var wave)) parameters = parameters with { WaveType = ParseWaveform(wave, line) };
if (TryGetAny(fields, ["base", "b"], out var baseFreq)) parameters = parameters with { BaseFreq = Math.Clamp(ParseFloat(baseFreq, line), 0, 1) };
if (TryGetAny(fields, ["limit", "lim"], out var limit)) parameters = parameters with { FreqLimit = Math.Clamp(ParseFloat(limit, line), 0, 1) };
if (TryGetAny(fields, ["ramp", "r"], out var ramp)) parameters = parameters with { FreqRamp = Math.Clamp(ParseFloat(ramp, line), -1, 1) };
if (TryGetAny(fields, ["dramp", "dr"], out var dramp)) parameters = parameters with { FreqDramp = Math.Clamp(ParseFloat(dramp, line), -1, 1) };
if (TryGetAny(fields, ["duty", "du"], out var duty)) parameters = parameters with { Duty = Math.Clamp(ParseFloat(duty, line), 0, 1) };
if (TryGetAny(fields, ["duty_ramp", "dur"], out var dutyRamp)) parameters = parameters with { DutyRamp = Math.Clamp(ParseFloat(dutyRamp, line), -1, 1) };
if (TryGetAny(fields, ["vib", "vi"], out var vib)) parameters = parameters with { VibStrength = Math.Clamp(ParseFloat(vib, line), 0, 1) };
if (TryGetAny(fields, ["vib_speed", "vs"], out var vibSpeed)) parameters = parameters with { VibSpeed = Math.Clamp(ParseFloat(vibSpeed, line), 0, 1) };
if (TryGetAny(fields, ["vib_delay", "vd"], out var vibDelay)) parameters = parameters with { VibDelay = Math.Clamp(ParseFloat(vibDelay, line), 0, 1) };
if (TryGetAny(fields, ["attack", "a"], out var attack)) parameters = parameters with { EnvAttack = Math.Clamp(ParseFloat(attack, line), 0, 1) };
if (TryGetAny(fields, ["sustain", "s"], out var sustain)) parameters = parameters with { EnvSustain = Math.Clamp(ParseFloat(sustain, line), 0, 1) };
if (TryGetAny(fields, ["decay", "d"], out var decay)) parameters = parameters with { EnvDecay = Math.Clamp(ParseFloat(decay, line), 0, 1) };
if (TryGetAny(fields, ["punch", "pu"], out var punch)) parameters = parameters with { EnvPunch = Math.Clamp(ParseFloat(punch, line), -1, 1) };
if (TryGetAny(fields, ["resonance", "res"], out var resonance)) parameters = parameters with { LpfResonance = Math.Clamp(ParseFloat(resonance, line), 0, 1) };
if (TryGetAny(fields, ["lpf"], out var lpf)) parameters = parameters with { LpfFreq = Math.Clamp(ParseFloat(lpf, line), 0, 1) };
if (TryGetAny(fields, ["lpf_ramp", "lpfr"], out var lpfRamp)) parameters = parameters with { LpfRamp = Math.Clamp(ParseFloat(lpfRamp, line), -1, 1) };
if (TryGetAny(fields, ["hpf"], out var hpf)) parameters = parameters with { HpfFreq = Math.Clamp(ParseFloat(hpf, line), 0, 1) };
if (TryGetAny(fields, ["hpf_ramp", "hpfr"], out var hpfRamp)) parameters = parameters with { HpfRamp = Math.Clamp(ParseFloat(hpfRamp, line), -1, 1) };
if (TryGetAny(fields, ["phaser", "ph"], out var phaser)) parameters = parameters with { PhaOffset = Math.Clamp(ParseFloat(phaser, line), -1, 1) };
if (TryGetAny(fields, ["phaser_ramp", "phr"], out var phaserRamp)) parameters = parameters with { PhaRamp = Math.Clamp(ParseFloat(phaserRamp, line), -1, 1) };
if (TryGetAny(fields, ["repeat", "rep"], out var repeat)) parameters = parameters with { RepeatSpeed = Math.Clamp(ParseFloat(repeat, line), 0, 1) };
if (TryGetAny(fields, ["arp"], out var arp)) parameters = parameters with { ArpSpeed = Math.Clamp(ParseFloat(arp, line), 0, 1) };
if (TryGetAny(fields, ["arp_mod", "am"], out var arpMod)) parameters = parameters with { ArpMod = Math.Clamp(ParseFloat(arpMod, line), -1, 1) };
return parameters;
}
}
private static readonly (string[] Keys, ModTarget Target)[] ModTargets =
@ -311,6 +380,7 @@ public static class PatchScript
"v" or "voice" => "voice",
"mod" or "wob" or "wobble" or "bus" => "mod",
"lfo" or "control" => "control",
"s" or "sfxr" => "sfxr",
_ => command
};

View file

@ -0,0 +1,67 @@
namespace AquariumSynth.Dsl;
public static class Presets
{
public static SynthPatch AquariumPluck()
{
var envelope = new Envelope(0.002f, 0.09f, 0.43f, 0.38f);
return new SynthPatch
{
Voices =
[
Simple(new Oscillator(Waveform.Sine, 440), envelope, 0.18f),
Simple(new Oscillator(Waveform.Triangle, 880), envelope, 0.05f),
Simple(new Oscillator(Waveform.Sine, 1760), envelope, 0.018f)
],
Gain = 0.95f,
SoftClip = true
};
}
public static SynthPatch AquariumHeartbeat()
{
var envelope = new Envelope(0.004f, 0.08f, 0.2f, 0.62f);
return new SynthPatch
{
Voices =
[
Simple(new Oscillator(Waveform.Sine, 72), envelope, 0.22f),
Simple(new Oscillator(Waveform.Sine, 116), envelope, 0.09f)
],
Gain = 0.9f,
SoftClip = true
};
}
public static SynthPatch AquariumVoice()
{
var voice = Simple(new Oscillator(Waveform.Triangle, 220), new Envelope(0.018f, 0.34f, 0.28f, 0.08f), 0.18f) with
{
Pitch = new PitchMotion(VibratoDepth: 0.018f, VibratoHz: 5.6f),
Color = new VoiceColor(0.035f, 0.18f, 0.04f, 0.12f, 4.2f, 0.68f),
Formants =
[
new Formant(520, 85, 0.9f),
new Formant(1380, 180, 1),
new Formant(2550, 300, 0.42f)
]
};
return new SynthPatch
{
Voices = [voice],
Gain = 0.95f,
SoftClip = true
};
}
public static SynthPatch Sfxr(string name) =>
SfxrParams.Named(name)?.ToPatch() ?? throw new ArgumentException($"Unknown SFXR preset `{name}`.", nameof(name));
private static Voice Simple(Oscillator oscillator, Envelope envelope, float gain) => new()
{
Oscillator = oscillator,
Envelope = envelope,
Gain = gain
};
}

View file

@ -0,0 +1,101 @@
namespace AquariumSynth.Dsl;
public sealed record PatchScriptMetrics(
int ByteCount,
int LineCount,
int StatementCount,
int FieldCount,
float AverageFieldsPerStatement,
float AliasFieldRatio,
float TerseScore,
float ReadabilityScore,
float BalancedScore);
public static class PatchScriptScoring
{
public static PatchScriptMetrics Measure(string script)
{
var statements = Statements(script).ToList();
var statementCount = statements.Count;
var lineCount = script.Replace("\r\n", "\n")
.Split('\n')
.Count(line => !line.Split('#', 2)[0].Trim().Equals(""))
.ClampMin(1);
var fieldCount = 0;
var aliasFields = 0;
var namedCommands = 0;
var numericChars = 0;
var numericValues = 0;
foreach (var statement in statements)
{
var parts = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) continue;
if (parts[0].Length > 1) namedCommands++;
foreach (var part in parts.Skip(1))
{
var split = part.Split('=', 2);
if (split.Length != 2) continue;
fieldCount++;
if (split[0].Length <= 2) aliasFields++;
if (float.TryParse(split[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _))
{
numericValues++;
numericChars += split[1].Count(char.IsAsciiDigit);
}
}
}
var averageFieldsPerStatement = fieldCount / (float)Math.Max(1, statementCount);
var aliasFieldRatio = aliasFields / (float)Math.Max(1, fieldCount);
var namedCommandRatio = namedCommands / (float)Math.Max(1, statementCount);
var lineRoom = Math.Clamp(lineCount / (float)Math.Max(1, statementCount), 0, 1);
var fieldLoad = Math.Max(0, 1 - Math.Clamp(averageFieldsPerStatement / 16, 0, 1));
var numericBreath = numericValues == 0
? 1
: Math.Clamp(1 - Math.Max(0, numericChars / (float)numericValues - 4) / 8, 0, 1);
var readabilityScore = Math.Clamp(
0.30f * (1 - aliasFieldRatio) +
0.20f * namedCommandRatio +
0.20f * lineRoom +
0.15f * fieldLoad +
0.15f * numericBreath,
0,
1);
var byteCount = script.Trim().Length;
var terseScore = Math.Clamp(1 / (1 + byteCount / 160f), 0, 1);
var balancedScore = readabilityScore + terseScore <= float.Epsilon
? 0
: 2 * readabilityScore * terseScore / (readabilityScore + terseScore);
return new PatchScriptMetrics(
byteCount,
lineCount,
statementCount,
fieldCount,
averageFieldsPerStatement,
aliasFieldRatio,
terseScore,
readabilityScore,
balancedScore);
}
private static IEnumerable<string> Statements(string script)
{
foreach (var rawLine in script.Replace("\r\n", "\n").Split('\n'))
{
var uncommented = rawLine.Split('#', 2)[0];
foreach (var part in uncommented.Split(';'))
{
var statement = part.Trim();
if (statement.Length > 0) yield return statement;
}
}
}
}
file static class IntExtensions
{
public static int ClampMin(this int value, int min) => Math.Max(value, min);
}

View file

@ -0,0 +1,251 @@
namespace AquariumSynth.Dsl;
public sealed record SfxrParams
{
public Waveform WaveType { get; init; } = Waveform.Square;
public float BaseFreq { get; init; } = 0.3f;
public float FreqLimit { get; init; }
public float FreqRamp { get; init; }
public float FreqDramp { get; init; }
public float Duty { get; init; }
public float DutyRamp { get; init; }
public float VibStrength { get; init; }
public float VibSpeed { get; init; }
public float VibDelay { get; init; }
public float EnvAttack { get; init; } = 0.4f;
public float EnvSustain { get; init; } = 0.1f;
public float EnvDecay { get; init; } = 0.5f;
public float EnvPunch { get; init; }
public float LpfResonance { get; init; }
public float LpfFreq { get; init; } = 1;
public float LpfRamp { get; init; }
public float HpfFreq { get; init; }
public float HpfRamp { get; init; }
public float PhaOffset { get; init; }
public float PhaRamp { get; init; }
public float RepeatSpeed { get; init; }
public float ArpSpeed { get; init; }
public float ArpMod { get; init; }
public static SfxrParams? Named(string name) => name.ToLowerInvariant() switch
{
"blip" => Blip(),
"pickup" or "coin" => Pickup(),
"laser" or "shoot" => Laser(),
"explosion" => Explosion(),
"powerup" => Powerup(),
"hit" or "hurt" => Hit(),
"jump" => Jump(),
_ => null
};
public static SfxrParams Blip() => new()
{
WaveType = Waveform.Sine,
BaseFreq = 0.42f,
EnvAttack = 0,
EnvSustain = 0.13f,
EnvDecay = 0.08f,
HpfFreq = 0.1f
};
public static SfxrParams Explosion() => new()
{
WaveType = Waveform.Noise,
BaseFreq = 0.18f,
FreqRamp = -0.15f,
EnvAttack = 0,
EnvSustain = 0.29f,
EnvDecay = 0.36f,
EnvPunch = 0.52f,
PhaOffset = -0.22f,
PhaRamp = -0.1f,
VibStrength = 0.22f,
VibSpeed = 0.28f,
RepeatSpeed = 0.42f
};
public static SfxrParams Powerup() => new()
{
WaveType = Waveform.Sine,
BaseFreq = 0.36f,
FreqRamp = 0.28f,
EnvAttack = 0,
EnvSustain = 0.24f,
EnvDecay = 0.28f,
RepeatSpeed = 0.55f,
VibStrength = 0.18f,
VibSpeed = 0.33f
};
public static SfxrParams Hit() => new()
{
WaveType = Waveform.Noise,
BaseFreq = 0.34f,
FreqRamp = -0.48f,
EnvAttack = 0,
EnvSustain = 0.05f,
EnvDecay = 0.2f,
HpfFreq = 0.12f
};
public static SfxrParams Jump() => new()
{
WaveType = Waveform.Square,
Duty = 0.24f,
BaseFreq = 0.42f,
FreqRamp = 0.22f,
EnvAttack = 0,
EnvSustain = 0.22f,
EnvDecay = 0.18f,
HpfFreq = 0.05f,
LpfFreq = 0.72f
};
public static SfxrParams Pickup() => new()
{
BaseFreq = 0.58f,
EnvAttack = 0,
EnvSustain = 0.08f,
EnvDecay = 0.24f,
EnvPunch = 0.45f,
ArpSpeed = 0.58f,
ArpMod = 0.34f
};
public static SfxrParams Laser() => new()
{
WaveType = Waveform.Sawtooth,
BaseFreq = 0.72f,
FreqLimit = 0.18f,
FreqRamp = -0.42f,
Duty = 0.38f,
DutyRamp = -0.16f,
EnvAttack = 0,
EnvSustain = 0.19f,
EnvDecay = 0.18f,
PhaOffset = 0.09f,
PhaRamp = -0.12f,
HpfFreq = 0.04f
};
public SfxrParams Mutate(ulong seed, float amount)
{
var rng = new SeededRng(seed);
amount = Math.Max(0, amount);
float Nudge(float value, float min, float max) => Math.Clamp(value + rng.Range(-amount, amount), min, max);
return this with
{
BaseFreq = Nudge(BaseFreq, 0, 1),
FreqRamp = Nudge(FreqRamp, -1, 1),
FreqDramp = Nudge(FreqDramp, -1, 1),
Duty = Nudge(Duty, 0, 1),
DutyRamp = Nudge(DutyRamp, -1, 1),
VibStrength = Nudge(VibStrength, 0, 1),
VibSpeed = Nudge(VibSpeed, 0, 1),
VibDelay = Nudge(VibDelay, 0, 1),
EnvAttack = Nudge(EnvAttack, 0, 1),
EnvSustain = Nudge(EnvSustain, 0, 1),
EnvDecay = Nudge(EnvDecay, 0, 1),
EnvPunch = Nudge(EnvPunch, -1, 1),
LpfResonance = Nudge(LpfResonance, 0, 1),
LpfFreq = Nudge(LpfFreq, 0, 1),
LpfRamp = Nudge(LpfRamp, -1, 1),
HpfFreq = Nudge(HpfFreq, 0, 1),
HpfRamp = Nudge(HpfRamp, -1, 1),
PhaOffset = Nudge(PhaOffset, -1, 1),
PhaRamp = Nudge(PhaRamp, -1, 1),
RepeatSpeed = Nudge(RepeatSpeed, 0, 1),
ArpSpeed = Nudge(ArpSpeed, 0, 1),
ArpMod = Nudge(ArpMod, -1, 1)
};
}
public SynthPatch ToPatch()
{
var baseFrequency = SfxrFrequencyHz(BaseFreq);
var voice = new Voice
{
Oscillator = new Oscillator(WaveType, baseFrequency, 0.5f - Math.Clamp(Duty, 0, 1) * 0.5f),
Envelope = new Envelope(
NormalizedEnvSeconds(EnvAttack),
NormalizedEnvSeconds(EnvSustain),
NormalizedEnvSeconds(EnvDecay),
Math.Clamp(EnvPunch, -1, 1)),
Pitch = new PitchMotion(
Math.Min(SfxrFrequencyHz(FreqLimit), baseFrequency),
-Cube(FreqRamp) * 9.5f,
-Cube(FreqDramp) * 0.65f,
Math.Clamp(VibStrength, 0, 1) * 0.5f,
2 + Square(Math.Clamp(VibSpeed, 0, 1)) * 18,
Square(Math.Clamp(VibDelay, 0, 1)) * 0.8f),
Duty = new DutyMotion(-DutyRamp * 0.35f),
Filter = new Filter(
Math.Clamp(LpfFreq, 0, 1),
Math.Clamp(LpfRamp, -1, 1),
Math.Clamp(LpfResonance, 0, 1),
Math.Clamp(HpfFreq, 0, 1),
Math.Clamp(HpfRamp, -1, 1)),
Phaser = new Phaser(
MathF.Sign(PhaOffset) * MathF.Abs(Square(PhaOffset)) * 0.018f,
MathF.Sign(PhaRamp) * MathF.Abs(Square(PhaRamp)) * 0.035f),
Arpeggio = ArpSpeed > 0
? new Arpeggio(
Square(1 - ArpSpeed) * 0.46f + 0.0007f,
ArpMod >= 0
? 1 / Math.Max(0.1f, 1 - Square(ArpMod) * 0.9f)
: Math.Max(0.1f, 1 - Square(ArpMod) * 0.75f))
: null,
Color = new VoiceColor(
WaveType == Waveform.Noise ? 0.35f : 0,
0.12f + Math.Max(0, EnvPunch) * 0.18f,
0,
Math.Clamp(VibStrength, 0, 1) * 0.12f,
8 + Math.Clamp(VibSpeed, 0, 1) * 18),
Gain = 0.22f
};
return new SynthPatch
{
Voices = [voice],
Repeat = RepeatSpeed > 0 ? new Repeat(Square(1 - RepeatSpeed) * 0.46f + 0.02f) : null,
Gain = 1,
SoftClip = true
};
}
private static float SfxrFrequencyHz(float value)
{
var period = 100 / (Square(Math.Clamp(value, 0, 1)) + 0.001f);
return Math.Clamp(44100 / period, 20, 20000);
}
private static float NormalizedEnvSeconds(float value) => Square(Math.Clamp(value, 0, 1)) * 100000 / 44100;
private static float Square(float value) => value * value;
private static float Cube(float value) => value * value * value;
}
file struct SeededRng(ulong seed)
{
private ulong _state = seed ^ 0x517c_c1b7_2722_0a95UL;
public float NextFloat()
{
_state = (_state + 0x9e37_79b9_7f4a_7c15UL).RotateLeft(17);
var value = _state;
value ^= value >> 30;
value *= 0xbf58_476d_1ce4_e5b9UL;
value ^= value >> 27;
value *= 0x94d0_49bb_1331_11ebUL;
value ^= value >> 31;
return (value >> 40) / 16777216f;
}
public float Range(float min, float max) => min + (max - min) * NextFloat();
}
file static class UlongExtensions
{
public static ulong RotateLeft(this ulong value, int offset) => (value << offset) | (value >> (64 - offset));
}

View file

@ -53,11 +53,60 @@ public sealed class PatchScriptTests
var patch = PatchScript.Parse(BuiltInScripts.PatchScriptExample);
var export = FaustEmitter.Emit(patch);
Assert.Equal(2, patch.Voices.Count);
Assert.Equal(3, patch.Voices.Count);
Assert.Equal(5, patch.Controls.Count);
Assert.Contains("patch_mod_formant_mix", export.Source);
}
[Fact]
public void SfxrAtomsAndMutationsParse()
{
var named = PatchScript.Parse("laser");
var verbose = PatchScript.Parse("sfxr preset=laser mutate_seed=9 mutate=0.01");
var golfed = PatchScript.Parse("s p=laser ms=9 m=0.01");
Assert.Single(named.Voices);
Assert.Single(verbose.Voices);
Assert.Single(golfed.Voices);
Assert.Equal(verbose.Voices[0].Oscillator.FrequencyHz, golfed.Voices[0].Oscillator.FrequencyHz);
}
[Fact]
public void ScriptMetricsScoreTerseAndReadableInputs()
{
var terse = PatchScriptScoring.Measure("v w=sq f=80 g=.2 s=.1 d=.2");
var readable = PatchScriptScoring.Measure("""
voice wave=square freq=80 gain=0.2 sustain=0.1 decay=0.2
""");
Assert.True(terse.TerseScore > readable.TerseScore);
Assert.True(readable.ReadabilityScore > terse.ReadabilityScore);
Assert.InRange(terse.BalancedScore, 0, 1);
}
[Fact]
public void AudioAnalyzerComparesSimpleBuffers()
{
var samples = Enumerable.Range(0, 2048)
.Select(i => MathF.Sin(i * MathF.Tau * 440 / 44100) * 0.2f)
.ToArray();
var comparison = new AudioAnalyzer().Compare(samples, samples);
Assert.True(comparison.Reference.Features.Peak > 0.19f);
Assert.True(comparison.Score > 0.99f);
}
[Fact]
public void AquariumPresetsExportFaust()
{
foreach (var patch in new[] { Presets.AquariumPluck(), Presets.AquariumHeartbeat(), Presets.AquariumVoice(), Presets.Sfxr("pickup") })
{
var export = FaustEmitter.Emit(patch);
Assert.Contains("process =", export.Source);
}
}
[Fact]
public void ClassicAbstractGolfScriptParsesAndExportsFaust()
{