diff --git a/.github/workflows/playback-tests.yml b/.github/workflows/playback-tests.yml new file mode 100644 index 0000000..4353ceb --- /dev/null +++ b/.github/workflows/playback-tests.yml @@ -0,0 +1,32 @@ +name: 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 ./... diff --git a/README.md b/README.md index 9b1225d..d0ebb6f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # playback +![Tests](https://github.com/gotracker/playback/workflows/Tests/badge.svg) + ## What is it? It's an embeddable tracked music player written in Go. diff --git a/channelstate.go b/channelstate.go index e06b2b6..28b3047 100644 --- a/channelstate.go +++ b/channelstate.go @@ -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 { @@ -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 } diff --git a/effect.go b/effect.go index 4cb28fb..b78503f 100644 --- a/effect.go +++ b/effect.go @@ -2,7 +2,6 @@ package playback import ( "fmt" - "reflect" "github.com/gotracker/playback/index" "github.com/gotracker/playback/period" @@ -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 } @@ -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 != "" { @@ -58,7 +60,7 @@ 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)...) @@ -66,7 +68,7 @@ func (e CombinedEffect[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning, 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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() } diff --git a/effect_test.go b/effect_test.go new file mode 100644 index 0000000..353da50 --- /dev/null +++ b/effect_test.go @@ -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) + } +} diff --git a/filter/filter_test.go b/filter/filter_test.go new file mode 100644 index 0000000..3931470 --- /dev/null +++ b/filter/filter_test.go @@ -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]) + } +} diff --git a/filter/it_resonantfilter.go b/filter/it_resonantfilter.go index 9ecc2d8..c3edff2 100644 --- a/filter/it_resonantfilter.go +++ b/filter/it_resonantfilter.go @@ -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" ) diff --git a/format/common/basesong.go b/format/common/basesong.go index 78a0bb0..375fd51 100644 --- a/format/common/basesong.go +++ b/format/common/basesong.go @@ -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" diff --git a/format/common/format.go b/format/common/format.go index bdbb9e4..1a48a54 100644 --- a/format/common/format.go +++ b/format/common/format.go @@ -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 diff --git a/format/common/format_test.go b/format/common/format_test.go new file mode 100644 index 0000000..b93a767 --- /dev/null +++ b/format/common/format_test.go @@ -0,0 +1,140 @@ +package common + +import ( + "testing" + + "github.com/gotracker/playback/player/feature" + "github.com/gotracker/playback/player/machine/settings" + optional "github.com/heucuva/optional" +) + +func TestConvertFeaturesToSettings(t *testing.T) { + us := settings.UserSettings{} + + features := []feature.Feature{ + feature.SongLoop{Count: 2}, + feature.StartOrderAndRow{}, + feature.PlayUntilOrderAndRow{Order: 3, Row: 5}, + feature.SetDefaultTempo{Tempo: 6}, + feature.SetDefaultBPM{BPM: 7}, + feature.IgnoreUnknownEffect{Enabled: true}, + } + + if err := (Format{}).ConvertFeaturesToSettings(&us, features); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if us.SongLoopCount != 2 { + t.Fatalf("expected SongLoopCount=2, got %d", us.SongLoopCount) + } + if o, set := us.Start.Order.Get(); set { + t.Fatalf("expected Start.Order to remain unset, got %v", o) + } + if r, set := us.Start.Row.Get(); set { + t.Fatalf("expected Start.Row to remain unset, got %v", r) + } + if o, set := us.PlayUntil.Order.Get(); !set || o != 3 { + t.Fatalf("expected PlayUntil.Order=3, got %v set=%v", o, set) + } + if r, set := us.PlayUntil.Row.Get(); !set || r != 5 { + t.Fatalf("expected PlayUntil.Row=5, got %v set=%v", r, set) + } + if us.Start.Tempo != 6 { + t.Fatalf("expected Start.Tempo=6, got %d", us.Start.Tempo) + } + if us.Start.BPM != 7 { + t.Fatalf("expected Start.BPM=7, got %d", us.Start.BPM) + } + if !us.IgnoreUnknownEffect { + t.Fatalf("expected IgnoreUnknownEffect=true") + } +} + +func TestConvertFeaturesSetsStartOrderAndRow(t *testing.T) { + us := settings.UserSettings{} + + features := []feature.Feature{ + feature.StartOrderAndRow{ + Order: optional.NewValue(4), + Row: optional.NewValue(2), + }, + } + + if err := (Format{}).ConvertFeaturesToSettings(&us, features); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if o, set := us.Start.Order.Get(); !set || o != 4 { + t.Fatalf("expected Start.Order=4 set, got %v set=%v", o, set) + } + if r, set := us.Start.Row.Get(); !set || r != 2 { + t.Fatalf("expected Start.Row=2 set, got %v set=%v", r, set) + } +} + +type unknownFeature struct{} + +func TestConvertFeaturesIgnoresUnknown(t *testing.T) { + us := settings.UserSettings{} + us.SongLoopCount = 5 + + features := []feature.Feature{unknownFeature{}} + + if err := (Format{}).ConvertFeaturesToSettings(&us, features); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if us.SongLoopCount != 5 { + t.Fatalf("expected SongLoopCount to remain 5, got %d", us.SongLoopCount) + } +} + +func TestConvertFeaturesSetsQuirksMode(t *testing.T) { + us := settings.UserSettings{} + + features := []feature.Feature{ + feature.QuirksMode{ + Profile: optional.NewValue("ft2.09"), + LinearSlides: optional.NewValue(true), + }, + } + + if err := (Format{}).ConvertFeaturesToSettings(&us, features); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if prof, ok := us.Quirks.Profile.Get(); !ok || prof != "ft2.09" { + t.Fatalf("expected quirks profile ft2.09 set, got %v set=%v", prof, ok) + } + if linear, ok := us.Quirks.LinearSlidesOverride.Get(); !ok || !linear { + t.Fatalf("expected linear slides override set true, got %v set=%v", linear, ok) + } +} + +func TestConvertFeaturesLeavesDefaultsOnEmpty(t *testing.T) { + us := settings.UserSettings{} + us.SongLoopCount = 4 + us.Start.Tempo = 3 + us.Start.BPM = 2 + us.IgnoreUnknownEffect = true + + if err := (Format{}).ConvertFeaturesToSettings(&us, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if us.SongLoopCount != 4 { + t.Fatalf("expected SongLoopCount to stay 4, got %d", us.SongLoopCount) + } + if us.Start.Tempo != 3 || us.Start.BPM != 2 { + t.Fatalf("expected Start tempo/BPM unchanged, got tempo=%d bpm=%d", us.Start.Tempo, us.Start.BPM) + } + if o, set := us.Start.Order.Get(); set { + t.Fatalf("expected Start.Order to remain unset, got %v", o) + } + if r, set := us.Start.Row.Get(); set { + t.Fatalf("expected Start.Row to remain unset, got %v", r) + } + if !us.IgnoreUnknownEffect { + t.Fatalf("expected IgnoreUnknownEffect to remain true") + } +} diff --git a/format/common/linearslides.go b/format/common/linearslides.go new file mode 100644 index 0000000..52bd319 --- /dev/null +++ b/format/common/linearslides.go @@ -0,0 +1,16 @@ +package common + +import "github.com/gotracker/playback/player/feature" + +// ResolveLinearSlides returns the desired linear slides setting, allowing +// user-provided quirks configuration to override the file's default flag. +func ResolveLinearSlides(defaultLinear bool, features []feature.Feature) bool { + for _, feat := range features { + if qm, ok := feat.(feature.QuirksMode); ok { + if linear, ok := qm.LinearSlides.Get(); ok { + return linear + } + } + } + return defaultLinear +} diff --git a/format/common/linearslides_test.go b/format/common/linearslides_test.go new file mode 100644 index 0000000..228b183 --- /dev/null +++ b/format/common/linearslides_test.go @@ -0,0 +1,23 @@ +package common + +import ( + "testing" + + "github.com/gotracker/playback/player/feature" + "github.com/heucuva/optional" +) + +func TestResolveLinearSlidesUsesOverride(t *testing.T) { + features := []feature.Feature{ + feature.QuirksMode{LinearSlides: optional.NewValue(false)}, + } + if linear := ResolveLinearSlides(true, features); linear { + t.Fatalf("expected override to disable linear slides") + } +} + +func TestResolveLinearSlidesFallsBackToDefault(t *testing.T) { + if linear := ResolveLinearSlides(false, nil); linear { + t.Fatalf("expected default value to be used when no override present") + } +} diff --git a/format/format_test.go b/format/format_test.go new file mode 100644 index 0000000..c5b7bdc --- /dev/null +++ b/format/format_test.go @@ -0,0 +1,53 @@ +package format + +import ( + "bytes" + "os" + "testing" +) + +func TestLoadFromReaderUnknownFormat(t *testing.T) { + _, _, err := LoadFromReader("zzz", bytes.NewReader(nil)) + if err == nil || err.Error() != "unsupported format" { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + +func TestLoadFromReaderNoFormatFallsThrough(t *testing.T) { + _, _, err := LoadFromReader("", bytes.NewReader([]byte("not a module"))) + if err == nil { + t.Fatalf("expected error for unsupported data") + } + if err.Error() != "unsupported format" { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + +func TestLoadNonexistentFileReturnsNotExist(t *testing.T) { + _, _, err := Load("this_file_should_not_exist_12345.s3m") + if err == nil { + t.Fatalf("expected error for missing file") + } + if !os.IsNotExist(err) { + t.Fatalf("expected os.IsNotExist, got %v", err) + } +} + +func TestLoadUnsupportedFormatFromExistingFile(t *testing.T) { + f, err := os.CreateTemp("", "unsupported*.bin") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + name := f.Name() + _, _ = f.WriteString("not a module") + _ = f.Close() + t.Cleanup(func() { _ = os.Remove(name) }) + + _, _, err = Load(name) + if err == nil { + t.Fatalf("expected unsupported format error") + } + if err.Error() != "unsupported format" { + t.Fatalf("expected unsupported format, got %v", err) + } +} diff --git a/format/it/channel/data.go b/format/it/channel/data.go index d18a5a4..795723b 100644 --- a/format/it/channel/data.go +++ b/format/it/channel/data.go @@ -5,13 +5,13 @@ import ( "strings" itfile "github.com/gotracker/goaudiofile/music/tracked/it" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback" itNote "github.com/gotracker/playback/format/it/note" itPanning "github.com/gotracker/playback/format/it/panning" itVolume "github.com/gotracker/playback/format/it/volume" "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/note" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine" diff --git a/format/it/channel/effect_panbrello.go b/format/it/channel/effect_panbrello.go index c038dcc..0de6d59 100644 --- a/format/it/channel/effect_panbrello.go +++ b/format/it/channel/effect_panbrello.go @@ -15,7 +15,7 @@ import ( type Panbrello[TPeriod period.Period] DataEffect // 'Y' func (e Panbrello[TPeriod]) String() string { - return fmt.Sprintf("H%0.2x", DataEffect(e)) + return fmt.Sprintf("Y%0.2x", DataEffect(e)) } func (e Panbrello[TPeriod]) Tick(ch index.Channel, m machine.Machine[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning], tick int) error { diff --git a/format/it/channel/effect_portavolslide.go b/format/it/channel/effect_portavolslide.go index 62fc9ad..788be58 100644 --- a/format/it/channel/effect_portavolslide.go +++ b/format/it/channel/effect_portavolslide.go @@ -11,7 +11,7 @@ import ( // PortaVolumeSlide defines a portamento-to-note combined with a volume slide effect type PortaVolumeSlide[TPeriod period.Period] struct { // 'L' - playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] } // NewPortaVolumeSlide creates a new PortaVolumeSlide object diff --git a/format/it/channel/effect_vibratovolslide.go b/format/it/channel/effect_vibratovolslide.go index 02504c1..af33f07 100644 --- a/format/it/channel/effect_vibratovolslide.go +++ b/format/it/channel/effect_vibratovolslide.go @@ -11,7 +11,7 @@ import ( // VibratoVolumeSlide defines a combination vibrato and volume slide effect type VibratoVolumeSlide[TPeriod period.Period] struct { // 'K' - playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] } // NewVibratoVolumeSlide creates a new VibratoVolumeSlide object diff --git a/format/it/channel/effectfactory.go b/format/it/channel/effectfactory.go index d4fbe0b..79444ed 100644 --- a/format/it/channel/effectfactory.go +++ b/format/it/channel/effectfactory.go @@ -14,7 +14,7 @@ type EffectIT = playback.Effect // VolEff is a combined effect that includes a volume effect and a standard effect type VolEff[TPeriod period.Period] struct { - playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] eff EffectIT } diff --git a/format/it/channel/machine.go b/format/it/channel/machine.go index 76bf0b2..035a8ee 100644 --- a/format/it/channel/machine.go +++ b/format/it/channel/machine.go @@ -9,7 +9,7 @@ import ( ) func withOscillatorDo[TPeriod period.Period](ch index.Channel, m machine.Machine[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning], speed int, depth float32, osc machine.Oscillator, fn func(value float32) error) error { - value, err := m.GetNextChannelWavetableValue(ch, speed, depth, machine.OscillatorVibrato) + value, err := m.GetNextChannelWavetableValue(ch, speed, depth, osc) if err != nil { return err } diff --git a/format/it/channel/memory_test.go b/format/it/channel/memory_test.go new file mode 100644 index 0000000..69a74a3 --- /dev/null +++ b/format/it/channel/memory_test.go @@ -0,0 +1,39 @@ +package channel + +import "testing" + +func TestMemoryEFGLinkModeSharesRegisters(t *testing.T) { + mem := Memory{Shared: &SharedMemory{EFGLinkMode: true}} + if got := mem.PortaDown(0x31); got != 0x31 { + t.Fatalf("expected porta down to store 0x31, got 0x%02x", got) + } + if got := mem.PortaUp(0); got != 0x31 { + t.Fatalf("expected porta up to reuse shared value, got 0x%02x", got) + } + if got := mem.PortaToNote(0); got != 0x31 { + t.Fatalf("expected porta-to-note to reuse shared value, got 0x%02x", got) + } +} + +func TestMemoryStartOrderReset(t *testing.T) { + mem := Memory{Shared: &SharedMemory{ResetMemoryAtStartOfOrder0: true}} + mem.VolumeSlide(0x12) + mem.PortaDown(0x34) + mem.PortaUp(0x56) + mem.Vibrato(0x78) + + mem.StartOrder0() + + if got, _ := mem.VolumeSlide(0); got != 0 { + t.Fatalf("expected volume slide memory reset to 0, got 0x%02x", got) + } + if got := mem.PortaDown(0); got != 0 { + t.Fatalf("expected porta down memory reset to 0, got 0x%02x", got) + } + if got := mem.PortaUp(0); got != 0 { + t.Fatalf("expected porta up memory reset to 0, got 0x%02x", got) + } + if got, _ := mem.Vibrato(0); got != 0 { + t.Fatalf("expected vibrato memory reset to 0, got 0x%02x", got) + } +} diff --git a/format/it/filter/factory_test.go b/format/it/filter/factory_test.go new file mode 100644 index 0000000..f596781 --- /dev/null +++ b/format/it/filter/factory_test.go @@ -0,0 +1,60 @@ +package filter + +import ( + "testing" + + pf "github.com/gotracker/playback/filter" + "github.com/gotracker/playback/frequency" +) + +func TestFactoryAmigaLPF(t *testing.T) { + f, err := Factory("amigalpf", frequency.Frequency(8363), nil) + if err != nil { + t.Fatalf("expected nil error for amigalpf: %v", err) + } + if f == nil { + t.Fatalf("expected non-nil filter for amigalpf") + } +} + +func TestFactoryITResonantParamsValidation(t *testing.T) { + params := pf.ITResonantFilterParams{Cutoff: 0x40, Resonance: 0x20, ExtendedFilterRange: true, Highpass: false} + f, err := Factory("itresonant", frequency.Frequency(44100), params) + if err != nil { + t.Fatalf("expected nil error for itresonant: %v", err) + } + if f == nil { + t.Fatalf("expected filter instance for itresonant") + } + + if _, err := Factory("itresonant", frequency.Frequency(44100), "bad"); err == nil { + t.Fatalf("expected type assertion error for wrong params type") + } +} + +func TestFactoryEchoParamsValidation(t *testing.T) { + params := pf.EchoFilterSettings{WetDryMix: 0.5, Feedback: 0.3, LeftDelay: 0.05, RightDelay: 0.06, PanDelay: 0.7} + f, err := Factory("echo", frequency.Frequency(48000), params) + if err != nil { + t.Fatalf("expected nil error for echo: %v", err) + } + if f == nil { + t.Fatalf("expected filter instance for echo") + } + + if _, err := Factory("echo", frequency.Frequency(48000), 123); err == nil { + t.Fatalf("expected type assertion error for wrong echo params") + } +} + +func TestFactoryEmptyAndUnknown(t *testing.T) { + if f, err := Factory("", frequency.Frequency(0), nil); err != nil { + t.Fatalf("expected empty name to succeed: %v", err) + } else if f != nil { + t.Fatalf("expected nil filter for empty name") + } + + if _, err := Factory("nope", frequency.Frequency(0), nil); err == nil { + t.Fatalf("expected error for unsupported filter") + } +} diff --git a/format/it/it_test.go b/format/it/it_test.go new file mode 100644 index 0000000..67302c1 --- /dev/null +++ b/format/it/it_test.go @@ -0,0 +1,39 @@ +package it + +import ( + "bytes" + "testing" + + itFeature "github.com/gotracker/playback/format/it/feature" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/feature" +) + +func TestLoadFromReaderRejectsInvalidData(t *testing.T) { + _, err := IT.LoadFromReader(bytes.NewReader([]byte("bad")), nil) + if err == nil { + t.Fatalf("expected error for invalid IT data") + } +} + +func TestConvertFeaturesToSettings(t *testing.T) { + var us settings.UserSettings + features := []feature.Feature{ + itFeature.LongChannelOutput{Enabled: true}, + itFeature.NewNoteActions{Enabled: false}, + feature.SongLoop{Count: 3}, + } + + if err := IT.ConvertFeaturesToSettings(&us, features); err != nil { + t.Fatalf("ConvertFeaturesToSettings error: %v", err) + } + if !us.LongChannelOutput { + t.Fatalf("expected LongChannelOutput set") + } + if us.EnableNewNoteActions { + t.Fatalf("expected EnableNewNoteActions cleared") + } + if us.SongLoopCount != 3 { + t.Fatalf("expected SongLoopCount=3, got %d", us.SongLoopCount) + } +} diff --git a/format/it/layout/channelsetting_test.go b/format/it/layout/channelsetting_test.go new file mode 100644 index 0000000..b20a0be --- /dev/null +++ b/format/it/layout/channelsetting_test.go @@ -0,0 +1,62 @@ +package layout + +import ( + "testing" + + "github.com/gotracker/playback/filter" + itPanning "github.com/gotracker/playback/format/it/panning" + itVolume "github.com/gotracker/playback/format/it/volume" +) + +func TestChannelSettingGetters(t *testing.T) { + cs := ChannelSetting{ + Enabled: true, + Muted: false, + OutputChannelNum: 2, + InitialVolume: itVolume.Volume(10), + ChannelVolume: itVolume.FineVolume(20), + PanEnabled: true, + InitialPanning: itPanning.Panning(0x10), + Vol0OptEnabled: true, + } + + if !cs.IsEnabled() || cs.IsMuted() { + t.Fatalf("expected enabled and unmuted") + } + if got := cs.GetOutputChannelNum(); got != 2 { + t.Fatalf("unexpected output channel: %d", got) + } + if got := cs.GetInitialVolume(); got != 10 { + t.Fatalf("unexpected initial volume: %d", got) + } + if got := cs.GetMixingVolume(); got != 20 { + t.Fatalf("unexpected mixing volume: %d", got) + } + if got := cs.GetInitialPanning(); got != 0x10 { + t.Fatalf("unexpected initial panning: %d", got) + } + if !cs.IsPanEnabled() { + t.Fatalf("expected pan enabled") + } + vo := cs.GetVol0OptimizationSettings() + if !vo.Enabled || vo.MaxRowsAt0 != 3 { + t.Fatalf("unexpected vol0 optimization settings: %+v", vo) + } +} + +func TestChannelSettingDefaultPanningWhenDisabled(t *testing.T) { + cs := ChannelSetting{PanEnabled: false, InitialPanning: itPanning.Panning(0x40)} + if got := cs.GetInitialPanning(); got != itPanning.DefaultPanning { + t.Fatalf("expected default panning when disabled, got %d", got) + } +} + +func TestChannelSettingFilterDefaults(t *testing.T) { + cs := ChannelSetting{} + if got := cs.GetDefaultFilterInfo(); got != (filter.Info{}) { + t.Fatalf("expected empty filter info, got %+v", got) + } + if cs.IsDefaultFilterEnabled() { + t.Fatalf("expected default filter disabled") + } +} diff --git a/format/it/layout/song.go b/format/it/layout/song.go index 24fdd17..c34dd83 100644 --- a/format/it/layout/song.go +++ b/format/it/layout/song.go @@ -67,8 +67,8 @@ func (s Song[TPeriod]) GetInstrument(instID int, st note.Semitone) (instrument.I } func (s Song[TPeriod]) GetRowRenderStringer(row song.Row, channels int, longFormat bool) render.RowStringer { - rt := render.NewRowText[channel.Data[TPeriod]](channels, longFormat) - rowData := make([]channel.Data[TPeriod], channels) + vm := render.NewRowViewModel[channel.Data[TPeriod]](channels) + rowData := vm.Channels song.ForEachRowChannel(row, func(ch index.Channel, d song.ChannelData[itVolume.Volume]) (bool, error) { if int(ch) >= channels || !s.ChannelSettings[ch].Enabled || s.ChannelSettings[ch].Muted { return true, nil @@ -76,8 +76,8 @@ func (s Song[TPeriod]) GetRowRenderStringer(row song.Row, channels int, longForm rowData[ch] = d.(channel.Data[TPeriod]) return true, nil }) - rt.Channels = rowData - return rt + vm.Channels = rowData + return render.FormatRowText(vm, longFormat) } func (s Song[TPeriod]) ForEachChannel(enabledOnly bool, fn func(ch index.Channel) (bool, error)) error { diff --git a/format/it/load/instrument.go b/format/it/load/instrument.go index d1c24c6..214225b 100644 --- a/format/it/load/instrument.go +++ b/format/it/load/instrument.go @@ -8,8 +8,17 @@ import ( "math" itfile "github.com/gotracker/goaudiofile/music/tracked/it" + "github.com/heucuva/optional" + + "github.com/gotracker/playback/filter" + itNote "github.com/gotracker/playback/format/it/note" + itPanning "github.com/gotracker/playback/format/it/panning" + itVolume "github.com/gotracker/playback/format/it/volume" "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/instrument" "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/note" + oscillatorImpl "github.com/gotracker/playback/oscillator" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/feature" "github.com/gotracker/playback/util" @@ -20,15 +29,6 @@ import ( "github.com/gotracker/playback/voice/pcm" "github.com/gotracker/playback/voice/pitchpan" "github.com/gotracker/playback/voice/types" - "github.com/heucuva/optional" - - "github.com/gotracker/playback/filter" - itNote "github.com/gotracker/playback/format/it/note" - itPanning "github.com/gotracker/playback/format/it/panning" - itVolume "github.com/gotracker/playback/format/it/volume" - "github.com/gotracker/playback/instrument" - "github.com/gotracker/playback/note" - oscillatorImpl "github.com/gotracker/playback/oscillator" ) type convInst[TPeriod period.Period] struct { @@ -242,7 +242,16 @@ func convertVolEnvValue(v int8) itVolume.Volume { } func convertPanEnvValue(v int8) itPanning.Panning { - return itPanning.Panning(int(v) + 128) + // IT pan envelope nodes are stored as -32..+32 with 0 at center (ITTECH.TXT) + // Map to playback panning range 0..MaxPanning with center at DefaultPanning. + p := (int(v) + 32) * 4 + if p < 0 { + p = 0 + } + if p > int(itPanning.MaxPanning) { + p = int(itPanning.MaxPanning) + } + return itPanning.Panning(p) } func convertPitchEnvValue(v int8) types.PitchFiltValue { diff --git a/format/it/load/instrument_test.go b/format/it/load/instrument_test.go new file mode 100644 index 0000000..3daf001 --- /dev/null +++ b/format/it/load/instrument_test.go @@ -0,0 +1,25 @@ +package load + +import ( + "testing" + + itPanning "github.com/gotracker/playback/format/it/panning" +) + +func TestConvertPanEnvValueScalesToPanRange(t *testing.T) { + tests := []struct { + in int8 + want itPanning.Panning + }{ + {-32, 0}, + {0, itPanning.DefaultPanning}, + {32, itPanning.MaxPanning}, + } + + for _, tt := range tests { + got := convertPanEnvValue(tt.in) + if got != tt.want { + t.Fatalf("convertPanEnvValue(%d) = %d, want %d", tt.in, got, tt.want) + } + } +} diff --git a/format/it/load/itformat.go b/format/it/load/itformat.go index fc042cf..e62add0 100644 --- a/format/it/load/itformat.go +++ b/format/it/load/itformat.go @@ -27,7 +27,7 @@ import ( "github.com/gotracker/playback/song" ) -func moduleHeaderToHeader(fh *itfile.ModuleHeader) (*layout.Header, error) { +func moduleHeaderToHeader(fh *itfile.ModuleHeader, linearSlides bool) (*layout.Header, error) { if fh == nil { return nil, errors.New("file header is nil") } @@ -37,7 +37,7 @@ func moduleHeaderToHeader(fh *itfile.ModuleHeader) (*layout.Header, error) { InitialTempo: int(fh.InitialTempo), GlobalVolume: itVolume.FineVolume(fh.GlobalVolume), MixingVolume: itVolume.FineVolume(fh.MixingVolume), - LinearFreqSlides: fh.Flags.IsLinearSlides(), + LinearFreqSlides: linearSlides, InitialOrder: 0, } switch { @@ -91,20 +91,19 @@ func convertItPattern[TPeriod period.Period](pkt itfile.PackedPattern, channels } func convertItFileToSong(f *itfile.File, features []feature.Feature) (song.Data, error) { - if f.Head.Flags.IsLinearSlides() { - return convertItFileToTypedSong[period.Linear](f, features) - } else { - return convertItFileToTypedSong[period.Amiga](f, features) + linearSlides := common.ResolveLinearSlides(f.Head.Flags.IsLinearSlides(), features) + if linearSlides { + return convertItFileToTypedSong[period.Linear](f, features, linearSlides) } + return convertItFileToTypedSong[period.Amiga](f, features, linearSlides) } -func convertItFileToTypedSong[TPeriod period.Period](f *itfile.File, features []feature.Feature) (*layout.Song[TPeriod], error) { - h, err := moduleHeaderToHeader(&f.Head) +func convertItFileToTypedSong[TPeriod period.Period](f *itfile.File, features []feature.Feature, linearFrequencySlides bool) (*layout.Song[TPeriod], error) { + h, err := moduleHeaderToHeader(&f.Head, linearFrequencySlides) if err != nil { return nil, err } - linearFrequencySlides := f.Head.Flags.IsLinearSlides() oldEffectMode := f.Head.Flags.IsOldEffects() efgLinkMode := f.Head.Flags.IsEFGLinking() stereoMode := f.Head.Flags.IsStereo() @@ -199,14 +198,18 @@ func convertItFileToTypedSong[TPeriod period.Period](f *itfile.File, features [] channels := make([]layout.ChannelSetting, lastEnabledChannel+1) for chNum := range channels { + panByte := f.Head.ChannelPan[chNum] + disabled := (panByte & 0x80) != 0 + pan := itPanning.Panning(panByte &^ 0x80) + cs := layout.ChannelSetting{ OutputChannelNum: chNum, Enabled: true, - Muted: false, + Muted: disabled, InitialVolume: itVolume.Volume(itVolume.DefaultItVolume), ChannelVolume: min(itVolume.FineVolume(f.Head.ChannelVol[chNum]*2), itVolume.MaxItFineVolume), PanEnabled: stereoMode, - InitialPanning: itPanning.Panning(f.Head.ChannelPan[chNum]), + InitialPanning: pan, Memory: channel.Memory{ Shared: &sharedMem, }, diff --git a/format/it/note/note_test.go b/format/it/note/note_test.go new file mode 100644 index 0000000..ad84640 --- /dev/null +++ b/format/it/note/note_test.go @@ -0,0 +1,37 @@ +package note + +import ( + "testing" + + itfile "github.com/gotracker/goaudiofile/music/tracked/it" + pnote "github.com/gotracker/playback/note" +) + +func TestFromItNoteSpecials(t *testing.T) { + cases := []struct { + name string + in itfile.Note + exp pnote.Note + }{ + {"note off", itfile.Note(255), pnote.ReleaseNote{}}, + {"note cut", itfile.Note(254), pnote.StopNote{}}, + {"note fade", itfile.Note(120), pnote.FadeoutNote{}}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := FromItNote(tt.in); got != tt.exp { + t.Fatalf("FromItNote(%d) = %#v, want %#v", tt.in, got, tt.exp) + } + }) + } +} + +func TestFromItNoteNormal(t *testing.T) { + in := itfile.Note(48) + exp := pnote.Normal(pnote.Semitone(48)) + + if got := FromItNote(in); got != exp { + t.Fatalf("FromItNote(%d) = %#v, want %#v", in, got, exp) + } +} diff --git a/format/it/oscillator/factory_test.go b/format/it/oscillator/factory_test.go new file mode 100644 index 0000000..6e90cab --- /dev/null +++ b/format/it/oscillator/factory_test.go @@ -0,0 +1,31 @@ +package oscillator + +import "testing" + +func TestOscillatorFactoryKnown(t *testing.T) { + for _, name := range []string{"vibrato", "tremolo", "panbrello", "autovibrato"} { + osc, err := OscillatorFactory(name) + if err != nil { + t.Fatalf("expected nil error for %s: %v", name, err) + } + if osc == nil { + t.Fatalf("expected oscillator for %s", name) + } + } +} + +func TestOscillatorFactoryEmpty(t *testing.T) { + osc, err := OscillatorFactory("") + if err != nil { + t.Fatalf("expected nil error for empty name: %v", err) + } + if osc != nil { + t.Fatalf("expected nil oscillator for empty name") + } +} + +func TestOscillatorFactoryUnknown(t *testing.T) { + if _, err := OscillatorFactory("nope"); err == nil { + t.Fatalf("expected error for unsupported oscillator") + } +} diff --git a/format/it/panning/panning.go b/format/it/panning/panning.go index 1a8aef4..9968124 100644 --- a/format/it/panning/panning.go +++ b/format/it/panning/panning.go @@ -60,7 +60,24 @@ func FromItPanning(pos itfile.PanValue) panning.Position { if pos.IsDisabled() { return panning.CenterAhead } - return panning.MakeStereoPosition(pos.Value(), 0, 1) + + if pos.IsSurround() { + return panning.SurroundPosition + } + + // Don't use pos.Value() - it panics on out-of-range values + pv := pos &^ 128 + var value float32 + switch { + case pv <= 64: + value = float32(pv) / 64 + case pv == 100: + value = 0.5 + default: + value = 1 + } + + return panning.MakeStereoPosition(value, 0, 1) } // ToItPanning returns the it panning value for a radian panning position diff --git a/format/it/panning/panning_test.go b/format/it/panning/panning_test.go new file mode 100644 index 0000000..e33e523 --- /dev/null +++ b/format/it/panning/panning_test.go @@ -0,0 +1,54 @@ +package panning + +import ( + "testing" + + itfile "github.com/gotracker/goaudiofile/music/tracked/it" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/voice/types" +) + +func TestFromItPanningDisabledUsesCenterAhead(t *testing.T) { + pos := FromItPanning(itfile.PanValue(0x80)) + if pos != panning.CenterAhead { + t.Fatalf("expected disabled panning to map to CenterAhead, got %+v", pos) + } +} + +func TestItPanningRoundTrip(t *testing.T) { + source := itfile.PanValue(32) + pos := FromItPanning(source) + if pos.Angle == 0 || pos.Distance != 1 { + t.Fatalf("unexpected stereo position %+v", pos) + } + back := ToItPanning(pos) + if back != source { + t.Fatalf("expected round-trip to preserve panning, got %d", back) + } +} + +func TestItPanningClampsOutOfRangeRight(t *testing.T) { + pos := FromItPanning(itfile.PanValue(0x70)) + back := ToItPanning(pos) + if back != 64 { + t.Fatalf("expected out-of-range pan to clamp to 64, got %d", back) + } +} + +func TestPanningClampOperations(t *testing.T) { + if got := Panning(10).FMA(2, 5); got != 25 { + t.Fatalf("expected FMA to yield 25, got %d", got) + } + + if got := Panning(250).FMA(2, 0); got != MaxPanning { + t.Fatalf("expected FMA to clamp to MaxPanning, got %d", got) + } + + if got := Panning(0).AddDelta(types.PanDelta(-5)); got != 0 { + t.Fatalf("expected AddDelta to clamp at 0, got %d", got) + } + + if got := Panning(240).AddDelta(types.PanDelta(20)); got != MaxPanning { + t.Fatalf("expected AddDelta to clamp to MaxPanning, got %d", got) + } +} diff --git a/format/it/period/converter_test.go b/format/it/period/converter_test.go new file mode 100644 index 0000000..463e18c --- /dev/null +++ b/format/it/period/converter_test.go @@ -0,0 +1,30 @@ +package period + +import ( + "math" + "testing" + + "github.com/gotracker/playback/format/it/system" + "github.com/gotracker/playback/period" +) + +func TestConvertersConfigured(t *testing.T) { + amiga, ok := AmigaConverter.(period.AmigaConverter) + if !ok { + t.Fatalf("expected AmigaConverter to be period.AmigaConverter") + } + if amiga.MinPeriod != 1 || amiga.MaxPeriod != math.MaxUint16 { + t.Fatalf("unexpected AmigaConverter bounds: min %d max %d", amiga.MinPeriod, amiga.MaxPeriod) + } + if amiga.System != system.ITSystem { + t.Fatalf("unexpected AmigaConverter system") + } + + linear, ok := LinearConverter.(period.LinearConverter) + if !ok { + t.Fatalf("expected LinearConverter to be period.LinearConverter") + } + if linear.System != system.ITSystem { + t.Fatalf("unexpected LinearConverter system") + } +} diff --git a/format/it/settings/machine.go b/format/it/settings/machine.go index f1b4d80..49162ae 100644 --- a/format/it/settings/machine.go +++ b/format/it/settings/machine.go @@ -1,45 +1,28 @@ package settings import ( - itFilter "github.com/gotracker/playback/format/it/filter" - itOscillator "github.com/gotracker/playback/format/it/oscillator" itPanning "github.com/gotracker/playback/format/it/panning" - itPeriod "github.com/gotracker/playback/format/it/period" itVolume "github.com/gotracker/playback/format/it/volume" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/quirks" ) func GetMachineSettings[TPeriod period.Period]() *settings.MachineSettings[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] { var p TPeriod switch any(p).(type) { case period.Amiga: - return any(&amigaMachine).(*settings.MachineSettings[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) + return any(amigaMachine).(*settings.MachineSettings[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) case period.Linear: - return any(&linearMachine).(*settings.MachineSettings[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) + return any(linearMachine).(*settings.MachineSettings[TPeriod, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) default: panic("unsupported machine type") } } var ( - amigaMachine = settings.MachineSettings[period.Amiga, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ - PeriodConverter: itPeriod.AmigaConverter, - GetFilterFactory: itFilter.Factory, - GetVibratoFactory: itOscillator.VibratoFactory, - GetTremoloFactory: itOscillator.TremoloFactory, - GetPanbrelloFactory: itOscillator.PanbrelloFactory, - VoiceFactory: amigaVoiceFactory, - OPL2Enabled: false, - } + itProfile = quirks.ProfileIT214 - linearMachine = settings.MachineSettings[period.Linear, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ - PeriodConverter: itPeriod.LinearConverter, - GetFilterFactory: itFilter.Factory, - GetVibratoFactory: itOscillator.VibratoFactory, - GetTremoloFactory: itOscillator.TremoloFactory, - GetPanbrelloFactory: itOscillator.PanbrelloFactory, - VoiceFactory: linearVoiceFactory, - OPL2Enabled: false, - } + amigaMachine = quirks.GetITMachineSettingsAmiga(itProfile, amigaVoiceFactory) + linearMachine = quirks.GetITMachineSettingsLinear(itProfile, linearVoiceFactory) ) diff --git a/format/it/settings/machine_test.go b/format/it/settings/machine_test.go new file mode 100644 index 0000000..36cbb4f --- /dev/null +++ b/format/it/settings/machine_test.go @@ -0,0 +1,54 @@ +package settings + +import ( + "testing" + + "github.com/gotracker/playback/period" +) + +func TestGetMachineSettingsAmiga(t *testing.T) { + ms := GetMachineSettings[period.Amiga]() + if ms != amigaMachine { + t.Fatalf("expected amiga machine settings pointer") + } + if ms.PeriodConverter != amigaMachine.PeriodConverter { + t.Fatalf("expected amiga period converter") + } + if ms.VoiceFactory != amigaMachine.VoiceFactory { + t.Fatalf("expected amiga voice factory") + } + if ms.OPL2Enabled { + t.Fatalf("unexpected OPL2 enabled for amiga machine") + } +} + +func TestGetMachineSettingsLinear(t *testing.T) { + ms := GetMachineSettings[period.Linear]() + if ms != linearMachine { + t.Fatalf("expected linear machine settings pointer") + } + if ms.PeriodConverter != linearMachine.PeriodConverter { + t.Fatalf("expected linear period converter") + } + if ms.VoiceFactory != linearMachine.VoiceFactory { + t.Fatalf("expected linear voice factory") + } + if ms.OPL2Enabled { + t.Fatalf("unexpected OPL2 enabled for linear machine") + } +} + +type unsupportedPeriod struct{} + +func (unsupportedPeriod) IsInvalid() bool { return false } + +func TestGetMachineSettingsUnsupportedPanics(t *testing.T) { + t.Helper() + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic for unsupported period type") + } + }() + + _ = GetMachineSettings[unsupportedPeriod]() +} diff --git a/format/it/system/system_test.go b/format/it/system/system_test.go new file mode 100644 index 0000000..dee809c --- /dev/null +++ b/format/it/system/system_test.go @@ -0,0 +1,46 @@ +package system + +import ( + "testing" + + "github.com/gotracker/playback/note" +) + +func TestITBaseClockCalculation(t *testing.T) { + if ITBaseClock != DefaultC5SampleRate*C5Period { + t.Fatalf("expected ITBaseClock to equal DefaultC5SampleRate*C5Period, got %v", ITBaseClock) + } +} + +func TestITSystemValues(t *testing.T) { + s := ITSystem + if s.GetBaseClock() != ITBaseClock { + t.Fatalf("unexpected base clock: %v", s.GetBaseClock()) + } + if s.GetCommonRate() != DefaultC5SampleRate { + t.Fatalf("unexpected common rate: %v", s.GetCommonRate()) + } + if s.GetCommonPeriod() != C5Period { + t.Fatalf("unexpected common period: %d", s.GetCommonPeriod()) + } + if s.GetMaxPastNotesPerChannel() != 1 { + t.Fatalf("unexpected max past notes: %d", s.GetMaxPastNotesPerChannel()) + } + if got := s.GetOctaveShift(); got != 0 { + t.Fatalf("expected zero octave shift, got %d", got) + } +} + +func TestITSystemSemitonePeriods(t *testing.T) { + s := ITSystem + period, ok := s.GetSemitonePeriod(note.KeyC) + if !ok { + t.Fatalf("expected semitone period for KeyC") + } + if period != semitonePeriodTable[0] { + t.Fatalf("unexpected semitone period for KeyC: %d", period) + } + if _, ok := s.GetSemitonePeriod(note.KeyInvalid1); ok { + t.Fatalf("expected invalid key to report missing semitone period") + } +} diff --git a/format/it/voice/voice_test.go b/format/it/voice/voice_test.go new file mode 100644 index 0000000..41c9761 --- /dev/null +++ b/format/it/voice/voice_test.go @@ -0,0 +1,89 @@ +package voice + +import ( + "testing" + + itPanning "github.com/gotracker/playback/format/it/panning" + itPeriod "github.com/gotracker/playback/format/it/period" + itVolume "github.com/gotracker/playback/format/it/volume" + "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/period" + voiceCore "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/autovibrato" + "github.com/gotracker/playback/voice/pcm" +) + +func makeTestInstrument() instrument.Instrument[period.Linear, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] { + sample := pcm.NewSampleNative([]volume.Matrix{{StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}}, 1, 2) + + return instrument.Instrument[period.Linear, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ + Static: instrument.StaticValues[period.Linear, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ + PC: itPeriod.LinearConverter, + Volume: itVolume.Volume(32), + AutoVibrato: autovibrato.AutoVibratoConfig[period.Linear]{ + PC: itPeriod.LinearConverter, + FactoryName: "vibrato", + }, + }, + Inst: &instrument.PCM[itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{Sample: sample}, + SampleRate: 8363, + } +} + +type testVoice interface { + voiceCore.RenderSampler[period.Linear] + Setup(*instrument.Instrument[period.Linear, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) error +} + +func makeVoice() testVoice { + cfg := voiceCore.VoiceConfig[period.Linear, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ + PC: itPeriod.LinearConverter, + InitialVolume: itVolume.Volume(32), + InitialMixing: itVolume.FineVolume(64), + PanEnabled: true, + InitialPan: itPanning.DefaultPanning, + } + return New[period.Linear](cfg).(testVoice) +} + +func TestVoiceSetupAndSample(t *testing.T) { + v := makeVoice() + inst := makeTestInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + if v.IsDone() { + t.Fatalf("expected voice active after setup") + } + + if err := v.Tick(); err != nil { + t.Fatalf("tick error: %v", err) + } + + samp := v.GetSample(sampling.Pos{}) + if samp.Channels != 2 { + t.Fatalf("expected stereo sample, got %d channels", samp.Channels) + } + if fv := v.GetFinalVolume(); fv <= 0 { + t.Fatalf("expected final volume > 0, got %v", fv) + } +} + +func TestVoiceStopMarksDone(t *testing.T) { + v := makeVoice() + inst := makeTestInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + v.Stop() + if !v.IsDone() { + t.Fatalf("expected voice to be done after stop") + } + if fv := v.GetFinalVolume(); fv != 0 { + t.Fatalf("expected final volume to be 0 after stop, got %v", fv) + } +} diff --git a/format/it/volume/volume_test.go b/format/it/volume/volume_test.go new file mode 100644 index 0000000..fafb978 --- /dev/null +++ b/format/it/volume/volume_test.go @@ -0,0 +1,105 @@ +package volume + +import ( + "math" + "testing" + + itfile "github.com/gotracker/goaudiofile/music/tracked/it" + mixvol "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/voice/types" +) + +func TestVolumeConversionsAndSentinels(t *testing.T) { + if got := FromItVolume(itfile.Volume(MaxItVolume)); got != 1 { + t.Fatalf("expected it volume 64 to convert to 1, got %v", got) + } + + if !Volume(0xff).IsUseInstrumentVol() { + t.Fatalf("expected 0xff to be use-instrument sentinel") + } + if Volume(65).IsInvalid() == false { + t.Fatalf("expected volumes over 64 (except sentinel) to be invalid") + } + if Volume(0xff).IsInvalid() { + t.Fatalf("sentinel volume should not be invalid") + } + + if got := ToItVolume(mixvol.VolumeUseInstVol); got != Volume(0xff) { + t.Fatalf("expected use-instrument sentinel") + } + if got := ToItVolume(-0.1); got != 0 { + t.Fatalf("expected negative volumes to clamp to 0, got %d", got) + } + if got := ToItVolume(2); got != Volume(MaxItVolume) { + t.Fatalf("expected volumes over 1 to clamp to MaxItVolume, got %d", got) + } + if got := ToItVolume(0.5); got != Volume(MaxItVolume/2) { + t.Fatalf("expected 0.5 to convert to 32, got %d", got) + } +} + +func TestVolumeArithmeticClamps(t *testing.T) { + if got := Volume(60).FMA(2, 0); got != Volume(MaxItVolume) { + t.Fatalf("expected FMA clamp to MaxItVolume, got %d", got) + } + + if got := Volume(0xff).FMA(2, 1); got != Volume(0xff) { + t.Fatalf("expected FMA to preserve instrument sentinel, got %d", got) + } + + if got := Volume(3).AddDelta(types.VolumeDelta(-5)); got != 0 { + t.Fatalf("expected AddDelta to clamp at 0, got %d", got) + } + + if got := Volume(60).AddDelta(types.VolumeDelta(10)); got != Volume(MaxItVolume) { + t.Fatalf("expected AddDelta to clamp at MaxItVolume, got %d", got) + } +} + +func TestFineVolumeConversionsAndClamps(t *testing.T) { + if FineVolume(0xff).IsInvalid() { + t.Fatalf("sentinel fine volume should not be invalid") + } + if FineVolume(MaxItFineVolume+1).IsInvalid() == false { + t.Fatalf("expected fine volumes over max (except sentinel) to be invalid") + } + if !FineVolume(0xff).IsUseInstrumentVol() { + t.Fatalf("expected 0xff to be use-instrument sentinel") + } + + if got := ToItFineVolume(mixvol.VolumeUseInstVol); got != FineVolume(0xff) { + t.Fatalf("expected fine volume use-instrument sentinel, got %d", got) + } + if got := ToItFineVolume(-0.2); got != 0 { + t.Fatalf("expected negative fine volumes to clamp to 0, got %d", got) + } + if got := ToItFineVolume(2); got != MaxItFineVolume { + t.Fatalf("expected fine volumes over 1 to clamp to max, got %d", got) + } + + if got := FineVolume(120).FMA(2, 0); got != MaxItFineVolume { + t.Fatalf("expected fine FMA clamp to max, got %d", got) + } + if got := FineVolume(0xff).FMA(2, 1); got != FineVolume(0xff) { + t.Fatalf("expected fine FMA to preserve sentinel, got %d", got) + } + + if got := FineVolume(1).AddDelta(types.VolumeDelta(-5)); got != 0 { + t.Fatalf("expected fine AddDelta to clamp at 0, got %d", got) + } + if got := FineVolume(125).AddDelta(types.VolumeDelta(10)); got != MaxItFineVolume { + t.Fatalf("expected fine AddDelta to clamp at max, got %d", got) + } +} + +func TestFromVolPan(t *testing.T) { + const epsilon = 1e-6 + + if got := FromVolPan(32); math.Abs(float64(got-0.5)) > epsilon { + t.Fatalf("expected volpan 32 to be ~0.5, got %v", got) + } + + if got := FromVolPan(200); got != mixvol.VolumeUseInstVol { + t.Fatalf("expected volpan over max to use instrument volume sentinel, got %v", got) + } +} diff --git a/format/mod/mod_test.go b/format/mod/mod_test.go new file mode 100644 index 0000000..0bc3a87 --- /dev/null +++ b/format/mod/mod_test.go @@ -0,0 +1,13 @@ +package mod + +import ( + "bytes" + "testing" +) + +func TestLoadFromReaderRejectsInvalidData(t *testing.T) { + _, err := MOD.LoadFromReader(bytes.NewReader([]byte("bad")), nil) + if err == nil { + t.Fatalf("expected error for invalid MOD data") + } +} diff --git a/format/s3m/channel/data.go b/format/s3m/channel/data.go index f5d2f20..8bfef3a 100644 --- a/format/s3m/channel/data.go +++ b/format/s3m/channel/data.go @@ -75,7 +75,7 @@ func (d Data) Channel() uint8 { return d.What.Channel() } -func (d Data) GetEffects(mem *Memory, p period.Period) []playback.Effect { +func (d Data) GetEffects(mem *Memory) []playback.Effect { if d.HasCommand() { if e := EffectFactory(mem, d); e != nil { return []playback.Effect{e} diff --git a/format/s3m/channel/effect_portavolslide.go b/format/s3m/channel/effect_portavolslide.go index 04b51af..168a507 100644 --- a/format/s3m/channel/effect_portavolslide.go +++ b/format/s3m/channel/effect_portavolslide.go @@ -11,7 +11,7 @@ import ( // PortaVolumeSlide defines a portamento-to-note combined with a volume slide effect type PortaVolumeSlide struct { // 'L' - playback.CombinedEffect[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning, *Memory, Data] + playback.CombinedEffect[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning] } // NewPortaVolumeSlide creates a new PortaVolumeSlide object diff --git a/format/s3m/channel/effect_vibratovolslide.go b/format/s3m/channel/effect_vibratovolslide.go index 8a9e377..011d6d2 100644 --- a/format/s3m/channel/effect_vibratovolslide.go +++ b/format/s3m/channel/effect_vibratovolslide.go @@ -11,7 +11,7 @@ import ( // VibratoVolumeSlide defines a combination vibrato and volume slide effect type VibratoVolumeSlide struct { // 'K' - playback.CombinedEffect[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning, *Memory, Data] + playback.CombinedEffect[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning] } // NewVibratoVolumeSlide creates a new VibratoVolumeSlide object diff --git a/format/s3m/channel/memory_test.go b/format/s3m/channel/memory_test.go new file mode 100644 index 0000000..0c2816a --- /dev/null +++ b/format/s3m/channel/memory_test.go @@ -0,0 +1,51 @@ +package channel + +import "testing" + +func TestMemoryVibratoNibbles(t *testing.T) { + var mem Memory + vx, vy := mem.Vibrato(0x4f) + if vx != 0x04 || vy != 0x0f { + t.Fatalf("expected vibrato nibbles 0x04/0x0f, got 0x%02x/0x%02x", vx, vy) + } + // reuse last values + vx, vy = mem.Vibrato(0) + if vx != 0x04 || vy != 0x0f { + t.Fatalf("expected vibrato memory reuse, got 0x%02x/0x%02x", vx, vy) + } +} + +func TestMemoryStartOrderReset(t *testing.T) { + mem := Memory{Shared: &SharedMemory{ResetMemoryAtStartOfOrder0: true}} + mem.Porta(0x22) + mem.Vibrato(0x34) + mem.Tremolo(0x56) + mem.SampleOffset(0x78) + mem.TempoDecrease(0x9a) + mem.TempoIncrease(0xbc) + mem.LastNonZero(0xde) + + mem.StartOrder0() + + if got := mem.Porta(0); got != 0 { + t.Fatalf("expected porta reset to 0, got 0x%02x", got) + } + if vx, vy := mem.Vibrato(0); vx != 0 || vy != 0 { + t.Fatalf("expected vibrato reset, got 0x%02x/0x%02x", vx, vy) + } + if vx, vy := mem.Tremolo(0); vx != 0 || vy != 0 { + t.Fatalf("expected tremolo reset, got 0x%02x/0x%02x", vx, vy) + } + if got := mem.SampleOffset(0); got != 0 { + t.Fatalf("expected sample offset reset, got 0x%02x", got) + } + if got := mem.TempoDecrease(0); got != 0 { + t.Fatalf("expected tempo decrease reset, got 0x%02x", got) + } + if got := mem.TempoIncrease(0); got != 0 { + t.Fatalf("expected tempo increase reset, got 0x%02x", got) + } + if got := mem.LastNonZero(0); got != 0 { + t.Fatalf("expected last non-zero reset, got 0x%02x", got) + } +} diff --git a/format/s3m/filter/factory_test.go b/format/s3m/filter/factory_test.go new file mode 100644 index 0000000..1a22d45 --- /dev/null +++ b/format/s3m/filter/factory_test.go @@ -0,0 +1,29 @@ +package filter + +import ( + "testing" + + "github.com/gotracker/playback/frequency" +) + +func TestFactoryAmigaLPF(t *testing.T) { + f, err := Factory("amigalpf", frequency.Frequency(8363), nil) + if err != nil { + t.Fatalf("expected nil error for amigalpf: %v", err) + } + if f == nil { + t.Fatalf("expected non-nil filter for amigalpf") + } +} + +func TestFactoryEmptyAndUnknown(t *testing.T) { + if f, err := Factory("", frequency.Frequency(0), nil); err != nil { + t.Fatalf("expected empty name to succeed: %v", err) + } else if f != nil { + t.Fatalf("expected nil filter for empty name") + } + + if _, err := Factory("nope", frequency.Frequency(0), nil); err == nil { + t.Fatalf("expected error for unsupported filter") + } +} diff --git a/format/s3m/layout/channelsetting_test.go b/format/s3m/layout/channelsetting_test.go new file mode 100644 index 0000000..f4aa6ee --- /dev/null +++ b/format/s3m/layout/channelsetting_test.go @@ -0,0 +1,71 @@ +package layout + +import ( + "testing" + + s3mfile "github.com/gotracker/goaudiofile/music/tracked/s3m" + "github.com/gotracker/playback/filter" + "github.com/gotracker/playback/format/s3m/channel" + s3mPanning "github.com/gotracker/playback/format/s3m/panning" + s3mVolume "github.com/gotracker/playback/format/s3m/volume" + "github.com/gotracker/playback/index" +) + +func TestChannelSettingGetters(t *testing.T) { + cs := ChannelSetting{ + Enabled: true, + Muted: false, + OutputChannelNum: 3, + Category: s3mfile.ChannelCategoryOPL2Melody, + InitialVolume: s3mVolume.Volume(12), + PanEnabled: true, + InitialPanning: s3mPanning.Panning(0x0a), + Memory: channel.Memory{Shared: &channel.SharedMemory{ZeroVolOptimization: true}}, + DefaultFilter: filter.Info{Name: "amigalpf"}, + } + + if !cs.IsEnabled() || cs.IsMuted() { + t.Fatalf("expected enabled and unmuted") + } + if got := cs.GetOutputChannelNum(); got != 3 { + t.Fatalf("unexpected output channel: %d", got) + } + if got := cs.GetInitialVolume(); got != 12 { + t.Fatalf("unexpected initial volume: %d", got) + } + if got := cs.GetMixingVolume(); got != s3mVolume.FineVolume(0x7f) { + t.Fatalf("unexpected mixing volume: %d", got) + } + if got := cs.GetInitialPanning(); got != 0x0a { + t.Fatalf("unexpected initial panning: %d", got) + } + if !cs.IsPanEnabled() { + t.Fatalf("expected pan enabled") + } + if !cs.IsDefaultFilterEnabled() { + t.Fatalf("expected default filter enabled") + } + vo := cs.GetVol0OptimizationSettings() + if !vo.Enabled || vo.MaxRowsAt0 != 3 { + t.Fatalf("unexpected vol0 optimization settings: %+v", vo) + } + if ch := cs.GetOPLChannel(); ch != 3 { + t.Fatalf("expected OPL channel passthrough, got %d", ch) + } +} + +func TestChannelSettingDefaults(t *testing.T) { + cs := ChannelSetting{PanEnabled: false, InitialPanning: s3mPanning.Panning(0x02)} + if got := cs.GetInitialPanning(); got != s3mPanning.DefaultPanning { + t.Fatalf("expected default panning when disabled, got %d", got) + } + if got := cs.GetDefaultFilterInfo(); got != (filter.Info{}) { + t.Fatalf("expected empty filter info, got %+v", got) + } + if cs.IsDefaultFilterEnabled() { + t.Fatalf("expected default filter disabled") + } + if ch := cs.GetOPLChannel(); ch != index.InvalidOPLChannel { + t.Fatalf("expected invalid OPL channel for non-OPL category, got %d", ch) + } +} diff --git a/format/s3m/layout/song.go b/format/s3m/layout/song.go index a09278c..05d1d23 100644 --- a/format/s3m/layout/song.go +++ b/format/s3m/layout/song.go @@ -31,9 +31,9 @@ func (s Song) GetChannelSettings(channelNum index.Channel) song.ChannelSettings func (s Song) GetRowRenderStringer(row song.Row, channels int, longFormat bool) render.RowStringer { nch := min(s.NumChannels, channels) - rt := render.NewRowText[channel.Data](nch, longFormat) - rowData := make([]channel.Data, 0, nch) - _ = song.ForEachRowChannel[s3mVolume.Volume](row, func(ch index.Channel, d song.ChannelData[s3mVolume.Volume]) (bool, error) { + vm := render.NewRowViewModel[channel.Data](nch) + rowData := vm.Channels[:0] + song.ForEachRowChannel(row, func(ch index.Channel, d song.ChannelData[s3mVolume.Volume]) (bool, error) { if int(ch) >= nch || !s.ChannelSettings[ch].Enabled || s.ChannelSettings[ch].Muted { return true, nil } @@ -43,8 +43,8 @@ func (s Song) GetRowRenderStringer(row song.Row, channels int, longFormat bool) for len(rowData) < nch { rowData = append(rowData, channel.Data{}) } - rt.Channels = rowData - return rt + vm.Channels = rowData + return render.FormatRowText(vm, longFormat) } func (s Song) ForEachChannel(enabledOnly bool, fn func(ch index.Channel) (bool, error)) error { diff --git a/format/s3m/load/load_test.go b/format/s3m/load/load_test.go new file mode 100644 index 0000000..d119e0e --- /dev/null +++ b/format/s3m/load/load_test.go @@ -0,0 +1,49 @@ +package load + +import ( + "bytes" + "testing" + + s3mfile "github.com/gotracker/goaudiofile/music/tracked/s3m" + + s3mVolume "github.com/gotracker/playback/format/s3m/volume" + "github.com/gotracker/playback/frequency" +) + +func TestModuleHeaderToHeaderNil(t *testing.T) { + if _, err := moduleHeaderToHeader(nil); err == nil { + t.Fatalf("expected error when module header is nil") + } +} + +func TestConvertSCRSFullToInstrumentNilAncillary(t *testing.T) { + scrs := &s3mfile.SCRSFull{} + if _, err := convertSCRSFullToInstrument(scrs, false, nil); err == nil { + t.Fatalf("expected error when SCRS ancillary is nil") + } +} + +func TestConvertSCRSFullToInstrumentClampsVolume(t *testing.T) { + scrs := &s3mfile.SCRSFull{ + SCRS: s3mfile.SCRS{ + Ancillary: &s3mfile.SCRSNoneHeader{Volume: s3mfile.Volume(0x80), C2Spd: s3mfile.HiLo32{Lo: 1234}}, + }, + } + + inst, err := convertSCRSFullToInstrument(scrs, false, nil) + if err != nil { + t.Fatalf("unexpected error converting SCRS: %v", err) + } + if inst.Static.Volume != s3mVolume.MaxVolume { + t.Fatalf("expected volume clamp to MaxVolume, got %v", inst.Static.Volume) + } + if inst.SampleRate != frequency.Frequency(1234) { + t.Fatalf("expected sample rate 1234, got %v", inst.SampleRate) + } +} + +func TestMODLoaderRejectsInvalidData(t *testing.T) { + if _, err := MOD(bytes.NewReader([]byte("bad")), nil); err == nil { + t.Fatalf("expected error for invalid MOD data") + } +} diff --git a/format/s3m/load/s3mformat.go b/format/s3m/load/s3mformat.go index 4faa84d..6e790ff 100644 --- a/format/s3m/load/s3mformat.go +++ b/format/s3m/load/s3mformat.go @@ -7,7 +7,6 @@ import ( "io" s3mfile "github.com/gotracker/goaudiofile/music/tracked/s3m" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/format/common" "github.com/gotracker/playback/format/s3m/channel" @@ -19,6 +18,7 @@ import ( "github.com/gotracker/playback/frequency" "github.com/gotracker/playback/index" "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/feature" "github.com/gotracker/playback/song" diff --git a/format/s3m/oscillator/factory_test.go b/format/s3m/oscillator/factory_test.go new file mode 100644 index 0000000..6971f29 --- /dev/null +++ b/format/s3m/oscillator/factory_test.go @@ -0,0 +1,31 @@ +package oscillator + +import "testing" + +func TestOscillatorFactoryKnown(t *testing.T) { + for _, name := range []string{"vibrato", "tremolo", "panbrello"} { + osc, err := OscillatorFactory(name) + if err != nil { + t.Fatalf("expected nil error for %s: %v", name, err) + } + if osc == nil { + t.Fatalf("expected oscillator for %s", name) + } + } +} + +func TestOscillatorFactoryEmpty(t *testing.T) { + osc, err := OscillatorFactory("") + if err != nil { + t.Fatalf("expected nil error for empty name: %v", err) + } + if osc != nil { + t.Fatalf("expected nil oscillator for empty name") + } +} + +func TestOscillatorFactoryUnknown(t *testing.T) { + if _, err := OscillatorFactory("nope"); err == nil { + t.Fatalf("expected error for unsupported oscillator") + } +} diff --git a/format/s3m/panning/panning_test.go b/format/s3m/panning/panning_test.go new file mode 100644 index 0000000..2b63e69 --- /dev/null +++ b/format/s3m/panning/panning_test.go @@ -0,0 +1,23 @@ +package panning + +import "testing" + +func TestPanningClampAndPosition(t *testing.T) { + if got := Panning(0x02).FMA(2, 1); got != 0x05 { + t.Fatalf("expected FMA result 0x05, got %d", got) + } + if got := Panning(0x0E).FMA(2, 1); got != MaxPanning { + t.Fatalf("expected FMA clamp to max, got %d", got) + } + if got := Panning(0).AddDelta(-5); got != 0 { + t.Fatalf("expected AddDelta clamp at 0, got %d", got) + } + if got := Panning(0x0F).AddDelta(5); got != MaxPanning { + t.Fatalf("expected AddDelta clamp at max, got %d", got) + } + + pos := PanningFromS3M(0x08) + if pos.Distance != 1 { + t.Fatalf("unexpected distance from panning position: %v", pos) + } +} diff --git a/format/s3m/period/util_test.go b/format/s3m/period/util_test.go new file mode 100644 index 0000000..659ae21 --- /dev/null +++ b/format/s3m/period/util_test.go @@ -0,0 +1,58 @@ +package period + +import ( + "testing" + + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/period" +) + +func TestCalcFinetuneC4SampleRateTable(t *testing.T) { + cases := []struct { + name string + finetune uint8 + expect frequency.Frequency + }{ + {"base", 0x0, 7895}, + {"center", 0x8, 8363}, + {"max", 0xF, 8757}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := CalcFinetuneC4SampleRate(tt.finetune); got != tt.expect { + t.Fatalf("finetune 0x%X -> %v, want %v", tt.finetune, got, tt.expect) + } + }) + } +} + +func TestCalcFinetuneC4SampleRatePanicsOnInvalid(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic for invalid finetune") + } + }() + _ = CalcFinetuneC4SampleRate(0x10) +} + +func TestAmigaConvertersConfigured(t *testing.T) { + s3m, ok := S3MAmigaConverter.(period.AmigaConverter) + if !ok { + t.Fatalf("expected S3MAmigaConverter to be period.AmigaConverter") + } + if !s3m.SlideTo0Allowed { + t.Fatalf("expected SlideTo0Allowed to be true") + } + if s3m.MinPeriod != 64 || s3m.MaxPeriod != 32767 { + t.Fatalf("unexpected S3MAmigaConverter bounds: min %d max %d", s3m.MinPeriod, s3m.MaxPeriod) + } + + mod, ok := MODAmigaConverter.(period.AmigaConverter) + if !ok { + t.Fatalf("expected MODAmigaConverter to be period.AmigaConverter") + } + if mod.MinPeriod != 56 || mod.MaxPeriod != 13696 { + t.Fatalf("unexpected MODAmigaConverter bounds: min %d max %d", mod.MinPeriod, mod.MaxPeriod) + } +} diff --git a/format/s3m/s3m_test.go b/format/s3m/s3m_test.go new file mode 100644 index 0000000..75ed525 --- /dev/null +++ b/format/s3m/s3m_test.go @@ -0,0 +1,13 @@ +package s3m + +import ( + "bytes" + "testing" +) + +func TestLoadFromReaderRejectsInvalidData(t *testing.T) { + _, err := S3M.LoadFromReader(bytes.NewReader([]byte("bad")), nil) + if err == nil { + t.Fatalf("expected error for invalid S3M data") + } +} diff --git a/format/s3m/settings/machine.go b/format/s3m/settings/machine.go index b61cc9b..4e915b6 100644 --- a/format/s3m/settings/machine.go +++ b/format/s3m/settings/machine.go @@ -1,48 +1,21 @@ package settings import ( - s3mFilter "github.com/gotracker/playback/format/s3m/filter" - s3mOscillator "github.com/gotracker/playback/format/s3m/oscillator" s3mPanning "github.com/gotracker/playback/format/s3m/panning" - s3mPeriod "github.com/gotracker/playback/format/s3m/period" s3mVolume "github.com/gotracker/playback/format/s3m/volume" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/quirks" ) func GetMachineSettings(modLimits bool) *settings.MachineSettings[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning] { if modLimits { - return &amigaMOD31Settings + return amigaMOD31Settings } - return &amigaS3MSettings + return amigaS3MSettings } var ( - s3mQuirks = settings.MachineQuirks{ - PreviousPeriodUsesModifiedPeriod: true, - PortaToNoteUsesModifiedPeriod: true, - DoNotProcessEffectsOnMutedChannels: true, - } - - amigaMOD31Settings = settings.MachineSettings[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ - PeriodConverter: s3mPeriod.S3MAmigaConverter, - GetFilterFactory: s3mFilter.Factory, - GetVibratoFactory: s3mOscillator.VibratoFactory, - GetTremoloFactory: s3mOscillator.TremoloFactory, - GetPanbrelloFactory: s3mOscillator.PanbrelloFactory, - VoiceFactory: amigaVoiceFactory, - OPL2Enabled: true, - Quirks: s3mQuirks, - } - - amigaS3MSettings = settings.MachineSettings[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ - PeriodConverter: s3mPeriod.S3MAmigaConverter, - GetFilterFactory: s3mFilter.Factory, - GetVibratoFactory: s3mOscillator.VibratoFactory, - GetTremoloFactory: s3mOscillator.TremoloFactory, - GetPanbrelloFactory: s3mOscillator.PanbrelloFactory, - VoiceFactory: amigaVoiceFactory, - OPL2Enabled: true, - Quirks: s3mQuirks, - } + amigaMOD31Settings = quirks.GetS3MMachineSettings(quirks.ProfileST321_ModLimits, amigaVoiceFactory) + amigaS3MSettings = quirks.GetS3MMachineSettings(quirks.ProfileST321, amigaVoiceFactory) ) diff --git a/format/s3m/settings/machine_test.go b/format/s3m/settings/machine_test.go new file mode 100644 index 0000000..74821d0 --- /dev/null +++ b/format/s3m/settings/machine_test.go @@ -0,0 +1,42 @@ +package settings + +import ( + "testing" + + "github.com/gotracker/playback/period" +) + +func TestGetMachineSettingsS3M(t *testing.T) { + ms := GetMachineSettings(false) + if ms != amigaS3MSettings { + t.Fatalf("expected amigaS3MSettings pointer") + } + if ms.PeriodConverter != amigaS3MSettings.PeriodConverter { + t.Fatalf("unexpected period converter") + } + if !ms.OPL2Enabled { + t.Fatalf("expected OPL2 enabled") + } + if !ms.Quirks.PreviousPeriodUsesModifiedPeriod || !ms.Quirks.PortaToNoteUsesModifiedPeriod { + t.Fatalf("expected S3M quirks set") + } +} + +func TestGetMachineSettingsMod(t *testing.T) { + ms := GetMachineSettings(true) + if ms != amigaMOD31Settings { + t.Fatalf("expected amigaMOD31Settings pointer") + } + if ms.PeriodConverter != amigaMOD31Settings.PeriodConverter { + t.Fatalf("unexpected period converter") + } +} + +func TestSettingsPeriodType(t *testing.T) { + // ensure type parameters line up with period.Amiga + ms := GetMachineSettings(false) + var _ *period.Amiga + if ms.PeriodConverter == nil { + t.Fatalf("expected period converter present") + } +} diff --git a/format/s3m/system/system_test.go b/format/s3m/system/system_test.go new file mode 100644 index 0000000..d44fd55 --- /dev/null +++ b/format/s3m/system/system_test.go @@ -0,0 +1,46 @@ +package system + +import ( + "testing" + + "github.com/gotracker/playback/note" +) + +func TestS3MBaseClockCalculation(t *testing.T) { + if S3MBaseClock != DefaultC4SampleRate*C4Period { + t.Fatalf("expected S3MBaseClock to equal DefaultC4SampleRate*C4Period, got %v", S3MBaseClock) + } +} + +func TestS3MSystemValues(t *testing.T) { + s := S3MSystem + if s.GetBaseClock() != S3MBaseClock { + t.Fatalf("unexpected base clock: %v", s.GetBaseClock()) + } + if s.GetCommonRate() != DefaultC4SampleRate { + t.Fatalf("unexpected common rate: %v", s.GetCommonRate()) + } + if s.GetCommonPeriod() != C4Period { + t.Fatalf("unexpected common period: %d", s.GetCommonPeriod()) + } + if s.GetMaxPastNotesPerChannel() != 0 { + t.Fatalf("unexpected max past notes: %d", s.GetMaxPastNotesPerChannel()) + } + if got := s.GetOctaveShift(); got != 1 { + t.Fatalf("expected octave shift 1, got %d", got) + } +} + +func TestS3MSemitonePeriods(t *testing.T) { + s := S3MSystem + period, ok := s.GetSemitonePeriod(note.KeyC) + if !ok { + t.Fatalf("expected semitone period for KeyC") + } + if period != semitonePeriodTable[0]>>s.GetOctaveShift() { + t.Fatalf("unexpected semitone period for KeyC: %d", period) + } + if _, ok := s.GetSemitonePeriod(note.KeyInvalid1); ok { + t.Fatalf("expected invalid key to report missing semitone period") + } +} diff --git a/format/s3m/voice/voice.go b/format/s3m/voice/voice.go index 237a135..8805c9a 100644 --- a/format/s3m/voice/voice.go +++ b/format/s3m/voice/voice.go @@ -204,6 +204,7 @@ func (v *s3mVoice) Reset() error { func (v *s3mVoice) Stop() { v.stopped = true + _ = v.AmpModulator.SetActive(false) } func (v s3mVoice) IsMuted() bool { diff --git a/format/s3m/voice/voice_test.go b/format/s3m/voice/voice_test.go new file mode 100644 index 0000000..495261b --- /dev/null +++ b/format/s3m/voice/voice_test.go @@ -0,0 +1,85 @@ +package voice + +import ( + "testing" + + s3mPanning "github.com/gotracker/playback/format/s3m/panning" + s3mPeriod "github.com/gotracker/playback/format/s3m/period" + s3mSystem "github.com/gotracker/playback/format/s3m/system" + s3mVolume "github.com/gotracker/playback/format/s3m/volume" + "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/period" + voiceCore "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/pcm" +) + +func makeS3MInstrument() instrument.Instrument[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning] { + sample := pcm.NewSampleNative([]volume.Matrix{{StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}}, 1, 2) + + return instrument.Instrument[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ + Static: instrument.StaticValues[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ + PC: s3mPeriod.S3MAmigaConverter, + Volume: s3mVolume.Volume(32), + }, + Inst: &instrument.PCM[s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{Sample: sample}, + SampleRate: s3mSystem.DefaultC4SampleRate, + } +} + +type s3mTestVoice interface { + voiceCore.RenderSampler[period.Amiga] + Setup(*instrument.Instrument[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]) error +} + +func makeS3MVoice() s3mTestVoice { + cfg := voiceCore.VoiceConfig[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ + PC: s3mPeriod.S3MAmigaConverter, + InitialVolume: s3mVolume.Volume(32), + InitialMixing: s3mVolume.FineVolume(64), + PanEnabled: true, + InitialPan: s3mPanning.DefaultPanning, + } + return New(cfg).(s3mTestVoice) +} + +func TestS3MVoiceSetupAndSample(t *testing.T) { + v := makeS3MVoice() + inst := makeS3MInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + if v.IsDone() { + t.Fatalf("expected voice active after setup") + } + + if err := v.Tick(); err != nil { + t.Fatalf("tick error: %v", err) + } + + samp := v.GetSample(sampling.Pos{}) + if samp.Channels != 2 { + t.Fatalf("expected stereo sample, got %d channels", samp.Channels) + } + if fv := v.GetFinalVolume(); fv <= 0 { + t.Fatalf("expected final volume > 0, got %v", fv) + } +} + +func TestS3MVoiceStopMarksDone(t *testing.T) { + v := makeS3MVoice() + inst := makeS3MInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + v.Stop() + if !v.IsDone() { + t.Fatalf("expected voice to be done after stop") + } + if fv := v.GetFinalVolume(); fv != 0 { + t.Fatalf("expected final volume to be 0 after stop, got %v", fv) + } +} diff --git a/format/s3m/volume/volume.go b/format/s3m/volume/volume.go index cb4dc2a..b5445a6 100644 --- a/format/s3m/volume/volume.go +++ b/format/s3m/volume/volume.go @@ -81,7 +81,14 @@ func VolumeToS3M(v volume.Volume) Volume { case v == volume.VolumeUseInstVol: return Volume(s3mfile.EmptyVolume) default: - return Volume(v * volume.Volume(MaxVolume)) + scaled := math.Round(float64(v * volume.Volume(MaxVolume))) + switch { + case scaled < 0: + scaled = 0 + case scaled > float64(MaxVolume): + scaled = float64(MaxVolume) + } + return Volume(scaled) } } diff --git a/format/s3m/volume/volume_test.go b/format/s3m/volume/volume_test.go new file mode 100644 index 0000000..fc2fd8b --- /dev/null +++ b/format/s3m/volume/volume_test.go @@ -0,0 +1,79 @@ +package volume + +import ( + "testing" + + s3mfile "github.com/gotracker/goaudiofile/music/tracked/s3m" + mixvol "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/voice/types" +) + +func TestVolumeConversionsAndSentinels(t *testing.T) { + if got := VolumeFromS3M(MaxVolume); got < 0.98 || got > 1.0 { + t.Fatalf("expected max S3M volume near 1, got %v", got) + } + if Volume(s3mfile.EmptyVolume).IsUseInstrumentVol() == false { + t.Fatalf("expected empty volume sentinel to indicate instrument volume") + } + if Volume(0x41).IsInvalid() == false { + t.Fatalf("expected >MaxVolume (except sentinel) invalid") + } + if Volume(s3mfile.EmptyVolume).IsInvalid() { + t.Fatalf("sentinel should not be invalid") + } + + if got := VolumeToS3M(mixvol.VolumeUseInstVol); got != Volume(s3mfile.EmptyVolume) { + t.Fatalf("expected use-instrument sentinel") + } + if got := VolumeToS3M(2); got == MaxVolume*2 { + t.Fatalf("expected 2*MaxVolume, got %d", got) + } +} + +func TestVolumeArithmeticClamps(t *testing.T) { + if got := Volume(60).FMA(2, 0); got != MaxVolume { + t.Fatalf("expected FMA clamp to MaxVolume, got %d", got) + } + if got := Volume(s3mfile.EmptyVolume).FMA(2, 1); got != Volume(s3mfile.EmptyVolume) { + t.Fatalf("expected FMA to preserve sentinel, got %d", got) + } + if got := Volume(2).AddDelta(types.VolumeDelta(-5)); got != 0 { + t.Fatalf("expected AddDelta clamp to 0, got %d", got) + } + if got := Volume(100).AddDelta(types.VolumeDelta(10)); got != MaxVolume { + t.Fatalf("expected AddDelta clamp to max, got %d", got) + } +} + +func TestFineVolumeConversionsAndClamps(t *testing.T) { + if FineVolume(s3mfile.EmptyVolume).IsUseInstrumentVol() == false { + t.Fatalf("expected fine volume sentinel") + } + if FineVolume(0x80).IsInvalid() == false { + t.Fatalf("expected fine volume over max invalid") + } + if FineVolume(s3mfile.EmptyVolume).IsInvalid() { + t.Fatalf("sentinel fine volume should not be invalid") + } + if got := FineVolume(s3mfile.EmptyVolume).FMA(2, 1); got != FineVolume(s3mfile.EmptyVolume) { + t.Fatalf("expected fine FMA to preserve sentinel, got %d", got) + } + if got := FineVolume(0x7e).FMA(2, 0); got != MaxFineVolume { + t.Fatalf("expected fine FMA clamp to max, got %d", got) + } + if got := FineVolume(1).AddDelta(types.VolumeDelta(-5)); got != 0 { + t.Fatalf("expected fine AddDelta clamp to 0, got %d", got) + } + if got := FineVolume(0x7e).AddDelta(types.VolumeDelta(10)); got != MaxFineVolume { + t.Fatalf("expected fine AddDelta clamp to max, got %d", got) + } +} + +func TestSampleVolumeConversions(t *testing.T) { + if got := VolumeFromS3M8BitSample(128); got != 0 { + t.Fatalf("expected centered 8-bit sample to map to 0, got %v", got) + } + if got := VolumeFromS3M16BitSample(32768); got != 0 { + t.Fatalf("expected centered 16-bit sample to map to 0, got %v", got) + } +} diff --git a/format/xm/channel/data.go b/format/xm/channel/data.go index 7f38c78..0b08591 100644 --- a/format/xm/channel/data.go +++ b/format/xm/channel/data.go @@ -5,13 +5,13 @@ import ( "strings" xmfile "github.com/gotracker/goaudiofile/music/tracked/xm" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback" xmNote "github.com/gotracker/playback/format/xm/note" xmPanning "github.com/gotracker/playback/format/xm/panning" xmVolume "github.com/gotracker/playback/format/xm/volume" "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/note" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine" @@ -101,7 +101,7 @@ func (d Data[TPeriod]) Channel() uint8 { return 0 } -func (d Data[TPeriod]) GetEffects(mem *Memory, periodType period.Period) []playback.Effect { +func (d Data[TPeriod]) GetEffects(mem *Memory) []playback.Effect { if e := EffectFactory[TPeriod](mem, d); e != nil { return []playback.Effect{e} } diff --git a/format/xm/channel/effect_portavolslide.go b/format/xm/channel/effect_portavolslide.go index 2cae5f8..2e2788c 100644 --- a/format/xm/channel/effect_portavolslide.go +++ b/format/xm/channel/effect_portavolslide.go @@ -11,7 +11,7 @@ import ( // PortaVolumeSlide defines a portamento-to-note combined with a volume slide effect type PortaVolumeSlide[TPeriod period.Period] struct { // '5' - playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] } // NewPortaVolumeSlide creates a new PortaVolumeSlide object diff --git a/format/xm/channel/effect_sampleoffset.go b/format/xm/channel/effect_sampleoffset.go index c25ab62..1ba43b2 100644 --- a/format/xm/channel/effect_sampleoffset.go +++ b/format/xm/channel/effect_sampleoffset.go @@ -3,11 +3,10 @@ package channel import ( "fmt" - "github.com/gotracker/playback/mixing/sampling" - xmPanning "github.com/gotracker/playback/format/xm/panning" xmVolume "github.com/gotracker/playback/format/xm/volume" "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/sampling" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine" ) diff --git a/format/xm/channel/effect_vibratovolslide.go b/format/xm/channel/effect_vibratovolslide.go index 24afcf5..456fd4f 100644 --- a/format/xm/channel/effect_vibratovolslide.go +++ b/format/xm/channel/effect_vibratovolslide.go @@ -11,7 +11,7 @@ import ( // VibratoVolumeSlide defines a combination vibrato and volume slide effect type VibratoVolumeSlide[TPeriod period.Period] struct { // '6' - playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] } // NewVibratoVolumeSlide creates a new VibratoVolumeSlide object diff --git a/format/xm/channel/effectfactory.go b/format/xm/channel/effectfactory.go index e8854d7..9743d71 100644 --- a/format/xm/channel/effectfactory.go +++ b/format/xm/channel/effectfactory.go @@ -14,7 +14,7 @@ type EffectXM = playback.Effect // VolEff is a combined effect that includes a volume effect and a standard effect type VolEff[TPeriod period.Period] struct { - playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning, *Memory, Data[TPeriod]] + playback.CombinedEffect[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] eff EffectXM } diff --git a/format/xm/channel/effectfactory_test.go b/format/xm/channel/effectfactory_test.go new file mode 100644 index 0000000..85af159 --- /dev/null +++ b/format/xm/channel/effectfactory_test.go @@ -0,0 +1,24 @@ +package channel + +import ( + "testing" + + xmVolume "github.com/gotracker/playback/format/xm/volume" + "github.com/gotracker/playback/period" +) + +func TestEffectFactoryNoCommandReturnsNil(t *testing.T) { + var mem Memory + d := Data[period.Amiga]{} + if eff := EffectFactory[period.Amiga](&mem, d); eff != nil { + t.Fatalf("expected nil effect when no command present") + } + + if eff := EffectFactory[period.Amiga](nil, nil); eff != nil { + t.Fatalf("expected nil effect when data is nil") + } + + if got := d.GetVolume(); got != xmVolume.XmVolume(0) { + t.Fatalf("expected zero volume for empty data, got %v", got) + } +} diff --git a/format/xm/filter/factory_test.go b/format/xm/filter/factory_test.go new file mode 100644 index 0000000..8513d97 --- /dev/null +++ b/format/xm/filter/factory_test.go @@ -0,0 +1,15 @@ +package filter + +import "testing" + +func TestFilterFactoryKnownAndUnknown(t *testing.T) { + if f, err := Factory("amigalpf", 8363, nil); err != nil || f == nil { + t.Fatalf("expected amigalpf filter, got %v, err=%v", f, err) + } + if f, err := Factory("", 8363, nil); err != nil || f != nil { + t.Fatalf("expected empty filter to return nil without error, got %v, err=%v", f, err) + } + if _, err := Factory("nope", 8363, nil); err == nil { + t.Fatalf("expected error for unknown filter") + } +} diff --git a/format/xm/layout/channelsetting_test.go b/format/xm/layout/channelsetting_test.go new file mode 100644 index 0000000..ff35693 --- /dev/null +++ b/format/xm/layout/channelsetting_test.go @@ -0,0 +1,38 @@ +package layout + +import ( + "testing" + + xmPanning "github.com/gotracker/playback/format/xm/panning" + xmVolume "github.com/gotracker/playback/format/xm/volume" + "github.com/gotracker/playback/index" +) + +func TestChannelSettingDefaults(t *testing.T) { + cs := ChannelSetting{ + Enabled: true, + Muted: false, + OutputChannelNum: 2, + InitialVolume: xmVolume.XmVolume(0x20), + InitialPanning: xmPanning.DefaultPanning, + } + + if !cs.IsEnabled() || cs.IsMuted() { + t.Fatalf("expected enabled and not muted") + } + if cs.GetOutputChannelNum() != 2 { + t.Fatalf("expected output channel 2, got %d", cs.GetOutputChannelNum()) + } + if cs.GetInitialVolume() != cs.InitialVolume { + t.Fatalf("initial volume mismatch") + } + if cs.GetInitialPanning() != cs.InitialPanning { + t.Fatalf("initial panning mismatch") + } + if !cs.IsPanEnabled() { + t.Fatalf("expected pan enabled") + } + if cs.GetOPLChannel() != index.InvalidOPLChannel { + t.Fatalf("expected invalid OPL channel") + } +} diff --git a/format/xm/layout/song.go b/format/xm/layout/song.go index da4fd9c..a49d487 100644 --- a/format/xm/layout/song.go +++ b/format/xm/layout/song.go @@ -54,8 +54,8 @@ func (s Song[TPeriod]) GetInstrument(instID int, st note.Semitone) (instrument.I } func (s Song[TPeriod]) GetRowRenderStringer(row song.Row, channels int, longFormat bool) render.RowStringer { - rt := render.NewRowText[channel.Data[TPeriod]](channels, longFormat) - rowData := make([]channel.Data[TPeriod], channels) + vm := render.NewRowViewModel[channel.Data[TPeriod]](channels) + rowData := vm.Channels song.ForEachRowChannel(row, func(ch index.Channel, d song.ChannelData[xmVolume.XmVolume]) (bool, error) { if int(ch) >= channels || !s.ChannelSettings[ch].Enabled || s.ChannelSettings[ch].Muted { return true, nil @@ -63,8 +63,8 @@ func (s Song[TPeriod]) GetRowRenderStringer(row song.Row, channels int, longForm rowData[ch] = d.(channel.Data[TPeriod]) return true, nil }) - rt.Channels = rowData - return rt + vm.Channels = rowData + return render.FormatRowText(vm, longFormat) } func (s Song[TPeriod]) ForEachChannel(enabledOnly bool, fn func(ch index.Channel) (bool, error)) error { diff --git a/format/xm/load/load_test.go b/format/xm/load/load_test.go new file mode 100644 index 0000000..eef5069 --- /dev/null +++ b/format/xm/load/load_test.go @@ -0,0 +1,62 @@ +package load + +import ( + "testing" + + xmfile "github.com/gotracker/goaudiofile/music/tracked/xm" + + xmPeriod "github.com/gotracker/playback/format/xm/period" + xmVolume "github.com/gotracker/playback/format/xm/volume" + "github.com/gotracker/playback/period" +) + +func TestModuleHeaderToHeaderNil(t *testing.T) { + if _, err := moduleHeaderToHeader(nil, false); err == nil { + t.Fatalf("expected error when module header is nil") + } +} + +func TestConvertXMInstrumentToInstrumentNil(t *testing.T) { + if _, _, err := convertXMInstrumentToInstrument[period.Amiga](nil, xmPeriod.AmigaConverter, false, nil); err == nil { + t.Fatalf("expected error when instrument is nil") + } +} + +func TestXMInstrumentVolumeClampAndVibratoScaling(t *testing.T) { + inst := &xmfile.InstrumentHeader{ + SamplesCount: 1, + VibratoDepth: 64, + VibratoRate: 1, + Samples: []xmfile.SampleHeader{{ + Length: 1, + Volume: 0x80, // over max, should clamp to 0x40 + Finetune: 0, + Flags: 0, + Panning: 0x40, + RelativeNoteNumber: 0, + SampleData: []byte{0}, + }}, + } + + samplesLinear, _, err := convertXMInstrumentToInstrument(inst, xmPeriod.AmigaConverter, true, nil) + if err != nil { + t.Fatalf("unexpected error converting instrument (linear): %v", err) + } + if len(samplesLinear) != 1 { + t.Fatalf("expected 1 sample, got %d", len(samplesLinear)) + } + if got := samplesLinear[0].Static.Volume; got != xmVolume.XmVolume(0x40) { + t.Fatalf("expected volume clamp to 0x40, got %v", got) + } + if got := samplesLinear[0].Static.AutoVibrato.Depth; got != float32(64) { + t.Fatalf("expected vibrato depth 64 with linear slides, got %v", got) + } + + samplesAmiga, _, err := convertXMInstrumentToInstrument(inst, xmPeriod.AmigaConverter, false, nil) + if err != nil { + t.Fatalf("unexpected error converting instrument (amiga): %v", err) + } + if got := samplesAmiga[0].Static.AutoVibrato.Depth; got != float32(1) { + t.Fatalf("expected vibrato depth scaled to 1 for amiga slides, got %v", got) + } +} diff --git a/format/xm/load/xmformat.go b/format/xm/load/xmformat.go index afbefa0..a5a1da2 100644 --- a/format/xm/load/xmformat.go +++ b/format/xm/load/xmformat.go @@ -6,7 +6,6 @@ import ( "math" xmfile "github.com/gotracker/goaudiofile/music/tracked/xm" - "github.com/gotracker/playback/mixing/volume" "github.com/heucuva/optional" "github.com/gotracker/playback/format/common" @@ -20,6 +19,7 @@ import ( "github.com/gotracker/playback/frequency" "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/oscillator" "github.com/gotracker/playback/period" @@ -32,7 +32,7 @@ import ( "github.com/gotracker/playback/voice/pcm" ) -func moduleHeaderToHeader(fh *xmfile.ModuleHeader) (*xmLayout.Header, error) { +func moduleHeaderToHeader(fh *xmfile.ModuleHeader, linearSlides bool) (*xmLayout.Header, error) { if fh == nil { return nil, errors.New("file header is nil") } @@ -42,7 +42,7 @@ func moduleHeaderToHeader(fh *xmfile.ModuleHeader) (*xmLayout.Header, error) { InitialTempo: int(fh.DefaultTempo), GlobalVolume: xmVolume.DefaultXmVolume, MixingVolume: xmVolume.DefaultXmMixingVolume, - LinearFreqSlides: fh.Flags.IsLinearSlides(), + LinearFreqSlides: linearSlides, InitialOrder: 0, } return &head, nil @@ -286,21 +286,19 @@ func convertXmPattern[TPeriod period.Period](pkt xmfile.Pattern) (song.Pattern, } func convertXmFileToSong(f *xmfile.File, features []feature.Feature) (song.Data, error) { - if f.Head.Flags.IsLinearSlides() { - return convertXmFileToTypedSong[period.Linear](f, features) - } else { - return convertXmFileToTypedSong[period.Amiga](f, features) + linearSlides := common.ResolveLinearSlides(f.Head.Flags.IsLinearSlides(), features) + if linearSlides { + return convertXmFileToTypedSong[period.Linear](f, features, linearSlides) } + return convertXmFileToTypedSong[period.Amiga](f, features, linearSlides) } -func convertXmFileToTypedSong[TPeriod period.Period](f *xmfile.File, features []feature.Feature) (*xmLayout.Song[TPeriod], error) { - h, err := moduleHeaderToHeader(&f.Head) +func convertXmFileToTypedSong[TPeriod period.Period](f *xmfile.File, features []feature.Feature, linearFrequencySlides bool) (*xmLayout.Song[TPeriod], error) { + h, err := moduleHeaderToHeader(&f.Head, linearFrequencySlides) if err != nil { return nil, err } - linearFrequencySlides := f.Head.Flags.IsLinearSlides() - ms := xmSettings.GetMachineSettings[TPeriod]() s := xmLayout.Song[TPeriod]{ diff --git a/format/xm/note/note_test.go b/format/xm/note/note_test.go new file mode 100644 index 0000000..c8e2f43 --- /dev/null +++ b/format/xm/note/note_test.go @@ -0,0 +1,36 @@ +package note + +import ( + "testing" + + pnote "github.com/gotracker/playback/note" +) + +func TestFromXmNoteSpecials(t *testing.T) { + cases := []struct { + name string + in uint8 + exp pnote.Note + }{ + {"release", 97, pnote.ReleaseNote{}}, + {"empty", 0, pnote.EmptyNote{}}, + {"invalid", 98, pnote.InvalidNote{}}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := FromXmNote(tt.in); got != tt.exp { + t.Fatalf("FromXmNote(%d) = %#v, want %#v", tt.in, got, tt.exp) + } + }) + } +} + +func TestFromXmNoteNormal(t *testing.T) { + in := uint8(1) + exp := pnote.Normal(pnote.Semitone(0)) + + if got := FromXmNote(in); got != exp { + t.Fatalf("FromXmNote(%d) = %#v, want %#v", in, got, exp) + } +} diff --git a/format/xm/oscillator/factory_test.go b/format/xm/oscillator/factory_test.go new file mode 100644 index 0000000..77ad462 --- /dev/null +++ b/format/xm/oscillator/factory_test.go @@ -0,0 +1,15 @@ +package oscillator + +import "testing" + +func TestOscillatorFactoryKnownAndUnknown(t *testing.T) { + if o, err := Factory("vibrato"); err != nil || o == nil { + t.Fatalf("expected vibrato oscillator, got %v, err=%v", o, err) + } + if o, err := Factory(""); err != nil || o != nil { + t.Fatalf("expected empty name to return nil without error, got %v, err=%v", o, err) + } + if _, err := Factory("nope"); err == nil { + t.Fatalf("expected error for unknown oscillator") + } +} diff --git a/format/xm/panning/panning.go b/format/xm/panning/panning.go index 75878cf..c142bf4 100644 --- a/format/xm/panning/panning.go +++ b/format/xm/panning/panning.go @@ -62,5 +62,12 @@ func PanningFromXm(pos Panning) panning.Position { // PanningToXm returns the xm panning value for a radian panning position func PanningToXm(pan panning.Position) uint8 { - return uint8(panning.FromStereoPosition(pan, 0, 0xFF)) + val := math.Round(float64(panning.FromStereoPosition(pan, 0, 0xFF))) + switch { + case val < 0: + val = 0 + case val > 0xFF: + val = 0xFF + } + return uint8(val) } diff --git a/format/xm/panning/panning_test.go b/format/xm/panning/panning_test.go new file mode 100644 index 0000000..3c65033 --- /dev/null +++ b/format/xm/panning/panning_test.go @@ -0,0 +1,26 @@ +package panning + +import ( + "testing" + + "github.com/gotracker/playback/mixing/panning" +) + +func TestPanningRoundTripAndClamp(t *testing.T) { + pos := PanningFromXm(DefaultPanning) + if got := PanningToXm(pos); got != uint8(DefaultPanning) { + t.Fatalf("expected round trip panning %d, got %d", DefaultPanning, got) + } + + if got := Panning(0xFF).AddDelta(5); got != MaxPanning { + t.Fatalf("expected AddDelta clamp to MaxPanning, got %d", got) + } + if got := Panning(1).AddDelta(-5); got != 0 { + t.Fatalf("expected AddDelta clamp to 0, got %d", got) + } + + posLeft := PanningFromXm(DefaultPanningLeft) + if panning.FromStereoPosition(posLeft, 0, 0xFF) >= 0x80 { + t.Fatalf("expected left default to be left-biased") + } +} diff --git a/format/xm/period/util_test.go b/format/xm/period/util_test.go new file mode 100644 index 0000000..0687acd --- /dev/null +++ b/format/xm/period/util_test.go @@ -0,0 +1,44 @@ +package period + +import ( + "testing" + + xmSystem "github.com/gotracker/playback/format/xm/system" + "github.com/gotracker/playback/note" + "github.com/gotracker/playback/period" +) + +func TestCalcFinetuneC4SampleRateIdentity(t *testing.T) { + got := CalcFinetuneC4SampleRate(xmSystem.DefaultC4SampleRate, note.Semitone(xmSystem.C4Note), 0) + if got != xmSystem.DefaultC4SampleRate { + t.Fatalf("expected unchanged c4 sample rate, got %v", got) + } +} + +func TestCalcFinetuneC4SampleRateAdjusts(t *testing.T) { + got := CalcFinetuneC4SampleRate(xmSystem.DefaultC4SampleRate, note.Semitone(xmSystem.C4Note), note.Finetune(64)) + if got != 8608 { + t.Fatalf("expected finetune to raise rate to 8608, got %v", got) + } +} + +func TestConvertersConfigured(t *testing.T) { + linear, ok := LinearConverter.(period.LinearConverter) + if !ok { + t.Fatalf("expected LinearConverter to be period.LinearConverter") + } + amiga, ok := AmigaConverter.(period.AmigaConverter) + if !ok { + t.Fatalf("expected AmigaConverter to be period.AmigaConverter") + } + + if linear.System != xmSystem.XMSystem { + t.Fatalf("unexpected LinearConverter system") + } + if amiga.MaxPeriod != 31999 || amiga.MinPeriod != 1 { + t.Fatalf("unexpected AmigaConverter bounds: min %d max %d", amiga.MinPeriod, amiga.MaxPeriod) + } + if amiga.System != xmSystem.XMSystem { + t.Fatalf("unexpected AmigaConverter system") + } +} diff --git a/format/xm/settings/machine.go b/format/xm/settings/machine.go index e14f873..4481632 100644 --- a/format/xm/settings/machine.go +++ b/format/xm/settings/machine.go @@ -1,45 +1,28 @@ package settings import ( - xmFilter "github.com/gotracker/playback/format/xm/filter" - xmOscillator "github.com/gotracker/playback/format/xm/oscillator" xmPanning "github.com/gotracker/playback/format/xm/panning" - xmPeriod "github.com/gotracker/playback/format/xm/period" xmVolume "github.com/gotracker/playback/format/xm/volume" "github.com/gotracker/playback/period" "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/quirks" ) func GetMachineSettings[TPeriod period.Period]() *settings.MachineSettings[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] { var p TPeriod switch any(p).(type) { case period.Amiga: - return any(&amigaMachine).(*settings.MachineSettings[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) + return any(amigaMachine).(*settings.MachineSettings[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) case period.Linear: - return any(&linearMachine).(*settings.MachineSettings[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) + return any(linearMachine).(*settings.MachineSettings[TPeriod, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) default: panic("unsupported machine type") } } var ( - amigaMachine = settings.MachineSettings[period.Amiga, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ - PeriodConverter: xmPeriod.AmigaConverter, - GetFilterFactory: xmFilter.Factory, - GetVibratoFactory: xmOscillator.VibratoFactory, - GetTremoloFactory: xmOscillator.TremoloFactory, - GetPanbrelloFactory: xmOscillator.PanbrelloFactory, - VoiceFactory: amigaVoiceFactory, - OPL2Enabled: false, - } + xmProfile = quirks.ProfileFT210 - linearMachine = settings.MachineSettings[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ - PeriodConverter: xmPeriod.LinearConverter, - GetFilterFactory: xmFilter.Factory, - GetVibratoFactory: xmOscillator.VibratoFactory, - GetTremoloFactory: xmOscillator.TremoloFactory, - GetPanbrelloFactory: xmOscillator.PanbrelloFactory, - VoiceFactory: linearVoiceFactory, - OPL2Enabled: false, - } + amigaMachine = quirks.GetXMMachineSettingsAmiga(xmProfile, amigaVoiceFactory) + linearMachine = quirks.GetXMMachineSettingsLinear(xmProfile, linearVoiceFactory) ) diff --git a/format/xm/settings/machine_test.go b/format/xm/settings/machine_test.go new file mode 100644 index 0000000..c6c1eb2 --- /dev/null +++ b/format/xm/settings/machine_test.go @@ -0,0 +1,28 @@ +package settings + +import ( + "testing" + + xmPeriod "github.com/gotracker/playback/format/xm/period" + "github.com/gotracker/playback/period" +) + +func TestGetMachineSettingsAmiga(t *testing.T) { + ms := GetMachineSettings[period.Amiga]() + if ms.PeriodConverter != xmPeriod.AmigaConverter { + t.Fatalf("expected Amiga converter") + } + if ms.OPL2Enabled { + t.Fatalf("expected OPL2 disabled") + } + if _, err := ms.GetFilterFactory("", 0, nil); err != nil { + t.Fatalf("expected empty filter ok: %v", err) + } +} + +func TestGetMachineSettingsLinear(t *testing.T) { + ms := GetMachineSettings[period.Linear]() + if ms.PeriodConverter != xmPeriod.LinearConverter { + t.Fatalf("expected Linear converter") + } +} diff --git a/format/xm/system/system_test.go b/format/xm/system/system_test.go new file mode 100644 index 0000000..bff7d20 --- /dev/null +++ b/format/xm/system/system_test.go @@ -0,0 +1,31 @@ +package system + +import ( + "testing" + + "github.com/gotracker/playback/note" +) + +func TestXMSystemConstants(t *testing.T) { + if XMBaseClock != DefaultC4SampleRate*C4Period { + t.Fatalf("expected base clock product, got %v", XMBaseClock) + } + if len(semitonePeriodTable) != 12 { + t.Fatalf("expected 12 semitone periods, got %d", len(semitonePeriodTable)) + } + if semitonePeriodTable[0] != 27392 { + t.Fatalf("unexpected first semitone period: %d", semitonePeriodTable[0]) + } + if XMSystem.GetBaseClock() != XMBaseClock { + t.Fatalf("expected XMSystem base clock to match") + } + if XMSystem.GetCommonPeriod() != C4Period { + t.Fatalf("expected XMSystem common period to match C4 period") + } + if XMSystem.GetCommonRate() != DefaultC4SampleRate { + t.Fatalf("expected XMSystem common rate to match default C4 sample rate") + } + if p, ok := XMSystem.GetSemitonePeriod(note.Key(0)); !ok || p != semitonePeriodTable[0] { + t.Fatalf("expected first semitone period from XMSystem") + } +} diff --git a/format/xm/voice/voice.go b/format/xm/voice/voice.go index 9c48da2..3cb1345 100644 --- a/format/xm/voice/voice.go +++ b/format/xm/voice/voice.go @@ -218,6 +218,7 @@ func (v *xmVoice[TPeriod]) Reset() error { func (v *xmVoice[TPeriod]) Stop() { v.stopped = true + _ = v.amp.SetActive(false) } func (v xmVoice[TPeriod]) IsDone() bool { diff --git a/format/xm/voice/voice_test.go b/format/xm/voice/voice_test.go new file mode 100644 index 0000000..d6dd07f --- /dev/null +++ b/format/xm/voice/voice_test.go @@ -0,0 +1,90 @@ +package voice + +import ( + "testing" + + xmPanning "github.com/gotracker/playback/format/xm/panning" + xmPeriod "github.com/gotracker/playback/format/xm/period" + xmSystem "github.com/gotracker/playback/format/xm/system" + xmVolume "github.com/gotracker/playback/format/xm/volume" + "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/period" + voiceCore "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/autovibrato" + "github.com/gotracker/playback/voice/pcm" +) + +func makeXMInstrument() instrument.Instrument[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] { + sample := pcm.NewSampleNative([]volume.Matrix{{StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}}, 1, 2) + + return instrument.Instrument[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ + Static: instrument.StaticValues[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ + PC: xmPeriod.LinearConverter, + Volume: xmVolume.XmVolume(32), + AutoVibrato: autovibrato.AutoVibratoConfig[period.Linear]{ + PC: xmPeriod.LinearConverter, + FactoryName: "vibrato", + }, + }, + Inst: &instrument.PCM[xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{Sample: sample}, + SampleRate: xmSystem.DefaultC4SampleRate, + } +} + +type xmTestVoice interface { + voiceCore.RenderSampler[period.Linear] + Setup(*instrument.Instrument[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) error +} + +func makeXMVoice() xmTestVoice { + cfg := voiceCore.VoiceConfig[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ + PC: xmPeriod.LinearConverter, + InitialVolume: xmVolume.XmVolume(32), + InitialMixing: xmVolume.DefaultXmMixingVolume, + PanEnabled: true, + InitialPan: xmPanning.DefaultPanning, + } + return New[period.Linear](cfg).(xmTestVoice) +} + +func TestXMVoiceSetupAndSample(t *testing.T) { + v := makeXMVoice() + inst := makeXMInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + if v.IsDone() { + t.Fatalf("expected voice active after setup") + } + + if err := v.Tick(); err != nil { + t.Fatalf("tick error: %v", err) + } + + samp := v.GetSample(sampling.Pos{}) + if samp.Channels != 2 { + t.Fatalf("expected stereo sample, got %d channels", samp.Channels) + } + if fv := v.GetFinalVolume(); fv <= 0 { + t.Fatalf("expected final volume > 0, got %v", fv) + } +} + +func TestXMVoiceStopMarksDone(t *testing.T) { + v := makeXMVoice() + inst := makeXMInstrument() + + if err := v.Setup(&inst); err != nil { + t.Fatalf("voice setup error: %v", err) + } + v.Stop() + if !v.IsDone() { + t.Fatalf("expected voice to be done after stop") + } + if fv := v.GetFinalVolume(); fv != 0 { + t.Fatalf("expected final volume to be 0 after stop, got %v", fv) + } +} diff --git a/format/xm/volume/volume_test.go b/format/xm/volume/volume_test.go new file mode 100644 index 0000000..1fadc9f --- /dev/null +++ b/format/xm/volume/volume_test.go @@ -0,0 +1,33 @@ +package volume + +import ( + "testing" + + mixvol "github.com/gotracker/playback/mixing/volume" +) + +func TestXmVolumeConversionsAndClamps(t *testing.T) { + if got := XmVolume(0x40).ToVolume(); got != 1 { + t.Fatalf("expected max xm volume to map to 1, got %v", got) + } + if XmVolume(0xff).IsUseInstrumentVol() == false { + t.Fatalf("expected use-instrument sentinel") + } + if XmVolume(0x41).IsInvalid() == false { + t.Fatalf("expected >0x40 (non-sentinel) invalid") + } + + if got := XmVolume(0x7f).FMA(2, 0); got != 0x40 { + t.Fatalf("expected FMA clamp to 0x40, got %d", got) + } + if got := XmVolume(1).AddDelta(-5); got != 0 { + t.Fatalf("expected AddDelta clamp to 0, got %d", got) + } + if got := XmVolume(0x40).AddDelta(5); got != 0x40 { + t.Fatalf("expected AddDelta clamp to max, got %d", got) + } + + if got := ToVolumeXM(mixvol.VolumeUseInstVol); got != XmVolume(0xff) { + t.Fatalf("expected sentinel round trip for VolumeUseInstVol") + } +} diff --git a/format/xm/xm_test.go b/format/xm/xm_test.go new file mode 100644 index 0000000..c122e20 --- /dev/null +++ b/format/xm/xm_test.go @@ -0,0 +1,13 @@ +package xm + +import ( + "bytes" + "testing" +) + +func TestLoadFromReaderRejectsInvalidData(t *testing.T) { + _, err := XM.LoadFromReader(bytes.NewReader([]byte("bad")), nil) + if err == nil { + t.Fatalf("expected error for invalid XM data") + } +} diff --git a/frequency/frequency_test.go b/frequency/frequency_test.go new file mode 100644 index 0000000..5575459 --- /dev/null +++ b/frequency/frequency_test.go @@ -0,0 +1,25 @@ +package frequency + +import "testing" + +func TestFrequencyGoStringFormatsUnits(t *testing.T) { + cases := []struct { + name string + in Frequency + exp string + }{ + {"hz", 440, "440.000000Hz"}, + {"khz", 20000, "20000kHz"}, + {"mhz", 3_200_000, "3200000MHz"}, + {"ghz", 5_000_000_000, "5000000000GHz"}, + {"thz", 7_000_000_000_000, "7000000000000THz"}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := tt.in.GoString(); got != tt.exp { + t.Fatalf("GoString() = %q, want %q", got, tt.exp) + } + }) + } +} diff --git a/index/index_test.go b/index/index_test.go new file mode 100644 index 0000000..1b90e0e --- /dev/null +++ b/index/index_test.go @@ -0,0 +1,37 @@ +package index + +import "testing" + +func TestChannelValidity(t *testing.T) { + if !Channel(3).IsValid() { + t.Fatalf("expected channel 3 to be valid") + } + if Channel(InvalidChannel).IsValid() { + t.Fatalf("expected invalid channel to be invalid") + } + + if !OPLChannel(1).IsValid() { + t.Fatalf("expected OPL channel 1 to be valid") + } + if OPLChannel(InvalidOPLChannel).IsValid() { + t.Fatalf("expected invalid OPL channel to be invalid") + } +} + +func TestRowIncrementOverflow(t *testing.T) { + r := Row(0) + if overflow := r.Increment(4); overflow { + t.Fatalf("did not expect overflow on first increment") + } + if r != 1 { + t.Fatalf("expected row to be 1, got %d", r) + } + + r = 2 + if overflow := r.Increment(3); !overflow { + t.Fatalf("expected overflow when incrementing at max") + } + if r != 3 { + t.Fatalf("expected row to wrap to 3 (one past max), got %d", r) + } +} diff --git a/instrument/instrument.go b/instrument/instrument.go index 43f6b2f..451c8ac 100644 --- a/instrument/instrument.go +++ b/instrument/instrument.go @@ -1,12 +1,12 @@ package instrument import ( - "github.com/gotracker/playback/mixing/sampling" - "github.com/gotracker/playback/mixing/volume" "github.com/heucuva/optional" "github.com/gotracker/playback/filter" "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/note" "github.com/gotracker/playback/period" "github.com/gotracker/playback/voice/autovibrato" diff --git a/instrument/instrument_test.go b/instrument/instrument_test.go new file mode 100644 index 0000000..bbd9a6c --- /dev/null +++ b/instrument/instrument_test.go @@ -0,0 +1,110 @@ +package instrument + +import ( + "testing" + + "github.com/gotracker/playback/filter" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/note" + "github.com/gotracker/playback/period" +) + +type stubPeriod struct{} + +func (stubPeriod) IsInvalid() bool { return false } + +type stubVol float32 + +func (stubVol) IsInvalid() bool { return false } +func (stubVol) IsUseInstrumentVol() bool { return false } +func (v stubVol) ToVolume() volume.Volume { return volume.Volume(v) } + +type stubPan float32 + +func (stubPan) IsInvalid() bool { return false } +func (stubPan) ToPosition() panning.Position { return panning.CenterAhead } + +type stubID struct{ idx int } + +func (stubID) IsEmpty() bool { return false } +func (id stubID) GetIndexAndSample() (int, int) { return id.idx, 0 } +func (id stubID) String() string { return "id" } + +type stubData struct{ l sampling.Pos } + +func (s stubData) GetLength() sampling.Pos { return s.l } + +func TestInstrumentFinetuneOverridesStatic(t *testing.T) { + inst := Instrument[stubPeriod, stubVol, stubVol, stubPan]{ + Static: StaticValues[stubPeriod, stubVol, stubVol, stubPan]{Finetune: 2}, + } + + if inst.GetFinetune() != 2 { + t.Fatalf("expected default finetune 2") + } + + inst.SetFinetune(5) + if inst.GetFinetune() != 5 { + t.Fatalf("expected override finetune 5") + } +} + +func TestInstrumentReleaseStopNotesForOPL2(t *testing.T) { + oplInst := Instrument[period.Amiga, stubVol, stubVol, stubPan]{ + Inst: &OPL2{}, + } + otherInst := Instrument[period.Amiga, stubVol, stubVol, stubPan]{ + Inst: stubData{}, + } + + n := note.StopOrReleaseNote{} + if !oplInst.IsReleaseNote(n) || !oplInst.IsStopNote(n) { + t.Fatalf("expected OPL2 stop/release to map to release/stop") + } + if otherInst.IsReleaseNote(n) || otherInst.IsStopNote(n) { + t.Fatalf("expected non-OPL2 stop/release to stay non-release/stop") + } +} + +func TestInstrumentGetters(t *testing.T) { + inst := Instrument[stubPeriod, stubVol, stubVol, stubPan]{ + Static: StaticValues[stubPeriod, stubVol, stubVol, stubPan]{ + Volume: stubVol(0.5), + RelativeNoteNumber: 3, + NewNoteAction: note.ActionRelease, + ID: stubID{idx: 4}, + VoiceFilter: filter.Info{Name: "vf"}, + PluginFilter: filter.Info{Name: "pf"}, + }, + Inst: stubData{l: sampling.Pos{Pos: 7}}, + SampleRate: 1234, + } + + if inst.GetDefaultVolume() != stubVol(0.5) || inst.GetDefaultVolumeGeneric() != volume.Volume(0.5) { + t.Fatalf("unexpected default volume values") + } + if inst.GetLength() != (sampling.Pos{Pos: 7}) { + t.Fatalf("unexpected length") + } + if inst.GetID().(stubID).idx != 4 { + t.Fatalf("unexpected id") + } + if inst.GetSemitoneShift() != 3 { + t.Fatalf("unexpected semitone shift") + } + if inst.GetNewNoteAction() != note.ActionRelease { + t.Fatalf("unexpected new note action") + } + if inst.GetVoiceFilterInfo().Name != "vf" || inst.GetPluginFilterInfo().Name != "pf" { + t.Fatalf("unexpected filter info") + } + if inst.GetSampleRate() != 1234 { + t.Fatalf("unexpected sample rate") + } + inst.SetSampleRate(4321) + if inst.GetSampleRate() != 4321 { + t.Fatalf("expected updated sample rate") + } +} diff --git a/instrument/pcm.go b/instrument/pcm.go index 597e508..f07b55a 100644 --- a/instrument/pcm.go +++ b/instrument/pcm.go @@ -1,6 +1,8 @@ package instrument import ( + "github.com/heucuva/optional" + "github.com/gotracker/playback/mixing/sampling" "github.com/gotracker/playback/voice/envelope" "github.com/gotracker/playback/voice/fadeout" @@ -8,7 +10,6 @@ import ( "github.com/gotracker/playback/voice/pcm" "github.com/gotracker/playback/voice/pitchpan" "github.com/gotracker/playback/voice/types" - "github.com/heucuva/optional" ) // PCM is a PCM-data instrument diff --git a/internal/examples/bufferload/example.go b/internal/examples/bufferload/example.go index d5d6875..27e000e 100644 --- a/internal/examples/bufferload/example.go +++ b/internal/examples/bufferload/example.go @@ -27,6 +27,10 @@ func ExamplePlayBufferToStdout() { sampleFormat = sampling.Format16BitLESigned ) + if os.Getenv("GOTRACKER_SKIP_EXAMPLES") == "1" { + return + } + // This is a list of features we can build up before handing off to the loader and player. var features []feature.Feature @@ -97,7 +101,7 @@ func ExamplePlayBufferToStdout() { // way for the calling application (our example) to get the generated output data in the form of // pre-mixed packets. These packets can be further mixed into audio streams for use with sound // devices and files. - out := sampler.NewSampler(sampleRate, channels, func(premix *output.PremixData) { + out := sampler.NewSampler(sampleRate, channels, 1.0, func(premix *output.PremixData) { // put our premixed data into the premixDataChannel we built earlier. premixDataChannel <- premix }) @@ -132,7 +136,7 @@ playerUpdateLoop: // row tick's worth of pre-mix data and call our callback function specified in the Sampler // stage we specified earlier. Normally, we would want to set up a goroutine for this call to // run in, but in this example, we're fine to do a simple loop. - if err := player.Tick(out); err != nil { + if err := player.Advance(); err != nil { // In the event we finish our song, we will receive a specific error message informing us // we can quit. if errors.Is(err, song.ErrStopSong) { @@ -141,6 +145,13 @@ playerUpdateLoop: // If we get here, then we don't know what the error is... panic(err) } + + if err := player.Render(out); err != nil { + if errors.Is(err, song.ErrStopSong) { + break playerUpdateLoop + } + panic(err) + } } // We're done! diff --git a/internal/examples/bufferload/example_test.go b/internal/examples/bufferload/example_test.go new file mode 100644 index 0000000..d743fdf --- /dev/null +++ b/internal/examples/bufferload/example_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + "time" +) + +func TestExamplePlayBufferToStdoutSkips(t *testing.T) { + t.Setenv("GOTRACKER_SKIP_EXAMPLES", "1") + done := make(chan struct{}) + go func() { + ExamplePlayBufferToStdout() + close(done) + }() + + select { + case <-done: + // ok + case <-time.After(2 * time.Second): + t.Fatalf("example did not return promptly when skip env set") + } +} + +func TestModfileDataPresent(t *testing.T) { + if len(modfile) == 0 { + t.Fatalf("modfile bytes should not be empty") + } + if modfile[0] != '1' { + t.Fatalf("unexpected modfile header") + } +} diff --git a/internal/examples/fileload/example.go b/internal/examples/fileload/example.go index fe3e90c..01e5dbf 100644 --- a/internal/examples/fileload/example.go +++ b/internal/examples/fileload/example.go @@ -27,6 +27,10 @@ func ExamplePlayFileToStdout() { sampleFormat = sampling.Format16BitLESigned ) + if os.Getenv("GOTRACKER_SKIP_EXAMPLES") == "1" { + return + } + // This is a list of features we can build up before handing off to the loader and player. var features []feature.Feature @@ -96,7 +100,7 @@ func ExamplePlayFileToStdout() { // way for the calling application (our example) to get the generated output data in the form of // pre-mixed packets. These packets can be further mixed into audio streams for use with sound // devices and files. - out := sampler.NewSampler(sampleRate, channels, func(premix *output.PremixData) { + out := sampler.NewSampler(sampleRate, channels, 1.0, func(premix *output.PremixData) { // put our premixed data into the premixDataChannel we built earlier. premixDataChannel <- premix }) @@ -131,7 +135,16 @@ playerUpdateLoop: // row tick's worth of pre-mix data and call our callback function specified in the Sampler // stage we specified earlier. Normally, we would want to set up a goroutine for this call to // run in, but in this example, we're fine to do a simple loop. - if err := player.Tick(out); err != nil { + if err := player.Advance(); err != nil { + // In the event we finish our song, we will receive a specific error message informing us + // we can quit. + if errors.Is(err, song.ErrStopSong) { + break playerUpdateLoop + } + panic(err) + } + + if err := player.Render(out); err != nil { // In the event we finish our song, we will receive a specific error message informing us // we can quit. if errors.Is(err, song.ErrStopSong) { diff --git a/internal/examples/fileload/example_test.go b/internal/examples/fileload/example_test.go new file mode 100644 index 0000000..3a6a00c --- /dev/null +++ b/internal/examples/fileload/example_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestExamplePlayFileToStdoutSkips(t *testing.T) { + t.Setenv("GOTRACKER_SKIP_EXAMPLES", "1") + done := make(chan struct{}) + go func() { + ExamplePlayFileToStdout() + close(done) + }() + + select { + case <-done: + // ok + case <-time.After(2 * time.Second): + t.Fatalf("example did not return promptly when skip env set") + } +} + +func TestExampleFileExists(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + dir := filepath.Dir(file) + path := filepath.Join(dir, "..", "..", "..", "test", "ode_to_protracker.mod") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected test module to exist at %s: %v", path, err) + } +} diff --git a/memory/value_test.go b/memory/value_test.go new file mode 100644 index 0000000..3c621c4 --- /dev/null +++ b/memory/value_test.go @@ -0,0 +1,29 @@ +package memory + +import "testing" + +func TestValueCoalesceAndXY(t *testing.T) { + var v Value[uint8] + + if got := v.Coalesce(0); got != 0 { + t.Fatalf("expected zero fallback when empty, got %d", got) + } + + if got := v.Coalesce(0x3c); got != 0x3c { + t.Fatalf("expected stored value, got %d", got) + } + if got := v.Coalesce(0); got != 0x3c { + t.Fatalf("expected coalesce to reuse memory value, got %d", got) + } + + v.Reset() + hi, lo := v.CoalesceXY(0xAB) + if hi != 0x0a || lo != 0x0b { + t.Fatalf("unexpected XY split: hi=%X lo=%X", hi, lo) + } + + hi, lo = v.CoalesceXY(0) + if hi != 0x0a || lo != 0x0b { + t.Fatalf("expected XY split to persist stored value: hi=%X lo=%X", hi, lo) + } +} diff --git a/mixing/mixer_test.go b/mixing/mixer_test.go new file mode 100644 index 0000000..b984b33 --- /dev/null +++ b/mixing/mixer_test.go @@ -0,0 +1,134 @@ +package mixing + +import ( + "bytes" + "testing" + + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" +) + +type fakePanMixer struct { + matrix volume.Matrix + calls int +} + +func (p *fakePanMixer) ApplyToMatrix(mtx volume.Matrix) volume.Matrix { + p.calls++ + return p.matrix.ApplyToMatrix(mtx) +} + +func (p *fakePanMixer) Apply(v volume.Volume) volume.Matrix { + p.calls++ + return p.matrix.Apply(v) +} + +func TestMixerFlattenAppliesPanAndVolume(t *testing.T) { + pan := &fakePanMixer{matrix: volume.Matrix{StaticMatrix: volume.StaticMatrix{1, 0.5}, Channels: 2}} + flushCalls := 0 + row := []ChannelData{ + { + { + Data: MixBuffer{ + {StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}, + {StaticMatrix: volume.StaticMatrix{2, 2}, Channels: 2}, + }, + PanMatrix: pan, + Volume: 0.5, + Pos: 0, + Flush: func() { flushCalls++ }, + }, + }, + } + + m := Mixer{Channels: 2} + out := m.Flatten(2, row, 1, sampling.Format8BitUnsigned) + + expected := []byte{0xBF, 0x9F, 0xFF, 0xBF} + if !bytes.Equal(out, expected) { + t.Fatalf("unexpected mixed data: got %v want %v", out, expected) + } + if flushCalls != 1 { + t.Fatalf("flush should be invoked once, got %d", flushCalls) + } + if pan.calls != 1 { + t.Fatalf("pan Apply should be called once, got %d", pan.calls) + } +} + +func TestMixerFlattenToInts(t *testing.T) { + pan := &fakePanMixer{matrix: volume.Matrix{StaticMatrix: volume.StaticMatrix{1, 0.5}, Channels: 2}} + row := []ChannelData{ + { + { + Data: MixBuffer{ + {StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}, + {StaticMatrix: volume.StaticMatrix{2, 2}, Channels: 2}, + }, + PanMatrix: pan, + Volume: 0.5, + Pos: 0, + }, + }, + } + + m := Mixer{Channels: 2} + out := m.FlattenToInts(2, 2, 16, row, 1) + + if len(out) != 2 { + t.Fatalf("expected 2 output channels, got %d", len(out)) + } + + expectedLeft := []int32{16339, 32678} + expectedRight := []int32{8169, 16339} + + if !bytes.Equal(int32ToBytes(out[0]), int32ToBytes(expectedLeft)) { + t.Fatalf("unexpected left channel: got %v want %v", out[0], expectedLeft) + } + if !bytes.Equal(int32ToBytes(out[1]), int32ToBytes(expectedRight)) { + t.Fatalf("unexpected right channel: got %v want %v", out[1], expectedRight) + } +} + +func TestMixerFlattenToBuffers(t *testing.T) { + pan := &fakePanMixer{matrix: volume.Matrix{StaticMatrix: volume.StaticMatrix{1, 0.5}, Channels: 2}} + row := []ChannelData{ + { + { + Data: MixBuffer{ + {StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2}, + {StaticMatrix: volume.StaticMatrix{2, 2}, Channels: 2}, + }, + PanMatrix: pan, + Volume: 0.5, + Pos: 0, + }, + }, + } + + bufA := make([]byte, 2) + bufB := make([]byte, 2) + + m := Mixer{Channels: 2} + m.FlattenTo([][]byte{bufA, bufB}, 2, 2, row, 1, sampling.Format8BitUnsigned) + + expectedA := []byte{0xBF, 0x9F} + expectedB := []byte{0xFF, 0xBF} + + if !bytes.Equal(bufA, expectedA) { + t.Fatalf("unexpected first buffer: got %v want %v", bufA, expectedA) + } + if !bytes.Equal(bufB, expectedB) { + t.Fatalf("unexpected second buffer: got %v want %v", bufB, expectedB) + } +} + +// int32ToBytes provides a stable comparison helper without float rounding differences. +func int32ToBytes(in []int32) []byte { + buf := bytes.Buffer{} + for _, v := range in { + // big endian is fine; we just need byte stability for comparison + buf.Write([]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) + } + return buf.Bytes() +} diff --git a/mixing/panmixer_stereo.go b/mixing/panmixer_stereo.go index 4dbe47f..f93be00 100644 --- a/mixing/panmixer_stereo.go +++ b/mixing/panmixer_stereo.go @@ -12,15 +12,33 @@ var PanMixerStereo PanMixer = &panMixerStereo{} type panMixerStereo struct{} +// Dolby Pro Logic II surround encoding: feed surrounds as equal-magnitude, +// opposite-phase signals to L/R (nominal +90/-90deg). We approximate using +// out-of-phase amplitudes and bypass additional stereo separation to +// preserve phase cues for downstream decoders. +const surroundScale = volume.Volume(1 / math.Sqrt2) + func (p panMixerStereo) GetMixingMatrix(pan panning.Position, stereoSeparation float32) panning.PanMixer { - s, c := math.Sincos(float64(pan.Angle) * 2.0) + if pan == panning.SurroundPosition { + return panning.MixerStereo{ + Matrix: volume.Matrix{ + StaticMatrix: volume.StaticMatrix{surroundScale, -surroundScale}, + Channels: 2, + }, + StereoSeparationFunc: func(in volume.Matrix) volume.Matrix { + return in.AsStereo() + }, + } + } + + s, c := math.Sincos(float64(pan.Angle)) var d volume.Volume if pan.Distance > 0 { d = 1 / volume.Volume(pan.Distance*pan.Distance) } - l := d * volume.StereoCoeff * volume.Volume(c-s) - r := d * volume.StereoCoeff * volume.Volume(c+s) + l := d * volume.Volume(s) + r := d * volume.Volume(c) midCoeff := volume.Volume(1.0 / (1.0 + stereoSeparation)) sideCoeff := volume.Volume(stereoSeparation * 0.5) diff --git a/mixing/panmixer_test.go b/mixing/panmixer_test.go new file mode 100644 index 0000000..720858b --- /dev/null +++ b/mixing/panmixer_test.go @@ -0,0 +1,120 @@ +package mixing + +import ( + "math" + "testing" + + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/volume" +) + +func almostEqual(a, b float64, tol float64) bool { + return math.Abs(a-b) <= tol +} + +func TestPanMixerMonoMatrix(t *testing.T) { + mixer := GetPanMixer(1) + if mixer == nil { + t.Fatalf("expected mono pan mixer") + } + if mixer.NumChannels() != 1 { + t.Fatalf("expected mono mixer to report 1 channel, got %d", mixer.NumChannels()) + } + pan := panning.Position{Angle: 0, Distance: 1} + m := mixer.GetMixingMatrix(pan, 0) + matrix := m.Apply(volume.Volume(1)) + if matrix.Channels != 1 || !almostEqual(float64(matrix.StaticMatrix[0]), 1.0, 1e-6) { + t.Fatalf("unexpected mono matrix: %+v", matrix) + } +} + +func TestPanMixerStereoCenteredCoefficients(t *testing.T) { + mixer := GetPanMixer(2) + pan := panning.Position{Angle: 0, Distance: 1} + pm := mixer.GetMixingMatrix(pan, 1) + + stereo, ok := pm.(panning.MixerStereo) + if !ok { + t.Fatalf("expected MixerStereo") + } + + matrix := stereo.Apply(volume.Volume(1)) + if matrix.Channels != 2 { + t.Fatalf("expected 2 channels, got %d", matrix.Channels) + } + if !almostEqual(float64(matrix.StaticMatrix[0]), 0, 1e-6) || + !almostEqual(float64(matrix.StaticMatrix[1]), 1, 1e-6) { + t.Fatalf("unexpected coefficients: %+v", matrix.StaticMatrix) + } +} + +func TestPanMixerStereoApplyRespectsSeparation(t *testing.T) { + mixer := GetPanMixer(2) + pan := panning.Position{Angle: 0.2, Distance: 1} + pm := mixer.GetMixingMatrix(pan, 0) + + stereo := pm.(panning.MixerStereo) + + matrix := stereo.Apply(volume.Volume(1)) + if matrix.Channels != 2 { + t.Fatalf("expected 2 channels, got %d", matrix.Channels) + } + + if !almostEqual(float64(matrix.StaticMatrix[0]), float64(matrix.StaticMatrix[1]), 1e-6) { + t.Fatalf("expected separation=0 to collapse to mono, got L=%v R=%v", matrix.StaticMatrix[0], matrix.StaticMatrix[1]) + } +} + +func TestPanMixerStereoSeparationZeroCollapsesToMono(t *testing.T) { + mixer := GetPanMixer(2) + pm := mixer.GetMixingMatrix(panning.Position{Angle: 0, Distance: 1}, 0) + + stereo := pm.(panning.MixerStereo) + + in := volume.Matrix{StaticMatrix: volume.StaticMatrix{1, -1}, Channels: 2} + wet := stereo.StereoSeparationFunc(in) + + if wet.Channels != 2 { + t.Fatalf("expected 2 channels after separation") + } + if !almostEqual(float64(wet.StaticMatrix[0]), 0, 1e-6) || !almostEqual(float64(wet.StaticMatrix[1]), 0, 1e-6) { + t.Fatalf("expected collapse to mono zero: %+v", wet.StaticMatrix) + } +} + +func TestPanMixerStereoSeparationFullKeepsChannels(t *testing.T) { + mixer := GetPanMixer(2) + pm := mixer.GetMixingMatrix(panning.Position{Angle: 0, Distance: 1}, 1) + + stereo := pm.(panning.MixerStereo) + + in := volume.Matrix{StaticMatrix: volume.StaticMatrix{0.25, -0.25}, Channels: 2} + wet := stereo.StereoSeparationFunc(in) + + if wet != in { + t.Fatalf("expected stereo separation 1 to keep channels: got %+v want %+v", wet, in) + } +} + +func TestPanMixerQuadAngleZero(t *testing.T) { + mixer := GetPanMixer(4) + pan := panning.Position{Angle: 0, Distance: 1} + pm := mixer.GetMixingMatrix(pan, 0) + + matrix := pm.Apply(volume.Volume(1)) + if matrix.Channels != 4 { + t.Fatalf("expected 4-channel matrix, got %d", matrix.Channels) + } + expected := volume.StaticMatrix{0, 1, 0, 1} + for i := 0; i < 4; i++ { + if !almostEqual(float64(matrix.StaticMatrix[i]), float64(expected[i]), 1e-6) { + t.Fatalf("channel %d mismatch: got %v want %v", i, matrix.StaticMatrix[i], expected[i]) + } + } +} + +func TestGetPanMixerUnknownChannels(t *testing.T) { + if m := GetPanMixer(3); m != nil { + t.Fatalf("expected nil mixer for unsupported channel count, got %#v", m) + } +} diff --git a/mixing/panning/mixer_stereo.go b/mixing/panning/mixer_stereo.go index f844e85..0bbac0a 100644 --- a/mixing/panning/mixer_stereo.go +++ b/mixing/panning/mixer_stereo.go @@ -13,5 +13,6 @@ func (m MixerStereo) ApplyToMatrix(mtx volume.Matrix) volume.Matrix { } func (m MixerStereo) Apply(vol volume.Volume) volume.Matrix { - return m.Matrix.Apply(vol) + dry := m.Matrix.Apply(vol) + return m.StereoSeparationFunc(dry) } diff --git a/mixing/panning/mixer_stereo_test.go b/mixing/panning/mixer_stereo_test.go new file mode 100644 index 0000000..c252b5d --- /dev/null +++ b/mixing/panning/mixer_stereo_test.go @@ -0,0 +1,23 @@ +package panning + +import ( + "testing" + + "github.com/gotracker/playback/mixing/volume" +) + +func TestMixerStereoApplyUsesSeparation(t *testing.T) { + mtx := volume.Matrix{StaticMatrix: volume.StaticMatrix{2, 3}, Channels: 2} + m := MixerStereo{ + Matrix: volume.Matrix{StaticMatrix: volume.StaticMatrix{1, 2}, Channels: 2}, + StereoSeparationFunc: func(in volume.Matrix) volume.Matrix { return in.Apply(volume.Volume(0.5)) }, + } + + got := m.ApplyToMatrix(mtx) + if got.Channels != 2 { + t.Fatalf("expected stereo output, got %d", got.Channels) + } + if got.StaticMatrix[0] != volume.Volume(1) || got.StaticMatrix[1] != volume.Volume(3) { + t.Fatalf("unexpected matrix result: %+v", got.StaticMatrix) + } +} diff --git a/mixing/panning/position.go b/mixing/panning/position.go index fda9fe1..8da3ae9 100644 --- a/mixing/panning/position.go +++ b/mixing/panning/position.go @@ -13,6 +13,12 @@ type Position struct { var ( // CenterAhead is the position directly ahead of the listener CenterAhead = MakeStereoPosition(0.5, 0, 1) + + // SurroundPosition is the position directly behind the listener + SurroundPosition = Position{ + Angle: float32(math.Pi), + Distance: 1.0, + } ) // MakeStereoPosition creates a stereo panning position based on a linear interpolation between `leftValue` and `RightValue` diff --git a/mixing/panning/position_test.go b/mixing/panning/position_test.go new file mode 100644 index 0000000..2edbc75 --- /dev/null +++ b/mixing/panning/position_test.go @@ -0,0 +1,39 @@ +package panning + +import ( + "math" + "testing" +) + +func TestMakeStereoPositionRoundTrip(t *testing.T) { + pos := MakeStereoPosition(0, -1, 1) + if pos.Distance != 1 { + t.Fatalf("expected distance 1, got %v", pos.Distance) + } + if math.Abs(float64(pos.Angle-math.Pi/4)) > 1e-6 { + t.Fatalf("unexpected angle: %v", pos.Angle) + } + + value := FromStereoPosition(pos, -1, 1) + if math.Abs(float64(value)) > 1e-6 { + t.Fatalf("expected value round trip to 0, got %v", value) + } +} + +func TestMakeStereoPositionPanicsOnEqualBounds(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when bounds are equal") + } + }() + _ = MakeStereoPosition(0, 1, 1) +} + +func TestFromStereoPositionPanicsOnEqualBounds(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when bounds are equal") + } + }() + _ = FromStereoPosition(Position{}, 2, 2) +} diff --git a/mixing/sampling/position_test.go b/mixing/sampling/position_test.go new file mode 100644 index 0000000..9d9bfeb --- /dev/null +++ b/mixing/sampling/position_test.go @@ -0,0 +1,14 @@ +package sampling + +import "testing" + +func TestPosAddCarriesFraction(t *testing.T) { + p := Pos{Pos: 1, Frac: 0.75} + p.Add(0.5) + if p.Pos != 2 { + t.Fatalf("expected pos carry to 2, got %d", p.Pos) + } + if p.Frac < 0.24 || p.Frac > 0.26 { + t.Fatalf("expected frac around 0.25, got %f", p.Frac) + } +} diff --git a/mixing/volume/volume.go b/mixing/volume/volume.go index bf099e9..0250f5e 100644 --- a/mixing/volume/volume.go +++ b/mixing/volume/volume.go @@ -8,6 +8,15 @@ import ( // Volume is a mixable volume type Volume float32 +const denormThreshold Volume = 1e-18 + +func clearDenorm(v Volume) Volume { + if v > -denormThreshold && v < denormThreshold { + return 0 + } + return v +} + var ( // VolumeUseInstVol tells the system to use the volume stored on the instrument // This is useful for trackers and other musical applications @@ -71,7 +80,7 @@ func (v Volume) ToUintSample(bitsPerSample int) uint32 { // Apply multiplies the volume to 1 sample, then returns the results func (v Volume) ApplySingle(samp Volume) Volume { - return samp * v + return clearDenorm(samp * v) } // Apply multiplies the volume to 1 sample, then returns the results diff --git a/mixing/volume/volume_test.go b/mixing/volume/volume_test.go new file mode 100644 index 0000000..496973c --- /dev/null +++ b/mixing/volume/volume_test.go @@ -0,0 +1,58 @@ +package volume + +import "testing" + +func TestWithOverflowProtectionClamps(t *testing.T) { + cases := []struct { + name string + in Volume + exp float64 + }{ + {"within", 0.5, 0.5}, + {"pos overflow", 2, 1}, + {"neg overflow", -2, -1}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := tt.in.WithOverflowProtection(); got != tt.exp { + t.Fatalf("WithOverflowProtection() = %v, want %v", got, tt.exp) + } + }) + } +} + +func TestToSampleConversions(t *testing.T) { + v := Volume(0.5) + if got := v.ToSample(8).(int8); got != 64 { + t.Fatalf("8-bit sample = %d, want 64", got) + } + if got := v.ToSample(16).(int16); got != 16339 { + t.Fatalf("16-bit sample = %d, want 16339", got) + } + if got := Volume(2).ToSample(16).(int16); got != 32678 { + t.Fatalf("16-bit clamped sample = %d, want 32678", got) + } +} + +func TestToUintSampleConversions(t *testing.T) { + if got := Volume(0).ToUintSample(8); got != 0x80 { + t.Fatalf("uint8 sample = %d, want 128", got) + } + if got := Volume(1).ToUintSample(8); got != 0xFF { + t.Fatalf("uint8 sample at max = %d, want 255", got) + } +} + +func TestApplyHelpers(t *testing.T) { + v := Volume(0.5) + if got := v.ApplySingle(0.8); got != Volume(0.4) { + t.Fatalf("ApplySingle = %v, want 0.4", got) + } + + in := []Volume{1, -1} + out := v.ApplyMultiple(in) + if len(out) != 2 || out[0] != 0.5 || out[1] != -0.5 { + t.Fatalf("ApplyMultiple = %#v, want [0.5 -0.5]", out) + } +} diff --git a/oscillator/impulsetracker.go b/oscillator/impulsetracker.go index d9609ff..81165da 100644 --- a/oscillator/impulsetracker.go +++ b/oscillator/impulsetracker.go @@ -63,6 +63,7 @@ type impulseOscillator struct { Table oscillator.WaveTableSelect Pos uint8 Mul uint8 + Delay uint8 // only used for random waveforms (speed as delay) } // NewImpulseTrackerOscillator creates a new ImpulseTracker-compatible oscillator @@ -77,6 +78,7 @@ func (o impulseOscillator) Clone() oscillator.Oscillator { Table: o.Table, Pos: 0, Mul: o.Mul, + Delay: 0, } } @@ -98,7 +100,12 @@ func (o *impulseOscillator) GetWave(depth float32) float32 { vib = -1.0 } case WaveTableSelectRandomRetrigger, WaveTableSelectRandomContinue: - vib = GetImpulseSine(rand.Intn(0xff)) + if o.Delay > 0 { + o.Delay-- + vib = 0 + } else { + vib = GetImpulseSine(rand.Intn(0xff)) + } } delta := vib * depth return delta @@ -106,6 +113,13 @@ func (o *impulseOscillator) GetWave(depth float32) float32 { // Advance advances the oscillator position by the specified amount func (o *impulseOscillator) Advance(speed int) { + if o.Table == WaveTableSelectRandomRetrigger || o.Table == WaveTableSelectRandomContinue { + // IT random waveform: speed acts as a delay between random picks + if o.Delay == 0 { + o.Delay = uint8(speed) + } + return + } o.Pos += uint8(speed) * o.Mul } diff --git a/oscillator/protracker_test.go b/oscillator/protracker_test.go new file mode 100644 index 0000000..24aab43 --- /dev/null +++ b/oscillator/protracker_test.go @@ -0,0 +1,44 @@ +package oscillator + +import "testing" + +func TestGetProtrackerSineWrapsAndSigns(t *testing.T) { + if v := GetProtrackerSine(0); v != 0 { + t.Fatalf("expected sine at 0 to be 0, got %v", v) + } + + posPos := GetProtrackerSine(8) // table value 180/255 + posNeg := GetProtrackerSine(40) + if posPos <= 0 { + t.Fatalf("expected positive sine at pos 8, got %v", posPos) + } + if posNeg >= 0 { + t.Fatalf("expected negative sine after wrap, got %v", posNeg) + } + if diff := posPos - (-posNeg); diff > 1e-4 { + t.Fatalf("expected magnitudes to mirror within tolerance, diff=%v", diff) + } +} + +func TestProtrackerOscillatorAdvanceAndReset(t *testing.T) { + osc := NewProtrackerOscillator().(*protrackerOscillator) + osc.SetWaveform(WaveTableSelectSineRetrigger) + + osc.Pos = 60 + osc.Advance(5) // wraps past 63 + if osc.Pos != 1 { + t.Fatalf("expected position to wrap to 1, got %d", osc.Pos) + } + + osc.HardReset() + if osc.Pos != 0 { + t.Fatalf("expected hard reset to zero position, got %d", osc.Pos) + } + + osc.Advance(16) + wave := osc.GetWave(0.5) + expected := GetProtrackerSine(16) * 0.5 + if wave != expected { + t.Fatalf("unexpected wave output: got %v want %v", wave, expected) + } +} diff --git a/output/premixdata_test.go b/output/premixdata_test.go new file mode 100644 index 0000000..7719229 --- /dev/null +++ b/output/premixdata_test.go @@ -0,0 +1,19 @@ +package output + +import "testing" + +func TestPremixDataZeroValue(t *testing.T) { + var p PremixData + if p.SamplesLen != 0 { + t.Fatalf("expected SamplesLen=0, got %d", p.SamplesLen) + } + if p.Data != nil { + t.Fatalf("expected nil Data slice, got %#v", p.Data) + } + if p.MixerVolume != 0 { + t.Fatalf("expected zero MixerVolume, got %v", p.MixerVolume) + } + if p.Userdata != nil { + t.Fatalf("expected nil Userdata, got %#v", p.Userdata) + } +} diff --git a/period/amiga_test.go b/period/amiga_test.go new file mode 100644 index 0000000..2abdf5a --- /dev/null +++ b/period/amiga_test.go @@ -0,0 +1,25 @@ +package period + +import "testing" + +func TestAmigaPortaDownUp(t *testing.T) { + minP, maxP := Amiga(100), Amiga(1000) + + if got := Amiga(500).PortaDown(Delta(10), minP, maxP, false); got != 510 { + t.Fatalf("PortaDown expected 510, got %d", got) + } + + if got := Amiga(500).PortaUp(Delta(10), minP, maxP, false); got != 490 { + t.Fatalf("PortaUp expected 490, got %d", got) + } +} + +func TestAmigaIsInvalid(t *testing.T) { + if !Amiga(0).IsInvalid() { + t.Fatalf("IsInvalid should be true for 0") + } + + if Amiga(500).IsInvalid() { + t.Fatalf("IsInvalid should be false for non-zero period") + } +} diff --git a/period/converter_test.go b/period/converter_test.go new file mode 100644 index 0000000..4450ed3 --- /dev/null +++ b/period/converter_test.go @@ -0,0 +1,79 @@ +package period + +import ( + "testing" + + "github.com/gotracker/playback/note" + "github.com/gotracker/playback/system" +) + +func TestLinearConverterSamplerAddAndPeriod(t *testing.T) { + sys := system.ClockedSystem{ + BaseFinetunes: 0, + FinetunesPerOctave: 12, + FinetunesPerNote: 1, + } + conv := LinearConverter{System: sys} + + p := Linear{Finetune: 12} + + freq := conv.GetFrequency(p) + if freq != 2 { + t.Fatalf("expected frequency 2, got %v", freq) + } + + add := conv.GetSamplerAdd(p, 10, 20) + if add != 1 { + t.Fatalf("expected sampler add 1, got %v", add) + } + + n := note.Normal(note.NewSemitone(note.KeyC, 1)) + got := conv.GetPeriod(n) + if got.Finetune != 12 { + t.Fatalf("expected finetune 12 from note C1, got %d", got.Finetune) + } + + empty := conv.GetPeriod(note.EmptyNote{}) + if empty.Finetune != 0 { + t.Fatalf("expected empty note to yield zero period") + } +} + +func TestAmigaConverterSamplerAddAndPeriod(t *testing.T) { + var semis [note.NumKeys]uint16 + semis[note.KeyC] = 1000 + + sys := system.ClockedSystem{ + BaseClock: 1000, + CommonRate: 10, + SemitonePeriods: semis, + OctaveShift: 0, + } + + conv := AmigaConverter{System: sys, MinPeriod: 100, MaxPeriod: 900, SlideTo0Allowed: false} + + n := note.Normal(note.NewSemitone(note.KeyC, 1)) + p := conv.GetPeriod(n) + if p != 500 { + t.Fatalf("expected period 500, got %d", p) + } + + freq := conv.GetFrequency(p) + if freq != 2 { + t.Fatalf("expected frequency 2, got %v", freq) + } + + add := conv.GetSamplerAdd(p, 20, 40) + if add != 0.1 { + t.Fatalf("expected sampler add 0.1, got %v", add) + } + + // invalid period should yield zero frequency/add + zero := conv.GetFrequency(Amiga(0)) + if zero != 0 { + t.Fatalf("expected zero frequency for invalid period") + } + if conv.GetSamplerAdd(Amiga(0), 20, 40) != 0 { + t.Fatalf("expected zero sampler add for invalid period") + } +} diff --git a/period/linear.go b/period/linear.go index 036a9ee..c3bd592 100644 --- a/period/linear.go +++ b/period/linear.go @@ -3,9 +3,10 @@ package period import ( "fmt" + "github.com/heucuva/comparison" + "github.com/gotracker/playback/note" "github.com/gotracker/playback/util" - "github.com/heucuva/comparison" ) // Linear is a linear period, based on semitone and finetune values diff --git a/period/linear_test.go b/period/linear_test.go new file mode 100644 index 0000000..fa33422 --- /dev/null +++ b/period/linear_test.go @@ -0,0 +1,47 @@ +package period + +import "testing" + +func TestLinearAddClampsAndPorta(t *testing.T) { + p := Linear{Finetune: 5} + if got := p.Add(Delta(-10)); got.Finetune != 1 { + t.Fatalf("expected clamp to 1, got %d", got.Finetune) + } + + a := Linear{Finetune: 10} + b := Linear{Finetune: 12} + if a.PortaUp(1).Finetune != 11 { + t.Fatalf("porta up failed") + } + if b.PortaDown(2).Finetune != 10 { + t.Fatalf("porta down failed") + } + + c := Linear{Finetune: 10} + target := Linear{Finetune: 12} + if res := c.PortaTo(1, target); res.Finetune != 11 { + t.Fatalf("porta to upward failed, got %d", res.Finetune) + } + if res := target.PortaTo(1, c); res.Finetune != 11 { + t.Fatalf("porta to downward failed, got %d", res.Finetune) + } +} + +func TestLinearCompareAndLerp(t *testing.T) { + a := Linear{Finetune: 5} + b := Linear{Finetune: 6} + if cmp := a.Compare(b); cmp != -1 { + t.Fatalf("expected a < b") + } + if cmp := b.Compare(a); cmp != 1 { + t.Fatalf("expected b > a") + } + if cmp := a.Compare(a); cmp != 0 { + t.Fatalf("expected equal compare") + } + + lerped := a.Lerp(0.5, b).(Linear) + if lerped.Finetune != 5 { + t.Fatalf("unexpected lerp result: %v", lerped.Finetune) + } +} diff --git a/playback.go b/playback.go index 3709baf..417acad 100644 --- a/playback.go +++ b/playback.go @@ -9,11 +9,15 @@ import ( type Playback interface { Configure([]feature.Feature) error - // runs a single tick - // if the onGenerate function was provided to the SetupSampler call, - // then the generated output will be provided through it + // Tick is a convenience for Advance+Render; sampler must be non-nil. Tick(s *sampler.Sampler) error + // Advance progresses sequencing without rendering audio. + Advance() error + + // Render produces audio for the current state using the provided sampler. + Render(s *sampler.Sampler) error + GetNumOrders() int CanOrderLoop() bool GetName() string diff --git a/player/feature/preconvertsamples_test.go b/player/feature/preconvertsamples_test.go new file mode 100644 index 0000000..5204beb --- /dev/null +++ b/player/feature/preconvertsamples_test.go @@ -0,0 +1,21 @@ +package feature + +import ( + "testing" + + "github.com/gotracker/playback/voice/pcm" +) + +func TestUseNativeSampleFormat(t *testing.T) { + pc := UseNativeSampleFormat(true) + cfg, ok := pc.(PreConvertSamples) + if !ok { + t.Fatalf("expected PreConvertSamples, got %T", pc) + } + if !cfg.Enabled { + t.Fatalf("expected Enabled=true") + } + if cfg.DesiredFormat != pcm.SampleDataFormatNative { + t.Fatalf("unexpected DesiredFormat: %v", cfg.DesiredFormat) + } +} diff --git a/player/feature/quirks.go b/player/feature/quirks.go new file mode 100644 index 0000000..fe7a9f1 --- /dev/null +++ b/player/feature/quirks.go @@ -0,0 +1,11 @@ +package feature + +import "github.com/heucuva/optional" + +// QuirksMode carries overrides for tracker quirks handling. +// Profile is optional and maps to a tracker profile name; LinearSlides toggles +// whether linear frequency slides should be used regardless of the file flag. +type QuirksMode struct { + Profile optional.Value[string] + LinearSlides optional.Value[bool] +} diff --git a/player/machine/channel.go b/player/machine/channel.go index e2eed15..b21486e 100644 --- a/player/machine/channel.go +++ b/player/machine/channel.go @@ -1,6 +1,8 @@ package machine import ( + "github.com/heucuva/optional" + "github.com/gotracker/playback/filter" "github.com/gotracker/playback/index" "github.com/gotracker/playback/instrument" @@ -11,7 +13,6 @@ import ( "github.com/gotracker/playback/song" "github.com/gotracker/playback/voice" "github.com/gotracker/playback/voice/oscillator" - "github.com/heucuva/optional" ) type channel[TPeriod Period, TGlobalVolume, TMixingVolume, TVolume Volume, TPanning Panning] struct { diff --git a/player/machine/hardware_synth.go b/player/machine/hardware_synth.go new file mode 100644 index 0000000..4d85b4c --- /dev/null +++ b/player/machine/hardware_synth.go @@ -0,0 +1,16 @@ +package machine + +import ( + "github.com/gotracker/playback/mixing" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/voice/mixer" +) + +// mixerVolumeAdjuster updates mixer volume to accommodate hardware synth output. +type mixerVolumeAdjuster func(volume.Volume) volume.Volume + +// hardwareSynth abstracts rendering of hardware synth frames (e.g., OPL2). +type hardwareSynth interface { + RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) +} diff --git a/player/machine/instruction/instruction_test.go b/player/machine/instruction/instruction_test.go new file mode 100644 index 0000000..def0e63 --- /dev/null +++ b/player/machine/instruction/instruction_test.go @@ -0,0 +1,10 @@ +package instruction + +import "testing" + +func TestValueStoresTypedValue(t *testing.T) { + v := Value[string]{Value: "hello"} + if v.Value != "hello" { + t.Fatalf("Value stored %q, want %q", v.Value, "hello") + } +} diff --git a/player/machine/machine.go b/player/machine/machine.go index 138bb95..2846e36 100644 --- a/player/machine/machine.go +++ b/player/machine/machine.go @@ -46,6 +46,8 @@ type MachineTicker interface { MachineInfo Tick(s *sampler.Sampler) error + Advance() error + Render(s *sampler.Sampler) error } type Machine[TPeriod Period, TGlobalVolume, TMixingVolume, TVolume Volume, TPanning Panning] interface { @@ -134,11 +136,12 @@ type machine[TPeriod Period, TGlobalVolume, TMixingVolume, TVolume Volume, TPann ticker ticker age int - songData song.Data - ms *settings.MachineSettings[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning] - us settings.UserSettings - opl2 *opl2.Chip - opl2Enabled bool + songData song.Data + ms *settings.MachineSettings[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning] + us settings.UserSettings + opl2 *opl2.Chip + opl2Enabled bool + hardwareSynths []hardwareSynth rowStringer render.RowStringer // 1:1 with channels @@ -232,7 +235,7 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) upda m.channels[i].instructions = nil } - numRowChannels := song.GetRowNumChannels[TVolume](rowData) + numRowChannels := song.GetRowNumChannels(rowData) rowChannels := min(m.songData.GetNumChannels(), numRowChannels) return song.ForEachRowChannel(rowData, func(ch index.Channel, d song.ChannelData[TVolume]) (bool, error) { if int(ch) >= rowChannels { diff --git a/player/machine/machine_channel.go b/player/machine/machine_channel.go index d46b87f..8f31d81 100644 --- a/player/machine/machine_channel.go +++ b/player/machine/machine_channel.go @@ -4,11 +4,10 @@ import ( "errors" "fmt" - "github.com/gotracker/playback/mixing/sampling" - "github.com/gotracker/playback/filter" "github.com/gotracker/playback/index" "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/sampling" "github.com/gotracker/playback/note" "github.com/gotracker/playback/period" "github.com/gotracker/playback/song" diff --git a/player/machine/machine_factory.go b/player/machine/machine_factory.go index 6895374..dd2fbd9 100644 --- a/player/machine/machine_factory.go +++ b/player/machine/machine_factory.go @@ -5,9 +5,9 @@ import ( "reflect" "github.com/gotracker/opl2" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/note" "github.com/gotracker/playback/player/machine/settings" "github.com/gotracker/playback/player/render" @@ -65,6 +65,11 @@ func RegisterMachine[TPeriod Period, TGlobalVolume, TMixingVolume, TVolume Volum m.songData = songData m.us = us + // Apply quirks overrides (user profile or flag overrides) + msCopy := *m.ms + msCopy.Quirks = resolveQuirks(msCopy.Quirks, us) + m.ms = &msCopy + order := songData.GetInitialOrder() if o, set := us.Start.Order.Get(); set { order = index.Order(o) diff --git a/player/machine/machine_factory_test.go b/player/machine/machine_factory_test.go new file mode 100644 index 0000000..358b546 --- /dev/null +++ b/player/machine/machine_factory_test.go @@ -0,0 +1,28 @@ +package machine + +import ( + "testing" + + "github.com/gotracker/playback/player/machine/settings" +) + +func TestNewMachineErrorsForUnregisteredTypes(t *testing.T) { + if _, err := NewMachine(stubSongData{}, settings.UserSettings{}); err == nil { + t.Fatalf("expected error for unregistered machine types") + } +} + +func TestRegisterMachinePanicsOnDuplicate(t *testing.T) { + ms := &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + PeriodConverter: stubPeriodCalc{}, + } + RegisterMachine(ms) + + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected duplicate registration to panic") + } + }() + + RegisterMachine(ms) +} diff --git a/player/machine/machine_opl2.go b/player/machine/machine_opl2.go index 84de378..73278af 100644 --- a/player/machine/machine_opl2.go +++ b/player/machine/machine_opl2.go @@ -4,11 +4,13 @@ import ( "errors" "github.com/gotracker/opl2" + "github.com/gotracker/playback/mixing" "github.com/gotracker/playback/mixing/panning" "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/player/sampler" "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/mixer" ) func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) setupOPL2(s *sampler.Sampler) error { @@ -37,28 +39,42 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) setu } } + m.hardwareSynths = append(m.hardwareSynths, opl2Synth{ + chip: m.opl2, + gv: func() volume.Volume { return m.gv.ToVolume() }, + }) + return nil } -func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) renderOPL2Tick(centerAheadPan panning.PanMixer, mixerData *mixing.Data, mix *mixing.Mixer, tickSamples int) error { - // make a stand-alone data buffer for this channel for this tick - data := mix.NewMixBuffer(tickSamples) +type opl2Synth struct { + chip *opl2.Chip + gv func() volume.Volume +} - opl2data := make([]int32, tickSamples) +func (o opl2Synth) RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) { + data := details.Mix.NewMixBuffer(details.Samples) + opl2data := make([]int32, details.Samples) - if opl2 := m.opl2; opl2 != nil { - opl2.GenerateBlock2(uint(tickSamples), opl2data) + if chip := o.chip; chip != nil { + chip.GenerateBlock2(uint(details.Samples), opl2data) } for i, s := range opl2data { sv := volume.Volume(s) / 32768.0 data[i].Assign(1, []volume.Volume{sv}) } - *mixerData = mixing.Data{ + + mixerData := mixing.Data{ Data: data, PanMatrix: centerAheadPan, - Volume: m.gv.ToVolume(), - SamplesLen: tickSamples, + Volume: o.gv(), + SamplesLen: details.Samples, } - return nil + + adjust := func(mv volume.Volume) volume.Volume { + return mv / (mv + 1) + } + + return mixerData, adjust, nil } diff --git a/player/machine/machine_render.go b/player/machine/machine_render.go index dd80238..9fabd6e 100644 --- a/player/machine/machine_render.go +++ b/player/machine/machine_render.go @@ -15,9 +15,40 @@ import ( ) func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) render(s *sampler.Sampler) (*output.PremixData, error) { + frame, err := m.prepareRenderFrame(s) + if err != nil { + return nil, err + } + + mixData, err := m.renderVoices(frame) + if err != nil { + return nil, err + } + + if len(mixData) > 0 { + frame.premix.Data = append(frame.premix.Data, mixData) + } + + if err := m.mixHardwareSynths(frame, &frame.premix); err != nil { + return nil, err + } + + m.normalizePremix(&frame) + + return &frame.premix, nil +} + +type renderFrame struct { + renderRow render.RowRender + premix output.PremixData + details mixer.Details + centerAheadPan panning.PanMixer +} + +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) prepareRenderFrame(s *sampler.Sampler) (renderFrame, error) { tickDuration := m.songData.GetTickDuration(m.bpm) if tickDuration <= 0 { - return nil, fmt.Errorf("unexpected tick duration: %v", tickDuration) + return renderFrame{}, fmt.Errorf("unexpected tick duration: %v", tickDuration) } renderRow := render.RowRender{ @@ -48,14 +79,24 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) rend centerAheadPan := details.Panmixer.GetMixingMatrix(panning.CenterAhead, s.StereoSeparation) + return renderFrame{ + renderRow: renderRow, + premix: premix, + details: details, + centerAheadPan: centerAheadPan, + }, nil +} + +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) renderVoices(frame renderFrame) ([]mixing.Data, error) { var mixData []mixing.Data + for i := range m.actualOutputs { rc := &m.actualOutputs[i] rc.GlobalVolume = m.gv.ToVolume() rc.GetVoice().DumpState(index.Channel(i), m.us.Tracer) - data, err := rc.RenderAndTick(m.ms.PeriodConverter, centerAheadPan, details) + data, err := rc.RenderAndTick(m.ms.PeriodConverter, frame.centerAheadPan, frame.details) if err != nil { return nil, err } @@ -64,11 +105,11 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) rend mixData = append(mixData, *data) } else { mixData = append(mixData, mixing.Data{ - Data: details.Mix.NewMixBuffer(details.Samples), - PanMatrix: centerAheadPan, + Data: frame.details.Mix.NewMixBuffer(frame.details.Samples), + PanMatrix: frame.centerAheadPan, Volume: volume.Volume(0), Pos: 0, - SamplesLen: details.Samples, + SamplesLen: frame.details.Samples, }) } } @@ -82,7 +123,7 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) rend //rc.GetVoice().DumpState(index.Channel(i), m.us.Tracer) var err error - data, err = rc.RenderAndTick(m.ms.PeriodConverter, centerAheadPan, details) + data, err = rc.RenderAndTick(m.ms.PeriodConverter, frame.centerAheadPan, frame.details) if err != nil { return nil, err } @@ -92,49 +133,52 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) rend mixData = append(mixData, *data) } else { mixData = append(mixData, mixing.Data{ - Data: details.Mix.NewMixBuffer(details.Samples), - PanMatrix: centerAheadPan, + Data: frame.details.Mix.NewMixBuffer(frame.details.Samples), + PanMatrix: frame.centerAheadPan, Volume: volume.Volume(0), Pos: 0, - SamplesLen: details.Samples, + SamplesLen: frame.details.Samples, }) } } - if len(mixData) > 0 { - premix.Data = append(premix.Data, mixData) + return mixData, nil +} + +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) mixHardwareSynths(frame renderFrame, premix *output.PremixData) error { + if len(m.hardwareSynths) == 0 { + return nil } - if m.opl2 != nil { - rr := [1]mixing.Data{} - if err := m.renderOPL2Tick(centerAheadPan, &rr[0], s.Mixer(), premix.SamplesLen); err != nil { - return nil, err + for _, synth := range m.hardwareSynths { + data, adjust, err := synth.RenderTick(frame.centerAheadPan, frame.details) + if err != nil { + return err + } + + premix.Data = append(premix.Data, mixing.ChannelData{data}) + + if adjust != nil { + premix.MixerVolume = adjust(premix.MixerVolume) } - premix.Data = append(premix.Data, rr[:]) - - // make room in the mixer for the OPL2 data - // effectively, we can do this by calculating the new number (+1) of channels from the mixer volume (channels = reciprocal of mixer volume): - // numChannels = (1/mv) + 1 - // then by taking the reciprocal of it: - // 1 / numChannels - // but that ends up being simplified to: - // mv / (mv + 1) - // and we get protection from div/0 in the process - provided, of course, that the mixerVolume is not exactly -1... - mv := premix.MixerVolume - premix.MixerVolume /= (mv + 1) } - if len(premix.Data) == 0 { - premix.Data = append(premix.Data, mixing.ChannelData{ - mixing.Data{ - Data: details.Mix.NewMixBuffer(details.Samples), - PanMatrix: centerAheadPan, - Volume: volume.Volume(0), - Pos: 0, - SamplesLen: details.Samples, - }, - }) + return nil +} + +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) normalizePremix(frame *renderFrame) { + if len(frame.premix.Data) != 0 { + return } - return &premix, nil + frame.premix.Data = append(frame.premix.Data, mixing.ChannelData{ + mixing.Data{ + Data: frame.details.Mix.NewMixBuffer(frame.details.Samples), + PanMatrix: frame.centerAheadPan, + Volume: volume.Volume(0), + Pos: 0, + SamplesLen: frame.details.Samples, + }, + }) + return } diff --git a/player/machine/machine_render_test.go b/player/machine/machine_render_test.go new file mode 100644 index 0000000..21c86b1 --- /dev/null +++ b/player/machine/machine_render_test.go @@ -0,0 +1,918 @@ +package machine + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/gotracker/playback/filter" + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/index" + "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/note" + "github.com/gotracker/playback/output" + "github.com/gotracker/playback/period" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/render" + "github.com/gotracker/playback/player/sampler" + "github.com/gotracker/playback/song" + "github.com/gotracker/playback/system" + "github.com/gotracker/playback/tracing" + "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/mixer" + "github.com/gotracker/playback/voice/vol0optimization" +) + +type stubGV float32 + +func (stubGV) IsInvalid() bool { return false } +func (stubGV) IsUseInstrumentVol() bool { return false } +func (g stubGV) ToVolume() volume.Volume { return volume.Volume(g) } + +type stubPan float32 + +func (stubPan) IsInvalid() bool { return false } +func (p stubPan) ToPosition() panning.Position { + return panning.Position{Angle: 0, Distance: float32(p)} +} + +type stubPeriod struct{} + +func (stubPeriod) IsInvalid() bool { return false } + +type stubPeriodCalc struct{} + +func (stubPeriodCalc) GetSystem() system.System { return nil } +func (stubPeriodCalc) GetPeriod(_ note.Note) stubPeriod { return stubPeriod{} } +func (stubPeriodCalc) PortaToNote(p stubPeriod, _ period.Delta, _ stubPeriod) (stubPeriod, error) { + return p, nil +} +func (stubPeriodCalc) PortaDown(p stubPeriod, _ period.Delta) (stubPeriod, error) { return p, nil } +func (stubPeriodCalc) PortaUp(p stubPeriod, _ period.Delta) (stubPeriod, error) { return p, nil } +func (stubPeriodCalc) AddDelta(p stubPeriod, _ period.Delta) (stubPeriod, error) { return p, nil } +func (stubPeriodCalc) GetSamplerAdd(stubPeriod, frequency.Frequency, frequency.Frequency) float64 { + return 0 +} +func (stubPeriodCalc) GetFrequency(stubPeriod) frequency.Frequency { return 0 } + +type stubChannelMemory struct{} + +func (stubChannelMemory) Retrigger() {} +func (stubChannelMemory) StartOrder0() {} + +type stubChannelSettings struct{} + +func (stubChannelSettings) IsEnabled() bool { return true } +func (stubChannelSettings) IsMuted() bool { return false } +func (stubChannelSettings) GetOutputChannelNum() int { return 0 } +func (stubChannelSettings) GetMemory() song.ChannelMemory { return stubChannelMemory{} } +func (stubChannelSettings) IsPanEnabled() bool { return true } +func (stubChannelSettings) GetDefaultFilterInfo() filter.Info { return filter.Info{} } +func (stubChannelSettings) IsDefaultFilterEnabled() bool { return false } +func (stubChannelSettings) GetVol0OptimizationSettings() vol0optimization.Vol0OptimizationSettings { + return vol0optimization.Vol0OptimizationSettings{} +} +func (stubChannelSettings) GetOPLChannel() index.OPLChannel { return 0 } + +type stubSongData struct{} + +func (stubSongData) GetPeriodType() reflect.Type { return reflect.TypeOf(stubPeriod{}) } +func (stubSongData) GetGlobalVolumeType() reflect.Type { return reflect.TypeOf(stubGV(0)) } +func (stubSongData) GetChannelMixingVolumeType() reflect.Type { return reflect.TypeOf(stubGV(0)) } +func (stubSongData) GetChannelVolumeType() reflect.Type { return reflect.TypeOf(stubGV(0)) } +func (stubSongData) GetChannelPanningType() reflect.Type { return reflect.TypeOf(stubPan(0)) } +func (stubSongData) GetInitialBPM() int { return 6 } +func (stubSongData) GetInitialTempo() int { return 6 } +func (stubSongData) GetMixingVolumeGeneric() volume.Volume { return 1 } +func (stubSongData) GetTickDuration(int) time.Duration { return time.Second } +func (stubSongData) GetOrderList() []index.Pattern { return []index.Pattern{0} } +func (stubSongData) GetNumChannels() int { return 1 } +func (stubSongData) GetChannelSettings(index.Channel) song.ChannelSettings { + return stubChannelSettings{} +} +func (stubSongData) NumInstruments() int { return 0 } +func (stubSongData) GetInstrument(int, note.Semitone) (instrument.InstrumentIntf, note.Semitone) { + return nil, 0 +} +func (stubSongData) GetName() string { return "stub" } +func (stubSongData) GetPatternByOrder(index.Order) (song.Pattern, error) { + return nil, song.ErrStopSong +} +func (stubSongData) GetPattern(index.Pattern) (song.Pattern, error) { return nil, song.ErrStopSong } +func (stubSongData) GetPeriodCalculator() song.PeriodCalculatorIntf { return nil } +func (stubSongData) GetInitialOrder() index.Order { return 0 } +func (stubSongData) GetRowRenderStringer(song.Row, int, bool) song.RowStringer { return nil } +func (stubSongData) GetSystem() system.System { return nil } +func (stubSongData) GetMachineSettings() any { return nil } +func (stubSongData) ForEachChannel(bool, func(index.Channel) (bool, error)) error { + return nil +} +func (stubSongData) IsOPL2Enabled() bool { return false } + +type zeroTickSongData struct{ stubSongData } + +func (zeroTickSongData) GetTickDuration(int) time.Duration { return 0 } + +type tick1500SongData struct{ stubSongData } + +func (tick1500SongData) GetTickDuration(int) time.Duration { return 1500 * time.Millisecond } + +type doneVoice struct{ ticked int } + +func (v *doneVoice) Clone(bool) voice.Voice { return v } +func (v *doneVoice) DumpState(index.Channel, tracing.Tracer) {} +func (v *doneVoice) Reset() error { return nil } +func (v *doneVoice) SetPlaybackRate(frequency.Frequency) error { return nil } +func (v *doneVoice) Attack() {} +func (v *doneVoice) Release() {} +func (v *doneVoice) Fadeout() {} +func (v *doneVoice) Stop() {} +func (v *doneVoice) Tick() error { v.ticked++; return nil } +func (v *doneVoice) RowEnd() error { return nil } +func (v *doneVoice) IsDone() bool { return true } +func (v *doneVoice) SetMuted(bool) error { return nil } +func (v *doneVoice) IsMuted() bool { return false } +func (v *doneVoice) GetSampleRate() frequency.Frequency { return 0 } + +type errorRenderVoice struct { + pos sampling.Pos + err error +} + +func (v *errorRenderVoice) Clone(bool) voice.Voice { return v } +func (v *errorRenderVoice) DumpState(index.Channel, tracing.Tracer) {} +func (v *errorRenderVoice) Reset() error { return nil } +func (v *errorRenderVoice) SetPlaybackRate(frequency.Frequency) error { return nil } +func (v *errorRenderVoice) Attack() {} +func (v *errorRenderVoice) Release() {} +func (v *errorRenderVoice) Fadeout() {} +func (v *errorRenderVoice) Stop() {} +func (v *errorRenderVoice) Tick() error { return nil } +func (v *errorRenderVoice) RowEnd() error { return nil } +func (v *errorRenderVoice) IsDone() bool { return false } +func (v *errorRenderVoice) SetMuted(bool) error { return nil } +func (v *errorRenderVoice) IsMuted() bool { return false } +func (v *errorRenderVoice) GetSampleRate() frequency.Frequency { return 1 } +func (v *errorRenderVoice) GetSample(sampling.Pos) volume.Matrix { return volume.Matrix{} } +func (v *errorRenderVoice) IsActive() bool { return true } +func (v *errorRenderVoice) SetPos(pos sampling.Pos) error { v.pos = pos; return nil } +func (v *errorRenderVoice) GetPos() (sampling.Pos, error) { return v.pos, nil } +func (v *errorRenderVoice) GetFinalPeriod() (stubPeriod, error) { return stubPeriod{}, v.err } +func (v *errorRenderVoice) GetFinalVolume() volume.Volume { return 1 } +func (v *errorRenderVoice) GetFinalPan() panning.Position { return panning.CenterAhead } + +type stubHardwareSynth struct { + called int + center panning.PanMixer + details mixer.Details +} + +func (s *stubHardwareSynth) RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) { + s.called++ + s.center = centerAheadPan + s.details = details + + data := mixing.Data{ + Data: details.Mix.NewMixBuffer(details.Samples), + PanMatrix: centerAheadPan, + Volume: volume.Volume(0.25), + SamplesLen: details.Samples, + } + + return data, func(v volume.Volume) volume.Volume { + return v * 0.5 + }, nil +} + +type errorHardwareSynth struct{ err error } + +func (s errorHardwareSynth) RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) { + return mixing.Data{}, nil, s.err +} + +type nilAdjustHardwareSynth struct { + called bool +} + +func (s *nilAdjustHardwareSynth) RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) { + s.called = true + return mixing.Data{ + Data: details.Mix.NewMixBuffer(details.Samples), + PanMatrix: centerAheadPan, + Volume: volume.Volume(0.75), + SamplesLen: details.Samples, + }, nil, nil +} + +type sizedHardwareSynth struct { + samples int +} + +func (s sizedHardwareSynth) RenderTick(centerAheadPan panning.PanMixer, details mixer.Details) (mixing.Data, mixerVolumeAdjuster, error) { + return mixing.Data{ + Data: details.Mix.NewMixBuffer(s.samples), + PanMatrix: centerAheadPan, + Volume: volume.Volume(0.9), + SamplesLen: s.samples, + }, nil, nil +} + +func TestPrepareRenderFrameCopiesRowStringerOnTickZero(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 1, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: stubSongData{}, + rowStringer: stubRowStringer{s: "row text"}, + } + m.ticker.current = Position{Order: 2, Row: 3, Tick: 0} + + s := sampler.NewSampler(10, 2, 1, nil) + + frame, err := m.prepareRenderFrame(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if frame.renderRow.RowText != m.rowStringer { + t.Fatalf("expected row stringer to be copied on tick 0") + } + userdata, ok := frame.premix.Userdata.(*render.RowRender) + if !ok { + t.Fatalf("expected premix userdata to store row render, got %T", frame.premix.Userdata) + } + if userdata.RowText != m.rowStringer { + t.Fatalf("expected userdata row stringer to be copied") + } + if frame.renderRow.Order != int(m.ticker.current.Order) || frame.renderRow.Row != int(m.ticker.current.Row) || frame.renderRow.Tick != m.ticker.current.Tick { + t.Fatalf("row render metadata mismatch: got %+v", frame.renderRow) + } +} + +func TestPrepareRenderFrameSkipsRowStringerOnNonZeroTick(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 1, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: stubSongData{}, + rowStringer: stubRowStringer{s: "row text"}, + } + m.ticker.current = Position{Order: 1, Row: 4, Tick: 2} + + s := sampler.NewSampler(10, 2, 1, nil) + + frame, err := m.prepareRenderFrame(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if frame.renderRow.RowText != nil { + t.Fatalf("expected row stringer to be nil on non-zero tick") + } + userdata, ok := frame.premix.Userdata.(*render.RowRender) + if !ok { + t.Fatalf("expected premix userdata to store row render, got %T", frame.premix.Userdata) + } + if userdata.RowText != nil { + t.Fatalf("expected userdata row stringer to be nil on non-zero tick") + } +} + +func TestPrepareRenderFrameUsesTickDurationForSamples(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 1, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: tick1500SongData{}, + } + + s := sampler.NewSampler(10, 2, 1, nil) + + frame, err := m.prepareRenderFrame(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if frame.premix.SamplesLen != 15 { + t.Fatalf("expected samples len 15 from 1.5s tick at 10Hz, got %d", frame.premix.SamplesLen) + } + if frame.details.Samples != 15 { + t.Fatalf("expected details samples 15, got %d", frame.details.Samples) + } + if frame.details.Duration != 1500*time.Millisecond { + t.Fatalf("unexpected duration: %v", frame.details.Duration) + } +} + +func TestNormalizePremixAddsSilence(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 3, + } + + center := panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation) + + frame := renderFrame{ + details: details, + centerAheadPan: center, + premix: output.PremixData{}, + } + + m.normalizePremix(&frame) + + if len(frame.premix.Data) != 1 { + t.Fatalf("expected premix to contain one channel, got %d", len(frame.premix.Data)) + } + if len(frame.premix.Data[0]) != 1 { + t.Fatalf("expected channel data to have one entry, got %d", len(frame.premix.Data[0])) + } + entry := frame.premix.Data[0][0] + if entry.SamplesLen != details.Samples { + t.Fatalf("unexpected samples len: got %d want %d", entry.SamplesLen, details.Samples) + } + if len(entry.Data) != details.Samples { + t.Fatalf("mixbuffer length mismatch: got %d", len(entry.Data)) + } +} + +func TestNormalizePremixZeroSamples(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 0, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: output.PremixData{}, + } + + m.normalizePremix(&frame) + + if len(frame.premix.Data) != 1 || len(frame.premix.Data[0]) != 1 { + t.Fatalf("expected single channel entry even for zero samples") + } + entry := frame.premix.Data[0][0] + if entry.SamplesLen != 0 { + t.Fatalf("expected samples len 0, got %d", entry.SamplesLen) + } + if len(entry.Data) != 0 { + t.Fatalf("expected zero-length mixbuffer, got %d", len(entry.Data)) + } +} + +func TestNormalizePremixKeepsExistingData(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 2, + } + + existing := mixing.ChannelData{mixing.Data{SamplesLen: details.Samples}} + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: output.PremixData{ + SamplesLen: details.Samples, + Data: []mixing.ChannelData{existing}, + }, + } + + m.normalizePremix(&frame) + + if len(frame.premix.Data) != 1 { + t.Fatalf("expected existing data retained") + } + if frame.premix.Data[0][0].SamplesLen != details.Samples { + t.Fatalf("unexpected samples len after normalize: %d", frame.premix.Data[0][0].SamplesLen) + } +} + +func TestRenderVoicesGeneratesSilence(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + PeriodConverter: stubPeriodCalc{}, + }, + } + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 2, + } + center := panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation) + + ch := render.Channel[stubPeriod]{} + ch.StartVoice(&doneVoice{}, nil) + m.actualOutputs = []render.Channel[stubPeriod]{ch} + m.virtualOutputs = []render.Channel[stubPeriod]{{}} + + frame := renderFrame{ + details: details, + centerAheadPan: center, + } + + mixData, err := m.renderVoices(frame) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := len(m.actualOutputs) + len(m.virtualOutputs) + if len(mixData) != expected { + t.Fatalf("expected %d mix entries, got %d", expected, len(mixData)) + } + + for i, d := range mixData { + if d.Volume != 0 { + t.Fatalf("entry %d expected volume 0, got %v", i, d.Volume) + } + if applied := d.PanMatrix.Apply(volume.Volume(1)); applied.Channels != 2 { + t.Fatalf("entry %d expected stereo pan matrix, got %d channels", i, applied.Channels) + } + if d.SamplesLen != details.Samples { + t.Fatalf("entry %d samples len mismatch: got %d want %d", i, d.SamplesLen, details.Samples) + } + if len(d.Data) != details.Samples { + t.Fatalf("entry %d mixbuffer length mismatch: got %d", i, len(d.Data)) + } + } +} + +func TestRenderVoicesZeroSamples(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + PeriodConverter: stubPeriodCalc{}, + }, + } + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 0, + } + center := panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation) + + ch := render.Channel[stubPeriod]{} + ch.StartVoice(&doneVoice{}, nil) + m.actualOutputs = []render.Channel[stubPeriod]{ch} + m.virtualOutputs = []render.Channel[stubPeriod]{{}} + + frame := renderFrame{ + details: details, + centerAheadPan: center, + } + + mixData, err := m.renderVoices(frame) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for i, d := range mixData { + if d.SamplesLen != 0 { + t.Fatalf("entry %d expected samples len 0, got %d", i, d.SamplesLen) + } + if len(d.Data) != 0 { + t.Fatalf("entry %d expected zero-length mixbuffer, got %d", i, len(d.Data)) + } + } +} + +func TestRenderVoicesPropagatesVoiceError(t *testing.T) { + errRender := errors.New("render fail") + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + PeriodConverter: stubPeriodCalc{}, + }, + } + + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: 2, + } + + ch := render.Channel[stubPeriod]{} + ch.StartVoice(&errorRenderVoice{err: errRender}, nil) + m.actualOutputs = []render.Channel[stubPeriod]{ch} + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + } + + if _, err := m.renderVoices(frame); !errors.Is(err, errRender) { + t.Fatalf("expected render error to propagate, got %v", err) + } +} + +func TestRenderInvokesOnGenerateWithPremix(t *testing.T) { + called := 0 + var gotPremix *output.PremixData + s := sampler.NewSampler(10, 2, 1, func(p *output.PremixData) { + called++ + gotPremix = p + }) + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 0.5, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + us: settings.UserSettings{}, + songData: stubSongData{}, + } + + if err := m.Render(s); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if called != 1 { + t.Fatalf("expected OnGenerate to be called once, got %d", called) + } + if gotPremix == nil { + t.Fatalf("expected premix data") + } + + expectedLen := int(float64(s.SampleRate) * float64(time.Second) / float64(time.Second)) + if gotPremix.SamplesLen != expectedLen { + t.Fatalf("unexpected samples len: got %d want %d", gotPremix.SamplesLen, expectedLen) + } + + expectedVol := m.gv.ToVolume() * m.mv + if gotPremix.MixerVolume != expectedVol { + t.Fatalf("unexpected mixer volume: got %v want %v", gotPremix.MixerVolume, expectedVol) + } + + if len(gotPremix.Data) != 1 { + t.Fatalf("expected premix to contain one channel, got %d", len(gotPremix.Data)) + } + if len(gotPremix.Data[0]) != 1 { + t.Fatalf("expected channel data to have one entry, got %d", len(gotPremix.Data[0])) + } +} + +func TestRenderFailsOnInvalidTickDuration(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 0.5, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: zeroTickSongData{}, + } + + s := sampler.NewSampler(10, 2, 1, nil) + + if err := m.Render(s); err == nil { + t.Fatalf("expected error when tick duration is invalid") + } +} + +func TestRenderPropagatesRenderVoicesError(t *testing.T) { + errRender := errors.New("render voices fail") + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{ + bpm: 6, + tempo: 6, + gv: stubGV(1), + mv: 0.5, + }, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + PeriodConverter: stubPeriodCalc{}, + }, + songData: stubSongData{}, + } + + ch := render.Channel[stubPeriod]{} + ch.StartVoice(&errorRenderVoice{err: errRender}, nil) + m.actualOutputs = []render.Channel[stubPeriod]{ch} + + s := sampler.NewSampler(10, 2, 1, nil) + + if err := m.Render(s); !errors.Is(err, errRender) { + t.Fatalf("expected renderVoices error to propagate, got %v", err) + } +} + +func TestMixHardwareSynthsAppendsDataAndAdjustsVolume(t *testing.T) { + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + m.hardwareSynths = []hardwareSynth{&stubHardwareSynth{}} + + premix := output.PremixData{ + SamplesLen: 4, + MixerVolume: 1, + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: premix, + } + + if err := m.mixHardwareSynths(frame, &frame.premix); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if frame.premix.MixerVolume != 0.5 { + t.Fatalf("expected mixer volume adjusted to 0.5, got %v", frame.premix.MixerVolume) + } + + if len(frame.premix.Data) != 1 { + t.Fatalf("expected 1 channel data entry, got %d", len(frame.premix.Data)) + } + channel := frame.premix.Data[0] + if len(channel) != 1 { + t.Fatalf("expected 1 mix data entry, got %d", len(channel)) + } + d := channel[0] + if d.SamplesLen != premix.SamplesLen { + t.Fatalf("samples len mismatch: got %d want %d", d.SamplesLen, premix.SamplesLen) + } + if d.Volume != volume.Volume(0.25) { + t.Fatalf("unexpected volume: got %v want %v", d.Volume, volume.Volume(0.25)) + } + if len(d.Data) != premix.SamplesLen { + t.Fatalf("mixbuffer length mismatch: got %d want %d", len(d.Data), premix.SamplesLen) + } + if applied := d.PanMatrix.Apply(volume.Volume(1)); applied.Channels != 2 { + t.Fatalf("expected stereo pan matrix, got %d channels", applied.Channels) + } +} + +func TestMixHardwareSynthsNoDevicesLeavesPremix(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + premix := output.PremixData{SamplesLen: 2, MixerVolume: 0.6} + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: mixing.GetPanMixer(2), + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{details: details, premix: premix} + + if err := m.mixHardwareSynths(frame, &frame.premix); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if frame.premix.MixerVolume != premix.MixerVolume { + t.Fatalf("expected mixer volume unchanged, got %v", frame.premix.MixerVolume) + } + if len(frame.premix.Data) != 0 { + t.Fatalf("expected no data appended, got %d entries", len(frame.premix.Data)) + } +} + +func TestMixHardwareSynthsReturnsError(t *testing.T) { + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + errRender := errors.New("render failure") + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + m.hardwareSynths = []hardwareSynth{errorHardwareSynth{err: errRender}} + + premix := output.PremixData{ + SamplesLen: 2, + MixerVolume: 1, + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: premix, + } + + err := m.mixHardwareSynths(frame, &premix) + if !errors.Is(err, errRender) { + t.Fatalf("expected error to propagate, got %v", err) + } + if len(premix.Data) != 0 { + t.Fatalf("expected premix data to remain unchanged on error, got %d entries", len(premix.Data)) + } + if premix.MixerVolume != 1 { + t.Fatalf("expected mixer volume unchanged, got %v", premix.MixerVolume) + } +} + +func TestMixHardwareSynthsKeepsVolumeWhenNoAdjuster(t *testing.T) { + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + var synth nilAdjustHardwareSynth + m.hardwareSynths = []hardwareSynth{&synth} + + premix := output.PremixData{ + SamplesLen: 3, + MixerVolume: 0.8, + } + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: premix, + } + + if err := m.mixHardwareSynths(frame, &premix); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !synth.called { + t.Fatalf("expected hardware synth to be called") + } + if premix.MixerVolume != 0.8 { + t.Fatalf("expected mixer volume to remain unchanged, got %v", premix.MixerVolume) + } + if len(premix.Data) != 1 { + t.Fatalf("expected 1 channel data entry, got %d", len(premix.Data)) + } + channel := premix.Data[0] + if len(channel) != 1 { + t.Fatalf("expected 1 mix data entry, got %d", len(channel)) + } + if channel[0].Volume != volume.Volume(0.75) { + t.Fatalf("unexpected volume: got %v want %v", channel[0].Volume, volume.Volume(0.75)) + } +} + +func TestMixHardwareSynthsAppliesAllAdjusters(t *testing.T) { + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + m.hardwareSynths = []hardwareSynth{&stubHardwareSynth{}, &stubHardwareSynth{}} + + premix := output.PremixData{SamplesLen: 2, MixerVolume: 1} + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: premix, + } + + if err := m.mixHardwareSynths(frame, &frame.premix); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if frame.premix.MixerVolume != 0.25 { + t.Fatalf("expected mixer volume adjusted twice to 0.25, got %v", frame.premix.MixerVolume) + } + if len(frame.premix.Data) != 2 { + t.Fatalf("expected 2 channel data entries, got %d", len(frame.premix.Data)) + } +} + +func TestMixHardwareSynthsAllowsMismatchedSampleLens(t *testing.T) { + panMixer := mixing.GetPanMixer(2) + if panMixer == nil { + t.Fatalf("expected stereo pan mixer") + } + + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + m.hardwareSynths = []hardwareSynth{sizedHardwareSynth{samples: 3}} + + premix := output.PremixData{SamplesLen: 2, MixerVolume: 0.7} + + details := mixer.Details{ + Mix: &mixing.Mixer{Channels: 2}, + Panmixer: panMixer, + SampleRate: 10, + StereoSeparation: 1, + Samples: premix.SamplesLen, + } + + frame := renderFrame{ + details: details, + centerAheadPan: panMixer.GetMixingMatrix(panning.CenterAhead, details.StereoSeparation), + premix: premix, + } + + if err := m.mixHardwareSynths(frame, &frame.premix); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if frame.premix.MixerVolume != premix.MixerVolume { + t.Fatalf("expected mixer volume unchanged, got %v", frame.premix.MixerVolume) + } + if len(frame.premix.Data) != 1 { + t.Fatalf("expected 1 channel data entry, got %d", len(frame.premix.Data)) + } + if got := frame.premix.Data[0][0].SamplesLen; got != 3 { + t.Fatalf("expected appended data to keep synth samples len 3, got %d", got) + } +} diff --git a/player/machine/machine_tick.go b/player/machine/machine_tick.go index e5fdef3..d6d1af4 100644 --- a/player/machine/machine_tick.go +++ b/player/machine/machine_tick.go @@ -1,19 +1,26 @@ package machine import ( + "errors" + "github.com/gotracker/playback/index" "github.com/gotracker/playback/player/sampler" ) func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Tick(s *sampler.Sampler) error { - if s != nil { - if m.opl2Enabled && m.opl2 == nil && m.ms.OPL2Enabled { - if err := m.setupOPL2(s); err != nil { - return err - } - } + if s == nil { + return errors.New("sampler is required; call Advance() for sequencing-only") + } + + if err := m.Advance(); err != nil { + return err } + return m.Render(s) +} + +// Advance progresses song sequencing and channel state without rendering audio. +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Advance() error { if err := m.songData.ForEachChannel(true, func(ch index.Channel) (bool, error) { c := &m.channels[ch] if err := c.DoNoteAction(ch, m); err != nil { @@ -24,22 +31,35 @@ func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Tick return err } - err := runTick(&m.ticker, m) - if err != nil { + if err := runTick(&m.ticker, m); err != nil { return err } m.age++ + return nil +} - if s != nil { - premix, err := m.render(s) - if err != nil { +// Render produces audio for the current state using the provided sampler. +func (m *machine[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning]) Render(s *sampler.Sampler) error { + if s == nil { + return errors.New("sampler is nil") + } + + if m.opl2Enabled && m.opl2 == nil && m.ms.OPL2Enabled { + if err := m.setupOPL2(s); err != nil { return err } - if s.OnGenerate != nil { - s.OnGenerate(premix) - } } + + premix, err := m.render(s) + if err != nil { + return err + } + + if s.OnGenerate != nil { + s.OnGenerate(premix) + } + return nil } diff --git a/player/machine/machine_tick_test.go b/player/machine/machine_tick_test.go new file mode 100644 index 0000000..79433b7 --- /dev/null +++ b/player/machine/machine_tick_test.go @@ -0,0 +1,293 @@ +package machine + +import ( + "errors" + "testing" + "time" + + "github.com/gotracker/playback/index" + "github.com/gotracker/playback/output" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/render" + "github.com/gotracker/playback/player/sampler" + "github.com/gotracker/playback/song" + optional "github.com/heucuva/optional" +) + +func TestTickRequiresSampler(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + if err := m.Tick(nil); err == nil { + t.Fatalf("expected error when sampler is nil") + } +} + +func TestRenderRequiresSampler(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + if err := m.Render(nil); err == nil { + t.Fatalf("expected error when sampler is nil") + } +} + +func TestAdvanceStopsWhenSongStops(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + songData: stubSongData{}, + } + + err := m.Advance() + if !errors.Is(err, song.ErrStopSong) { + t.Fatalf("expected song.ErrStopSong, got %v", err) + } +} + +func TestExtraTicksAndRepeats(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{} + + if err := m.AddExtraTicks(2); err != nil { + t.Fatalf("AddExtraTicks unexpected error: %v", err) + } + if m.extraTicks != 2 { + t.Fatalf("expected extraTicks=2, got %d", m.extraTicks) + } + + if err := m.RowRepeat(3); err != nil { + t.Fatalf("RowRepeat unexpected error: %v", err) + } + if m.repeats != 3 { + t.Fatalf("expected repeats=3, got %d", m.repeats) + } + + if !m.consumeRepeat() { + t.Fatalf("expected consumeRepeat to report true") + } + if m.repeats != 2 { + t.Fatalf("expected repeats decremented to 2, got %d", m.repeats) + } + + if err := m.AddExtraTicks(-1); err == nil { + t.Fatalf("expected error for negative extra ticks") + } + if err := m.RowRepeat(-1); err == nil { + t.Fatalf("expected error for negative repeats") + } +} + +type seqSongData struct { + stubSongData + pat song.Pattern +} + +func (s seqSongData) GetPatternByOrder(index.Order) (song.Pattern, error) { return s.pat, nil } +func (s seqSongData) GetPattern(index.Pattern) (song.Pattern, error) { return s.pat, nil } +func (s seqSongData) GetOrderList() []index.Pattern { return []index.Pattern{0} } +func (s seqSongData) ForEachChannel(bool, func(index.Channel) (bool, error)) error { + return nil +} +func (s seqSongData) GetRowRenderStringer(song.Row, int, bool) song.RowStringer { + return stubRowStringer{s: "row"} +} + +type stopSongData struct { + stubSongData +} + +func (stopSongData) ForEachChannel(bool, func(index.Channel) (bool, error)) error { + return song.ErrStopSong +} + +type playSongData struct { + stubSongData + pat song.Pattern +} + +func (p playSongData) GetPatternByOrder(index.Order) (song.Pattern, error) { return p.pat, nil } +func (p playSongData) GetPattern(index.Pattern) (song.Pattern, error) { return p.pat, nil } +func (p playSongData) GetOrderList() []index.Pattern { return []index.Pattern{0} } +func (p playSongData) ForEachChannel(bool, func(index.Channel) (bool, error)) error { + return nil +} +func (p playSongData) GetRowRenderStringer(song.Row, int, bool) song.RowStringer { + return stubRowStringer{s: "row"} +} +func (p playSongData) GetTickDuration(int) time.Duration { return time.Second } + +func TestAdvanceProgressesRowsAndLoopsOrder(t *testing.T) { + pat := song.Pattern{song.Row(0), song.Row(1)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ticker: ticker{}, + globals: globals[stubGV]{tempo: 2}, + songData: seqSongData{pat: pat}, + } + + if err := initTick(&m.ticker, &m, tickerSettings{InitialOrder: 0, InitialRow: 0, SongLoopCount: -1}); err != nil { + t.Fatalf("initTick error: %v", err) + } + + if err := m.Advance(); err != nil { + t.Fatalf("advance1 error: %v", err) + } + if m.ticker.current.Row != 0 || m.ticker.current.Tick != 1 || m.ticker.current.Order != 0 { + t.Fatalf("state after advance1 got row %d tick %d order %d", m.ticker.current.Row, m.ticker.current.Tick, m.ticker.current.Order) + } + + if err := m.Advance(); err != nil { + t.Fatalf("advance2 error: %v", err) + } + if m.ticker.current.Row != 1 || m.ticker.current.Tick != 0 || m.ticker.current.Order != 0 { + t.Fatalf("state after advance2 got row %d tick %d order %d", m.ticker.current.Row, m.ticker.current.Tick, m.ticker.current.Order) + } + + if err := m.Advance(); err != nil { + t.Fatalf("advance3 error: %v", err) + } + if m.ticker.current.Row != 1 || m.ticker.current.Tick != 1 || m.ticker.current.Order != 0 { + t.Fatalf("state after advance3 got row %d tick %d order %d", m.ticker.current.Row, m.ticker.current.Tick, m.ticker.current.Order) + } + + if err := m.Advance(); err != nil { + t.Fatalf("advance4 error: %v", err) + } + if m.ticker.current.Row != 0 || m.ticker.current.Tick != 0 || m.ticker.current.Order != 0 { + t.Fatalf("state after advance4 got row %d tick %d order %d", m.ticker.current.Row, m.ticker.current.Tick, m.ticker.current.Order) + } +} + +func TestAdvanceStopsOnSongLoopCountZero(t *testing.T) { + pat := song.Pattern{song.Row(0)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ticker: ticker{}, + globals: globals[stubGV]{tempo: 1}, + songData: seqSongData{pat: pat}, + } + + if err := initTick(&m.ticker, &m, tickerSettings{InitialOrder: 0, InitialRow: 0, SongLoopCount: 0, SongLoopStartingOrder: 0}); err != nil { + t.Fatalf("initTick error: %v", err) + } + + err := m.Advance() + if !errors.Is(err, song.ErrStopSong) { + t.Fatalf("expected ErrStopSong due to loop count, got %v", err) + } +} + +func TestAdvanceStopsAtPlayUntilPosition(t *testing.T) { + pat := song.Pattern{song.Row(0), song.Row(1)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + ticker: ticker{}, + globals: globals[stubGV]{tempo: 1}, + songData: seqSongData{pat: pat}, + } + + playUntilOrder := optional.NewValue[index.Order](0) + playUntilRow := optional.NewValue[index.Row](1) + + ts := tickerSettings{InitialOrder: 0, InitialRow: 0, SongLoopCount: -1, PlayUntilOrder: playUntilOrder, PlayUntilRow: playUntilRow} + if err := initTick(&m.ticker, &m, ts); err != nil { + t.Fatalf("initTick error: %v", err) + } + + err := m.Advance() + if !errors.Is(err, song.ErrStopSong) { + t.Fatalf("expected ErrStopSong at play-until position, got %v", err) + } +} + +func TestTickPropagatesAdvanceStopWithoutRender(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + songData: stopSongData{}, + } + + s := sampler.NewSampler(10, 2, 1, nil) + + if err := m.Tick(s); !errors.Is(err, song.ErrStopSong) { + t.Fatalf("expected ErrStopSong from Advance, got %v", err) + } +} + +func TestTickRendersWhenSequencingContinues(t *testing.T) { + pat := song.Pattern{song.Row(0), song.Row(1)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{tempo: 2, bpm: 6, gv: stubGV(1), mv: 1}, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: playSongData{pat: pat}, + } + + called := 0 + s := sampler.NewSampler(10, 2, 1, func(*output.PremixData) { + called++ + }) + + if err := m.Tick(s); err != nil { + t.Fatalf("unexpected error from Tick: %v", err) + } + if called != 1 { + t.Fatalf("expected OnGenerate to be called once, got %d", called) + } +} + +func TestTickRendersWithNilOnGenerate(t *testing.T) { + pat := song.Pattern{song.Row(0)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{tempo: 1, bpm: 6, gv: stubGV(1), mv: 1}, + ms: &settings.MachineSettings[stubPeriod, stubGV, stubGV, stubGV, stubPan]{PeriodConverter: stubPeriodCalc{}}, + songData: playSongData{pat: pat}, + } + + s := sampler.NewSampler(10, 2, 1, nil) + + if err := m.Tick(s); err != nil { + t.Fatalf("unexpected error from Tick with nil OnGenerate: %v", err) + } +} + +func TestAdvanceSeparatesSequencingFromRendering(t *testing.T) { + pat := song.Pattern{song.Row(0), song.Row(1)} + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{tempo: 2}, + songData: seqSongData{pat: pat}, + } + + ch := render.Channel[stubPeriod]{} + voice := &doneVoice{} + ch.StartVoice(voice, nil) + m.actualOutputs = []render.Channel[stubPeriod]{ch} + + if err := initTick(&m.ticker, &m, tickerSettings{InitialOrder: 0, InitialRow: 0, SongLoopCount: -1}); err != nil { + t.Fatalf("initTick error: %v", err) + } + + if err := m.Advance(); err != nil { + t.Fatalf("Advance error: %v", err) + } + + if voice.ticked != 0 { + t.Fatalf("expected no rendering during sequencing-only Advance, got %d voice ticks", voice.ticked) + } + if m.ticker.current.Tick != 1 || m.ticker.current.Row != 0 || m.ticker.current.Order != 0 { + t.Fatalf("unexpected sequencing state: %+v", m.ticker.current) + } +} + +func TestRenderDoesNotAdvanceSequencer(t *testing.T) { + m := machine[stubPeriod, stubGV, stubGV, stubGV, stubPan]{ + globals: globals[stubGV]{tempo: 2, bpm: 6, gv: stubGV(1), mv: 0.5}, + songData: stubSongData{}, + } + m.ticker.current = Position{Order: 2, Row: 3, Tick: 4} + m.age = 7 + + s := sampler.NewSampler(10, 2, 1, nil) + + prev := m.ticker.current + if err := m.Render(s); err != nil { + t.Fatalf("Render error: %v", err) + } + + if m.age != 7 { + t.Fatalf("expected age to remain unchanged, got %d", m.age) + } + if m.ticker.current != prev { + t.Fatalf("expected sequencing position unchanged, got %+v", m.ticker.current) + } +} diff --git a/player/machine/newnoteinfo.go b/player/machine/newnoteinfo.go index d2da909..fbfd0e1 100644 --- a/player/machine/newnoteinfo.go +++ b/player/machine/newnoteinfo.go @@ -1,10 +1,11 @@ package machine import ( + "github.com/heucuva/optional" + "github.com/gotracker/playback/instrument" "github.com/gotracker/playback/mixing/sampling" "github.com/gotracker/playback/note" - "github.com/heucuva/optional" ) type ActionTick struct { diff --git a/player/machine/quirks_apply.go b/player/machine/quirks_apply.go new file mode 100644 index 0000000..6e4d75e --- /dev/null +++ b/player/machine/quirks_apply.go @@ -0,0 +1,47 @@ +package machine + +import ( + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/player/quirks" +) + +func resolveQuirks(base settings.MachineQuirks, us settings.UserSettings) settings.MachineQuirks { + // start from the machine's preferred profile so defaults always come from the profile definitions + if base.Profile != "" { + base = quirks.Resolve(quirks.Profile(base.Profile)) + } + + q := base + customized := false + + if prof, ok := us.Quirks.Profile.Get(); ok { + q = quirks.Resolve(quirks.Profile(prof)) + customized = true + } + + if value, ok := us.Quirks.PreviousPeriodUsesModifiedPeriodOverride.Get(); ok { + q.PreviousPeriodUsesModifiedPeriod = value + customized = true + } + if value, ok := us.Quirks.PortaToNoteUsesModifiedPeriodOverride.Get(); ok { + q.PortaToNoteUsesModifiedPeriod = value + customized = true + } + if value, ok := us.Quirks.DoNotProcessEffectsOnMutedChannelsOverride.Get(); ok { + q.DoNotProcessEffectsOnMutedChannels = value + customized = true + } + + // keep profile label meaningful after overrides + if prof, ok := us.Quirks.Profile.Get(); ok { + q.Profile = prof + } else if customized { + if base.Profile != "" { + q.Profile = base.Profile + "+custom" + } else { + q.Profile = "custom" + } + } + + return q +} diff --git a/player/machine/settings/machinesettings.go b/player/machine/settings/machinesettings.go index ae55119..40c622c 100644 --- a/player/machine/settings/machinesettings.go +++ b/player/machine/settings/machinesettings.go @@ -24,10 +24,12 @@ type MachineSettings[TPeriod Period, TGlobalVolume, TMixingVolume, TVolume Volum GetPanbrelloFactory func() (oscillator.Oscillator, error) VoiceFactory voice.VoiceFactory[TPeriod, TGlobalVolume, TMixingVolume, TVolume, TPanning] OPL2Enabled bool + ModLimits bool Quirks MachineQuirks } type MachineQuirks struct { + Profile string PreviousPeriodUsesModifiedPeriod bool PortaToNoteUsesModifiedPeriod bool DoNotProcessEffectsOnMutedChannels bool diff --git a/player/machine/settings/usersettings.go b/player/machine/settings/usersettings.go index 86f7bdd..f922fc3 100644 --- a/player/machine/settings/usersettings.go +++ b/player/machine/settings/usersettings.go @@ -1,14 +1,16 @@ package settings import ( + "github.com/heucuva/optional" + "github.com/gotracker/playback/index" "github.com/gotracker/playback/tracing" - "github.com/heucuva/optional" ) type UserSettings struct { Tracer tracing.TracerWithClose SongLoopCount int + Quirks QuirksUserSettings Start struct { Order optional.Value[index.Order] // default: based on song Row optional.Value[index.Row] // default: 0 @@ -24,12 +26,27 @@ type UserSettings struct { EnableNewNoteActions bool } +type QuirkOverride[T any] = optional.Value[T] + +type QuirksUserSettings struct { + Profile optional.Value[string] + LinearSlidesOverride QuirkOverride[bool] + PreviousPeriodUsesModifiedPeriodOverride QuirkOverride[bool] + PortaToNoteUsesModifiedPeriodOverride QuirkOverride[bool] + DoNotProcessEffectsOnMutedChannelsOverride QuirkOverride[bool] +} + // Reset applies the defaults // // NOTE: does not reset the Tracer value func (s *UserSettings) Reset() { // don't touch the Tracer here s.SongLoopCount = 0 + s.Quirks.Profile.Reset() + s.Quirks.LinearSlidesOverride = QuirkOverride[bool]{} + s.Quirks.PreviousPeriodUsesModifiedPeriodOverride = QuirkOverride[bool]{} + s.Quirks.PortaToNoteUsesModifiedPeriodOverride = QuirkOverride[bool]{} + s.Quirks.DoNotProcessEffectsOnMutedChannelsOverride = QuirkOverride[bool]{} s.Start.Order.Reset() s.Start.Row.Reset() s.Start.Tempo = 0 diff --git a/player/machine/settings/usersettings_test.go b/player/machine/settings/usersettings_test.go new file mode 100644 index 0000000..42622cc --- /dev/null +++ b/player/machine/settings/usersettings_test.go @@ -0,0 +1,182 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gotracker/playback/index" +) + +type fakeTracer struct { + calls []string + closed bool + lastTick struct { + order index.Order + row index.Row + tick int + } +} + +func (f *fakeTracer) Close() error { + f.closed = true + return nil +} + +func (f *fakeTracer) OutputTraces() { + f.calls = append(f.calls, "output") +} + +func (f *fakeTracer) SetTracingTick(order index.Order, row index.Row, tick int) { + f.calls = append(f.calls, "tick") + f.lastTick = struct { + order index.Order + row index.Row + tick int + }{order, row, tick} +} + +func (f *fakeTracer) Trace(op string) { + f.calls = append(f.calls, "trace:"+op) +} + +func (f *fakeTracer) TraceWithComment(op, commentFmt string, commentParams ...any) { + f.calls = append(f.calls, "tracecomment:"+op) +} + +func (f *fakeTracer) TraceValueChange(op string, prev, new any) { + f.calls = append(f.calls, "traceval:"+op) +} + +func (f *fakeTracer) TraceValueChangeWithComment(op string, prev, new any, commentFmt string, commentParams ...any) { + f.calls = append(f.calls, "tracevalcomment:"+op) +} + +func (f *fakeTracer) TraceChannel(ch index.Channel, op string) { + f.calls = append(f.calls, "channel:"+op) +} + +func (f *fakeTracer) TraceChannelWithComment(ch index.Channel, op, commentFmt string, commentParams ...any) { + f.calls = append(f.calls, "channelcomment:"+op) +} + +func (f *fakeTracer) TraceChannelValueChange(ch index.Channel, op string, prev, new any) { + f.calls = append(f.calls, "channelval:"+op) +} + +func (f *fakeTracer) TraceChannelValueChangeWithComment(ch index.Channel, op string, prev, new any, commentFmt string, commentParams ...any) { + f.calls = append(f.calls, "channelvalcomment:"+op) +} + +func TestUserSettingsReset(t *testing.T) { + ft := &fakeTracer{} + s := UserSettings{Tracer: ft, SongLoopCount: 2} + s.Start.Order.Set(index.Order(5)) + s.Start.Row.Set(index.Row(6)) + s.Start.Tempo = 7 + s.Start.BPM = 8 + s.PlayUntil.Order.Set(index.Order(9)) + s.PlayUntil.Row.Set(index.Row(10)) + s.LongChannelOutput = false + s.IgnoreUnknownEffect = true + s.EnableNewNoteActions = false + + s.Reset() + + if s.Tracer != ft { + t.Fatalf("expected tracer preserved after reset") + } + if s.SongLoopCount != 0 { + t.Fatalf("expected SongLoopCount reset to 0, got %d", s.SongLoopCount) + } + if _, ok := s.Start.Order.Get(); ok { + t.Fatalf("expected Start.Order cleared") + } + if _, ok := s.Start.Row.Get(); ok { + t.Fatalf("expected Start.Row cleared") + } + if s.Start.Tempo != 0 || s.Start.BPM != 0 { + t.Fatalf("expected Start tempo/BPM reset") + } + if _, ok := s.PlayUntil.Order.Get(); ok { + t.Fatalf("expected PlayUntil.Order cleared") + } + if _, ok := s.PlayUntil.Row.Get(); ok { + t.Fatalf("expected PlayUntil.Row cleared") + } + if !s.LongChannelOutput { + t.Fatalf("expected LongChannelOutput default true") + } + if s.IgnoreUnknownEffect { + t.Fatalf("expected IgnoreUnknownEffect default false") + } + if !s.EnableNewNoteActions { + t.Fatalf("expected EnableNewNoteActions default true") + } +} + +func TestUserSettingsTraceDelegation(t *testing.T) { + ft := &fakeTracer{} + s := UserSettings{Tracer: ft} + + s.OutputTraces() + s.SetTracingTick(1, 2, 3) + s.Trace("a") + s.TraceWithComment("b", "fmt") + s.TraceValueChange("c", 1, 2) + s.TraceValueChangeWithComment("d", 1, 2, "fmt") + s.TraceChannel(4, "e") + s.TraceChannelWithComment(5, "f", "fmt") + s.TraceChannelValueChange(6, "g", 1, 2) + s.TraceChannelValueChangeWithComment(7, "h", 1, 2, "fmt") + + if len(ft.calls) != 10 { + t.Fatalf("expected 10 tracer calls, got %d", len(ft.calls)) + } + if ft.lastTick.order != 1 || ft.lastTick.row != 2 || ft.lastTick.tick != 3 { + t.Fatalf("unexpected SetTracingTick args: %+v", ft.lastTick) + } + if ft.closed { + t.Fatalf("tracer should not be closed yet") + } + if err := s.CloseTracing(); err != nil { + t.Fatalf("CloseTracing returned error: %v", err) + } + if !ft.closed { + t.Fatalf("expected tracer closed after CloseTracing") + } +} + +func TestUserSettingsTracingNilGuard(t *testing.T) { + var s UserSettings + s.OutputTraces() + s.SetTracingTick(0, 0, 0) + s.Trace("noop") + s.TraceWithComment("noop", "fmt") + s.TraceValueChange("noop", 1, 2) + s.TraceValueChangeWithComment("noop", 1, 2, "fmt") + s.TraceChannel(0, "noop") + s.TraceChannelWithComment(0, "noop", "fmt") + s.TraceChannelValueChange(0, "noop", 1, 2) + s.TraceChannelValueChangeWithComment(0, "noop", 1, 2, "fmt") + if err := s.CloseTracing(); err != nil { + t.Fatalf("CloseTracing returned error on nil tracer: %v", err) + } +} + +func TestSetupTracingWithFilename(t *testing.T) { + var s UserSettings + path := filepath.Join(t.TempDir(), "trace.log") + if err := s.SetupTracingWithFilename(path); err != nil { + t.Fatalf("SetupTracingWithFilename error: %v", err) + } + if s.Tracer == nil { + t.Fatalf("expected tracer to be created") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected trace file to exist: %v", err) + } + if err := s.CloseTracing(); err != nil { + t.Fatalf("CloseTracing error: %v", err) + } +} diff --git a/player/machine/stub_row_stringer_test.go b/player/machine/stub_row_stringer_test.go new file mode 100644 index 0000000..1ceab27 --- /dev/null +++ b/player/machine/stub_row_stringer_test.go @@ -0,0 +1,5 @@ +package machine + +type stubRowStringer struct{ s string } + +func (s stubRowStringer) String() string { return s.s } diff --git a/player/machine/ticker.go b/player/machine/ticker.go index 39b35eb..cae1ba2 100644 --- a/player/machine/ticker.go +++ b/player/machine/ticker.go @@ -3,9 +3,10 @@ package machine import ( "errors" + "github.com/heucuva/optional" + "github.com/gotracker/playback/index" "github.com/gotracker/playback/song" - "github.com/heucuva/optional" ) type ticker struct { diff --git a/player/quirks/machine_ft2.go b/player/quirks/machine_ft2.go new file mode 100644 index 0000000..4683919 --- /dev/null +++ b/player/quirks/machine_ft2.go @@ -0,0 +1,73 @@ +package quirks + +import ( + "github.com/gotracker/playback/filter" + xmFilter "github.com/gotracker/playback/format/xm/filter" + xmOscillator "github.com/gotracker/playback/format/xm/oscillator" + xmPanning "github.com/gotracker/playback/format/xm/panning" + xmPeriod "github.com/gotracker/playback/format/xm/period" + xmVolume "github.com/gotracker/playback/format/xm/volume" + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/period" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/oscillator" +) + +type XMMachineDefaults struct { + AmigaPeriod period.PeriodConverter[period.Amiga] + LinearPeriod period.PeriodConverter[period.Linear] + FilterFactory any + VibratoFactory any + TremoloFactory any + PanbrelloFactory any +} + +func GetXMMachineSettingsAmiga(profile Profile, vf voice.VoiceFactory[period.Amiga, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) *settings.MachineSettings[period.Amiga, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] { + defs := getXMDefaults(profile) + + return &settings.MachineSettings[period.Amiga, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ + PeriodConverter: defs.AmigaPeriod, + GetFilterFactory: defs.FilterFactory.(func(string, frequency.Frequency, any) (filter.Filter, error)), + GetVibratoFactory: defs.VibratoFactory.(func() (oscillator.Oscillator, error)), + GetTremoloFactory: defs.TremoloFactory.(func() (oscillator.Oscillator, error)), + GetPanbrelloFactory: defs.PanbrelloFactory.(func() (oscillator.Oscillator, error)), + VoiceFactory: vf, + OPL2Enabled: false, + ModLimits: false, + Quirks: Resolve(profile), + } +} + +func GetXMMachineSettingsLinear(profile Profile, vf voice.VoiceFactory[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]) *settings.MachineSettings[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning] { + defs := getXMDefaults(profile) + + return &settings.MachineSettings[period.Linear, xmVolume.XmVolume, xmVolume.XmVolume, xmVolume.XmVolume, xmPanning.Panning]{ + PeriodConverter: defs.LinearPeriod, + GetFilterFactory: defs.FilterFactory.(func(string, frequency.Frequency, any) (filter.Filter, error)), + GetVibratoFactory: defs.VibratoFactory.(func() (oscillator.Oscillator, error)), + GetTremoloFactory: defs.TremoloFactory.(func() (oscillator.Oscillator, error)), + GetPanbrelloFactory: defs.PanbrelloFactory.(func() (oscillator.Oscillator, error)), + VoiceFactory: vf, + OPL2Enabled: false, + ModLimits: false, + Quirks: Resolve(profile), + } +} + +func getXMDefaults(profile Profile) XMMachineDefaults { + if def, ok := Get(profile); ok { + if md, ok := def.MachineDefaults.(XMMachineDefaults); ok { + return md + } + } + + return XMMachineDefaults{ + AmigaPeriod: xmPeriod.AmigaConverter, + LinearPeriod: xmPeriod.LinearConverter, + FilterFactory: xmFilter.Factory, + VibratoFactory: xmOscillator.VibratoFactory, + TremoloFactory: xmOscillator.TremoloFactory, + PanbrelloFactory: xmOscillator.PanbrelloFactory, + } +} diff --git a/player/quirks/machine_it.go b/player/quirks/machine_it.go new file mode 100644 index 0000000..4c6714c --- /dev/null +++ b/player/quirks/machine_it.go @@ -0,0 +1,73 @@ +package quirks + +import ( + "github.com/gotracker/playback/filter" + itFilter "github.com/gotracker/playback/format/it/filter" + itOscillator "github.com/gotracker/playback/format/it/oscillator" + itPanning "github.com/gotracker/playback/format/it/panning" + itPeriod "github.com/gotracker/playback/format/it/period" + itVolume "github.com/gotracker/playback/format/it/volume" + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/period" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/oscillator" +) + +type ITMachineDefaults struct { + AmigaPeriod period.PeriodConverter[period.Amiga] + LinearPeriod period.PeriodConverter[period.Linear] + FilterFactory any + VibratoFactory any + TremoloFactory any + PanbrelloFactory any +} + +func GetITMachineSettingsAmiga(profile Profile, vf voice.VoiceFactory[period.Amiga, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) *settings.MachineSettings[period.Amiga, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] { + defs := getITDefaults(profile) + + return &settings.MachineSettings[period.Amiga, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ + PeriodConverter: defs.AmigaPeriod, + GetFilterFactory: defs.FilterFactory.(func(string, frequency.Frequency, any) (filter.Filter, error)), + GetVibratoFactory: defs.VibratoFactory.(func() (oscillator.Oscillator, error)), + GetTremoloFactory: defs.TremoloFactory.(func() (oscillator.Oscillator, error)), + GetPanbrelloFactory: defs.PanbrelloFactory.(func() (oscillator.Oscillator, error)), + VoiceFactory: vf, + OPL2Enabled: false, + ModLimits: false, + Quirks: Resolve(profile), + } +} + +func GetITMachineSettingsLinear(profile Profile, vf voice.VoiceFactory[period.Linear, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]) *settings.MachineSettings[period.Linear, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning] { + defs := getITDefaults(profile) + + return &settings.MachineSettings[period.Linear, itVolume.FineVolume, itVolume.FineVolume, itVolume.Volume, itPanning.Panning]{ + PeriodConverter: defs.LinearPeriod, + GetFilterFactory: defs.FilterFactory.(func(string, frequency.Frequency, any) (filter.Filter, error)), + GetVibratoFactory: defs.VibratoFactory.(func() (oscillator.Oscillator, error)), + GetTremoloFactory: defs.TremoloFactory.(func() (oscillator.Oscillator, error)), + GetPanbrelloFactory: defs.PanbrelloFactory.(func() (oscillator.Oscillator, error)), + VoiceFactory: vf, + OPL2Enabled: false, + ModLimits: false, + Quirks: Resolve(profile), + } +} + +func getITDefaults(profile Profile) ITMachineDefaults { + if def, ok := Get(profile); ok { + if md, ok := def.MachineDefaults.(ITMachineDefaults); ok { + return md + } + } + + return ITMachineDefaults{ + AmigaPeriod: itPeriod.AmigaConverter, + LinearPeriod: itPeriod.LinearConverter, + FilterFactory: itFilter.Factory, + VibratoFactory: itOscillator.VibratoFactory, + TremoloFactory: itOscillator.TremoloFactory, + PanbrelloFactory: itOscillator.PanbrelloFactory, + } +} diff --git a/player/quirks/machine_s3m.go b/player/quirks/machine_s3m.go new file mode 100644 index 0000000..04a5ac6 --- /dev/null +++ b/player/quirks/machine_s3m.go @@ -0,0 +1,58 @@ +package quirks + +import ( + "github.com/gotracker/playback/filter" + s3mFilter "github.com/gotracker/playback/format/s3m/filter" + s3mOscillator "github.com/gotracker/playback/format/s3m/oscillator" + s3mPanning "github.com/gotracker/playback/format/s3m/panning" + s3mPeriod "github.com/gotracker/playback/format/s3m/period" + s3mVolume "github.com/gotracker/playback/format/s3m/volume" + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/period" + "github.com/gotracker/playback/player/machine/settings" + "github.com/gotracker/playback/song" + "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/oscillator" +) + +type S3MMachineDefaults struct { + AmigaPeriod period.PeriodConverter[period.Amiga] + FilterFactory any + VibratoFactory any + TremoloFactory any + PanbrelloFactory any + ModLimits bool +} + +func GetS3MMachineSettings(profile Profile, vf voice.VoiceFactory[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]) *settings.MachineSettings[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning] { + defs := getS3MDefaults(profile) + + return &settings.MachineSettings[period.Amiga, s3mVolume.Volume, s3mVolume.FineVolume, s3mVolume.Volume, s3mPanning.Panning]{ + PeriodConverter: defs.AmigaPeriod.(song.PeriodCalculator[period.Amiga]), + GetFilterFactory: defs.FilterFactory.(func(string, frequency.Frequency, any) (filter.Filter, error)), + GetVibratoFactory: defs.VibratoFactory.(func() (oscillator.Oscillator, error)), + GetTremoloFactory: defs.TremoloFactory.(func() (oscillator.Oscillator, error)), + GetPanbrelloFactory: defs.PanbrelloFactory.(func() (oscillator.Oscillator, error)), + VoiceFactory: vf, + OPL2Enabled: true, + ModLimits: defs.ModLimits, + Quirks: Resolve(profile), + } +} + +func getS3MDefaults(profile Profile) S3MMachineDefaults { + if def, ok := Get(profile); ok { + if md, ok := def.MachineDefaults.(S3MMachineDefaults); ok { + return md + } + } + + return S3MMachineDefaults{ + AmigaPeriod: s3mPeriod.S3MAmigaConverter, + FilterFactory: s3mFilter.Factory, + VibratoFactory: s3mOscillator.VibratoFactory, + TremoloFactory: s3mOscillator.TremoloFactory, + PanbrelloFactory: s3mOscillator.PanbrelloFactory, + ModLimits: false, + } +} diff --git a/player/quirks/profile_ft210.go b/player/quirks/profile_ft210.go new file mode 100644 index 0000000..7dccaff --- /dev/null +++ b/player/quirks/profile_ft210.go @@ -0,0 +1,33 @@ +package quirks + +import ( + xmFilter "github.com/gotracker/playback/format/xm/filter" + xmOscillator "github.com/gotracker/playback/format/xm/oscillator" + xmPeriod "github.com/gotracker/playback/format/xm/period" + "github.com/gotracker/playback/player/machine/settings" +) + +const ( + ProfileFT210 Profile = "ft2.10" +) + +func init() { + Register(Definition{ + Profile: ProfileFT210, + Description: "FastTracker 2.10", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileFT210), + PreviousPeriodUsesModifiedPeriod: false, + PortaToNoteUsesModifiedPeriod: false, + DoNotProcessEffectsOnMutedChannels: false, + }, + MachineDefaults: XMMachineDefaults{ + AmigaPeriod: xmPeriod.AmigaConverter, + LinearPeriod: xmPeriod.LinearConverter, + FilterFactory: xmFilter.Factory, + VibratoFactory: xmOscillator.VibratoFactory, + TremoloFactory: xmOscillator.TremoloFactory, + PanbrelloFactory: xmOscillator.PanbrelloFactory, + }, + }) +} diff --git a/player/quirks/profile_it212.go b/player/quirks/profile_it212.go new file mode 100644 index 0000000..938cb57 --- /dev/null +++ b/player/quirks/profile_it212.go @@ -0,0 +1,33 @@ +package quirks + +import ( + itFilter "github.com/gotracker/playback/format/it/filter" + itOscillator "github.com/gotracker/playback/format/it/oscillator" + itPeriod "github.com/gotracker/playback/format/it/period" + "github.com/gotracker/playback/player/machine/settings" +) + +const ( + ProfileIT212 Profile = "it212" +) + +func init() { + Register(Definition{ + Profile: ProfileIT212, + Description: "Impulse Tracker 2.12 (older behavior)", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileIT212), + PreviousPeriodUsesModifiedPeriod: false, + PortaToNoteUsesModifiedPeriod: false, + DoNotProcessEffectsOnMutedChannels: false, + }, + MachineDefaults: ITMachineDefaults{ + AmigaPeriod: itPeriod.AmigaConverter, + LinearPeriod: itPeriod.LinearConverter, + FilterFactory: itFilter.Factory, + VibratoFactory: itOscillator.VibratoFactory, + TremoloFactory: itOscillator.TremoloFactory, + PanbrelloFactory: itOscillator.PanbrelloFactory, + }, + }) +} diff --git a/player/quirks/profile_it214.go b/player/quirks/profile_it214.go new file mode 100644 index 0000000..9bcb8ab --- /dev/null +++ b/player/quirks/profile_it214.go @@ -0,0 +1,33 @@ +package quirks + +import ( + itFilter "github.com/gotracker/playback/format/it/filter" + itOscillator "github.com/gotracker/playback/format/it/oscillator" + itPeriod "github.com/gotracker/playback/format/it/period" + "github.com/gotracker/playback/player/machine/settings" +) + +const ( + ProfileIT214 Profile = "it214" +) + +func init() { + Register(Definition{ + Profile: ProfileIT214, + Description: "Impulse Tracker 2.14 (classic behavior)", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileIT214), + PreviousPeriodUsesModifiedPeriod: false, + PortaToNoteUsesModifiedPeriod: false, + DoNotProcessEffectsOnMutedChannels: false, + }, + MachineDefaults: ITMachineDefaults{ + AmigaPeriod: itPeriod.AmigaConverter, + LinearPeriod: itPeriod.LinearConverter, + FilterFactory: itFilter.Factory, + VibratoFactory: itOscillator.VibratoFactory, + TremoloFactory: itOscillator.TremoloFactory, + PanbrelloFactory: itOscillator.PanbrelloFactory, + }, + }) +} diff --git a/player/quirks/profile_openmpt.go b/player/quirks/profile_openmpt.go new file mode 100644 index 0000000..e697376 --- /dev/null +++ b/player/quirks/profile_openmpt.go @@ -0,0 +1,20 @@ +package quirks + +import "github.com/gotracker/playback/player/machine/settings" + +const ( + ProfileOpenMPTCurrent Profile = "openmpt-current" +) + +func init() { + Register(Definition{ + Profile: ProfileOpenMPTCurrent, + Description: "OpenMPT (modern defaults)", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileOpenMPTCurrent), + PreviousPeriodUsesModifiedPeriod: false, + PortaToNoteUsesModifiedPeriod: false, + DoNotProcessEffectsOnMutedChannels: false, + }, + }) +} diff --git a/player/quirks/profile_st321.go b/player/quirks/profile_st321.go new file mode 100644 index 0000000..02b0486 --- /dev/null +++ b/player/quirks/profile_st321.go @@ -0,0 +1,33 @@ +package quirks + +import ( + s3mFilter "github.com/gotracker/playback/format/s3m/filter" + s3mOscillator "github.com/gotracker/playback/format/s3m/oscillator" + s3mPeriod "github.com/gotracker/playback/format/s3m/period" + "github.com/gotracker/playback/player/machine/settings" +) + +const ( + ProfileST321 Profile = "st3.21" +) + +func init() { + Register(Definition{ + Profile: ProfileST321, + Description: "Scream Tracker 3.21", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileST321), + PreviousPeriodUsesModifiedPeriod: true, + PortaToNoteUsesModifiedPeriod: true, + DoNotProcessEffectsOnMutedChannels: true, + }, + MachineDefaults: S3MMachineDefaults{ + AmigaPeriod: s3mPeriod.S3MAmigaConverter, + FilterFactory: s3mFilter.Factory, + VibratoFactory: s3mOscillator.VibratoFactory, + TremoloFactory: s3mOscillator.TremoloFactory, + PanbrelloFactory: s3mOscillator.PanbrelloFactory, + ModLimits: false, + }, + }) +} diff --git a/player/quirks/profile_st321_modlimits.go b/player/quirks/profile_st321_modlimits.go new file mode 100644 index 0000000..af958ce --- /dev/null +++ b/player/quirks/profile_st321_modlimits.go @@ -0,0 +1,33 @@ +package quirks + +import ( + s3mFilter "github.com/gotracker/playback/format/s3m/filter" + s3mOscillator "github.com/gotracker/playback/format/s3m/oscillator" + s3mPeriod "github.com/gotracker/playback/format/s3m/period" + "github.com/gotracker/playback/player/machine/settings" +) + +const ( + ProfileST321_ModLimits Profile = "st3.21+modlimits" +) + +func init() { + Register(Definition{ + Profile: ProfileST321_ModLimits, + Description: "Scream Tracker 3.21 (MOD limits)", + Quirks: settings.MachineQuirks{ + Profile: string(ProfileST321_ModLimits), + PreviousPeriodUsesModifiedPeriod: true, + PortaToNoteUsesModifiedPeriod: true, + DoNotProcessEffectsOnMutedChannels: true, + }, + MachineDefaults: S3MMachineDefaults{ + AmigaPeriod: s3mPeriod.S3MAmigaConverter, + FilterFactory: s3mFilter.Factory, + VibratoFactory: s3mOscillator.VibratoFactory, + TremoloFactory: s3mOscillator.TremoloFactory, + PanbrelloFactory: s3mOscillator.PanbrelloFactory, + ModLimits: true, + }, + }) +} diff --git a/player/quirks/quirks.go b/player/quirks/quirks.go new file mode 100644 index 0000000..85725f7 --- /dev/null +++ b/player/quirks/quirks.go @@ -0,0 +1,42 @@ +package quirks + +import "github.com/gotracker/playback/player/machine/settings" + +type Profile string + +type Definition struct { + Profile Profile + Description string + Quirks settings.MachineQuirks + MachineDefaults any +} + +var registry = map[Profile]Definition{} + +// Register adds or replaces a quirks definition for a profile. +func Register(def Definition) { + registry[def.Profile] = def +} + +// Resolve returns a quirks set for the requested profile. Unknown profiles fall back to an empty quirks definition. +func Resolve(profile Profile) settings.MachineQuirks { + if def, ok := registry[profile]; ok { + return def.Quirks + } + return settings.MachineQuirks{Profile: string(profile)} +} + +// Get returns the full definition for a profile if registered. +func Get(profile Profile) (Definition, bool) { + def, ok := registry[profile] + return def, ok +} + +// List returns the known quirk definitions. +func List() []Definition { + out := make([]Definition, 0, len(registry)) + for _, def := range registry { + out = append(out, def) + } + return out +} diff --git a/player/render/channel.go b/player/render/channel.go index 56e496d..99126be 100644 --- a/player/render/channel.go +++ b/player/render/channel.go @@ -2,11 +2,11 @@ package render import ( "github.com/gotracker/opl2" + + "github.com/gotracker/playback/filter" "github.com/gotracker/playback/mixing" "github.com/gotracker/playback/mixing/panning" "github.com/gotracker/playback/mixing/volume" - - "github.com/gotracker/playback/filter" "github.com/gotracker/playback/period" "github.com/gotracker/playback/voice" "github.com/gotracker/playback/voice/mixer" diff --git a/player/render/channel_test.go b/player/render/channel_test.go new file mode 100644 index 0000000..3b7fe91 --- /dev/null +++ b/player/render/channel_test.go @@ -0,0 +1,42 @@ +package render + +import ( + "testing" + + "github.com/gotracker/playback/filter" + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/period" +) + +type stubFilter struct { + factor float32 + playbackRate float32 +} + +func (s *stubFilter) Filter(m volume.Matrix) volume.Matrix { + for i := 0; i < m.Channels; i++ { + m.StaticMatrix[i] = volume.Volume(float32(m.StaticMatrix[i]) * s.factor) + } + return m +} +func (s *stubFilter) SetPlaybackRate(pr frequency.Frequency) { s.playbackRate = float32(pr) } +func (s *stubFilter) UpdateEnv(uint8) {} +func (s *stubFilter) Clone() filter.Filter { c := *s; return &c } + +func TestChannelApplyFilterPipeline(t *testing.T) { + plugin := &stubFilter{factor: 2} + output := &stubFilter{factor: 0.5} + ch := Channel[period.Linear]{ + PluginFilter: plugin, + OutputFilter: output, + GlobalVolume: 0.5, + } + + dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1}, Channels: 1} + wet := ch.ApplyFilter(dry) + // dry -> plugin *2 -> 2; apply GlobalVolume 0.5 -> 1; output *0.5 -> 0.5 + if wet.StaticMatrix[0] != 0.5 { + t.Fatalf("unexpected filtered value: %v", wet.StaticMatrix[0]) + } +} diff --git a/player/render/render.go b/player/render/render.go index 012504c..5a7966d 100644 --- a/player/render/render.go +++ b/player/render/render.go @@ -6,28 +6,42 @@ import ( "github.com/gotracker/playback/song" ) -// RowDisplay is an array of ChannelDisplays -type RowDisplay[TChannelData song.ChannelDataIntf] struct { - Channels []TChannelData +// RowViewModel holds the channel data to be rendered. +type RowViewModel[TChannelData song.ChannelDataIntf] struct { + Channels []TChannelData + MaxChannels int // <=0 means no limit +} + +// NewRowViewModel creates an empty row view model with the requested channel capacity. +func NewRowViewModel[TChannelData song.ChannelDataIntf](channels int) RowViewModel[TChannelData] { + return RowViewModel[TChannelData]{ + Channels: make([]TChannelData, channels), + MaxChannels: -1, + } +} + +// RowText formats a row view model as a string. +type RowText[TChannelData song.ChannelDataIntf] struct { + ViewModel RowViewModel[TChannelData] longFormat bool } -// NewRowText creates an array of ChannelDisplay information -func NewRowText[TChannelData song.ChannelDataIntf](channels int, longFormat bool) RowDisplay[TChannelData] { - rd := RowDisplay[TChannelData]{ - Channels: make([]TChannelData, channels), +// FormatRowText builds a stringer from a populated view model. +func FormatRowText[TChannelData song.ChannelDataIntf](vm RowViewModel[TChannelData], longFormat bool) RowText[TChannelData] { + return RowText[TChannelData]{ + ViewModel: vm, longFormat: longFormat, } - return rd } -func (rt RowDisplay[TChannelData]) String(options ...any) string { - maxChannels := -1 - if len(options) > 0 { - maxChannels = options[0].(int) +func (rt RowText[TChannelData]) String() string { + vm := rt.ViewModel + maxChannels := vm.MaxChannels + if maxChannels <= 0 { + maxChannels = len(vm.Channels) } - items := make([]string, 0, len(rt.Channels)) - for i, c := range rt.Channels { + items := make([]string, 0, len(vm.Channels)) + for i, c := range vm.Channels { if maxChannels >= 0 && i >= maxChannels { break } diff --git a/player/render/render_test.go b/player/render/render_test.go new file mode 100644 index 0000000..c3f7533 --- /dev/null +++ b/player/render/render_test.go @@ -0,0 +1,44 @@ +package render + +import ( + "testing" + + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/note" +) + +type stubChannelData struct{} + +func (stubChannelData) HasNote() bool { return false } +func (stubChannelData) GetNote() note.Note { return note.EmptyNote{} } +func (stubChannelData) HasInstrument() bool { return false } +func (stubChannelData) GetInstrument() int { return 0 } +func (stubChannelData) HasVolume() bool { return false } +func (stubChannelData) GetVolumeGeneric() volume.Volume { return 0 } +func (stubChannelData) HasCommand() bool { return false } +func (stubChannelData) Channel() uint8 { return 0 } +func (stubChannelData) String() string { return "AAA" } +func (stubChannelData) ShortString() string { return "A" } + +func TestRowTextShortAndLong(t *testing.T) { + vm := NewRowViewModel[stubChannelData](3) + vm.Channels[0] = stubChannelData{} + vm.Channels[1] = stubChannelData{} + vm.Channels[2] = stubChannelData{} + + short := FormatRowText(vm, false).String() + if short != "|A|A|A|" { + t.Fatalf("unexpected short row text: %q", short) + } + + long := FormatRowText(vm, true).String() + if long != "|AAA|AAA|AAA|" { + t.Fatalf("unexpected long row text: %q", long) + } + + vm.MaxChannels = 2 + truncated := FormatRowText(vm, false).String() + if truncated != "|A|A|" { + t.Fatalf("expected truncation to 2 channels, got %q", truncated) + } +} diff --git a/player/sampler/sampler.go b/player/sampler/sampler.go index ea005e6..fcf6da8 100644 --- a/player/sampler/sampler.go +++ b/player/sampler/sampler.go @@ -6,7 +6,12 @@ import ( "github.com/gotracker/playback/output" ) -// Sampler is a container of sampler/mixer settings +// Sampler is a container of sampler/mixer settings. +// +// Concurrency and ownership: +// - OnGenerate runs synchronously during rendering; long-running callbacks will stall playback. +// - Mixer should be configured before playback starts; do not mutate it while playback or callbacks run. +// - External goroutines must not race with Sampler state (SampleRate, StereoSeparation, Mixer). type Sampler struct { SampleRate int BaseClockRate frequency.Frequency @@ -34,6 +39,11 @@ func (s *Sampler) Mixer() *mixing.Mixer { return &s.mixer } +// MixerConfig returns a copy of the current mixer settings without exposing mutability. +func (s *Sampler) MixerConfig() mixing.Mixer { + return s.mixer +} + // GetPanMixer returns the panning mixer that can generate a matrix // based on input pan value func (s *Sampler) GetPanMixer() mixing.PanMixer { diff --git a/player/sampler/sampler_test.go b/player/sampler/sampler_test.go new file mode 100644 index 0000000..2f19723 --- /dev/null +++ b/player/sampler/sampler_test.go @@ -0,0 +1,41 @@ +package sampler + +import "testing" + +func TestSamplerMixerAccessors(t *testing.T) { + s := NewSampler(44100, 2, 0.5, nil) + + m := s.Mixer() + if m.Channels != 2 { + t.Fatalf("expected mixer channels 2, got %d", m.Channels) + } + + // mutating through Mixer pointer updates internal mixer + m.Channels = 4 + if s.MixerConfig().Channels != 4 { + t.Fatalf("expected MixerConfig to reflect pointer mutation to 4, got %d", s.MixerConfig().Channels) + } + + // modifying returned config copy should not alter stored mixer + cfg := s.MixerConfig() + cfg.Channels = 1 + if s.Mixer().Channels != 4 { + t.Fatalf("expected internal mixer to remain 4, got %d", s.Mixer().Channels) + } +} + +func TestSamplerGetPanMixer(t *testing.T) { + s := NewSampler(44100, 2, 1, nil) + pm := s.GetPanMixer() + if pm == nil { + t.Fatalf("expected stereo pan mixer") + } + if pm.NumChannels() != 2 { + t.Fatalf("expected pan mixer with 2 channels, got %d", pm.NumChannels()) + } + + s.mixer.Channels = 3 + if s.GetPanMixer() != nil { + t.Fatalf("expected nil pan mixer for unsupported channel count") + } +} diff --git a/song/row.go b/song/row.go index ab39fac..4573072 100644 --- a/song/row.go +++ b/song/row.go @@ -1,17 +1,25 @@ package song -import "github.com/gotracker/playback/index" +import ( + "fmt" + + "github.com/gotracker/playback/index" +) type rowIntf[TVolume Volume] interface { Len() int ForEach(fn func(ch index.Channel, d ChannelData[TVolume]) (bool, error)) error } +type rowLenIntf interface { + Len() int +} + // Row is a structure containing a single row type Row any -func GetRowNumChannels[TVolume Volume](r Row) int { - if row, ok := r.(rowIntf[TVolume]); ok { +func GetRowNumChannels(r Row) int { + if row, ok := r.(rowLenIntf); ok { return row.Len() } return 0 @@ -27,5 +35,5 @@ func ForEachRowChannel[TVolume Volume](r Row, fn func(ch index.Channel, d Channe } type RowStringer interface { - String(options ...any) string + fmt.Stringer } diff --git a/song/row_test.go b/song/row_test.go new file mode 100644 index 0000000..80edb51 --- /dev/null +++ b/song/row_test.go @@ -0,0 +1,50 @@ +package song + +import ( + "testing" + + "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/volume" +) + +type testVol int + +func (testVol) IsInvalid() bool { return false } +func (testVol) IsUseInstrumentVol() bool { return false } +func (v testVol) ToVolume() volume.Volume { return volume.Volume(v) } + +type stubRow[T Volume] struct{ n int } + +func (s stubRow[T]) Len() int { return s.n } +func (s stubRow[T]) ForEach(fn func(ch index.Channel, d ChannelData[T]) (bool, error)) error { + for i := 0; i < s.n; i++ { + if stop, err := fn(index.Channel(i), nil); stop || err != nil { + return err + } + } + return nil +} + +func TestGetRowNumChannels(t *testing.T) { + if GetRowNumChannels(stubRow[testVol]{n: 3}) != 3 { + t.Fatalf("expected 3 channels") + } + if GetRowNumChannels(struct{}{}) != 0 { + t.Fatalf("expected 0 for unknown type") + } +} + +func TestForEachRowChannel(t *testing.T) { + r := stubRow[testVol]{n: 2} + count := 0 + err := ForEachRowChannel[testVol](r, func(ch index.Channel, d ChannelData[testVol]) (bool, error) { + count++ + return false, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 2 { + t.Fatalf("expected to iterate 2 channels, got %d", count) + } +} diff --git a/system/clockedsystem_test.go b/system/clockedsystem_test.go new file mode 100644 index 0000000..3c50dcd --- /dev/null +++ b/system/clockedsystem_test.go @@ -0,0 +1,53 @@ +package system + +import ( + "testing" + + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/note" +) + +func TestClockedSystemAccessors(t *testing.T) { + s := ClockedSystem{ + MaxPastNotesPerChannel: 2, + BaseClock: 1000, + BaseFinetunes: 48, + FinetunesPerOctave: 192, + FinetunesPerNote: 16, + CommonPeriod: 900, + CommonRate: frequency.Frequency(48000), + SemitonePeriods: [note.NumKeys]uint16{10, 20, 30, 40}, + OctaveShift: 1, + } + + if s.GetMaxPastNotesPerChannel() != 2 { + t.Fatalf("MaxPastNotesPerChannel = %d", s.GetMaxPastNotesPerChannel()) + } + if s.GetBaseClock() != 1000 { + t.Fatalf("BaseClock = %v", s.GetBaseClock()) + } + if s.GetBaseFinetunes() != 48 { + t.Fatalf("BaseFinetunes = %v", s.GetBaseFinetunes()) + } + if s.GetFinetunesPerOctave() != 192 || s.GetFinetunesPerSemitone() != 16 { + t.Fatalf("unexpected finetune values") + } + if s.GetCommonPeriod() != 900 || s.GetCommonRate() != 48000 { + t.Fatalf("unexpected common values") + } + + per0, ok := s.GetSemitonePeriod(note.Key(0)) + if !ok || per0 != 10>>1 { + t.Fatalf("expected semitone period 5, got %d ok=%v", per0, ok) + } + per1, ok := s.GetSemitonePeriod(note.Key(1)) + if !ok || per1 != 20>>1 { + t.Fatalf("expected semitone period 10, got %d ok=%v", per1, ok) + } + if _, ok := s.GetSemitonePeriod(note.Key(20)); ok { + t.Fatalf("expected out-of-range key to return ok=false") + } + if s.GetOctaveShift() != 1 { + t.Fatalf("OctaveShift = %d", s.GetOctaveShift()) + } +} diff --git a/tracing/entry.go b/tracing/entry.go index 0f34eda..d6d1c45 100644 --- a/tracing/entry.go +++ b/tracing/entry.go @@ -5,14 +5,14 @@ import ( "strings" ) -type entry[TPrefix Ticker, TPayload fmt.Stringer] struct { - prefix TPrefix +type entry struct { + prefix Ticker operation string comment string - payload TPayload + payload fmt.Stringer } -func (e entry[TPrefix, TPayload]) String() string { +func (e entry) String() string { var chunks []string if len(e.operation) > 0 { chunks = append(chunks, e.operation) @@ -26,11 +26,11 @@ func (e entry[TPrefix, TPayload]) String() string { return strings.Join(chunks, " ") } -func (e entry[TPrefix, TPayload]) GetTick() Tick { +func (e entry) GetTick() Tick { return e.prefix.GetTick() } -func (e entry[TPrefix, TPayload]) Prefix() string { +func (e entry) Prefix() string { return e.prefix.String() } @@ -55,8 +55,8 @@ func (t *tracerFile) traceWithComment(tick Tick, op, comment string) { traceWithPayload(t, tick, op, comment, empty) } -func traceWithPayload[TPrefix Ticker, TPayload fmt.Stringer](t *tracerFile, prefix TPrefix, op, comment string, payload TPayload) { - e := entry[TPrefix, TPayload]{ +func traceWithPayload(t *tracerFile, prefix Ticker, op, comment string, payload fmt.Stringer) { + e := entry{ prefix: prefix, operation: op, comment: comment, diff --git a/tracing/tracing_test.go b/tracing/tracing_test.go new file mode 100644 index 0000000..c5a8163 --- /dev/null +++ b/tracing/tracing_test.go @@ -0,0 +1,47 @@ +package tracing + +import ( + "os" + "testing" +) + +func TestTickEqualsAndString(t *testing.T) { + a := Tick{Order: 1, Row: 2, Tick: 3} + b := Tick{Order: 1, Row: 2, Tick: 3} + c := Tick{Order: 1, Row: 2, Tick: 4} + + if !a.Equals(b) { + t.Fatalf("expected ticks to be equal") + } + if a.Equals(c) { + t.Fatalf("expected ticks to differ") + } + if got := a.String(); got != "001:002 3" { + t.Fatalf("unexpected tick string: %q", got) + } +} + +func TestTracerSkipsWhenFileNil(t *testing.T) { + tf := tracerFile{} + tf.traceWithComment(Tick{}, "op", "comment") + tf.traceValueChange(Tick{}, "op", 1, 2) + if len(tf.updates) != 0 { + t.Fatalf("expected no updates when file is nil, got %d", len(tf.updates)) + } +} + +func TestTracerValueChangeIgnoresEqual(t *testing.T) { + f, err := os.CreateTemp("", "trace-test-*.log") + if err != nil { + t.Fatalf("temp file error: %v", err) + } + defer os.Remove(f.Name()) + tf := tracerFile{file: f} + + tf.traceValueChange(Tick{}, "op", 1, 1) + tf.traceValueChange(Tick{}, "op", 1, 2) + + if len(tf.updates) != 1 { + t.Fatalf("expected only unequal change to be recorded, got %d", len(tf.updates)) + } +} diff --git a/tremor/tremor_test.go b/tremor/tremor_test.go new file mode 100644 index 0000000..296038a --- /dev/null +++ b/tremor/tremor_test.go @@ -0,0 +1,24 @@ +package tremor + +import "testing" + +func TestTremorToggleAdvanceReset(t *testing.T) { + var tr Tremor + + if !tr.IsActive() { + t.Fatalf("expected tremor to start active") + } + + tr.ToggleAndReset() + if tr.IsActive() { + t.Fatalf("expected tremor to toggle off") + } + if tr.Advance() != 1 { + t.Fatalf("expected tick to increment after toggle") + } + + tr.Reset() + if tr.tick != 0 || !tr.IsActive() { + t.Fatalf("expected reset to clear tick and enable: %+v", tr) + } +} diff --git a/util/lerp_test.go b/util/lerp_test.go new file mode 100644 index 0000000..0e2f494 --- /dev/null +++ b/util/lerp_test.go @@ -0,0 +1,15 @@ +package util + +import "testing" + +func TestLerpClampsAndInterpolates(t *testing.T) { + if got := Lerp(-0.1, 0, 10); got != 0 { + t.Fatalf("expected clamp to a when t<0, got %d", got) + } + if got := Lerp(1.5, 0, 10); got != 10 { + t.Fatalf("expected clamp to b when t>1, got %d", got) + } + if got := Lerp(0.5, 0, 10); got != 5 { + t.Fatalf("expected midpoint interpolation to be 5, got %d", got) + } +} diff --git a/voice/autovibrato/config_test.go b/voice/autovibrato/config_test.go new file mode 100644 index 0000000..5bc1610 --- /dev/null +++ b/voice/autovibrato/config_test.go @@ -0,0 +1,66 @@ +package autovibrato + +import ( + "errors" + "testing" + + "github.com/gotracker/playback/voice/oscillator" +) + +type fakeOsc struct { + wave oscillator.WaveTableSelect + clone bool +} + +func (f *fakeOsc) Clone() oscillator.Oscillator { f.clone = true; return f } +func (f *fakeOsc) GetWave(depth float32) float32 { return float32(f.wave) + depth } +func (f *fakeOsc) Advance(speed int) {} +func (f *fakeOsc) SetWaveform(table oscillator.WaveTableSelect) { f.wave = table } +func (f *fakeOsc) GetWaveform() oscillator.WaveTableSelect { return f.wave } +func (f *fakeOsc) HardReset() {} +func (f *fakeOsc) Reset() {} + +type testPeriod struct{} + +func (testPeriod) IsInvalid() bool { return false } + +func TestGenerateUsesFactoryName(t *testing.T) { + cfg := AutoVibratoConfig[testPeriod]{FactoryName: "sine", WaveformSelection: 2} + osc, err := cfg.Generate(func(name string) (oscillator.Oscillator, error) { + if name != "sine" { + return nil, errors.New("bad name") + } + return &fakeOsc{}, nil + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + if osc == nil { + t.Fatalf("expected oscillator instance") + } + if osc.GetWaveform() != 2 { + t.Fatalf("expected waveform 2, got %d", osc.GetWaveform()) + } +} + +func TestGenerateReturnsNilWhenFactoryNil(t *testing.T) { + cfg := AutoVibratoConfig[testPeriod]{} + osc, err := cfg.Generate(nil) + if err != nil { + t.Fatalf("expected nil error when factory nil") + } + if osc != nil { + t.Fatalf("expected nil oscillator when factory nil") + } +} + +func TestGeneratePropagatesFactoryError(t *testing.T) { + expect := errors.New("boom") + cfg := AutoVibratoConfig[testPeriod]{FactoryName: "saw"} + _, err := cfg.Generate(func(string) (oscillator.Oscillator, error) { + return nil, expect + }) + if !errors.Is(err, expect) { + t.Fatalf("expected propagated error, got %v", err) + } +} diff --git a/voice/autovibrato/settings_test.go b/voice/autovibrato/settings_test.go new file mode 100644 index 0000000..d301028 --- /dev/null +++ b/voice/autovibrato/settings_test.go @@ -0,0 +1,31 @@ +package autovibrato + +import ( + "testing" + + "github.com/gotracker/playback/voice/oscillator" +) + +type stubOsc struct{} + +func (s *stubOsc) Clone() oscillator.Oscillator { return s } +func (s *stubOsc) GetWave(depth float32) float32 { return depth } +func (s *stubOsc) Advance(speed int) {} +func (s *stubOsc) SetWaveform(table oscillator.WaveTableSelect) {} +func (s *stubOsc) GetWaveform() oscillator.WaveTableSelect { return 0 } +func (s *stubOsc) HardReset() {} +func (s *stubOsc) Reset() {} + +func TestAutoVibratoSettingsFactoryStored(t *testing.T) { + cfg := AutoVibratoSettings[testPeriod]{Factory: func(name string) (oscillator.Oscillator, error) { + return &stubOsc{}, nil + }} + + osc, err := cfg.Factory("any") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if osc == nil { + t.Fatalf("expected oscillator instance") + } +} diff --git a/voice/component/envelope.go b/voice/component/envelope.go index c3ff266..a64fb8b 100755 --- a/voice/component/envelope.go +++ b/voice/component/envelope.go @@ -2,6 +2,7 @@ package component import ( "fmt" + "sort" "github.com/gotracker/playback/index" "github.com/gotracker/playback/tracing" @@ -41,6 +42,7 @@ type EnvelopeSettings[TIn, TOut any] struct { func (e *baseEnvelope[TIn, TOut]) Setup(settings EnvelopeSettings[TIn, TOut], update func(TIn, TIn, float64) TOut) { e.settings = settings + e.ensureDefaults() e.updater = update e.Reset() } @@ -48,6 +50,7 @@ func (e *baseEnvelope[TIn, TOut]) Setup(settings EnvelopeSettings[TIn, TOut], up func (e baseEnvelope[TIn, TOut]) Clone(update func(TIn, TIn, float64) TOut, onFinished voice.Callback) baseEnvelope[TIn, TOut] { m := e m.settings.OnFinished = onFinished + m.ensureDefaults() m.updater = update return m } @@ -58,6 +61,22 @@ func (e *baseEnvelope[TIn, TOut]) Reset() error { return e.stateReset() } +func (e *baseEnvelope[TIn, TOut]) ensureDefaults() { + if e.settings.Envelope.Loop == nil { + e.settings.Envelope.Loop = &loop.Disabled{} + } + if e.settings.Envelope.Sustain == nil { + e.settings.Envelope.Sustain = &loop.Disabled{} + } + if e.settings.Envelope.Length < 0 { + e.settings.Envelope.Length = 0 + } +} + +func (e baseEnvelope[TIn, TOut]) loopPos(pos int, keyOn bool) (int, bool) { + return loop.CalcLoopPos(e.settings.Envelope.Loop, e.settings.Envelope.Sustain, pos, e.settings.Envelope.Length, keyOn) +} + func (e baseEnvelope[TIn, TOut]) CanLoop() bool { return e.settings.Loop != nil && e.settings.Loop.Enabled() } @@ -128,6 +147,7 @@ func (e *baseEnvelope[TIn, TOut]) stateReset() error { e.keyed.pos = 0 e.keyed.done = false + e.ensureDefaults() return e.updateValue() } @@ -142,42 +162,28 @@ func (e *baseEnvelope[TIn, TOut]) updateValue() error { return nil } - curTick, _ := loop.CalcLoopPos(e.settings.Envelope.Loop, e.settings.Envelope.Sustain, e.keyed.pos, e.settings.Envelope.Length, e.prevKeyOn) - nextTick, _ := loop.CalcLoopPos(e.settings.Envelope.Loop, e.settings.Envelope.Sustain, curTick+1, e.settings.Envelope.Length, e.keyOn) - - curPoint := -1 - for i, it := range e.settings.Envelope.Values { - if it.Pos > curTick { - curPoint = i - 1 - break - } - } - var cur envelope.Point[TIn] - if curPoint >= 0 && curPoint < nPoints { - cur = e.settings.Values[curPoint] - } else { - cur = e.settings.Values[nPoints-1] + curTick, _ := e.loopPos(e.keyed.pos, e.prevKeyOn) + vals := e.settings.Envelope.Values + curIdx := sort.Search(len(vals), func(i int) bool { return vals[i].Pos > curTick }) - 1 + if curIdx < 0 { + curIdx = 0 + } else if curIdx >= nPoints { + curIdx = nPoints - 1 } - nextPoint := -1 - for i, it := range e.settings.Envelope.Values { - if it.Pos > nextTick { - nextPoint = i - break - } - } - - if nextPoint < 0 || nextPoint >= nPoints { - e.value = e.updater(cur.Y, cur.Y, 0) + nextIdx := curIdx + 1 + if nextIdx >= nPoints { + e.value = e.updater(vals[curIdx].Y, vals[curIdx].Y, 0) return nil } - next := e.settings.Values[nextPoint] + cur := vals[curIdx] + next := vals[nextIdx] t := float64(0) - if cur.Length > 0 { + if cur.Length > 0 && curTick >= cur.Pos { if tl := curTick - cur.Pos; tl > 0 { - t = max(min((float64(tl)/float64(cur.Length)), 1), 0) + t = max(min(float64(tl)/float64(cur.Length), 1), 0) } } @@ -208,26 +214,15 @@ func (e *baseEnvelope[TIn, TOut]) stateAdvance(keyOn bool) bool { } e.keyed.pos++ - curTick, looped := loop.CalcLoopPos(e.settings.Envelope.Loop, e.settings.Envelope.Sustain, e.keyed.pos, e.settings.Envelope.Length, keyOn) + curTick, looped := e.loopPos(e.keyed.pos, keyOn) - found := false - for _, i := range e.settings.Envelope.Values { - if i.Pos >= curTick { - found = true - break - } - } - - if !found { + vals := e.settings.Envelope.Values + idx := sort.Search(len(vals), func(i int) bool { return vals[i].Pos >= curTick }) + if idx == len(vals) && !looped && !keyOn && curTick >= e.settings.Length { e.keyed.done = true return true } - if !keyOn && !looped && curTick >= e.settings.Length { - e.keyed.done = false - return true - } - e.updateValue() return false } diff --git a/voice/component/envelope_test.go b/voice/component/envelope_test.go new file mode 100644 index 0000000..81c943b --- /dev/null +++ b/voice/component/envelope_test.go @@ -0,0 +1,99 @@ +package component + +import ( + "testing" + + "github.com/gotracker/playback/voice" + "github.com/gotracker/playback/voice/envelope" + "github.com/gotracker/playback/voice/loop" +) + +func TestVolumeEnvelopeAdvancesAndFinishes(t *testing.T) { + var env VolumeEnvelope[testVolume] + finished := false + onFinished := voice.Callback(func(v voice.Voice) { finished = true }) + + env.Setup(EnvelopeSettings[testVolume, testVolume]{ + Envelope: envelope.Envelope[testVolume]{ + Enabled: true, + Loop: &loop.Disabled{}, + Sustain: &loop.Disabled{}, + Length: 12, + Values: []envelope.Point[testVolume]{ + {Pos: 0, Length: 2, Y: testVolume(0)}, + {Pos: 10, Length: 0, Y: testVolume(1)}, + }, + }, + OnFinished: onFinished, + }) + + if v := env.GetCurrentValue(); !almostEqualVol(v.ToVolume(), 0, 1e-6) { + t.Fatalf("initial envelope value mismatch: got %v", v) + } + + if cb := env.Advance(); cb != nil { + t.Fatalf("expected no callback on first advance") + } + if v := env.GetCurrentValue(); !almostEqualVol(v.ToVolume(), 0.5, 1e-6) { + t.Fatalf("after first advance expected 0.5, got %v", v) + } + + if cb := env.Advance(); cb != nil { + t.Fatalf("expected no callback on second advance") + } + if v := env.GetCurrentValue(); !almostEqualVol(v.ToVolume(), 1, 1e-6) { + t.Fatalf("after second advance expected 1.0, got %v", v) + } + + var cb voice.Callback + for i := 0; i < 16 && cb == nil; i++ { + cb = env.Advance() + } + if cb == nil { + t.Fatalf("expected callback when envelope finishes") + } + + cb(nil) + if !finished { + t.Fatalf("expected returned callback to set finished flag") + } +} + +func TestEnvelopeHandlesNilLoopsDeterministically(t *testing.T) { + var env VolumeEnvelope[testVolume] + finished := false + onFinished := voice.Callback(func(v voice.Voice) { finished = true }) + + env.Setup(EnvelopeSettings[testVolume, testVolume]{ + Envelope: envelope.Envelope[testVolume]{ + Enabled: true, + Length: 4, + Values: []envelope.Point[testVolume]{ + {Pos: 0, Length: 2, Y: testVolume(0)}, + {Pos: 2, Length: 1, Y: testVolume(1)}, + }, + }, + OnFinished: onFinished, + }) + + if v := env.GetCurrentValue(); !almostEqualVol(v.ToVolume(), 0, 1e-6) { + t.Fatalf("initial value mismatch: got %v", v) + } + + _ = env.Advance() + if v := env.GetCurrentValue(); v.ToVolume() <= 0 { + t.Fatalf("expected progression after advance, got %v", v) + } + + var cb voice.Callback + for i := 0; i < 8 && cb == nil; i++ { + cb = env.Advance() + } + if cb == nil { + t.Fatalf("expected envelope to finish") + } + cb(nil) + if !finished { + t.Fatalf("expected finished callback to run") + } +} diff --git a/voice/component/modulator_amp.go b/voice/component/modulator_amp.go index af37953..58cfc4f 100755 --- a/voice/component/modulator_amp.go +++ b/voice/component/modulator_amp.go @@ -3,11 +3,12 @@ package component import ( "fmt" + "github.com/heucuva/optional" + "github.com/gotracker/playback/index" "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/tracing" "github.com/gotracker/playback/voice/types" - "github.com/heucuva/optional" ) // AmpModulator is an amplitude (volume) modulator diff --git a/voice/component/opl2.go b/voice/component/opl2.go index e707417..5cc0342 100755 --- a/voice/component/opl2.go +++ b/voice/component/opl2.go @@ -2,10 +2,10 @@ package component import ( "github.com/gotracker/opl2" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/frequency" "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/period" "github.com/gotracker/playback/tracing" "github.com/gotracker/playback/voice/types" @@ -69,6 +69,9 @@ func (o *OPL2[TPeriod, TMixingVolume, TVolume]) Fadeout() { // DeferredAttack activates the key-on bit func (o *OPL2[TPeriod, TMixingVolume, TVolume]) DeferredAttack() { o.keyOn = true + if o.chip == nil { + return + } // calculate the register addressing information index := uint32(o.channel) mod := o.getChannelIndex(o.channel) @@ -98,6 +101,9 @@ func (o *OPL2[TPeriod, TMixingVolume, TVolume]) DeferredRelease() { // calculate the register addressing information index := uint32(o.channel) ch := o.chip + if ch == nil { + return + } // send the voice details out to the chip ch.WriteReg(0xB0|index, 0x00) @@ -105,6 +111,10 @@ func (o *OPL2[TPeriod, TMixingVolume, TVolume]) DeferredRelease() { // Advance advances the playback func (o *OPL2[TPeriod, TMixingVolume, TVolume]) Advance(carVol volume.Volume, period TPeriod) { + if o.chip == nil { + return + } + // calculate the register addressing information index := uint32(o.channel) mod := o.getChannelIndex(o.channel) diff --git a/voice/component/opl2_test.go b/voice/component/opl2_test.go new file mode 100644 index 0000000..cacdb2d --- /dev/null +++ b/voice/component/opl2_test.go @@ -0,0 +1,42 @@ +package component + +import ( + "testing" + + s3mVolume "github.com/gotracker/playback/format/s3m/volume" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/period" +) + +// These tests focus on the math helpers that do not require a live OPL2 chip. +func TestOPL2Calc40(t *testing.T) { + opl := OPL2[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume]{} + + if got := opl.calc40(0x00, volume.Volume(1)); got != 0x00 { + t.Fatalf("calc40(0x00, 1.0) = %d, want 0", got) + } + + if got := opl.calc40(0x3f, volume.Volume(1)); got != 0x3f { + t.Fatalf("calc40(0x3f, 1.0) = %d, want 63 (no attenuation)", got) + } + + if got := opl.calc40(0x00, volume.Volume(0.5)); got != 0x1f && got != 0x20 { + t.Fatalf("calc40(0x00, 0.5) = %d, want ~31-32", got) + } +} + +func TestOPL2FreqToFnumBlock(t *testing.T) { + opl := OPL2[period.Amiga, s3mVolume.FineVolume, s3mVolume.Volume]{} + + fnum, block := opl.freqToFnumBlock(440.0) + if block != 4 { + t.Fatalf("freqToFnumBlock block = %d, want 4", block) + } + if fnum != 580 { + t.Fatalf("freqToFnumBlock fnum = %d, want 580", fnum) + } + + if fnum, block := opl.freqToFnumBlock(7000.0); fnum != 0 || block != 0 { + t.Fatalf("freqToFnumBlock high freq = (%d,%d), want (0,0)", fnum, block) + } +} diff --git a/voice/component/sampler.go b/voice/component/sampler.go index 34afb48..8cb9894 100644 --- a/voice/component/sampler.go +++ b/voice/component/sampler.go @@ -3,10 +3,9 @@ package component import ( "fmt" + "github.com/gotracker/playback/index" "github.com/gotracker/playback/mixing/sampling" "github.com/gotracker/playback/mixing/volume" - - "github.com/gotracker/playback/index" "github.com/gotracker/playback/tracing" "github.com/gotracker/playback/voice/loop" "github.com/gotracker/playback/voice/pcm" @@ -130,16 +129,19 @@ func (s *Sampler[TPeriod, TMixingVolume, TVolume]) getConvertedSample(pos int) v return volume.Matrix{} } sl := s.settings.Sample.Length() + looped := false + if s.canLoop() { + // Always run through the loop calculator when looping is enabled so we never + // read past the loop end and drag in post-loop garbage. + pos, looped = loop.CalcLoopPos(s.settings.WholeLoop, s.settings.SustainLoop, pos, sl, s.keyOn) + } + fadeout := false fadeoutLen := 0 - if pos >= sl { - if s.canLoop() { - pos, _ = loop.CalcLoopPos(s.settings.WholeLoop, s.settings.SustainLoop, pos, sl, s.keyOn) - } else { - fadeoutLen = pos - sl - pos = sl - 1 - fadeout = true - } + if pos >= sl && !looped { + fadeoutLen = pos - sl + pos = sl - 1 + fadeout = true } if pos < 0 || pos >= sl { return volume.Matrix{} diff --git a/voice/component/sampler_test.go b/voice/component/sampler_test.go new file mode 100644 index 0000000..64733b6 --- /dev/null +++ b/voice/component/sampler_test.go @@ -0,0 +1,65 @@ +package component + +import ( + "math" + "testing" + + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/voice/pcm" + "github.com/gotracker/playback/voice/types" +) + +type testVolume float32 + +func (testVolume) IsInvalid() bool { return false } +func (testVolume) IsUseInstrumentVol() bool { return false } +func (v testVolume) ToVolume() volume.Volume { return volume.Volume(v) } +func (testVolume) AddDelta(types.VolumeDelta) testVolume { return testVolume(0) } +func (testVolume) GetMax() testVolume { return testVolume(1) } + +func almostEqualVol(a, b volume.Volume, tol float64) bool { + return math.Abs(float64(a-b)) <= tol +} + +func TestSamplerLerpsBetweenSamples(t *testing.T) { + data := []volume.Matrix{ + {StaticMatrix: volume.StaticMatrix{1}, Channels: 1}, + {StaticMatrix: volume.StaticMatrix{0}, Channels: 1}, + } + samp := pcm.NewSampleNative(data, len(data), 1) + + var s Sampler[types.Period, testVolume, testVolume] + s.Setup(SamplerSettings[types.Period, testVolume, testVolume]{ + Sample: samp, + DefaultVolume: testVolume(1), + MixVolume: testVolume(1), + }) + + got := s.GetSample(sampling.Pos{Pos: 0, Frac: 0.5}) + want := volume.Matrix{StaticMatrix: volume.StaticMatrix{0.5}, Channels: 1} + if got.Channels != want.Channels || !almostEqualVol(got.StaticMatrix[0], want.StaticMatrix[0], 1e-6) { + t.Fatalf("unexpected lerp result: got %+v want %+v", got, want) + } +} + +func TestSamplerFadeoutPastEndWithoutLoop(t *testing.T) { + data := []volume.Matrix{ + {StaticMatrix: volume.StaticMatrix{0.5}, Channels: 1}, + {StaticMatrix: volume.StaticMatrix{0.25}, Channels: 1}, + } + samp := pcm.NewSampleNative(data, len(data), 1) + + var s Sampler[types.Period, testVolume, testVolume] + s.Setup(SamplerSettings[types.Period, testVolume, testVolume]{ + Sample: samp, + DefaultVolume: testVolume(1), + MixVolume: testVolume(1), + }) + + got := s.GetSample(sampling.Pos{Pos: 3}) + want := volume.Matrix{StaticMatrix: volume.StaticMatrix{0.125}, Channels: 1} + if got.Channels != want.Channels || !almostEqualVol(got.StaticMatrix[0], want.StaticMatrix[0], 1e-6) { + t.Fatalf("unexpected fadeout result: got %+v want %+v", got, want) + } +} diff --git a/voice/envelope/envelope_test.go b/voice/envelope/envelope_test.go new file mode 100644 index 0000000..a6911d5 --- /dev/null +++ b/voice/envelope/envelope_test.go @@ -0,0 +1,47 @@ +package envelope + +import "testing" + +type dummyLoop struct{ on bool } + +func (d dummyLoop) Enabled() bool { return d.on } +func (d dummyLoop) Length() int { return 3 } +func (d dummyLoop) CalcPos(pos int, length int) (int, bool) { return pos + 1, true } + +func TestEnvelopeDefaults(t *testing.T) { + var e Envelope[int] + if e.Enabled { + t.Fatalf("expected default Enabled=false") + } + if e.Loop != nil || e.Sustain != nil { + t.Fatalf("expected nil loops by default") + } + if e.Length != 0 || len(e.Values) != 0 { + t.Fatalf("expected zero length/values") + } +} + +func TestEnvelopeStoresValues(t *testing.T) { + e := Envelope[int]{ + Enabled: true, + Loop: dummyLoop{on: true}, + Sustain: dummyLoop{on: true}, + Length: 2, + Values: []Point[int]{ + {Pos: 0, Length: 1, Y: 10}, + {Pos: 1, Length: 1, Y: 20}, + }, + } + if !e.Enabled { + t.Fatalf("expected Enabled=true") + } + if !e.Loop.Enabled() || !e.Sustain.Enabled() { + t.Fatalf("expected loops enabled") + } + if e.Length != 2 || len(e.Values) != 2 { + t.Fatalf("unexpected length/values") + } + if e.Values[0].Y != 10 || e.Values[1].Y != 20 { + t.Fatalf("unexpected point values: %+v", e.Values) + } +} diff --git a/voice/fadeout/fadeout_test.go b/voice/fadeout/fadeout_test.go new file mode 100644 index 0000000..7ad6add --- /dev/null +++ b/voice/fadeout/fadeout_test.go @@ -0,0 +1,42 @@ +package fadeout + +import ( + "testing" + + "github.com/gotracker/playback/mixing/volume" +) + +func TestModeIsFadeoutActive(t *testing.T) { + cases := []struct { + mode Mode + force bool + volEnvEnabled bool + volEnvDone bool + expect bool + }{ + {ModeDisabled, false, false, false, false}, + {ModeAlwaysActive, false, false, false, true}, + {ModeAlwaysActive, true, false, false, true}, + {ModeAlwaysActive, false, true, false, false}, + {ModeAlwaysActive, false, true, true, true}, + {ModeOnlyIfVolEnvActive, false, false, false, false}, + {ModeOnlyIfVolEnvActive, true, false, false, true}, + {ModeOnlyIfVolEnvActive, false, true, false, true}, + } + + for _, tt := range cases { + if got := tt.mode.IsFadeoutActive(tt.force, tt.volEnvEnabled, tt.volEnvDone); got != tt.expect { + t.Fatalf("mode %v force=%v env=%v done=%v => %v, want %v", tt.mode, tt.force, tt.volEnvEnabled, tt.volEnvDone, got, tt.expect) + } + } +} + +func TestSettingsHoldsValues(t *testing.T) { + s := Settings{Mode: ModeAlwaysActive, Amount: volume.Volume(0.5)} + if s.Mode != ModeAlwaysActive { + t.Fatalf("expected ModeAlwaysActive") + } + if s.Amount != 0.5 { + t.Fatalf("expected Amount=0.5, got %v", s.Amount) + } +} diff --git a/voice/filter/filterapplier_test.go b/voice/filter/filterapplier_test.go new file mode 100644 index 0000000..cb726b8 --- /dev/null +++ b/voice/filter/filterapplier_test.go @@ -0,0 +1,38 @@ +package filter + +import ( + "testing" + + "github.com/gotracker/playback/mixing/volume" +) + +type fakeApplier struct { + applied volume.Matrix + setEnv uint8 +} + +func (f *fakeApplier) ApplyFilter(dry volume.Matrix) volume.Matrix { + f.applied = dry + return dry.Apply(volume.Volume(0.5)) +} + +func (f *fakeApplier) SetFilterEnvelopeValue(envVal uint8) { + f.setEnv = envVal +} + +func TestApplierInterface(t *testing.T) { + var _ Applier = (*fakeApplier)(nil) + fa := &fakeApplier{} + dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{1, 1}, Channels: 2} + wet := fa.ApplyFilter(dry) + if fa.applied.Channels != 2 || fa.applied.StaticMatrix[0] != 1 { + t.Fatalf("expected applied matrix recorded") + } + if wet.StaticMatrix[0] != 0.5 || wet.StaticMatrix[1] != 0.5 { + t.Fatalf("expected halved volumes, got %#v", wet.StaticMatrix) + } + fa.SetFilterEnvelopeValue(7) + if fa.setEnv != 7 { + t.Fatalf("expected env set to 7, got %d", fa.setEnv) + } +} diff --git a/voice/loop/disabled.go b/voice/loop/disabled.go index c8369ee..e831c0a 100644 --- a/voice/loop/disabled.go +++ b/voice/loop/disabled.go @@ -1,7 +1,8 @@ package loop // Disabled is a disabled loop -// |start>----------------------------------------end| <= on playthrough 1, whole sample plays +// +// |start>----------------------------------------end| <= on playthrough 1, whole sample plays type Disabled struct{} // Enabled returns true if the loop is enabled diff --git a/voice/loop/legacy.go b/voice/loop/legacy.go index 3aeaa95..824853d 100644 --- a/voice/loop/legacy.go +++ b/voice/loop/legacy.go @@ -1,9 +1,10 @@ package loop // Legacy is a legacy loop based on how some old protracker players worked -// |start>----------------------------------------end| <= on playthrough 1, whole sample plays -// |-------------|loopBegin>-----loopEnd|------------| <= only if looped and on playthrough 2+, only the part that loops plays -// |-------------|loopBegin>----------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn +// +// |start>----------------------------------------end| <= on playthrough 1, whole sample plays +// |-------------|loopBegin>-----loopEnd|------------| <= only if looped and on playthrough 2+, only the part that loops plays +// |-------------|loopBegin>----------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn type Legacy struct { Settings } diff --git a/voice/loop/loop.go b/voice/loop/loop.go index fffa190..9d9f797 100644 --- a/voice/loop/loop.go +++ b/voice/loop/loop.go @@ -15,6 +15,13 @@ type Settings struct { // CalcLoopPos returns the new location and looped flag within a pair of loops (normal and sustain) func CalcLoopPos(loop Loop, sustain Loop, pos int, length int, keyOn bool) (int, bool) { + if loop == nil { + loop = &Disabled{} + } + if sustain == nil { + sustain = &Disabled{} + } + if keyOn && sustain.Enabled() { // sustain loop return sustain.CalcPos(pos, length) diff --git a/voice/loop/loop_test.go b/voice/loop/loop_test.go new file mode 100644 index 0000000..b316c04 --- /dev/null +++ b/voice/loop/loop_test.go @@ -0,0 +1,100 @@ +package loop + +import ( + "fmt" + "testing" +) + +type stubLoop struct { + enabled bool + pos int + looped bool +} + +func (s stubLoop) Enabled() bool { return s.enabled } +func (s stubLoop) Length() int { return 0 } +func (s stubLoop) CalcPos(pos int, length int) (int, bool) { return s.pos, s.looped } + +func TestNewLoopReturnsExpectedType(t *testing.T) { + cases := []struct { + mode Mode + expect string + }{ + {ModeDisabled, "*loop.Disabled"}, + {ModeLegacy, "*loop.Legacy"}, + {ModeNormal, "*loop.Normal"}, + {ModePingPong, "*loop.PingPong"}, + } + + for _, tt := range cases { + l := NewLoop(tt.mode, Settings{}) + if got := fmt.Sprintf("%T", l); got != tt.expect { + t.Fatalf("mode %v returned %s, want %s", tt.mode, got, tt.expect) + } + } +} + +func TestDisabledLoopClamps(t *testing.T) { + l := &Disabled{} + if pos, looped := l.CalcPos(-1, 5); pos != 0 || looped { + t.Fatalf("neg pos -> %d looped=%v, want 0 false", pos, looped) + } + if pos, looped := l.CalcPos(10, 5); pos != 5 || looped { + t.Fatalf("past end -> %d looped=%v, want 5 false", pos, looped) + } +} + +func TestNormalLoopWraps(t *testing.T) { + l := &Normal{Settings: Settings{Begin: 2, End: 5}} + if pos, looped := l.CalcPos(3, 8); pos != 3 || looped { + t.Fatalf("inside -> %d looped=%v", pos, looped) + } + if pos, looped := l.CalcPos(5, 8); pos != 2 || !looped { + t.Fatalf("at end -> %d looped=%v, want 2 true", pos, looped) + } + if pos, looped := l.CalcPos(7, 8); pos != 4 || !looped { + t.Fatalf("after end -> %d looped=%v, want 4 true", pos, looped) + } +} + +func TestPingPongLoopBounces(t *testing.T) { + l := &PingPong{Settings: Settings{Begin: 2, End: 5}} + if pos, looped := l.CalcPos(5, 8); pos != 4 || !looped { + t.Fatalf("bounce first -> %d looped=%v, want 4 true", pos, looped) + } + if pos, looped := l.CalcPos(6, 8); pos != 3 || !looped { + t.Fatalf("bounce second -> %d looped=%v, want 3 true", pos, looped) + } + if pos, looped := l.CalcPos(8, 8); pos != 2 || !looped { + t.Fatalf("reverse direction -> %d looped=%v, want 2 true", pos, looped) + } +} + +func TestLegacyLoopAfterEnd(t *testing.T) { + l := &Legacy{Settings: Settings{Begin: 2, End: 5}} + if pos, looped := l.CalcPos(9, 8); pos != 3 || !looped { + t.Fatalf("after end -> %d looped=%v, want 3 true", pos, looped) + } +} + +func TestCalcLoopPosPrefersSustainWhenKeyOn(t *testing.T) { + sustain := stubLoop{enabled: true, pos: 9, looped: true} + main := stubLoop{enabled: true, pos: 1, looped: false} + if pos, looped := CalcLoopPos(main, sustain, 0, 10, true); pos != 9 || !looped { + t.Fatalf("sustain expected 9/true, got %d/%v", pos, looped) + } + if pos, looped := CalcLoopPos(main, sustain, 0, 10, false); pos != 1 || looped { + t.Fatalf("non-sustain expected 1/false, got %d/%v", pos, looped) + } +} + +func TestCalcLoopPosHandlesNilLoops(t *testing.T) { + pos, looped := CalcLoopPos(nil, nil, -2, 5, false) + if pos != 0 || looped { + t.Fatalf("nil loops should clamp and not loop, got %d/%v", pos, looped) + } + pos, looped = CalcLoopPos(nil, nil, 9, 5, false) + if pos != 5 || looped { + t.Fatalf("nil loops should clamp at length, got %d/%v", pos, looped) + } +} diff --git a/voice/loop/normal.go b/voice/loop/normal.go index 8ecb5e2..7c2dd81 100644 --- a/voice/loop/normal.go +++ b/voice/loop/normal.go @@ -1,9 +1,10 @@ package loop // Normal is a normal loop -// |start>-----------------------loopEnd|------------| <= on playthrough 1, whole sample plays -// |-------------|loopBegin>-----loopEnd|------------| <= only if looped and on playthrough 2+, only the part that loops plays -// |-------------|loopBegin>----------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn +// +// |start>-----------------------loopEnd|------------| <= on playthrough 1, whole sample plays +// |-------------|loopBegin>-----loopEnd|------------| <= only if looped and on playthrough 2+, only the part that loops plays +// |-------------|loopBegin>----------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn type Normal struct { Settings } @@ -29,10 +30,7 @@ func (l *Normal) CalcPos(pos int, length int) (int, bool) { loopLen := l.Length() if loopLen < 0 { - if pos < length { - return pos, false - } - return length, false + return max(pos, length), false } else if loopLen == 0 { return l.Begin, true } diff --git a/voice/loop/pingpong.go b/voice/loop/pingpong.go index b3ad343..f879b44 100644 --- a/voice/loop/pingpong.go +++ b/voice/loop/pingpong.go @@ -1,9 +1,10 @@ package loop // PingPong is a loop that bounces forward and backward between loopBegin and loopEnd -// |start>-----------------------loopEnd|------------| <= on playthrough 1, whole sample plays -// |-------------|loopBegin>--------------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn +// +// |start>-----------------------loopEnd|------------| <= on playthrough 1, whole sample plays +// |-------------|loopBegin>--------------------------end| <= on playthrough 2+, the loop ends and playback continues to end, if !keyOn type PingPong struct { Settings } @@ -29,10 +30,7 @@ func (l *PingPong) CalcPos(pos int, length int) (int, bool) { loopLen := l.Length() if loopLen < 0 { - if pos < length { - return pos, false - } - return length, false + return max(pos, length), false } else if loopLen == 0 { return l.Begin, true } diff --git a/voice/mixer/output_test.go b/voice/mixer/output_test.go new file mode 100644 index 0000000..fbcb31e --- /dev/null +++ b/voice/mixer/output_test.go @@ -0,0 +1,60 @@ +package mixer + +import ( + "testing" + + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" +) + +type stubStream struct { + gotPos sampling.Pos + sample volume.Matrix +} + +func (s *stubStream) GetSample(pos sampling.Pos) volume.Matrix { + s.gotPos = pos + return s.sample +} + +type stubFilter struct { + gotDry volume.Matrix + out volume.Matrix + called int +} + +func (f *stubFilter) ApplyFilter(dry volume.Matrix) volume.Matrix { + f.called++ + f.gotDry = dry + return f.out +} + +func TestOutputGetSampleAppliesFilter(t *testing.T) { + dry := volume.Matrix{StaticMatrix: volume.StaticMatrix{volume.Volume(0.25), volume.Volume(-0.5)}, Channels: 2} + filtered := volume.Matrix{StaticMatrix: volume.StaticMatrix{volume.Volume(0.5), volume.Volume(-1)}, Channels: 2} + + stream := &stubStream{sample: dry} + filter := &stubFilter{out: filtered} + + o := Output{Input: stream, Output: filter} + + pos := sampling.Pos{Pos: 3, Frac: 0.75} + + got := o.GetSample(pos) + + if stream.gotPos != pos { + t.Fatalf("expected sample request at %+v, got %+v", pos, stream.gotPos) + } + + if filter.called != 1 { + t.Fatalf("expected filter to be called once, got %d", filter.called) + } + + if filter.gotDry != dry { + t.Fatalf("expected filter to receive dry sample %v, got %v", dry, filter.gotDry) + } + + if got != filtered { + t.Fatalf("expected filtered sample %v, got %v", filtered, got) + } +} diff --git a/voice/oscillator/oscillator_test.go b/voice/oscillator/oscillator_test.go new file mode 100644 index 0000000..4da0760 --- /dev/null +++ b/voice/oscillator/oscillator_test.go @@ -0,0 +1,35 @@ +package oscillator + +import "testing" + +type fakeOsc struct { + wave WaveTableSelect + adv int + reset int +} + +func (f *fakeOsc) Clone() Oscillator { cp := *f; return &cp } +func (f *fakeOsc) GetWave(depth float32) float32 { return float32(f.wave) + depth } +func (f *fakeOsc) Advance(speed int) { f.adv += speed } +func (f *fakeOsc) SetWaveform(table WaveTableSelect) { f.wave = table } +func (f *fakeOsc) GetWaveform() WaveTableSelect { return f.wave } +func (f *fakeOsc) HardReset() { f.reset++ } +func (f *fakeOsc) Reset() { f.reset++ } + +func TestOscillatorInterfaceMethods(t *testing.T) { + f := &fakeOsc{wave: 2} + clone := f.Clone().(*fakeOsc) + clone.SetWaveform(3) + clone.Advance(4) + clone.Reset() + + if f.wave != 2 { + t.Fatalf("expected original waveform unchanged, got %d", f.wave) + } + if clone.wave != 3 || clone.adv != 4 || clone.reset != 1 { + t.Fatalf("unexpected clone state: %+v", clone) + } + if clone.GetWave(1) != 4 { + t.Fatalf("GetWave unexpected result") + } +} diff --git a/voice/pcm/format_test.go b/voice/pcm/format_test.go new file mode 100644 index 0000000..870c01c --- /dev/null +++ b/voice/pcm/format_test.go @@ -0,0 +1,24 @@ +package pcm + +import "testing" + +func TestGetSampleBytes(t *testing.T) { + cases := []struct { + fmt SampleDataFormat + exp int + }{ + {SampleDataFormat8BitUnsigned, 1}, + {SampleDataFormat8BitSigned, 1}, + {SampleDataFormat16BitLEUnsigned, 2}, + {SampleDataFormat16BitBESigned, 2}, + {SampleDataFormat32BitLEFloat, 4}, + {SampleDataFormat64BitBEFloat, 8}, + {SampleDataFormatNative, 1}, + } + + for _, tt := range cases { + if got := getSampleBytes(tt.fmt); got != tt.exp { + t.Fatalf("fmt %v => %d, want %d", tt.fmt, got, tt.exp) + } + } +} diff --git a/voice/pitchpan/pitchpan_test.go b/voice/pitchpan/pitchpan_test.go new file mode 100644 index 0000000..7579960 --- /dev/null +++ b/voice/pitchpan/pitchpan_test.go @@ -0,0 +1,29 @@ +package pitchpan + +import "testing" + +func TestPitchPanDefaults(t *testing.T) { + var p PitchPan + if p.Enabled { + t.Fatalf("expected default Enabled=false") + } + if p.Center != 0 { + t.Fatalf("expected default Center=0, got %d", p.Center) + } + if p.Separation != 0 { + t.Fatalf("expected default Separation=0, got %v", p.Separation) + } +} + +func TestPitchPanValues(t *testing.T) { + p := PitchPan{Enabled: true, Center: 5, Separation: 0.25} + if !p.Enabled { + t.Fatalf("expected Enabled=true") + } + if p.Center != 5 { + t.Fatalf("expected Center=5, got %d", p.Center) + } + if p.Separation != 0.25 { + t.Fatalf("expected Separation=0.25, got %v", p.Separation) + } +} diff --git a/voice/render_test.go b/voice/render_test.go new file mode 100644 index 0000000..814100d --- /dev/null +++ b/voice/render_test.go @@ -0,0 +1,280 @@ +package voice + +import ( + "testing" + + "github.com/gotracker/playback/frequency" + "github.com/gotracker/playback/index" + "github.com/gotracker/playback/mixing" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" + "github.com/gotracker/playback/note" + "github.com/gotracker/playback/period" + "github.com/gotracker/playback/system" + "github.com/gotracker/playback/tracing" + "github.com/gotracker/playback/voice/mixer" +) + +type stubPeriod float32 + +func (stubPeriod) IsInvalid() bool { return false } + +type stubSystem struct{} + +func (stubSystem) GetMaxPastNotesPerChannel() int { return 0 } +func (stubSystem) GetCommonRate() frequency.Frequency { return 0 } + +type stubPeriodConverter struct { + samplerAdd float64 +} + +func (s stubPeriodConverter) GetSystem() system.System { return stubSystem{} } +func (s stubPeriodConverter) GetPeriod(note.Note) stubPeriod { return 0 } +func (s stubPeriodConverter) PortaToNote(p stubPeriod, d period.Delta, target stubPeriod) (stubPeriod, error) { + return p, nil +} +func (s stubPeriodConverter) PortaDown(p stubPeriod, d period.Delta) (stubPeriod, error) { + return p, nil +} +func (s stubPeriodConverter) PortaUp(p stubPeriod, d period.Delta) (stubPeriod, error) { return p, nil } +func (s stubPeriodConverter) AddDelta(p stubPeriod, d period.Delta) (stubPeriod, error) { + return p, nil +} +func (s stubPeriodConverter) GetSamplerAdd(p stubPeriod, _ frequency.Frequency, _ frequency.Frequency) float64 { + return s.samplerAdd +} +func (s stubPeriodConverter) GetFrequency(p stubPeriod) frequency.Frequency { + return frequency.Frequency(p) +} + +type stubPanMatrix struct { + factorL float32 + factorR float32 + calls int +} + +func (p *stubPanMatrix) ApplyToMatrix(mtx volume.Matrix) volume.Matrix { + p.calls++ + out := mtx + if out.Channels >= 1 { + out.StaticMatrix[0] *= volume.Volume(p.factorL) + } + if out.Channels >= 2 { + out.StaticMatrix[1] *= volume.Volume(p.factorR) + } + return out +} + +func (p *stubPanMatrix) Apply(v volume.Volume) volume.Matrix { + p.calls++ + return volume.Matrix{StaticMatrix: volume.StaticMatrix{v * volume.Volume(p.factorL), v * volume.Volume(p.factorR)}, Channels: 2} +} + +type stubDetailPanMixer struct { + matrix panning.PanMixer + pan panning.Position + sep float32 + calls int +} + +func (p *stubDetailPanMixer) GetMixingMatrix(pan panning.Position, sep float32) panning.PanMixer { + p.calls++ + p.pan = pan + p.sep = sep + return p.matrix +} + +func (p *stubDetailPanMixer) NumChannels() int { return 2 } + +type stubFilter struct { + calls int +} + +func (f *stubFilter) ApplyFilter(dry volume.Matrix) volume.Matrix { + f.calls++ + out := dry + for i := 0; i < out.Channels; i++ { + out.StaticMatrix[i] *= 0.5 + } + return out +} + +type stubRenderSampler struct { + done bool + active bool + muted bool + pos sampling.Pos + setPosValue sampling.Pos + sampleRate frequency.Frequency + playbackRate frequency.Frequency + pan panning.Position + period stubPeriod + tickCount int + sampleCalls []sampling.Pos +} + +func (s *stubRenderSampler) Clone(_ bool) Voice { return s } +func (s *stubRenderSampler) DumpState(index.Channel, tracing.Tracer) {} +func (s *stubRenderSampler) Reset() error { return nil } +func (s *stubRenderSampler) SetPlaybackRate(rate frequency.Frequency) error { + s.playbackRate = rate + return nil +} +func (s *stubRenderSampler) Attack() {} +func (s *stubRenderSampler) Release() {} +func (s *stubRenderSampler) Fadeout() {} +func (s *stubRenderSampler) Stop() {} +func (s *stubRenderSampler) Tick() error { s.tickCount++; return nil } +func (s *stubRenderSampler) RowEnd() error { return nil } +func (s *stubRenderSampler) IsDone() bool { return s.done } +func (s *stubRenderSampler) SetMuted(muted bool) error { s.muted = muted; return nil } +func (s *stubRenderSampler) IsMuted() bool { return s.muted } +func (s *stubRenderSampler) GetSampleRate() frequency.Frequency { return s.sampleRate } +func (s *stubRenderSampler) IsActive() bool { return s.active } +func (s *stubRenderSampler) SetPos(pos sampling.Pos) error { + s.setPosValue = pos + s.pos = pos + return nil +} +func (s *stubRenderSampler) GetPos() (sampling.Pos, error) { return s.pos, nil } +func (s *stubRenderSampler) GetFinalPeriod() (stubPeriod, error) { return s.period, nil } +func (s *stubRenderSampler) GetFinalVolume() volume.Volume { return 1 } +func (s *stubRenderSampler) GetFinalPan() panning.Position { return s.pan } +func (s *stubRenderSampler) GetSample(pos sampling.Pos) volume.Matrix { + s.sampleCalls = append(s.sampleCalls, pos) + v := float32(pos.Pos + 1) + return volume.Matrix{StaticMatrix: volume.StaticMatrix{volume.Volume(v), volume.Volume(-v)}, Channels: 2} +} + +type bareVoice struct{ ticked int } + +func (b *bareVoice) Clone(_ bool) Voice { return b } +func (b *bareVoice) DumpState(index.Channel, tracing.Tracer) {} +func (b *bareVoice) Reset() error { return nil } +func (b *bareVoice) SetPlaybackRate(frequency.Frequency) error { return nil } +func (b *bareVoice) Attack() {} +func (b *bareVoice) Release() {} +func (b *bareVoice) Fadeout() {} +func (b *bareVoice) Stop() {} +func (b *bareVoice) Tick() error { b.ticked++; return nil } +func (b *bareVoice) RowEnd() error { return nil } +func (b *bareVoice) IsDone() bool { return false } +func (b *bareVoice) SetMuted(bool) error { return nil } +func (b *bareVoice) IsMuted() bool { return false } +func (b *bareVoice) GetSampleRate() frequency.Frequency { return 0 } + +func TestRenderAndTickReturnsNilWhenDone(t *testing.T) { + v := &stubRenderSampler{done: true} + mix := mixing.Mixer{Channels: 2} + details := mixer.Details{Mix: &mix} + + data, err := RenderAndTick[stubPeriod](v, stubPeriodConverter{samplerAdd: 1}, nil, details, &stubFilter{}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data != nil { + t.Fatalf("expected nil data when voice done") + } + if v.tickCount != 0 { + t.Fatalf("tick should not run when IsDone") + } +} + +func TestRenderAndTickTicksNonRenderSampler(t *testing.T) { + b := &bareVoice{} + + data, err := RenderAndTick[stubPeriod](b, stubPeriodConverter{samplerAdd: 1}, nil, mixer.Details{}, &stubFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data != nil { + t.Fatalf("expected nil data for non-render sampler") + } + if b.ticked != 1 { + t.Fatalf("tick should be called once, got %d", b.ticked) + } +} + +func TestRenderAndTickRendersAndUpdatesPosition(t *testing.T) { + centerPan := &stubPanMatrix{factorL: 2, factorR: 3} + panMixer := &stubDetailPanMixer{matrix: &stubPanMatrix{factorL: 1, factorR: 1}} + filter := &stubFilter{} + + v := &stubRenderSampler{ + active: true, + pos: sampling.Pos{Pos: 0, Frac: 0}, + sampleRate: 10, + period: stubPeriod(4), + pan: panning.Position{Angle: 0.1, Distance: 0.5}, + } + + mix := mixing.Mixer{Channels: 2} + details := mixer.Details{ + Mix: &mix, + Panmixer: panMixer, + SampleRate: 20, + StereoSeparation: 0.75, + Samples: 2, + } + + data, err := RenderAndTick[stubPeriod](v, stubPeriodConverter{samplerAdd: 1}, centerPan, details, filter) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data == nil { + t.Fatalf("expected data output") + } + + if v.tickCount != 1 { + t.Fatalf("tick should run once, got %d", v.tickCount) + } + if v.playbackRate != details.SampleRate { + t.Fatalf("playback rate mismatch: got %v want %v", v.playbackRate, details.SampleRate) + } + if v.setPosValue != (sampling.Pos{Pos: 2, Frac: 0}) { + t.Fatalf("expected position to update to Pos 2, got %+v", v.setPosValue) + } + + if len(v.sampleCalls) != details.Samples { + t.Fatalf("expected %d samples read, got %d", details.Samples, len(v.sampleCalls)) + } + if v.sampleCalls[0].Pos != 0 || v.sampleCalls[1].Pos != 1 { + t.Fatalf("unexpected sample positions: %+v", v.sampleCalls) + } + + if filter.calls != details.Samples { + t.Fatalf("filter should be applied per sample, got %d", filter.calls) + } + if centerPan.calls != details.Samples { + t.Fatalf("pan matrix should apply per sample, got %d", centerPan.calls) + } + if panMixer.calls != 1 || panMixer.pan != v.pan || panMixer.sep != details.StereoSeparation { + t.Fatalf("pan mixer should be invoked once with final pan: calls=%d pan=%+v sep=%v", panMixer.calls, panMixer.pan, panMixer.sep) + } + + if data.PanMatrix != panMixer.matrix { + t.Fatalf("pan matrix not propagated to output") + } + if data.SamplesLen != details.Samples { + t.Fatalf("sample length mismatch: got %d want %d", data.SamplesLen, details.Samples) + } + if data.Volume != 1 { + t.Fatalf("volume should default to 1, got %v", data.Volume) + } + + if len(data.Data) != details.Samples { + t.Fatalf("mixbuffer length mismatch: got %d", len(data.Data)) + } + first := data.Data[0] + second := data.Data[1] + + // first sample: dry {1,-1}, filter halves to {0.5,-0.5}, pan scales -> {1,-1.5} + if first.StaticMatrix[0] != volume.Volume(1) || first.StaticMatrix[1] != volume.Volume(-1.5) { + t.Fatalf("unexpected first sample mix: %+v", first.StaticMatrix) + } + // second sample: dry {2,-2}, filter halves to {1,-1}, pan scales -> {2,-3} + if second.StaticMatrix[0] != volume.Volume(2) || second.StaticMatrix[1] != volume.Volume(-3) { + t.Fatalf("unexpected second sample mix: %+v", second.StaticMatrix) + } +} diff --git a/voice/types/types_test.go b/voice/types/types_test.go new file mode 100644 index 0000000..967cb3e --- /dev/null +++ b/voice/types/types_test.go @@ -0,0 +1,63 @@ +package types + +import ( + "testing" + + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/volume" +) + +type testVol volume.Volume + +func (testVol) IsInvalid() bool { return false } +func (testVol) IsUseInstrumentVol() bool { return false } +func (v testVol) ToVolume() volume.Volume { return volume.Volume(v) } +func (testVol) AddDelta(d VolumeDelta) testVol { return testVol(volume.Volume(1) + volume.Volume(d)) } + +type maxVol volume.Volume + +func (maxVol) IsInvalid() bool { return false } +func (maxVol) IsUseInstrumentVol() bool { return false } +func (v maxVol) ToVolume() volume.Volume { return volume.Volume(v) } +func (maxVol) GetMax() maxVol { return maxVol(2) } + +type testPan float32 + +func (testPan) IsInvalid() bool { return false } +func (testPan) ToPosition() panning.Position { return panning.CenterAhead } +func (testPan) AddDelta(d PanDelta) testPan { return testPan(float32(0.5) + float32(d)) } + +type panInfo float32 + +func (panInfo) IsInvalid() bool { return false } +func (panInfo) ToPosition() panning.Position { return panning.CenterAhead } +func (panInfo) GetDefault() panInfo { return panInfo(0.25) } +func (panInfo) GetMax() panInfo { return panInfo(1) } +func (panInfo) AddDelta(d PanDelta) panInfo { return panInfo(float32(0.25) + float32(d)) } + +func TestAddVolumeDelta(t *testing.T) { + v := testVol(1) + res := AddVolumeDelta(v, VolumeDelta(2)) + if res != 3 { + t.Fatalf("expected 3, got %v", res) + } +} + +func TestGetMaxVolume(t *testing.T) { + if got := GetMaxVolume[maxVol](); got != 2 { + t.Fatalf("expected max volume 2, got %v", got) + } +} + +func TestPanningHelpers(t *testing.T) { + p := testPan(0) + if res := AddPanningDelta(p, PanDelta(0.1)); res != 0.6 { + t.Fatalf("expected pan 0.6, got %v", res) + } + if def := GetPanDefault[panInfo](); def != 0.25 { + t.Fatalf("expected default 0.25, got %v", def) + } + if mx := GetPanMax[panInfo](); mx != 1 { + t.Fatalf("expected max 1, got %v", mx) + } +} diff --git a/voice/voice.go b/voice/voice.go index faf389d..b5cb429 100755 --- a/voice/voice.go +++ b/voice/voice.go @@ -2,13 +2,13 @@ package voice import ( "github.com/gotracker/opl2" - "github.com/gotracker/playback/mixing/panning" - "github.com/gotracker/playback/mixing/sampling" - "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/frequency" "github.com/gotracker/playback/index" "github.com/gotracker/playback/instrument" + "github.com/gotracker/playback/mixing/panning" + "github.com/gotracker/playback/mixing/sampling" + "github.com/gotracker/playback/mixing/volume" "github.com/gotracker/playback/note" "github.com/gotracker/playback/period" "github.com/gotracker/playback/tracing" diff --git a/voice/vol0optimization/settings_test.go b/voice/vol0optimization/settings_test.go new file mode 100644 index 0000000..487f144 --- /dev/null +++ b/voice/vol0optimization/settings_test.go @@ -0,0 +1,23 @@ +package vol0optimization + +import "testing" + +func TestVol0OptimizationSettingsDefaults(t *testing.T) { + var s Vol0OptimizationSettings + if s.Enabled { + t.Fatalf("expected default Enabled=false") + } + if s.MaxRowsAt0 != 0 { + t.Fatalf("expected default MaxRowsAt0=0, got %d", s.MaxRowsAt0) + } +} + +func TestVol0OptimizationSettingsValues(t *testing.T) { + s := Vol0OptimizationSettings{Enabled: true, MaxRowsAt0: 4} + if !s.Enabled { + t.Fatalf("expected Enabled=true") + } + if s.MaxRowsAt0 != 4 { + t.Fatalf("expected MaxRowsAt0=4, got %d", s.MaxRowsAt0) + } +}