DoughDspPlotGallery.kt
src/jvmMain/kotlin/afield/audio/synth/DoughDspPlotGallery.kt · 648 lines
package afield.audio.synth
import afield.ui.visualization.niceTicks
import afield.ui.visualization.tickLabels
import java.awt.BasicStroke
import java.awt.Color
import java.awt.Font
import java.awt.RenderingHints
import java.awt.geom.Ellipse2D
import java.awt.geom.Line2D
import java.awt.geom.Path2D
import java.awt.geom.Rectangle2D
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.math.abs
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.sqrt
FIELD_TEST id: synth.dough-dsp.gallery runner: jvm-main entrypoint: afield.audio.synth.DoughDspPlotGallery tags: [smoke, synth, dsp, dough, audio, plot, capture, cpu] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "dough dsp plot gallery rendered"
Response plots of the dough DSP primitives (PlateReverb, Phaser and others), drawn from samples the primitives actually produce.
DoughDspPlotGallery runs each primitive on the CPU and draws the result to PNG with AWT, with no
browser or GPU. It runs as the synth.dough-dsp.gallery FIELD_TEST; given an output directory it
writes the captures under dev_docs/assets/dough_dsp_gallery/.
object DoughDspPlotGallery
Headless response-gallery / plot-capture harness for the dough DSP ports in DoughDsp.
It is the visual companion to DoughDspSmokeTest (which asserts the same behaviour numerically):
every plotted point comes from actually running a primitive on the CPU — an impulse into
PlateReverb, a swept sine into Phaser / ChamberlinLowpass, a ramp into SoftDistort, a
gated CurvedADSR, and raw ZawOsc / PulzeOsc periods — never a synthesized or hand-drawn
curve.
Rendering path mirrors afield.examples.functions.FunctionsPlotGallery and
afield.examples.rectsl.RectSLDataVizProof: pure-CPU AWT Graphics2D → BufferedImage →
ImageIO PNG, run under java.awt.headless=true. There is no browser, GPU, Vulkan or Field
window. Only the "draw a line between real samples" step is synthetic; the samples themselves
are real.
As a FIELD_TEST (synth.dough-dsp.gallery) it renders to a temp dir and asserts real
correctness (painted fraction, finite samples, and per-plot sanity: the reverb tail decays and
L≠R, the resonant filter peaks above its flat setting, the phaser notch dips, the ADSR
retrigger has no discontinuity, the soft-clip is monotonic). It writes NO committed assets
during the test. Pass an output dir as args[0] (or set DOUGH_DSP_PLOT_DIR) to regenerate the
committed docs captures under dev_docs/assets/dough_dsp_gallery/.
The harness is parameterized/reusable: add a plot by appending one Plot to PLOTS.
object DoughDspPlotGallery {
private const val SR = 48_000f
private const val BLOCK = 128
plot model
enum class Style { LINE, THICK, DASHED, AREA, POINTS }
class Series
class Series(
val label: String,
val xs: DoubleArray,
val ys: DoubleArray,
val color: Color,
val style: Style = Style.LINE
)
class PlotSpec
class PlotSpec(
val title: String,
val xLabel: String,
val yLabel: String,
val series: List<Series>,
val yRange: Pair<Double, Double>? = null,
/** Log-scale the x axis — filter magnitude responses need it to read a peak. */
val logX: Boolean = false
)
class Plot
A named plot the gallery renders, plus an optional deeper correctness check on its data.
class Plot(
val name: String,
val build: () -> PlotSpec,
val verify: (PlotSpec) -> Unit = {}
)
Dark-mode palette shared with the rest of the docs figures.
private val BG = Color(20, 22, 28)
private val PANEL = Color(28, 30, 38)
private val AXIS = Color(120, 126, 140)
private val GRID = Color(48, 52, 62)
private val TEXT = Color(206, 210, 220)
private val CYAN = Color(72, 202, 228)
private val ORANGE = Color(240, 156, 74)
private val VIOLET = Color(176, 142, 240)
private val GREEN = Color(120, 214, 140)
private val PINK = Color(232, 120, 168)
private val GREY = Color(150, 156, 168)
curated plots
val PLOTS: List<Plot>
body — 182 lines, from line 115
val PLOTS: List<Plot> = listOf(
Plot("plate-reverb-impulse", {
// A single-sample impulse into the plate: the abs-peak decay envelope of each channel.
// The tail must decay, and L and R diverge (the seven-tap-per-side decorrelation).
val (l, r) = renderStereo(
PlateReverb(source = ImpulseSource(), decay = Param(0.85f), mix = Param(1f)),
1.5f
)
val seconds = 1.5
PlotSpec(
title = "PlateReverb impulse response — abs-peak envelope (decay 0.85)",
xLabel = "time (s)", yLabel = "|amplitude|",
series = listOf(
envelope("left", l, seconds, CYAN, Style.LINE),
envelope("right", r, seconds, ORANGE, Style.LINE)
)
)
}, { spec ->
val l = spec.series[0].ys
val r = spec.series[1].ys
// decays: mean of the last third well below the first third.
fun meanAbs(a: DoubleArray, from: Int, to: Int): Double {
var s = 0.0; for (i in from until to) s += abs(a[i]); return s / (to - from)
}
val early = meanAbs(l, l.size / 20, l.size / 6)
val late = meanAbs(l, l.size * 5 / 6, l.size)
check(late < early * 0.5) { "plate-reverb tail did not decay: early=$early late=$late" }
// decorrelated: L and R envelopes are not the same curve.
var same = 0; var seen = 0
for (i in l.indices) if (max(abs(l[i]), abs(r[i])) > 1e-4) { seen++; if (abs(l[i] - r[i]) < 1e-5) same++ }
check(seen > 0 && same < seen / 2) { "plate-reverb L and R look identical (not decorrelated)" }
}),
Plot("phaser-swept-notch", {
// Magnitude response with the LFO parked (rate=0) at three centre settings: the notch
// sits at a different frequency in each, i.e. the positions its sweep moves through.
fun response(center: Float): Series =
magnitude("notch @ ${center.toInt()} Hz", { f ->
SineOsc(Param(f)).through(
Phaser(rate = Param(0f), depth = Param(0f), center = Param(center), sweep = Param(0f), mix = Param(1f))
)
}, CYAN)
val a = response(400f); val b = response(1200f); val c = response(3500f)
PlotSpec(
title = "Phaser swept notch — magnitude at three LFO positions (rate 0)",
xLabel = "frequency (Hz)", yLabel = "gain (dB)",
series = listOf(
a.recolor(CYAN), b.recolor(ORANGE), c.recolor(VIOLET)
),
yRange = -26.0 to 6.0,
logX = true
)
}, { spec ->
// each curve must dip: its minimum is well below its high-frequency shelf.
for (s in spec.series) {
val lo = s.ys.filter { it.isFinite() }.min()
val shelf = s.ys.takeLast(s.ys.size / 6).filter { it.isFinite() }.average()
check(lo < shelf - 6.0) { "phaser '${s.label}' notch too shallow: min=$lo shelf=$shelf" }
}
}),
Plot("chamberlin-lowpass", {
fun response(label: String, cutoff: Float, res: Float, color: Color): Series =
magnitude(label, { f ->
SineOsc(Param(f)).through(ChamberlinLowpass(Param(cutoff), Param(res)))
}, color)
PlotSpec(
title = "ChamberlinLowpass magnitude — cutoff / resonance",
xLabel = "frequency (Hz)", yLabel = "gain (dB)",
series = listOf(
response("1 kHz, res 0.0", 1000f, 0.0f, GREY),
response("1 kHz, res 0.7", 1000f, 0.7f, CYAN),
response("1 kHz, res 0.95", 1000f, 0.95f, ORANGE),
response("4 kHz, res 0.9", 4000f, 0.9f, VIOLET)
),
yRange = -42.0 to 18.0,
logX = true
)
}, { spec ->
val flat = spec.series[0].ys
val resonant = spec.series[2].ys // 1 kHz res 0.95
check(resonant.max() > flat.max() + 4.0) {
"resonant setting shows no peak: flatMax=${flat.max()} resMax=${resonant.max()}"
}
}),
Plot("curved-adsr", {
// Gate on, off part-way through release, on again, off. CurvedADSR retriggers from the
// current level (no click); plain ADSR restarts from a jump — plotted for contrast.
val schedule = listOf(true to 0.18f, false to 0.05f, true to 0.15f, false to 0.28f)
val a = 0.03f; val d = 0.06f; val s = 0.6f; val rel = 0.12f
val gate = Gate()
val curved = CurvedADSR(gate, Param(a), Param(d), Param(s), Param(rel))
val (times, curvedLevels, gates) = runEnvelope(curved, gate, schedule)
val plainGate = Gate()
val plain = ADSR(plainGate, Param(a), Param(d), Param(s), Param(rel))
val (_, plainLevels, _) = runEnvelope(plain, plainGate, schedule)
PlotSpec(
title = "CurvedADSR — click-free retrigger (vs plain ADSR)",
xLabel = "time (ms)", yLabel = "level",
series = listOf(
Series("gate", times, gates, GREY, Style.DASHED),
Series("ADSR (jumps)", times, plainLevels, PINK, Style.LINE),
Series("CurvedADSR", times, curvedLevels, CYAN, Style.THICK)
),
yRange = -0.05 to 1.1
)
}, { spec ->
val curved = spec.series[2].ys
check(curved.max() > 0.98) { "CurvedADSR never reached full level: peak=${curved.max()}" }
check(abs(curved.last()) < 0.02) { "CurvedADSR did not return to zero: last=${curved.last()}" }
var maxJump = 0.0
for (i in 1 until curved.size) maxJump = max(maxJump, abs(curved[i] - curved[i - 1]))
// downsampled trace, so allow a larger threshold than the per-sample smoke test.
check(maxJump < 0.08) { "CurvedADSR retrigger has a discontinuity: maxJump=$maxJump" }
}),
Plot("soft-distort-transfer", {
val n = 800
val xs = DoubleArray(n) { -3.0 + 6.0 * it / (n - 1) }
fun curve(label: String, amount: Float, color: Color): Series {
val ys = transfer(SoftDistort(Param(amount), Param(1f)), xs)
return Series(label, xs, ys, color, Style.LINE)
}
PlotSpec(
title = "SoftDistort transfer curve (input sweep -3..3)",
xLabel = "input", yLabel = "output",
series = listOf(
Series("identity", xs, xs.copyOf(), GREY, Style.DASHED),
curve("amount 0.5", 0.5f, GREEN),
curve("amount 1.0", 1.0f, CYAN),
curve("amount 2.0", 2.0f, ORANGE),
curve("amount 4.0", 4.0f, VIOLET)
),
yRange = -2.0 to 2.0
)
}, { spec ->
val shapers = spec.series.drop(1) // amounts 0.5, 1, 2, 4
for (s in shapers) {
var monotonic = true
for (i in 1 until s.ys.size) if (s.ys[i] < s.ys[i - 1] - 1e-6) monotonic = false
check(monotonic) { "SoftDistort '${s.label}' transfer curve is not monotonic" }
check(s.ys.all { it.isFinite() && abs(it) <= 2.6 }) { "SoftDistort '${s.label}' left the expected bound" }
}
// More drive = more compression: the output at the top of the input sweep decreases.
val ends = shapers.map { it.ys.last() }
for (i in 1 until ends.size) {
check(ends[i] < ends[i - 1]) { "SoftDistort drive did not increase compression: $ends" }
}
}),
Plot("naive-oscillators", {
// Two periods of each raw (aliasing) oscillator at 200 Hz — 240 samples/period @ 48 kHz.
val freq = 200f
val periods = 2
val n = (SR / freq * periods).toInt()
fun ms(samples: FloatArray) = DoubleArray(samples.size) { it / SR.toDouble() * 1000.0 }
val zaw = renderMonoSamples(ZawOsc(Param(freq)), n)
val sq = renderMonoSamples(PulzeOsc(Param(freq), Param(0.5f)), n)
val narrow = renderMonoSamples(PulzeOsc(Param(freq), Param(0.25f)), n)
PlotSpec(
title = "Naive oscillators — 2 periods @ 200 Hz (raw, aliasing)",
xLabel = "time (ms)", yLabel = "amplitude",
series = listOf(
Series("zaw (saw)", ms(zaw), zaw.toDoubles(), CYAN, Style.LINE),
Series("pulze (duty 0.5)", ms(sq), sq.toDoubles(), ORANGE, Style.LINE),
Series("pulze (duty 0.25)", ms(narrow), narrow.toDoubles(), VIOLET, Style.LINE)
),
yRange = -1.3 to 1.3
)
}, { spec ->
val zaw = spec.series[0].ys
check(zaw.min() < -0.9 && zaw.max() > 0.9) { "zaw does not span [-1,1]: ${zaw.min()}..${zaw.max()}" }
})
)
private fun Series.recolor(c: Color) = Series(label, xs, ys, c, style)
private fun FloatArray.toDoubles(): DoubleArray = DoubleArray(size) { this[it].toDouble() }
CPU drivers (real primitive output)
A single 1.0 sample followed by silence.
private class ImpulseSource : Signal() {
private var fired = false
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
java.util.Arrays.fill(output, 0, numFrames, 0f)
if (!fired) { output[0] = 1f; fired = true }
}
}
Emits a caller-supplied sample sequence, one per frame (for probing transfer curves).
source — 41 lines, from line 310
private class SequenceSource(private val values: DoubleArray) : Signal() {
private var i = 0
override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
for (k in 0 until numFrames) { output[k] = if (i < values.size) values[i].toFloat() else 0f; i++ }
}
}
private fun renderMonoSamples(signal: Signal, total: Int): FloatArray {
val ctx = SynthContext(SR, BLOCK)
val out = FloatArray(total)
val buf = FloatArray(BLOCK)
var written = 0
while (written < total) {
signal.render(buf, BLOCK, ctx)
ctx.advanceCycle()
val n = minOf(BLOCK, total - written)
System.arraycopy(buf, 0, out, written, n)
written += n
}
return out
}
private fun renderMono(signal: Signal, seconds: Float): FloatArray =
renderMonoSamples(signal, (seconds * SR).toInt())
private fun renderStereo(node: StereoRenderable, seconds: Float): Pair<FloatArray, FloatArray> {
val ctx = SynthContext(SR, BLOCK)
val total = (seconds * SR).toInt()
val outL = FloatArray(total); val outR = FloatArray(total)
val bufL = FloatArray(BLOCK); val bufR = FloatArray(BLOCK)
var written = 0
while (written < total) {
node.render(bufL, bufR, BLOCK, ctx)
ctx.advanceCycle()
val n = minOf(BLOCK, total - written)
System.arraycopy(bufL, 0, outL, written, n)
System.arraycopy(bufR, 0, outR, written, n)
written += n
}
return outL to outR
}
Bucketed abs-peak envelope of a signal over seconds, as (x=time s, y=|peak|).
private fun envelope(label: String, samples: FloatArray, seconds: Double, color: Color, style: Style, buckets: Int = 360): Series {
val n = buckets.coerceAtMost(samples.size).coerceAtLeast(1)
val xs = DoubleArray(n); val ys = DoubleArray(n)
for (b in 0 until n) {
val start = (b.toLong() * samples.size / n).toInt()
val end = ((b + 1).toLong() * samples.size / n).toInt().coerceAtLeast(start + 1).coerceAtMost(samples.size)
var peak = 0f
for (i in start until end) peak = max(peak, abs(samples[i]))
xs[b] = (start + end) / 2.0 / samples.size * seconds
ys[b] = peak.toDouble()
}
return Series(label, xs, ys, color, style)
}
Magnitude response in dB: for each log-spaced frequency, run a sine through the chain and measure steady-state output RMS relative to the input sine's RMS.
private fun magnitude(
label: String,
chain: (Float) -> Signal,
color: Color,
fromHz: Float = 30f,
toHz: Float = 18_000f,
points: Int = 140
): Series {
val xs = DoubleArray(points); val ys = DoubleArray(points)
val logFrom = log10(fromHz.toDouble()); val logTo = log10(toHz.toDouble())
for (i in 0 until points) {
val f = Math.pow(10.0, logFrom + (logTo - logFrom) * i / (points - 1)).toFloat()
val out = renderMono(chain(f), 0.35f)
// steady state: skip the first 60%, measure the last 40%.
val steady = out.copyOfRange((out.size * 6 / 10), out.size)
val outRms = rms(steady)
val inRms = 0.7071068f // unit-amplitude sine
val db = 20.0 * log10((outRms / inRms).coerceAtLeast(1e-4f).toDouble())
xs[i] = f.toDouble(); ys[i] = db
}
return Series(label, xs, ys, color, Style.LINE)
}
Push a value sequence through a memoryless processor, returning the output per input.
private fun transfer(processor: SignalProcessor, xs: DoubleArray): DoubleArray {
val chain = SequenceSource(xs).through(processor)
val out = renderMonoSamples(chain, xs.size)
return DoubleArray(xs.size) { out[it].toDouble() }
}
Run an envelope through a gate schedule, returning (timesMs, levels, gate01) downsampled.
private fun runEnvelope(env: Signal, gate: Gate, schedule: List<Pair<Boolean, Float>>): Triple<DoubleArray, DoubleArray, DoubleArray> {
val ctx = SynthContext(SR, BLOCK)
val buf = FloatArray(BLOCK)
val levels = ArrayList<Float>(); val gates = ArrayList<Float>()
for ((open, seconds) in schedule) {
if (open) gate.open() else gate.close()
val blocks = (seconds * SR / BLOCK).toInt()
repeat(blocks) {
env.render(buf, BLOCK, ctx)
ctx.advanceCycle()
for (i in 0 until BLOCK) { levels += buf[i]; gates += if (open) 1f else 0f }
}
}
val stride = 16
val m = levels.size / stride
val times = DoubleArray(m); val lv = DoubleArray(m); val gt = DoubleArray(m)
for (j in 0 until m) {
val idx = j * stride
times[j] = idx / SR.toDouble() * 1000.0
lv[j] = levels[idx].toDouble()
gt[j] = gates[idx].toDouble()
}
return Triple(times, lv, gt)
}
private fun rms(samples: FloatArray): Float {
if (samples.isEmpty()) return 0f
var sum = 0.0
for (s in samples) sum += s.toDouble() * s
return sqrt(sum / samples.size).toFloat()
}
renderer (shared with FunctionsPlotGallery; + log-x support)
private const val W = 900
private const val H = 540
private const val ML = 78
private const val MR = 24
private const val MT = 48
private const val MB = 54
fun render(spec: PlotSpec): BufferedImage
body — 152 lines, from line 441
fun render(spec: PlotSpec): BufferedImage {
val img = BufferedImage(W, H, BufferedImage.TYPE_INT_RGB)
val g = img.createGraphics()
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
g.color = BG
g.fillRect(0, 0, W, H)
val plotX = ML; val plotY = MT
val plotW = W - ML - MR; val plotH = H - MT - MB
g.color = PANEL
g.fillRect(plotX, plotY, plotW, plotH)
var xmin = Double.POSITIVE_INFINITY; var xmax = Double.NEGATIVE_INFINITY
for (s in spec.series) for (x in s.xs) { if (x < xmin) xmin = x; if (x > xmax) xmax = x }
if (!xmin.isFinite() || !xmax.isFinite() || xmin == xmax) { xmin = 0.0; xmax = 1.0 }
val (ymin, ymax) = spec.yRange ?: autoYRange(spec.series)
val logLo = if (spec.logX) log10(xmin.coerceAtLeast(1e-9)) else 0.0
val logHi = if (spec.logX) log10(xmax.coerceAtLeast(xmin * 1.0000001)) else 0.0
fun xToPx(x: Double): Double =
if (spec.logX) plotX + (log10(x.coerceAtLeast(1e-9)) - logLo) / (logHi - logLo) * plotW
else plotX + (x - xmin) / (xmax - xmin) * plotW
fun yToPx(y: Double) = plotY + plotH - (y - ymin) / (ymax - ymin) * plotH
g.font = Font(Font.SANS_SERIF, Font.PLAIN, 11)
g.stroke = BasicStroke(1f)
// Linear tick selection and labelling come from `charts.kt` (`afield.ui.visualization`). The *audio* halves stay
// local on purpose: [LOG_TICKS] is the 20 Hz-20 kHz decade ladder a frequency response is
// read against, not a general log axis, and [fmtHz] spells 2000 as "2k".
val yTicks = niceTicks(ymin, ymax, 6)
val yText = tickLabels(yTicks, if (yTicks.size >= 2) yTicks[1] - yTicks[0] else 0.0)
yTicks.forEachIndexed { i, yt ->
val py = yToPx(yt).toFloat()
g.color = GRID
g.draw(Line2D.Double(plotX.toDouble(), py.toDouble(), (plotX + plotW).toDouble(), py.toDouble()))
g.color = TEXT
val str = yText[i]
g.drawString(str, (plotX - 8 - g.fontMetrics.stringWidth(str)).toFloat(), py + 4)
}
val xTicks = if (spec.logX) logTicks(xmin, xmax) else niceTicks(xmin, xmax, 6)
val xText =
if (spec.logX) xTicks.map { fmtHz(it) }
else tickLabels(xTicks, if (xTicks.size >= 2) xTicks[1] - xTicks[0] else 0.0)
xTicks.forEachIndexed { i, xt ->
val px = xToPx(xt).toFloat()
g.color = GRID
g.draw(Line2D.Double(px.toDouble(), plotY.toDouble(), px.toDouble(), (plotY + plotH).toDouble()))
g.color = TEXT
val str = xText[i]
g.drawString(str, px - g.fontMetrics.stringWidth(str) / 2f, (plotY + plotH + 18).toFloat())
}
if (0.0 in ymin..ymax) {
g.color = AXIS; g.stroke = BasicStroke(1.4f)
val py = yToPx(0.0)
g.draw(Line2D.Double(plotX.toDouble(), py, (plotX + plotW).toDouble(), py))
}
g.color = AXIS; g.stroke = BasicStroke(1.2f)
g.draw(Rectangle2D.Double(plotX.toDouble(), plotY.toDouble(), plotW.toDouble(), plotH.toDouble()))
val solid = BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
val thick = BasicStroke(3.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
val dashed = BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND, 10f, floatArrayOf(7f, 6f), 0f)
for (s in spec.series) {
g.color = s.color
when (s.style) {
Style.POINTS -> for (i in s.xs.indices) {
val y = s.ys[i]; if (!y.isFinite()) continue
g.fill(Ellipse2D.Double(xToPx(s.xs[i]) - 2.5, yToPx(y) - 2.5, 5.0, 5.0))
}
Style.AREA -> {
g.stroke = solid
val path = Path2D.Double()
val base = yToPx(0.0.coerceIn(ymin, ymax))
var started = false
for (i in s.xs.indices) {
val y = s.ys[i]; if (!y.isFinite()) continue
val px = xToPx(s.xs[i]); val py = yToPx(y)
if (!started) { path.moveTo(px, base); path.lineTo(px, py); started = true } else path.lineTo(px, py)
}
path.lineTo(xToPx(s.xs.last()), base)
g.color = Color(s.color.red, s.color.green, s.color.blue, 70)
g.fill(path)
g.color = s.color
drawPolyline(g, s, ::xToPx, ::yToPx)
}
else -> {
g.stroke = when (s.style) { Style.THICK -> thick; Style.DASHED -> dashed; else -> solid }
drawPolyline(g, s, ::xToPx, ::yToPx)
}
}
}
drawLegend(g, spec.series, plotX + plotW - 8, plotY + 8)
g.color = TEXT
g.font = Font(Font.SANS_SERIF, Font.BOLD, 15)
g.drawString(spec.title, ML.toFloat(), 28f)
g.font = Font(Font.SANS_SERIF, Font.PLAIN, 12)
g.drawString(spec.xLabel, (plotX + plotW / 2).toFloat(), (H - 12).toFloat())
val old = g.transform
g.rotate(-Math.PI / 2, 18.0, (plotY + plotH / 2).toDouble())
g.drawString(spec.yLabel, 18f, (plotY + plotH / 2 + 4).toFloat())
g.transform = old
g.dispose()
return img
}
private fun drawPolyline(g: java.awt.Graphics2D, s: Series, xToPx: (Double) -> Double, yToPx: (Double) -> Double) {
val path = Path2D.Double()
var pen = false
for (i in s.xs.indices) {
val y = s.ys[i]
if (!y.isFinite()) { pen = false; continue }
val px = xToPx(s.xs[i]); val py = yToPx(y)
if (!pen) { path.moveTo(px, py); pen = true } else path.lineTo(px, py)
}
g.draw(path)
}
private fun drawLegend(g: java.awt.Graphics2D, series: List<Series>, right: Int, top: Int) {
g.font = Font(Font.SANS_SERIF, Font.PLAIN, 12)
val fm = g.fontMetrics
val rowH = 18; val swatch = 20; val pad = 8
val textW = series.maxOf { fm.stringWidth(it.label) }
val boxW = swatch + 8 + textW + pad * 2
val boxH = series.size * rowH + pad * 2
val x = right - boxW
g.color = Color(16, 18, 24, 210)
g.fill(Rectangle2D.Double(x.toDouble(), top.toDouble(), boxW.toDouble(), boxH.toDouble()))
g.color = GRID
g.draw(Rectangle2D.Double(x.toDouble(), top.toDouble(), boxW.toDouble(), boxH.toDouble()))
series.forEachIndexed { i, s ->
val cy = top + pad + i * rowH + rowH / 2
g.color = s.color; g.stroke = BasicStroke(3f)
g.draw(Line2D.Double((x + pad).toDouble(), cy.toDouble(), (x + pad + swatch).toDouble(), cy.toDouble()))
g.color = TEXT
g.drawString(s.label, (x + pad + swatch + 8).toFloat(), (cy + 4).toFloat())
}
}
private fun autoYRange(series: List<Series>): Pair<Double, Double> {
var lo = Double.POSITIVE_INFINITY; var hi = Double.NEGATIVE_INFINITY
for (s in series) for (y in s.ys) if (y.isFinite()) { if (y < lo) lo = y; if (y > hi) hi = y }
if (!lo.isFinite() || !hi.isFinite()) { lo = -1.0; hi = 1.0 }
if (lo == hi) { lo -= 1.0; hi += 1.0 }
val pad = (hi - lo) * 0.08
return (lo - pad) to (hi + pad)
}
The audio log ladder, not afield.ui.visualization.logTicks.
A frequency response is read against these ten landmarks — 20 Hz, 20 kHz, and the 1/2/5
points between — not against whatever decades a general log axis would choose for the data's
own extent. fmtHz labels them the way an EQ does.
private val LOG_TICKS = doubleArrayOf(20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0, 20000.0)
private fun logTicks(lo: Double, hi: Double): List<Double> = LOG_TICKS.filter { it in lo..hi }
private fun fmtHz(v: Double): String = if (v >= 1000.0) "${(v / 1000.0).let { if (it == Math.rint(it)) it.toInt().toString() else "%.1f".format(it) }}k" else v.toInt().toString()
private fun paintedFraction(img: BufferedImage): Double {
val bg = BG.rgb and 0xFFFFFF; val panel = PANEL.rgb and 0xFFFFFF
var painted = 0; var seen = 0
var y = 0
while (y < img.height) {
var x = 0
while (x < img.width) {
val p = img.getRGB(x, y) and 0xFFFFFF
if (p != bg && p != panel) painted++
seen++; x += 2
}
y += 2
}
return painted.toDouble() / seen.coerceAtLeast(1)
}
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
System.setProperty("java.awt.headless", "true")
val outDir = File(
args.getOrNull(0) ?: System.getenv("DOUGH_DSP_PLOT_DIR")
?: File(System.getProperty("java.io.tmpdir"), "dough-dsp-plot-gallery").path
)
outDir.mkdirs()
PLOTS.forEach { plot ->
val spec = plot.build()
for (s in spec.series) {
check(s.ys.any { it.isFinite() }) { "${plot.name}: series '${s.label}' has no finite samples" }
}
plot.verify(spec)
val img = render(spec)
val file = File(outDir, "dough-${plot.name}.png")
ImageIO.write(img, "png", file)
val painted = paintedFraction(img)
check(painted > 0.02) { "${plot.name}: rasterized near-empty (painted=$painted)" }
println("wrote ${file.absolutePath} (${img.width}x${img.height}, painted=${"%.3f".format(painted)})")
}
println("dough dsp plot gallery rendered to ${outDir.absolutePath} (${PLOTS.size} plots)")
}
}