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
122 changes: 122 additions & 0 deletions docs/PITCH_SHIFTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 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
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)
}
```

### 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)
```
```
Comment thread
charneykaye marked this conversation as resolved.
Outdated
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=
84 changes: 59 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,18 @@ 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
// 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 {
Expand All @@ -68,7 +83,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 +197,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
}
Loading