LorisVisualizer.kt
src/jvmMain/kotlin/afield/audio/analysis/loris/LorisVisualizer.kt · 95 lines
@file:afield.runtime.completion.CompletionCategory("Visualization")
package afield.audio.analysis.loris
import afield.ui.visualization.AudioVisualizer
import afield.ui.visualization.AudioVisualizerOptions
import afield.ui.visualization.TimeViewerShell
import kotlin.math.*
Draws a LorisModel's partial trajectories as an interactive HTML panel next to its waveform.
visualizationHtml plots each partial's breakpoints at their real times on a cents, ERB or Hz axis (LorisFrequencyAxis), shares the time window, cursor and click-to-seek with the waveform view, and caps how many partials and points are drawn (LorisViewOptions). The caps affect drawing only.
enum class LorisFrequencyAxis { CENTS, ERB, HZ }
Frequency scale of the trajectory plot.
enum class LorisTrackSelection { BAND_BALANCED, STRONGEST }
Which partials fill the drawing budget: spread across ERB bands, or the strongest overall.
data class LorisViewOptions
Display limits affect drawing only, never the stored native model or resynthesis.
data class LorisViewOptions(
val width: Int = 1000, val height: Int = 380,
val minimumHz: Double = 0.0, val maximumHz: Double? = null,
val axis: LorisFrequencyAxis = LorisFrequencyAxis.ERB,
val selection: LorisTrackSelection = LorisTrackSelection.BAND_BALANCED,
val maxPartials: Int = 320, val pointsPerPartial: Int = 1500,
val syncGroup: String = "", val label: String = "Loris partial trajectories",
) {
init
init {
require(width >= 320 && height >= 180)
require(minimumHz.isFinite() && minimumHz >= 0)
require(maximumHz == null || maximumHz.isFinite() && maximumHz > minimumHz)
require(axis != LorisFrequencyAxis.CENTS || minimumHz > 0) { "Cents/log frequency needs a positive lower bound" }
require(maxPartials in 1..2000 && pointsPerPartial in 2..10000)
require(maxPartials.toLong() * pointsPerPartial <= 500_000)
}
}
fun LorisModel.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String
Actual irregular trajectories, with shared waveform time-window, cursor and click-to-seek events.
body — 44 lines, from line 43
fun LorisModel.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String {
val maximum = options.maximumHz ?: sampleRate / 2.0
require(maximum > options.minimumHz)
val ranked = partials.sortedByDescending { p -> p.points.sumOf { it.amplitude } / p.points.size *
(p.points.last().timeSeconds - p.points.first().timeSeconds) }
val selected = if (options.selection == LorisTrackSelection.STRONGEST) ranked.take(options.maxPartials) else {
// Spread the drawing budget across the full native ERB range, not just loud voiced fundamentals.
val erbMax = 21.4 * log10(1 + .00437 * sampleRate / 2.0)
val bands = ranked.groupBy { p ->
val weight = p.points.sumOf { it.amplitude }.coerceAtLeast(1e-20)
val center = p.points.sumOf { it.frequencyHz * it.amplitude } / weight
floor(8 * 21.4 * log10(1 + .00437 * center) / erbMax).toInt().coerceIn(0, 7)
}
val picked = bands.toSortedMap().values.flatMap { it.take(max(1, options.maxPartials / 8)) }
.take(options.maxPartials)
val ids = picked.map { it.id }.toSet()
picked + ranked.filterNot { it.id in ids }.take(options.maxPartials - picked.size)
}
val tracks = selected.map { p ->
// Evenly select native points, including both endpoints; do not create pooled timestamps.
val n = min(p.points.size, options.pointsPerPartial)
mapOf("id" to p.id, "points" to List(n) { i ->
val b = p.points[if (n == 1) 0 else (i.toLong() * (p.points.size - 1) / (n - 1)).toInt()]
listOf(b.timeSeconds, b.frequencyHz, b.amplitude, b.bandwidth, b.phaseRadians)
})
}
val cfg = TimeViewerShell.scriptJson(mapOf("duration" to durationSeconds, "tracks" to tracks,
"totalTracks" to partials.size, "minimumHz" to options.minimumHz, "maximumHz" to maximum,
"nyquistHz" to sampleRate / 2.0, "selection" to options.selection.name,
"axis" to options.axis.name, "group" to options.syncGroup, "label" to options.label,
"height" to options.height))
val script = checkNotNull(LorisModel::class.java.getResourceAsStream("/loris-viewer.js"))
.bufferedReader().use { it.readText() }
return """
<div class="field-loris-view" style="width:${options.width}px;max-width:100%;box-sizing:border-box;background:#10161d;color:#d8e2eb;border:1px solid #34424e;border-radius:10px;padding:14px;font:12px ui-monospace,monospace">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><strong class="lv-title"></strong><span class="lv-count" style="color:#9daebc;flex:1"></span><button class="lv-all" type="button">all time</button></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px;font-size:10px;color:#a4bacb"><button class="lv-full" type="button">full band</button><button class="lv-voice" type="button">80–4000 Hz detail</button><label>axis <select class="lv-axis"><option value="ERB">ERB</option><option value="CENTS">cents/log</option><option value="HZ">Hz</option></select></label><span>local bandwidth</span><span>0</span><span style="width:110px;height:8px;background:linear-gradient(90deg,rgb(83,194,229),rgb(255,164,75));border-radius:3px"></span><span>1</span></div>
<div class="lv-frame" style="position:relative"><svg class="lv-plot" viewBox="0 0 1000 ${options.height}" style="width:100%;display:block;touch-action:none" role="img" aria-label="Partial frequency trajectories; click to seek, wheel to zoom"></svg></div>
<div class="lv-readout" style="min-height:18px;color:#a4bacb;margin-top:6px"></div>
<div style="font-size:10px;color:#8496a4;margin-top:4px">local segment colour: blue = bandwidth 0, amber = bandwidth 1 · not confidence · wheel zoom / drag pan / click seek</div>
<script type="application/json" class="lv-config">$cfg</script><script>$script</script>
</div>
""".trimIndent()
}
fun LorisResult.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String
Adds a real reconstruction player to the synchronized partial map. PCM data itself is unchanged.
fun LorisResult.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String {
require(peak <= 1f) { "Reconstruction peak is $peak: apply an explicit audition gain before 16-bit playback" }
return model.visualizationHtml(options) + AudioVisualizer.renderSamplesHtml(samples, model.sampleRate,
options = AudioVisualizerOptions(width = options.width, waveformHeight = 120, syncGroup = options.syncGroup),
label = "${options.label} · reconstruction · ${model.sampleRate} Hz")
}