CorpusOperations.kt
src/jvmMain/kotlin/afield/audio/ai/CorpusOperations.kt · 153 lines
@file:afield.runtime.completion.CompletionCategory("Audio AI")
package afield.audio.ai
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import kotlin.math.abs
Multi-scale corpus operations: segment a source into overlapping windows, embed each window with grouped audio descriptors, and retrieve neighbours by weighted distance.
The three operations form a pipeline that runs server-side. segmentCorpus enumerates
deterministic time windows at several scales without computing any descriptors. embedCorpus
computes and persists the five grouped descriptor families (harmony, pitch, texture, rhythm,
dynamics) for every window. findCorpusNeighbours retrieves the closest embedded windows
to a query using CorpusDistanceWeights, which controls how much each group contributes to
the distance. All three produce a ModelOperation that FieldAudioClient.submit executes.
data class CorpusDistanceWeights
Explicit weights for the five interpretable descriptor groups used by corpus retrieval. Each scales how much its own group counts towards the distance between two windows, a zero takes that group out of the comparison, and at least one of the five has to be positive.
data class CorpusDistanceWeights(
/** Mean and variation of the twelve chroma classes: the chord and key colour of a window. */
val harmony: Double = 1.0,
/** Dominant-pitch centre, dominant-pitch spread, and how much of the window is voiced. */
val pitch: Double = 0.7,
/** MFCCs with spectral centroid, bandwidth, flatness and rolloff: timbre rather than notes. */
val texture: Double = 1.0,
/** Onset strength, peak density and local tempo statistics. */
val rhythm: Double = 0.8,
/** RMS centre, spread and range: the loudness shape, independent of what is making it. */
val dynamics: Double = 0.45,
) {
init
body — 41 lines, from line 41
init {
require(listOf(harmony, pitch, texture, rhythm, dynamics).all { it.isFinite() && it >= 0.0 }) {
"corpus distance weights must be finite and non-negative"
}
require(harmony + pitch + texture + rhythm + dynamics > 0.0) {
"at least one corpus distance weight must be positive"
}
}
}
private fun corpusWindowOperation(
operation: String,
source: AssetRegion,
scalesSeconds: List<Double>,
overlap: Double,
hopLength: Int,
nFft: Int,
backend: String?,
requestedBy: String?,
forceRegenerate: Boolean,
): ModelOperation {
require(scalesSeconds.isNotEmpty() && scalesSeconds.all { it.isFinite() && it > 0.0 }) {
"scalesSeconds must contain positive finite values"
}
require(overlap >= 0.0 && overlap < 1.0) { "overlap must be in [0, 1)" }
require(hopLength > 0 && nFft > 0) { "hopLength and nFft must be positive" }
return ModelOperation(
operation = operation,
inputs = listOf(source),
parameters = buildJsonObject {
putJsonArray("scales_seconds") { scalesSeconds.forEach { add(JsonPrimitive(it)) } }
put("overlap", overlap)
put("hop_length", hopLength)
put("n_fft", nFft)
},
candidateCount = 1,
backendPreference = backend,
requestedBy = requestedBy,
forceRegenerate = forceRegenerate,
)
}
fun segmentCorpus
Enumerate deterministic source-adjacent windows without computing descriptors.
fun segmentCorpus(
source: AssetRegion,
scalesSeconds: List<Double> = listOf(1.5, 3.0, 6.0, 12.0),
overlap: Double = 0.5,
backend: String? = "analysis.librosa",
requestedBy: String? = null,
forceRegenerate: Boolean = false,
): ModelOperation = corpusWindowOperation(
"analysis.segment", source, scalesSeconds, overlap, 512, 2048,
backend, requestedBy, forceRegenerate,
)
fun embedCorpus
Compute and persist grouped descriptors for every multi-scale source window.
fun embedCorpus(
source: AssetRegion,
scalesSeconds: List<Double> = listOf(1.5, 3.0, 6.0, 12.0),
overlap: Double = 0.5,
hopLength: Int = 512,
nFft: Int = 2048,
backend: String? = "analysis.librosa",
requestedBy: String? = null,
forceRegenerate: Boolean = false,
): ModelOperation = corpusWindowOperation(
"analysis.embed", source, scalesSeconds, overlap, hopLength, nFft,
backend, requestedBy, forceRegenerate,
)
fun findCorpusNeighbours
Retrieve indexed windows by an explicit weighted descriptor distance.
body — 41 lines, from line 112
fun findCorpusNeighbours(
source: AssetRegion,
scaleSeconds: Double = source.durationSeconds,
limit: Int = 12,
weights: CorpusDistanceWeights = CorpusDistanceWeights(),
includeSameAsset: Boolean = true,
hopLength: Int = 512,
nFft: Int = 2048,
backend: String? = "analysis.librosa",
requestedBy: String? = null,
forceRegenerate: Boolean = false,
): ModelOperation {
require(scaleSeconds > 0.0 && scaleSeconds.isFinite()) { "scaleSeconds must be positive" }
require(abs(scaleSeconds - source.durationSeconds) < 1e-6) {
"the query duration must equal scaleSeconds"
}
require(limit in 1..64) { "limit must be between 1 and 64" }
require(hopLength > 0 && nFft > 0) { "hopLength and nFft must be positive" }
return ModelOperation(
operation = "analysis.find_neighbours",
inputs = listOf(source),
parameters = buildJsonObject {
put("scale_seconds", scaleSeconds)
put("limit", limit)
put("include_same_asset", includeSameAsset)
put("hop_length", hopLength)
put("n_fft", nFft)
putJsonObject("weights") {
put("harmony", weights.harmony)
put("pitch", weights.pitch)
put("texture", weights.texture)
put("rhythm", weights.rhythm)
put("dynamics", weights.dynamics)
}
},
candidateCount = 1,
backendPreference = backend,
requestedBy = requestedBy,
forceRegenerate = forceRegenerate,
)
}