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
32 changes: 32 additions & 0 deletions .github/workflows/playback-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Playback Tests

on:
push:
paths:
- '**'
- '.github/workflows/playback-tests.yml'
pull_request:
paths:
- '**'
- '.github/workflows/playback-tests.yml'

jobs:
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
cache: true

- name: Download modules
run: go mod download

- name: Run tests
run: go test ./...
52 changes: 42 additions & 10 deletions channelstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ import (

// ChannelState is the information needed to make an instrument play
type ChannelState[TPeriod types.Period, TVolume types.Volume, TPanning types.Panning] struct {
Instrument instrument.InstrumentIntf
Period TPeriod
vol TVolume
Pos sampling.Pos
Pan TPanning
inst instrument.InstrumentIntf
period TPeriod
vol TVolume
pos sampling.Pos
pan TPanning
}

// Reset sets the render state to defaults
func (s *ChannelState[TPeriod, TVolume, TPanning]) Reset() {
s.Instrument = nil
s.inst = nil
var emptyPeriod TPeriod
s.Period = emptyPeriod
s.Pos = sampling.Pos{}
s.period = emptyPeriod
s.pos = sampling.Pos{}
var emptyPan TPanning
s.Pan = emptyPan
s.pan = emptyPan
}

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

func (s *ChannelState[TPeriod, TVolume, TPanning]) NoteCut() {
var empty TPeriod
s.Period = empty
s.period = empty
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) Instrument() instrument.InstrumentIntf {
return s.inst
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) SetInstrument(inst instrument.InstrumentIntf) {
s.inst = inst
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) Period() TPeriod {
return s.period
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPeriod(p TPeriod) {
s.period = p
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) Pos() sampling.Pos {
return s.pos
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPos(pos sampling.Pos) {
s.pos = pos
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) Pan() TPanning {
return s.pan
}

func (s *ChannelState[TPeriod, TVolume, TPanning]) SetPan(p TPanning) {
s.pan = p
}
36 changes: 19 additions & 17 deletions effect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package playback

import (
"fmt"
"reflect"

"github.com/gotracker/playback/index"
"github.com/gotracker/playback/period"
Expand All @@ -17,14 +16,13 @@ type Effect interface {
}

type Effecter[TMemory song.ChannelMemory] interface {
GetEffects(TMemory, period.Period) []Effect
GetEffects(TMemory) []Effect
}

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 {
func GetEffects[TMemory song.ChannelMemory, TChannelData song.ChannelData[TVolume], TVolume song.Volume](mem TMemory, d TChannelData) []Effect {
var e []Effect
if eff, ok := any(d).(Effecter[TMemory]); ok {
var p TPeriod
e = eff.GetEffects(mem, p)
e = eff.GetEffects(mem)
}
return e
}
Expand All @@ -36,19 +34,23 @@ type EffectNamer interface {
func GetEffectNames(e Effect) []string {
if namer, ok := e.(EffectNamer); ok {
return namer.Names()
} else {
typ := reflect.TypeOf(e)
return []string{typ.Name()}
}
if s, ok := e.(fmt.Stringer); ok {
name := s.String()
if name != "" {
return []string{name}
}
}
return nil
}

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

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

func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) Names() []string {
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Names() []string {
var names []string
for _, eff := range e.Effects {
names = append(names, GetEffectNames(eff)...)
}
return names
}

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

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

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 {
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Tick(ch index.Channel, m machine.Machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning], tick int) error {
for _, effect := range e.Effects {
if err := m.DoInstructionTick(ch, effect); err != nil {
return err
Expand All @@ -93,7 +95,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning,
return nil
}

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

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

func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, TMemory, TChannelData]) TraceData() string {
func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) TraceData() string {
return e.String()
}
31 changes: 31 additions & 0 deletions effect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package playback

import (
"fmt"
"testing"
)

type stubEffecter struct{}
type stubEffect struct{ label string }

func (s stubEffect) TraceData() string { return s.label }
func (s stubEffect) String() string { return s.label }

type nameEffect struct{ names []string }

func (n nameEffect) TraceData() string { return "" }
func (n nameEffect) Names() []string { return n.names }

func TestGetEffectNamesPrefersNames(t *testing.T) {
e := nameEffect{names: []string{"x", "y"}}
names := GetEffectNames(e)
if fmt.Sprint(names) != "[x y]" {
t.Fatalf("unexpected names: %v", names)
}

s := stubEffect{label: "label"}
names = GetEffectNames(s)
if fmt.Sprint(names) != "[label]" {
t.Fatalf("expected stringer name fallback, got %v", names)
}
}
94 changes: 94 additions & 0 deletions filter/filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package filter

import (
"math"
"testing"

"github.com/gotracker/playback/frequency"
"github.com/gotracker/playback/mixing/volume"
)

func almostEqualVol(a, b volume.Volume, tol float64) bool {
return math.Abs(float64(a-b)) <= tol
}

// helper to compare two matrices of equal channel counts within tolerance
func assertMatrixAlmostEqual(t *testing.T, got, want volume.Matrix, tol float64) {
t.Helper()
if got.Channels != want.Channels {
t.Fatalf("channel mismatch: got %d want %d", got.Channels, want.Channels)
}
for i := 0; i < got.Channels; i++ {
if !almostEqualVol(got.StaticMatrix[i], want.StaticMatrix[i], tol) {
t.Fatalf("channel %d mismatch: got %v want %v", i, got.StaticMatrix[i], want.StaticMatrix[i])
}
}
}

func TestAmigaLPFFilterProgression(t *testing.T) {
f := NewAmigaLPF(frequency.Frequency(6550))

dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1}, Channels: 1}

first := f.Filter(dry)
expectedFirst := dry.StaticMatrix[0] * f.a0
if !almostEqualVol(first.StaticMatrix[0], expectedFirst, 1e-6) {
t.Fatalf("first sample mismatch: got %v want %v", first.StaticMatrix[0], expectedFirst)
}

second := f.Filter(dry)
expectedSecond := dry.StaticMatrix[0]*f.a0 + expectedFirst*f.b0
if !almostEqualVol(second.StaticMatrix[0], expectedSecond, 1e-6) {
t.Fatalf("second sample mismatch: got %v want %v", second.StaticMatrix[0], expectedSecond)
}
}

func TestEchoFilterZeroDelayNoFeedback(t *testing.T) {
echo := EchoFilter{
EchoFilterSettings: EchoFilterSettings{
WetDryMix: 0.5,
Feedback: 0,
LeftDelay: 0.125, // yields 1-sample delay at 4Hz playback rate
RightDelay: 0.125,
PanDelay: 0,
},
}
echo.SetPlaybackRate(frequency.Frequency(4))

dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1, -1}, Channels: 2}

first := echo.Filter(dry)
expectedFirst := dry.Apply(volume.Volume(0.5))
assertMatrixAlmostEqual(t, first, expectedFirst, 1e-6)

second := echo.Filter(dry)
assertMatrixAlmostEqual(t, second, dry, 1e-6)
}

func TestResonantFilterBypassWhenWideOpen(t *testing.T) {
rf := NewITResonantFilter(0xFF, 0x00, false, false)
rf.SetPlaybackRate(frequency.Frequency(44100))

dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{0.25, -0.25}, Channels: 2}

wet := rf.Filter(dry)
if wet != dry {
t.Fatalf("expected bypass output to equal input: got %v want %v", wet, dry)
}
if rf.(*ResonantFilter).enabled {
t.Fatalf("filter should be disabled for wide-open cutoff and zero resonance")
}
}

func TestResonantFilterAppliesCoefficients(t *testing.T) {
rf := NewITResonantFilter(0x80|64, 0x80|32, false, false)
rf.SetPlaybackRate(frequency.Frequency(48000))

dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1}, Channels: 1}

wet := rf.Filter(dry)
expected := dry.StaticMatrix[0] * rf.(*ResonantFilter).a0
if !almostEqualVol(wet.StaticMatrix[0], expected, 1e-5) {
t.Fatalf("expected filtered output near %v, got %v", expected, wet.StaticMatrix[0])
}
}
2 changes: 1 addition & 1 deletion filter/it_resonantfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package filter
import (
"math"

"github.com/gotracker/playback/frequency"
"github.com/gotracker/playback/mixing/volume"

"github.com/gotracker/playback/frequency"
"github.com/heucuva/optional"
)

Expand Down
3 changes: 1 addition & 2 deletions format/common/basesong.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import (
"reflect"
"time"

"github.com/gotracker/playback/mixing/volume"

"github.com/gotracker/playback/index"
"github.com/gotracker/playback/instrument"
"github.com/gotracker/playback/mixing/volume"
"github.com/gotracker/playback/note"
"github.com/gotracker/playback/player/machine/settings"
"github.com/gotracker/playback/player/render"
Expand Down
7 changes: 7 additions & 0 deletions format/common/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ func (Format) ConvertFeaturesToSettings(us *settings.UserSettings, features []fe
us.Start.BPM = f.BPM
case feature.IgnoreUnknownEffect:
us.IgnoreUnknownEffect = f.Enabled
case feature.QuirksMode:
if prof, ok := f.Profile.Get(); ok {
us.Quirks.Profile.Set(prof)
}
if linear, ok := f.LinearSlides.Get(); ok {
us.Quirks.LinearSlidesOverride.Set(linear)
}
}
}
return nil
Expand Down
Loading