Port reusable Rust synth tooling
This commit is contained in:
parent
ccd94e3088
commit
3246672bbe
9 changed files with 882 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
323
src/AquariumSynth.Dsl/AudioAnalysis.cs
Normal file
323
src/AquariumSynth.Dsl/AudioAnalysis.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
67
src/AquariumSynth.Dsl/Presets.cs
Normal file
67
src/AquariumSynth.Dsl/Presets.cs
Normal 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
|
||||
};
|
||||
}
|
||||
101
src/AquariumSynth.Dsl/ScriptMetrics.cs
Normal file
101
src/AquariumSynth.Dsl/ScriptMetrics.cs
Normal 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);
|
||||
}
|
||||
251
src/AquariumSynth.Dsl/SfxrParams.cs
Normal file
251
src/AquariumSynth.Dsl/SfxrParams.cs
Normal 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));
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue