Skip to content

MusicTranscription.kt

src/jvmMain/kotlin/afield/audio/ai/MusicTranscription.kt · 172 lines

@file:afield.runtime.completion.CompletionCategory("Audio AI")

package afield.audio.ai

import org.msgpack.core.MessagePack
import org.msgpack.value.Value
import org.msgpack.value.ValueType
import java.util.concurrent.CompletableFuture

Typed decoder for the versioned note document a multi-instrument transcription job leaves behind.

musicTranscriptionAnalysis in AnalysisOperations.kt builds the request, and three accessors beside it locate what came back: performance MIDI, optional beat-quantized MIDI, and the MessagePack event document. This file reads the last of those. decodeMusicTranscriptionMessagePack turns its bytes into a MusicTranscription, and FieldAudioClient.musicTranscription is the one-call path from a finished AnalysisResult.

MessagePack rather than JSON keeps the note times binary, so an onset reaches the JVM as the float64 the worker wrote. The decoder rejects a payload whose format is not field.audio.music-transcription.v1, rejects trailing data after the root map, and range-checks every note: non-negative onset, offset at or after onset, MIDI pitch inside 0..127. A malformed artifact fails at the decode rather than at the first drawing that reads it.

Most of the document is optional. MusicTranscription.beatGrid is null when the artifact carries no beat_grid or a nil one, and inside a grid beatsPerBar, onsetDelaySeconds and beatSubdivision are each independently absent. The four provenance fields come from the artifact's metadata map and are null when the worker recorded nothing there. instrument is a string naming one of the worker's own instrument groups, which is a model's hypothesis about a mixed recording and not a reading of what played it. See Multi-instrument transcription with MuScriptor for how far that has been checked against a known source.

data class MusicTranscriptionNote

One source-aligned note recovered by a multi-instrument transcription worker.

data class MusicTranscriptionNote(
    /** Where the note begins, in seconds. The decoder rejects a negative onset. */
    val startSeconds: Double,
    /** Where the note ends, in seconds. Never earlier than [startSeconds]; the decoder rejects a pair that is. */
    val endSeconds: Double,
    /** MIDI note number, 60 being middle C. The decoder rejects anything outside 0..127. */
    val midi: Int,
    /**
     * The worker's own name for the instrument group it put this note in. It is a model's reading of
     * a mixed recording rather than a record of what played the note.
     */
    val instrument: String,
)

data class MusicTranscriptionBeatGrid

Tempo, meter, and the measured model-onset correction retained with a transcription.

data class MusicTranscriptionBeatGrid(
    /** Tempo in beats per minute. The decoder rejects a grid whose bpm is not positive. */
    val bpm: Double,
    /** Beats to the bar, or null where the artifact records no meter. */
    val beatsPerBar: Int?,
    /** Where the first downbeat falls, in seconds. */
    val firstDownbeatSeconds: Double,
    /** The onset correction the worker recorded, in seconds, or null where it recorded none. */
    val onsetDelaySeconds: Double?,
    /** How many parts the worker divided each beat into, or null where the artifact records no subdivision. */
    val beatSubdivision: Int?,
)

data class MusicTranscription

Typed contents of the versioned MuScriptor MessagePack artifact.

data class MusicTranscription(
    /** Every note in the artifact, in the order it lists them. */
    val notes: List<MusicTranscriptionNote>,
    /** Tempo and meter, or null where the artifact carries no beat grid or an explicitly nil one. */
    val beatGrid: MusicTranscriptionBeatGrid?,
    /** Which transcription backend the worker ran, from the artifact's metadata. Null where it recorded nothing. */
    val backend: String?,
    /** The model the backend loaded, from the artifact's metadata. Null where it recorded nothing. */
    val modelId: String?,
    /** The revision of that model, from the artifact's metadata. Null where it recorded nothing. */
    val modelRevision: String?,
    /** SHA-256 of the checkpoint weights, from the artifact's metadata. Null where it recorded nothing. */
    val checkpointSha256: String?,
)

private fun Value.namedMap(context: String): Map<String, Value> {
    require(valueType == ValueType.MAP) { "$context must be a MessagePack map" }
    return asMapValue().map().entries.associate { entry ->
        require(entry.key.valueType == ValueType.STRING) { "$context contains a non-string key" }
        entry.key.asStringValue().asString() to entry.value
    }
}

private fun Value.number(context: String): Double = when (valueType) {
    ValueType.INTEGER -> asIntegerValue().toLong().toDouble()
    ValueType.FLOAT -> asFloatValue().toDouble()
    else -> error("$context must be numeric")
}.also { require(it.isFinite()) { "$context must be finite" } }

private fun Value.stringOrNull(context: String): String? = when (valueType) {
    ValueType.NIL -> null
    ValueType.STRING -> asStringValue().asString()
    else -> error("$context must be a string or nil")
}

private fun Map<String, Value>.optionalString(name: String): String? =
    get(name)?.stringOrNull(name)

fun decodeMusicTranscriptionMessagePack(bytes: ByteArray): MusicTranscription

Decode the complete source-aligned note document without routing numerical data through JSON.

body — 64 lines, from line 104
fun decodeMusicTranscriptionMessagePack(bytes: ByteArray): MusicTranscription {
    require(bytes.isNotEmpty()) { "music transcription MessagePack is empty" }
    val root = MessagePack.newDefaultUnpacker(bytes).use { unpacker ->
        val value = unpacker.unpackValue()
        require(!unpacker.hasNext()) { "music transcription MessagePack has trailing data" }
        value.namedMap("music transcription")
    }
    val format = root["format"]?.stringOrNull("format")
    require(format == "field.audio.music-transcription.v1") {
        "unsupported music transcription MessagePack format: $format"
    }
    val noteValues = requireNotNull(root["notes"]) { "music transcription has no notes" }
    require(noteValues.valueType == ValueType.ARRAY) { "music transcription notes must be an array" }
    val notes = noteValues.asArrayValue().list().mapIndexed { index, value ->
        val note = value.namedMap("note $index")
        val start = requireNotNull(note["start_seconds"]) { "note $index has no onset" }
            .number("note $index onset")
        val end = requireNotNull(note["end_seconds"]) { "note $index has no offset" }
            .number("note $index offset")
        val midiValue = requireNotNull(note["midi"]) { "note $index has no MIDI pitch" }
        require(midiValue.valueType == ValueType.INTEGER) { "note $index MIDI pitch must be an integer" }
        val midi = midiValue.asIntegerValue().toInt()
        val instrument = requireNotNull(note["instrument"]?.stringOrNull("note $index instrument")) {
            "note $index has no instrument"
        }
        require(start >= 0.0 && end >= start) { "note $index has an invalid time range" }
        require(midi in 0..127) { "note $index MIDI pitch is outside 0..127" }
        MusicTranscriptionNote(start, end, midi, instrument)
    }
    val beatGrid = root["beat_grid"]?.let { value ->
        if (value.valueType == ValueType.NIL) null else {
            val grid = value.namedMap("beat grid")
            val bpm = requireNotNull(grid["bpm"]) { "beat grid has no bpm" }.number("bpm")
            val firstDownbeat = requireNotNull(grid["first_downbeat"]) {
                "beat grid has no first downbeat"
            }.number("first_downbeat")
            require(bpm > 0.0) { "beat grid bpm must be positive" }
            MusicTranscriptionBeatGrid(
                bpm = bpm,
                beatsPerBar = grid["beats_per_bar"]?.let {
                    if (it.valueType == ValueType.NIL) null else it.asIntegerValue().toInt()
                },
                firstDownbeatSeconds = firstDownbeat,
                onsetDelaySeconds = grid["onset_delay"]?.let {
                    if (it.valueType == ValueType.NIL) null else it.number("onset_delay")
                },
                beatSubdivision = grid["beat_subdivision"]?.let {
                    if (it.valueType == ValueType.NIL) null else it.asIntegerValue().toInt()
                },
            )
        }
    }
    val metadata = root["metadata"]?.let {
        if (it.valueType == ValueType.NIL) emptyMap() else it.namedMap("metadata")
    } ?: emptyMap()
    return MusicTranscription(
        notes = notes,
        beatGrid = beatGrid,
        backend = metadata.optionalString("backend"),
        modelId = metadata.optionalString("model_id"),
        modelRevision = metadata.optionalString("model_revision"),
        checkpointSha256 = metadata.optionalString("checkpoint_sha256"),
    )
}

fun FieldAudioClient.musicTranscription(result: AnalysisResult): CompletableFuture<MusicTranscription>

Download and decode the complete typed note document retained by musicTranscriptionAnalysis.

fun FieldAudioClient.musicTranscription(result: AnalysisResult): CompletableFuture<MusicTranscription> =
    downloadBytes(result.musicTranscriptionMessagePack()).thenApply(::decodeMusicTranscriptionMessagePack)