Build Fensalir Perlines demo
This commit is contained in:
commit
2d386ed588
11 changed files with 1097 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
.vs/
|
||||
.idea/
|
||||
*.user
|
||||
*.suo
|
||||
*.log
|
||||
TestResults/
|
||||
|
||||
18
Perlines.sln
Normal file
18
Perlines.sln
Normal file
|
|
@ -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
|
||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -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.
|
||||
61
docs/system-map.md
Normal file
61
docs/system-map.md
Normal file
|
|
@ -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.
|
||||
104
scripts/capture-headless.ps1
Normal file
104
scripts/capture-headless.ps1
Normal file
|
|
@ -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 { "<stderr missing>" }
|
||||
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 { "<stderr missing>" }
|
||||
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"
|
||||
16
scripts/run-dev.ps1
Normal file
16
scripts/run-dev.ps1
Normal file
|
|
@ -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
|
||||
}
|
||||
81
src/Perlines/Perlin4D.cs
Normal file
81
src/Perlines/Perlin4D.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
16
src/Perlines/Perlines.csproj
Normal file
16
src/Perlines/Perlines.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>Perlines</AssemblyName>
|
||||
<RootNamespace>Perlines</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="E:\Projects\Fensalir\src\Aquarium.Engine.Contracts\Aquarium.Engine.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
439
src/Perlines/PerlinesRuntime.cs
Normal file
439
src/Perlines/PerlinesRuntime.cs
Normal file
|
|
@ -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<AquariumSpline3D>(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;
|
||||
}
|
||||
}
|
||||
255
src/Perlines/PerlinesSignalProcessor.cs
Normal file
255
src/Perlines/PerlinesSignalProcessor.cs
Normal file
|
|
@ -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<float> 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<float> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Perlines/SmoothNoise.cs
Normal file
54
src/Perlines/SmoothNoise.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue