Batch Faust speech loss through exported controls

This commit is contained in:
Meta Vens 2026-05-19 19:24:15 +01:00
commit 14b118ce86
6 changed files with 483 additions and 4 deletions

View file

@ -21,6 +21,17 @@ public sealed record FaustRenderedSpeechLoss(
FaustRender Candidate,
AudioComparison Comparison);
public sealed record FaustRenderedSpeechCandidateLoss(
VocalTractControlTarget Target,
float Loss,
float[] OutputGradient,
FaustRender Render,
AudioComparison Comparison);
public sealed record FaustRenderedSpeechBatchLoss(
FaustRender Reference,
IReadOnlyList<FaustRenderedSpeechCandidateLoss> Candidates);
public sealed class FaustRenderedSpeechLossSurface(FaustRenderedSpeechLossOptions? options = null, string? faustPath = null)
{
private readonly FaustRenderedSpeechLossOptions options = options ?? new FaustRenderedSpeechLossOptions();
@ -103,7 +114,7 @@ public sealed class FaustRenderedSpeechLossSurface(FaustRenderedSpeechLossOption
}
private IReadOnlyList<int> GradientIndices() =>
options.GradientOutputIndices ?? Enumerable.Range(14, options.MelBandCount).ToArray();
options.GradientOutputIndices ?? Enumerable.Range(0, 14 + options.MelBandCount).ToArray();
private AudioAnalyzer Analyzer() =>
new(new AudioAnalysisConfig(
@ -167,3 +178,230 @@ public sealed class FaustRenderedSpeechLossSurface(FaustRenderedSpeechLossOption
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);
}
public sealed class CompiledFaustRenderedSpeechLossSurface : IDisposable
{
private readonly AquaSynthPatchCompiler compiler;
private readonly AquaSynthCompiledPatch patch;
private readonly FaustRenderedSpeechLossOptions options;
private bool disposed;
private CompiledFaustRenderedSpeechLossSurface(
AquaSynthPatchCompiler compiler,
AquaSynthCompiledPatch patch,
FaustRenderedSpeechLossOptions options)
{
this.compiler = compiler;
this.patch = patch;
this.options = options;
}
public IReadOnlyList<string> ControlPaths => patch.ControlPaths;
public static bool TryCreate(
out CompiledFaustRenderedSpeechLossSurface? surface,
out string? error,
FaustRenderedSpeechLossOptions? options = null,
AquaSynthNativeOptions? nativeOptions = null)
{
options ??= new FaustRenderedSpeechLossOptions();
var runtimeOptions = (nativeOptions ?? new AquaSynthNativeOptions()) with
{
SampleRate = options.SampleRate,
MinRenderSeconds = options.DurationSeconds,
MaxRenderSeconds = Math.Max(options.DurationSeconds, nativeOptions?.MaxRenderSeconds ?? options.DurationSeconds)
};
var compiler = new AquaSynthPatchCompiler(runtimeOptions);
var source = ControllableSource(options);
if (!compiler.TryCompileSource(
new AquaSynthCompileIdentity("speech_loss_surface", "speech_loss_surface", source),
source,
options.DurationSeconds,
out var patch,
out error))
{
compiler.Dispose();
surface = null;
return false;
}
surface = new CompiledFaustRenderedSpeechLossSurface(compiler, patch!, options);
error = null;
return true;
}
public FaustRender Render(VocalTractControlTarget target)
{
ObjectDisposedException.ThrowIf(disposed, this);
var samples = patch.Render(ControlsFor(target));
return new FaustRender(samples, options.SampleRate, "native-faust", "", "");
}
public FaustRenderedSpeechLoss Evaluate(VocalTractControlTarget reference, VocalTractControlTarget candidate)
{
var batch = EvaluateBatch(reference, [candidate]);
var loss = batch.Candidates[0];
return new FaustRenderedSpeechLoss(loss.Loss, loss.OutputGradient, batch.Reference, loss.Render, loss.Comparison);
}
public FaustRenderedSpeechBatchLoss EvaluateBatch(
VocalTractControlTarget reference,
IReadOnlyList<VocalTractControlTarget> candidates)
{
ObjectDisposedException.ThrowIf(disposed, this);
var referenceRender = Render(reference);
var results = new List<FaustRenderedSpeechCandidateLoss>(candidates.Count);
foreach (var candidate in candidates)
{
var candidateRender = Render(candidate);
var comparison = Analyzer().Compare(referenceRender.Samples, candidateRender.Samples);
var gradient = EstimateGradient(referenceRender.Samples, candidate, comparison.LogMelDistance);
results.Add(new FaustRenderedSpeechCandidateLoss(
candidate,
comparison.LogMelDistance,
gradient,
candidateRender,
comparison));
}
return new FaustRenderedSpeechBatchLoss(referenceRender, results);
}
public void Dispose()
{
if (disposed)
{
return;
}
patch.Dispose();
compiler.Dispose();
disposed = true;
}
public static string ControllableSource(FaustRenderedSpeechLossOptions? options = null)
{
options ??= new FaustRenderedSpeechLossOptions();
var source = new StringBuilder();
source.AppendLine("import(\"stdfaust.lib\");");
source.AppendLine("declare name \"aquasynth_speech_loss_surface\";");
source.AppendLine("time = ba.time / ma.SR;");
source.AppendLine("clip01(x) = min(1.0, max(0.0, x));");
source.AppendLine("sine(hz) = os.osc(hz);");
for (var index = 0; index < 14 + options.MelBandCount; index++)
{
source.AppendLine($"o{index} = hslider(\"{OutputPath(index)}\", 0.5, 0.0, 1.0, 0.0001) : si.smoo;");
}
source.AppendLine("fm = sine((120.0 + o5 * 110.0) * 2.0) * o9 * 0.18;");
source.AppendLine("glottis = sine((120.0 + o5 * 110.0) * (1.0 + fm)) * (0.08 + o7 * 0.22);");
source.AppendLine("breath = no.noise * o6 * 0.18;");
for (var band = 0; band < options.MelBandCount; band++)
{
source.AppendLine($"mel{band} = sine({F(MelBandCenterHz(options, band))}) * o{14 + band} * o{14 + band} * 0.08;");
}
var melMix = options.MelBandCount == 0
? "0.0"
: string.Join(" + ", Enumerable.Range(0, options.MelBandCount).Select(index => $"mel{index}"));
source.AppendLine($"raw = glottis + breath + {melMix};");
source.AppendLine("trem = 1.0 - o8 * (0.5 + 0.5 * sine(2.0 + o10 * 11.0));");
source.AppendLine("filtered = raw * trem : fi.lowpass(2, min(ma.SR * 0.45, max(20.0, o12 * 18000.0)));");
var keepControls = string.Join(" + ", Enumerable.Range(0, 14 + options.MelBandCount).Select(index => $"o{index}"));
source.AppendLine($"process = filtered * 0.85 + 0.000000001 * ({keepControls});");
return source.ToString();
}
public static string OutputPath(int outputIndex) => $"speech/output/{outputIndex}";
private float[] EstimateGradient(
IReadOnlyList<float> referenceSamples,
VocalTractControlTarget candidate,
float baselineLoss)
{
var gradient = new float[14 + options.MelBandCount];
foreach (var index in GradientIndices())
{
var perturbed = Perturb(candidate, index, options.FiniteDifferenceStep);
var render = Render(perturbed);
var loss = Analyzer().Compare(referenceSamples.ToArray(), render.Samples).LogMelDistance;
gradient[index] = (loss - baselineLoss) / options.FiniteDifferenceStep;
}
return gradient;
}
private IReadOnlyList<int> GradientIndices() =>
options.GradientOutputIndices ?? Enumerable.Range(0, 14 + options.MelBandCount).ToArray();
private IReadOnlyDictionary<string, float> ControlsFor(VocalTractControlTarget target)
{
var values = target.ToVector(options.MelBandCount);
var controls = new Dictionary<string, float>(values.Length, StringComparer.Ordinal);
for (var index = 0; index < values.Length; index++)
{
controls[OutputPath(index)] = Math.Clamp(values[index], 0, 1);
}
return controls;
}
private AudioAnalyzer Analyzer() =>
new(new AudioAnalysisConfig(
SampleRate: options.SampleRate,
FftSize: 256,
HopSize: 96,
MelBandCount: options.MelBandCount,
MinFrequencyHz: options.MinFrequencyHz,
MaxFrequencyHz: options.MaxFrequencyHz));
private static VocalTractControlTarget Perturb(VocalTractControlTarget target, int outputIndex, float delta)
{
var values = target.ToVector(Math.Max(0, outputIndex - 13));
if (outputIndex >= values.Length)
{
Array.Resize(ref values, outputIndex + 1);
}
values[outputIndex] = Math.Clamp(values[outputIndex] + delta, 0, 1);
return TargetFromVector(values);
}
private static VocalTractControlTarget TargetFromVector(IReadOnlyList<float> values)
{
var mel = new float[Math.Max(0, values.Count - 14)];
for (var index = 0; index < mel.Length; index++)
{
mel[index] = values[14 + index];
}
return new VocalTractControlTarget(
values.ElementAtOrDefault(0),
values.ElementAtOrDefault(1),
values.ElementAtOrDefault(2),
values.ElementAtOrDefault(3),
values.ElementAtOrDefault(4),
values.ElementAtOrDefault(5),
values.ElementAtOrDefault(6),
values.ElementAtOrDefault(7),
values.ElementAtOrDefault(8),
values.ElementAtOrDefault(9),
values.ElementAtOrDefault(10),
values.ElementAtOrDefault(11),
values.ElementAtOrDefault(12),
values.ElementAtOrDefault(13),
mel);
}
private static float MelBandCenterHz(FaustRenderedSpeechLossOptions options, int band)
{
var minMel = HzToMel(options.MinFrequencyHz);
var maxMel = HzToMel(options.MaxFrequencyHz);
var t = (band + 0.5f) / Math.Max(1, options.MelBandCount);
return MelToHz(minMel + (maxMel - minMel) * t);
}
private static string F(float value) => value.ToString("0.######", CultureInfo.InvariantCulture);
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);
}

View file

@ -42,22 +42,36 @@ public sealed class AquaSynthCompiledPatch : IDisposable
private readonly IntPtr factory;
private bool disposed;
internal AquaSynthCompiledPatch(FaustNativeToolchain toolchain, IntPtr factory, AquaSynthNativeManifest manifest)
internal AquaSynthCompiledPatch(
FaustNativeToolchain toolchain,
IntPtr factory,
AquaSynthNativeManifest manifest,
IReadOnlyList<string> controlPaths)
{
this.toolchain = toolchain;
this.factory = factory;
Manifest = manifest;
ControlPaths = controlPaths;
}
public AquaSynthNativeManifest Manifest { get; }
public float[] Render(float gain = 1.0f)
public IReadOnlyList<string> ControlPaths { get; }
public float[] Render(float gain = 1.0f) => Render(null, gain);
public float[] Render(IReadOnlyDictionary<string, float>? controls, float gain = 1.0f)
{
ObjectDisposedException.ThrowIf(disposed, this);
var dsp = toolchain.CreateInstance(factory);
try
{
toolchain.InitInstance(dsp, Manifest.SampleRate);
if (controls is not null)
{
toolchain.SetControls(dsp, controls);
}
var outputs = new float[Manifest.OutputCount][];
var outputPointers = new IntPtr[Manifest.OutputCount];
var handles = new GCHandle[Manifest.OutputCount];
@ -163,6 +177,37 @@ public sealed class AquaSynthPatchCompiler : IDisposable
}
}
public bool TryCompileSource(
AquaSynthCompileIdentity identity,
string source,
float durationSeconds,
out AquaSynthCompiledPatch? patch,
out string? error)
{
ObjectDisposedException.ThrowIf(disposed, this);
patch = null;
if (!EnsureCompiler(out error))
{
return false;
}
try
{
patch = compiler!.CompileSource(
identity,
AquaSynthNativeCompiler.SafeFaustName(identity.FaustName),
source,
durationSeconds);
error = null;
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool TryRenderScript(string name, string script, float gain, out float[] samples, out string? error)
{
samples = [];
@ -311,6 +356,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable
{
var outputs = Math.Max(toolchain.GetNumOutputs(probe), 1);
var frames = Math.Max(1, (int)MathF.Ceiling(durationSeconds * options.SampleRate));
var controlPaths = toolchain.ControlPaths(probe);
stopwatch.Stop();
var manifest = new AquaSynthNativeManifest(
identity.Id,
@ -325,7 +371,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable
toolchain.Version,
toolchain.Home,
dspSourcePath);
return new AquaSynthCompiledPatch(toolchain, factory, manifest);
return new AquaSynthCompiledPatch(toolchain, factory, manifest, controlPaths);
}
finally
{
@ -435,6 +481,7 @@ internal sealed class FaustNativeToolchain : IDisposable
private readonly InitInstanceFn initInstance;
private readonly ComputeInstanceFn computeInstance;
private readonly GetNumOutputsFn getNumOutputs;
private readonly BuildUserInterfaceFn buildUserInterface;
private readonly StartFactoriesFn startFactories;
private readonly StopFactoriesFn stopFactories;
private bool disposed;
@ -452,6 +499,7 @@ internal sealed class FaustNativeToolchain : IDisposable
initInstance = Export<InitInstanceFn>("initCDSPInstance");
computeInstance = Export<ComputeInstanceFn>("computeCDSPInstance");
getNumOutputs = Export<GetNumOutputsFn>("getNumOutputsCDSPInstance");
buildUserInterface = Export<BuildUserInterfaceFn>("buildUserInterfaceCDSPInstance");
startFactories = Export<StartFactoriesFn>("startMTDSPFactories");
stopFactories = Export<StopFactoriesFn>("stopMTDSPFactories");
_ = startFactories();
@ -524,6 +572,24 @@ internal sealed class FaustNativeToolchain : IDisposable
public int GetNumOutputs(IntPtr dsp) => getNumOutputs(dsp);
public IReadOnlyList<string> ControlPaths(IntPtr dsp) =>
BuildControlMap(dsp).Keys.Order(StringComparer.Ordinal).ToArray();
public void SetControls(IntPtr dsp, IReadOnlyDictionary<string, float> controls)
{
var map = BuildControlMap(dsp);
foreach (var (path, value) in controls)
{
if (!map.TryGetValue(path, out var zone) &&
!map.TryGetValue(path.TrimStart('/'), out zone))
{
throw new ArgumentException($"Faust control `{path}` was not exported by the compiled patch.", nameof(controls));
}
Marshal.Copy([value], 0, zone, 1);
}
}
public void Dispose()
{
if (disposed)
@ -542,6 +608,22 @@ internal sealed class FaustNativeToolchain : IDisposable
return Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(library, name));
}
private Dictionary<string, IntPtr> BuildControlMap(IntPtr dsp)
{
var collector = new FaustControlCollector();
var handle = GCHandle.Alloc(collector);
try
{
var glue = FaustUiGlue.Create(GCHandle.ToIntPtr(handle));
buildUserInterface(dsp, ref glue);
return collector.Zones;
}
finally
{
handle.Free();
}
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate IntPtr GetVersion();
@ -566,11 +648,98 @@ internal sealed class FaustNativeToolchain : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int GetNumOutputsFn(IntPtr dsp);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void BuildUserInterfaceFn(IntPtr dsp, ref FaustUiGlue glue);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate bool StartFactoriesFn();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void StopFactoriesFn();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void OpenBoxFn(IntPtr ui, IntPtr label);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CloseBoxFn(IntPtr ui);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AddButtonFn(IntPtr ui, IntPtr label, IntPtr zone);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AddSliderFn(IntPtr ui, IntPtr label, IntPtr zone, float init, float min, float max, float step);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AddBargraphFn(IntPtr ui, IntPtr label, IntPtr zone, float min, float max);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AddSoundfileFn(IntPtr ui, IntPtr label, IntPtr url, IntPtr sfZone);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void DeclareFn(IntPtr ui, IntPtr zone, IntPtr key, IntPtr value);
[StructLayout(LayoutKind.Sequential)]
private struct FaustUiGlue
{
public IntPtr UiInterface;
public OpenBoxFn OpenTabBox;
public OpenBoxFn OpenHorizontalBox;
public OpenBoxFn OpenVerticalBox;
public CloseBoxFn CloseBox;
public AddButtonFn AddButton;
public AddButtonFn AddCheckButton;
public AddSliderFn AddVerticalSlider;
public AddSliderFn AddHorizontalSlider;
public AddSliderFn AddNumEntry;
public AddBargraphFn AddHorizontalBargraph;
public AddBargraphFn AddVerticalBargraph;
public AddSoundfileFn AddSoundfile;
public DeclareFn Declare;
public static FaustUiGlue Create(IntPtr ui) =>
new()
{
UiInterface = ui,
OpenTabBox = NoOpenBox,
OpenHorizontalBox = NoOpenBox,
OpenVerticalBox = NoOpenBox,
CloseBox = NoCloseBox,
AddButton = AddControl,
AddCheckButton = AddControl,
AddVerticalSlider = AddControl,
AddHorizontalSlider = AddControl,
AddNumEntry = AddControl,
AddHorizontalBargraph = NoBargraph,
AddVerticalBargraph = NoBargraph,
AddSoundfile = NoSoundfile,
Declare = NoDeclare
};
}
private sealed class FaustControlCollector
{
public Dictionary<string, IntPtr> Zones { get; } = new(StringComparer.Ordinal);
public void Add(IntPtr label, IntPtr zone)
{
var path = Marshal.PtrToStringUTF8(label);
if (!string.IsNullOrWhiteSpace(path))
{
Zones[path] = zone;
}
}
}
private static FaustControlCollector Collector(IntPtr ui) =>
(FaustControlCollector)GCHandle.FromIntPtr(ui).Target!;
private static void NoOpenBox(IntPtr ui, IntPtr label) { }
private static void NoCloseBox(IntPtr ui) { }
private static void AddControl(IntPtr ui, IntPtr label, IntPtr zone) => Collector(ui).Add(label, zone);
private static void AddControl(IntPtr ui, IntPtr label, IntPtr zone, float init, float min, float max, float step) => Collector(ui).Add(label, zone);
private static void NoBargraph(IntPtr ui, IntPtr label, IntPtr zone, float min, float max) { }
private static void NoSoundfile(IntPtr ui, IntPtr label, IntPtr url, IntPtr sfZone) { }
private static void NoDeclare(IntPtr ui, IntPtr zone, IntPtr key, IntPtr value) { }
}
internal sealed class NativeUtf8String : IDisposable

View file

@ -115,3 +115,4 @@
{"date":"2026-05-19","topic":"espeak-gradient-descent-fixture","lesson":"The first speech-learning fixture should train on generated eSpeak evidence before pretending AquaSynth has waveform-loss vocal-tract backprop. eSpeak log-mel means can supervise both utterance embedding targets and synth-driver mel/control lanes, giving the packed optimizer real generated speech pressure while the renderer is still catching up.","evidence":"Added EspeakNgGradientDescentTests. When ESPEAK_NG or espeak/espeak-ng is available, it renders a/pa/ta/ka/sa/ma, extracts log-mel means, trains UtteranceEmbeddingNeuralEncoder, trains VocalTractNeuralMapper against eSpeak-seeded control targets, asserts loss reductions, and writes artifacts/parity/espeak-ng-gradient-descent/<timestamp>-tiny/training-report.md."}
{"date":"2026-05-19","topic":"speech-pipeline-chained-backprop","lesson":"The speech-learning path now has real chained backprop between the utterance encoder and synth driver. The packed network can return gradients with respect to its input, so downstream synth-driver loss can update the upstream utterance encoder instead of training the two models as separate supervised islands.","evidence":"Added PackedNeuralBackpropagation, TrainSingle/TrainSingleFromOutputGradient, SpeechBackpropagationPipeline, and SpeechBackpropagationPipelineTests; updated the eSpeak gradient fixture to train the utterance encoder from synth-driver loss rather than from a separate compressed-mel target."}
{"date":"2026-05-19","topic":"faust-rendered-speech-loss","lesson":"For speech learning, Core should accept external synth-output gradients while Faust owns rendered loss evaluation. Finite-difference gradients over controller outputs are honest black-box renderer pressure, but too slow for default tests until render compilation is cached or proxied.","evidence":"Added SpeechBackpropagationPipeline.TrainSingleFromSynthOutputGradient and FaustRenderedSpeechLossSurface. The opt-in AQUASYNTH_RUN_FAUST_SPEECH_LOSS=1 proof renders reference and candidate speech targets through Faust, compares log-mel evidence, and returns a finite-difference output gradient for backpropagation through the learned chain."}
{"date":"2026-05-19","topic":"compiled-faust-speech-loss-surface","lesson":"The usable speech loss surface should compile topology once and vary every neural mapper output through exported Faust controls. Batch rendering through a native compiled factory makes finite-difference and curriculum learning plausible; recompiling per candidate is only a proof scaffold.","evidence":"Added native Faust UI control discovery/set support, AquaSynthCompiledPatch.Render(controlValues), and CompiledFaustRenderedSpeechLossSurface with speech/output/N knobs for all mapper outputs. The opt-in proof renders multiple candidates through one compiled patch and caught the need to clamp dynamic filter cutoff below Nyquist."}

View file

@ -80,6 +80,7 @@ boundaries:
- external-gradient speech backpropagation entry point for rendered loss surfaces
- optional eSpeak NG generated ground-truth fixture for supervised utterance-embedding and synth-driver gradient descent
- Faust-rendered speech loss surface in AquaSynth.Faust, using rendered audio analysis and finite-difference controller-output gradients
- compiled native Faust speech-loss surface with exported /speech/output/N controls for batch candidate rendering without recompiling DSP
dev_only:
- public-domain DX7 SysEx fixtures
- pinned GPL ZynAddSubFX source for parity tests

View file

@ -2,6 +2,31 @@
Current slice:
- Reworked the Faust-rendered speech-loss path toward the actual curriculum
machine: `CompiledFaustRenderedSpeechLossSurface` compiles one native Faust
patch with exported `speech/output/N` hslider controls for every neural
mapper output, then renders reference/candidate batches by setting controls
on new DSP instances from the same compiled factory.
- `AquaSynthCompiledPatch` now exposes `ControlPaths` and supports
`Render(IReadOnlyDictionary<string,float>)`. `FaustNativeToolchain` builds a
C UI glue map from Faust control labels to native zones and writes parameter
values before `compute`.
- The compiled speech probe keeps every mapper output as an exported knob even
when a current simple synth rung does not use the value strongly. The tiny
keepalive term exists for that invariant: topology and control surface stay
stable across curriculum rungs.
- Rendered proof found a real loss-surface hazard: dynamic low-pass cutoff can
exceed Nyquist at lower training sample rates and produce NaNs. The compiled
probe clamps cutoff to `ma.SR * 0.45`.
- Verification:
`dotnet test tests\AquaSynth.Dsl.Tests\AquaSynth.Dsl.Tests.csproj --filter "FaustRenderedSpeechLossSurfaceTests|NativeFaustRuntimeTests|SpeechBackpropagationPipelineTests" -v minimal`:
6 passed.
- Opt-in native batch proof:
`AQUASYNTH_RUN_FAUST_SPEECH_LOSS=1 dotnet test tests\AquaSynth.Dsl.Tests\AquaSynth.Dsl.Tests.csproj --filter "CompiledFaustRenderedSpeechLossSurfaceReusesOnePatch" -v minimal`:
1 passed.
Previous slice:
- Added the first Faust-rendered speech loss surface. `AquaSynth.Faust`
now owns `FaustRenderedSpeechLossSurface`, which turns a
`VocalTractControlTarget` into an inspectable AquaSynth script, renders it

View file

@ -30,6 +30,51 @@ public sealed class FaustRenderedSpeechLossSurfaceTests
Assert.Contains("process =", surface.SourceFor(candidate));
}
[Fact]
public void CompiledFaustRenderedSpeechLossSurfaceReusesOnePatchWithExportedOutputKnobsWhenToolchainIsAvailable()
{
if (Environment.GetEnvironmentVariable("AQUASYNTH_RUN_FAUST_SPEECH_LOSS") != "1")
{
return;
}
var options = new FaustRenderedSpeechLossOptions(
SampleRate: 16000,
DurationSeconds: 0.10f,
MelBandCount: 4,
GradientOutputIndices: [14, 15]);
if (!CompiledFaustRenderedSpeechLossSurface.TryCreate(out var surface, out var error, options))
{
if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
{
return;
}
Assert.Fail(error);
}
using (surface)
{
Assert.Contains(CompiledFaustRenderedSpeechLossSurface.OutputPath(0), surface!.ControlPaths);
Assert.Contains(CompiledFaustRenderedSpeechLossSurface.OutputPath(17), surface.ControlPaths);
var reference = Target([0.90f, 0.78f, 0.28f, 0.12f], turbulence: 0.08f, cutoff: 0.46f);
var candidates = new[]
{
Target([0.18f, 0.25f, 0.76f, 0.88f], turbulence: 0.40f, cutoff: 0.80f),
Target([0.84f, 0.74f, 0.32f, 0.18f], turbulence: 0.10f, cutoff: 0.48f)
};
var batch = surface.EvaluateBatch(reference, candidates);
Assert.Equal(2, batch.Candidates.Count);
Assert.True(batch.Candidates[0].Loss > batch.Candidates[1].Loss, "closer controller values should produce a lower rendered loss");
Assert.Contains(batch.Candidates[0].OutputGradient, value => Math.Abs(value) > 0.0001f);
Assert.Equal(batch.Reference.Samples.Length, batch.Candidates[0].Render.Samples.Length);
}
}
private static VocalTractControlTarget Target(IReadOnlyList<float> mel, float turbulence, float cutoff) =>
new(
TongueBody: 0.55f,