analyze

package
v0.1.6 Latest Latest
Warning

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

Go to latest
Published: Jun 14, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package analyze owns the bridge's offline audio analysis: decoding a source file to PCM via sox(1), reducing it to a compact peak waveform sidecar, and the worker pool the `bridge analyze` CLI and the serve-side background pass share.

**Why shell out to sox** — same rationale as internal/transcode: keeping the bridge pure-Go preserves single-host cross-compilation to all target platforms. sox is already the optional, feature-gated dependency the upscaling feature installs; analysis reuses it, so it adds no new posture (missing sox ⇒ the feature degrades off, the rest of the server runs unchanged).

**Bit-exact mission preserved**: analysis only READS samples to derive metadata (a waveform sidecar, EBU R128 loudness, and estimated key + tempo). It never alters what /v1/download serves — the original file streams byte-for-byte as before.

One decode per track at 48 kHz and the SOURCE channel count feeds both the peak envelope (mono downmix) and the loudness meter (channel-aware R128). 48 kHz is load-bearing for loudness — the BS.1770 K-weighting coefficients are defined for 48 kHz — and decoding at the source channel count avoids the +3..+6 dB bias a mono downmix imposes on multichannel loudness.

Index

Constants

View Source
const (
	// AnalysisSampleRate is the mono PCM rate sox decodes to.
	AnalysisSampleRate = 48000

	// WaveformSchemaVersion stamps the analysis row. Bump when the
	// decode params or the sidecar binary format change so a prior
	// sidecar is recognised as stale (re-analyzed) without a migration —
	// the scan-skip gate re-enqueues any row whose stamp differs.
	//
	// wf1 → wf2: the decode moved from forced mono (`-c 1`) to the source
	// channel count so EBU R128 loudness is measured channel-aware (a
	// mono downmix reads several dB hot). The 1BWF sidecar BYTES are
	// unchanged — the peak envelope still downmixes to mono, byte-
	// identical to wf1 for mono/stereo sources — so iOS keeps the same
	// `waveformTag` and does NOT re-fetch the sidecar; the bump exists
	// only to trigger the one-time loudness backfill on already-analyzed
	// libraries (a row re-analyzes once, gains its ReplayGain scalar, and
	// stamps wf2 so it isn't re-enqueued even when loudness is
	// unavailable — silence / unprobeable channel layout).
	//
	// wf2 → wf3: the same decode now also estimates musical key
	// (Krumhansl-Schmuckler) + tempo (onset autocorrelation) off the mono
	// downmix. The waveform bytes AND the loudness value are unchanged
	// (key/tempo only ADD consumption of the existing mono stream), so
	// again iOS re-fetches no sidecars and existing rows re-analyze once
	// to backfill key/tempo — stamping wf3 so an un-estimable track (too
	// short / atonal / arrhythmic) isn't re-enqueued forever.
	WaveformSchemaVersion = "wf3"

	// WaveformDirSubdir is the fixed subdir under cfg.DataDir where
	// waveform sidecars land (source-path-mirrored beneath it).
	WaveformDirSubdir = "waveforms"
)

Variables

View Source
var ErrPoolClosed = errors.New("analyze pool is closed")

ErrPoolClosed is returned by Enqueue after Stop.

View Source
var ErrQueueFull = errors.New("analyze pool queue is full")

ErrQueueFull is returned by Enqueue when the bounded pending-job channel is at capacity. The `bridge analyze` driver and any HTTP trigger map it to a clean rejection rather than blocking.

Functions

func WaveformDirFor

func WaveformDirFor(dataDir string) string

WaveformDirFor returns the absolute directory waveform sidecars are written under, given the bridge's dataDir. Pure path arithmetic.

Types

type AnalyzeSpec

type AnalyzeSpec struct {
	SourceAbsPath    string
	SourceLibraryRel string
	SourceMTimeNS    int64
	SourceSize       int64
	OutputDir        string // <dataDir>/waveforms
}

AnalyzeSpec describes one source-to-waveform analysis job. The SourceMTimeNS / SourceSize are captured at enqueue time and stored on the row so the scan-skip gate and the serving-time freshness check can detect a drifted source.

func (AnalyzeSpec) SidecarPath

func (s AnalyzeSpec) SidecarPath() string

SidecarPath returns the absolute on-disk path for this spec's waveform sidecar in the source-path-mirrored layout (<OutputDir>/<libRel-dir>/<base>.waveform.bin). Mirrors the layout internal/transcode uses for variants so operators see one consistent on-disk shape.

type Pool

type Pool struct {
	// contains filtered or unexported fields
}

Pool is the long-lived single-FIFO worker pool that runs offline audio analysis. Unlike internal/transcode's two-channel priority pool, analysis is purely background work, so a plain FIFO is the right shape — no CarPlay-latency bias to defend.

The pending-job channel is bounded (queueCap); Enqueue is non-blocking (select + default → ErrQueueFull). Dedup keys on the source library-relative path (one waveform per source), so a duplicate enqueue while a job is queued or running is a silent no-op.

func NewPool

func NewPool(store *manifest.Store, workers, queueCap int, opts ...PoolOption) *Pool

NewPool constructs and starts a Pool with `workers` parallel decoders and a `queueCap`-bounded pending-job channel. Caller is expected to have verified sox is on PATH (transcode.PrecheckSox) — the worker doesn't repeat the probe per job.

func (*Pool) Enqueue

func (p *Pool) Enqueue(spec AnalyzeSpec) error

Enqueue submits a spec to the pool. Non-blocking; ErrQueueFull when the channel is full, nil (silent no-op) on a duplicate, ErrPoolClosed after Stop. The channel send runs inside p.mu and Stop closes the channel under the same lock, so a send-on-closed-channel panic is impossible.

func (*Pool) SetOnStateChange

func (p *Pool) SetOnStateChange(fn func())

SetOnStateChange wires (or rewires) the callback fired after every observable state transition. nil disables. Set-once at cmd/bridge wiring time; race-safe.

func (*Pool) Stats

func (p *Pool) Stats() PoolStats

Stats returns the current snapshot. Safe to call concurrently.

func (*Pool) Stop

func (p *Pool) Stop()

Stop drains the queue, kills any in-flight sox, and blocks until every worker AND the publisher have exited. Idempotent. Ordering is load-bearing: close(jobs) under p.mu (so an in-flight Enqueue can't send on a closed channel), cancel stopCtx (kill sox), wg.Wait (no more sends possible), then close + drain the publisher channel.

type PoolOption

type PoolOption func(*Pool)

PoolOption customises a Pool at construction (before workers start).

func WithClock

func WithClock(now func() time.Time) PoolOption

WithClock overrides the wall clock used for the analysis row's created_at (tests inject a deterministic clock).

func WithFsync

func WithFsync(fn func(path string) error) PoolOption

WithFsync overrides the durability-barrier fsync (tests inject a no-op or a failure stub).

func WithJobTimeout

func WithJobTimeout(d time.Duration) PoolOption

WithJobTimeout overrides the per-job deadline (tests shrink it to drive the timeout branch).

func WithRunner

func WithRunner(fn func(ctx context.Context, spec AnalyzeSpec) (Result, error)) PoolOption

WithRunner overrides the analysis runner (tests inject a stub to avoid spawning sox). Production uses RunAnalysis.

type PoolStats

type PoolStats struct {
	Workers  int
	QueueCap int
	QueueLen int
	Inflight int
	Enqueued uint64
	Done     uint64
	Failed   uint64
}

PoolStats is a snapshot of pool counters for the stats surface.

type Result

type Result struct {
	WaveformPath  string
	WaveformTag   string
	WaveformSize  int64
	SchemaVersion string

	// ReplayGainTrackDB is the EBU R128 / ReplayGain 2.0 track gain in dB
	// (the gain that brings the program to the -18 LUFS reference). Valid
	// only when HasLoudness is true — set when sox reported the channel
	// layout (so loudness wasn't measured off a biased mono downmix) and
	// the program wasn't silence. The caller surfaces it on the wire only
	// when the source carries no ReplayGain tag.
	ReplayGainTrackDB float64
	HasLoudness       bool

	// KeyRoot / KeyMode are the estimated musical key (Krumhansl-
	// Schmuckler): KeyRoot is the tonic 0..11 (C=0), KeyMode is
	// "major"/"minor". Both nil/"" when the estimator saw too little
	// signal. Best-effort — surfaced as an "estimated" key.
	KeyRoot *int
	KeyMode string

	// BPM is the estimated tempo (onset autocorrelation), or nil when no
	// confident estimate. Surfaced only when the source has no BPM tag.
	BPM *int
}

Result is the outcome of a completed analysis job — the written sidecar's path, content tag (8 hex of its SHA-256, the iOS cache key), size, the schema version that produced it, and the signal- derived loudness.

func RunAnalysis

func RunAnalysis(ctx context.Context, spec AnalyzeSpec) (Result, error)

RunAnalysis decodes the source via sox, computes the peak waveform + EBU R128 loudness, and writes the sidecar atomically (tmp + rename), returning its path, content tag, size, and loudness. The pool fsyncs the sidecar before committing the DB row.

**Commit only on clean decode**: a truncated / corrupt file makes sox exit non-zero mid-stream; decodeFrames surfaces that as an error and RunAnalysis writes nothing (the cleanup defer reaps the tmp on every non-success path), so the caller never persists a waveform covering only the first N seconds. Cancellation flows via ctx → exec.CommandContext (decodeFrames kills + reaps sox).

Jump to

Keyboard shortcuts

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