Skip to content

Commit c1453b2

Browse files
authored
Solidify playback API, rendering pipeline, and coverage (#16)
* Solidify playback API, rendering pipeline, and coverage - Added explicit Advance/Render API, enforced non-nil sampler in Tick, and migrated callers off Tick. - Pruned unused generics and reflection options, replacing them with typed options/interfaces. - Exposed read-only mixer accessor while keeping legacy pointer compatibility. - Extracted render pipeline stages and isolated OPL2 handling; split channel display into MVVM-style layers. - Documented sampler concurrency expectations and mixer ownership/callback behavior; refreshed examples for new sampler signature. - Expanded focused tests for sequencing vs rendering, option parsing, mixers/panning, filters/DSP, player lifecycle/render, output pipeline, voice components (sampler/envelopes/OPL2), period/frequency math, format loaders (IT/S3M/XM/MOD), registration wiring, tremor, tracing/logging, and utility helpers. - Fixed IT correctness quirks (pan clamping, panbrello/tremolo oscillator choice, disabled channel handling, random-wave delay, muted-but-processing channels). - Stabilized mixing by clamping denormals and matching echo filter semantics to Microsoft behavior. - Added tracker-version quirks framework for opt-in compatibility behaviors. * Add unit tests to github actions
1 parent e2ecbc3 commit c1453b2

File tree

178 files changed

+6919
-370
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

178 files changed

+6919
-370
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Playback Tests
2+
3+
on:
4+
push:
5+
paths:
6+
- '**'
7+
- '.github/workflows/playback-tests.yml'
8+
pull_request:
9+
paths:
10+
- '**'
11+
- '.github/workflows/playback-tests.yml'
12+
13+
jobs:
14+
test:
15+
name: Unit tests
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Go
22+
uses: actions/setup-go@v5
23+
with:
24+
go-version-file: go.mod
25+
check-latest: true
26+
cache: true
27+
28+
- name: Download modules
29+
run: go mod download
30+
31+
- name: Run tests
32+
run: go test ./...

channelstate.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,21 @@ import (
99

1010
// ChannelState is the information needed to make an instrument play
1111
type ChannelState[TPeriod types.Period, TVolume types.Volume, TPanning types.Panning] struct {
12-
Instrument instrument.InstrumentIntf
13-
Period TPeriod
14-
vol TVolume
15-
Pos sampling.Pos
16-
Pan TPanning
12+
inst instrument.InstrumentIntf
13+
period TPeriod
14+
vol TVolume
15+
pos sampling.Pos
16+
pan TPanning
1717
}
1818

1919
// Reset sets the render state to defaults
2020
func (s *ChannelState[TPeriod, TVolume, TPanning]) Reset() {
21-
s.Instrument = nil
21+
s.inst = nil
2222
var emptyPeriod TPeriod
23-
s.Period = emptyPeriod
24-
s.Pos = sampling.Pos{}
23+
s.period = emptyPeriod
24+
s.pos = sampling.Pos{}
2525
var emptyPan TPanning
26-
s.Pan = emptyPan
26+
s.pan = emptyPan
2727
}
2828

2929
func (s *ChannelState[TPeriod, TVolume, TPanning]) GetVolume() TVolume {
@@ -38,5 +38,37 @@ func (s *ChannelState[TPeriod, TVolume, TPanning]) SetVolume(vol TVolume) {
3838

3939
func (s *ChannelState[TPeriod, TVolume, TPanning]) NoteCut() {
4040
var empty TPeriod
41-
s.Period = empty
41+
s.period = empty
42+
}
43+
44+
func (s *ChannelState[TPeriod, TVolume, TPanning]) Instrument() instrument.InstrumentIntf {
45+
return s.inst
46+
}
47+
48+
func (s *ChannelState[TPeriod, TVolume, TPanning]) SetInstrument(inst instrument.InstrumentIntf) {
49+
s.inst = inst
50+
}
51+
52+
func (s *ChannelState[TPeriod, TVolume, TPanning]) Period() TPeriod {
53+
return s.period
54+
}
55+
56+
func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPeriod(p TPeriod) {
57+
s.period = p
58+
}
59+
60+
func (s *ChannelState[TPeriod, TVolume, TPanning]) Pos() sampling.Pos {
61+
return s.pos
62+
}
63+
64+
func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPos(pos sampling.Pos) {
65+
s.pos = pos
66+
}
67+
68+
func (s *ChannelState[TPeriod, TVolume, TPanning]) Pan() TPanning {
69+
return s.pan
70+
}
71+
72+
func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPan(p TPanning) {
73+
s.pan = p
4274
}

effect.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package playback
22

33
import (
44
"fmt"
5-
"reflect"
65

76
"github.com/gotracker/playback/index"
87
"github.com/gotracker/playback/period"
@@ -17,14 +16,13 @@ type Effect interface {
1716
}
1817

1918
type Effecter[TMemory song.ChannelMemory] interface {
20-
GetEffects(TMemory, period.Period) []Effect
19+
GetEffects(TMemory) []Effect
2120
}
2221

23-
func GetEffects[TPeriod period.Period, TMemory song.ChannelMemory, TChannelData song.ChannelData[TVolume], TGlobalVolume, TMixingVolume, TVolume song.Volume, TPanning song.Panning](mem TMemory, d TChannelData) []Effect {
22+
func GetEffects[TMemory song.ChannelMemory, TChannelData song.ChannelData[TVolume], TVolume song.Volume](mem TMemory, d TChannelData) []Effect {
2423
var e []Effect
2524
if eff, ok := any(d).(Effecter[TMemory]); ok {
26-
var p TPeriod
27-
e = eff.GetEffects(mem, p)
25+
e = eff.GetEffects(mem)
2826
}
2927
return e
3028
}
@@ -36,19 +34,23 @@ type EffectNamer interface {
3634
func GetEffectNames(e Effect) []string {
3735
if namer, ok := e.(EffectNamer); ok {
3836
return namer.Names()
39-
} else {
40-
typ := reflect.TypeOf(e)
41-
return []string{typ.Name()}
4237
}
38+
if s, ok := e.(fmt.Stringer); ok {
39+
name := s.String()
40+
if name != "" {
41+
return []string{name}
42+
}
43+
}
44+
return nil
4345
}
4446

4547
// CombinedEffect specifies multiple simultaneous effects into one
46-
type CombinedEffect[TPeriod period.Period, TGlobalVolume, TMixingVolume, TVolume song.Volume, TPanning song.Panning, TMemory song.ChannelMemory, TChannelData song.ChannelData[TVolume]] struct {
48+
type CombinedEffect[TPeriod period.Period, TGlobalVolume, TMixingVolume, TVolume song.Volume, TPanning song.Panning] struct {
4749
Effects []Effect
4850
}
4951

5052
// String returns the string for the effect list
51-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) String() string {
53+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) String() string {
5254
for _, eff := range e.Effects {
5355
s := fmt.Sprint(eff)
5456
if s != "" {
@@ -58,15 +60,15 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
5860
return ""
5961
}
6062

61-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) Names() []string {
63+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Names() []string {
6264
var names []string
6365
for _, eff := range e.Effects {
6466
names = append(names, GetEffectNames(eff)...)
6567
}
6668
return names
6769
}
6870

69-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) OrderStart(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
71+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) OrderStart(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
7072
for _, effect := range e.Effects {
7173
if err := m.DoInstructionOrderStart(ch, effect); err != nil {
7274
return err
@@ -75,7 +77,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
7577
return nil
7678
}
7779

78-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) RowStart(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
80+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) RowStart(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
7981
for _, effect := range e.Effects {
8082
if err := m.DoInstructionRowStart(ch, effect); err != nil {
8183
return err
@@ -84,7 +86,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
8486
return nil
8587
}
8688

87-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) Tick(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning], tick int) error {
89+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Tick(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning], tick int) error {
8890
for _, effect := range e.Effects {
8991
if err := m.DoInstructionTick(ch, effect); err != nil {
9092
return err
@@ -93,7 +95,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
9395
return nil
9496
}
9597

96-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) RowEnd(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
98+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) RowEnd(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
9799
for _, effect := range e.Effects {
98100
if err := m.DoInstructionRowEnd(ch, effect); err != nil {
99101
return err
@@ -102,7 +104,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
102104
return nil
103105
}
104106

105-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) OrderEnd(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
107+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) OrderEnd(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) error {
106108
for _, effect := range e.Effects {
107109
if err := m.DoInstructionOrderEnd(ch, effect); err != nil {
108110
return err
@@ -111,6 +113,6 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
111113
return nil
112114
}
113115

114-
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) TraceData() string {
116+
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) TraceData() string {
115117
return e.String()
116118
}

effect_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package playback
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
type stubEffecter struct{}
9+
type stubEffect struct{ label string }
10+
11+
func (s stubEffect) TraceData() string { return s.label }
12+
func (s stubEffect) String() string { return s.label }
13+
14+
type nameEffect struct{ names []string }
15+
16+
func (n nameEffect) TraceData() string { return "" }
17+
func (n nameEffect) Names() []string { return n.names }
18+
19+
func TestGetEffectNamesPrefersNames(t *testing.T) {
20+
e := nameEffect{names: []string{"x", "y"}}
21+
names := GetEffectNames(e)
22+
if fmt.Sprint(names) != "[x y]" {
23+
t.Fatalf("unexpected names: %v", names)
24+
}
25+
26+
s := stubEffect{label: "label"}
27+
names = GetEffectNames(s)
28+
if fmt.Sprint(names) != "[label]" {
29+
t.Fatalf("expected stringer name fallback, got %v", names)
30+
}
31+
}

filter/filter_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package filter
2+
3+
import (
4+
"math"
5+
"testing"
6+
7+
"github.com/gotracker/playback/frequency"
8+
"github.com/gotracker/playback/mixing/volume"
9+
)
10+
11+
func almostEqualVol(a, b volume.Volume, tol float64) bool {
12+
return math.Abs(float64(a-b)) <= tol
13+
}
14+
15+
// helper to compare two matrices of equal channel counts within tolerance
16+
func assertMatrixAlmostEqual(t *testing.T, got, want volume.Matrix, tol float64) {
17+
t.Helper()
18+
if got.Channels != want.Channels {
19+
t.Fatalf("channel mismatch: got %d want %d", got.Channels, want.Channels)
20+
}
21+
for i := 0; i < got.Channels; i++ {
22+
if !almostEqualVol(got.StaticMatrix[i], want.StaticMatrix[i], tol) {
23+
t.Fatalf("channel %d mismatch: got %v want %v", i, got.StaticMatrix[i], want.StaticMatrix[i])
24+
}
25+
}
26+
}
27+
28+
func TestAmigaLPFFilterProgression(t *testing.T) {
29+
f := NewAmigaLPF(frequency.Frequency(6550))
30+
31+
dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1}, Channels: 1}
32+
33+
first := f.Filter(dry)
34+
expectedFirst := dry.StaticMatrix[0] * f.a0
35+
if !almostEqualVol(first.StaticMatrix[0], expectedFirst, 1e-6) {
36+
t.Fatalf("first sample mismatch: got %v want %v", first.StaticMatrix[0], expectedFirst)
37+
}
38+
39+
second := f.Filter(dry)
40+
expectedSecond := dry.StaticMatrix[0]*f.a0 + expectedFirst*f.b0
41+
if !almostEqualVol(second.StaticMatrix[0], expectedSecond, 1e-6) {
42+
t.Fatalf("second sample mismatch: got %v want %v", second.StaticMatrix[0], expectedSecond)
43+
}
44+
}
45+
46+
func TestEchoFilterZeroDelayNoFeedback(t *testing.T) {
47+
echo := EchoFilter{
48+
EchoFilterSettings: EchoFilterSettings{
49+
WetDryMix: 0.5,
50+
Feedback: 0,
51+
LeftDelay: 0.125, // yields 1-sample delay at 4Hz playback rate
52+
RightDelay: 0.125,
53+
PanDelay: 0,
54+
},
55+
}
56+
echo.SetPlaybackRate(frequency.Frequency(4))
57+
58+
dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1, -1}, Channels: 2}
59+
60+
first := echo.Filter(dry)
61+
expectedFirst := dry.Apply(volume.Volume(0.5))
62+
assertMatrixAlmostEqual(t, first, expectedFirst, 1e-6)
63+
64+
second := echo.Filter(dry)
65+
assertMatrixAlmostEqual(t, second, dry, 1e-6)
66+
}
67+
68+
func TestResonantFilterBypassWhenWideOpen(t *testing.T) {
69+
rf := NewITResonantFilter(0xFF, 0x00, false, false)
70+
rf.SetPlaybackRate(frequency.Frequency(44100))
71+
72+
dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{0.25, -0.25}, Channels: 2}
73+
74+
wet := rf.Filter(dry)
75+
if wet != dry {
76+
t.Fatalf("expected bypass output to equal input: got %v want %v", wet, dry)
77+
}
78+
if rf.(*ResonantFilter).enabled {
79+
t.Fatalf("filter should be disabled for wide-open cutoff and zero resonance")
80+
}
81+
}
82+
83+
func TestResonantFilterAppliesCoefficients(t *testing.T) {
84+
rf := NewITResonantFilter(0x80|64, 0x80|32, false, false)
85+
rf.SetPlaybackRate(frequency.Frequency(48000))
86+
87+
dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1}, Channels: 1}
88+
89+
wet := rf.Filter(dry)
90+
expected := dry.StaticMatrix[0] * rf.(*ResonantFilter).a0
91+
if !almostEqualVol(wet.StaticMatrix[0], expected, 1e-5) {
92+
t.Fatalf("expected filtered output near %v, got %v", expected, wet.StaticMatrix[0])
93+
}
94+
}

filter/it_resonantfilter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package filter
33
import (
44
"math"
55

6+
"github.com/gotracker/playback/frequency"
67
"github.com/gotracker/playback/mixing/volume"
78

8-
"github.com/gotracker/playback/frequency"
99
"github.com/heucuva/optional"
1010
)
1111

format/common/basesong.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import (
44
"reflect"
55
"time"
66

7-
"github.com/gotracker/playback/mixing/volume"
8-
97
"github.com/gotracker/playback/index"
108
"github.com/gotracker/playback/instrument"
9+
"github.com/gotracker/playback/mixing/volume"
1110
"github.com/gotracker/playback/note"
1211
"github.com/gotracker/playback/player/machine/settings"
1312
"github.com/gotracker/playback/player/render"

format/common/format.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ func (Format) ConvertFeaturesToSettings(us *settings.UserSettings, features []fe
2929
us.Start.BPM = f.BPM
3030
case feature.IgnoreUnknownEffect:
3131
us.IgnoreUnknownEffect = f.Enabled
32+
case feature.QuirksMode:
33+
if prof, ok := f.Profile.Get(); ok {
34+
us.Quirks.Profile.Set(prof)
35+
}
36+
if linear, ok := f.LinearSlides.Get(); ok {
37+
us.Quirks.LinearSlidesOverride.Set(linear)
38+
}
3239
}
3340
}
3441
return nil

0 commit comments

Comments
 (0)