LorisTransforms.kt
src/jvmMain/kotlin/afield/audio/analysis/loris/LorisTransforms.kt · 77 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.analysis.loris
import kotlin.math.*
Kotlin-side edits of a LorisModel: filtering partials, transposing, stretching, tilting and cropping.
Every function here returns a new model and changes breakpoint envelopes only, so the result is
heard by passing it to LorisClient.synthesize. Labelling, sifting, dilation and morphing are
Loris's own algorithms and run natively through LorisClient.
val LorisPartial.startSeconds: Double get() = points.first().timeSeconds
Time of the partial's first breakpoint.
val LorisPartial.endSeconds: Double get() = points.last().timeSeconds
val LorisPartial.durationSeconds: Double get() = endSeconds - startSeconds
val LorisPartial.peakAmplitude: Double get() = points.maxOf { it.amplitude }
val LorisPartial.meanFrequencyHz: Double get()
Amplitude-weighted mean frequency.
val LorisPartial.meanFrequencyHz: Double get() {
val w = points.sumOf { it.amplitude }
return if (w <= 0) points.sumOf { it.frequencyHz } / points.size else points.sumOf { it.frequencyHz * it.amplitude } / w
}
fun LorisModel.labelCounts(): Map<Int, Int>
Number of partials carrying each label; label 0 is "unlabeled".
fun LorisModel.labelCounts(): Map<Int, Int> = partials.groupingBy { it.label }.eachCount().toSortedMap()
fun LorisModel.filterPartials(keep: (LorisPartial) -> Boolean): LorisModel = copy(partials
Keep partials matching keep; ids are preserved.
fun LorisModel.filterPartials(keep: (LorisPartial) -> Boolean): LorisModel = copy(partials = partials.filter(keep))
fun LorisModel.keepLabels(labels: Iterable<Int>): LorisModel
fun LorisModel.keepLabels(labels: Iterable<Int>): LorisModel = labels.toSet().let { s -> filterPartials { it.label in s } }
fun LorisModel.keepLabels(range: IntRange): LorisModel = filterPartials { it.label in range }
fun LorisModel.dropUnlabeled(): LorisModel = filterPartials { it.label != 0 }
fun LorisModel.minimumDuration(seconds: Double): LorisModel
fun LorisModel.minimumDuration(seconds: Double): LorisModel = filterPartials { it.durationSeconds >= seconds }
fun LorisModel.minimumPeak(db: Double): LorisModel
Drop partials whose loudest breakpoint is below db dBFS (amplitude 1 = 0 dB).
fun LorisModel.minimumPeak(db: Double): LorisModel = 10.0.pow(db / 20).let { a -> filterPartials { it.peakAmplitude >= a } }
fun LorisModel.shiftPitch(cents: LorisEnvelope): LorisModel
Transpose by cents (may vary in time); breakpoints pushed past 0.49 × sampleRate are removed.
fun LorisModel.shiftPitch(cents: LorisEnvelope): LorisModel = mapPoints { b ->
b.copy(frequencyHz = b.frequencyHz * 2.0.pow(cents.valueAt(b.timeSeconds) / 1200))
}
fun LorisModel.shiftPitch(cents: Double): LorisModel = shiftPitch(LorisEnvelope.constant(cents))
fun LorisModel.stretched(factor: Double): LorisModel = transformed(timeScale = factor)
Uniform time stretch: all breakpoint times and the timeline scale by factor.
fun LorisModel.tilt(dbPerOctave: Double, pivotHz: Double = 1000.0): LorisModel
Spectral tilt: amplitude × (f / pivotHz) ^ (dbPerOctave / 6.02...), so +6 dB/oct brightens and
−6 dB/oct darkens, with partials at pivotHz unchanged.
fun LorisModel.tilt(dbPerOctave: Double, pivotHz: Double = 1000.0): LorisModel {
require(dbPerOctave.isFinite() && pivotHz.isFinite() && pivotHz > 0)
return mapPoints { b -> b.copy(amplitude = b.amplitude * 10.0.pow(dbPerOctave * log2(b.frequencyHz / pivotHz) / 20)) }
}
fun LorisModel.crop(startSeconds: Double, endSeconds: Double): LorisModel
Keep [startSeconds, endSeconds] and move it to time 0. Partials are cut at the boundaries' nearest breakpoints.
fun LorisModel.crop(startSeconds: Double, endSeconds: Double): LorisModel {
require(startSeconds >= 0 && endSeconds > startSeconds && endSeconds <= durationSeconds)
return copy(frameCount = ceil((endSeconds - startSeconds) * sampleRate).toInt(), partials = partials.mapNotNull { p ->
p.points.filter { it.timeSeconds in startSeconds..endSeconds }.map { it.copy(timeSeconds = it.timeSeconds - startSeconds) }
.takeIf { it.isNotEmpty() }?.let { p.copy(points = it) }
})
}
private fun LorisModel.mapPoints(f: (LorisBreakpoint) -> LorisBreakpoint): LorisModel = copy(partials = partials.mapNotNull { p ->
p.points.map(f).filter { it.frequencyHz < sampleRate * .49 }.takeIf { it.isNotEmpty() }?.let { p.copy(points = it) }
})
fun LorisModel.loudestTimeSeconds(binSeconds: Double = .05): Double
Centre of the binSeconds bin with the largest summed breakpoint amplitude: a simple marker for dilate.
fun LorisModel.loudestTimeSeconds(binSeconds: Double = .05): Double {
require(binSeconds.isFinite() && binSeconds > 0)
val bins = DoubleArray(ceil(durationSeconds / binSeconds).toInt().coerceAtLeast(1))
partials.forEach { p -> p.points.forEach { b -> bins[(b.timeSeconds / binSeconds).toInt().coerceIn(bins.indices)] += b.amplitude } }
return (bins.indices.maxBy { bins[it] } + .5) * binSeconds
}