diff --git a/README.md b/README.md index 85bf1d8..f585c67 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/migration-checklist.md b/docs/migration-checklist.md index ddfdbd6..25fda1d 100644 --- a/docs/migration-checklist.md +++ b/docs/migration-checklist.md @@ -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 diff --git a/src/AquariumSynth.Dsl/AudioAnalysis.cs b/src/AquariumSynth.Dsl/AudioAnalysis.cs new file mode 100644 index 0000000..43e9ea3 --- /dev/null +++ b/src/AquariumSynth.Dsl/AudioAnalysis.cs @@ -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 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 referenceSamples, ReadOnlySpan candidateSamples) => + FromAnalysis(Analyze(referenceSamples), Analyze(candidateSamples)); + + private AudioFeatures ExtractFeatures(ReadOnlySpan 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.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 samples) + { + var bands = Math.Max(1, _config.MelBandCount); + if (samples.IsEmpty) return new Spectrogram(1, bands, new float[bands]); + + var values = new List(); + 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 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 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 samples, AudioAnalysisConfig? config = null) => + new AudioAnalyzer(config).Analyze(samples); + + public static AudioComparison CompareAudio(ReadOnlySpan referenceSamples, ReadOnlySpan 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 samples, int windowSize, int hopSize) + { + windowSize = Math.Max(16, windowSize); + hopSize = Math.Max(1, hopSize); + if (samples.IsEmpty) return [0]; + + var envelope = new List(); + 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 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 reference, IReadOnlyList 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 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 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); +} diff --git a/src/AquariumSynth.Dsl/BuiltInScripts.cs b/src/AquariumSynth.Dsl/BuiltInScripts.cs index a265404..5c51f22 100644 --- a/src/AquariumSynth.Dsl/BuiltInScripts.cs +++ b/src/AquariumSynth.Dsl/BuiltInScripts.cs @@ -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 ClassicSfxrNames = diff --git a/src/AquariumSynth.Dsl/PatchScript.cs b/src/AquariumSynth.Dsl/PatchScript.cs index 5546157..b08c1d1 100644 --- a/src/AquariumSynth.Dsl/PatchScript.cs +++ b/src/AquariumSynth.Dsl/PatchScript.cs @@ -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(_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 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 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 }; diff --git a/src/AquariumSynth.Dsl/Presets.cs b/src/AquariumSynth.Dsl/Presets.cs new file mode 100644 index 0000000..ea4675b --- /dev/null +++ b/src/AquariumSynth.Dsl/Presets.cs @@ -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 + }; +} diff --git a/src/AquariumSynth.Dsl/ScriptMetrics.cs b/src/AquariumSynth.Dsl/ScriptMetrics.cs new file mode 100644 index 0000000..a8490a0 --- /dev/null +++ b/src/AquariumSynth.Dsl/ScriptMetrics.cs @@ -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 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); +} diff --git a/src/AquariumSynth.Dsl/SfxrParams.cs b/src/AquariumSynth.Dsl/SfxrParams.cs new file mode 100644 index 0000000..7b74691 --- /dev/null +++ b/src/AquariumSynth.Dsl/SfxrParams.cs @@ -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)); +} diff --git a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs index ca64948..19ff408 100644 --- a/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquariumSynth.Dsl.Tests/PatchScriptTests.cs @@ -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() {