Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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