Skip to content
Open
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
92 changes: 92 additions & 0 deletions docs/PITCH_SHIFTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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
mix.SetFire("sound.wav", time.Duration(0), 0, 1.0, 0)
Comment thread
charneykaye marked this conversation as resolved.
Outdated

// Pitch shifted up one octave (2.0x pitch)
mix.SetFireWithPitch("sound.wav", time.Duration(0), 0, 1.0, 0, 2.0, 1.0)

// Pitch shifted down one octave (0.5x pitch)
mix.SetFireWithPitch("sound.wav", time.Duration(0), 0, 1.0, 0, 0.5, 1.0)

// Pitch shifted up a perfect fifth (~1.5x pitch)
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
```

## 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
Comment thread
charneykaye marked this conversation as resolved.

## 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)
}
```
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
81 changes: 56 additions & 25 deletions lib/fire/fire.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,47 @@ 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
}

// 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 - exposed for interpolation
Comment thread
charneykaye marked this conversation as resolved.
Outdated
state fireStateEnum
}

// At the series of Tz it's playing for, return the series of Tz corresponding to source audio.
Expand All @@ -53,9 +59,15 @@ func (f *Fire) At(at spec.Tz) (t spec.Tz) {
if at >= f.BeginTz {
f.state = fireStatePlay
f.nowTz++
// Initialize PlaybackTz based on pitch advancement rate
// Matches original nowTz behavior where first sample position is 1
f.PlaybackTz = f.pitchAdvancement()
Comment thread
charneykaye marked this conversation as resolved.
Outdated
}
case fireStatePlay:
t = f.nowTz
// Return current sample position
t = spec.Tz(f.PlaybackTz)
// Advance playback position based on pitch (affects playback rate)
f.PlaybackTz += f.pitchAdvancement()
Comment thread
charneykaye marked this conversation as resolved.
Outdated
f.nowTz++
if f.EndTz != 0 {
if at >= f.EndTz {
Expand All @@ -68,7 +80,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)
Comment thread
charneykaye marked this conversation as resolved.
} else {
f.EndTz = f.BeginTz + actualLength
}
}
case fireStateRelease:
t = f.nowTz
Expand Down Expand Up @@ -176,3 +194,16 @@ const (
func (f *Fire) sourceLength() spec.Tz {
return source.GetLength(f.Source)
}

// hasPitchShift returns true if pitch shifting is enabled
Comment thread
charneykaye marked this conversation as resolved.
Outdated
func (f *Fire) hasPitchShift() bool {
return f.Pitch != 0 && f.Pitch != 1.0
Comment thread
charneykaye marked this conversation as resolved.
Outdated
}

// pitchAdvancement returns the amount to advance playback position per sample
func (f *Fire) pitchAdvancement() float64 {
if f.hasPitchShift() {
return f.Pitch
}
return 1.0
}
4 changes: 3 additions & 1 deletion lib/fire/fire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
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))
Expand Down Expand Up @@ -46,7 +48,7 @@
vol := float64(0.8)
pan := float64(-0.5)

fire := New(src, bgnTz, endTz, vol, pan, 0, 0, 1.0, 0)

Check failure on line 51 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 51 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 51 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

assert.NotNil(t, fire)
assert.Equal(t, src, fire.Source)
Expand All @@ -61,7 +63,7 @@
src := "test.wav"
bgnTz := spec.Tz(100)
endTz := spec.Tz(200)
fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0)

Check failure on line 66 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 66 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 66 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

// Before begin: should return 0 and stay in ready state
result := fire.At(bgnTz - 1)
Expand All @@ -85,7 +87,7 @@
src := "test.wav"
bgnTz := spec.Tz(100)
endTz := spec.Tz(150)
fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0)

Check failure on line 90 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 90 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 90 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

// Initial state should be ready
assert.Equal(t, fireStateReady, fire.state)
Expand All @@ -103,7 +105,7 @@
src := "test.wav"
bgnTz := spec.Tz(100)
endTz := spec.Tz(150)
fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0)

Check failure on line 108 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 108 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 108 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

// Should be alive in ready state
assert.True(t, fire.IsAlive())
Expand All @@ -121,7 +123,7 @@
src := "test.wav"
bgnTz := spec.Tz(100)
endTz := spec.Tz(150)
fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0)

Check failure on line 126 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 126 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 126 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

// Should not be playing in ready state
assert.False(t, fire.IsPlaying())
Expand All @@ -142,7 +144,7 @@
src := "test.wav"
bgnTz := spec.Tz(100)
endTz := spec.Tz(150)
fire := New(src, bgnTz, endTz, 1.0, 0, 0, 0, 1.0, 0)

Check failure on line 147 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

not enough arguments in call to New

Check failure on line 147 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

not enough arguments in call to New

Check failure on line 147 in lib/fire/fire_test.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

not enough arguments in call to New

// Verify state transitions through At() method
assert.Equal(t, fireStateReady, fire.state)
Expand Down
31 changes: 26 additions & 5 deletions lib/mix/mix.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@
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 len(fireSample) < channels {
continue
if fireTz := fire.At(nowTz); fireTz > 0 || fire.IsPlaying() {
Comment thread
charneykaye marked this conversation as resolved.
Outdated
// Use interpolated sampling if pitch shifting is enabled
if fire.Pitch != 0 && fire.Pitch != 1.0 {
Comment thread
charneykaye marked this conversation as resolved.
Outdated
// 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
}
}
Comment thread
charneykaye marked this conversation as resolved.
Outdated
for c := 0; c < channels; c++ {
smp[c] += fireSample[c]
Expand Down Expand Up @@ -79,16 +85,23 @@
// 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 SetFireWithPitch(source, begin, sustain, volume, pan, 1.0, 1.0)
}
Comment thread
charneykaye marked this conversation as resolved.

// SetFireWithPitch to represent 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 (1.0 = no change, 2.0 = twice as slow, 0.5 = twice as fast)
func SetFireWithPitch(source string, begin time.Duration, sustain time.Duration, volume float64, pan float64, pitch float64, timeStretch float64) *fire.Fire {
Comment thread
charneykaye marked this conversation as resolved.
Outdated
mixPrepareSource(mixSourcePrefix + source)
beginTz := spec.Tz(begin.Nanoseconds() / masterTzDur.Nanoseconds())
var endTz spec.Tz
if sustain != 0 {
endTz = beginTz + spec.Tz(sustain.Nanoseconds()/masterTzDur.Nanoseconds())
}
attackTz := spec.Tz(attack.Nanoseconds() / masterTzDur.Nanoseconds())

Check failure on line 101 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

undefined: attack

Check failure on line 101 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

undefined: attack

Check failure on line 101 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

undefined: attack
decayTz := spec.Tz(decay.Nanoseconds() / masterTzDur.Nanoseconds())

Check failure on line 102 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

undefined: decay

Check failure on line 102 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

undefined: decay

Check failure on line 102 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

undefined: decay
releaseTz := spec.Tz(release.Nanoseconds() / masterTzDur.Nanoseconds())

Check failure on line 103 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

undefined: release

Check failure on line 103 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

undefined: release

Check failure on line 103 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

undefined: release
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)

Check failure on line 104 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.20)

undefined: sustainLevel

Check failure on line 104 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.22)

undefined: sustainLevel

Check failure on line 104 in lib/mix/mix.go

View workflow job for this annotation

GitHub Actions / Run Tests (1.21)

undefined: sustainLevel
mixReadyFires = append(mixReadyFires, f)
return f
}
Expand Down Expand Up @@ -192,6 +205,14 @@
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)
}
Expand Down
43 changes: 43 additions & 0 deletions lib/source/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,49 @@ 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 {
Comment thread
charneykaye marked this conversation as resolved.
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
Expand Down
Loading
Loading