match

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package match turns a drum hit — recorded or rendered — into a small set of perceptual features, and scores two of them against each other.

It exists next to package analysis rather than inside it because analysis is pinned sample-for-sample by a committed reference fixture, while this package is expected to grow as the measures are refined. Nothing here is used at audio runtime; it is offline tooling for cmd/fit-physical.

Index

Constants

This section is empty.

Variables

View Source
var ErrChannelNotChosen = errors.New("channel reduction not chosen")

ErrChannelNotChosen reports a multi-channel reference reduced by default rather than by decision. See LoadReferenceExplicit for why that is refused.

View Source
var ErrEsprit = fmt.Errorf("%w: esprit", ErrInvalidOptions)

ErrEsprit reports a high-resolution extraction that could not be performed.

View Source
var ErrInvalidOptions = errors.New("invalid match options")

ErrInvalidOptions reports feature options that cannot describe an analysis.

View Source
var ErrInvalidReference = errors.New("invalid reference signal")

ErrInvalidReference reports a reference file this package cannot use.

Functions

This section is empty.

Types

type Channel

type Channel string

Channel selects how a multi-channel reference is reduced to mono.

const (
	// ChannelMono averages every channel. The default, and usually right: a
	// close-microphone tom recording spread to stereo is mostly the same signal
	// twice, and averaging suppresses the decorrelated room.
	ChannelMono Channel = "mono"
	// ChannelLeft takes the first channel only.
	ChannelLeft Channel = "left"
	// ChannelRight takes the second channel only.
	ChannelRight Channel = "right"
)

type EspritOptions

type EspritOptions struct {
	// The band the estimator sweeps, matching Options.MinFrequencyHz and
	// MaxFrequencyHz so the two estimators are asked the same question.
	MinFrequencyHz float64 `json:"minFrequencyHz"`
	MaxFrequencyHz float64 `json:"maxFrequencyHz"`

	// BandsPerOctave sets how finely that sweep is cut into subbands. This is
	// the estimator's main cost/resolution control: a narrower band means a
	// lower decimated rate for the same number of samples, so more of the
	// signal reaches the model, and fewer components have to share one order.
	BandsPerOctave float64 `json:"bandsPerOctave"`

	// The span of the hit the model is fitted over, from the onset. The start
	// clears the strike transient exactly as DecayFitStartSeconds does; the
	// subband filter's own settling is added to it per band, since it depends
	// on that band's width.
	StartSeconds float64 `json:"startSeconds"`
	EndSeconds   float64 `json:"endSeconds"`

	// MaxOrder bounds how many damped exponentials one subband may be given.
	// The stabilisation sweep runs every order up to it, so this is a cost
	// control as well as a ceiling.
	MaxOrder int `json:"maxOrder"`

	// Support is how many of those orders a component must appear at, within
	// the tolerances below, before it is reported. See selectStable: this, and
	// not ESTER, is what decides the model order here, and the reason is
	// measured rather than assumed.
	Support int `json:"support"`

	// The tolerances a component is tracked across orders with. A physical mode
	// barely moves as the order is raised; a component the fit invented to
	// absorb noise moves a great deal.
	StabilityCents      float64 `json:"stabilityCents"`
	StabilityT60Percent float64 `json:"stabilityT60Percent"`

	// A component is discarded unless its ring time falls inside these bounds.
	// Below the floor it is a filter transient or a click; above the ceiling it
	// is an undamped artefact of a nearly-singular fit, not a drum mode.
	MinT60Seconds float64 `json:"minT60Seconds"`
	MaxT60Seconds float64 `json:"maxT60Seconds"`

	// FloorDB is how far below the strongest component a component may sit and
	// still be reported, matching Options.PartialFloorDB.
	FloorDB float64 `json:"floorDB"`
}

EspritOptions controls the high-resolution estimator. The zero value is not usable; start from DefaultEspritOptions.

func DefaultEspritOptions

func DefaultEspritOptions() EspritOptions

DefaultEspritOptions mirrors DefaultOptions where the two estimators measure the same thing, so that a disagreement between them is a disagreement about the signal rather than about what was asked.

type Explanation

type Explanation struct {
	Terms Terms `json:"terms"`

	// Pairs is the matched set, in the order the greedy match claimed them:
	// closest first, not frequency order. Each entry indexes both sides'
	// Features.Partials.
	Pairs []Pair `json:"pairs"`

	// UnmatchedRefIndices are the reference partials no candidate accounts
	// for. Their audibility share is Terms.Unmatched, and they are what the
	// three partial terms are blended against a penalty for.
	UnmatchedRefIndices []int `json:"unmatchedRefIndices"`

	// SpuriousCandIndices are candidate partials with no reference counterpart
	// that lie *inside* the reference's own frequency span, and so are charged
	// as Terms.Spurious.
	SpuriousCandIndices []int `json:"spuriousCandIndices"`

	// UnclaimedOutsideBandIndices are candidate partials with no counterpart
	// that lie above or below every reference partial. They are deliberately
	// *not* charged as spurious: out there the reference's own detection is
	// unproven — a room recording's noise floor hides modes a model
	// legitimately has — so they are left to the spectral envelope, on
	// evidence. Reported separately because "invented" and "unjudged" are
	// different claims and a picture that merged them would be making the
	// stronger one for free.
	UnclaimedOutsideBandIndices []int `json:"unclaimedOutsideBandIndices"`
}

Explanation is Distance's working, kept.

Terms is the score itself, field-for-field what Distance returns for the same three arguments — TestExplainAgreesWithDistance pins that, and the two share one implementation so it cannot drift. The rest is the partial identification the score was computed from, which Distance discards.

func Explain

func Explain(reference, candidate Features, weights Weights) Explanation

Explain scores a candidate exactly as Distance does, and additionally reports which candidate partial was identified with which reference partial.

It is the same computation: Explain and Distance call one shared body, so Explain(...).Terms is Distance(...) and cannot become something else.

type Features

type Features struct {
	SampleRateHz float64 `json:"sampleRateHz"`
	// OnsetSample is where the analysis was anchored in the source signal.
	OnsetSample int `json:"onsetSample"`

	Partials   []Partial `json:"partials"`
	GlideCents float64   `json:"glideCents"`
	// GlideMeasured reports whether GlideCents is a reading at all. False means
	// the fundamental did not survive far enough past the strike for two
	// probes to be placed on it, and GlideCents is zero because there is no
	// number, not because the pitch held. Distance treats the two differently.
	GlideMeasured bool            `json:"glideMeasured"`
	Windows       []WindowFeature `json:"windows"`
	EnvelopeDB    []float64       `json:"envelopeDB"`
	AttackBalance float64         `json:"attackBalanceDB"`

	Waveform timestats.Stats `json:"waveform"`
	Decay    ir.Metrics      `json:"decay"`

	// BandCentresHz labels Windows[i].BandDB. Same for every window.
	BandCentresHz []float64 `json:"bandCentresHz"`
}

Features is everything this package measures about one hit.

func Extract

func Extract(samples []float64, sampleRateHz float64, options Options) (Features, error)

Extract measures one hit. samples may contain leading silence; the onset is found and everything is measured relative to it.

type HighResolutionPartial

type HighResolutionPartial struct {
	Partial

	// BandCentreHz identifies the subband the component was found in, which is
	// what makes a duplicate across an overlap visible.
	BandCentreHz float64 `json:"bandCentreHz"`

	// Order is how many exponentials the subband was fitted with. A component
	// reported at order 2 or more in a band the fast estimator reports one
	// partial in is the split-pair case this estimator was built to see.
	Order int `json:"order"`

	// Support is how many model orders the component survived — the evidence
	// that it is a mode of the drum rather than a component the fit invented.
	Support int `json:"support"`

	// EsterOrder is the order ESTER's criterion would have chosen for this
	// band, and is reported rather than used. See selectStable.
	EsterOrder int `json:"esterOrder"`
}

HighResolutionPartial is one damped exponential the subspace estimator found, carrying the same four fields Partial does so that the two estimators' tables can be compared directly, plus what only this one can report.

func ExtractHighResolution

func ExtractHighResolution(samples []float64, sampleRateHz float64,
	options Options, esprit EspritOptions,
) ([]HighResolutionPartial, error)

ExtractHighResolution measures the partials of one hit with subband ESPRIT.

samples may contain leading silence; the onset is found and the analysis is anchored to it, by the same code path Extract uses, so the two estimators are reading the same span of the same signal.

Partial.FitQuality carries the fraction of the subband's energy the fitted exponentials account for, which is the closest analogue to the log-linear fit's R² this method has. Partial.LevelDB is extrapolated back to the onset from the fitted amplitude and decay, so it means what measureDecays' fitted intercept means.

type Options

type Options struct {
	// AnalysisSeconds bounds everything measured here. The default stops
	// before a typical room tail has decided the answer.
	AnalysisSeconds float64 `json:"analysisSeconds"`

	// Partial detection.
	MaxPartials      int     `json:"maxPartials"`
	MinFrequencyHz   float64 `json:"minFrequencyHz"`
	MaxFrequencyHz   float64 `json:"maxFrequencyHz"`
	PartialFloorDB   float64 `json:"partialFloorDB"`   // relative to the strongest peak
	PeakProminenceDB float64 `json:"peakProminenceDB"` // how far a peak must clear its skirt
	MinSeparationHz  float64 `json:"minSeparationHz"`  // rejects one lobe picked twice
	SustainStartSecs float64 `json:"sustainStartSecs"` // partial detection window
	SustainEndSecs   float64 `json:"sustainEndSecs"`
	// A second, earlier detection window. The sustain window cannot see a
	// partial that is over before it closes; this one can. See detectPartials.
	EarlyDetectionStartSecs float64 `json:"earlyDetectionStartSecs"`
	EarlyDetectionEndSecs   float64 `json:"earlyDetectionEndSecs"`
	FFTSize                 int     `json:"fftSize"`

	// Per-partial decay fitting.
	DecayFitStartSeconds float64 `json:"decayFitStartSeconds"`
	DecayFitEndSeconds   float64 `json:"decayFitEndSeconds"`
	DecayFitFloorDB      float64 `json:"decayFitFloorDB"` // stop fitting below this

	// Glide: instantaneous frequency of the fundamental, early versus late.
	GlideEarlySeconds float64 `json:"glideEarlySeconds"`
	// GlideLateSeconds is the *latest* the second probe may sit, not where it
	// necessarily lands: the probe is walked back from here to the last point
	// the tracked partial still supports a reading. See measureGlide.
	GlideLateSeconds float64 `json:"glideLateSeconds"`
	// GlideMinSpanSeconds is the shortest early-to-late span still worth
	// calling a glide. Below it the measurement is refused outright.
	GlideMinSpanSeconds float64 `json:"glideMinSpanSeconds"`
	// GlideFloorDB is how far the tracked partial's baseband envelope may fall
	// below its level at the early probe before the late probe is treated as
	// unsupported. See measureGlide for why this bound is the whole fix.
	GlideFloorDB float64 `json:"glideFloorDB"`
	// GlidePartialWindowDB is how far below the loudest partial the glide may
	// still be read: the lowest partial within this window is the fundamental.
	GlidePartialWindowDB float64 `json:"glidePartialWindowDB"`

	// Windowed spectra.
	Windows        []TimeWindow `json:"windows"`
	WindowFFTSize  int          `json:"windowFFTSize"`
	BandMinHz      float64      `json:"bandMinHz"`
	BandMaxHz      float64      `json:"bandMaxHz"`
	BandsPerOctave int          `json:"bandsPerOctave"`

	// Amplitude envelope.
	EnvelopeFrameSeconds float64 `json:"envelopeFrameSeconds"`
	EnvelopeHopSeconds   float64 `json:"envelopeHopSeconds"`
	EnvelopeFloorDB      float64 `json:"envelopeFloorDB"`

	// Attack balance: the two bands whose ratio names "click" against "body".
	AttackWindowSeconds float64 `json:"attackWindowSeconds"`
	AttackHighMinHz     float64 `json:"attackHighMinHz"`
	AttackHighMaxHz     float64 `json:"attackHighMaxHz"`
	AttackLowMinHz      float64 `json:"attackLowMinHz"`
	AttackLowMaxHz      float64 `json:"attackLowMaxHz"`

	// Diagnostics turns on Features.Waveform and Features.Decay, which are
	// reported by cmd/measure-tom and read by nothing that scores anything.
	//
	// They are off by default because they are not free: between them they walk
	// the whole hit twice more per extraction — a fourth-order Welford update
	// over every sample, and a full Schroeder integration — for numbers
	// match.Distance never looks at. A fit run pays that once per take per
	// candidate, millions of times.
	Diagnostics bool `json:"diagnostics,omitempty"`
}

Options controls feature extraction. The zero value is not usable; start from DefaultOptions.

func DefaultOptions

func DefaultOptions() Options

DefaultOptions is the measurement this repository's tom work is calibrated against. Every number is a judgement about what a tom is, so each is explained where it is not obvious.

type Pair

type Pair struct {
	RefIndex  int `json:"refIndex"`
	CandIndex int `json:"candIndex"`

	// CentsError is |1200*log2(cand/ref)|, always non-negative: the match is
	// made on distance, and the sign was never computed.
	CentsError float64 `json:"centsError"`
	// LevelErrorDB is candidate minus reference, in dB relative to each side's
	// own strongest partial. Signed, so a candidate too quiet reads negative.
	LevelErrorDB float64 `json:"levelErrorDB"`
	// DecayLogRatio is ln(T60_ref / T60_candidate), signed: positive means the
	// candidate rings shorter. Zero when either side's fit did not converge on
	// a decay, which is also how the decay term treats it.
	DecayLogRatio float64 `json:"decayLogRatio"`
}

Pair is one reference partial identified with one candidate partial, with the three per-partial errors the corresponding terms are aggregated from.

The errors are per pair and untrimmed. Terms.PartialFrequency and its two siblings are a *trimmed* RMS over these — the worst fifth is dropped — so summing this column will not reproduce the term, and is not meant to.

type Partial

type Partial struct {
	FrequencyHz float64 `json:"frequencyHz"`
	// LevelDB is relative to the strongest partial, so it survives any gain.
	LevelDB float64 `json:"levelDB"`
	// T60Seconds comes from a log-linear fit of the partial's own envelope.
	// Zero means the fit did not converge on a decay.
	T60Seconds float64 `json:"t60Seconds"`
	// FitQuality is that fit's R². Partials whose envelope is not an
	// exponential — beating pairs, buried modes — score low and are weighted
	// down rather than discarded, because their frequency is still evidence.
	//
	// It is reported and it is *not* what the decay term trusts. Measured
	// against subband ESPRIT over the sixteen velocities of the licensed
	// reference, R² does not discriminate at all: median ring-time disagreement
	// is 39 % at R² >= 0.95 and 44 % below it. DecayRangeDB is what replaced it,
	// and unlike R² it was measured to separate the two populations before being
	// given the job. docs/physical-objective-validation.md §5c/§5f.
	FitQuality float64 `json:"fitQuality"`
	// DecayRangeDB is how far the partial fell inside the fit window before the
	// fitted noise floor caught it: the dynamic range the ring time was actually
	// read over. It says how much evidence there is for the number, where R²
	// says only how straight a line was drawn through whatever evidence there
	// was — and a slope through a noise floor is perfectly straight.
	//
	// Decay before the window opens is excluded, because it was not observed,
	// and the whole quantity is capped at the trace's own span, because a
	// partial still above the noise when the window closes leaves the floor
	// unconstrained and the model's 10*log10(P0/N) runs away.
	//
	// Measured, on the same evidence R² was measured on: it does not
	// discriminate on this reference either, for the reason given in
	// docs/physical-objective-validation.md §5f. It is reported, and the decay
	// term does not weight by it.
	DecayRangeDB float64 `json:"decayRangeDB"`
}

Partial is one resolved mode of the hit.

type Reference

type Reference struct {
	Samples      []float64
	SampleRateHz float64
	Channels     int
	BitDepth     int

	// ChannelDelaySamples is how far the second channel lags the first, and
	// ChannelCorrelation is how alike they are once that lag is taken out.
	//
	// Both are reported because a stereo pair of the same hit is usually two
	// microphones at different distances, and summing it without aligning it
	// first is a comb filter, not a mono reduction. On this repository's own
	// tom reference the offset is 69 samples at 44.1 kHz — 1.56 ms — which
	// combs the sum with a notch at 320 Hz and a peak at 639 Hz. Neither is a
	// property of the drum, and a model fitted to it is fitted to the
	// microphone geometry. ChannelMono therefore aligns before it averages.
	ChannelDelaySamples int
	ChannelCorrelation  float64
}

Reference is a decoded reference signal in its own sample rate.

The rate is deliberately *not* converted: the physical model accepts any rate from 8 kHz to 384 kHz, so a candidate can be rendered at the reference's rate instead, and no resampler ever enters the measurement path.

func DecodeReference

func DecodeReference(reader io.ReadSeeker, channel Channel) (Reference, error)

DecodeReference is LoadReference from an already-open reader, and is what LoadReference is a two-line wrapper over.

It is exported for callers that have no filesystem to open a path in — a browser handed a File, an HTTP body, an embedded asset. Every reduction rule LoadReference documents applies here unchanged, including the stereo alignment: this is the same function, not a second decoder.

func LoadReference

func LoadReference(path string, channel Channel) (Reference, error)

LoadReference reads a WAV file and reduces it to a single channel.

func LoadReferenceExplicit

func LoadReferenceExplicit(path string, channel Channel, chosen bool) (Reference, error)

LoadReferenceExplicit is LoadReference with the default reduction guarded: when the file turns out to carry more than one channel and the caller did not actually choose a reduction (chosen == false), it fails instead of quietly averaging the channels together.

The reason is that ChannelMono is a defensible default and a wrong one. It is a real reduction — it aligns the pair before averaging, so it is not the comb filter a bare sum would be — but a *spaced* stereo capture reduced that way is a different signal from either of its channels, and therefore a different target. The recording this guard was written for had its channels 1.56 ms apart, correlating 0.36 at zero lag, so averaging it combed the target and every archived number was fitted to its right channel alone. A run that omitted -channel fitted the average instead, announced nothing, printed a plausible baseline and a plausible best, and left its only trace in a "channel": "mono" field of the report that nobody reads. That cost a full-budget fit run, which is why the ambiguity is now an error rather than a default.

Note that mono is the *right* reduction for a coincident pair, where there is no arrival-time difference to smear — reference/tt08x08/lp/hd/v*.wav peaks at 0 samples of lag on thirteen of the sixteen and 1 sample on the other three, and one sample at 48 kHz is 21 µs, which combs nothing inside the band. The guard is not an argument against ChannelMono; it is an argument against taking it without deciding. See reference/CREDITS.md.

"Not chosen" is deliberately not the same as "equals ChannelMono": passing the mono reduction on a stereo file is a decision someone may legitimately make, and it is accepted. A genuinely single-channel file needs no decision at all and loads exactly as before, so mono recordings are unaffected.

type SpectrumResult

type SpectrumResult struct {
	// FrequenciesHz labels MagnitudeDB, from DC to Nyquist inclusive.
	FrequenciesHz []float64 `json:"frequenciesHz"`
	// MagnitudeDB is relative to the strongest bin, so it survives any gain —
	// the same convention Partial.LevelDB uses. Bins at exactly zero are
	// reported at FloorDB rather than at negative infinity.
	MagnitudeDB []float64 `json:"magnitudeDB"`
	// FloorDB is the value substituted for an empty bin.
	FloorDB float64 `json:"floorDB"`

	// BinHz is the spacing of FrequenciesHz. It is the sample rate over
	// Options.FFTSize, which is finer than the resolution the window actually
	// achieved whenever the segment is shorter than the transform — the
	// zero padding interpolates, it does not resolve.
	BinHz float64 `json:"binHz"`
	// SegmentSamples is how much audio went in before zero padding, and
	// ResolutionHz is what that length can genuinely separate: the Hann
	// window's main lobe is four bins of the *segment*, not of the transform.
	SegmentSamples int     `json:"segmentSamples"`
	ResolutionHz   float64 `json:"resolutionHz"`
}

SpectrumResult is one magnitude spectrum of an analysis window.

func Spectrum

func Spectrum(samples []float64, sampleRateHz float64, options Options,
	offsetSeconds, windowSeconds float64,
) (SpectrumResult, error)

Spectrum is the magnitude spectrum of one window of a hit, over the same alignment, Hann window and zero padding the peak picker runs on.

offsetSeconds is measured from the onset, not from the start of samples: the signal is onset-aligned and peak-normalized first, exactly as Extract does it, so a spectrum drawn here lines up with the partials Extract reported. Asking for Options.SustainStartSecs and SustainEndSecs-SustainStartSecs reproduces the sustain transform the detection is mostly made from.

type Terms

type Terms struct {
	// PartialFrequency is the trimmed RMS cents error over matched partials.
	// Cents, because the ear hears pitch as a ratio and a 3 Hz error means
	// something different at 118 Hz than at 1180 Hz.
	PartialFrequency float64 `json:"partialFrequencyCents"`
	// PartialLevel is the trimmed RMS dB error of the partials' relative levels
	// — the balance between the fundamental and the modes above it.
	PartialLevel float64 `json:"partialLevelDB"`
	// PartialDecay is the trimmed RMS of |ln(T60_ref / T60_candidate)|. A log
	// ratio, because ringing twice as long and half as long are the same size
	// of mistake.
	//
	// It used to be weighted by the product of the two fits' R², on the reasoning
	// that a partial whose envelope is not an exponential has a meaningless
	// slope. The reasoning is sound and R² does not implement it: measured
	// against subband ESPRIT over the sixteen velocities of the licensed
	// reference, median ring-time disagreement is 40 % at R² >= 0.95 and 55 %
	// below it, which is not a separation. Its replacement candidate,
	// Partial.DecayRangeDB, was measured on the same evidence and does not
	// separate them either — it is not even monotone in the disagreement. So the
	// weighting is gone rather than replaced: an unmeasured confidence is worse
	// than none, because it looks like a guard.
	//
	// What does the job the weighting was meant to do is the trimming, which
	// needs no per-partial confidence estimate to work.
	// docs/physical-objective-validation.md §5c and §5f.
	PartialDecay float64 `json:"partialDecayLogRatio"`
	// SpectralEnvelope is the mean per-window RMS dB error of the
	// fractional-octave shape — what the hit sounds like, band by band, as it
	// evolves.
	SpectralEnvelope float64 `json:"spectralEnvelopeDB"`
	// Envelope is the dB RMS error of the amplitude envelope: the shape of the
	// decay as a whole, independent of which partial carries it.
	Envelope float64 `json:"envelopeDB"`
	// Glide is the absolute cents error of the pitch bend.
	Glide float64 `json:"glideCents"`
	// AttackBalance is the dB error of the click-to-body ratio.
	AttackBalance float64 `json:"attackBalanceDB"`
	// Unmatched is the share of the reference's partial *audibility* that no
	// candidate partial accounts for, where a partial is worth how far it
	// stands above the level at which it would not have been detected at all.
	// It is both a diagnostic and the blend that makes the three terms above
	// pay for what is missing rather than averaging over whatever happened to
	// match.
	//
	// This was energy-weighted, as $10^(dB/10)$, and that was wrong in a way
	// that took a listening test to notice. Energy is dominated by whichever
	// partial is loudest: on this repository's tom reference the 212.78 Hz
	// partial carries 99.4 % of it, so a candidate that reproduced that one
	// partial and nothing else scored an unmatched share of 0.006 — and every
	// partial term, averaging over the single pair that matched, reported an
	// excellent number for a drum with one mode in it. Six of eight terms were
	// effectively scoring one partial.
	//
	// Counting instead would overcorrect: the same reference has a genuine but
	// isolated component 39 dB down that no two-headed drum will produce, and
	// missing it must not cost what missing the loudest partial costs.
	// Weighting by dB above the detection floor is the compromise that keeps
	// both properties — it is monotone in loudness, so the loud partials still
	// dominate, but it is compressed enough that six missing quiet ones cannot
	// be rounded away.
	Unmatched float64 `json:"unmatchedShare"`
	// Spurious is the mirror of Unmatched: the share of the candidate's partial
	// audibility that sits in modes the reference has nothing to put against.
	//
	// Unmatched alone is a one-sided measure. It charges for reference partials
	// the candidate fails to produce, but a candidate partial with no reference
	// counterpart is invisible to every partial term — matchPartials iterates
	// the reference — and reaches the total only through the spectral envelope.
	// Measured on the first fit run under the audibility weighting: the
	// candidate covered all seven reference partials, reported an unmatched
	// share of 0.000, and its second-loudest component was an invented 182 Hz
	// mode 15 dB down that cost it nothing. Making missing partials expensive
	// without making invented ones expensive just moves the degenerate optimum
	// from too few modes to too many.
	//
	// Counted only between the lowest and highest reference partial. Above and
	// below that the reference's own detection is unproven — a room recording's
	// noise floor hides modes a model legitimately has — so a partial out there
	// is charged by the spectral envelope, on evidence, and not by this.
	Spurious float64 `json:"spuriousShare"`
	// Total is the weighted sum.
	Total float64 `json:"total"`
}

Terms is the distance broken into the things a listener would name separately. Each is reported in its own unit, so a number here can be read against a tolerance rather than only against another run.

func Distance

func Distance(reference, candidate Features, weights Weights) Terms

Distance scores a candidate against a reference.

Deliberately absent: any sample-aligned waveform comparison. Against a room recording of a different physical drum, waveform error measures the phase relationship between two signals that were never meant to share one — it is large for candidates that sound identical and small for candidates that do not. analysis.CompareSignals keeps that job, for regression between two renders of the same model.

func OverGates

func OverGates(terms Terms, weights Weights) Terms

OverGates restates a score as the nine ratios it is the sum of: each term divided by the weight set's own gate for it.

This is the reading AGENTS.md asks every report to use. weight = 1/gate, so a term over its gate *is* its additive contribution to the total, a ratio of 1.0 is a term sitting exactly at the objective's measured reproducibility floor, and anything below 1.0 has not moved in a sense that means anything. Total carries the sum, which is Terms.Total again and is checked to be so.

It exists because the arithmetic looks trivial and is easy to get silently wrong across a language boundary: Terms is keyed by unit — partialFrequency *Cents*, partialLevel *DB* — and Weights is keyed by term name, so the obvious JSON-object division yields undefined for all nine and a chart of blanks. Doing it here means the pairing is made once, in the package that defines both.

type TimeWindow

type TimeWindow struct {
	Name         string  `json:"name"`
	StartSeconds float64 `json:"startSeconds"`
	EndSeconds   float64 `json:"endSeconds"`
}

TimeWindow is one span of the hit, measured from the onset.

type Trace

type Trace struct {
	// FrequencyHz is the detected partial this trace belongs to, which is the
	// nearest detection to the frequency asked for and not that frequency.
	FrequencyHz float64 `json:"frequencyHz"`
	// CutoffHz is the heterodyne low-pass the envelope was measured through,
	// set by the distance to the nearest neighbouring partial.
	CutoffHz float64 `json:"cutoffHz"`

	// WindowStartSeconds and WindowEndSeconds bound the fit window, measured
	// from the onset the analysis was aligned to.
	WindowStartSeconds float64 `json:"windowStartSeconds"`
	WindowEndSeconds   float64 `json:"windowEndSeconds"`

	// TimesSeconds and LevelsDB are the envelope truncated at
	// Options.DecayFitFloorDB below its own peak — the trace the log-linear
	// seed was fitted over.
	TimesSeconds []float64 `json:"timesSeconds"`
	LevelsDB     []float64 `json:"levelsDB"`
	// RefinementTimesSeconds and RefinementLevelsDB are the same envelope
	// decimated and *not* truncated, which the exponential-plus-floor
	// refinement was fitted over. It runs past the truncation on purpose: in
	// that model the noise floor is a parameter to identify rather than
	// something to cut away.
	RefinementTimesSeconds []float64 `json:"refinementTimesSeconds"`
	RefinementLevelsDB     []float64 `json:"refinementLevelsDB"`

	// SlopeDBPerSecond and InterceptDB are the fitted line, in dB of power.
	// The intercept is the level the partial is reported at: the fit
	// extrapolated back to the strike.
	SlopeDBPerSecond float64 `json:"slopeDBPerSecond"`
	InterceptDB      float64 `json:"interceptDB"`
	// FloorDB is the fitted stationary noise floor, the horizontal asymptote
	// the curve settles onto. Only meaningful when Refined is true.
	FloorDB float64 `json:"floorDB"`
	// Refined reports whether the exponential-plus-floor refinement converged.
	// False means every number here is the log-linear seed's, which is the
	// conservative fallback measureDecays also takes.
	Refined bool `json:"refined"`

	// T60Seconds, FitQuality and DecayRangeDB are exactly the fields the
	// corresponding Features.Partials entry carries, when Rejection is empty.
	T60Seconds   float64 `json:"t60Seconds"`
	FitQuality   float64 `json:"fitQuality"`
	DecayRangeDB float64 `json:"decayRangeDB"`

	// Rejection is empty when the fit was accepted. Otherwise it names the
	// admissibility guard that discarded it, and the partial is absent from
	// Features.Partials for that reason — the curve is still returned, because
	// seeing why a partial was refused is the point of asking.
	Rejection string `json:"rejection,omitempty"`
}

Trace is one partial's decay as the objective measured it, curves included.

It is what Features.Partials reports for that partial, plus the two envelopes the numbers were read off. A picture drawn from these is a picture of the fit that scored, not a reconstruction of it.

func DecayTrace

func DecayTrace(samples []float64, sampleRateHz float64, options Options, frequencyHz float64) (Trace, error)

DecayTrace measures one partial's decay and returns the envelope it was read off, alongside the fit.

frequencyHz selects a partial by proximity: the signal is put through the same onset alignment and the same detection the objective runs, and the detected partial nearest the requested frequency is the one traced. That indirection is deliberate — the heterodyne bandwidth is set by the distance to the *nearest neighbouring detection*, so tracing an arbitrary frequency through an invented neighbourhood would measure a different decay than the objective did. The frequency actually traced is reported back.

For an accepted partial, T60Seconds, FitQuality and DecayRangeDB equal the corresponding Features.Partials entry exactly, which TestDecayTraceAgreesWithExtract pins.

type Weights

type Weights struct {
	PartialFrequency float64 `json:"partialFrequency"`
	PartialLevel     float64 `json:"partialLevel"`
	PartialDecay     float64 `json:"partialDecay"`
	SpectralEnvelope float64 `json:"spectralEnvelope"`
	Envelope         float64 `json:"envelope"`
	Glide            float64 `json:"glide"`
	AttackBalance    float64 `json:"attackBalance"`
	Unmatched        float64 `json:"unmatched"`
	Spurious         float64 `json:"spurious"`

	// MatchToleranceCents bounds how far a candidate partial may sit from a
	// reference partial and still be called the same mode. Scaled by the
	// partial's index, because the high modes of a real drum scatter and
	// insisting they do not would make the low ones unfittable.
	MatchToleranceCents float64 `json:"matchToleranceCents"`
}

Weights converts each term to a common currency. The defaults are set so that one "just about audible" error in any term contributes roughly the same amount, which is what makes the sum meaningful rather than arbitrary.

func AdoptionGates

func AdoptionGates() Weights

AdoptionGates is DefaultWeights stated the other way round: the value of each term at which a candidate stops being distinguishable from a second observation of the reference. weight = 1/gate is the definition, so this is derived rather than a second source of truth, and it exists so that a report can print the gate beside the score without every caller doing the division.

func DefaultWeights

func DefaultWeights() Weights

DefaultWeights is the scoring this repository's tom fit uses.

Every weight is the reciprocal of that term's adoption gate, so a candidate scoring exactly at its gate contributes exactly 1.0 and the nine terms are commensurable. That much was always the intent, and TestWeightsAreReciprocalGates makes it structural.

The gates are **measured**, not chosen. Each is the 90th percentile of the objective's disagreement with itself, taken over the sixteen velocities of reference/tt08x08/lp/hd/v*.wav scored channel-against-channel in both directions — 32 scorings. That pair is coincident: peak inter-channel correlation at 0 samples of lag on thirteen of the sixteen and 1 sample on the other three, at 0.944-0.990. The two signals are two observations of one acoustic event, so any disagreement between them is the instrument's own noise floor, and a candidate at its gate is indistinguishable from a second microphone at the same point in space. cmd/measure-objective performs the measurement, through this Distance rather than through a copy of it.

They have now been measured four times. The third measurement was a different *drum*, not a different estimator: the reference set moved from the medium-pitch head strikes to the low-pitch ones on 2026-08-01, because that is the sound the fit is now aiming at, and the floor is a property of the estimator **and of what it is pointed at**. The fourth is the same drum through the analysis and decay windows PLAN N17 widened, plus the repair N17 turned out to need. All four columns are the p90 of the same 32 scorings, taken through this Distance:

term              mp-hd, defective  mp-hd, repaired  lp-hd pre-N17  lp-hd (current)  gate now
partial frequency        113.0 ¢           76.2 ¢         65.0 ¢          65.5 ¢       70 ¢
partial level             17.85 dB          6.81 dB        6.76 dB         6.42 dB      7 dB
partial decay              1.262            0.558          0.589           0.535        0.55
spectral envelope          3.65 dB          3.67 dB        3.24 dB         3.24 dB      3.5 dB
envelope                   3.81 dB          3.84 dB        1.38 dB         1.38 dB      1.5 dB
glide                    310.3 ¢          280.1 ¢          2.3 ¢          23.4 ¢       30 ¢
attack balance             1.12 dB          1.13 dB        0.81 dB         0.81 dB      0.9 dB
unmatched share            0.880            0.250          0.280           0.223        0.25
spurious share             0.346            0.245          0.293           0.239        0.25  (see below)

Every term is at least as reproducible on the low-pitch set as on the medium, and two are dramatically more so. **Glide was the headline**: 280.1 ¢ → 2.3 ¢, a factor of 120. That is not an estimator change — the estimator was untouched. The medium-pitch fundamental died before the late probe, so more than half of those pairs were placing two probes on a partial that was no longer there and measuring the noise between them; the low-pitch fundamental rings long enough that both probes land on signal. A term that was documented here as "still broken" turned out to have been broken *by the target*. The envelope term improves for the same underlying reason — 2.5 s of file against 1.25 s, so the tail being compared is real signal rather than floor.

The fourth column is where that headline is partly withdrawn, and the reason is worth stating plainly because it is the strongest evidence in this comment for re-measuring rather than carrying numbers forward. N17 widened the analysis span to 2.0 s and the decay window to 1.60 s, and it bounded the decay refinement per partial. The widening improved every partial term. The bound, in its first form, was a regression that nothing in the N17 work could see: it made short partials' *levels* unidentifiable, one bad fit re-based the whole level table, and glide — which picks its partial off that table — went from 2.3 ¢ to 286 ¢, with frequency, level, unmatched and spurious all following it. See minimumRefinementSpanSeconds in decay.go for the measurement and the repair. The current column is post-repair; every term is at or better than it was before N17 except glide, which is not back to 2.3 ¢ and may not be reachable again — the level table it depends on is a different table now.

The consequence to keep in view: the glide gate is 30 cents rather than the 10 it briefly was. A candidate whose pitch bend is 30 cents wrong contributes 1.0. Glide is no longer a term the objective cannot see, but neither is it the fine instrument the pre-N17 column suggested, and no fit has yet been run under any of these weights.

Gates are rounded *up* from the measured p90, because a gate is what a candidate has to beat and rounding a floor down sets a threshold below the floor.

The two middle columns are the estimator history, kept because it is the reason the first gate set was wrong and the reason this comment insists the floor be re-derived. Both were measured on mp-hd. Running that campaign with the trimming in the three partial terms disabled separated the two defects by measurement rather than dividing them by assertion:

term               2026-08-01  repaired only  repaired + trimmed
partial frequency     113.0 ¢       112.4 ¢          76.2 ¢
partial level          17.85 dB       7.24 dB         6.81 dB
partial decay           1.262          0.608           0.558
unmatched share         0.880          0.250           0.250
spurious share          0.346          0.245           0.245

So the estimator repair is what fixed level, decay, unmatched and spurious — those four were measuring the collapsed takes and nothing else — and the trimming is what fixed frequency, which the repair did not touch at all. They are two different defects and neither substitutes for the other.

Where that leaves the standing findings, after the change of reference:

  • **The spectral envelope is still the term that was always right.** 3.24 dB against a gate of 3.5, and it was 3.67 against 4 on the other drum: the one term neither an estimator defect, nor a change of target, nor N17's windows have moved at all. Every conclusion drawn from it stands. It and AttackBalance are the two terms that do not read the partial table, which is exactly why they are the two that never move.
  • **AttackBalance is still the most reproducible term in the objective**, 0.231 dB at the median, and it is still the one that used to carry the smallest weight, 1/6. It now carries 1/0.9.
  • **"Glide is broken" was withdrawn and is now half re-instated.** It is not broken by the target here — the low-pitch fundamental does outlive the late probe — but it is the term most sensitive to the partial table underneath it, because measureGlide reads the partial off that table. 23.4 ¢ at p90 with a 119.8 ¢ maximum is one take in sixteen where the two channels track different partials. Treat it as usable and fragile, and re-check it rather than assume it on any new reference or after any estimator change.
  • **"The partial terms were never gateable" stays withdrawn.** 65 cents and 6.4 dB are wide tolerances but they are thresholds a model can be held to. The six rounds of intervention aimed at the old 25-cent and 0.25 gates were still aimed at thresholds nothing could reach; that part stands.

Measured consequence: at these weights the objective's disagreement with itself totals **6.32 at the median and 8.25 at p90** on this reference. Read that as the floor, not as a score: no fit total below 6.32 is distinguishable from the objective's own noise, whatever else it claims.

Those two numbers were briefly recorded as 6.54 / 7.86, which was an error of exactly the kind the paragraph below warns about: the run that produced them was made minutes *before* the gates in this function were edited, so the per-term p90s in the table above were right and the totals beside them were computed under the previous weight set. Re-run against the shipped weights the median falls to 6.32 and the p90 rises to 8.25 — the p90 moving the other way, because tightening a gate raises its weight and the same raw disagreement then scores higher. A total is a property of a weight set, and a floor quoted from the wrong one is worse than no floor.

Which is the whole difficulty with reading totals: **no total recorded before this change is comparable to any total after it**, and not even the sign of the change is meaningful. Two things moved at once here — the weights and the drum — so this boundary is harder than the previous ones, and the pre-2026-08-01 fits were already incomparable for their own reasons. The readable quantity is the per-term contribution, and there the claim the weights make is intact: every term's p90 lands at or under its own gate, so nothing contributes more than 1.0 at the floor. cmd/measure-objective writes the floor into its own report so that a total always arrives beside the floor it should be read against.

Spurious used to be a deliberate departure from the reciprocal rule: on mp-hd its floor came out at 0.245 against Unmatched's 0.250, which would have made it very slightly the heavier of the two, and that direction had already been refuted by a fit run — it abandoned the drum and converged on two partials with a spurious share of 0.000, because the blend's pressure toward completeness is exactly what the spurious weight works against, and outweighing it makes emptiness the cheapest bank on offer. So both terms were pinned to Unmatched's gate. On lp-hd the order is the other way round (unmatched 0.223, spurious 0.239) and both round up to the same 0.25, so the departure is no longer doing anything and the equality is now what the measurement says rather than an override of it. The refuted direction is still refuted, and the inequality is still worth pinning in case a future measurement separates them: TestSpuriousDoesNotOutweighCompleteness pins it; it cannot pin the behaviour, for the reason given there.

The raw distribution, the method and the commands are in docs/physical-objective-validation.md. Re-derive these whenever the estimators in features.go change **or the reference set does**. The first version of this comment asserted the floor was "a property of the estimator, not of the drum". The lp-hd measurement refutes that: same estimator, different drum, and the glide floor moved by a factor of 120. It is a property of the pair. This repository has twice quoted gates measured through an estimator it had since replaced; do not now start quoting gates measured on a drum it no longer aims at.

type WindowFeature

type WindowFeature struct {
	TimeWindow
	// BandDB is the fractional-octave band level, mean-removed across the
	// bands present. Removing the mean is what makes it a *shape*: a level
	// difference between reference and candidate cannot show up here.
	BandDB   []float64            `json:"bandDB"`
	Spectrum frequencystats.Stats `json:"spectrum"`
}

WindowFeature is one time window's spectral shape.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL