Files
sointu/tracker/presets.go
2025-10-19 17:13:00 +03:00

551 lines
18 KiB
Go

package tracker
import (
"bytes"
"embed"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
"gopkg.in/yaml.v3"
)
//go:generate go run generate/gmdls_entries.go
//go:generate go run generate/clean_presets.go
//go:embed presets/*
var instrumentPresetFS embed.FS
type (
// GmDlsEntry is a single sample entry from the gm.dls file
GmDlsEntry struct {
Start int // sample start offset in words
LoopStart int // loop start offset in words
LoopLength int // loop length in words
SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch
Name string // sample Name
}
Preset struct {
Directory string
User bool
NeedsGmDls bool
Instr sointu.Instrument
}
Presets struct {
Presets []Preset
Dirs []string
}
InstrumentPresetYieldFunc func(index int, item string) (ok bool)
LoadPreset struct {
Index int
*Model
}
PresetSearchString Model
NoGmDlsFilter Model
BuiltinPresetsFilter Model
UserPresetsFilter Model
PresetDirectory Model
PresetKind Model
ClearPresetSearch Model
PresetDirList Model
PresetResultList Model
SaveUserPreset Model
TryDeleteUserPreset Model
DeleteUserPreset Model
ConfirmDeleteUserPresetAction Model
OverwriteUserPreset Model
derivedPresetSearch struct {
dir string
dirIndex int
noGmDls bool
kind PresetKindEnum
searchStrings []string
results []Preset
}
PresetKindEnum int
)
const (
BuiltinPresets PresetKindEnum = -1
AllPresets PresetKindEnum = 0
UserPresets PresetKindEnum = 1
)
func (m *Model) updateDerivedPresetSearch() {
// reset derived data, keeping the
str := m.derived.presetSearch.searchStrings[:0]
m.derived.presetSearch = derivedPresetSearch{searchStrings: str, dirIndex: -1}
// parse filters from the search string. in: dir, gmdls: yes/no, kind: builtin/user/all
search := strings.TrimSpace(m.d.PresetSearchString)
parts := strings.Fields(search)
// parse parts to see if they contain :
for _, part := range parts {
if strings.HasPrefix(part, "d:") && len(part) > 2 {
dir := strings.TrimSpace(part[2:])
m.derived.presetSearch.dir = dir
ind := slices.IndexFunc(m.presets.Dirs, func(c string) bool { return c == dir })
m.derived.presetSearch.dirIndex = ind
} else if strings.HasPrefix(part, "g:n") {
m.derived.presetSearch.noGmDls = true
} else if strings.HasPrefix(part, "t:") && len(part) > 2 {
val := strings.TrimSpace(part[2:3])
switch val {
case "b":
m.derived.presetSearch.kind = BuiltinPresets
case "u":
m.derived.presetSearch.kind = UserPresets
}
} else {
m.derived.presetSearch.searchStrings = append(m.derived.presetSearch.searchStrings, strings.ToLower(part))
}
}
// update results
m.derived.presetSearch.results = m.derived.presetSearch.results[:0]
for _, p := range m.presets.Presets {
if m.derived.presetSearch.kind == BuiltinPresets && p.User {
continue
}
if m.derived.presetSearch.kind == UserPresets && !p.User {
continue
}
if m.derived.presetSearch.dir != "" && p.Directory != m.derived.presetSearch.dir {
continue
}
if m.derived.presetSearch.noGmDls && p.NeedsGmDls {
continue
}
if len(m.derived.presetSearch.searchStrings) == 0 {
goto found
}
for _, s := range m.derived.presetSearch.searchStrings {
if strings.Contains(strings.ToLower(p.Instr.Name), s) {
goto found
}
}
continue
found:
m.derived.presetSearch.results = append(m.derived.presetSearch.results, p)
}
}
func (m *Presets) load() {
*m = Presets{}
seenDir := make(map[string]bool)
m.loadPresetsFromFs(instrumentPresetFS, false, seenDir)
if configDir, err := os.UserConfigDir(); err == nil {
userPresets := filepath.Join(configDir, "sointu")
m.loadPresetsFromFs(os.DirFS(userPresets), true, seenDir)
}
sort.Sort(m)
m.Dirs = make([]string, 0, len(seenDir))
for k := range seenDir {
m.Dirs = append(m.Dirs, k)
}
sort.Strings(m.Dirs)
}
func (m *Presets) loadPresetsFromFs(fsys fs.FS, userDefined bool, seenDir map[string]bool) {
fs.WalkDir(fsys, "presets", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
var instr sointu.Instrument
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if dec.Decode(&instr) == nil {
noExt := path[:len(path)-len(filepath.Ext(path))]
splitted := splitPath(noExt)
splitted = splitted[1:] // remove "presets" from the path
instr.Name = filenameToInstrumentName(splitted[len(splitted)-1])
dir := strings.Join(splitted[:len(splitted)-1], "/")
preset := Preset{
Directory: dir,
User: userDefined,
Instr: instr,
NeedsGmDls: checkNeedsGmDls(instr),
}
if dir != "" {
seenDir[dir] = true
}
m.Presets = append(m.Presets, preset)
}
return nil
})
}
func filenameToInstrumentName(filename string) string {
return strings.ReplaceAll(filename, "_", " ")
}
func instrumentNameToFilename(name string) string {
// remove all special characters
reg, _ := regexp.Compile("[^a-zA-Z0-9 _]+")
name = reg.ReplaceAllString(name, "")
name = strings.ReplaceAll(name, " ", "_")
return name
}
func checkNeedsGmDls(instr sointu.Instrument) bool {
for _, u := range instr.Units {
if u.Type == "oscillator" {
if u.Parameters["type"] == sointu.Sample {
return true
}
}
}
return false
}
func (m *Model) PresetSearchString() String { return MakeString((*PresetSearchString)(m)) }
func (m *PresetSearchString) Value() string { return m.d.PresetSearchString }
func (m *PresetSearchString) SetValue(value string) bool {
if m.d.PresetSearchString == value {
return false
}
m.d.PresetSearchString = value
(*Model)(m).updateDerivedPresetSearch()
return true
}
func (m *Model) NoGmDls() Bool { return MakeBool((*NoGmDlsFilter)(m)) }
func (m *NoGmDlsFilter) Value() bool { return m.derived.presetSearch.noGmDls }
func (m *NoGmDlsFilter) SetValue(val bool) {
if m.derived.presetSearch.noGmDls == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "g:")
if val {
m.d.PresetSearchString = "g:n " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *NoGmDlsFilter) Enabled() bool { return true }
func (m *Model) UserPresetFilter() Bool { return MakeBool((*UserPresetsFilter)(m)) }
func (m *UserPresetsFilter) Value() bool { return m.derived.presetSearch.kind == UserPresets }
func (m *UserPresetsFilter) SetValue(val bool) {
if (m.derived.presetSearch.kind == UserPresets) == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
if val {
m.d.PresetSearchString = "t:u " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *UserPresetsFilter) Enabled() bool { return true }
func (m *Model) BuiltinPresetsFilter() Bool { return MakeBool((*BuiltinPresetsFilter)(m)) }
func (m *BuiltinPresetsFilter) Value() bool { return m.derived.presetSearch.kind == BuiltinPresets }
func (m *BuiltinPresetsFilter) SetValue(val bool) {
if (m.derived.presetSearch.kind == BuiltinPresets) == val {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
if val {
m.d.PresetSearchString = "t:b " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *BuiltinPresetsFilter) Enabled() bool { return true }
func (m *Model) ClearPresetSearch() Action { return MakeAction((*ClearPresetSearch)(m)) }
func (m *ClearPresetSearch) Enabled() bool { return len(m.d.PresetSearchString) > 0 }
func (m *ClearPresetSearch) Do() {
m.d.PresetSearchString = ""
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) PresetDirList() *PresetDirList { return (*PresetDirList)(m) }
func (v *PresetDirList) List() List { return List{v} }
func (m *PresetDirList) Count() int { return len(m.presets.Dirs) + 1 }
func (m *PresetDirList) Selected() int { return m.derived.presetSearch.dirIndex + 1 }
func (m *PresetDirList) Selected2() int { return m.derived.presetSearch.dirIndex + 1 }
func (m *PresetDirList) SetSelected2(i int) {}
func (m *PresetDirList) Value(i int) string {
if i < 1 || i > len(m.presets.Dirs) {
return "---"
}
return m.presets.Dirs[i-1]
}
func (m *PresetDirList) SetSelected(i int) {
i = min(max(i, 0), len(m.presets.Dirs))
if i < 0 || i > len(m.presets.Dirs) {
return
}
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "d:")
if i > 0 {
m.d.PresetSearchString = "d:" + m.presets.Dirs[i-1] + " " + m.d.PresetSearchString
}
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) PresetResultList() *PresetResultList { return (*PresetResultList)(m) }
func (v *PresetResultList) List() List { return List{v} }
func (m *PresetResultList) Count() int { return len(m.derived.presetSearch.results) }
func (m *PresetResultList) Selected() int {
return min(max(m.presetIndex, 0), len(m.derived.presetSearch.results)-1)
}
func (m *PresetResultList) Selected2() int { return m.Selected() }
func (m *PresetResultList) SetSelected2(i int) {}
func (m *PresetResultList) Value(i int) (name string, dir string, user bool) {
if i < 0 || i >= len(m.derived.presetSearch.results) {
return "", "", false
}
p := m.derived.presetSearch.results[i]
return p.Instr.Name, p.Directory, p.User
}
func (m *PresetResultList) SetSelected(i int) {
i = min(max(i, 0), len(m.derived.presetSearch.results)-1)
if i < 0 || i >= len(m.derived.presetSearch.results) {
return
}
m.presetIndex = i
defer (*Model)(m).change("LoadPreset", PatchChange, MinorChange)()
if m.d.InstrIndex < 0 {
m.d.InstrIndex = 0
}
m.d.InstrIndex2 = m.d.InstrIndex
for m.d.InstrIndex >= len(m.d.Song.Patch) {
m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy())
}
newInstr := m.derived.presetSearch.results[i].Instr.Copy()
newInstr.NumVoices = clamp(m.d.Song.Patch[m.d.InstrIndex].NumVoices, 1, vm.MAX_VOICES)
(*Model)(m).assignUnitIDs(newInstr.Units)
m.d.Song.Patch[m.d.InstrIndex] = newInstr
}
func removeFilters(str string, prefix string) string {
parts := strings.Split(str, " ")
newParts := make([]string, 0, len(parts))
for _, part := range parts {
if !strings.HasPrefix(strings.ToLower(part), prefix) {
newParts = append(newParts, part)
}
}
return strings.Join(newParts, " ")
}
func (m *Model) SaveAsUserPreset() Action { return MakeAction((*SaveUserPreset)(m)) }
func (m *SaveUserPreset) Enabled() bool {
return m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch)
}
func (m *SaveUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
userPresetsDir := filepath.Join(configDir, "sointu", "presets", m.derived.presetSearch.dir)
instr := m.d.Song.Patch[m.d.InstrIndex]
name := instrumentNameToFilename(instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
// if exists, do not overwrite
if _, err := os.Stat(fileName); err == nil {
m.dialog = OverwriteUserPresetDialog
return
}
(*Model)(m).OverwriteUserPreset().Do()
}
func (m *Model) OverwriteUserPreset() Action { return MakeAction((*OverwriteUserPreset)(m)) }
func (m *OverwriteUserPreset) Enabled() bool { return true }
func (m *OverwriteUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
userPresetsDir := filepath.Join(configDir, "sointu", "presets", m.derived.presetSearch.dir)
instr := m.d.Song.Patch[m.d.InstrIndex]
name := instrumentNameToFilename(instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
os.MkdirAll(userPresetsDir, 0755)
data, err := yaml.Marshal(&instr)
if err != nil {
return
}
os.WriteFile(fileName, data, 0644)
m.dialog = NoDialog
(*Model)(m).presets.load()
(*Model)(m).updateDerivedPresetSearch()
}
func (m *Model) TryDeleteUserPreset() Action { return MakeAction((*TryDeleteUserPreset)(m)) }
func (m *TryDeleteUserPreset) Do() { m.dialog = DeleteUserPresetDialog }
func (m *TryDeleteUserPreset) Enabled() bool {
if m.presetIndex < 0 || m.presetIndex >= len(m.derived.presetSearch.results) {
return false
}
return m.derived.presetSearch.results[m.presetIndex].User
}
func (m *Model) DeleteUserPreset() Action { return MakeAction((*DeleteUserPreset)(m)) }
func (m *DeleteUserPreset) Enabled() bool { return (*Model)(m).TryDeleteUserPreset().Enabled() }
func (m *DeleteUserPreset) Do() {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
p := m.derived.presetSearch.results[m.presetIndex]
userPresetsDir := filepath.Join(configDir, "sointu", "presets")
if p.Directory != "" {
userPresetsDir = filepath.Join(userPresetsDir, p.Directory)
}
name := instrumentNameToFilename(p.Instr.Name)
fileName := filepath.Join(userPresetsDir, name+".yml")
os.Remove(fileName)
m.dialog = NoDialog
(*Model)(m).presets.load()
(*Model)(m).updateDerivedPresetSearch()
}
// gmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the
// GmDlsEntries list based on the sample offset. Do not modify during runtime.
var gmDlsEntryMap = make(map[vm.SampleOffset]int)
func init() {
for i, e := range GmDlsEntries {
key := vm.SampleOffset{Start: uint32(e.Start), LoopStart: uint16(e.LoopStart), LoopLength: uint16(e.LoopLength)}
gmDlsEntryMap[key] = i
}
}
var defaultUnits = map[string]sointu.Unit{
"envelope": {Type: "envelope", Parameters: map[string]int{"stereo": 0, "attack": 64, "decay": 64, "sustain": 64, "release": 64, "gain": 64}},
"oscillator": {Type: "oscillator", Parameters: map[string]int{"stereo": 0, "transpose": 64, "detune": 64, "phase": 0, "color": 64, "shape": 64, "gain": 64, "type": sointu.Sine}},
"noise": {Type: "noise", Parameters: map[string]int{"stereo": 0, "shape": 64, "gain": 64}},
"mulp": {Type: "mulp", Parameters: map[string]int{"stereo": 0}},
"mul": {Type: "mul", Parameters: map[string]int{"stereo": 0}},
"add": {Type: "add", Parameters: map[string]int{"stereo": 0}},
"addp": {Type: "addp", Parameters: map[string]int{"stereo": 0}},
"push": {Type: "push", Parameters: map[string]int{"stereo": 0}},
"pop": {Type: "pop", Parameters: map[string]int{"stereo": 0}},
"xch": {Type: "xch", Parameters: map[string]int{"stereo": 0}},
"receive": {Type: "receive", Parameters: map[string]int{"stereo": 0}},
"loadnote": {Type: "loadnote", Parameters: map[string]int{"stereo": 0}},
"loadval": {Type: "loadval", Parameters: map[string]int{"stereo": 0, "value": 64}},
"pan": {Type: "pan", Parameters: map[string]int{"stereo": 0, "panning": 64}},
"gain": {Type: "gain", Parameters: map[string]int{"stereo": 0, "gain": 64}},
"invgain": {Type: "invgain", Parameters: map[string]int{"stereo": 0, "invgain": 64}},
"dbgain": {Type: "dbgain", Parameters: map[string]int{"stereo": 0, "decibels": 64}},
"crush": {Type: "crush", Parameters: map[string]int{"stereo": 0, "resolution": 64}},
"clip": {Type: "clip", Parameters: map[string]int{"stereo": 0}},
"hold": {Type: "hold", Parameters: map[string]int{"stereo": 0, "holdfreq": 64}},
"distort": {Type: "distort", Parameters: map[string]int{"stereo": 0, "drive": 64}},
"filter": {Type: "filter", Parameters: map[string]int{"stereo": 0, "frequency": 64, "resonance": 64, "lowpass": 1, "bandpass": 0, "highpass": 0}},
"out": {Type: "out", Parameters: map[string]int{"stereo": 1, "gain": 64}},
"outaux": {Type: "outaux", Parameters: map[string]int{"stereo": 1, "outgain": 64, "auxgain": 64}},
"aux": {Type: "aux", Parameters: map[string]int{"stereo": 1, "gain": 64, "channel": 2}},
"delay": {Type: "delay",
Parameters: map[string]int{"damp": 0, "dry": 128, "feedback": 96, "notetracking": 2, "pregain": 40, "stereo": 0},
VarArgs: []int{48}},
"in": {Type: "in", Parameters: map[string]int{"stereo": 1, "channel": 2}},
"speed": {Type: "speed", Parameters: map[string]int{}},
"compressor": {Type: "compressor", Parameters: map[string]int{"stereo": 0, "attack": 64, "release": 64, "invgain": 64, "threshold": 64, "ratio": 64}},
"send": {Type: "send", Parameters: map[string]int{"stereo": 0, "amount": 64, "voice": 0, "unit": 0, "port": 0, "sendpop": 1}},
"sync": {Type: "sync", Parameters: map[string]int{}},
}
var defaultInstrument = sointu.Instrument{
Name: "Instr",
NumVoices: 1,
Units: []sointu.Unit{
defaultUnits["envelope"],
defaultUnits["oscillator"],
defaultUnits["mulp"],
defaultUnits["delay"],
defaultUnits["pan"],
defaultUnits["outaux"],
},
}
var defaultSong = sointu.Song{
BPM: 100,
RowsPerBeat: 4,
Score: sointu.Score{
RowsPerPattern: 16,
Length: 1,
Tracks: []sointu.Track{
{NumVoices: 1, Order: sointu.Order{0}, Patterns: []sointu.Pattern{{72, 0}}},
},
},
Patch: sointu.Patch{defaultInstrument,
{Name: "Global", NumVoices: 1, Units: []sointu.Unit{
defaultUnits["in"],
{Type: "delay",
Parameters: map[string]int{"damp": 64, "dry": 128, "feedback": 125, "notetracking": 0, "pregain": 40, "stereo": 1},
VarArgs: []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618,
1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642,
}},
{Type: "out", Parameters: map[string]int{"stereo": 1, "gain": 128}},
}}},
}
var reverbs = []delayPreset{
{"stereo", 1, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618,
1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642,
}},
{"left", 0, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618}},
{"right", 0, []int{1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642}},
}
type delayPreset struct {
name string
stereo int
varArgs []int
}
func splitPath(path string) []string {
subPath := path
var result []string
for {
subPath = filepath.Clean(subPath) // Amongst others, removes trailing slashes (except for the root directory).
dir, last := filepath.Split(subPath)
if last == "" {
if dir != "" { // Root directory.
result = append(result, dir)
}
break
}
result = append(result, last)
if dir == "" { // Nothing to split anymore.
break
}
subPath = dir
}
slices.Reverse(result)
return result
}
func (p Presets) Len() int { return len(p.Presets) }
func (p Presets) Less(i, j int) bool {
if p.Presets[i].Instr.Name == p.Presets[j].Instr.Name {
return p.Presets[i].User && !p.Presets[j].User
}
return p.Presets[i].Instr.Name < p.Presets[j].Instr.Name
}
func (p Presets) Swap(i, j int) { p.Presets[i], p.Presets[j] = p.Presets[j], p.Presets[i] }