Visualizers.kt
src/jvmMain/kotlin/afield/audio/synth/Visualizers.kt · 1246 lines
@file:afield.runtime.completion.CompletionCategory("Synthesis")
package afield.audio.synth
import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import afield.document.graph.Box
import afield.runtime.lifecycle.RunScope
import afield.runtime.lifecycle.RunsAt
import afield.document.graph.captureLaunchEpoch
import afield.document.graph.registerLaunchJob
import afield.runtime.lifecycle.newRoot
import afield.ui.visualization.registerValuePrinter
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.*
How to look at the audio. Every function here is an extension on a type declared elsewhere in this category, and none of them changes what you hear.
Most of it happens without being asked for. registerSynthVisualizers runs on each new root box and registers a value printer for AudioPatchSpec, Scope, StereoScope, SpectralWavetable, ADSR, AR, BreakpointEnvelope, EnvelopeFollower, Compressor and Limiter, so returning one of those from a box draws it. The six envelope and dynamics types can be metered at all because each keeps a trace of its own state as it runs; anything else has to be tapped.
Tapping means a Scope. It passes its input through untouched while recording it into a ring
buffer, so saw.scope() sits anywhere in a graph without altering it, and snapshot() lifts a
still AudioSnapshot out of the ring. From a snapshot come the three renderings the file offers
everywhere: waveformSvg, spectrumSvg, and summaryHtml for the two side by side.
The live* family is those same renderings on a timer. Each starts a coroutine on the current box
that re-renders at refreshHz, clamped between 1 and 60, and displays into a named channel, then
returns its own receiver so the call can sit in the middle of an expression. They need a box
context and throw without one. One job is kept per box and channel, a second install cancelling the
first, and the job is registered with the box so it stops when the box does.
private const val SYNTH_BLUE = "#0af"
private const val FIELD_ORANGE = "#f80"
private const val DEFAULT_BACKGROUND = "#000000"
private const val DEFAULT_FONT = "\"Iosevka Web\", \"IBM Plex Mono\", monospace"
internal class ScalarTrace
internal class ScalarTrace(
private val capacity: Int = 4096,
private val stride: Int = 1
) {
private val values = FloatArray(capacity.coerceAtLeast(1))
@Volatile private var writeIndex = 0
@Volatile private var size = 0
private var strideCounter = 0
fun record(value: Float)
fun record(value: Float) {
strideCounter++
if (strideCounter < stride) return
strideCounter = 0
values[writeIndex] = value
writeIndex = (writeIndex + 1) % values.size
if (size < values.size) size++
}
fun snapshot(): FloatArray
fun snapshot(): FloatArray {
val count = size
if (count <= 0) return FloatArray(0)
val out = FloatArray(count)
val start = (writeIndex - count + values.size) % values.size
val first = min(count, values.size - start)
System.arraycopy(values, start, out, 0, first)
if (first < count) {
System.arraycopy(values, 0, out, first, count - first)
}
return out
}
fun clear()
fun clear() {
writeIndex = 0
size = 0
strideCounter = 0
values.fill(0f)
}
}
internal class StereoTrace
internal class StereoTrace(
private val capacity: Int = 4096,
private val stride: Int = 1
) {
private val left = FloatArray(capacity.coerceAtLeast(1))
private val right = FloatArray(capacity.coerceAtLeast(1))
@Volatile private var writeIndex = 0
@Volatile private var size = 0
private var strideCounter = 0
fun recordMono(values: FloatArray, numFrames: Int)
fun recordMono(values: FloatArray, numFrames: Int) {
for (i in 0 until numFrames) {
strideCounter++
if (strideCounter < stride) continue
strideCounter = 0
left[writeIndex] = values[i]
right[writeIndex] = values[i]
advance()
}
}
fun recordStereo(leftValues: FloatArray, rightValues: FloatArray, numFrames: Int)
fun recordStereo(leftValues: FloatArray, rightValues: FloatArray, numFrames: Int) {
for (i in 0 until numFrames) {
strideCounter++
if (strideCounter < stride) continue
strideCounter = 0
left[writeIndex] = leftValues[i]
right[writeIndex] = rightValues[i]
advance()
}
}
private fun advance() {
writeIndex = (writeIndex + 1) % left.size
if (size < left.size) size++
}
fun snapshot(sampleRate: Int): AudioSnapshot
fun snapshot(sampleRate: Int): AudioSnapshot {
val count = size
if (count <= 0) return AudioSnapshot(FloatArray(0), null, sampleRate)
val leftOut = FloatArray(count)
val rightOut = FloatArray(count)
val start = (writeIndex - count + left.size) % left.size
val first = min(count, left.size - start)
System.arraycopy(left, start, leftOut, 0, first)
System.arraycopy(right, start, rightOut, 0, first)
if (first < count) {
System.arraycopy(left, 0, leftOut, first, count - first)
System.arraycopy(right, 0, rightOut, first, count - first)
}
return AudioSnapshot(leftOut, rightOut, sampleRate)
}
fun clear()
fun clear() {
writeIndex = 0
size = 0
strideCounter = 0
left.fill(0f)
right.fill(0f)
}
}
data class AudioSnapshot
A still copy of some audio: left, an optional right, and the sampleRate the samples were
taken at.
Everything in this file ultimately draws one of these. It is a plain value rather than a view on a
live buffer, so it can be held onto, compared with one taken later, or rendered while the graph
carries on moving. isStereo reports whether right arrived, and frameCount is the shorter of
the two channels when it did.
data class AudioSnapshot(
val left: FloatArray,
val right: FloatArray? = null,
val sampleRate: Int = 48_000
) {
val frameCount: Int
val frameCount: Int
get() = if (right == null) left.size else min(left.size, right.size)
val isStereo: Boolean
val isStereo: Boolean
get() = right != null
fun waveformSvg
fun waveformSvg(
widthPx: Int = 480,
heightPx: Int = 96,
title: String = if (isStereo) "Waveform (Stereo)" else "Waveform",
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = renderWaveformSvg(
snapshot = this,
widthPx = widthPx,
heightPx = heightPx,
title = title,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
fun spectrumSvg
fun spectrumSvg(
widthPx: Int = 480,
heightPx: Int = 96,
title: String = if (isStereo) "Spectrum (Stereo)" else "Spectrum",
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = renderSpectrumSvg(
snapshot = this,
widthPx = widthPx,
heightPx = heightPx,
title = title,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
fun summaryHtml
fun summaryHtml(
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String {
val gap = 8
val panelWidth = ((widthPx - gap) / 2).coerceAtLeast(180)
return renderPanelGridHtml(
listOf(
waveformSvg(
widthPx = panelWidth,
heightPx = waveformHeightPx,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
),
spectrumSvg(
widthPx = panelWidth,
heightPx = spectrumHeightPx,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
),
widthPx = widthPx,
gapPx = gap
)
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"frameCount" to frameCount,
"isStereo" to isStereo,
"sampleRate" to sampleRate
)
}
class Scope
A tap on a mono signal: passes its input through unchanged and keeps the last historySize
samples of what went by.
Signal.scope() is the usual way to insert one, and because what comes out is exactly what went in
it can go anywhere in a graph. The ring is overwritten in place, so a Scope is a window on the
recent past rather than a recording; snapshot copies out of it. Returning one from a box draws
its waveform and spectrum, since registerSynthVisualizers gives it a value printer.
class Scope(
historySize: Int = 4096
) : SignalProcessor() {
internal val trace = StereoTrace(capacity = historySize, stride = 1)
@Volatile internal var sampleRateHint: Int = 48_000
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
input.render(output, numFrames, ctx)
sampleRateHint = ctx.sampleRate.roundToInt()
trace.recordMono(output, numFrames)
}
}
class StereoScope
The stereo tap. It wraps a StereoRenderable rather than sitting in a mono chain, and keeps both channels.
StereoRenderable.scope() is the usual way to get one, and it calls StereoRenderable.hold on the
source on the way in, so a stateful stereo generator does not advance itself twice for being
watched. Everything else matches Scope.
class StereoScope(
private val source: StereoRenderable,
historySize: Int = 4096
) : StereoNode(), LiveGraphInspectable {
internal val trace = StereoTrace(capacity = historySize, stride = 1)
@Volatile internal var sampleRateHint: Int = 48_000
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
source.render(outL, outR, numFrames, ctx)
sampleRateHint = ctx.sampleRate.roundToInt()
trace.recordStereo(outL, outR, numFrames)
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("source") { source }
)
}
fun Signal.scope(historySize: Int = 4096): Scope = Scope(historySize).also { it.input = this }
fun StereoRenderable.scope(historySize: Int = 4096): StereoScope
fun StereoRenderable.scope(historySize: Int = 4096): StereoScope = StereoScope(this.hold(), historySize)
fun AudioPatchSpec.snapshot(): AudioSnapshot = visualTrace.snapshot(visualSampleRate)
fun AudioPatchSpec.waveformSvg
fun AudioPatchSpec.waveformSvg(
widthPx: Int = 480,
heightPx: Int = 96,
title: String = "Waveform (Patch)",
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = snapshot().waveformSvg(
widthPx = widthPx,
heightPx = heightPx,
title = title,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
fun AudioPatchSpec.spectrumSvg
fun AudioPatchSpec.spectrumSvg(
widthPx: Int = 480,
heightPx: Int = 96,
title: String = "Spectrum (Patch)",
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = snapshot().spectrumSvg(
widthPx = widthPx,
heightPx = heightPx,
title = title,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
fun AudioPatchSpec.summaryHtml
body — 48 lines, from line 339
fun AudioPatchSpec.summaryHtml(
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = snapshot().summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx,
background = background,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
fontFamily = fontFamily
)
private val liveSynthVisualizerJobs =
java.util.Collections.synchronizedMap(WeakHashMap<Box, MutableMap<String, Job>>())
@OptIn(DelicateCoroutinesApi::class)
private fun installLiveSynthVisualizer(
key: String,
channel: String,
refreshHz: Double,
render: () -> String
) {
val box = Box.context.get()
?: throw IllegalStateException("Live synth visualizers require an active Box context")
val intervalMs = (1000.0 / refreshHz.coerceIn(1.0, 60.0)).toLong().coerceAtLeast(16L)
val epoch = box.captureLaunchEpoch()
val job = GlobalScope.launch(Box.boxContextElement(box)) {
while (isActive) {
box.display(channel, render())
delay(intervalMs)
}
}
liveSynthVisualizerJobs.computeIfAbsent(box) { mutableMapOf() }
.put(key, job)
?.cancel()
// Through the epoch fence rather than `box.jobs +=`, so a stop that lands while this is being
// set up cannot miss the job; and owned by the run, so the next run of the strand ends it too.
if (box.registerLaunchJob(job, epoch)) RunScope.currentFor(box)?.ownJob(job)
}
fun AudioPatchSpec.liveSummary
fun AudioPatchSpec.liveSummary(
channel: String = "audio",
refreshHz: Double = 12.0,
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96
): AudioPatchSpec = apply {
installLiveSynthVisualizer("audio-summary:$channel", channel, refreshHz) {
summaryHtml(widthPx = widthPx, waveformHeightPx = waveformHeightPx, spectrumHeightPx = spectrumHeightPx)
}
}
fun Scope.snapshot(sampleRate: Int = sampleRateHint): AudioSnapshot = trace.snapshot(sampleRate)
fun Scope.waveformSvg
fun Scope.waveformSvg(
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).waveformSvg(widthPx = widthPx, heightPx = heightPx, title = "Waveform (Scope)")
fun Scope.spectrumSvg
fun Scope.spectrumSvg(
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).spectrumSvg(widthPx = widthPx, heightPx = heightPx, title = "Spectrum (Scope)")
fun Scope.summaryHtml
fun Scope.summaryHtml(
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx
)
fun Scope.liveWaveform
fun Scope.liveWaveform(
channel: String = "scope",
refreshHz: Double = 30.0,
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): Scope = apply {
installLiveSynthVisualizer("scope-waveform:$channel", channel, refreshHz) {
waveformSvg(widthPx = widthPx, heightPx = heightPx, sampleRate = sampleRate)
}
}
fun Scope.liveSpectrum
fun Scope.liveSpectrum(
channel: String = "scope",
refreshHz: Double = 30.0,
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): Scope = apply {
installLiveSynthVisualizer("scope-spectrum:$channel", channel, refreshHz) {
spectrumSvg(widthPx = widthPx, heightPx = heightPx, sampleRate = sampleRate)
}
}
fun Scope.liveSummary
fun Scope.liveSummary(
channel: String = "scope",
refreshHz: Double = 12.0,
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
sampleRate: Int = 48_000
): Scope = apply {
installLiveSynthVisualizer("scope-summary:$channel", channel, refreshHz) {
summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx,
sampleRate = sampleRate
)
}
}
fun StereoScope.snapshot(sampleRate: Int = sampleRateHint): AudioSnapshot
fun StereoScope.snapshot(sampleRate: Int = sampleRateHint): AudioSnapshot = trace.snapshot(sampleRate)
fun StereoScope.waveformSvg
fun StereoScope.waveformSvg(
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).waveformSvg(widthPx = widthPx, heightPx = heightPx, title = "Waveform (Stereo Scope)")
fun StereoScope.spectrumSvg
fun StereoScope.spectrumSvg(
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).spectrumSvg(widthPx = widthPx, heightPx = heightPx, title = "Spectrum (Stereo Scope)")
fun StereoScope.summaryHtml
fun StereoScope.summaryHtml(
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
sampleRate: Int = 48_000
): String = snapshot(sampleRate).summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx
)
fun StereoScope.liveWaveform
fun StereoScope.liveWaveform(
channel: String = "stereo",
refreshHz: Double = 12.0,
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): StereoScope = apply {
installLiveSynthVisualizer("stereo-waveform:$channel", channel, refreshHz) {
waveformSvg(widthPx = widthPx, heightPx = heightPx, sampleRate = sampleRate)
}
}
fun StereoScope.liveSpectrum
fun StereoScope.liveSpectrum(
channel: String = "stereo",
refreshHz: Double = 12.0,
widthPx: Int = 480,
heightPx: Int = 96,
sampleRate: Int = 48_000
): StereoScope = apply {
installLiveSynthVisualizer("stereo-spectrum:$channel", channel, refreshHz) {
spectrumSvg(widthPx = widthPx, heightPx = heightPx, sampleRate = sampleRate)
}
}
fun StereoScope.liveSummary
fun StereoScope.liveSummary(
channel: String = "stereo",
refreshHz: Double = 12.0,
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96,
sampleRate: Int = 48_000
): StereoScope = apply {
installLiveSynthVisualizer("stereo-summary:$channel", channel, refreshHz) {
summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx,
sampleRate = sampleRate
)
}
}
fun SpectralWavetable.waveformSvg
fun SpectralWavetable.waveformSvg(
position: Float = lastAccessPosition,
harmonicLimit: Int = frameSize / 2,
widthPx: Int = 480,
heightPx: Int = 96
): String = snapshot(position = position, harmonicLimit = harmonicLimit).waveformSvg(
widthPx = widthPx,
heightPx = heightPx,
title = "Wavetable Waveform"
)
fun SpectralWavetable.spectrumSvg
fun SpectralWavetable.spectrumSvg(
position: Float = lastAccessPosition,
harmonicLimit: Int = frameSize / 2,
widthPx: Int = 480,
heightPx: Int = 96
): String = snapshot(position = position, harmonicLimit = harmonicLimit).spectrumSvg(
widthPx = widthPx,
heightPx = heightPx,
title = "Wavetable Spectrum"
)
fun SpectralWavetable.summaryHtml
fun SpectralWavetable.summaryHtml(
position: Float = lastAccessPosition,
harmonicLimit: Int = frameSize / 2,
widthPx: Int = 640,
waveformHeightPx: Int = 96,
spectrumHeightPx: Int = 96
): String = snapshot(position = position, harmonicLimit = harmonicLimit).summaryHtml(
widthPx = widthPx,
waveformHeightPx = waveformHeightPx,
spectrumHeightPx = spectrumHeightPx
)
fun ADSR.stateSvg
fun ADSR.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = renderCompactTraceSvg(
title = "ADSR",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Level", vizLevelTrace.snapshot(), primaryColor, 0.95, 1.7),
TraceSeries("Gate", vizGateTrace.snapshot(), secondaryColor, 0.55, 1.3)
),
fixedMin = 0f,
fixedMax = 1.05f,
referenceValues = listOf(0f, 1f),
badges = listOf(
SvgBadge(vizStageLabel),
SvgBadge("lvl ${formatFloat(vizCurrentLevel)}")
)
)
fun AR.stateSvg
fun AR.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String = renderCompactTraceSvg(
title = "AR",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Level", vizLevelTrace.snapshot(), primaryColor, 0.95, 1.7),
TraceSeries("Gate", vizGateTrace.snapshot(), secondaryColor, 0.55, 1.3)
),
fixedMin = 0f,
fixedMax = 1.05f,
referenceValues = listOf(0f, 1f),
badges = listOf(
SvgBadge(vizStageLabel),
SvgBadge("lvl ${formatFloat(vizCurrentLevel)}")
)
)
fun BreakpointEnvelope.stateSvg
fun BreakpointEnvelope.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
fontFamily: String = DEFAULT_FONT
): String = renderCompactTraceSvg(
title = "Breakpoint Envelope",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Value", vizLevelTrace.snapshot(), primaryColor, 0.95, 1.7)
),
fixedMin = 0f,
fixedMax = 1.05f,
referenceValues = listOf(0f, 1f),
badges = listOf(
SvgBadge(if (vizRunning) "RUNNING" else "HOLD"),
SvgBadge("${formatFloat(vizCurrentTimeSec * 1000f)} ms")
)
)
fun EnvelopeFollower.stateSvg
fun EnvelopeFollower.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
fontFamily: String = DEFAULT_FONT
): String = renderCompactTraceSvg(
title = "Envelope Follower",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Envelope", vizEnvelopeTrace.snapshot(), primaryColor, 0.95, 1.7)
),
fixedMin = 0f,
fixedMax = max(1.0f, vizEnvelopeTrace.snapshot().maxOrNull() ?: 1f),
referenceValues = listOf(0f),
badges = listOf(
SvgBadge(vizModeLabel),
SvgBadge("lvl ${formatFloat(vizCurrentLevel)}")
)
)
fun Compressor.stateSvg
fun Compressor.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String {
val levelTrace = vizOutputDbTrace.snapshot()
val reductionTrace = vizReductionDbTrace.snapshot()
val minDb = min(-60f, min(levelTrace.minOrNull() ?: 0f, reductionTrace.minOrNull() ?: 0f) - 3f)
return renderCompactTraceSvg(
title = "Compressor",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Output dB", levelTrace, primaryColor, 0.95, 1.7),
TraceSeries("Gain Reduction", reductionTrace, secondaryColor, 0.8, 1.5)
),
fixedMin = minDb,
fixedMax = 3f,
referenceValues = listOf(0f, vizCurrentThresholdDb),
badges = listOf(
SvgBadge("GR ${formatFloat(-vizCurrentGainReductionDb)} dB", secondaryColor),
SvgBadge("th ${formatFloat(vizCurrentThresholdDb)} dB"),
SvgBadge("${formatFloat(vizCurrentRatio)}:1")
)
)
}
fun Limiter.stateSvg
fun Limiter.stateSvg(
widthPx: Int = 480,
heightPx: Int = 84,
background: String = DEFAULT_BACKGROUND,
primaryColor: String = SYNTH_BLUE,
secondaryColor: String = FIELD_ORANGE,
fontFamily: String = DEFAULT_FONT
): String {
val levelTrace = vizOutputDbTrace.snapshot()
val reductionTrace = vizReductionDbTrace.snapshot()
val minDb = min(-48f, min(levelTrace.minOrNull() ?: 0f, reductionTrace.minOrNull() ?: 0f) - 3f)
return renderCompactTraceSvg(
title = "Limiter",
widthPx = widthPx,
heightPx = heightPx,
background = background,
textColor = primaryColor,
fontFamily = fontFamily,
series = listOf(
TraceSeries("Output dB", levelTrace, primaryColor, 0.95, 1.7),
TraceSeries("Gain Reduction", reductionTrace, secondaryColor, 0.8, 1.5)
),
fixedMin = minDb,
fixedMax = 3f,
referenceValues = listOf(0f, vizCurrentCeilingDb),
badges = listOf(
SvgBadge("GR ${formatFloat(-vizCurrentGainReductionDb)} dB", secondaryColor),
SvgBadge("ceiling ${formatFloat(vizCurrentCeilingDb)} dB")
)
)
}
fun ADSR.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
fun AR.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
fun BreakpointEnvelope.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String
fun BreakpointEnvelope.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
fun EnvelopeFollower.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String
fun EnvelopeFollower.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
fun Compressor.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String
fun Compressor.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
fun Limiter.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String
body — 490 lines, from line 742
fun Limiter.stateHtml(widthPx: Int = 480, heightPx: Int = 84): String = stateSvg(widthPx, heightPx)
private data class SvgBadge(
val text: String,
val color: String = SYNTH_BLUE
)
private data class TraceSeries(
val label: String,
val values: FloatArray,
val color: String,
val opacity: Double,
val strokeWidth: Double
)
private data class WaveStats(
val durationMs: Float,
val peakLeft: Float,
val peakRight: Float?,
val rmsLeft: Float,
val rmsRight: Float?
)
private data class SpectrumCurve(
val frequencies: FloatArray,
val dbs: FloatArray
)
private fun renderWaveformSvg(
snapshot: AudioSnapshot,
widthPx: Int,
heightPx: Int,
title: String,
background: String,
primaryColor: String,
secondaryColor: String,
fontFamily: String
): String {
val frames = snapshot.frameCount
if (frames <= 1) return emptySvg(widthPx, heightPx, background, primaryColor, fontFamily, "Waiting for audio")
val leftValues = snapshot.left.copyOf(frames)
val rightValues = snapshot.right?.copyOf(frames)
val stats = computeWaveStats(leftValues, rightValues, snapshot.sampleRate)
val w = widthPx.toDouble()
val h = heightPx.toDouble()
val padLeft = 8.0
val padRight = 8.0
val padTop = 18.0
val padBottom = 12.0
val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
val x0 = padLeft
val y0 = padTop
val centerY = y0 + innerH / 2.0
// Quiet audio drawn on a fixed ±1 axis is a flat line, so a signal peaking below half scale is
// drawn with enough gain to reach 0.9; the gain is printed as a badge so the scale stays honest.
val peak = max(stats.peakLeft, stats.peakRight ?: 0f)
val gain = if (peak > 1e-6f && peak < 0.5f) min(0.9f / peak, 1000f) else 1f
fun yFor(sample: Float): Double {
val normalized = ((sample * gain).coerceIn(-1.05f, 1.05f) + 1.05f) / 2.10f
return y0 + innerH - normalized * innerH
}
val bucketCount = min(max(32, innerW.roundToInt()), frames)
val leftBuckets = buildWaveBuckets(leftValues, bucketCount)
val rightBuckets = rightValues?.let { buildWaveBuckets(it, bucketCount) }
fun drawEnvelope(buckets: List<WaveBucket>, color: String, fillOpacity: Double, strokeOpacity: Double): String {
val envelope = buildWaveEnvelopePath(
buckets = buckets,
x0 = x0,
innerW = innerW,
yFor = ::yFor
)
if (envelope.isEmpty()) return ""
return """
<path d="$envelope" fill="$color" fill-opacity="$fillOpacity" stroke="$color" stroke-opacity="$strokeOpacity" stroke-width="1.0" stroke-linejoin="round"/>
""".trimIndent()
}
val infoBadges = buildBadges(
listOfNotNull(
SvgBadge("${formatFloat(stats.durationMs)} ms"),
SvgBadge("pk ${formatFloat(stats.peakLeft)}"),
if (stats.peakRight != null) SvgBadge("R ${formatFloat(stats.peakRight)}", secondaryColor) else null,
SvgBadge("rms ${formatFloat(stats.rmsLeft)}"),
if (gain != 1f) SvgBadge("×${formatFloat(gain)}") else null
),
widthPx = widthPx,
startX = widthPx - 8,
y = 13,
fontFamily = fontFamily,
title = title
)
val legend = if (snapshot.isStereo) """
<text x="${(x0 + 2).f2()}" y="${(y0 + 11).f2()}" font-family="$fontFamily" font-size="10" fill="$primaryColor">L</text>
<text x="${(x0 + 14).f2()}" y="${(y0 + 11).f2()}" font-family="$fontFamily" font-size="10" fill="$secondaryColor">R</text>
""".trimIndent() else ""
return """
<svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
<rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
<text x="${x0.f2()}" y="13" font-family="$fontFamily" font-size="10" fill="$primaryColor">$title</text>
$infoBadges
<line x1="${x0.f2()}" y1="${centerY.f2()}" x2="${(x0 + innerW).f2()}" y2="${centerY.f2()}" stroke="$primaryColor" opacity="0.25" stroke-width="1"/>
${drawEnvelope(leftBuckets, primaryColor, fillOpacity = 0.24, strokeOpacity = 0.92)}
${if (rightBuckets != null) drawEnvelope(rightBuckets, secondaryColor, fillOpacity = 0.14, strokeOpacity = 0.72) else ""}
$legend
</svg>
""".trimIndent()
}
private fun renderSpectrumSvg(
snapshot: AudioSnapshot,
widthPx: Int,
heightPx: Int,
title: String,
background: String,
primaryColor: String,
secondaryColor: String,
fontFamily: String
): String {
val frames = snapshot.frameCount
if (frames < 64) return emptySvg(widthPx, heightPx, background, primaryColor, fontFamily, "Waiting for audio")
val leftCurve = computeSpectrum(snapshot.left.copyOf(frames), snapshot.sampleRate) ?: return emptySvg(
widthPx, heightPx, background, primaryColor, fontFamily, "No spectrum"
)
val rightCurve = snapshot.right?.copyOf(frames)?.let { computeSpectrum(it, snapshot.sampleRate) }
val w = widthPx.toDouble()
val h = heightPx.toDouble()
val padLeft = 8.0
val padRight = 8.0
val padTop = 18.0
val padBottom = 14.0
val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
val x0 = padLeft
val y0 = padTop
val floorDb = -72f
val maxFreq = (snapshot.sampleRate / 2f).coerceAtLeast(40f)
val minFreq = 20f.coerceAtMost(maxFreq)
fun xFor(freq: Float): Double {
val clamped = freq.coerceIn(minFreq, maxFreq)
val minLog = log10(minFreq.toDouble())
val maxLog = log10(maxFreq.toDouble())
val t = (log10(clamped.toDouble()) - minLog) / (maxLog - minLog)
return x0 + t * innerW
}
fun yFor(db: Float): Double {
val t = ((db.coerceIn(floorDb, 0f) - floorDb) / -floorDb).toDouble()
return y0 + (1.0 - t) * innerH
}
fun pathFor(curve: SpectrumCurve): String {
if (curve.frequencies.isEmpty()) return ""
return buildString {
curve.frequencies.indices.forEach { index ->
val x = xFor(curve.frequencies[index])
val y = yFor(curve.dbs[index])
if (index == 0) append("M ${x.f2()} ${y.f2()}") else append(" L ${x.f2()} ${y.f2()}")
}
}
}
val markerFreqs = listOf(50f, 100f, 500f, 1_000f, 5_000f, 10_000f).filter { it < maxFreq }
val markers = buildString {
markerFreqs.forEach { freq ->
val x = xFor(freq)
append(
"""<line x1="${x.f2()}" y1="${y0.f2()}" x2="${x.f2()}" y2="${(y0 + innerH).f2()}" stroke="$primaryColor" opacity="0.16" stroke-width="1" stroke-dasharray="2,3"/>"""
)
append('\n')
append(
"""<text x="${x.f2()}" y="${(y0 + innerH + 10).f2()}" text-anchor="middle" font-family="$fontFamily" font-size="9" fill="$primaryColor" opacity="0.8">${formatFrequency(freq)}</text>"""
)
append('\n')
}
}
val badges = buildBadges(
listOfNotNull(
SvgBadge("0 dB"),
SvgBadge("${formatFrequency(findPeakFrequency(leftCurve))} peak"),
if (rightCurve != null) SvgBadge("R", secondaryColor) else null
),
widthPx = widthPx,
startX = widthPx - 8,
y = 13,
fontFamily = fontFamily,
title = title
)
return """
<svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
<rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
<text x="${x0.f2()}" y="13" font-family="$fontFamily" font-size="10" fill="$primaryColor">$title</text>
$badges
<line x1="${x0.f2()}" y1="${(y0 + innerH).f2()}" x2="${(x0 + innerW).f2()}" y2="${(y0 + innerH).f2()}" stroke="$primaryColor" opacity="0.25" stroke-width="1"/>
<line x1="${x0.f2()}" y1="${y0.f2()}" x2="${(x0 + innerW).f2()}" y2="${y0.f2()}" stroke="$primaryColor" opacity="0.15" stroke-width="1" stroke-dasharray="2,2"/>
<line x1="${x0.f2()}" y1="${(yFor(-36f)).f2()}" x2="${(x0 + innerW).f2()}" y2="${(yFor(-36f)).f2()}" stroke="$primaryColor" opacity="0.12" stroke-width="1" stroke-dasharray="2,2"/>
$markers
<path d="${pathFor(leftCurve)}" fill="none" stroke="$primaryColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" opacity="0.95"/>
${if (rightCurve != null) """<path d="${pathFor(rightCurve)}" fill="none" stroke="$secondaryColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>""" else ""}
</svg>
""".trimIndent()
}
private fun renderCompactTraceSvg(
title: String,
widthPx: Int,
heightPx: Int,
background: String,
textColor: String,
fontFamily: String,
series: List<TraceSeries>,
fixedMin: Float? = null,
fixedMax: Float? = null,
referenceValues: List<Float> = emptyList(),
badges: List<SvgBadge> = emptyList()
): String {
val values = series.flatMap { s -> s.values.asList().filter { it.isFinite() } }
if (values.isEmpty()) return emptySvg(widthPx, heightPx, background, textColor, fontFamily, "Waiting for state")
val rawMin = fixedMin ?: values.minOrNull() ?: 0f
val rawMax = fixedMax ?: values.maxOrNull() ?: 1f
val minValue = if (rawMin == rawMax) rawMin - 1f else rawMin
val maxValue = if (rawMin == rawMax) rawMax + 1f else rawMax
val w = widthPx.toDouble()
val h = heightPx.toDouble()
val padLeft = 8.0
val padRight = 8.0
val padTop = 18.0
val padBottom = 12.0
val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
val x0 = padLeft
val y0 = padTop
fun yFor(value: Float): Double {
val t = ((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f)
return y0 + (1.0 - t) * innerH
}
fun pathFor(values: FloatArray): String {
if (values.isEmpty()) return ""
val count = values.size
return buildString {
values.forEachIndexed { index, value ->
val x = if (count <= 1) x0 else x0 + innerW * index / (count - 1)
val y = yFor(value)
if (index == 0) append("M ${x.f2()} ${y.f2()}") else append(" L ${x.f2()} ${y.f2()}")
}
}
}
val refs = buildString {
referenceValues.forEach { value ->
if (value in minValue..maxValue) {
val y = yFor(value)
append(
"""<line x1="${x0.f2()}" y1="${y.f2()}" x2="${(x0 + innerW).f2()}" y2="${y.f2()}" stroke="$textColor" opacity="0.18" stroke-width="1" stroke-dasharray="2,2"/>"""
)
append('\n')
}
}
}
val badgeMarkup = buildBadges(badges, widthPx = widthPx, startX = widthPx - 8, y = 13, fontFamily = fontFamily, title = title)
return """
<svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
<rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
<text x="${x0.f2()}" y="13" font-family="$fontFamily" font-size="10" fill="$textColor">$title</text>
$badgeMarkup
$refs
${series.joinToString("\n") { trace ->
"""<path d="${pathFor(trace.values)}" fill="none" stroke="${trace.color}" stroke-width="${trace.strokeWidth}" stroke-linejoin="round" stroke-linecap="round" opacity="${trace.opacity}"/>"""
}}
<text x="${(x0 + innerW).f2()}" y="${(y0 + 9).f2()}" text-anchor="end" font-family="$fontFamily" font-size="9" fill="$textColor" opacity="0.75">${formatFloat(maxValue)}</text>
<text x="${(x0 + innerW).f2()}" y="${(y0 + innerH).f2()}" text-anchor="end" font-family="$fontFamily" font-size="9" fill="$textColor" opacity="0.55">${formatFloat(minValue)}</text>
</svg>
""".trimIndent()
}
private fun renderPanelGridHtml(panels: List<String>, widthPx: Int, gapPx: Int): String = """
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:${gapPx}px;max-width:${widthPx}px;">
${panels.joinToString("\n") { """<div style="line-height:0;">$it</div>""" }}
</div>
""".trimIndent()
private fun emptySvg(
widthPx: Int,
heightPx: Int,
background: String,
textColor: String,
fontFamily: String,
message: String
): String = """
<svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
<rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
<text x="${widthPx / 2}" y="${heightPx / 2}" text-anchor="middle" dominant-baseline="middle"
font-family="$fontFamily" font-size="11" fill="$textColor" opacity="0.75">$message</text>
</svg>
""".trimIndent()
private data class WaveBucket(val min: Float, val max: Float)
private fun buildWaveEnvelopePath(
buckets: List<WaveBucket>,
x0: Double,
innerW: Double,
yFor: (Float) -> Double
): String {
if (buckets.isEmpty()) return ""
val count = buckets.size
val step = if (count <= 1) 0.0 else innerW / (count - 1)
return buildString {
buckets.forEachIndexed { index, bucket ->
val x = x0 + step * index
val y = yFor(bucket.max)
if (index == 0) append("M ${x.f2()} ${y.f2()}") else append(" L ${x.f2()} ${y.f2()}")
}
for (index in buckets.indices.reversed()) {
val bucket = buckets[index]
val x = x0 + step * index
append(" L ${x.f2()} ${yFor(bucket.min).f2()}")
}
append(" Z")
}
}
private fun buildWaveBuckets(values: FloatArray, bucketCount: Int): List<WaveBucket> {
if (values.isEmpty()) return emptyList()
val safeCount = bucketCount.coerceAtLeast(1).coerceAtMost(values.size)
return List(safeCount) { bucketIndex ->
val start = (bucketIndex * values.size) / safeCount
val endExclusive = max(start + 1, ((bucketIndex + 1) * values.size) / safeCount).coerceAtMost(values.size)
var minValue = Float.POSITIVE_INFINITY
var maxValue = Float.NEGATIVE_INFINITY
for (i in start until endExclusive) {
minValue = min(minValue, values[i])
maxValue = max(maxValue, values[i])
}
WaveBucket(minValue, maxValue)
}
}
private fun computeWaveStats(left: FloatArray, right: FloatArray?, sampleRate: Int): WaveStats {
fun peak(values: FloatArray): Float = values.maxOfOrNull { kotlin.math.abs(it) } ?: 0f
fun rms(values: FloatArray): Float {
if (values.isEmpty()) return 0f
var sum = 0.0
values.forEach { sum += it * it }
return sqrt(sum / values.size).toFloat()
}
return WaveStats(
durationMs = left.size * 1000f / sampleRate.toFloat(),
peakLeft = peak(left),
peakRight = right?.let(::peak),
rmsLeft = rms(left),
rmsRight = right?.let(::rms)
)
}
private fun computeSpectrum(values: FloatArray, sampleRate: Int, preferredFftSize: Int = 2048): SpectrumCurve? {
val fftSize = selectFftSize(values.size, preferredFftSize)
if (fftSize < 64) return null
val start = values.size - fftSize
val complex = FloatArray(fftSize * 2)
for (i in 0 until fftSize) {
val window = (0.5 - 0.5 * cos((2.0 * PI * i) / (fftSize - 1))).toFloat()
complex[i * 2] = values[start + i] * window
complex[i * 2 + 1] = 0f
}
FFT(fftSize).forward(complex)
val frequencies = FloatArray((fftSize / 2) - 1)
val dbs = FloatArray((fftSize / 2) - 1)
var peak = 0f
for (bin in 1 until fftSize / 2) {
val re = complex[bin * 2]
val im = complex[bin * 2 + 1]
val mag = sqrt(re * re + im * im)
peak = max(peak, mag)
}
if (peak <= 1e-7f) return null
for (bin in 1 until fftSize / 2) {
val re = complex[bin * 2]
val im = complex[bin * 2 + 1]
val mag = sqrt(re * re + im * im)
frequencies[bin - 1] = bin * sampleRate.toFloat() / fftSize.toFloat()
dbs[bin - 1] = max(-72f, (20.0 * log10((mag / peak).coerceAtLeast(1e-7f).toDouble())).toFloat())
}
return SpectrumCurve(frequencies, dbs)
}
private fun selectFftSize(length: Int, preferred: Int): Int {
val maxSize = min(length, preferred).coerceAtLeast(0)
if (maxSize < 2) return 0
var size = 1
while (size * 2 <= maxSize) size *= 2
return size
}
private fun buildBadges(
badges: List<SvgBadge>,
widthPx: Int,
startX: Int,
y: Int,
fontFamily: String,
title: String = "",
): String {
if (badges.isEmpty()) return ""
// Badges are laid out right to left and stop before they reach the panel's title, which is
// drawn from x = 8 at the same baseline; a narrow panel drops its leftmost badges instead of
// printing them over the title.
val leftLimit = max(widthPx * 0.35, 8.0 + estimatedTextWidth(title) + 8.0)
var cursor = startX.toDouble()
val parts = mutableListOf<String>()
for (badge in badges.asReversed()) {
val estimatedWidth = estimatedTextWidth(badge.text) + 10.0
if (cursor - estimatedWidth < leftLimit) break
cursor -= estimatedWidth
parts += """
<text x="${cursor.f2()}" y="$y" font-family="$fontFamily" font-size="10" fill="${badge.color}" opacity="0.92">${escapeXml(badge.text)}</text>
""".trimIndent()
cursor -= 8.0
}
return parts.asReversed().joinToString("\n")
}
private fun estimatedTextWidth(text: String): Double = text.length * 6.2
private fun findPeakFrequency(curve: SpectrumCurve): Float {
if (curve.frequencies.isEmpty()) return 0f
var bestIndex = 0
var bestValue = Float.NEGATIVE_INFINITY
curve.dbs.indices.forEach { index ->
if (curve.dbs[index] > bestValue) {
bestValue = curve.dbs[index]
bestIndex = index
}
}
return curve.frequencies[bestIndex]
}
private fun formatFloat(value: Float): String {
val abs = kotlin.math.abs(value)
val decimals = when {
abs >= 100f -> 0
abs >= 10f -> 1
abs >= 1f -> 2
else -> 3
}
return String.format(Locale.US, "%.${decimals}f", value)
}
private fun formatFrequency(freqHz: Float): String {
if (!freqHz.isFinite() || freqHz <= 0f) return "--"
return if (freqHz >= 1_000f) {
String.format(Locale.US, "%.1fk", freqHz / 1_000f)
} else {
String.format(Locale.US, "%.0f", freqHz)
}
}
private fun escapeXml(text: String): String = text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
private fun Double.f2(): String = String.format(Locale.US, "%.2f", this)
fun registerSynthVisualizers()
@RunsAt(newRoot)
fun registerSynthVisualizers() {
registerValuePrinter<AudioPatchSpec> { it.summaryHtml() }
registerValuePrinter<Scope> { it.summaryHtml() }
registerValuePrinter<StereoScope> { it.summaryHtml() }
registerValuePrinter<SpectralWavetable> { it.summaryHtml() }
registerValuePrinter<ADSR> { it.stateHtml() }
registerValuePrinter<AR> { it.stateHtml() }
registerValuePrinter<BreakpointEnvelope> { it.stateHtml() }
registerValuePrinter<EnvelopeFollower> { it.stateHtml() }
registerValuePrinter<Compressor> { it.stateHtml() }
registerValuePrinter<Limiter> { it.stateHtml() }
}