Feed Perlines from Fensalir audio stems
This commit is contained in:
parent
4d20f8693a
commit
27d0ad669a
3 changed files with 151 additions and 9 deletions
|
|
@ -11,15 +11,17 @@ reservoir, denoising, and presentation machinery used by the engine.
|
||||||
|
|
||||||
## Authority Map
|
## Authority Map
|
||||||
|
|
||||||
- Owner: `PerlinesSignalProcessor` owns audio synthesis, FFT whitening, key
|
- Owner: `PerlinesSignalProcessor` owns FFT whitening, key detection, and
|
||||||
detection, and projection from frequency bins into musical scale-note lanes.
|
projection from frequency bins into musical scale-note lanes.
|
||||||
- Inputs: frame time and delta time from the Fensalir runtime loop.
|
- Inputs: a Fensalir-owned Windows system-loopback capture stem when available;
|
||||||
|
otherwise frame time and delta time drive the procedural fallback signal.
|
||||||
- Outputs: one normalized Float32 column of scale-note energy per frame,
|
- Outputs: one normalized Float32 column of scale-note energy per frame,
|
||||||
transposed into note-owned TubeField splines for rendering.
|
transposed into note-owned TubeField splines for rendering.
|
||||||
- Derived state: detected key, flux, tonal center, and energy are diagnostic
|
- Derived state: detected key, flux, tonal center, and energy are diagnostic
|
||||||
readouts derived from the same spectral pass.
|
readouts derived from the same spectral pass.
|
||||||
- Forbidden writers: render passes, UI controls, and tube lowerings do not
|
- Forbidden writers: render passes, UI controls, tube lowerings, and audio stem
|
||||||
decide spectral content. They only consume the field evidence.
|
metadata do not decide spectral content. They only feed or consume the field
|
||||||
|
evidence path.
|
||||||
- Shared path: visible splines, TubeField evidence, headless captures, and UI
|
- Shared path: visible splines, TubeField evidence, headless captures, and UI
|
||||||
telemetry all read the same rolling note reservoir and version counter.
|
telemetry all read the same rolling note reservoir and version counter.
|
||||||
- Deletion line: Unity trail ownership, arbitrary band-to-spline assignment,
|
- Deletion line: Unity trail ownership, arbitrary band-to-spline assignment,
|
||||||
|
|
@ -27,7 +29,10 @@ reservoir, denoising, and presentation machinery used by the engine.
|
||||||
|
|
||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
1. Procedural audio is synthesized as a test signal.
|
1. Perlines requests a system-loopback capture stem from the Fensalir audio
|
||||||
|
host, drains published audio stem frames when available, and mixes their
|
||||||
|
channels to a mono analysis window. If no stem frame arrives, a procedural
|
||||||
|
signal is synthesized as a test fallback.
|
||||||
2. A Hann-windowed FFT measures the current frame.
|
2. A Hann-windowed FFT measures the current frame.
|
||||||
3. Each FFT bin is divided by a smoothed envelope, producing adaptive whitening
|
3. Each FFT bin is divided by a smoothed envelope, producing adaptive whitening
|
||||||
so persistent loud bands stop monopolizing the field.
|
so persistent loud bands stop monopolizing the field.
|
||||||
|
|
@ -49,6 +54,8 @@ reservoir, denoising, and presentation machinery used by the engine.
|
||||||
|
|
||||||
- Musical lanes are keyed scale notes, not arbitrary FFT bins.
|
- Musical lanes are keyed scale notes, not arbitrary FFT bins.
|
||||||
- Each TubeField spline belongs to one detected-key note lane.
|
- Each TubeField spline belongs to one detected-key note lane.
|
||||||
|
- Live audio stems are inputs to the same spectral owner as the fallback signal;
|
||||||
|
they do not bypass whitening, key detection, or lane projection.
|
||||||
- Perlines uploads a compact 70 by 96 Float32 resource, not a native-resolution
|
- Perlines uploads a compact 70 by 96 Float32 resource, not a native-resolution
|
||||||
buffer.
|
buffer.
|
||||||
- The showy dance splines are derived from the same note reservoir. They do not
|
- The showy dance splines are derived from the same note reservoir. They do not
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ using Aquarium.Engine.Ui;
|
||||||
|
|
||||||
namespace Perlines;
|
namespace Perlines;
|
||||||
|
|
||||||
public sealed class PerlinesRuntime : IAquariumRuntime
|
public sealed class PerlinesRuntime : IAquariumRuntime, IAquariumRuntimeServicesReceiver
|
||||||
{
|
{
|
||||||
private const int FftSize = 2048;
|
private const int FftSize = 2048;
|
||||||
private const int NoteLaneCount = 70;
|
private const int NoteLaneCount = 70;
|
||||||
|
|
@ -17,6 +17,7 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
private const string ClaimKey = "claim:perlines:whitened-fft:tube";
|
private const string ClaimKey = "claim:perlines:whitened-fft:tube";
|
||||||
private const string CandidateKey = "candidate:perlines:whitened-fft:tube";
|
private const string CandidateKey = "candidate:perlines:whitened-fft:tube";
|
||||||
private const string ProducerKey = "perlines:adaptive-whitener";
|
private const string ProducerKey = "perlines:adaptive-whitener";
|
||||||
|
private const string LoopbackProfileId = "perlines:system-loopback";
|
||||||
|
|
||||||
private readonly PerlinesSignalProcessor signal = new(FftSize, NoteLaneCount, sampleRate: 48000.0f);
|
private readonly PerlinesSignalProcessor signal = new(FftSize, NoteLaneCount, sampleRate: 48000.0f);
|
||||||
private readonly SmoothNoise danceNoise = new(0xDA7A);
|
private readonly SmoothNoise danceNoise = new(0xDA7A);
|
||||||
|
|
@ -24,6 +25,8 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
private readonly float[] rollingNotes = new float[NoteLaneCount * HistoryColumns];
|
private readonly float[] rollingNotes = new float[NoteLaneCount * HistoryColumns];
|
||||||
private readonly float[] tubeSamples = new float[NoteLaneCount * HistoryColumns];
|
private readonly float[] tubeSamples = new float[NoteLaneCount * HistoryColumns];
|
||||||
private readonly float[] uploadColumn = new float[NoteLaneCount];
|
private readonly float[] uploadColumn = new float[NoteLaneCount];
|
||||||
|
private AquariumRuntimeServices services = AquariumRuntimeServices.Empty;
|
||||||
|
private float[] stemMixBuffer = [];
|
||||||
private float timeSeconds;
|
private float timeSeconds;
|
||||||
private float previousTimeSeconds;
|
private float previousTimeSeconds;
|
||||||
private float cameraYaw;
|
private float cameraYaw;
|
||||||
|
|
@ -31,8 +34,11 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
private float cameraDistance = 7.8f;
|
private float cameraDistance = 7.8f;
|
||||||
private int rollingColumn;
|
private int rollingColumn;
|
||||||
private ulong resourceVersion = 1;
|
private ulong resourceVersion = 1;
|
||||||
|
private long consumedAudioSequence = -1;
|
||||||
private bool autoOrbit = true;
|
private bool autoOrbit = true;
|
||||||
private bool freezeSignal;
|
private bool freezeSignal;
|
||||||
|
private bool usedAudioStemThisFrame;
|
||||||
|
private string audioInputLabel = "synthetic";
|
||||||
|
|
||||||
public AquariumRuntimeOptions Options { get; private set; }
|
public AquariumRuntimeOptions Options { get; private set; }
|
||||||
|
|
||||||
|
|
@ -79,6 +85,7 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
panel.Slider("Camera Distance", () => cameraDistance, value => cameraDistance = Math.Clamp(value, 4.5f, 13.0f), 4.5f, 13.0f, "0.00");
|
panel.Slider("Camera Distance", () => cameraDistance, value => cameraDistance = Math.Clamp(value, 4.5f, 13.0f), 4.5f, 13.0f, "0.00");
|
||||||
panel.Readout("FFT", () => $"{FftSize} samples / {NoteLaneCount} scale-note lanes / v{resourceVersion}");
|
panel.Readout("FFT", () => $"{FftSize} samples / {NoteLaneCount} scale-note lanes / v{resourceVersion}");
|
||||||
panel.Readout("Key", () => signal.DetectedKeyName);
|
panel.Readout("Key", () => signal.DetectedKeyName);
|
||||||
|
panel.Readout("Input", () => audioInputLabel);
|
||||||
panel.Readout("Whitening", () => $"flux {signal.LastFlux:0.000} / tonal center {signal.LastCentroid:0.000} / energy {signal.LastEnergy:0.000}");
|
panel.Readout("Whitening", () => $"flux {signal.LastFlux:0.000} / tonal center {signal.LastCentroid:0.000} / energy {signal.LastEnergy:0.000}");
|
||||||
panel.Readout("Renderer", () => "Fensalir TubeField + shared reservoir + final reconstruction");
|
panel.Readout("Renderer", () => "Fensalir TubeField + shared reservoir + final reconstruction");
|
||||||
})
|
})
|
||||||
|
|
@ -92,6 +99,12 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
|
Audio.EnqueueSystemLoopbackCapture(
|
||||||
|
LoopbackProfileId,
|
||||||
|
"windows-default-render",
|
||||||
|
"System mix",
|
||||||
|
enabled: true,
|
||||||
|
sequence: 1);
|
||||||
Console.WriteLine("Perlines adaptive FFT tube field booted.");
|
Console.WriteLine("Perlines adaptive FFT tube field booted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +116,13 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
|
|
||||||
if (!freezeSignal)
|
if (!freezeSignal)
|
||||||
{
|
{
|
||||||
signal.Advance(timeSeconds, safeDelta, uploadColumn);
|
usedAudioStemThisFrame = TryAdvanceFromAudioStem(safeDelta, uploadColumn);
|
||||||
|
if (!usedAudioStemThisFrame)
|
||||||
|
{
|
||||||
|
signal.Advance(timeSeconds, safeDelta, uploadColumn);
|
||||||
|
audioInputLabel = "synthetic fallback";
|
||||||
|
}
|
||||||
|
|
||||||
rollingColumn = (rollingColumn + 1) % HistoryColumns;
|
rollingColumn = (rollingColumn + 1) % HistoryColumns;
|
||||||
Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount);
|
Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount);
|
||||||
resourceVersion++;
|
resourceVersion++;
|
||||||
|
|
@ -159,6 +178,11 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AttachServices(AquariumRuntimeServices runtimeServices)
|
||||||
|
{
|
||||||
|
services = runtimeServices;
|
||||||
|
}
|
||||||
|
|
||||||
internal void SetOptions(AquariumRuntimeOptions options)
|
internal void SetOptions(AquariumRuntimeOptions options)
|
||||||
{
|
{
|
||||||
Options = options;
|
Options = options;
|
||||||
|
|
@ -305,6 +329,61 @@ public sealed class PerlinesRuntime : IAquariumRuntime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryAdvanceFromAudioStem(float deltaSeconds, Span<float> output)
|
||||||
|
{
|
||||||
|
var frame = services.AudioStems
|
||||||
|
.DrainPublishedFrames(maxFrames: 16)
|
||||||
|
.Where(candidate => candidate.Channels.Count > 0 && candidate.FrameCount > 0 && candidate.SampleRate > 0)
|
||||||
|
.OrderBy(candidate => candidate.Sequence)
|
||||||
|
.LastOrDefault();
|
||||||
|
if (frame is null || frame.Sequence == consumedAudioSequence)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedAudioSequence = frame.Sequence;
|
||||||
|
var frameCount = frame.FrameCount;
|
||||||
|
if (stemMixBuffer.Length < frameCount)
|
||||||
|
{
|
||||||
|
stemMixBuffer = new float[frameCount];
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Clear(stemMixBuffer, 0, frameCount);
|
||||||
|
var mixedChannels = 0;
|
||||||
|
foreach (var channel in frame.Channels)
|
||||||
|
{
|
||||||
|
if (channel.Samples.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = Math.Min(frameCount, channel.Samples.Length);
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
stemMixBuffer[index] += channel.Samples[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
mixedChannels++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mixedChannels == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = 1.0f / mixedChannels;
|
||||||
|
for (var index = 0; index < frameCount; index++)
|
||||||
|
{
|
||||||
|
stemMixBuffer[index] = Math.Clamp(stemMixBuffer[index] * scale, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.AdvanceFromSamples(stemMixBuffer.AsSpan(0, frameCount), frame.SampleRate, deltaSeconds, output);
|
||||||
|
var stem = frame.Channels[0];
|
||||||
|
var source = string.IsNullOrWhiteSpace(stem.DisplayName) ? stem.StemId : stem.DisplayName;
|
||||||
|
audioInputLabel = $"{frame.ProfileId} / {source} / seq {frame.Sequence}";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private AquariumSplineFrame BuildDanceSplineFrame()
|
private AquariumSplineFrame BuildDanceSplineFrame()
|
||||||
{
|
{
|
||||||
var splines = new List<AquariumSpline3D>(NoteLaneCount);
|
var splines = new List<AquariumSpline3D>(NoteLaneCount);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ internal sealed class PerlinesSignalProcessor
|
||||||
|
|
||||||
private readonly int fftSize;
|
private readonly int fftSize;
|
||||||
private readonly int laneCount;
|
private readonly int laneCount;
|
||||||
private readonly float sampleRate;
|
private float sampleRate;
|
||||||
private readonly int binCount;
|
private readonly int binCount;
|
||||||
private readonly float[] window;
|
private readonly float[] window;
|
||||||
private readonly float[] envelope;
|
private readonly float[] envelope;
|
||||||
|
|
@ -20,6 +20,7 @@ internal sealed class PerlinesSignalProcessor
|
||||||
private readonly float[] chroma;
|
private readonly float[] chroma;
|
||||||
private readonly float[] smoothedChroma;
|
private readonly float[] smoothedChroma;
|
||||||
private readonly float[] previousLanes;
|
private readonly float[] previousLanes;
|
||||||
|
private readonly float[] externalSamples;
|
||||||
private readonly Complex[] spectrum;
|
private readonly Complex[] spectrum;
|
||||||
private readonly SmoothNoise noise = new(0x5EED);
|
private readonly SmoothNoise noise = new(0x5EED);
|
||||||
private float phaseA;
|
private float phaseA;
|
||||||
|
|
@ -27,6 +28,8 @@ internal sealed class PerlinesSignalProcessor
|
||||||
private float phaseC;
|
private float phaseC;
|
||||||
private int detectedRoot = 9;
|
private int detectedRoot = 9;
|
||||||
private bool detectedMinor;
|
private bool detectedMinor;
|
||||||
|
private int externalWriteIndex;
|
||||||
|
private int externalSampleCount;
|
||||||
|
|
||||||
public float LastEnergy { get; private set; }
|
public float LastEnergy { get; private set; }
|
||||||
|
|
||||||
|
|
@ -53,6 +56,7 @@ internal sealed class PerlinesSignalProcessor
|
||||||
chroma = new float[12];
|
chroma = new float[12];
|
||||||
smoothedChroma = new float[12];
|
smoothedChroma = new float[12];
|
||||||
previousLanes = new float[laneCount];
|
previousLanes = new float[laneCount];
|
||||||
|
externalSamples = new float[fftSize];
|
||||||
spectrum = new Complex[fftSize];
|
spectrum = new Complex[fftSize];
|
||||||
Array.Fill(envelope, 0.08f);
|
Array.Fill(envelope, 0.08f);
|
||||||
|
|
||||||
|
|
@ -70,6 +74,30 @@ internal sealed class PerlinesSignalProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
FillSignal(timeSeconds);
|
FillSignal(timeSeconds);
|
||||||
|
Analyze(deltaSeconds, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AdvanceFromSamples(ReadOnlySpan<float> samples, int inputSampleRate, float deltaSeconds, Span<float> output)
|
||||||
|
{
|
||||||
|
if (output.Length < laneCount)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Output span is smaller than the configured lane count.", nameof(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (samples.IsEmpty || inputSampleRate <= 0)
|
||||||
|
{
|
||||||
|
Advance(0.0f, deltaSeconds, output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleRate = inputSampleRate;
|
||||||
|
PushExternalSamples(samples);
|
||||||
|
FillExternalWindow();
|
||||||
|
Analyze(deltaSeconds, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Analyze(float deltaSeconds, Span<float> output)
|
||||||
|
{
|
||||||
Fft(spectrum);
|
Fft(spectrum);
|
||||||
WhitenBins(Math.Max(deltaSeconds, 1.0f / 240.0f));
|
WhitenBins(Math.Max(deltaSeconds, 1.0f / 240.0f));
|
||||||
DetectKey();
|
DetectKey();
|
||||||
|
|
@ -100,6 +128,34 @@ internal sealed class PerlinesSignalProcessor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PushExternalSamples(ReadOnlySpan<float> samples)
|
||||||
|
{
|
||||||
|
foreach (var sample in samples)
|
||||||
|
{
|
||||||
|
externalSamples[externalWriteIndex] = Math.Clamp(sample, -1.0f, 1.0f);
|
||||||
|
externalWriteIndex = (externalWriteIndex + 1) % fftSize;
|
||||||
|
externalSampleCount = Math.Min(externalSampleCount + 1, fftSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FillExternalWindow()
|
||||||
|
{
|
||||||
|
var available = Math.Max(0, Math.Min(externalSampleCount, fftSize));
|
||||||
|
var first = (externalWriteIndex - available + fftSize) % fftSize;
|
||||||
|
var pad = fftSize - available;
|
||||||
|
for (var index = 0; index < pad; index++)
|
||||||
|
{
|
||||||
|
spectrum[index] = Complex.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < available; index++)
|
||||||
|
{
|
||||||
|
var sourceIndex = (first + index) % fftSize;
|
||||||
|
var targetIndex = pad + index;
|
||||||
|
spectrum[targetIndex] = new Complex(externalSamples[sourceIndex] * window[targetIndex], 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void WhitenBins(float safeDelta)
|
private void WhitenBins(float safeDelta)
|
||||||
{
|
{
|
||||||
Array.Clear(chroma);
|
Array.Clear(chroma);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue