Host streaming Faust DSP programs
This commit is contained in:
parent
d669acb274
commit
3931b4c844
8 changed files with 225 additions and 1 deletions
BIN
packages/AquaSynth.Faust.0.1.0-alpha.4.nupkg
Normal file
BIN
packages/AquaSynth.Faust.0.1.0-alpha.4.nupkg
Normal file
Binary file not shown.
|
|
@ -6,6 +6,7 @@ public sealed class AquariumAudioDocument
|
|||
{
|
||||
private readonly ConcurrentQueue<AquariumPcmAudioChunk> pcmChunks = new();
|
||||
private readonly ConcurrentQueue<AquariumAudioControlFrame> controlFrames = new();
|
||||
private readonly ConcurrentQueue<AquariumStreamingDspProgram> streamingDspPrograms = new();
|
||||
|
||||
public static AquariumAudioDocument Empty { get; } = new();
|
||||
|
||||
|
|
@ -85,6 +86,29 @@ public sealed class AquariumAudioDocument
|
|||
|
||||
return drained;
|
||||
}
|
||||
|
||||
public void EnqueueStreamingDspProgram(AquariumStreamingDspProgram program)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(program.ProfileId) ||
|
||||
string.IsNullOrWhiteSpace(program.FaustName) ||
|
||||
string.IsNullOrWhiteSpace(program.FaustSource))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
streamingDspPrograms.Enqueue(program);
|
||||
}
|
||||
|
||||
public IReadOnlyList<AquariumStreamingDspProgram> DrainStreamingDspPrograms(int maxPrograms = 4)
|
||||
{
|
||||
var drained = new List<AquariumStreamingDspProgram>();
|
||||
while (drained.Count < maxPrograms && streamingDspPrograms.TryDequeue(out var program))
|
||||
{
|
||||
drained.Add(program);
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AquariumPcmAudioChunk(float[] MonoSamples, int SampleRate, float LeftGain = 1.0f, float RightGain = 1.0f);
|
||||
|
|
@ -103,3 +127,10 @@ public sealed record AquariumAudioControlCommand(
|
|||
double ResampleRatio,
|
||||
double Confidence,
|
||||
IReadOnlyDictionary<string, float> Controls);
|
||||
|
||||
public sealed record AquariumStreamingDspProgram(
|
||||
string ProfileId,
|
||||
string FaustName,
|
||||
string FaustSource,
|
||||
int Revision,
|
||||
float ProbeDurationSeconds = 0.05f);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Vortice.Direct2D1" Version="3.8.3" />
|
||||
<PackageReference Include="AquaSynth.Faust" Version="0.1.0-alpha.3" />
|
||||
<PackageReference Include="AquaSynth.Faust" Version="0.1.0-alpha.4" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<ProjectReference Include="..\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj" />
|
||||
<PackageReference Include="Vortice.Direct3D11" Version="3.8.3" />
|
||||
|
|
|
|||
114
src/Aquarium.Engine/Audio/AquariumStreamingDspHost.cs
Normal file
114
src/Aquarium.Engine/Audio/AquariumStreamingDspHost.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
using AquaSynth.Faust;
|
||||
|
||||
namespace Aquarium.Engine.Audio;
|
||||
|
||||
internal sealed class AquariumStreamingDspHost : IDisposable
|
||||
{
|
||||
private readonly AquaSynthPatchCompiler compiler;
|
||||
private readonly Dictionary<string, StreamingProgramRuntime> programs = new(StringComparer.Ordinal);
|
||||
|
||||
public AquariumStreamingDspHost(AquaSynthNativeOptions? options = null)
|
||||
{
|
||||
compiler = new AquaSynthPatchCompiler(options);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ProgramIds => programs.Keys.Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
public string? LastError { get; private set; }
|
||||
|
||||
public bool UpsertProgram(AquariumStreamingDspProgram program)
|
||||
{
|
||||
if (program.ProbeDurationSeconds <= 0.0f)
|
||||
{
|
||||
LastError = $"Streaming DSP program `{program.ProfileId}` has invalid probe duration.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (programs.TryGetValue(program.ProfileId, out var existing) &&
|
||||
existing.Revision == program.Revision)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!compiler.TryCompileSource(
|
||||
new AquaSynthCompileIdentity(program.ProfileId, program.FaustName, program.FaustSource, program.Revision),
|
||||
program.FaustSource,
|
||||
program.ProbeDurationSeconds,
|
||||
out var patch,
|
||||
out var error))
|
||||
{
|
||||
LastError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = patch!.CreateStreamingPatch();
|
||||
if (programs.Remove(program.ProfileId, out var old))
|
||||
{
|
||||
old.Dispose();
|
||||
}
|
||||
|
||||
programs[program.ProfileId] = new StreamingProgramRuntime(program.Revision, patch, stream);
|
||||
LastError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ApplyControls(AquariumAudioControlFrame frame)
|
||||
{
|
||||
if (!programs.TryGetValue(frame.ProfileId, out var runtime))
|
||||
{
|
||||
LastError = $"Streaming DSP program `{frame.ProfileId}` is not loaded.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var command in frame.Commands)
|
||||
{
|
||||
runtime.Stream.SetControls(command.Controls);
|
||||
}
|
||||
|
||||
LastError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProcessBlock(string profileId, float[][] inputs, float[][] outputs, int frameCount, IReadOnlyDictionary<string, float>? controls = null)
|
||||
{
|
||||
if (!programs.TryGetValue(profileId, out var runtime))
|
||||
{
|
||||
LastError = $"Streaming DSP program `{profileId}` is not loaded.";
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.Stream.ProcessBlock(inputs, outputs, frameCount, controls);
|
||||
LastError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var runtime in programs.Values)
|
||||
{
|
||||
runtime.Dispose();
|
||||
}
|
||||
|
||||
programs.Clear();
|
||||
compiler.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StreamingProgramRuntime(
|
||||
int revision,
|
||||
AquaSynthCompiledPatch patch,
|
||||
AquaSynthStreamingPatch stream)
|
||||
: IDisposable
|
||||
{
|
||||
public int Revision { get; } = revision;
|
||||
|
||||
public AquaSynthCompiledPatch Patch { get; } = patch;
|
||||
|
||||
public AquaSynthStreamingPatch Stream { get; } = stream;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stream.Dispose();
|
||||
Patch.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ internal sealed class AquariumSynthHost : IDisposable
|
|||
|
||||
private readonly Dictionary<string, PatchRuntime> patches = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, AquariumAudioControlFrame> latestControlFrames = new(StringComparer.Ordinal);
|
||||
private readonly AquariumStreamingDspHost streamingDsp = new();
|
||||
private readonly WasapiAudioDevice audioDevice = new();
|
||||
private readonly AquaSynthPatchCompiler patchCompiler = new(new AquaSynthNativeOptions(DspSourceDirectory: Path.Combine(AppContext.BaseDirectory, "Synth")));
|
||||
private float timeSeconds;
|
||||
|
|
@ -27,9 +28,30 @@ internal sealed class AquariumSynthHost : IDisposable
|
|||
audioDevice.Play(chunk.MonoSamples, chunk.SampleRate, chunk.LeftGain, chunk.RightGain);
|
||||
}
|
||||
|
||||
foreach (var program in audio.DrainStreamingDspPrograms())
|
||||
{
|
||||
if (streamingDsp.UpsertProgram(program))
|
||||
{
|
||||
if (TraceAudio)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"Aquarium streaming DSP loaded: profile={program.ProfileId} faust={program.FaustName} revision={program.Revision}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Aquarium streaming DSP `{program.ProfileId}` compile failed: {streamingDsp.LastError}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var controlFrame in audio.DrainControlFrames())
|
||||
{
|
||||
latestControlFrames[controlFrame.ProfileId] = controlFrame;
|
||||
if (!streamingDsp.ApplyControls(controlFrame) && TraceAudio)
|
||||
{
|
||||
Console.WriteLine($"Aquarium audio control pending: {streamingDsp.LastError}");
|
||||
}
|
||||
|
||||
if (TraceAudio)
|
||||
{
|
||||
Console.WriteLine(
|
||||
|
|
@ -259,6 +281,7 @@ internal sealed class AquariumSynthHost : IDisposable
|
|||
runtime.Compiled?.Dispose();
|
||||
}
|
||||
|
||||
streamingDsp.Dispose();
|
||||
audioDevice.Dispose();
|
||||
patchCompiler.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,3 +263,4 @@
|
|||
{"lesson":"Imported field resources can now carry external producer fence synchronization. AquariumFieldResourceDeclaration has optional ProducerFenceHandle/ProducerFenceValue metadata, and D3D12Renderer opens/waits those ID3D12Fence handles before resource resolution and shader sampling, alongside existing Fensalir-owned lease waits. This is the D3D11/D3D12 interop sync path used by Mimir's MF GPU producer. Fensalir build and Aquarium.Engine.Tests passed; Mimir MJPG/H264 MF GPU smokes passed with nonzero producer fence handles.","timestamp":"2026-05-27T17:19:41+01:00","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","state/map.yaml"],"source":"direct MF GPU producer fence handoff","topic":"external-field-resource-producer-fences"}
|
||||
{"lesson":"Synchronized camera Texture2D import is debug-view authority only for now. Fensalir can resolve and wait Mimir camera resources, but production visual-fusion or feature-extraction lowerings are explicitly deferred until renderer and audio work are fixed. Do not let resource import success silently choose a camera fusion policy.","timestamp":"2026-05-27T17:53:38+01:00","files":["state/map.yaml"],"source":"camera lowering scope decision","topic":"camera-texture-debug-only-scope"}
|
||||
{"lesson":"AquariumAudioDocument now has an explicit audio control-frame queue for client-published DSP targets. AquariumSynthHost drains and retains the latest frame by profile, but this is contract/control-plane state only; it does not move samples or claim aligned audio until a Faust/native streaming processor consumes the controls. Fensalir build and Aquarium.Engine.Tests passed.","timestamp":"2026-05-27T18:06:07+01:00","files":["src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs","src/Aquarium.Engine/Audio/AquariumSynthHost.cs","tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs","state/map.yaml"],"source":"Mimir runtime actuator control handoff","topic":"audio-control-frame-contract"}
|
||||
{"lesson":"Fensalir now hosts profile-keyed streaming Faust DSP programs through AquaSynth.Faust 0.1.0-alpha.4. AquariumAudioDocument queues AquariumStreamingDspProgram declarations, AquariumStreamingDspHost compiles raw Faust source into a persistent AquaSynthStreamingPatch, AquariumSynthHost applies matching audio control frames, and Aquarium.Engine.Tests proves a controlled input block passes through a streaming Faust graph. Device I/O/stem routing is still the next cut before this becomes live aligned audio.","timestamp":"2026-05-27T18:53:49+01:00","files":["src/Aquarium.Engine.Contracts/Audio/AquariumAudio.cs","src/Aquarium.Engine/Audio/AquariumStreamingDspHost.cs","src/Aquarium.Engine/Audio/AquariumSynthHost.cs","src/Aquarium.Engine/Aquarium.Engine.csproj","tests/Aquarium.Engine.Tests/SynthPlaybackTests.cs","packages/AquaSynth.Faust.0.1.0-alpha.4.nupkg","state/map.yaml"],"source":"streaming Faust DSP host cut","topic":"streaming-faust-dsp-host"}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ invariants:
|
|||
audio host may retain the latest frame, but Faust/native streaming DSP must
|
||||
own the hot fractional-delay/resampling path before any aligned-audio claim
|
||||
is true.
|
||||
- Streaming Faust DSP is now an engine audio mechanism. Clients declare a
|
||||
profile-keyed `AquariumStreamingDspProgram`; Fensalir compiles it through
|
||||
AquaSynth.Faust, keeps a persistent DSP instance, applies matching audio
|
||||
control frames, and can process caller-supplied audio blocks. Device I/O
|
||||
and stem routing remain the next host cut.
|
||||
|
||||
next_actions:
|
||||
- Keep origin pointed at https://github.com/GameCult/Fensalir.git.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,56 @@ public sealed class SynthPlaybackTests
|
|||
Assert.Empty(document.DrainControlFrames());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingDspHostProcessesControlDrivenInputBlocksWhenToolchainIsAvailable()
|
||||
{
|
||||
const string source = """
|
||||
import("stdfaust.lib");
|
||||
gain = hslider("source0/gain", 1.0, 0.0, 2.0, 0.001);
|
||||
process = _ * gain;
|
||||
""";
|
||||
using var host = new AquariumStreamingDspHost();
|
||||
var program = new AquariumStreamingDspProgram(
|
||||
"six-source-faust-fractional-delay",
|
||||
"mimir_alignment_smoke",
|
||||
source,
|
||||
Revision: 1);
|
||||
|
||||
if (!host.UpsertProgram(program))
|
||||
{
|
||||
if (host.LastError?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
host.LastError?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Fail($"Streaming DSP compile failed: {host.LastError}");
|
||||
}
|
||||
|
||||
Assert.True(host.ApplyControls(new AquariumAudioControlFrame(
|
||||
program.ProfileId,
|
||||
"loopback-scarlett-speakers",
|
||||
0.0,
|
||||
[
|
||||
new AquariumAudioControlCommand(
|
||||
"scarlett-host-mic",
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
new Dictionary<string, float>
|
||||
{
|
||||
["source0/gain"] = 0.5f
|
||||
})
|
||||
],
|
||||
TruncatedSourceCount: 0,
|
||||
Sequence: 1)));
|
||||
var input = new[] { Enumerable.Repeat(0.25f, 128).ToArray() };
|
||||
var output = new[] { new float[128] };
|
||||
|
||||
Assert.True(host.ProcessBlock(program.ProfileId, input, output, 128));
|
||||
Assert.All(output[0], sample => Assert.InRange(sample, 0.124f, 0.126f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AquaSynthPatchCompilerCanRenderAudiblePatchForEnginePlayback()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue