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 ¶
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 ¶
var ErrPoolClosed = errors.New("analyze pool is closed")
ErrPoolClosed is returned by Enqueue after Stop.
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 ¶
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) 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).