Skip to content

Corpus microscope: from recordings to related windows

Pick a passage from a recording and the corpus microscope finds other stretches that sound like it. It compares harmony, pitch, texture, rhythm and dynamics, and you can choose how much each matters to the match.

To prepare a collection, it measures overlapping windows of 1.5, 3, 6 and 12 seconds; your audio files stay unchanged. Here we'll search for six seconds of Nancy in Final Out at Candlestick Park.

To run the examples on this page, open the audio_ai_corpus_microscope starting point.

Real Nancy query and two Candlestick neighbour windows with waveforms, distance bars and splice players

The first two neighbours of the six-second Nancy query, with their source timestamps and separate distances for harmony, pitch, texture, rhythm and dynamics.

What each window is measured by

Group Measurements
harmony mean and variation of twelve chroma classes
pitch dominant-pitch centre, spread and voiced fraction
texture MFCC plus spectral centroid, bandwidth, flatness and rolloff
rhythm onset strength, peak density and local tempo statistics
dynamics RMS centre, spread and range

Each group is pooled over the window, and the five together are the vector a query is matched against.

Submit and monitor the index

Segmentation and descriptor extraction are ordinary cached analysis.* jobs. This box indexes Nancy and Final Out at Candlestick Park. Keep the index, query and placement fragments below in the same box. Run the index first and wait for Index ready before running the query. The box properties publish only successful results for the later fragments:

import java.util.concurrent.CancellationException
import java.util.concurrent.CompletableFuture

val client = cached { FieldAudioClient() }
prop<AudioAsset>(nancy)
prop<CorpusPreview>(preview)
prop<AssetRepresentation>(canonical)
display("all jobs", fieldAudioJobMonitor(heightPixels = 330))

:::fold durable job tracking
fun tracked(run: RunScope?, label: String, request: ModelOperation): CompletableFuture<AnalysisResult> = RunScope.withCurrent(run) {
    client.trackAnalysis(
        request,
        onAccepted = { display("$label job", fieldAudioJobStatusHtml(it)) },
        onProgress = { job, event -> display("$label job", fieldAudioJobStatusHtml(job, event)) },
        onCompleted = { display("$label job", fieldAudioJobStatusHtml(it)) },
        onError = {}, // The enclosing operation reports failure once.
    )
}

:::endfold

runAsync<AudioAsset>(
    onResult = { source ->
        nancy = source
        markdown("index", "Index ready. Run the query fragment below.")
    },
    onError = { failure -> markdown("index error", "Index failed: ${(failure.message ?: failure.toString()).md}") },
) {
    // Each HTTP continuation must re-enter the run that owns its tracked job.
    val run = RunScope.current()
    val lookups = ["*Nancy*", "*Candlestick Park*"].map { client.assetByFilename(it) }
    onCancel { lookups.forEach { it.cancel(true) } }
    CompletableFuture.allOf(*lookups.toTypedArray()).thenCompose {
        if (future.isDone) throw CancellationException("run ended")
        val assets = lookups.map { it.join() }
        val jobs = assets.mapIndexed { index, asset ->
            val whole = AssetRegion(asset.id, 0.0, requireNotNull(asset.durationSeconds))
            tracked(run, "source ${index + 1} segments", segmentCorpus(whole, requestedBy = "field:corpus/index"))
                .also { pending -> onCancel { pending.cancel(true) } }
                .thenCompose {
                    if (future.isDone) throw CancellationException("run ended")
                    tracked(
                            run,
                            "source ${index + 1} descriptors",
                            embedCorpus(whole, requestedBy = "field:corpus/index"),
                        )
                        .also { pending -> onCancel { pending.cancel(true) } }
                }
        }
        CompletableFuture.allOf(*jobs.toTypedArray()).thenApply { assets.first() }
    }
}

segmentCorpus records deterministic windows and their previous/next source neighbours. embedCorpus pools the five descriptor groups over those exact windows. Re-running either unchanged request returns its saved result immediately.

Query, await and visualize

A query is a job too, since the query passage has to be measured. Once it completes, corpusPreview loads the neighbours and corpusMicroscopeHtml shows them with players you can listen to. This query leaves out Nancy itself, so every result comes from Candlestick; set includeSameAsset = true to search the query's own recording as well:

val query = AssetRegion(nancy.id, startSeconds = 18.0, durationSeconds = 6.0)

runAsync<CorpusPreview>(
    onResult = { result ->
        preview = result
        canonical =
            nancy.representations.firstOrNull { it.role == "canonical_project" }
                ?: nancy.representations.first { it.mediaType in ["audio/wav", "audio/x-wav"] }
        display("microscope", corpusMicroscopeHtml(preview, canonical))
    },
    onError = { failure -> markdown("query error", "Query failed: ${(failure.message ?: failure.toString()).md}") },
) {
    val run = RunScope.current()
    val neighbours = tracked(
        run,
        "Nancy neighbours",
        findCorpusNeighbours(
            query,
            limit = 12,
            includeSameAsset = false,
            weights = CorpusDistanceWeights(
                harmony = 0.8,
                pitch = 0.55,
                texture = 1.35,
                rhythm = 0.7,
                dynamics = 0.35,
            ),
            requestedBy = "field:corpus/query",
        ),
    )
    onCancel { neighbours.cancel(true) }
    neighbours.thenCompose { result ->
        if (future.isDone) throw CancellationException("run ended")
        client.corpusPreview(result.id).also { pending -> onCancel { pending.cancel(true) } }
    }
}

Each neighbour card shows where the window comes from, a bar for each of the five distances, and a player that crossfades from the query into the neighbour. The query and neighbour players both start at zero, so hovering, zooming, clicking and playback stay aligned between the two recordings. Each card also lists the window's timestamps in its source and the ids of the windows before and after it.

Place a neighbour as a box

Show the microscope with showCorpusMicroscope and each neighbour card gets a place button. After the query finishes, press it to make a box that plays that window. The new box is named for the recording and window; edit its times and run it to move or stretch the excerpt.

showCorpusMicroscope(preview, canonical)

placeCorpusNeighbour(neighbour) does the same from code. This box places the first neighbour and shows the code the new box was given:

:::static
import java.io.File

val folder = File("public/docs/assets/audio_ai_corpus_microscope")
val preview = FieldAudioClient.json.decodeFromString<CorpusPreview>(File(folder, "corpus.json").readText())
:::fold show the placed box's name and code
val placed = placeCorpusNeighbour(preview.neighbours.first())
markdown("placed", "**${placed.name}**\n\n```kotlin\n${placed.properties["code"]}```")

The code of a box placed from the corpus microscope: a comment naming Final Out at Candlestick Park from 21.00 to 27.00 seconds, a corpusClip call for that window, and audio(clip.sampler())

corpusClip fetches the recording from the Field audio service once, keeps it under ~/.afield/cache/field-audio, and cuts out the window; clip.sampler() plays it from the start.

See also: the EnCodec round trip for the token representation, and Short-time spectral spaces.