commit 2d386ed588d2475681a0566e970a1178788881e8 Author: Meta Vens Date: Sun May 31 00:38:24 2026 +0100 Build Fensalir Perlines demo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ade9fb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +artifacts/ +.vs/ +.idea/ +*.user +*.suo +*.log +TestResults/ + diff --git a/Perlines.sln b/Perlines.sln new file mode 100644 index 0000000..1155217 --- /dev/null +++ b/Perlines.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perlines", "src\Perlines\Perlines.csproj", "{9DD844F7-477F-43C0-8A97-D93D0FD2A62E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DD844F7-477F-43C0-8A97-D93D0FD2A62E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae51f22 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Perlines + +Perlines is the 2012 Aethervis trail idea rebuilt as a Fensalir client. + +The Unity version owned trails through `LineRenderer` and `TrailRenderer` +objects. This version owns only the signal: a procedural audio frame is sent +through an FFT, adaptively whitened per band, written into a rolling Float32 +field resource, and lowered through Fensalir's TubeField path. The renderer owns +visibility, reservoir reuse, bloom, and final reconstruction. + +## Run + +From `E:\Projects\Fensalir`: + +```powershell +.\scripts\dev-reload.ps1 -ClientProject E:\Projects\Perlines\src\Perlines\Perlines.csproj +``` + +Headless smoke test from Fensalir: + +```powershell +.\scripts\dev-reload.ps1 -Headless -ClientProject E:\Projects\Perlines\src\Perlines\Perlines.csproj +``` + +Headless PNG capture from this repo: + +```powershell +E:\Projects\Perlines\scripts\capture-headless.ps1 +``` + +## Live Model + +- Perlines owns procedural audio/FFT/whitening and field-resource uploads. +- Fensalir owns the Win32 host, D3D12 renderer, TubeField compute/render + lowering, shared reservoir, and presentation denoising. +- The rolling spectrum is a below-native field evidence source, not a private + line renderer. +- The spline lanes are assigned to notes in the detected key instead of + arbitrary spectrum bins. +- Each rendered spline is one note lane evolving over the rolling time window. +- A Fensalir spline presentation turns that reservoir into the old radial + Perlines trail storm with a divergence-free curl-noise flow, while TubeField + evidence remains available to the shared reservoir path. diff --git a/docs/system-map.md b/docs/system-map.md new file mode 100644 index 0000000..3c72241 --- /dev/null +++ b/docs/system-map.md @@ -0,0 +1,61 @@ +# Perlines System Map + +## Objective + +Perlines rebuilds the 2012 Aethervis trail demo as a Fensalir client whose +visible form is driven by a key-aware, adaptively whitened FFT reservoir. + +The real outcome is not a port of Unity `LineRenderer` state. The outcome is a +below-native spectral field that Fensalir can lower through the same TubeField, +reservoir, denoising, and presentation machinery used by the engine. + +## Authority Map + +- Owner: `PerlinesSignalProcessor` owns audio synthesis, FFT whitening, key + detection, and projection from frequency bins into musical scale-note lanes. +- Inputs: frame time and delta time from the Fensalir runtime loop. +- Outputs: one normalized Float32 column of scale-note energy per frame, + transposed into note-owned TubeField splines for rendering. +- Derived state: detected key, flux, tonal center, and energy are diagnostic + readouts derived from the same spectral pass. +- Forbidden writers: render passes, UI controls, and tube lowerings do not + decide spectral content. They only consume the field evidence. +- Shared path: visible splines, TubeField evidence, headless captures, and UI + telemetry all read the same rolling note reservoir and version counter. +- Deletion line: Unity trail ownership, arbitrary band-to-spline assignment, + and per-object procedural line state are dead. The renderer owns visibility. + +## Pipeline + +1. Procedural audio is synthesized as a test signal. +2. A Hann-windowed FFT measures the current frame. +3. Each FFT bin is divided by a smoothed envelope, producing adaptive whitening + so persistent loud bands stop monopolizing the field. +4. Whitened bins accumulate into chroma energy. +5. Major/minor key templates with hysteresis choose the current key. +6. The detected key defines 70 scale-note lanes across octaves. +7. Each lane samples nearby whitened FFT bins for the note frequency. +8. The rolling note history is transposed into one contiguous curve per note + lane. +9. Perlines uploads that compact 70 by 96 Float32 field resource. +10. Fensalir lowers each note lane through `AquariumFieldTubeSplineLowering`. +11. A Fensalir spline presentation reads the same note history and gives each + lane a radial path advected by `cross(position, d/dxyz noise4(position,t))`, + giving the presentation a divergence-free curl-noise style flow without + restoring Unity object ownership. +12. The D3D12 TubeField path feeds the shared reservoir and final presentation. + +## Invariants + +- Musical lanes are keyed scale notes, not arbitrary FFT bins. +- Each TubeField spline belongs to one detected-key note lane. +- Perlines uploads a compact 70 by 96 Float32 resource, not a native-resolution + buffer. +- The showy dance splines are derived from the same note reservoir. They do not + own signal truth. +- The rolling spectrum is a field-evidence resource, not private renderer + geometry. +- Fensalir owns D3D12 buffers, TubeField dispatch, reservoir reuse, bloom, and + final reconstruction. +- Headless verification must exercise the same client assembly and TubeField + lowering path as interactive runs. diff --git a/scripts/capture-headless.ps1 b/scripts/capture-headless.ps1 new file mode 100644 index 0000000..b35f149 --- /dev/null +++ b/scripts/capture-headless.ps1 @@ -0,0 +1,104 @@ +param( + [string]$OutputPath, + [int]$Width = 1280, + [int]$Height = 720, + [int]$ReadyFrames = 24, + [double]$FieldReservoirScale = 0.5, + [double]$FieldReservoirSpatialReuseBudget = 0.5, + [int]$RetainSlots = 4, + [int]$TimeoutSeconds = 180 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$fensalirRoot = "E:\Projects\Fensalir" +$engineProject = Join-Path $fensalirRoot "src\Aquarium.Engine\Aquarium.Engine.csproj" +$clientProject = Join-Path $repoRoot "src\Perlines\Perlines.csproj" +$devRoot = Join-Path $repoRoot "artifacts\headless" +$slotRoot = Join-Path $devRoot "slots" +$slotPath = Join-Path $slotRoot ("perlines-capture-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + [guid]::NewGuid().ToString("N").Substring(0, 8)) +$cachePath = Join-Path $devRoot "cultcache\aquarium-client.msgpack" +$stdoutLog = Join-Path $devRoot "capture.out.log" +$stderrLog = Join-Path $devRoot "capture.err.log" + +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $devRoot ("perlines-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".png") +} + +$OutputPath = [System.IO.Path]::GetFullPath($OutputPath) + +New-Item -ItemType Directory -Force -Path $slotPath | Out-Null +New-Item -ItemType Directory -Force -Path (Split-Path $cachePath) | Out-Null +New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath) | Out-Null + +Write-Host "Building Perlines capture slot:" +Write-Host " $slotPath" + +dotnet build $engineProject -c Debug -o $slotPath /p:UseAppHost=true +if ($LASTEXITCODE -ne 0) { + throw "Engine build failed with exit code $LASTEXITCODE." +} + +dotnet build $clientProject -c Debug -o $slotPath /p:UseAppHost=false +if ($LASTEXITCODE -ne 0) { + throw "Perlines client build failed with exit code $LASTEXITCODE." +} + +$exePath = Join-Path $slotPath "Aquarium.Engine.exe" +$clientAssembly = Join-Path $slotPath "Perlines.dll" +$arguments = @( + "--headless", + "--headless-width", $Width, + "--headless-height", $Height, + "--client-assembly", $clientAssembly, + "--cache", $cachePath, + "--shader-source", (Join-Path $slotPath "Render\Shaders\D3D12HeightField.hlsl"), + "--capture-frame", $OutputPath, + "--field-reservoir-scale", $FieldReservoirScale, + "--field-reservoir-spatial-reuse-budget", $FieldReservoirSpatialReuseBudget +) + +$previousReadyFrames = $env:AQUARIUM_HEADLESS_READY_FRAMES +$env:AQUARIUM_HEADLESS_READY_FRAMES = [string][Math]::Max(1, $ReadyFrames) +try { + $process = Start-Process -FilePath $exePath -ArgumentList $arguments -NoNewWindow -PassThru -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog + if (-not $process.WaitForExit([Math]::Max(1, $TimeoutSeconds) * 1000)) { + $process.Kill($true) + $tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "" } + throw "Perlines capture timed out after $TimeoutSeconds seconds.`n$($tail -join [Environment]::NewLine)" + } + + $process.WaitForExit() + $process.Refresh() + $exitCode = $process.ExitCode + if ($null -eq $exitCode -and (Test-Path $OutputPath)) { + $exitCode = 0 + } + + if ($exitCode -ne 0) { + $tail = if (Test-Path $stderrLog) { Get-Content -Path $stderrLog -Tail 80 } else { "" } + throw "Perlines capture failed with exit code $exitCode.`n$($tail -join [Environment]::NewLine)" + } +} +finally { + $env:AQUARIUM_HEADLESS_READY_FRAMES = $previousReadyFrames +} + +if (-not (Test-Path $OutputPath)) { + throw "Perlines capture did not produce expected PNG: $OutputPath" +} + +if ($RetainSlots -gt 0) { + Get-ChildItem -Path $slotRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -Skip $RetainSlots | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Perlines frame captured:" +Write-Host " $OutputPath" +Write-Host "Logs:" +Write-Host " $stdoutLog" +Write-Host " $stderrLog" diff --git a/scripts/run-dev.ps1 b/scripts/run-dev.ps1 new file mode 100644 index 0000000..b738c1e --- /dev/null +++ b/scripts/run-dev.ps1 @@ -0,0 +1,16 @@ +param( + [switch]$Headless +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$fensalir = "E:\Projects\Fensalir" +$client = "E:\Projects\Perlines\src\Perlines\Perlines.csproj" + +if ($Headless) { + & (Join-Path $fensalir "scripts\dev-reload.ps1") -Headless -ClientProject $client +} +else { + & (Join-Path $fensalir "scripts\dev-reload.ps1") -ClientProject $client +} diff --git a/src/Perlines/Perlin4D.cs b/src/Perlines/Perlin4D.cs new file mode 100644 index 0000000..086648a --- /dev/null +++ b/src/Perlines/Perlin4D.cs @@ -0,0 +1,81 @@ +using System.Numerics; + +namespace Perlines; + +internal sealed class Perlin4D +{ + private readonly int[] permutation = new int[512]; + + public Perlin4D(int seed) + { + var random = new Random(seed); + for (var index = 0; index < 256; index++) + { + permutation[index] = index; + } + + for (var index = 255; index > 0; index--) + { + var swap = random.Next(index + 1); + (permutation[index], permutation[swap]) = (permutation[swap], permutation[index]); + } + + for (var index = 0; index < 256; index++) + { + permutation[index + 256] = permutation[index]; + } + } + + public Vector3 Derivative(Vector4 point) + { + var x0 = (int)MathF.Floor(point.X); + var y0 = (int)MathF.Floor(point.Y); + var z0 = (int)MathF.Floor(point.Z); + var w0 = (int)MathF.Floor(point.W); + var x = point.X - x0; + var y = point.Y - y0; + var z = point.Z - z0; + var w = point.W - w0; + var fade = new Vector4(Fade(x), Fade(y), Fade(z), Fade(w)); + var fadeDerivative = new Vector4(FadeDerivative(x), FadeDerivative(y), FadeDerivative(z), FadeDerivative(w)); + var derivative = Vector3.Zero; + + for (var cornerX = 0; cornerX <= 1; cornerX++) + for (var cornerY = 0; cornerY <= 1; cornerY++) + for (var cornerZ = 0; cornerZ <= 1; cornerZ++) + for (var cornerW = 0; cornerW <= 1; cornerW++) + { + var distance = new Vector4(x - cornerX, y - cornerY, z - cornerZ, w - cornerW); + var gradient = Gradient(x0 + cornerX, y0 + cornerY, z0 + cornerZ, w0 + cornerW); + var contribution = Vector4.Dot(gradient, distance); + var wx = cornerX == 0 ? 1.0f - fade.X : fade.X; + var wy = cornerY == 0 ? 1.0f - fade.Y : fade.Y; + var wz = cornerZ == 0 ? 1.0f - fade.Z : fade.Z; + var ww = cornerW == 0 ? 1.0f - fade.W : fade.W; + var dwx = cornerX == 0 ? -fadeDerivative.X : fadeDerivative.X; + var dwy = cornerY == 0 ? -fadeDerivative.Y : fadeDerivative.Y; + var dwz = cornerZ == 0 ? -fadeDerivative.Z : fadeDerivative.Z; + + derivative.X += (dwx * wy * wz * ww * contribution) + (wx * wy * wz * ww * gradient.X); + derivative.Y += (wx * dwy * wz * ww * contribution) + (wx * wy * wz * ww * gradient.Y); + derivative.Z += (wx * wy * dwz * ww * contribution) + (wx * wy * wz * ww * gradient.Z); + } + + return derivative; + } + + private Vector4 Gradient(int x, int y, int z, int w) + { + var hash = permutation[(x + permutation[(y + permutation[(z + permutation[w & 255]) & 255]) & 255]) & 255]; + var gx = ((hash & 1) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 4) & 1) * 0.5f); + var gy = ((hash & 2) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 5) & 1) * 0.5f); + var gz = ((hash & 4) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 6) & 1) * 0.5f); + var gw = ((hash & 8) == 0 ? 1.0f : -1.0f) * (1.0f + ((hash >> 7) & 1) * 0.5f); + return Vector4.Normalize(new Vector4(gx, gy, gz, gw)); + } + + private static float Fade(float t) => t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); + + private static float FadeDerivative(float t) => 30.0f * t * t * (t - 1.0f) * (t - 1.0f); +} + diff --git a/src/Perlines/Perlines.csproj b/src/Perlines/Perlines.csproj new file mode 100644 index 0000000..f95e449 --- /dev/null +++ b/src/Perlines/Perlines.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + Perlines + Perlines + enable + enable + true + + + + + + + diff --git a/src/Perlines/PerlinesRuntime.cs b/src/Perlines/PerlinesRuntime.cs new file mode 100644 index 0000000..6fa093b --- /dev/null +++ b/src/Perlines/PerlinesRuntime.cs @@ -0,0 +1,439 @@ +using System.Numerics; +using Aquarium.Engine; +using Aquarium.Engine.Audio; +using Aquarium.Engine.Input; +using Aquarium.Engine.Render; +using Aquarium.Engine.Ui; + +namespace Perlines; + +public sealed class PerlinesRuntime : IAquariumRuntime +{ + private const int FftSize = 2048; + private const int NoteLaneCount = 70; + private const int HistoryColumns = 96; + private const string DomainKey = "perlines:domain:whitened-fft"; + private const string ResourceKey = "resource:perlines:whitened-fft"; + private const string ClaimKey = "claim:perlines:whitened-fft:tube"; + private const string CandidateKey = "candidate:perlines:whitened-fft:tube"; + private const string ProducerKey = "perlines:adaptive-whitener"; + + private readonly PerlinesSignalProcessor signal = new(FftSize, NoteLaneCount, sampleRate: 48000.0f); + private readonly SmoothNoise danceNoise = new(0xDA7A); + private readonly Perlin4D flowNoise = new(0xC012); + private readonly float[] rollingNotes = new float[NoteLaneCount * HistoryColumns]; + private readonly float[] tubeSamples = new float[NoteLaneCount * HistoryColumns]; + private readonly float[] uploadColumn = new float[NoteLaneCount]; + private float timeSeconds; + private float previousTimeSeconds; + private float cameraYaw; + private float cameraPitch = 0.24f; + private float cameraDistance = 7.8f; + private int rollingColumn; + private ulong resourceVersion = 1; + private bool autoOrbit = true; + private bool freezeSignal; + + public AquariumRuntimeOptions Options { get; private set; } + + public GraphicsSettings GraphicsSettings { get; set; } = new( + RenderDebugMode: 0, + SceneExposure: 0.92f, + BloomIntensity: 0.82f, + BloomVeilIntensity: 0.0f, + FieldReservoirMode: GraphicsSettings.FieldReservoirModeNativeDomain, + FieldReservoirScale: 0.5f, + FieldReservoirSpatialReuseBudget: 0.1f); + + public AquariumRenderPlan RenderPlan { get; } = CreateRenderPlan(); + + public AquariumUiDocument Ui { get; } + + public AquariumAudioDocument Audio { get; } = new(); + + public AquariumSynthDocument Synth { get; } = AquariumSynthDocument.Empty; + + public AquariumFrame Frame + { + get + { + var target = new Vector3(0.0f, 0.28f, -0.35f); + return new AquariumFrame( + new ViewFrame(Vector2.Zero, 7.5f), + ComposeCamera(target), + target, + timeSeconds, + Vector2.Zero, + BuildScene()); + } + } + + public PerlinesRuntime() + { + Ui = new AquariumUiDocument() + .Panel("Perlines", 18.0f, 82.0f, 390.0f, panel => + { + panel.Section("Adaptive FFT Tube Field"); + panel.Toggle("Auto Orbit", () => autoOrbit, value => autoOrbit = value); + panel.Toggle("Freeze Signal", () => freezeSignal, value => freezeSignal = value); + 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("Key", () => signal.DetectedKeyName); + 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"); + }) + .Command("perlines", _ => $"Perlines: {NoteLaneCount} key-mapped scale-note lanes, {signal.DetectedKeyName}, rolling column {rollingColumn}, v{resourceVersion}.", "Report Perlines signal state."); + } + + public AquariumFrame ComposeFrame(AquariumFrame frame, AquariumFrameInput input) + { + return frame; + } + + public void Start() + { + Console.WriteLine("Perlines adaptive FFT tube field booted."); + } + + public void Update(float deltaSeconds, InputState input) + { + previousTimeSeconds = timeSeconds; + var safeDelta = Math.Max(deltaSeconds, 0.0f); + timeSeconds += safeDelta; + + if (!freezeSignal) + { + signal.Advance(timeSeconds, safeDelta, uploadColumn); + rollingColumn = (rollingColumn + 1) % HistoryColumns; + Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount); + resourceVersion++; + } + + if (autoOrbit) + { + cameraYaw += safeDelta * 0.045f; + } + + if (input.IsKeyDown(KeyCode.A)) + { + cameraYaw -= safeDelta * 0.85f; + autoOrbit = false; + } + + if (input.IsKeyDown(KeyCode.D)) + { + cameraYaw += safeDelta * 0.85f; + autoOrbit = false; + } + + if (input.IsKeyDown(KeyCode.W)) + { + cameraPitch += safeDelta * 0.36f; + } + + if (input.IsKeyDown(KeyCode.S)) + { + cameraPitch -= safeDelta * 0.36f; + } + + if (MathF.Abs(input.WheelDelta) > 0.0f) + { + cameraDistance = Math.Clamp(cameraDistance - input.WheelDelta * MathF.Max(cameraDistance * 0.09f, 0.05f), 4.5f, 13.0f); + } + + if (input.LeftMouseDown && input.MouseDelta != Vector2.Zero) + { + cameraYaw -= input.MouseDelta.X * 0.006f; + cameraPitch += input.MouseDelta.Y * 0.0035f; + autoOrbit = false; + } + + cameraPitch = Math.Clamp(cameraPitch, 0.06f, 0.62f); + } + + public void FlushState() + { + } + + public void Dispose() + { + } + + internal void SetOptions(AquariumRuntimeOptions options) + { + Options = options; + if (options.Headless) + { + autoOrbit = false; + cameraYaw = 0.76f; + cameraPitch = 0.34f; + cameraDistance = 7.8f; + for (var frame = 0; frame < HistoryColumns; frame++) + { + timeSeconds += 1.0f / 60.0f; + signal.Advance(timeSeconds, 1.0f / 60.0f, uploadColumn); + rollingColumn = frame % HistoryColumns; + Array.Copy(uploadColumn, 0, rollingNotes, rollingColumn * NoteLaneCount, NoteLaneCount); + resourceVersion++; + } + + previousTimeSeconds = timeSeconds - (1.0f / 60.0f); + } + } + + private AquariumSceneState BuildScene() + { + var timestampNs = (long)(timeSeconds * 1_000_000_000.0f); + var support = new AquariumFieldSupport( + Center: new Vector3(0.0f, 0.9f, 0.0f), + Radius: new Vector3(4.8f, 2.2f, 3.2f), + LocalFrame: Matrix4x4.Identity, + ConservativeRadius: 5.9f, + ProjectedError: 1.0f / 128.0f, + Curvature: 0.2f + signal.LastFlux, + TemporalUncertainty: 0.016f); + var proposal = new AquariumFieldProposalPolicy( + AquariumFieldProposalKind.SensorObservation, + SourcePdf: 1.0f, + TargetContribution: Math.Max(0.1f, 2.5f + signal.LastEnergy * 6.0f), + RepresentedCandidateCount: NoteLaneCount, + Seed: (uint)(resourceVersion & uint.MaxValue)); + var guide = AquariumFieldGuide.Valid(Math.Clamp(0.68f + signal.LastFlux * 0.3f, 0.1f, 1.0f), 1.0f / 60.0f); + + var resource = new AquariumFieldResourceDeclaration( + ResourceKey, + AquariumFieldResourceKind.StructuredBuffer, + AquariumFieldResourceResidency.GpuResident, + AquariumFieldShaderAccess.ShaderResource, + "Float32", + Width: HistoryColumns, + Height: NoteLaneCount, + DepthOrCount: tubeSamples.Length, + StrideBytes: sizeof(float), + ValidFromNs: Math.Max(0, timestampNs - 50_000_000), + ValidUntilNs: timestampNs + 100_000_000, + Version: resourceVersion, + NativeHandle: IntPtr.Zero, + NativeHandleKind: "perlines-rolling-float32"); + + var frame = new AquariumFieldEvidenceFrame + { + AccumulationWindowSeconds = 0.45f, + PresentationDelaySeconds = 0.0f, + Domains = + [ + new AquariumFieldDomain( + DomainKey, + "", + AquariumFieldDomainKind.RollingBuffer, + Matrix4x4.Identity, + Matrix4x4.Identity, + new Vector3(-3.8f, -0.15f, -2.6f), + new Vector3(3.8f, 2.7f, 2.6f), + new Vector3(0.0f, 0.0f, HistoryColumns), + "Perlines") + ], + Resources = [resource, AquariumBuiltInFieldResources.BlackbodyRamp(resourceVersion)], + ResourceUploads = BuildResourceUploads(), + Claims = + [ + new AquariumFieldClaim( + ClaimKey, + DomainKey, + ProducerKey, + AquariumFieldLayer.Form, + AquariumFieldEncoding.Tube, + support, + proposal, + ResourceKey, + timestampNs, + guide.Confidence) + ], + Candidates = + [ + new AquariumFieldCandidate( + CandidateKey, + ClaimKey, + AquariumFieldLayer.Form, + AquariumFieldEncoding.Tube, + proposal, + guide) + ], + TubeSplineLowerings = + [ + new AquariumFieldTubeSplineLowering( + "lowering:perlines:fft:tube", + ClaimKey, + ResourceKey, + Width: HistoryColumns, + Height: NoteLaneCount, + StrideBytes: sizeof(float), + FirstColumn: 0, + ColumnCount: NoteLaneCount, + ColumnStride: 1, + RollingModulo: 0, + RollingOffset: 0, + Origin: new Vector3(-3.4f, -0.72f, -2.15f), + AxisStep: new Vector3(6.8f / Math.Max(1, HistoryColumns - 1), 0.0f, 0.0f), + ColumnStep: new Vector3(0.0f, 0.018f, 4.3f / Math.Max(1, NoteLaneCount - 1)), + AmplitudePower: 1.38f, + AmplitudeScale: 1.75f, + NormalizeMin: 0.0f, + NormalizeMax: 1.0f, + BaseRadius: 0.014f, + RadiusScale: 0.052f, + Alpha: 0.92f, + Feather: 0.32f, + RampTexturePath: "", + RampResourceKey: AquariumBuiltInFieldResources.BlackbodyRampResourceKey, + EmissionScale: 9.2f, + CatmullRomSubdivisions: 4) + ], + }; + + return new AquariumSceneState + { + TraceHeightFieldSurface = false, + UseStudioBackground = false, + SdfLights = + [ + new AquariumSdfLight(new Vector4(-3.2f, -3.8f, 4.2f, 6.0f), new Vector4(0.26f, 0.46f, 0.95f, 12.0f)), + new AquariumSdfLight(new Vector4(3.6f, 2.8f, 3.4f, 4.0f), new Vector4(1.0f, 0.36f, 0.18f, 5.8f)), + ], + FieldEvidenceFrame = frame, + SplineFrame = BuildDanceSplineFrame(), + }; + } + + private AquariumSplineFrame BuildDanceSplineFrame() + { + var splines = new List(NoteLaneCount); + var style = new AquariumSplineStyle( + radius: 0.012f, + emission: 10.0f, + alpha: 0.94f, + normalExponent: 0.42f, + feather: 0.08f); + + const int pointCount = 56; + for (var note = 0; note < NoteLaneCount; note++) + { + var vertices = new AquariumSplineVertex[pointCount]; + var note01 = note / Math.Max(1.0f, NoteLaneCount - 1.0f); + var baseAngle = note01 * MathF.Tau * 3.0f + note * 0.37f; + var lanePhase = timeSeconds * (0.22f + note01 * 0.18f) + note * 11.13f; + var laneHue = note % 7; + var radial = Vector3.Normalize(new Vector3(MathF.Cos(baseAngle), 0.18f * MathF.Sin(baseAngle * 1.7f), MathF.Sin(baseAngle))); + var position = radial * (0.18f + note01 * 0.4f); + for (var point = 0; point < pointCount; point++) + { + var t = point / Math.Max(1.0f, pointCount - 1.0f); + var historyIndex = Math.Clamp((int)MathF.Round(t * (HistoryColumns - 1)), 0, HistoryColumns - 1); + var physicalColumn = (rollingColumn + 1 + historyIndex) % HistoryColumns; + var energy = rollingNotes[physicalColumn * NoteLaneCount + note]; + var lift = MathF.Pow(Math.Clamp(energy, 0.0f, 1.0f), 0.55f); + var flare = MathF.Pow(lift, 1.7f); + var derivative = flowNoise.Derivative(new Vector4( + position.X * 0.58f + note * 0.013f, + position.Y * 0.72f + note01 * 2.0f, + position.Z * 0.58f - note * 0.017f, + timeSeconds * 0.22f + t * 1.8f)); + var curl = Vector3.Cross(position, derivative); + if (curl.LengthSquared() > 0.0001f) + { + curl = Vector3.Normalize(curl); + } + + var pulse = lift * (0.18f + energy * 0.42f); + var drift = 0.026f + lift * 0.12f; + var jitter = danceNoise.Fractal(note * 1.7f + point * 0.23f + timeSeconds * 0.5f, 2.1f) * 0.025f * (0.2f + lift); + position += radial * (drift + pulse) + curl * (0.055f + lift * 0.20f) + Vector3.UnitY * jitter; + vertices[point] = new AquariumSplineVertex(position, HotColor(flare, laneHue)); + } + + splines.Add(new AquariumSpline3D($"perlines:dance:{note:00}", vertices, style, CatmullRomSubdivisions: 5)); + } + + return new AquariumSplineFrame { Splines = splines }; + } + + private static Vector4 HotColor(float energy, int laneHue) + { + var e = Math.Clamp(energy, 0.0f, 1.0f); + var red = Math.Clamp(2.1f + e * 3.4f, 0.0f, 6.0f); + var green = Math.Clamp(0.02f + e * 0.85f, 0.0f, 1.6f); + var blue = Math.Clamp(e * e * 0.18f, 0.0f, 0.45f); + if (laneHue == 0) + { + green += 1.35f; + blue += 0.08f; + } + else if (laneHue == 4) + { + blue += 1.2f; + red *= 0.72f; + } + + return new Vector4(red, green, blue, Math.Clamp(0.22f + e * 0.78f, 0.0f, 1.0f)); + } + + private AquariumFieldResourceUpload[] BuildResourceUploads() + { + RefreshTubeSamples(); + return + [ + new AquariumFieldResourceUpload + { + ResourceKey = ResourceKey, + Version = resourceVersion, + ElementOffset = 0, + Float32Data = tubeSamples.ToArray(), + } + ]; + } + + private void RefreshTubeSamples() + { + for (var note = 0; note < NoteLaneCount; note++) + { + var noteOffset = note * HistoryColumns; + for (var age = 0; age < HistoryColumns; age++) + { + var physicalColumn = (rollingColumn + 1 + age) % HistoryColumns; + tubeSamples[noteOffset + age] = rollingNotes[physicalColumn * NoteLaneCount + note]; + } + } + } + + private Vector3 ComposeCamera(Vector3 target) + { + var horizontal = MathF.Cos(cameraPitch) * cameraDistance; + return new Vector3( + MathF.Sin(cameraYaw) * horizontal, + -MathF.Cos(cameraYaw) * horizontal, + target.Z + MathF.Sin(cameraPitch) * cameraDistance); + } + + private static AquariumRenderPlan CreateRenderPlan() + { + var app = new AquariumApp(); + var scene = app.RenderTargets.Hdr("scene"); + app.Cameras.Perspective("main"); + app.Graph.Pass("scene").Fullscreen(); + app.Features.Bloom(scene.Color); + app.Features.Presentation(scene.Color); + app.Features.DirectWriteOverlay(); + app.Debug.View("Scene", scene.Color); + return app.Plan; + } +} + +public sealed class PerlinesRuntimeFactory : IAquariumRuntimeFactory +{ + public IAquariumRuntime Create(AquariumRuntimeOptions options) + { + var runtime = new PerlinesRuntime(); + runtime.SetOptions(options); + return runtime; + } +} diff --git a/src/Perlines/PerlinesSignalProcessor.cs b/src/Perlines/PerlinesSignalProcessor.cs new file mode 100644 index 0000000..0168127 --- /dev/null +++ b/src/Perlines/PerlinesSignalProcessor.cs @@ -0,0 +1,255 @@ +using System.Numerics; + +namespace Perlines; + +internal sealed class PerlinesSignalProcessor +{ + private static readonly string[] NoteNames = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]; + private static readonly int[] MajorScale = [0, 2, 4, 5, 7, 9, 11]; + private static readonly int[] MinorScale = [0, 2, 3, 5, 7, 8, 10]; + private static readonly float[] MajorTemplate = [6.35f, 2.23f, 3.48f, 2.33f, 4.38f, 4.09f, 2.52f, 5.19f, 2.39f, 3.66f, 2.29f, 2.88f]; + private static readonly float[] MinorTemplate = [6.33f, 2.68f, 3.52f, 5.38f, 2.60f, 3.53f, 2.54f, 4.75f, 3.98f, 2.69f, 3.34f, 3.17f]; + + private readonly int fftSize; + private readonly int laneCount; + private readonly float sampleRate; + private readonly int binCount; + private readonly float[] window; + private readonly float[] envelope; + private readonly float[] whitenedBins; + private readonly float[] chroma; + private readonly float[] smoothedChroma; + private readonly float[] previousLanes; + private readonly Complex[] spectrum; + private readonly SmoothNoise noise = new(0x5EED); + private float phaseA; + private float phaseB; + private float phaseC; + private int detectedRoot = 9; + private bool detectedMinor; + + public float LastEnergy { get; private set; } + + public float LastFlux { get; private set; } + + public float LastCentroid { get; private set; } + + public string DetectedKeyName => $"{NoteNames[detectedRoot]} {(detectedMinor ? "minor" : "major")}"; + + public PerlinesSignalProcessor(int fftSize, int laneCount, float sampleRate) + { + if (!BitOperations.IsPow2(fftSize)) + { + throw new ArgumentException("FFT size must be a power of two.", nameof(fftSize)); + } + + this.fftSize = fftSize; + this.laneCount = laneCount; + this.sampleRate = sampleRate; + binCount = fftSize / 2; + window = new float[fftSize]; + envelope = new float[binCount]; + whitenedBins = new float[binCount]; + chroma = new float[12]; + smoothedChroma = new float[12]; + previousLanes = new float[laneCount]; + spectrum = new Complex[fftSize]; + Array.Fill(envelope, 0.08f); + + for (var index = 0; index < fftSize; index++) + { + window[index] = 0.5f - 0.5f * MathF.Cos(MathF.Tau * index / Math.Max(1, fftSize - 1)); + } + } + + public void Advance(float timeSeconds, float deltaSeconds, Span output) + { + if (output.Length < laneCount) + { + throw new ArgumentException("Output span is smaller than the configured lane count.", nameof(output)); + } + + FillSignal(timeSeconds); + Fft(spectrum); + WhitenBins(Math.Max(deltaSeconds, 1.0f / 240.0f)); + DetectKey(); + ProjectScaleLanes(output); + } + + private void FillSignal(float timeSeconds) + { + var rootMidi = 45 + ((detectedRoot + 3) % 12); + var baseFrequency = MidiToFrequency(rootMidi) * (1.0f + 0.035f * noise.Fractal(timeSeconds * 0.041f, 2.1f)); + var formant = 0.5f + 0.5f * noise.Fractal(timeSeconds * 0.071f + 17.0f, 3.4f); + var pulse = MathF.Pow(0.5f + 0.5f * MathF.Sin(timeSeconds * 1.7f + noise.Fractal(timeSeconds * 0.19f, 4.0f) * 2.0f), 3.0f); + + for (var index = 0; index < fftSize; index++) + { + var t = index / sampleRate; + var localTime = timeSeconds + t; + var fm = 1.0f + 0.028f * noise.Fractal(localTime * 0.8f, 8.0f); + phaseA += MathF.Tau * baseFrequency * fm / sampleRate; + phaseB += MathF.Tau * (baseFrequency * (1.5f + formant * 0.5f)) / sampleRate; + phaseC += MathF.Tau * (baseFrequency * (2.0f + formant)) / sampleRate; + var sample = + MathF.Sin(phaseA) * 0.50f + + MathF.Sin(phaseB + MathF.Sin(phaseA) * 0.45f) * 0.30f + + MathF.Sin(phaseC + phaseB * 0.12f) * (0.12f + pulse * 0.24f) + + noise.Fractal(localTime * 4.0f, 11.0f) * 0.03f; + spectrum[index] = new Complex(sample * window[index], 0.0f); + } + } + + private void WhitenBins(float safeDelta) + { + Array.Clear(chroma); + for (var bin = 1; bin < binCount; bin++) + { + var frequency = bin * sampleRate / fftSize; + var magnitude = (float)spectrum[bin].Magnitude; + var attack = 1.0f - MathF.Exp(-safeDelta * 22.0f); + var release = 1.0f - MathF.Exp(-safeDelta * 2.8f); + var coefficient = magnitude > envelope[bin] ? attack : release; + envelope[bin] += (magnitude - envelope[bin]) * coefficient; + var whitened = magnitude / Math.Max(envelope[bin], 0.0001f); + var shaped = Math.Clamp(0.5f + MathF.Tanh((whitened - 0.62f) * 1.25f) * 0.5f, 0.0f, 1.0f); + whitenedBins[bin] = MathF.Pow(shaped, 1.12f); + + if (frequency >= 48.0f && frequency <= 6000.0f) + { + var midi = 69.0f + 12.0f * MathF.Log2(frequency / 440.0f); + var chromaIndex = PositiveModulo((int)MathF.Round(midi), 12); + var octaveWeight = 1.0f - Math.Clamp(MathF.Abs(midi - 67.0f) / 60.0f, 0.0f, 0.75f); + chroma[chromaIndex] += whitenedBins[bin] * octaveWeight; + } + } + + for (var note = 0; note < 12; note++) + { + smoothedChroma[note] = smoothedChroma[note] * 0.92f + chroma[note] * 0.08f; + } + } + + private void DetectKey() + { + var bestRoot = detectedRoot; + var bestMinor = detectedMinor; + var bestScore = float.NegativeInfinity; + for (var root = 0; root < 12; root++) + { + var majorScore = KeyScore(root, MajorTemplate); + if (majorScore > bestScore) + { + bestScore = majorScore; + bestRoot = root; + bestMinor = false; + } + + var minorScore = KeyScore(root, MinorTemplate); + if (minorScore > bestScore) + { + bestScore = minorScore; + bestRoot = root; + bestMinor = true; + } + } + + var currentScore = KeyScore(detectedRoot, detectedMinor ? MinorTemplate : MajorTemplate); + if (bestScore > currentScore * 1.035f + 0.02f) + { + detectedRoot = bestRoot; + detectedMinor = bestMinor; + } + } + + private float KeyScore(int root, float[] template) + { + var score = 0.0f; + for (var offset = 0; offset < 12; offset++) + { + score += smoothedChroma[(root + offset) % 12] * template[offset]; + } + + return score; + } + + private void ProjectScaleLanes(Span output) + { + var scale = detectedMinor ? MinorScale : MajorScale; + var baseMidi = 36 + detectedRoot; + var energy = 0.0f; + var flux = 0.0f; + var centroid = 0.0f; + + for (var lane = 0; lane < laneCount; lane++) + { + var octave = lane / scale.Length; + var degree = lane % scale.Length; + var midi = baseMidi + octave * 12 + scale[degree]; + var frequency = MidiToFrequency(midi); + var bin = Math.Clamp((int)MathF.Round(frequency * fftSize / sampleRate), 1, binCount - 2); + var value = + whitenedBins[bin] * 0.56f + + whitenedBins[bin - 1] * 0.22f + + whitenedBins[bin + 1] * 0.22f; + var scaleDegreeAccent = degree is 0 or 2 or 4 ? 1.0f : 0.82f; + var shaped = Math.Clamp(MathF.Pow(value * scaleDegreeAccent, 1.06f), 0.0f, 1.0f); + output[lane] = shaped; + energy += shaped; + flux += Math.Max(0.0f, shaped - previousLanes[lane]); + centroid += shaped * (lane / Math.Max(1.0f, laneCount - 1.0f)); + previousLanes[lane] = shaped; + } + + LastEnergy = energy / laneCount; + LastFlux = flux / laneCount; + LastCentroid = centroid / Math.Max(energy, 0.0001f); + } + + private static float MidiToFrequency(int midi) => + 440.0f * MathF.Pow(2.0f, (midi - 69.0f) / 12.0f); + + private static int PositiveModulo(int value, int modulo) + { + var result = value % modulo; + return result < 0 ? result + modulo : result; + } + + private static void Fft(Complex[] values) + { + var n = values.Length; + var j = 0; + for (var i = 1; i < n; i++) + { + var bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) + { + j ^= bit; + } + + j ^= bit; + if (i < j) + { + (values[i], values[j]) = (values[j], values[i]); + } + } + + for (var length = 2; length <= n; length <<= 1) + { + var angle = -MathF.Tau / length; + var wLength = new Complex(MathF.Cos(angle), MathF.Sin(angle)); + for (var i = 0; i < n; i += length) + { + var w = Complex.One; + for (var k = 0; k < length / 2; k++) + { + var u = values[i + k]; + var v = values[i + k + length / 2] * w; + values[i + k] = u + v; + values[i + k + length / 2] = u - v; + w *= wLength; + } + } + } + } +} diff --git a/src/Perlines/SmoothNoise.cs b/src/Perlines/SmoothNoise.cs new file mode 100644 index 0000000..1e72654 --- /dev/null +++ b/src/Perlines/SmoothNoise.cs @@ -0,0 +1,54 @@ +namespace Perlines; + +internal sealed class SmoothNoise +{ + private readonly float[] values = new float[512]; + private readonly int[] permutation = new int[512]; + + public SmoothNoise(int seed) + { + var random = new Random(seed); + for (var index = 0; index < 256; index++) + { + values[index] = (float)(random.NextDouble() * 2.0 - 1.0); + permutation[index] = index; + } + + for (var index = 255; index > 0; index--) + { + var swap = random.Next(index + 1); + (permutation[index], permutation[swap]) = (permutation[swap], permutation[index]); + } + + for (var index = 0; index < 256; index++) + { + values[index + 256] = values[index]; + permutation[index + 256] = permutation[index]; + } + } + + public float Fractal(float x, float lacunarity) + { + var amplitude = 0.5f; + var frequency = 1.0f; + var value = 0.0f; + for (var octave = 0; octave < 5; octave++) + { + value += Noise(x * frequency) * amplitude; + frequency *= lacunarity; + amplitude *= 0.5f; + } + + return value; + } + + private float Noise(float x) + { + var floor = (int)MathF.Floor(x); + var t = x - floor; + var a = values[permutation[floor & 255]]; + var b = values[permutation[(floor + 1) & 255]]; + var s = t * t * (3.0f - 2.0f * t); + return a + (b - a) * s; + } +}