Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
952a9e1
test: pin chunked streaming equivalence with one-shot processing
tphakala Jul 17, 2026
a62b2ad
fix: cap polyphase history consumption at available input positions
tphakala Jul 17, 2026
652b349
fix: drain flush delay lines exactly, dropping phantom padding sample…
tphakala Jul 17, 2026
5937d30
fix: make Flush terminal, second Flush empty and post-flush Process f…
tphakala Jul 17, 2026
943ef6f
fix: return owned buffer from Process at unity ratio
tphakala Jul 17, 2026
3f4f732
fix: reject NaN sample rates in engine constructor
tphakala Jul 17, 2026
26a846d
feat: add Latency accessor for startup deficit on engine and Simple t…
tphakala Jul 17, 2026
977b2dc
docs: document streaming contract, latency, and add real-time FIFO ex…
tphakala Jul 17, 2026
4f98176
docs: feed streaming example FIFO by output deficit
tphakala Jul 17, 2026
186e927
fix: map QualityQuick to cubic engine in NewEngine paths, matching New()
tphakala Jul 17, 2026
2a99cf6
fix: propagate half-band stage construction errors instead of stub fa…
tphakala Jul 17, 2026
2f39141
fix: defer cubic emission until real history primes, drain true tail …
tphakala Jul 17, 2026
507350c
test: pin cubic flush tail content and partially primed flush
tphakala Jul 17, 2026
712d38e
test: replace vacuous assertions with behavioral checks in flush, res…
tphakala Jul 17, 2026
ccecaff
test: use InDelta for float comparisons and cap diff output
tphakala Jul 17, 2026
3ce29b8
fix: use adjacent prototype coefficients at polyphase phase boundaries
tphakala Jul 17, 2026
83f0f32
docs: changelog for issue #51 streaming contract fixes
tphakala Jul 17, 2026
e30fb7d
fix: correct QualityQuick samplesOut accounting and honor zero-alloc …
tphakala Jul 17, 2026
c90872c
docs: handle errors in SimpleResamplerFloat32 doc example
tphakala Jul 17, 2026
ffd0bd1
fix: return fresh empty slice from cubic Process and reject NaN in Co…
tphakala Jul 17, 2026
2596a36
fix: owned-copy symmetry and memory accounting in engine stages
tphakala Jul 17, 2026
db07500
docs: reconcile measured figures and streaming latency guidance
tphakala Jul 17, 2026
74cf602
test: close float32 and stage-adapter coverage gaps
tphakala Jul 17, 2026
e414938
docs: fix FlushMulti reference in package doc
tphakala Jul 17, 2026
c2f1c61
fix: resolve lint findings in streaming tests and example
tphakala Jul 17, 2026
84e4371
test: assert ProcessInto errors in zero-alloc helpers
tphakala Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,54 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `Latency()` on `SimpleResampler` and `SimpleResamplerFloat32`, returning the
startup deficit in output samples so a caller can prime a real-time FIFO
before the first output. (#51)
- Streaming documentation (latency, the `Flush` contract, per-channel instance
requirements) and a real-time FIFO example at `examples/streaming`. (#51)

### Fixed

- Polyphase phase-boundary coefficient interpolation used a wrapped neighbor,
degrading THD+N at ratios with active sub-phase interpolation. The committed
measurement shows an 86.26 dB improvement (wrapped -54.46 dB versus flat
-140.72 dB) at 44100 to 64000, with similar magnitude at other
active-interpolation ratios measured during the investigation but not
committed as tests; exact-rational ratios such as 44100 to 48000 were
unaffected. (#51)
- Severe non-integer downsampling (beyond roughly 1:16) corrupted output with
repeated stale samples and grew internal history without bound. (#51)
- `Flush` over-padded each filter stage by one zero, emitting about 2 phantom
samples; `Process` plus `Flush` now totals within
`[floor(n*ratio), ceil(n*ratio)+1]`. (#51)
- At unity ratio (`inputRate == outputRate`), `Process` returned the caller's
own input slice; it now returns an owned buffer. (#51)
- Cubic (`QualityQuick`) resampling computed its first output segments from a
fictional zero history and never emitted the final segments; output is now
aligned to real data, with the first output after 2 input samples. Its
`Process` also returned an aliased empty slice during priming; it now returns
an owned buffer. (#51)
- NaN sample rates are now rejected by all constructors, covering both the
`NewEngine`/`NewEngineFloat32` engine path and the `New(config)` pipeline
path (`New`, `NewMultiChannel`, `NewStereo`, `NewSimple`, and the preset
helpers). (#51)
- Half-band stage construction errors now propagate instead of silently
substituting a nearest-neighbor stub. (#51)
- `GetLatency` now accounts for decimation and cubic stages. (#51)

### Changed

- `Flush` is now terminal: a second `Flush` returns an empty slice, and a
`Process` call after `Flush` starts a fresh stream instead of convolving
against leftover padding. (#51)
- `QualityQuick` through `NewEngine` and `NewEngineFloat32` now uses cubic
interpolation (matching `New()` and the documented contract) instead of a
full FIR pipeline; latency drops accordingly. (#51)

## [1.4.0] - 2026-05-29

### Added
Expand Down Expand Up @@ -69,6 +117,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
design, quality presets, multi-channel and streaming support, validated against
libsoxr.

[Unreleased]: https://github.com/tphakala/go-audio-resampler/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/tphakala/go-audio-resampler/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/tphakala/go-audio-resampler/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/tphakala/go-audio-resampler/compare/v1.1.0...v1.2.0
Expand Down
46 changes: 42 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ import (
)

func main() {
// Create a resampler for CD to DAT conversion
// Create a resampler for CD to DAT conversion (mono; for multi-channel
// audio use ProcessMulti, see "Multi-Channel Streaming" below)
config := &resampling.Config{
InputRate: 44100,
OutputRate: 48000,
Channels: 2,
Channels: 1,
Quality: resampling.QualitySpec{Preset: resampling.QualityHigh},
}

Expand All @@ -134,12 +135,49 @@ func main() {
writeOutput(output)
}

// Flush remaining samples
final, _ := r.Flush()
// Flush remaining samples at end of stream
final, err := r.Flush()
if err != nil {
log.Fatal(err)
}
writeOutput(final)
}
```

### Latency and Real-Time Streaming

A streaming resampler has a startup deficit: the internal filter needs a few samples of history before it can emit correctly filtered output, so the first `Process` calls in a stream withhold roughly `Latency()` samples that later calls make up. Callers that need a fixed number of output samples per callback, such as a portaudio or miniaudio audio callback, should sit a small FIFO between the resampler and the callback, primed with `Latency()` samples of silence. `Latency()` matches the measured deficit to within about 2 samples, so priming with it keeps callbacks fed in practice; any 1-2 sample shortfall self-heals because the deficit-driven buffer sizing catches up within the first few callbacks.

`Latency()` is available on `SimpleResampler` and `SimpleResamplerFloat32` (the `NewEngine`/`NewEngineFloat32` path) only. Resamplers built from `New(config)` instead expose `GetLatency()`/`GetInfo()`, which report the filter group delay in the input domain: a different figure, not intended for FIFO priming.

```go
r, err := resampling.NewEngineFloat32(44100, 48000, resampling.QualityHigh)
if err != nil {
log.Fatal(err)
}
fifo := make([]float32, r.Latency()) // prime with the startup deficit

for chunk := range audioChunks {
out, err := r.Process(chunk)
if err != nil {
log.Fatal(err)
}
fifo = append(fifo, out...)
// deliver fixed-size slices from fifo to the audio callback here
}

// End of stream: drain the filter tail exactly once.
tail, err := r.Flush()
if err != nil {
log.Fatal(err)
}
fifo = append(fifo, tail...)
```

`Flush` is end-of-stream only. It pushes padding through the filter to drain its tail, so calling it once per chunk instead of once at the very end injects that padding into the middle of the stream and produces audible clicks at every chunk boundary. Call `Flush` exactly once, after the last `Process` call for the stream; a second `Flush` call is a no-op that returns an empty slice. `Reset` discards all filter state and is for starting an unrelated stream, never for use between chunks of the same stream. Each `SimpleResampler` or `SimpleResamplerFloat32` processes one channel, so a multi-channel stream keeps one persistent instance per channel alive for the duration of the stream.

See [`examples/streaming`](examples/streaming/main.go) for the complete, runnable FIFO pattern.

### Zero-Allocation Streaming (`ProcessInto`)

For allocation-sensitive pipelines, use caller-owned output buffers:
Expand Down
35 changes: 35 additions & 0 deletions aliasing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026 Tomi P. Hakala
// SPDX-License-Identifier: LGPL-2.1-or-later

package resampler

import "testing"

// Process must return an owned buffer at every ratio. At 1:1 the DFT stage
// passthrough used to return the caller's own slice, so mutating the input
// buffer afterwards corrupted previously returned output.
func TestProcessOutputOwned_UnityRatio(t *testing.T) {
r, err := NewEngine(48000, 48000, QualityHigh)
if err != nil {
t.Fatal(err)
}
in := make([]float64, 256)
for i := range in {
in[i] = float64(i)
}
out, err := r.Process(in)
if err != nil {
t.Fatal(err)
}
if len(out) != len(in) {
t.Fatalf("unity ratio length %d != %d", len(out), len(in))
}
for i := range in {
in[i] = -1
}
for i, v := range out {
if v != float64(i) {
t.Fatalf("output aliases input: out[%d] = %g after caller mutation", i, v)
}
}
}
97 changes: 85 additions & 12 deletions convenience.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ func NewMultiChannel(inputRate, outputRate float64, channels int, quality Qualit
// SimpleResampler provides a simplified interface for basic resampling tasks.
// It wraps the engine.Resampler directly for maximum performance.
// Uses float64 precision for maximum quality.
//
// Streaming pattern: keep one persistent SimpleResampler per channel, call
// Process once per chunk of input, and call Flush once at the end of the
// stream.
type SimpleResampler struct {
engine *engine.Resampler[float64]
}
Expand All @@ -131,7 +135,12 @@ func NewEngine(inputRate, outputRate float64, quality QualityPreset) (*SimpleRes
return &SimpleResampler{engine: r}, nil
}

// Process resamples the input samples.
// Process resamples one chunk of a single (mono) audio channel.
// The output length varies from call to call: early calls may withhold up
// to Latency() samples while the internal filter primes, so len(output)
// does not track len(input)*GetRatio() on a per-call basis. The returned
// slice is owned by the caller; it is never aliased by the resampler and
// stays valid after subsequent calls.
func (r *SimpleResampler) Process(input []float64) ([]float64, error) {
return r.engine.Process(input)
}
Expand Down Expand Up @@ -165,17 +174,27 @@ func (r *SimpleResampler) EstimateOutput(inputLen int) int {
return int(float64(inputLen)*r.engine.GetRatio()) + estimateOutputMargin
}

// Flush returns any remaining buffered samples.
// Flush drains the remaining buffered samples at end-of-stream. It is
// end-of-stream only: pushing padding through the filter on every chunk
// instead of just the last one creates audible edge transients.
// Flush is terminal. After it returns, the instance behaves like a fresh
// one for output purposes: a second Flush call returns an empty slice, and
// a subsequent Process call produces output bit-identical to a fresh
// instance. GetStatistics counters are cumulative and are not reset by
// Flush.
func (r *SimpleResampler) Flush() ([]float64, error) {
return r.engine.Flush()
}

// Reset clears internal state.
// Reset discards all filter state, returning the resampler to its
// just-constructed condition. It is for starting an unrelated stream, not
// for use between chunks of one continuous stream: calling it mid-stream
// destroys the filter's history and causes audible clicks.
func (r *SimpleResampler) Reset() {
r.engine.Reset()
}

// GetRatio returns the resampling ratio.
// GetRatio returns the resampling ratio (outputRate / inputRate).
func (r *SimpleResampler) GetRatio() float64 {
return r.engine.GetRatio()
}
Expand All @@ -185,10 +204,20 @@ func (r *SimpleResampler) GetStatistics() map[string]int64 {
return r.engine.GetStatistics()
}

// Latency returns the resampler's startup deficit in output samples: the
// number of samples early Process calls withhold while the internal filter
// primes. Real-time users feeding fixed-size output buffers should prime
// their FIFO with this many samples of silence.
func (r *SimpleResampler) Latency() int {
return r.engine.Latency()
}

// presetToEngineQuality converts a QualityPreset to engine.Quality.
func presetToEngineQuality(preset QualityPreset) engine.Quality {
switch preset {
case QualityQuick, QualityLow:
case QualityQuick:
return engine.QualityQuick
case QualityLow:
return engine.QualityLow
case QualityMedium:
return engine.QualityMedium
Expand All @@ -200,7 +229,13 @@ func presetToEngineQuality(preset QualityPreset) engine.Quality {
}

// ResampleMono is a convenience function for one-shot mono resampling.
// It creates a resampler, processes the input, flushes, and returns the result.
// It creates a resampler, processes the input, flushes, and returns the
// result. Use this when:
// - You have the entire input available upfront (not streaming)
// - float64 precision is required (mastering, archival)
//
// For real-time or chunked streaming, use NewEngine with Process per chunk
// instead; see the streaming example.
func ResampleMono(input []float64, inputRate, outputRate float64, quality QualityPreset) ([]float64, error) {
r, err := NewEngine(inputRate, outputRate, quality)
if err != nil {
Expand Down Expand Up @@ -301,17 +336,28 @@ func DeinterleaveFromStereo(interleaved []float64) (left, right []float64) {
// returns float64), SimpleResamplerFloat32 keeps everything in float32,
// eliminating type conversion overhead.
//
// Streaming pattern: keep one persistent SimpleResamplerFloat32 per channel,
// call Process once per chunk of input, and call Flush once at the end of
// the stream.
//
// Example:
//
// r, err := resampler.NewEngineFloat32(44100, 48000, resampler.QualityHigh)
// if err != nil {
// log.Fatal(err)
// }
// for chunk := range audioChunks {
// output, _ := r.Process(chunk) // []float32 in, []float32 out
// output, err := r.Process(chunk) // []float32 in, []float32 out
// if err != nil {
// log.Fatal(err)
// }
// writeOutput(output)
// }
// final, _ := r.Flush() // Returns []float32!
// final, err := r.Flush() // Returns []float32!
// if err != nil {
// log.Fatal(err)
// }
// writeOutput(final)
type SimpleResamplerFloat32 struct {
engine *engine.Resampler[float32]
}
Expand All @@ -335,8 +381,13 @@ func NewEngineFloat32(inputRate, outputRate float64, quality QualityPreset) (*Si
return &SimpleResamplerFloat32{engine: r}, nil
}

// Process resamples the input samples.
// Process resamples one chunk of a single (mono) audio channel.
// Input and output are both float32, with no type conversion overhead.
// The output length varies from call to call: early calls may withhold up
// to Latency() samples while the internal filter primes, so len(output)
// does not track len(input)*GetRatio() on a per-call basis. The returned
// slice is owned by the caller; it is never aliased by the resampler and
// stays valid after subsequent calls.
func (r *SimpleResamplerFloat32) Process(input []float32) ([]float32, error) {
return r.engine.Process(input)
}
Expand Down Expand Up @@ -372,14 +423,25 @@ func (r *SimpleResamplerFloat32) EstimateOutput(inputLen int) int {
return int(float64(inputLen)*r.engine.GetRatio()) + estimateOutputMargin
}

// Flush returns any remaining buffered samples as float32.
// Flush drains the remaining buffered samples as float32 at end-of-stream.
// Unlike the main Resampler.Flush() which returns float64, this returns
// float32 for a consistent float32 workflow.
//
// Flush is end-of-stream only: pushing padding through the filter on every
// chunk instead of just the last one creates audible edge transients (issue
// #51). Flush is terminal. After it returns, the instance behaves like a
// fresh one for output purposes: a second Flush call returns an empty
// slice, and a subsequent Process call produces output bit-identical to a
// fresh instance. GetStatistics counters are cumulative and are not reset
// by Flush.
func (r *SimpleResamplerFloat32) Flush() ([]float32, error) {
return r.engine.Flush()
}

// Reset clears internal state, allowing the resampler to be reused.
// Reset discards all filter state, returning the resampler to its
// just-constructed condition. It is for starting an unrelated stream, not
// for use between chunks of one continuous stream: calling it mid-stream
// destroys the filter's history and causes audible clicks.
func (r *SimpleResamplerFloat32) Reset() {
r.engine.Reset()
}
Expand All @@ -394,14 +456,25 @@ func (r *SimpleResamplerFloat32) GetStatistics() map[string]int64 {
return r.engine.GetStatistics()
}

// Latency returns the resampler's startup deficit in output samples: the
// number of samples early Process calls withhold while the internal filter
// primes. Real-time users feeding fixed-size output buffers should prime
// their FIFO with this many samples of silence.
func (r *SimpleResamplerFloat32) Latency() int {
return r.engine.Latency()
}

// ResampleMonoFloat32 is a convenience function for one-shot mono resampling
// with float32 samples. It creates a resampler, processes the input, flushes,
// and returns the result.
//
// This is the float32 equivalent of ResampleMono. Use this when:
// - Your audio data is already in float32 format
// - You want ~2x SIMD throughput compared to float64
// - 32-bit precision is sufficient (most real-time applications)
// - You have the entire input available upfront (not streaming)
//
// For real-time or chunked streaming, use NewEngineFloat32 with Process per
// chunk instead; see the streaming example.
//
// For maximum precision (mastering, archival), use ResampleMono instead.
func ResampleMonoFloat32(input []float32, inputRate, outputRate float64, quality QualityPreset) ([]float32, error) {
Expand Down
17 changes: 12 additions & 5 deletions convenience_float32_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,18 @@ func TestSimpleResamplerFloat32_Process(t *testing.T) {
}
}

// TestSimpleResamplerFloat32_Flush verifies that Flush returns float32.
// TestSimpleResamplerFloat32_Flush verifies that Flush returns the buffered
// tail as finite float32 samples after a warm Process() call.
func TestSimpleResamplerFloat32_Flush(t *testing.T) {
r, err := NewEngineFloat32(44100, 48000, QualityHigh)
if err != nil {
t.Fatalf("NewEngineFloat32 failed: %v", err)
}

// Process some samples first
input := make([]float32, 1000)
// 4410 samples (0.1s) is well beyond QualityHigh's filter latency, so
// the delay line is guaranteed to hold a real buffered tail; Flush must
// drain it rather than return empty.
input := make([]float32, 4410)
for i := range input {
input[i] = float32(math.Sin(2 * math.Pi * 1000 * float64(i) / 44100))
}
Expand All @@ -99,9 +102,13 @@ func TestSimpleResamplerFloat32_Flush(t *testing.T) {
t.Fatalf("Flush failed: %v", err)
}

// Flushed samples should exist (filter has latency)
if len(flushed) == 0 {
t.Log("Flush returned empty (may be valid depending on filter design)")
t.Fatal("Flush returned no samples after a warm Process() call; expected the buffered tail to drain")
}
for i, v := range flushed {
if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
t.Fatalf("flushed[%d] = %v, want finite", i, v)
}
}
}

Expand Down
Loading
Loading