mirror of
https://github.com/vsariola/sointu.git
synced 2026-02-02 13:50:15 -05:00
490 lines
14 KiB
Go
490 lines
14 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
|
|
|
|
// Preset returns a PresetModel, a view of the model used to manipulate
|
|
// instrument presets.
|
|
func (m *Model) Preset() *PresetModel { return (*PresetModel)(m) }
|
|
|
|
type PresetModel Model
|
|
|
|
// SearchTerm returns a String containing the search terms for finding the
|
|
// presets.
|
|
func (m *PresetModel) SearchTerm() String { return MakeString((*presetSearchTerm)(m)) }
|
|
|
|
type presetSearchTerm PresetModel
|
|
|
|
func (m *presetSearchTerm) Value() string { return m.d.PresetSearchString }
|
|
func (m *presetSearchTerm) SetValue(value string) bool {
|
|
if m.d.PresetSearchString == value {
|
|
return false
|
|
}
|
|
m.d.PresetSearchString = value
|
|
(*PresetModel)(m).updateCache()
|
|
return true
|
|
}
|
|
|
|
// NoGmDls returns a Bool toggling whether to show presets relying on gm.dls
|
|
// samples.
|
|
func (m *PresetModel) NoGmDls() Bool { return MakeBool((*presetNoGmDls)(m)) }
|
|
|
|
type presetNoGmDls PresetModel
|
|
|
|
func (m *presetNoGmDls) Value() bool { return m.presetData.cache.noGmDls }
|
|
func (m *presetNoGmDls) SetValue(val bool) {
|
|
if m.presetData.cache.noGmDls == val {
|
|
return
|
|
}
|
|
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "g:")
|
|
if val {
|
|
m.d.PresetSearchString = "g:n " + m.d.PresetSearchString
|
|
}
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
// UserPresetsFilter returns a Bool toggling whether to show the user defined
|
|
// presets.
|
|
func (m *PresetModel) UserFilter() Bool { return MakeBool((*userPresetsFilter)(m)) }
|
|
|
|
type userPresetsFilter PresetModel
|
|
|
|
func (m *userPresetsFilter) Value() bool { return m.presetData.cache.kind == UserPresets }
|
|
func (m *userPresetsFilter) SetValue(val bool) {
|
|
if (m.presetData.cache.kind == UserPresets) == val {
|
|
return
|
|
}
|
|
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
|
|
if val {
|
|
m.d.PresetSearchString = "t:u " + m.d.PresetSearchString
|
|
}
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
func (m *userPresetsFilter) Enabled() bool { return true }
|
|
|
|
// BuiltinFilter return a Bool toggling whether to show the built-in
|
|
// presets in the preset search results.
|
|
func (m *PresetModel) BuiltinFilter() Bool { return MakeBool((*builtinPresetsFilter)(m)) }
|
|
|
|
type builtinPresetsFilter PresetModel
|
|
|
|
func (m *builtinPresetsFilter) Value() bool { return m.presetData.cache.kind == BuiltinPresets }
|
|
func (m *builtinPresetsFilter) SetValue(val bool) {
|
|
if (m.presetData.cache.kind == BuiltinPresets) == val {
|
|
return
|
|
}
|
|
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "t:")
|
|
if val {
|
|
m.d.PresetSearchString = "t:b " + m.d.PresetSearchString
|
|
}
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
// ClearSearch returns an Action to clear the current preset search
|
|
// term(s).
|
|
func (m *PresetModel) ClearSearch() Action { return MakeAction((*clearPresetSearch)(m)) }
|
|
|
|
type clearPresetSearch PresetModel
|
|
|
|
func (m *clearPresetSearch) Enabled() bool { return len(m.d.PresetSearchString) > 0 }
|
|
func (m *clearPresetSearch) Do() {
|
|
m.d.PresetSearchString = ""
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
// PresetDirList return a List of all the different preset directories.
|
|
func (m *PresetModel) DirList() List { return MakeList((*presetDirList)(m)) }
|
|
|
|
type presetDirList PresetModel
|
|
|
|
func (m *presetDirList) Count() int { return len(m.presetData.dirs) + 1 }
|
|
func (m *presetDirList) Selected() int { return m.presetData.cache.dirIndex + 1 }
|
|
func (m *presetDirList) Selected2() int { return m.presetData.cache.dirIndex + 1 }
|
|
func (m *presetDirList) SetSelected2(i int) {}
|
|
func (m *presetDirList) SetSelected(i int) {
|
|
i = min(max(i, 0), len(m.presetData.dirs))
|
|
if i < 0 || i > len(m.presetData.dirs) {
|
|
return
|
|
}
|
|
m.d.PresetSearchString = removeFilters(m.d.PresetSearchString, "d:")
|
|
if i > 0 {
|
|
m.d.PresetSearchString = "d:" + m.presetData.dirs[i-1] + " " + m.d.PresetSearchString
|
|
}
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
// Dir returns the name of the directory at the given index in the preset
|
|
// directory list.
|
|
func (m *PresetModel) Dir(i int) string {
|
|
if i < 1 || i > len(m.presetData.dirs) {
|
|
return "---"
|
|
}
|
|
return m.presetData.dirs[i-1]
|
|
}
|
|
|
|
// SearchResultList returns a List of the current preset search results.
|
|
func (m *PresetModel) SearchResultList() List { return MakeList((*presetResultList)(m)) }
|
|
|
|
type presetResultList PresetModel
|
|
|
|
func (v *presetResultList) List() List { return List{v} }
|
|
func (m *presetResultList) Count() int { return len(m.presetData.cache.results) }
|
|
func (m *presetResultList) Selected() int {
|
|
return min(max(m.presetData.presetIndex, 0), len(m.presetData.cache.results)-1)
|
|
}
|
|
func (m *presetResultList) Selected2() int { return m.Selected() }
|
|
func (m *presetResultList) SetSelected2(i int) {}
|
|
func (m *presetResultList) SetSelected(i int) {
|
|
i = min(max(i, 0), len(m.presetData.cache.results)-1)
|
|
if i < 0 || i >= len(m.presetData.cache.results) {
|
|
return
|
|
}
|
|
m.presetData.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.presetData.cache.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
|
|
}
|
|
|
|
// SearchResult returns the search result at the given index in the search
|
|
// result list.
|
|
func (m *PresetModel) SearchResult(i int) (name string, dir string, user bool) {
|
|
if i < 0 || i >= len(m.presetData.cache.results) {
|
|
return "", "", false
|
|
}
|
|
p := m.presetData.cache.results[i]
|
|
return p.instr.Name, p.dir, p.user
|
|
}
|
|
|
|
// Save returns an Action to save the current instrument as a user-defined
|
|
// preset. It will not overwrite existing presets, but rather show a dialog to
|
|
// confirm the overwrite.
|
|
func (m *PresetModel) Save() Action { return MakeAction((*saveUserPreset)(m)) }
|
|
|
|
type saveUserPreset PresetModel
|
|
|
|
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.presetData.cache.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
|
|
}
|
|
(*PresetModel)(m).Overwrite().Do()
|
|
}
|
|
|
|
// OverwriteUserPreset returns an Action to overwrite the current instrument
|
|
// as a user-defined preset.
|
|
func (m *PresetModel) Overwrite() Action { return MakeAction((*overwriteUserPreset)(m)) }
|
|
|
|
type overwriteUserPreset PresetModel
|
|
|
|
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.presetData.cache.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
|
|
(*PresetModel)(m).presetData.load()
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
// TryDeleteUserPreset returns an Action to display a dialog to confirm deletion
|
|
// of an user preset.
|
|
func (m *PresetModel) Delete() Action { return MakeAction((*tryDeleteUserPreset)(m)) }
|
|
|
|
type tryDeleteUserPreset PresetModel
|
|
|
|
func (m *tryDeleteUserPreset) Do() { m.dialog = DeleteUserPresetDialog }
|
|
func (m *tryDeleteUserPreset) Enabled() bool {
|
|
if m.presetData.presetIndex < 0 || m.presetData.presetIndex >= len(m.presetData.cache.results) {
|
|
return false
|
|
}
|
|
return m.presetData.cache.results[m.presetData.presetIndex].user
|
|
}
|
|
|
|
// DeleteUserPreset returns an Action to confirm the deletion of an user preset.
|
|
func (m *PresetModel) ConfirmDelete() Action { return MakeAction((*deleteUserPreset)(m)) }
|
|
|
|
type deleteUserPreset PresetModel
|
|
|
|
func (m *deleteUserPreset) Enabled() bool { return (*Model)(m).Preset().Delete().Enabled() }
|
|
func (m *deleteUserPreset) Do() {
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return
|
|
}
|
|
p := m.presetData.cache.results[m.presetData.presetIndex]
|
|
userPresetsDir := filepath.Join(configDir, "sointu", "presets")
|
|
if p.dir != "" {
|
|
userPresetsDir = filepath.Join(userPresetsDir, p.dir)
|
|
}
|
|
name := instrumentNameToFilename(p.instr.Name)
|
|
fileName := filepath.Join(userPresetsDir, name+".yml")
|
|
os.Remove(fileName)
|
|
m.dialog = NoDialog
|
|
(*PresetModel)(m).presetData.load()
|
|
(*PresetModel)(m).updateCache()
|
|
}
|
|
|
|
type (
|
|
presetData struct {
|
|
presets []preset
|
|
dirs []string
|
|
presetIndex int
|
|
|
|
cache presetCache
|
|
}
|
|
|
|
preset struct {
|
|
dir string
|
|
user bool
|
|
needsGmDls bool
|
|
instr sointu.Instrument
|
|
}
|
|
|
|
presetCache 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 *PresetModel) updateCache() {
|
|
// reset derived data, keeping the
|
|
str := m.presetData.cache.searchStrings[:0]
|
|
m.presetData.cache = presetCache{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.presetData.cache.dir = dir
|
|
ind := slices.IndexFunc(m.presetData.dirs, func(c string) bool { return c == dir })
|
|
m.presetData.cache.dirIndex = ind
|
|
} else if strings.HasPrefix(part, "g:n") {
|
|
m.presetData.cache.noGmDls = true
|
|
} else if strings.HasPrefix(part, "t:") && len(part) > 2 {
|
|
val := strings.TrimSpace(part[2:3])
|
|
switch val {
|
|
case "b":
|
|
m.presetData.cache.kind = BuiltinPresets
|
|
case "u":
|
|
m.presetData.cache.kind = UserPresets
|
|
}
|
|
} else {
|
|
m.presetData.cache.searchStrings = append(m.presetData.cache.searchStrings, strings.ToLower(part))
|
|
}
|
|
}
|
|
// update results
|
|
m.presetData.cache.results = m.presetData.cache.results[:0]
|
|
for _, p := range m.presetData.presets {
|
|
if m.presetData.cache.kind == BuiltinPresets && p.user {
|
|
continue
|
|
}
|
|
if m.presetData.cache.kind == UserPresets && !p.user {
|
|
continue
|
|
}
|
|
if m.presetData.cache.dir != "" && p.dir != m.presetData.cache.dir {
|
|
continue
|
|
}
|
|
if m.presetData.cache.noGmDls && p.needsGmDls {
|
|
continue
|
|
}
|
|
if len(m.presetData.cache.searchStrings) == 0 {
|
|
goto found
|
|
}
|
|
for _, s := range m.presetData.cache.searchStrings {
|
|
if strings.Contains(strings.ToLower(p.instr.Name), s) {
|
|
goto found
|
|
}
|
|
}
|
|
continue
|
|
found:
|
|
m.presetData.cache.results = append(m.presetData.cache.results, p)
|
|
}
|
|
}
|
|
|
|
//go:embed presets/*
|
|
var builtInPresetsFS embed.FS
|
|
|
|
func (m *presetData) load() {
|
|
m.dirs = m.dirs[:0]
|
|
m.presets = m.presets[:0]
|
|
seenDir := make(map[string]bool)
|
|
m.loadPresetsFromFs(builtInPresetsFS, 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 *presetData) 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{
|
|
dir: 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 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 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 presetData) Len() int { return len(p.presets) }
|
|
func (p presetData) 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 presetData) Swap(i, j int) { p.presets[i], p.presets[j] = p.presets[j], p.presets[i] }
|