tactus

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 4 Imported by: 0

README

tactus

Go Reference

Pure Go tempo (BPM) estimation from mono PCM. Uses only the Go standard library — no cgo, no external DSP libraries, no bundled native binaries.

tactus is the signal-processing companion to tunetag: tunetag reads and writes the BPM tag, tactus computes the number to put in it.

Scope

tactus does exactly one thing: mono PCM → BPM. It does not decode audio files. You decode with whatever you already use, mix to mono, and hand tactus the samples.

This separation keeps the library dependency-free and trivial to test: the entire contract is a slice of float64 in, a float64 BPM out.

Install

go get github.com/cabbagekobe/tactus

Requires Go 1.24 or later.

API

// Estimate folds the result into the default home octave, 90–180 BPM.
func Estimate(mono []float64, sampleRate int) (float64, error)

// EstimateWith folds into a caller-chosen home octave.
func EstimateWith(mono []float64, sampleRate int, opt Options) (float64, error)

// EstimateCandidates returns [primary, half, double] for a DJ-style
// halve/double control. EstimateAllCandidates returns every tempo the
// estimator considered (incl. non-octave relatives), by descending Support.
func EstimateCandidates(mono []float64, sampleRate int) ([]Candidate, error)
func EstimateCandidatesWith(mono []float64, sampleRate int, opt Options) ([]Candidate, error)
func EstimateAllCandidates(mono []float64, sampleRate int) ([]Candidate, error)
func EstimateAllCandidatesWith(mono []float64, sampleRate int, opt Options) ([]Candidate, error)

type Candidate struct {
    BPM        float64
    Support    float64 // relative to the primary (primary = 1.0)
    Confidence float64 // absolute periodic strength in [0, 1]
}

// ErrTooShort: input too short to measure. ErrNoBeat: long enough but no
// detectable beat (a flat or silent onset function).
var ErrTooShort = errors.New("tactus: audio too short to estimate")
var ErrNoBeat = errors.New("tactus: no detectable beat")

// Options selects the home octave [MinBPM, 2*MinBPM). Zero values fall
// back to the default [90, 180). MinBPM is a single global setting —
// like a DJ tool's BPM range — not a per-track hint.
type Options struct {
    MinBPM float64
    MaxBPM float64
}
  • Input samples are expected in the range [-1, 1].
  • Stereo sources must be mixed to mono by the caller ((L+R)/2).
  • The result is a float64; round it yourself before writing an integer BPM tag.

Example

package main

import (
    "fmt"
    "math"

    "github.com/cabbagekobe/tactus"
)

func main() {
    // mono holds decoded PCM in [-1, 1]; sampleRate is its rate in Hz.
    var mono []float64
    sampleRate := 44100

    bpm, err := tactus.Estimate(mono, sampleRate)
    if err != nil {
        panic(err)
    }
    fmt.Printf("BPM: %d\n", int(math.Round(bpm)))

    // Shift the home octave to match a slower collection: a 75–95 BPM
    // library folds cleanly into [75, 150) instead of reading double.
    bpm, _ = tactus.EstimateWith(mono, sampleRate, tactus.Options{
        MinBPM: 75,
        MaxBPM: 150,
    })
    fmt.Printf("BPM (home 75–150): %.1f\n", bpm)

    // For a halve/double control, take the octave alternates. The
    // half-time almost always rivals the primary — perceived tempo is
    // ambiguous — so present it as a choice rather than auto-applying it.
    cands, _ := tactus.EstimateCandidates(mono, sampleRate)
    fmt.Printf("primary %.0f, halve to %.0f\n", cands[0].BPM, cands[1].BPM)
}

Algorithm

Reimplemented from scratch with the standard library (including a self-contained radix-2 FFT):

  1. Spectral-flux onset function — take a short-time Fourier transform (Hann-windowed) and sum, per frame, the positive changes in log-magnitude spectrum. Note attacks (which fall on beats) spike this signal; sustained tones and bass do not. This is far more beat-selective than a raw energy envelope, which is prone to spurious periodicities from bass lines and cross-rhythms.
  2. Resample — linearly resample the onset function up to a fixed rate so the beat period stays resolvable at integer autocorrelation lags even at fast tempi.
  3. Autocorrelation tempogram — sweep the normalised autocorrelation of the onset signal across a wide band and collect its local peaks. A periodic signal aligns with itself at its beat period, so peaks mark the true tempo along with its metrical harmonics and subharmonics (half, double, 3:2, 4:3, …).
  4. Fold into the home octave — halve or double each peak until it lands in the home octave [MinBPM, 2·MinBPM) (default [90, 180)). This collapses pure half/double-tempo errors. The home octave is a single global setting — like a DJ tool's BPM range — not a per-track hint; set MinBPM to match your collection's tempo centre.
  5. Pick by harmonic support — among the folded candidates, choose the one with the strongest support: its own autocorrelation plus a weighted share from its half- and double-tempo neighbours. The true beat is periodic at those octaves too, so it beats a lone 3:2 or 4:3 harmonic — resolving the octave without any per-track tuning.

License

MIT. See LICENSE. The algorithm is an independent, clean-room implementation built from standard signal-processing methods (spectral-flux onset detection, autocorrelation, octave folding with harmonic-support scoring); no GPL code is included.

Documentation

Overview

Package tactus estimates the tempo (BPM) of audio from mono PCM using only the Go standard library — no cgo, no external DSP dependencies.

Scope

tactus does one thing: it maps a mono PCM signal to a BPM value. It deliberately does not decode audio files. Callers are expected to decode their container of choice, mix to mono ((L+R)/2 for stereo), normalise samples to the range [-1, 1], and pass the resulting slice together with the sample rate.

Entry points

  • Estimate folds the estimate into the default home octave of 90–180 BPM.
  • EstimateWith folds into a caller-chosen home octave via Options — a single global setting (like a DJ tool's BPM range), not a per-track hint.
  • EstimateCandidates and EstimateCandidatesWith return the estimate together with its half- and double-time alternates, each scored, so a caller can offer a halve/double control where the perceived tempo is ambiguous.
  • EstimateAllCandidates and EstimateAllCandidatesWith return every distinct tempo the estimator considered, including non-octave metrical relatives, for a richer picker.

All return ErrTooShort when the input is too short to measure, and ErrNoBeat when it is long enough but carries no detectable beat. Each Candidate reports a Confidence in [0, 1] a caller can gate on.

Algorithm

The estimator builds a spectral-flux onset detection function: a short-time Fourier transform (via a self-contained radix-2 FFT) whose per-frame positive changes in log-magnitude spectrum are summed into an onset signal. Note attacks — which fall on beats — spike this signal, whereas sustained tones and bass do not, so its autocorrelation is far cleaner than a raw energy envelope's. The onset signal is resampled to a fixed rate and its autocorrelation is swept across a wide band; each local peak marks a candidate periodicity.

Autocorrelation alone is metrically ambiguous: a signal is also self-similar at half, double, and 3:2/4:3 harmonics of its beat. Two steps resolve the octave without any per-track tuning. Each peak is first folded into the home octave by repeated halving or doubling, which collapses pure half/double errors. The surviving candidates are then scored by harmonic support — a candidate's own autocorrelation plus a weighted share from its half- and double-tempo neighbours. The true beat is periodic at those octaves too, so it outscores a lone 3:2 or 4:3 spurious peak.

Concurrency

All functions are pure and re-entrant: they read only their arguments and allocate their own scratch buffers, so independent calls are safe to run concurrently.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoBeat = errors.New("tactus: no detectable beat")

ErrNoBeat is returned when the input is long enough but carries no detectable beat — a flat or silent onset function with no periodicity to lock onto. Callers may treat this differently from ErrTooShort (e.g. flag the track for review rather than skipping it).

View Source
var ErrTooShort = errors.New("tactus: audio too short to estimate")

ErrTooShort is returned when the input has too few samples to cover the search range (its onset function cannot span the slowest tempo's period twice).

Functions

func Estimate

func Estimate(mono []float64, sampleRate int) (float64, error)

Estimate estimates the tempo, in BPM, of mono PCM (samples in the range [-1, 1]) sampled at sampleRate Hz, folding the result into the default home octave of 90–180 BPM. Stereo sources must be mixed to mono by the caller ((L+R)/2). It returns ErrTooShort when the input is too short to measure.

func EstimateWith

func EstimateWith(mono []float64, sampleRate int, opt Options) (float64, error)

EstimateWith is like Estimate but folds the result into the home octave given by opt (see Options). It searches autocorrelation over a wide band, then resolves the octave: each periodicity peak is folded into the home octave and the candidate with the strongest half/double harmonic support wins. This lands on the true beat rather than a half, double, or 3:2/4:3 metrical harmonic, without any per-track tuning.

Types

type Candidate

type Candidate struct {
	BPM        float64
	Support    float64
	Confidence float64
}

Candidate is one tempo hypothesis.

Support is the harmonic support relative to the primary estimate (whose Support is 1.0) — useful for ranking the alternates against each other. Confidence is the absolute periodic strength at this tempo: the normalised autocorrelation at its beat period, in [0, 1]. A caller can gate on the primary's Confidence to decide whether to trust the estimate or flag the track for review.

func EstimateAllCandidates

func EstimateAllCandidates(mono []float64, sampleRate int) ([]Candidate, error)

EstimateAllCandidates is like EstimateCandidates but returns every distinct tempo the estimator considered. See EstimateAllCandidatesWith.

func EstimateAllCandidatesWith

func EstimateAllCandidatesWith(mono []float64, sampleRate int, opt Options) ([]Candidate, error)

EstimateAllCandidatesWith returns every distinct tempo hypothesis the estimator folded into the home octave, ordered by descending Support, with the primary estimate first. Unlike EstimateCandidatesWith (which gives only the octave alternates), this includes non-octave metrical relatives (a 3:2 or 4:3 reading), letting a caller build a richer "pick the tempo" UI. Support is relative to the primary and Confidence is the absolute periodic strength; see Candidate.

func EstimateCandidates

func EstimateCandidates(mono []float64, sampleRate int) ([]Candidate, error)

EstimateCandidates is like Estimate but also returns the octave alternates of the chosen tempo. See EstimateCandidatesWith.

func EstimateCandidatesWith

func EstimateCandidatesWith(mono []float64, sampleRate int, opt Options) ([]Candidate, error)

EstimateCandidatesWith returns the primary tempo estimate followed by its half- and double-time alternates, ordered [primary, half, double]. Each carries its harmonic support relative to the primary (primary = 1.0). The half-time almost always rivals the primary — octave relatives are equally periodic, which is exactly why perceived tempo (tactus) is ambiguous — so the support does not, and cannot, decide between them; the double is typically weaker. Surface a DJ-style halve/double control and let the user make the final octave choice rather than guessing it.

The primary's Support is 1.0 except in the degenerate case of a non-positive primary support (a beatless input that still cleared the length guard), where the raw support values are returned as-is.

type Options

type Options struct {
	MinBPM float64
	MaxBPM float64
}

Options selects the home octave that estimates are folded into. The effective window is [MinBPM, 2*MinBPM): MinBPM sets the low edge and the octave spans upward from it (MaxBPM is retained for validation and backward compatibility). Zero values fall back to the default home octave [90, 180). Set MinBPM to match a collection's tempo centre — e.g. MinBPM=75 for a 75–95 BPM library — the way DJ software exposes a single global BPM range. This is not a per-track hint.

Jump to

Keyboard shortcuts

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