diff --git a/README.md b/README.md index 8bca07a..e3d5891 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,15 @@ Read the API documentation at [godoc.org/github.com/go-mix/mix](https://godoc.or Mix stores and mixes audio in native Go `[]float64` and natively implements Paul Vögler's "Loudness Normalization by Logarithmic Dynamic Range Compression" (details below) +### Features + +- **Precise Timing**: Sample-accurate playback timing for sequence-based music applications +- **Pitch Shifting**: Change the pitch of audio sources without manual resampling (see [docs/PITCH_SHIFTING.md](docs/PITCH_SHIFTING.md)) +- **Volume Control**: Per-source volume adjustment (0 to 1) +- **Panning**: Stereo panning control (-1 to +1) +- **Dynamic Range Compression**: Built-in logarithmic dynamic range compression +- **Multiple Audio Formats**: Support for WAV and other formats via sox + Best efforts will be made to preserve each API version in a release tag that can be parsed, e.g. **[github.com/go-mix/mix](http://github.com/go-mix/mix)** ### Why? diff --git a/docs/PITCH_SHIFTING.md b/docs/PITCH_SHIFTING.md new file mode 100644 index 0000000..aac426b --- /dev/null +++ b/docs/PITCH_SHIFTING.md @@ -0,0 +1,123 @@ +# Audio Pitch Shifting + +Mix now supports pitch shifting audio without manual resampling, allowing you to change the pitch of audio sources dynamically. + +## Usage + +Use `SetFireWithPitch()` instead of `SetFire()` to enable pitch shifting: + +```go +import ( + "time" + "github.com/go-mix/mix" +) + +// Normal playback (with ADSR envelope disabled) +mix.SetFire("sound.wav", time.Duration(0), 0, 1.0, 0, 0, 0, 1.0, 0) + +// Pitch shifted up one octave (2.0x pitch), ADSR envelope disabled +mix.SetFireWithPitch("sound.wav", time.Duration(0), 0, 1.0, 0, 2.0, 1.0) + +// Pitch shifted down one octave (0.5x pitch), ADSR envelope disabled +mix.SetFireWithPitch("sound.wav", time.Duration(0), 0, 1.0, 0, 0.5, 1.0) + +// Pitch shifted up a perfect fifth (~1.5x pitch), ADSR envelope disabled +mix.SetFireWithPitch("sound.wav", time.Duration(0), 0, 1.0, 0, 1.5, 1.0) +``` + +## API + +```go +func SetFireWithPitch( + source string, // path to audio file + begin time.Duration, // start time + sustain time.Duration, // sustain duration (0 = play full file) + volume float64, // volume (0 to 1) + pan float64, // pan (-1 to +1) + pitch float64, // pitch multiplier (1.0 = no change) + timeStretch float64 // time stretch multiplier (currently unused) +) *fire.Fire + +func SetFireWithPitchADSR( + source string, // path to audio file + begin time.Duration, // start time + sustain time.Duration, // sustain duration (0 = play full file) + volume float64, // volume (0 to 1) + pan float64, // pan (-1 to +1) + attack time.Duration, // ADSR: Attack time (0 = disabled) + decay time.Duration, // ADSR: Decay time (0 = disabled) + sustainLevel float64, // ADSR: Sustain level (0 to 1, use 1.0 to disable) + release time.Duration, // ADSR: Release time (0 = disabled) + pitch float64, // pitch multiplier (1.0 = no change) + timeStretch float64 // time stretch multiplier (currently unused) +) *fire.Fire +``` + +**Note:** `SetFireWithPitch()` uses default ADSR settings (no envelope effect). For full control over both pitch shifting and ADSR envelope, use `SetFireWithPitchADSR()`. + +## Pitch Multiplier + +The `pitch` parameter is a multiplier: +- `1.0` = original pitch (no change) +- `2.0` = up one octave (twice the frequency) +- `0.5` = down one octave (half the frequency) +- `1.5` = up a perfect fifth +- `0.75` = down a perfect fourth + +**Valid Range**: The pitch parameter must be a positive non-zero value. Extreme values (less than 0.01 or greater than 100) are not recommended as they may produce unexpected results or audio quality issues. + +## How It Works + +The implementation uses sample-rate modification with linear interpolation: +- Higher pitch values increase playback speed (shorter duration) +- Lower pitch values decrease playback speed (longer duration) +- Linear interpolation between samples ensures smooth playback without artifacts + +## Note on Time Stretching + +Currently, changing pitch also changes playback speed proportionally. True independent time-stretching (changing speed without affecting pitch) would require more advanced algorithms like phase vocoding or time-domain harmonic scaling, which may be added in future versions. + +## Examples + +### Pitch Shifting a Drum Loop + +```go +// Play a drum loop at different pitches for variation +mix.SetFireWithPitch("drums.wav", 0*time.Second, 0, 1.0, 0, 1.0, 1.0) // original +mix.SetFireWithPitch("drums.wav", 4*time.Second, 0, 1.0, 0, 0.9, 1.0) // slightly lower +mix.SetFireWithPitch("drums.wav", 8*time.Second, 0, 1.0, 0, 1.1, 1.0) // slightly higher +``` + +### Creating Harmonies + +```go +// Play the same sample at different pitches to create harmony +baseTime := 2 * time.Second +mix.SetFireWithPitch("note.wav", baseTime, 0, 0.8, -0.5, 1.0, 1.0) // root (left) +mix.SetFireWithPitch("note.wav", baseTime, 0, 0.8, 0, 1.25, 1.0) // major third (center) +mix.SetFireWithPitch("note.wav", baseTime, 0, 0.8, 0.5, 1.5, 1.0) // perfect fifth (right) +``` + +### Bass Drop Effect + +```go +// Create a bass drop by pitch shifting down over time +for i := 0; i < 10; i++ { + pitch := 1.0 - float64(i)*0.1 // gradually lower pitch + mix.SetFireWithPitch("bass.wav", time.Duration(i)*200*time.Millisecond, + 200*time.Millisecond, 1.0, 0, pitch, 1.0) +} +``` + +### Combining Pitch Shift with ADSR Envelope + +```go +// Use SetFireWithPitchADSR for full control over both pitch and envelope +attack := 100 * time.Millisecond +decay := 50 * time.Millisecond +sustainLevel := 0.7 +release := 200 * time.Millisecond + +mix.SetFireWithPitchADSR("synth.wav", 0, 2*time.Second, 1.0, 0, + attack, decay, sustainLevel, release, 1.5, 1.0) +``` diff --git a/go.sum b/go.sum index abdb1db..ac7b263 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,10 +15,12 @@ github.com/krig/go-sox v0.0.0-20180617124112-7d2f8ae31981/go.mod h1:0uPmTzngejep github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/youpy/go-riff v0.0.0-20131220112943-557d78c11efb h1:RDh7U5Di6o7fblIBe7rVi9KnrcOXUbLwvvLLdP2InSI= @@ -27,6 +30,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/pkg/profile.v1 v1.3.0 h1:zQjfg5nVj3xAlW4eOk1IjKDjQ+SfQwcQXm+M1IemyYo= gopkg.in/pkg/profile.v1 v1.3.0/go.mod h1:knhHpoyiu3zB9bR/uG9+s8jTFrCOFA3g9Xkh/NCDzJ4= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/fire/fire.go b/lib/fire/fire.go index dcab456..52ce5da 100644 --- a/lib/fire/fire.go +++ b/lib/fire/fire.go @@ -8,21 +8,24 @@ import ( ) // New Fire to represent a single audio source playing at a specific time in the future. -func New(source string, beginTz spec.Tz, endTz spec.Tz, volume float64, pan float64, attack spec.Tz, decay spec.Tz, sustain float64, release spec.Tz) *Fire { +func New(source string, beginTz spec.Tz, endTz spec.Tz, volume float64, pan float64, attack spec.Tz, decay spec.Tz, sustain float64, release spec.Tz, pitch float64, timeStretch float64) *Fire { // debug.Printf("NewFire(%v, %v, %v, %v, %v)\n", source, beginTz, endTz, volume, pan) s := &Fire{ /* setup */ - Source: source, - Volume: volume, - Pan: pan, - BeginTz: beginTz, - EndTz: endTz, - Attack: attack, - Decay: decay, - Sustain: sustain, - Release: release, + Source: source, + Volume: volume, + Pan: pan, + BeginTz: beginTz, + EndTz: endTz, + Attack: attack, + Decay: decay, + Sustain: sustain, + Release: release, + Pitch: pitch, + TimeStretch: timeStretch, /* playback */ - state: fireStateReady, + state: fireStateReady, + PlaybackTz: 0, } return s } @@ -30,19 +33,22 @@ func New(source string, beginTz spec.Tz, endTz spec.Tz, volume float64, pan floa // Fire represents a single audio source playing at a specific time in the future. type Fire struct { /* setup */ - BeginTz spec.Tz - EndTz spec.Tz - Source string - Volume float64 // 0 to 1 - Pan float64 // -1 to +1 - Attack spec.Tz // ADSR: Attack time in samples - Decay spec.Tz // ADSR: Decay time in samples - Sustain float64 // ADSR: Sustain level (0 to 1) - Release spec.Tz // ADSR: Release time in samples + BeginTz spec.Tz + EndTz spec.Tz + Source string + Volume float64 // 0 to 1 + Pan float64 // -1 to +1 + Attack spec.Tz // ADSR: Attack time in samples + Decay spec.Tz // ADSR: Decay time in samples + Sustain float64 // ADSR: Sustain level (0 to 1) + Release spec.Tz // ADSR: Release time in samples + Pitch float64 // pitch shift multiplier (1.0 = no shift, 2.0 = up one octave, 0.5 = down one octave) + TimeStretch float64 // time stretch multiplier (1.0 = no stretch, 2.0 = twice as slow, 0.5 = twice as fast) /* playback */ - nowTz spec.Tz - releaseTz spec.Tz // Time when release phase started - state fireStateEnum + nowTz spec.Tz + releaseTz spec.Tz // Time when release phase started + PlaybackTz float64 // fractional position for pitch/time stretch - exported for internal mixer use, do not modify externally + state fireStateEnum } // At the series of Tz it's playing for, return the series of Tz corresponding to source audio. @@ -51,11 +57,22 @@ func (f *Fire) At(at spec.Tz) (t spec.Tz) { switch f.state { case fireStateReady: if at >= f.BeginTz { + // On the first playable sample, mirror fireStatePlay behavior: + // return the current PlaybackTz and then advance it. + currentPlaybackTz := f.PlaybackTz + t = spec.Tz(currentPlaybackTz) f.state = fireStatePlay f.nowTz++ + f.PlaybackTz = currentPlaybackTz + f.pitchAdvancement() } case fireStatePlay: - t = f.nowTz + // Capture current playback position before advancing so interpolation + // uses the pre-advancement value. + currentPlaybackTz := f.PlaybackTz + // Return current sample position based on the captured value. + t = spec.Tz(currentPlaybackTz) + // Advance playback position based on pitch (affects playback rate). + f.PlaybackTz = currentPlaybackTz + f.pitchAdvancement() f.nowTz++ if f.EndTz != 0 { if at >= f.EndTz { @@ -68,7 +85,13 @@ func (f *Fire) At(at spec.Tz) (t spec.Tz) { } } } else { - f.EndTz = f.BeginTz + f.sourceLength() + actualLength := f.sourceLength() + // Adjust end time based on pitch (faster pitch = shorter duration) + if f.HasPitchShift() { + f.EndTz = f.BeginTz + spec.Tz(float64(actualLength)/f.Pitch) + } else { + f.EndTz = f.BeginTz + actualLength + } } case fireStateRelease: t = f.nowTz @@ -176,3 +199,16 @@ const ( func (f *Fire) sourceLength() spec.Tz { return source.GetLength(f.Source) } + +// HasPitchShift returns true if pitch shifting is enabled +func (f *Fire) HasPitchShift() bool { + return f.Pitch != 1.0 +} + +// pitchAdvancement returns the amount to advance playback position per sample +func (f *Fire) pitchAdvancement() float64 { + if f.HasPitchShift() { + return f.Pitch + } + return 1.0 +} diff --git a/lib/fire/fire_test.go b/lib/fire/fire_test.go index fff0b1a..ccaaf9a 100644 --- a/lib/fire/fire_test.go +++ b/lib/fire/fire_test.go @@ -16,7 +16,9 @@ func TestBase(t *testing.T) { endTz := bgnTz + testLengthTz vol := float64(1) pan := float64(0) - fire := New(src, bgnTz, endTz, vol, pan, 0, 0, 1.0, 0) + pitch := float64(1.0) // no pitch shift + timeStretch := float64(1.0) // no time stretch + fire := New(src, bgnTz, endTz, vol, pan, 0, 0, 1.0, 0, pitch, timeStretch) // before start: assert.Equal(t, spec.Tz(0), fire.At(bgnTz-2)) assert.Equal(t, spec.Tz(0), fire.At(bgnTz-1)) @@ -46,7 +48,7 @@ func TestNewFire(t *testing.T) { vol := float64(0.8) pan := float64(-0.5) - fire := New(src, bgnTz, endTz, vol, pan, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, vol, pan, 0, 0, 1.0, 0, 1.0, 1.0) assert.NotNil(t, fire) assert.Equal(t, src, fire.Source) @@ -61,7 +63,7 @@ func TestAt(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(200) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Before begin: should return 0 and stay in ready state result := fire.At(bgnTz - 1) @@ -85,7 +87,7 @@ func TestState(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(150) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Initial state should be ready assert.Equal(t, fireStateReady, fire.state) @@ -103,7 +105,7 @@ func TestIsAlive(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(150) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Should be alive in ready state assert.True(t, fire.IsAlive()) @@ -121,7 +123,7 @@ func TestIsPlaying(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(150) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Should not be playing in ready state assert.False(t, fire.IsPlaying()) @@ -142,7 +144,7 @@ func TestSetState(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(150) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Verify state transitions through At() method assert.Equal(t, fireStateReady, fire.state) @@ -157,7 +159,7 @@ func TestSourceLength(t *testing.T) { // We test it indirectly through the Fire behavior when EndTz is 0 src := "test.wav" bgnTz := spec.Tz(100) - fire := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0) // EndTz = 0 + fire := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // EndTz = 0 // When we call At() during play state with EndTz=0, // it should compute EndTz from source length @@ -173,7 +175,7 @@ func TestTeardown(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(150) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Teardown should not panic assert.NotPanics(t, func() { @@ -187,20 +189,20 @@ func TestFire_VolumeAndPan(t *testing.T) { endTz := spec.Tz(150) // Test with different volume levels - fire1 := New(src, bgnTz, endTz, 0.5, 0, 0, 0, 1.0, 0) + fire1 := New(src, bgnTz, endTz, 0.5, 0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, 0.5, fire1.Volume) - fire2 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire2 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, 1.0, fire2.Volume) // Test with different pan values - fireLeft := New(src, bgnTz, endTz, 1.0, -1.0, 0, 0, 1.0, 0) + fireLeft := New(src, bgnTz, endTz, 1.0, -1.0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, -1.0, fireLeft.Pan) - fireRight := New(src, bgnTz, endTz, 1.0, 1.0, 0, 0, 1.0, 0) + fireRight := New(src, bgnTz, endTz, 1.0, 1.0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, 1.0, fireRight.Pan) - fireCenter := New(src, bgnTz, endTz, 1.0, 0.0, 0, 0, 1.0, 0) + fireCenter := New(src, bgnTz, endTz, 1.0, 0.0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, 0.0, fireCenter.Pan) } @@ -209,7 +211,7 @@ func TestFire_ZeroEndTz(t *testing.T) { bgnTz := spec.Tz(100) // Create fire with EndTz = 0 (should be calculated from source) - fire := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) assert.Equal(t, spec.Tz(0), fire.EndTz) // Start playing @@ -226,7 +228,7 @@ func TestFire_MultipleAtCalls(t *testing.T) { src := "test.wav" bgnTz := spec.Tz(100) endTz := spec.Tz(110) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Multiple calls before begin should return 0 for i := 0; i < 5; i++ { @@ -262,7 +264,7 @@ func TestADSR_NoEnvelope(t *testing.T) { src := "sound.wav" bgnTz := spec.Tz(1000) endTz := bgnTz + spec.Tz(100) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) // Envelope should always be 1.0 when no ADSR is configured assert.Equal(t, 1.0, fire.Envelope(bgnTz)) @@ -276,7 +278,7 @@ func TestADSR_AttackPhase(t *testing.T) { bgnTz := spec.Tz(1000) endTz := bgnTz + spec.Tz(200) attack := spec.Tz(100) - fire := New(src, bgnTz, endTz, 1.0, 0, attack, 0, 1.0, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, 0, 1.0, 0, 1.0, 1.0) // At start, envelope should be 0 assert.Equal(t, 0.0, fire.Envelope(bgnTz)) @@ -294,7 +296,7 @@ func TestADSR_DecayPhase(t *testing.T) { attack := spec.Tz(50) decay := spec.Tz(100) sustain := 0.7 - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, 0, 1.0, 1.0) // At end of attack (start of decay), should be 1.0 assert.Equal(t, 1.0, fire.Envelope(bgnTz+attack)) @@ -314,7 +316,7 @@ func TestADSR_SustainPhase(t *testing.T) { attack := spec.Tz(50) decay := spec.Tz(50) sustain := 0.6 - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, 0) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, 0, 1.0, 1.0) // During sustain phase, envelope should stay at sustain level assert.Equal(t, sustain, fire.Envelope(bgnTz+attack+decay)) @@ -331,7 +333,7 @@ func TestADSR_ReleasePhase(t *testing.T) { decay := spec.Tz(30) sustain := 0.8 release := spec.Tz(50) - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release, 1.0, 1.0) // Play through to end to trigger release fire.At(bgnTz) // Start @@ -363,7 +365,7 @@ func TestADSR_FullCycle(t *testing.T) { decay := spec.Tz(50) sustain := 0.7 release := spec.Tz(50) - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release, 1.0, 1.0) // Attack phase assert.Equal(t, 0.0, fire.Envelope(bgnTz)) @@ -398,7 +400,7 @@ func TestADSR_ReleaseTransition(t *testing.T) { bgnTz := spec.Tz(100) endTz := bgnTz + spec.Tz(50) release := spec.Tz(20) - fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, release) + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, release, 1.0, 1.0) // Start playing fire.At(bgnTz) @@ -436,7 +438,7 @@ func TestADSR_EdgeCases(t *testing.T) { decay := spec.Tz(50) sustain := 0.7 release := spec.Tz(50) - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release, 1.0, 1.0) // Before begin time, envelope should be 0 assert.Equal(t, 0.0, fire.Envelope(bgnTz-10)) @@ -465,7 +467,7 @@ func TestADSR_AttackExceedsDuration(t *testing.T) { decay := spec.Tz(50) // Decay is also 50 samples sustain := 0.7 release := spec.Tz(30) - fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release) + fire := New(src, bgnTz, endTz, 1.0, 0, attack, decay, sustain, release, 1.0, 1.0) // Start playing fire.At(bgnTz) @@ -504,3 +506,171 @@ func TestADSR_AttackExceedsDuration(t *testing.T) { assert.Equal(t, fireStateDone, fire.state) assert.Equal(t, 0.0, fire.Envelope(endTz+release)) } + +func TestPitchShiftPlaybackAdvancement(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + endTz := spec.Tz(200) + + // Test pitch shift up (2.0x) + fire1 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 2.0, 1.0) + assert.Equal(t, 2.0, fire1.Pitch) + assert.Equal(t, 0.0, fire1.PlaybackTz) // Initial position + + // First call transitions to play and returns 0, advances to 2.0 + result := fire1.At(bgnTz) + assert.Equal(t, spec.Tz(0), result) + assert.Equal(t, fireStatePlay, fire1.state) + assert.Equal(t, 2.0, fire1.PlaybackTz) // Advanced by pitch value + + // Second call returns 2.0, advances to 4.0 + result = fire1.At(bgnTz + 1) + assert.Equal(t, spec.Tz(2), result) + assert.Equal(t, 4.0, fire1.PlaybackTz) + + // Test pitch shift down (0.5x) + fire2 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 0.5, 1.0) + assert.Equal(t, 0.5, fire2.Pitch) + + // First call returns 0, advances to 0.5 + result = fire2.At(bgnTz) + assert.Equal(t, spec.Tz(0), result) + assert.Equal(t, 0.5, fire2.PlaybackTz) + + // Second call returns 0.5, advances to 1.0 + result = fire2.At(bgnTz + 1) + assert.Equal(t, spec.Tz(0), result) // Truncated to 0 + assert.Equal(t, 1.0, fire2.PlaybackTz) +} + +func TestPitchShiftEndTzCalculation(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + + // Test with pitch shift up (2.0x) and no explicit endTz + fire1 := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0, 2.0, 1.0) + assert.Equal(t, spec.Tz(0), fire1.EndTz) // Initially 0 + + // Start playing + fire1.At(bgnTz) + fire1.At(bgnTz + 1) // This should calculate EndTz + + // EndTz should be calculated based on pitch adjustment + // With 2.0x pitch, duration should be half + assert.True(t, fire1.EndTz > 0, "EndTz should be calculated") + + // Test with pitch shift down (0.5x) and no explicit endTz + fire2 := New(src, bgnTz, 0, 1.0, 0, 0, 0, 1.0, 0, 0.5, 1.0) + + // Start playing + fire2.At(bgnTz) + fire2.At(bgnTz + 1) + + // EndTz should be calculated with pitch adjustment + // With 0.5x pitch, duration should be double + assert.True(t, fire2.EndTz > 0, "EndTz should be calculated") +} + +func TestHasPitchShift(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + endTz := spec.Tz(200) + + // Test with no pitch shift (1.0) + fire1 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) + assert.False(t, fire1.HasPitchShift(), "Pitch 1.0 should not be considered pitch shift") + + // Test with pitch shift up + fire2 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 2.0, 1.0) + assert.True(t, fire2.HasPitchShift(), "Pitch 2.0 should be considered pitch shift") + + // Test with pitch shift down + fire3 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 0.5, 1.0) + assert.True(t, fire3.HasPitchShift(), "Pitch 0.5 should be considered pitch shift") + + // Test with slight pitch shift + fire4 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.1, 1.0) + assert.True(t, fire4.HasPitchShift(), "Pitch 1.1 should be considered pitch shift") +} + +func TestPitchAdvancement(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + endTz := spec.Tz(200) + + // Test no pitch shift returns 1.0 + fire1 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.0, 1.0) + assert.Equal(t, 1.0, fire1.pitchAdvancement()) + + // Test pitch shift returns pitch value + fire2 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 2.0, 1.0) + assert.Equal(t, 2.0, fire2.pitchAdvancement()) + + fire3 := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 0.5, 1.0) + assert.Equal(t, 0.5, fire3.pitchAdvancement()) +} + +func TestPitchShiftWithStateTransitions(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + endTz := spec.Tz(110) // Short duration + release := spec.Tz(5) + + // Test pitch shifting with ADSR and state transitions + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, release, 2.0, 1.0) + + // Before start - should be in Ready state + assert.Equal(t, fireStateReady, fire.state) + assert.Equal(t, 0.0, fire.PlaybackTz) + + // Start playing - transition to Play state + result := fire.At(bgnTz) + assert.Equal(t, spec.Tz(0), result) + assert.Equal(t, fireStatePlay, fire.state) + assert.Equal(t, 2.0, fire.PlaybackTz) // Advanced by pitch + + // Continue playing with pitch shift + for i := spec.Tz(1); i < 10; i++ { + fire.At(bgnTz + i) + } + + // Should have advanced playback position significantly due to 2.0x pitch + assert.True(t, fire.PlaybackTz >= 20.0, "PlaybackTz should advance by pitch multiplier") + + // Play until end - should transition to Release state + fire.At(endTz) + assert.Equal(t, fireStateRelease, fire.state) + + // Complete release + for i := spec.Tz(1); i <= release; i++ { + fire.At(endTz + i) + } + + // Should be done + assert.Equal(t, fireStateDone, fire.state) +} + +func TestPitchShiftFractionalPositions(t *testing.T) { + src := "test.wav" + bgnTz := spec.Tz(100) + endTz := spec.Tz(200) + + // Test that fractional playback positions are maintained correctly + fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0, 1.5, 1.0) + + // Start + fire.At(bgnTz) + assert.Equal(t, 1.5, fire.PlaybackTz) + + // Second sample + fire.At(bgnTz + 1) + assert.Equal(t, 3.0, fire.PlaybackTz) // 1.5 * 2 + + // Third sample + fire.At(bgnTz + 2) + assert.Equal(t, 4.5, fire.PlaybackTz) // 1.5 * 3 + + // Fourth sample + fire.At(bgnTz + 3) + assert.Equal(t, 6.0, fire.PlaybackTz) // 1.5 * 4 +} diff --git a/lib/mix/mix.go b/lib/mix/mix.go index 7f28f3e..afecc68 100644 --- a/lib/mix/mix.go +++ b/lib/mix/mix.go @@ -24,8 +24,14 @@ func NextSample() []sample.Value { smp := make([]sample.Value, channels) var fireSample []sample.Value for _, fire := range mixLiveFires { - if fireTz := fire.At(nowTz); fireTz > 0 { - fireSample = mixSourceAt(fire.Source, fire.Volume, fire.Pan, fireTz) + if fireTz := fire.At(nowTz); fireTz >= 0 && fire.IsPlaying() { + // Use interpolated sampling if pitch shifting is enabled + if fire.HasPitchShift() { + // Use fractional playback position for interpolation + fireSample = mixSourceAtInterpolated(fire.Source, fire.Volume, fire.Pan, fire.PlaybackTz) + } else { + fireSample = mixSourceAt(fire.Source, fire.Volume, fire.Pan, fireTz) + } if len(fireSample) < channels { continue } @@ -79,6 +85,45 @@ func Teardown() { // and ADSR envelope parameters: attack time.Duration, decay time.Duration, sustainLevel (0 to 1), release time.Duration. // To disable the ADSR envelope effect, use: attack=0, decay=0, sustainLevel=1.0, release=0 func SetFire(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, attack time.Duration, decay time.Duration, sustainLevel float64, release time.Duration) *fire.Fire { + return setFireInternal(source, begin, sustain, volume, pan, attack, decay, sustainLevel, release, 1.0, 1.0) +} + +// SetFireWithPitch represents a single audio source playing at a specific time with pitch shifting and time stretching. +// pitch: multiplier for pitch (1.0 = no change, 2.0 = up one octave, 0.5 = down one octave) +// Must be a positive non-zero value. Values < 0.01 or > 100 are not recommended. +// timeStretch: multiplier for duration (1.0 = no change, 2.0 = twice as slow, 0.5 = twice as fast). +// NOTE: timeStretch is not yet implemented; currently only a value of 1.0 is supported. +// This function uses a default ADSR envelope that has no effect (attack=0, decay=0, sustainLevel=1.0, release=0). +func SetFireWithPitch(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, pitch float64, timeStretch float64) *fire.Fire { + if pitch <= 0 { + panic("SetFireWithPitch: pitch must be a positive non-zero value") + } + if timeStretch != 1.0 { + panic("SetFireWithPitch: timeStretch != 1.0 is not yet implemented; only timeStretch = 1.0 is currently supported") + } + const defaultSustainLevel = 1.0 + return setFireInternal(source, begin, sustain, volume, pan, 0, 0, defaultSustainLevel, 0, pitch, timeStretch) +} + +// SetFireWithPitchADSR represents a single audio source playing at a specific time with both +// ADSR envelope control and pitch shifting / time stretching. +// pitch: multiplier for pitch (1.0 = no change, 2.0 = up one octave, 0.5 = down one octave) +// Must be a positive non-zero value. Values < 0.01 or > 100 are not recommended. +// timeStretch: multiplier for duration (1.0 = no change, 2.0 = twice as slow, 0.5 = twice as fast). +// NOTE: timeStretch is not yet implemented; currently only a value of 1.0 is supported. +func SetFireWithPitchADSR(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, attack time.Duration, decay time.Duration, sustainLevel float64, release time.Duration, pitch float64, timeStretch float64) *fire.Fire { + if pitch <= 0 { + panic("SetFireWithPitchADSR: pitch must be a positive non-zero value") + } + if timeStretch != 1.0 { + panic("SetFireWithPitchADSR: timeStretch != 1.0 is not yet implemented; only timeStretch = 1.0 is currently supported") + } + return setFireInternal(source, begin, sustain, volume, pan, attack, decay, sustainLevel, release, pitch, timeStretch) +} + +// setFireInternal is a shared helper that creates and schedules a Fire with full control over +// ADSR envelope and pitch/timeStretch parameters. +func setFireInternal(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, attack time.Duration, decay time.Duration, sustainLevel float64, release time.Duration, pitch float64, timeStretch float64) *fire.Fire { mixPrepareSource(mixSourcePrefix + source) beginTz := spec.Tz(begin.Nanoseconds() / masterTzDur.Nanoseconds()) var endTz spec.Tz @@ -88,7 +133,7 @@ func SetFire(source string, begin time.Duration, sustain time.Duration, volume f attackTz := spec.Tz(attack.Nanoseconds() / masterTzDur.Nanoseconds()) decayTz := spec.Tz(decay.Nanoseconds() / masterTzDur.Nanoseconds()) releaseTz := spec.Tz(release.Nanoseconds() / masterTzDur.Nanoseconds()) - f := fire.New(mixSourcePrefix+source, beginTz, endTz, volume, pan, attackTz, decayTz, sustainLevel, releaseTz) + f := fire.New(mixSourcePrefix+source, beginTz, endTz, volume, pan, attackTz, decayTz, sustainLevel, releaseTz, pitch, timeStretch) mixReadyFires = append(mixReadyFires, f) return f } @@ -192,6 +237,14 @@ func mixSourceAt(src string, volume float64, pan float64, at spec.Tz) []sample.V return s.SampleAt(at, volume, pan) } +func mixSourceAtInterpolated(src string, volume float64, pan float64, at float64) []sample.Value { + s := mixGetSource(src) + if s == nil { + return make([]sample.Value, masterSpec.Channels) + } + return s.SampleAtInterpolated(at, volume, pan) +} + func mixPrepareSource(src string) { source.Prepare(src) } diff --git a/lib/source/source.go b/lib/source/source.go index 586d772..e1144f6 100644 --- a/lib/source/source.go +++ b/lib/source/source.go @@ -57,6 +57,53 @@ func (s *Source) SampleAt(at spec.Tz, vol float64, pan float64) (out []sample.Va return } +// SampleAtInterpolated at a fractional Tz position using linear interpolation, volume (0 to 1), and pan (-1 to +1) +// This is used for pitch shifting and time stretching to avoid artifacts +func (s *Source) SampleAtInterpolated(at float64, vol float64, pan float64) (out []sample.Value) { + out = make([]sample.Value, masterSpec.Channels) + + // Get the integer and fractional parts + atInt := spec.Tz(math.Floor(at)) + atFrac := at - math.Floor(at) + + if atInt >= s.maxTz { + return out + } + + // Get samples for interpolation + sample1 := s.sample[atInt] + var sample2 sample.Sample + if atInt+1 < s.maxTz { + sample2 = s.sample[atInt+1] + } else { + // If we're at the end, use the last sample + sample2 = sample1 + } + + // Helper function for linear interpolation + interpolate := func(srcChan int) sample.Value { + // Ensure srcChan is within the bounds of the sample value slices to avoid panics. + if srcChan < 0 || srcChan >= len(sample1.Values) || srcChan >= len(sample2.Values) { + return 0 + } + return sample1.Values[srcChan]*(1.0-sample.Value(atFrac)) + sample2.Values[srcChan]*sample.Value(atFrac) + } + + // Linear interpolation between samples + if masterSpec.Channels == s.audioSpec.Channels { + for c := int(0); c < masterSpec.Channels; c++ { + out[c] = volume(float64(c), vol, pan) * interpolate(c) + } + } else { + tc := float64(s.audioSpec.Channels) + for c := int(0); c < masterSpec.Channels; c++ { + srcChan := int(math.Floor(tc * float64(c) / masterChannelsFloat)) + out[c] = volume(float64(c), vol, pan) * interpolate(srcChan) + } + } + return +} + // Length of the source audio in Tz func (s *Source) Length() spec.Tz { return s.maxTz diff --git a/lib/source/source_test.go b/lib/source/source_test.go index 43e7950..1ea86c7 100644 --- a/lib/source/source_test.go +++ b/lib/source/source_test.go @@ -98,6 +98,86 @@ func TestSampleAt(t *testing.T) { assert.Equal(t, sample.Value(0), smpBeyond[1]) } +func TestSampleAtInterpolated(t *testing.T) { + Configure(spec.AudioSpec{ + Freq: 44100, + Format: spec.AudioF32, + Channels: 1, + }) + source := New("testdata/Signed16bitLittleEndian44100HzMono.wav") + assert.NotNil(t, source) + + // Test interpolated sampling at fractional positions + sample1 := source.SampleAtInterpolated(1.0, 1.0, 0) + assert.NotNil(t, sample1) + + // Test interpolation between samples + sample2 := source.SampleAtInterpolated(1.5, 1.0, 0) + assert.NotNil(t, sample2) + + // Interpolated value should be between the two adjacent samples + sampleBefore := source.SampleAt(1, 1.0, 0) + sampleAfter := source.SampleAt(2, 1.0, 0) + + // The interpolated value at 1.5 should be between sample at 1 and sample at 2 + for c := 0; c < len(sample2); c++ { + if sampleBefore[c] != sampleAfter[c] { + // Check that interpolated value is between the two samples (or equal to one if they're the same) + minVal := sampleBefore[c] + maxVal := sampleAfter[c] + if minVal > maxVal { + minVal, maxVal = maxVal, minVal + } + assert.True(t, sample2[c] >= minVal && sample2[c] <= maxVal, + "Interpolated sample should be between adjacent samples") + } + } + + // Test edge case: sampling at position 0 (boundary) + sample0 := source.SampleAtInterpolated(0.0, 1.0, 0) + assert.NotNil(t, sample0) + assert.Equal(t, len(sample0), 1) + + // Test edge case: sampling beyond source length (should return zeros) + sampleBeyond := source.SampleAtInterpolated(float64(source.Length()+100), 1.0, 0) + assert.NotNil(t, sampleBeyond) + for c := 0; c < len(sampleBeyond); c++ { + assert.Equal(t, sample.Value(0), sampleBeyond[c], "Beyond source length should return zero") + } + + // Test edge case: sampling at the very last valid position + lastPos := float64(source.Length() - 1) + sampleLast := source.SampleAtInterpolated(lastPos, 1.0, 0) + assert.NotNil(t, sampleLast) + + // Test with volume adjustments + sampleHalfVol := source.SampleAtInterpolated(1.5, 0.5, 0) + assert.NotNil(t, sampleHalfVol) + // Volume should scale the interpolated value (using absolute values for comparison) + for c := 0; c < len(sampleHalfVol); c++ { + // Check that half volume produces approximately half the amplitude + if sample2[c] != 0 { + ratio := sampleHalfVol[c] / sample2[c] + assert.True(t, ratio >= 0.45 && ratio <= 0.55, + "Half volume should produce approximately half the amplitude") + } + } + + // Test with pan adjustments (if multi-channel) + Configure(spec.AudioSpec{ + Freq: 48000, + Format: spec.AudioF32, + Channels: 2, + }) + stereoSource := New("testdata/Float32bitLittleEndian48000HzEstéreo.wav") + if stereoSource != nil { + samplePanLeft := stereoSource.SampleAtInterpolated(1.5, 1.0, -1.0) + samplePanRight := stereoSource.SampleAtInterpolated(1.5, 1.0, 1.0) + assert.NotNil(t, samplePanLeft) + assert.NotNil(t, samplePanRight) + } +} + func TestState(t *testing.T) { testSourceSetup(44100, 1) testFile := "testdata/Signed16bitLittleEndian44100HzMono.wav" diff --git a/mix.go b/mix.go index 0b139cd..93db83d 100644 --- a/mix.go +++ b/mix.go @@ -208,6 +208,28 @@ func SetFire(source string, begin time.Duration, sustain time.Duration, volume f return mix.SetFire(source, begin, sustain, volume, pan, attack, decay, sustainLevel, release) } +// SetFireWithPitch represents a single audio source playing at a specific time with pitch shifting and time stretching. +// pitch: multiplier for pitch (1.0 = no change, 2.0 = up one octave, 0.5 = down one octave) +// timeStretch: multiplier for duration (currently must be 1.0; time-stretching is not yet implemented) +// This function uses a default ADSR envelope that has no effect (attack=0, decay=0, sustainLevel=1.0, release=0). +func SetFireWithPitch(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, pitch float64, timeStretch float64) *fire.Fire { + if timeStretch != 1.0 { + panic("mix: timeStretch is not yet implemented; timeStretch must be 1.0") + } + return mix.SetFireWithPitch(source, begin, sustain, volume, pan, pitch, timeStretch) +} + +// SetFireWithPitchADSR represents a single audio source playing at a specific time with both +// ADSR envelope control and pitch shifting / time stretching. +// pitch: multiplier for pitch (1.0 = no change, 2.0 = up one octave, 0.5 = down one octave) +// timeStretch: multiplier for duration (currently must be 1.0; time-stretching is not yet implemented) +func SetFireWithPitchADSR(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, attack time.Duration, decay time.Duration, sustainLevel float64, release time.Duration, pitch float64, timeStretch float64) *fire.Fire { + if timeStretch != 1.0 { + panic("mix: timeStretch is not yet implemented; timeStretch must be 1.0") + } + return mix.SetFireWithPitchADSR(source, begin, sustain, volume, pan, attack, decay, sustainLevel, release, pitch, timeStretch) +} + // FireCount to check the number of fires currently scheduled for playback func FireCount() int { return mix.FireCount() diff --git a/mix_test.go b/mix_test.go index 0276362..d5a3889 100644 --- a/mix_test.go +++ b/mix_test.go @@ -67,6 +67,84 @@ func TestSetFire(t *testing.T) { assert.NotNil(t, fire) } +func TestSetFireWithPitch(t *testing.T) { + testAPISetup() + // Test with pitch shift up one octave (2.0) + fire := SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 2.0, 1.0) + assert.NotNil(t, fire) + assert.Equal(t, 2.0, fire.Pitch) + assert.Equal(t, 1.0, fire.TimeStretch) + + // Test with pitch shift down one octave (0.5) + fire2 := SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 0.5, 1.0) + assert.NotNil(t, fire2) + assert.Equal(t, 0.5, fire2.Pitch) +} + +func TestSetFireWithPitchEdgeCases(t *testing.T) { + testAPISetup() + + // Test with very high pitch value + fire1 := SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 10.0, 1.0) + assert.NotNil(t, fire1) + assert.Equal(t, 10.0, fire1.Pitch) + + // Test with very low pitch value + fire2 := SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 0.01, 1.0) + assert.NotNil(t, fire2) + assert.Equal(t, 0.01, fire2.Pitch) + + // Test that zero pitch panics + assert.Panics(t, func() { + SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 0.0, 1.0) + }, "Zero pitch should panic") + + // Test that negative pitch panics + assert.Panics(t, func() { + SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, -1.0, 1.0) + }, "Negative pitch should panic") + + // Test that timeStretch != 1.0 panics + assert.Panics(t, func() { + SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 1.0, 2.0) + }, "timeStretch != 1.0 should panic") + + assert.Panics(t, func() { + SetFireWithPitch("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", time.Duration(0), 0, 1.0, 0, 1.0, 0.5) + }, "timeStretch != 1.0 should panic") +} + +func TestSetFireWithPitchADSR(t *testing.T) { + testAPISetup() + // Test with both pitch shifting and ADSR envelope + attack := 100 * time.Millisecond + decay := 50 * time.Millisecond + sustainLevel := 0.7 + release := 200 * time.Millisecond + + fire := SetFireWithPitchADSR("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", + time.Duration(0), 0, 1.0, 0, attack, decay, sustainLevel, release, 1.5, 1.0) + assert.NotNil(t, fire) + assert.Equal(t, 1.5, fire.Pitch) + assert.Equal(t, 1.0, fire.TimeStretch) + assert.Equal(t, sustainLevel, fire.Sustain) + assert.True(t, fire.Attack > 0, "Attack should be set") + assert.True(t, fire.Decay > 0, "Decay should be set") + assert.True(t, fire.Release > 0, "Release should be set") + + // Test that zero pitch panics in SetFireWithPitchADSR too + assert.Panics(t, func() { + SetFireWithPitchADSR("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", + time.Duration(0), 0, 1.0, 0, attack, decay, sustainLevel, release, 0.0, 1.0) + }, "Zero pitch should panic in SetFireWithPitchADSR") + + // Test that timeStretch != 1.0 panics in SetFireWithPitchADSR too + assert.Panics(t, func() { + SetFireWithPitchADSR("lib/source/testdata/Signed16bitLittleEndian44100HzMono.wav", + time.Duration(0), 0, 1.0, 0, attack, decay, sustainLevel, release, 1.5, 2.0) + }, "timeStretch != 1.0 should panic in SetFireWithPitchADSR") +} + func TestFireCount(t *testing.T) { testAPISetup() assert.Equal(t, 0, FireCount())