LorisDocs.kt
src/jvmMain/kotlin/afield/audio/analysis/loris/LorisDocs.kt · 127 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.analysis.loris
import afield.audio.engine.WavFloatArrayLoader
import afield.audio.engine.WavFloatArrayWriter
import afield.ui.visualization.AudioVisualizer
import afield.ui.visualization.AudioVisualizerOptions
import afield.ui.visualization.TimeViewerShell
import java.nio.file.Files
import java.nio.file.Path
The fixture that renders the audio and figures on the Loris docs page from a committed film-dialogue recording.
LorisDocs.render analyses the source WAV with fixed LorisOptions, resynthesizes it unedited and
with frequencies scaled by 0.75 and noise bandwidth removed, and writes both WAVs with a provenance.json under
public/docs/assets/loris. The ...Html functions return the comparison, transformed and morph
panels the page embeds. It needs the native Loris executable.
object LorisDocs
Reproducible real-audio documentation fixture; never substitutes synthetic tracks for Loris.
val sourcePath: Path get()
val sourcePath: Path get() = Path.of("public/docs/assets/subharmonic_sound/notld-barbara-source.wav")
val folder: Path get() = Path.of("public/docs/assets/loris")
val options
val options = LorisOptions(resolutionHz = 35.0, windowWidthHz = 80.0, hopSeconds = .005,
frequencyDriftHz = 50.0, amplitudeFloorDb = -55.0)
fun render(): Pair<LorisResult, LorisResult>
Analyze committed public-domain film dialogue; render the unedited and transformed models.
fun render(): Pair<LorisResult, LorisResult> {
val wav = WavFloatArrayLoader.loadWavToFloatArray(sourcePath.toString())
val client = LorisClient()
val result = client.analyze(wav.samples, wav.sampleRate, options)
val edited = client.synthesize(result.model.transformed(frequencyScale = .75, bandwidthScale = 0.0, gain = .8))
Files.createDirectories(folder)
check(result.peak <= 1f && edited.peak <= 1f) { "Docs audio needs explicit attenuation" }
WavFloatArrayWriter.writeMono16(folder.resolve("reconstruction.wav").toString(), result.samples, result.model.sampleRate)
WavFloatArrayWriter.writeMono16(folder.resolve("three-quarter-tonal.wav").toString(), edited.samples, edited.model.sampleRate)
Files.writeString(folder.resolve("provenance.json"), TimeViewerShell.scriptJson(mapOf(
"source" to sourcePath.toString(), "sourceChannel" to "left (committed source is mono)",
"sourceSha256" to java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(sourcePath))
.joinToString("") { "%02x".format(it) },
"options" to options, "analysis" to result.provenance, "edit" to edited.provenance,
"editParameters" to mapOf("frequencyScale" to .75, "bandwidthScale" to 0.0, "gain" to .8),
"sampleRate" to wav.sampleRate, "frames" to wav.samples.size, "partials" to result.model.partials.size,
"breakpoints" to result.model.partials.sumOf { it.points.size },
"peak" to result.peak, "editedPeak" to edited.peak,
"normalization" to "none", "resampling" to "none", "publishedEncoding" to "mono PCM16")))
return result to edited
}
fun comparisonHtml(): String
fun comparisonHtml(): String {
val (result, _) = render()
val wav = WavFloatArrayLoader.loadWavToFloatArray(sourcePath.toString())
val sync = "loris-film-comparison"
return AudioVisualizer.renderSamplesHtml(wav.samples, wav.sampleRate,
AudioVisualizerOptions(width = 1000, waveformHeight = 95, syncGroup = sync),
label = "Source · Night of the Living Dead (1968) · mono 48 kHz") +
result.visualizationHtml(LorisViewOptions(height = 400,
syncGroup = sync, label = "Film dialogue · irregular partial trajectories"))
}
fun transformedHtml(): String
fun transformedHtml(): String = render().second.visualizationHtml(LorisViewOptions(height = 340,
axis = LorisFrequencyAxis.ERB,
syncGroup = "loris-tonal-edit", label = "Frequency × 0.75 · noise bandwidth × 0 · gain × 0.8"))
fun whirlpoolHtml(): String
fun whirlpoolHtml(): String {
val analyzed = render().first
val edited = LorisClient().synthesize(analyzed.model.spectralWhirlpool(6.0, 3.0))
check(edited.peak <= 1f)
WavFloatArrayWriter.writeMono16(folder.resolve("spectral-whirlpool.wav").toString(), edited.samples, edited.model.sampleRate)
Files.writeString(folder.resolve("whirlpool-provenance.json"), TimeViewerShell.scriptJson(mapOf(
"parentAnalysis" to analyzed.provenance, "synthesis" to edited.provenance,
"startSeconds" to 6.0, "durationSeconds" to 3.0, "gesture" to "afield.audio.analysis.loris.spectralWhirlpool",
"octaveDepth" to 1.5, "sweepCycles" to 4, "shutterHz" to 12, "targetBandwidth" to .85,
"nyquistLimit" to ".49 * sampleRate", "peak" to edited.peak, "normalization" to "none")))
return edited.visualizationHtml(LorisViewOptions(height = 400, syncGroup = "loris-whirlpool",
label = "6–9 seconds · ±18-semitone whirlpool / 12 Hz shutter / noisy core"))
}
data class Morph(val source: LorisResult, val target: LorisResult, val aligned: LorisResult, val morphed: LorisResult)
Two phrases from the same film, labelled, aligned and morphed by Loris.
data class Morph(val source: LorisResult, val target: LorisResult, val aligned: LorisResult, val morphed: LorisResult)
val morphSource = 1.6 to 3.6
val morphTarget = 11.5 to 13.5
val morphEnvelope = LorisEnvelope.ramp(.4, 1.6)
fun morph(): Morph
fun morph(): Morph {
val client = LorisClient()
val film = render().first.model
val a = client.distill(film.crop(morphSource.first, morphSource.second), LorisReference.Estimated(150.0, 450.0))
val b = client.distill(film.crop(morphTarget.first, morphTarget.second), LorisReference.Estimated(60.0, 200.0))
val markerA = a.model.loudestTimeSeconds(); val markerB = b.model.loudestTimeSeconds()
// Pin both ends so only the inside is warped: B's loudest moment moves onto A's.
val end = a.model.durationSeconds
val aligned = client.dilate(b.model, listOf(0.0, markerB, end), listOf(0.0, markerA, end), durationSeconds = end)
val raw = client.morph(a.model.dropUnlabeled(), aligned.model.dropUnlabeled(), morphEnvelope)
val gain = if (raw.peak > .95f) .95 / raw.peak else 1.0
val morphed = if (gain == 1.0) raw else client.synthesize(raw.model.transformed(gain = gain))
Files.createDirectories(folder)
WavFloatArrayWriter.writeMono16(folder.resolve("morph-a-to-b.wav").toString(), morphed.samples, morphed.model.sampleRate)
Files.writeString(folder.resolve("morph-provenance.json"), TimeViewerShell.scriptJson(mapOf(
"parentAnalysis" to render().first.provenance, "sourceSeconds" to morphSource, "targetSeconds" to morphTarget,
"sourceReference" to "Estimated(150, 450)", "targetReference" to "Estimated(60, 200)",
"sourceLabels" to a.model.labelCounts(), "targetLabels" to b.model.labelCounts(),
"dilateFrom" to listOf(0.0, markerB, end), "dilateTo" to listOf(0.0, markerA, end), "morphEnvelope" to morphEnvelope.points,
"morph" to raw.provenance, "gain" to gain, "synthesis" to morphed.provenance,
"peak" to morphed.peak, "normalization" to "explicit gain above, no clipping")))
return Morph(a, b, aligned, morphed)
}
fun morphHtml(): String = morphPanelHtml(0) + morphPanelHtml(1) + morphPanelHtml(2)
fun morphPanelHtml(panel: Int): String
One panel of morphHtml: 0 = phrase A, 1 = phrase B aligned, 2 = the morph (each runs the cached pipeline).
fun morphPanelHtml(panel: Int): String {
val m = morph()
val view = { label: String -> LorisViewOptions(height = 260, minimumHz = 0.0, maximumHz = 5000.0,
syncGroup = "loris-morph", label = label) }
return when (panel) {
0 -> m.source.visualizationHtml(view("Phrase A · film 1.6–3.6 s · distilled"))
1 -> m.aligned.visualizationHtml(view("Phrase B · film 11.5–13.5 s · distilled, loudest moment dilated onto A's"))
2 -> m.morphed.visualizationHtml(view("Morph · A → B between 0.4 and 1.6 s"))
else -> throw IllegalArgumentException("panel must be 0, 1 or 2")
}
}
}