diff --git a/tracker/gioui/songpanel.go b/tracker/gioui/songpanel.go index bbb5bf0..acf5701 100644 --- a/tracker/gioui/songpanel.go +++ b/tracker/gioui/songpanel.go @@ -218,6 +218,6 @@ func (t *Tracker) layoutSongOptions(gtx C) D { gtx.Constraints.Min = image.Pt(0, 0) return recordBtnStyle.Layout(gtx) }), - layout.Rigid(VuMeter{Volume: t.lastVolume, Range: 100}.Layout), + layout.Rigid(VuMeter{AverageVolume: t.lastAvgVolume, PeakVolume: t.lastPeakVolume, Range: 100}.Layout), ) } diff --git a/tracker/gioui/tracker.go b/tracker/gioui/tracker.go index 68313da..868a59b 100644 --- a/tracker/gioui/tracker.go +++ b/tracker/gioui/tracker.go @@ -55,7 +55,8 @@ type Tracker struct { TrackEditor *TrackEditor Explorer *explorer.Explorer - lastVolume tracker.Volume + lastAvgVolume tracker.Volume + lastPeakVolume tracker.Volume wavFilePath string quitChannel chan struct{} @@ -198,7 +199,8 @@ mainloop: if err, ok := e.Inner.(tracker.PlayerVolumeErrorMessage); ok { t.Alert.Update(err.Error(), Warning, time.Second*3) } - t.lastVolume = e.Volume + t.lastAvgVolume = e.AverageVolume + t.lastPeakVolume = e.PeakVolume t.InstrumentEditor.voiceStates = e.VoiceStates t.ProcessPlayerMessage(e) w.Invalidate() diff --git a/tracker/gioui/vumeter.go b/tracker/gioui/vumeter.go index a1a1b4f..aeb4d87 100644 --- a/tracker/gioui/vumeter.go +++ b/tracker/gioui/vumeter.go @@ -11,8 +11,9 @@ import ( ) type VuMeter struct { - Volume tracker.Volume - Range float32 + AverageVolume tracker.Volume + PeakVolume tracker.Volume + Range float32 } func (v VuMeter) Layout(gtx C) D { @@ -20,7 +21,7 @@ func (v VuMeter) Layout(gtx C) D { gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(12)) height := gtx.Dp(unit.Dp(6)) for j := 0; j < 2; j++ { - value := float32(v.Volume.Average[j]) + v.Range + value := float32(v.AverageVolume[j]) + v.Range if value > 0 { x := int(value/v.Range*float32(gtx.Constraints.Max.X) + 0.5) if x > gtx.Constraints.Max.X { @@ -28,7 +29,7 @@ func (v VuMeter) Layout(gtx C) D { } paint.FillShape(gtx.Ops, mediumEmphasisTextColor, clip.Rect(image.Rect(0, 0, x, height)).Op()) } - valueMax := float32(v.Volume.Peak[j]) + v.Range + valueMax := float32(v.PeakVolume[j]) + v.Range if valueMax > 0 { color := white if valueMax >= v.Range { diff --git a/tracker/player.go b/tracker/player.go index 71a516f..6f3360e 100644 --- a/tracker/player.go +++ b/tracker/player.go @@ -21,7 +21,8 @@ type ( samplesSinceEvent []int samplesPerRow int bpm int - volume Volume + avgVolumeMeter VolumeAnalyzer + peakVolumeMeter VolumeAnalyzer voiceStates [vm.MAX_VOICES]float32 recording bool @@ -59,10 +60,11 @@ type ( // Volume and SongRow are transmitted so frequently that they are treated specially, to avoid boxing. All the // rest messages can be boxed to interface{} PlayerMessage struct { - Volume Volume - SongRow SongRow - VoiceStates [vm.MAX_VOICES]float32 - Inner interface{} + AverageVolume Volume + PeakVolume Volume + SongRow SongRow + VoiceStates [vm.MAX_VOICES]float32 + Inner interface{} } PlayerCrashMessage struct { @@ -87,10 +89,11 @@ const NUM_RENDER_TRIES = 10000 func NewPlayer(synther sointu.Synther, playerMessages chan<- PlayerMessage, modelMessages <-chan interface{}) *Player { p := &Player{ - playerMessages: playerMessages, - modelMessages: modelMessages, - synther: synther, - volume: Volume{Average: [2]float64{1e-9, 1e-9}, Peak: [2]float64{1e-9, 1e-9}}, + playerMessages: playerMessages, + modelMessages: modelMessages, + synther: synther, + avgVolumeMeter: VolumeAnalyzer{Attack: 0.3, Release: 0.3, Min: -100, Max: 20}, + peakVolumeMeter: VolumeAnalyzer{Attack: 1e-4, Release: 1, Min: -100, Max: 20}, } return p } @@ -170,11 +173,15 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext } // when the buffer is full, return if len(buffer) == 0 { - err := p.volume.Analyze(oldBuffer, 0.3, 1e-4, 1, -100, 20) + err := p.avgVolumeMeter.Update(oldBuffer) + err2 := p.peakVolumeMeter.Update(oldBuffer) var msg interface{} if err != nil { msg = PlayerVolumeErrorMessage{err} } + if err2 != nil { + msg = PlayerVolumeErrorMessage{err} + } p.trySend(msg) return } @@ -323,7 +330,7 @@ func (p *Player) compileOrUpdateSynth() { // all sends from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock func (p *Player) trySend(message interface{}) { select { - case p.playerMessages <- PlayerMessage{Volume: p.volume, SongRow: p.position, VoiceStates: p.voiceStates, Inner: message}: + case p.playerMessages <- PlayerMessage{AverageVolume: p.avgVolumeMeter.Level, PeakVolume: p.peakVolumeMeter.Level, SongRow: p.position, VoiceStates: p.voiceStates, Inner: message}: default: } } diff --git a/tracker/volume.go b/tracker/volume.go index e00da42..fb079a3 100644 --- a/tracker/volume.go +++ b/tracker/volume.go @@ -7,53 +7,61 @@ import ( "github.com/vsariola/sointu" ) -// Volume represents an average and peak volume measurement, in decibels. 0 dB = -// signal level of +-1. -type Volume struct { - Average [2]float64 - Peak [2]float64 -} +type ( + Volume [2]float64 -// Analyze updates Average and Peak fields, by analyzing the given buffer. + // VolumeAnalyzer measures the volume in an AudioBuffer, in decibels relative to + // full scale (0 dB = signal level of +-1) + VolumeAnalyzer struct { + Level Volume // current volume level of left and right channels + Attack float64 // attack time constant in seconds + Release float64 // release time constant in seconds + Min float64 // minimum volume in decibels + Max float64 // maximum volume in decibels + } +) + +var nanError = errors.New("NaN detected in master output") + +// Update updates the Level field, by analyzing the given buffer. // // Internally, it first converts the signal to decibels (0 dB = +-1). Then, the // average volume level is computed by smoothing the decibel values with a -// exponentially decaying average, with a time constant tau (in seconds). -// Typical value could be 0.3 (seconds). +// exponentially decaying average, with a time constant Attack (in seconds) if +// the decibel value is greater than current level and time constant Decay (in +// seconds) if the decibel value is less than current level. // -// Peak volume detection is similar exponential smoothing, but the time -// constants for attack and release are different. Generally attack << release. -// Typical values could be attack 1.5e-3 and release 1.5 (seconds) +// Typical time constants for average level detection would be 0.3 seconds for +// both attack and release. For peak level detection, attack could be 1.5e-3 and +// release 1.5 (seconds) // -// minVolume and maxVolume are hard limits in decibels to prevent negative +// MinVolume and MaxVolume are hard limits in decibels to prevent negative // infinities for volumes -func (v *Volume) Analyze(buffer sointu.AudioBuffer, tau float64, attack float64, release float64, minVolume float64, maxVolume float64) error { - alpha := 1 - math.Exp(-1.0/(tau*44100)) // from https://en.wikipedia.org/wiki/Exponential_smoothing - alphaAttack := 1 - math.Exp(-1.0/(attack*44100)) - alphaRelease := 1 - math.Exp(-1.0/(release*44100)) - var err error +func (v *VolumeAnalyzer) Update(buffer sointu.AudioBuffer) (err error) { + // from https://en.wikipedia.org/wiki/Exponential_smoothing + alphaAttack := 1 - math.Exp(-1.0/(v.Attack*44100)) + alphaRelease := 1 - math.Exp(-1.0/(v.Release*44100)) for j := 0; j < 2; j++ { for i := 0; i < len(buffer); i++ { sample2 := float64(buffer[i][j] * buffer[i][j]) if math.IsNaN(sample2) { if err == nil { - err = errors.New("NaN detected in master output") + err = nanError } continue } - dB := 10 * math.Log10(float64(sample2)) - if dB < minVolume || math.IsNaN(dB) { - dB = minVolume + dB := 10 * math.Log10(sample2) + if dB < v.Min || math.IsNaN(dB) { + dB = v.Min } - if dB > maxVolume { - dB = maxVolume + if dB > v.Max { + dB = v.Max } - v.Average[j] += (dB - v.Average[j]) * alpha - alphaPeak := alphaAttack - if dB < v.Peak[j] { - alphaPeak = alphaRelease + a := alphaAttack + if dB < v.Level[j] { + a = alphaRelease } - v.Peak[j] += (dB - v.Peak[j]) * alphaPeak + v.Level[j] += (dB - v.Level[j]) * a } } return err