feat(tracker): plot envelope shape in scope when envelope selected

This commit is contained in:
5684185+vsariola@users.noreply.github.com
2026-01-31 20:51:49 +02:00
parent 287bd036a6
commit 6e8acc8f9b
3 changed files with 79 additions and 1 deletions

View File

@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Plot the envelope shape on top of the oscilloscope when the envelope unit is
selected.
- Spectrum analyzer showing the spectrum. When the user has a filter or belleq
unit selected, it's frequency response is plotted on top. ([#67][i67])
- belleq unit: a bell-shaped second-order filter for equalization. Belleq unit

View File

@ -58,12 +58,19 @@ func (s *Oscilloscope) Layout(gtx C) D {
w := t.Scope().Waveform()
cx := float32(w.Cursor) / float32(len(w.Buffer))
env, envOk := t.Scope().Envelope()
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
x1 := max(int(xr.a*float32(len(w.Buffer))), 0)
x2 := min(int(xr.b*float32(len(w.Buffer))), len(w.Buffer)-1)
if x1 > x2 {
return plotRange{}, false
}
if chn == 2 && envOk {
y1 := env.Value(x1)
y2 := env.Value(x2)
return plotRange{-max(y1, y2), -min(y1, y2)}, true
}
step := max((x2-x1)/1000, 1) // if the range is too large, sample only ~ 1000 points
y1 := float32(math.Inf(-1))
y2 := float32(math.Inf(+1))
@ -101,7 +108,12 @@ func (s *Oscilloscope) Layout(gtx C) D {
yield(1, "")
}
return s.State.plot.Layout(gtx, data, xticks, yticks, cx, 2)
numChannels := 2
if envOk {
numChannels = 3
}
return s.State.plot.Layout(gtx, data, xticks, yticks, cx, numChannels)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,

View File

@ -1,6 +1,7 @@
package tracker
import (
"math"
"strconv"
"github.com/vsariola/sointu"
@ -68,6 +69,69 @@ func (s *scopeTriggerChannel) StringOf(value int) string {
// Waveform returns the oscilloscope waveform buffer.
func (s *ScopeModel) Waveform() RingBuffer[[2]float32] { return s.scopeData.waveForm }
func (s *ScopeModel) Envelope() (Envelope, bool) {
i := s.d.InstrIndex
u := s.d.UnitIndex
if i < 0 || i >= len(s.d.Song.Patch) || u < 0 || u >= len(s.d.Song.Patch[i].Units) || s.d.Song.Patch[i].Units[u].Type != "envelope" {
return Envelope{}, false
}
var ret Envelope = Envelope{{Position: math.MaxInt}, {Position: math.MaxInt}, {Position: math.MaxInt}, {Position: math.MaxInt}, {Position: math.MaxInt}}
releasePos := len(s.scopeData.waveForm.Buffer) / 2
p := s.d.Song.Patch[s.d.InstrIndex].Units[s.d.UnitIndex].Parameters
attack := nonLinearMap((float32)(p["attack"]) / 128.0)
decay := nonLinearMap((float32)(p["decay"]) / 128.0)
sustain := (float32)(p["sustain"]) / 128.0
release := nonLinearMap((float32)(p["release"]) / 128.0)
gain := (float32)(p["gain"]) / 128.0
curpos := 0
for i := 0; i < 3; i++ {
var nextpos int
switch i {
case 0:
ret[i] = EnvelopePoint{Position: curpos, Level: 0, Slope: attack * gain}
nextpos = curpos + int(math.Ceil(float64(1/attack)))
case 1:
ret[i] = EnvelopePoint{Position: curpos, Level: gain, Slope: -decay * gain}
nextpos = curpos + int(math.Ceil(float64((1-sustain)/decay)))
case 2:
ret[i] = EnvelopePoint{Position: curpos, Level: sustain * gain, Slope: 0}
nextpos = math.MaxInt
}
if nextpos >= releasePos {
v := ret[i].Level + ret[i].Slope*float32(releasePos-curpos)
ret[i+1] = EnvelopePoint{Position: releasePos, Level: v, Slope: -release * gain}
ret[i+2] = EnvelopePoint{Position: releasePos + int(math.Ceil(float64(v/(release*gain)))), Level: 0, Slope: 0}
break
}
curpos = nextpos
}
return ret, true
}
func nonLinearMap(value float32) float32 {
return float32(math.Exp2(float64(-24 * value)))
}
type Envelope [5]EnvelopePoint
type EnvelopePoint struct {
Position int
Level, Slope float32
}
func (e *Envelope) Value(position int) float32 {
for i := len(e) - 1; i >= 0; i-- {
if position >= e[i].Position {
return e[i].Value(position + 1)
}
}
return 0
}
func (e *EnvelopePoint) Value(position int) float32 {
return e.Level + e.Slope*float32(position-e.Position)
}
// processAudioBuffer fills the oscilloscope buffer with audio data from the
// given buffer.
func (s *ScopeModel) processAudioBuffer(bufPtr *sointu.AudioBuffer) {