Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ jobs:
run: |
go build ./cmd/resample-wav
go build ./cmd/resample
go build ./cmd/analyze-filter
- name: Verify go.mod is tidy
run: |
go mod tidy
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- `GetLatency` on the `New(config)` path now reports the measured startup
deficit in output samples; it previously mixed rate domains across pipeline
stages and mis-reported multi-stage ratios (672 reported vs 703 measured at
44100 to 96000 QualityHigh; now within 2 samples). (#52)

### Changed

Expand All @@ -53,6 +57,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
interpolation (matching `New()` and the documented contract) instead of a
full FIR pipeline; latency drops accordingly. (#51)

### Removed

- The internal duplicate polyphase filter design path
(`internal/filter/polyphase.go`) and the `cmd/analyze-filter` diagnostic
that consumed it; the engine's design code in `internal/engine` is the
single polyphase implementation. (#55)
- The production-dead `LinearStage` from `internal/engine`; only its own
tests referenced it. (#55)

## [1.4.0] - 2026-05-29

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func main() {

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.
`Latency()` is available on `SimpleResampler` and `SimpleResamplerFloat32` (the `NewEngine`/`NewEngineFloat32` path). Resamplers built from `New(config)` expose the same figure through `GetLatency()` (and `GetInfo().Latency`): the startup deficit in output samples, accurate to within a few samples across the pipeline's stages, so it primes a FIFO the same way.

```go
r, err := resampling.NewEngineFloat32(44100, 48000, resampling.QualityHigh)
Expand Down
132 changes: 0 additions & 132 deletions cmd/analyze-filter/analyze_filter_gain.go

This file was deleted.

1 change: 0 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,3 @@ ignore:
- "examples/**"
- "internal/testutil/**"
- "cmd/resample/**"
- "cmd/analyze-filter/**"
39 changes: 28 additions & 11 deletions constant.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package resampler

import (
"fmt"
"math"
"sync"

pipelinepkg "github.com/tphakala/go-audio-resampler/internal/pipeline"
Expand Down Expand Up @@ -403,26 +404,42 @@ func (r *constantRateResampler) FlushMulti() ([][]float64, error) {
return output, nil
}

// GetLatency returns the total pipeline latency in samples.
// startupDeficitStage is the accounting contract stages provide for accurate
// latency reporting: the un-rounded startup deficit in the stage's own
// output-sample domain. Compile-time assertions in stages.go keep every
// production stage type on this path.
type startupDeficitStage interface {
StartupDeficit() float64
}

// GetLatency returns the pipeline's startup deficit in output samples: how
// many samples early Process calls withhold while the stage filters prime.
// Each stage's deficit is converted into the final output rate domain
// through the downstream stages' ratios before summing.
func (r *constantRateResampler) GetLatency() int {
if r.pipeline == nil || len(r.channels) == 0 {
return 0
}

// Use the first channel's stages to calculate latency
ch := r.channels[0]
if ch == nil || len(ch.stages) == 0 {
return 0
}

totalLatency := 0
for _, stage := range ch.stages {
// Account for stage processing latency and ratio change
stageLatency := int(float64(stage.GetLatency()) * stage.GetRatio())
totalLatency += stageLatency
total := 0.0
for i, stage := range ch.stages {
var deficit float64
if s, ok := stage.(startupDeficitStage); ok {
deficit = s.StartupDeficit()
} else {
// Fallback for stages without deficit accounting: group-delay
// heuristic converted to the stage's output domain.
deficit = float64(stage.GetLatency()) * stage.GetRatio()
}
for _, downstream := range ch.stages[i+1:] {
deficit *= downstream.GetRatio()
}
total += deficit
}

return totalLatency
return int(math.Ceil(total))
}

// Reset clears all internal state.
Expand Down
35 changes: 0 additions & 35 deletions internal/engine/buffer_integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,38 +438,3 @@ func TestCubicStage_BufferIntegrity(t *testing.T) {

t.Log("Cubic stage buffer integrity verified")
}

// TestLinearStage_BufferIntegrity verifies LinearStage doesn't have buffer issues.
func TestLinearStage_BufferIntegrity(t *testing.T) {
stage := NewLinearStage(2.0)

// Generate test signal
input := make([]float64, 1000)
for i := range input {
input[i] = math.Sin(2.0 * math.Pi * float64(i) / 100)
}

// First process call
output1, err := stage.Process(input)
require.NoError(t, err, "First Process() failed")

// Save values
savedOutput := make([]float64, len(output1))
copy(savedOutput, output1)

// Second process call
input2 := make([]float64, 500)
for i := range input2 {
input2[i] = math.Cos(2.0 * math.Pi * float64(i) / 50)
}
_, err = stage.Process(input2)
require.NoError(t, err, "Second Process() failed")

// Verify output1 was not corrupted
for i, expected := range savedOutput {
assert.InDelta(t, expected, output1[i], 1e-15,
"output1[%d] was corrupted", i)
}

t.Log("Linear stage buffer integrity verified")
}
12 changes: 0 additions & 12 deletions internal/engine/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,3 @@ const (
// Memory usage estimate for cubic stage (bytes)
cubicMemoryUsage = 64
)

// Linear interpolation constants
const (
// Linear interpolation uses 2-point window
linearInterpolationPoints = 2

// Linear interpolation latency
linearLatencySamples = 1

// Memory usage estimate for linear stage (bytes)
linearMemoryUsage = 32
)
Loading