mirror of
https://github.com/vsariola/sointu.git
synced 2025-05-27 19:00:25 -04:00
491 lines
12 KiB
Go
491 lines
12 KiB
Go
package tracker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/vsariola/sointu"
|
|
"github.com/vsariola/sointu/vm"
|
|
)
|
|
|
|
// Model implements the mutable state for the tracker program GUI.
|
|
//
|
|
// Go does not have immutable slices, so there's no efficient way to guarantee
|
|
// accidental mutations in the song. But at least the value members are
|
|
// protected.
|
|
// It is owned by the GUI thread (goroutine), while the player is owned by
|
|
// by the audioprocessing thread. They communicate using the two channels
|
|
type (
|
|
// modelData is the part of the model that gets save to recovery file
|
|
modelData struct {
|
|
Song sointu.Song
|
|
Cursor, Cursor2 Cursor
|
|
LowNibble bool
|
|
InstrIndex, InstrIndex2 int
|
|
UnitIndex, UnitIndex2 int
|
|
ParamIndex int
|
|
UnitSearchIndex int
|
|
UnitSearchString string
|
|
UnitSearching bool
|
|
Octave int
|
|
Step int
|
|
FilePath string
|
|
ChangedSinceSave bool
|
|
RecoveryFilePath string
|
|
ChangedSinceRecovery bool
|
|
Loop Loop
|
|
}
|
|
|
|
Model struct {
|
|
d modelData
|
|
|
|
instrEnlarged bool
|
|
commentExpanded bool
|
|
|
|
prevUndoKind string
|
|
undoSkipCounter int
|
|
undoStack []modelData
|
|
redoStack []modelData
|
|
|
|
changeLevel int
|
|
changeCancel bool
|
|
changeSeverity ChangeSeverity
|
|
changeType ChangeType
|
|
|
|
panic bool
|
|
recording bool
|
|
playing bool
|
|
playPosition sointu.SongPos
|
|
noteTracking bool
|
|
quitted bool
|
|
|
|
cachePatternUseCount [][]int
|
|
|
|
voiceLevels [vm.MAX_VOICES]float32
|
|
avgVolume Volume
|
|
peakVolume Volume
|
|
|
|
alerts []Alert
|
|
dialog Dialog
|
|
synther sointu.Synther // the synther used to create new synths
|
|
|
|
PlayerMessages chan PlayerMsg
|
|
modelMessages chan<- interface{}
|
|
}
|
|
|
|
// Cursor identifies a row and a track in a song score.
|
|
Cursor struct {
|
|
Track int
|
|
sointu.SongPos
|
|
}
|
|
|
|
// Loop identifier the order rows, which are the loop positions
|
|
// Length = 0 means no loop is chosen, regardless of start
|
|
Loop struct {
|
|
Start, Length int
|
|
}
|
|
|
|
Explore struct {
|
|
IsSave bool // true if this is a save operation, false if open operation
|
|
IsSong bool // true if this is a song, false if instrument
|
|
Continuation func(string) // function to call with the selected file path
|
|
}
|
|
|
|
// Describes a note triggered either a track or an instrument
|
|
// If Go had union or Either types, this would be it, but in absence
|
|
// those, this uses a boolean to define if the instrument is defined or the track
|
|
NoteID struct {
|
|
IsInstr bool
|
|
Instr int
|
|
Track int
|
|
Note byte
|
|
|
|
model *Model
|
|
}
|
|
|
|
IsPlayingMsg struct{ bool }
|
|
StartPlayMsg struct{ sointu.SongPos }
|
|
BPMMsg struct{ int }
|
|
RowsPerBeatMsg struct{ int }
|
|
PanicMsg struct{ bool }
|
|
RecordingMsg struct{ bool }
|
|
NoteOnMsg struct{ NoteID }
|
|
NoteOffMsg struct{ NoteID }
|
|
|
|
ChangeSeverity int
|
|
ChangeType int
|
|
|
|
Dialog int
|
|
)
|
|
|
|
const (
|
|
MajorChange ChangeSeverity = iota
|
|
MinorChange
|
|
)
|
|
|
|
const (
|
|
NoChange ChangeType = 0
|
|
PatchChange ChangeType = 1 << iota
|
|
ScoreChange
|
|
BPMChange
|
|
RowsPerBeatChange
|
|
LoopChange
|
|
SongChange ChangeType = PatchChange | ScoreChange | BPMChange | RowsPerBeatChange
|
|
)
|
|
|
|
const (
|
|
NoDialog = iota
|
|
SaveAsExplorer
|
|
NewSongChanges
|
|
NewSongSaveExplorer
|
|
OpenSongChanges
|
|
OpenSongSaveExplorer
|
|
OpenSongOpenExplorer
|
|
Export
|
|
ExportFloatExplorer
|
|
ExportInt16Explorer
|
|
QuitChanges
|
|
QuitSaveExplorer
|
|
)
|
|
|
|
const maxUndo = 64
|
|
|
|
func (m *Model) AverageVolume() Volume { return m.avgVolume }
|
|
func (m *Model) PeakVolume() Volume { return m.peakVolume }
|
|
func (m *Model) PlayPosition() sointu.SongPos { return m.playPosition }
|
|
func (m *Model) Loop() Loop { return m.d.Loop }
|
|
func (m *Model) PlaySongRow() int { return m.d.Song.Score.SongRow(m.playPosition) }
|
|
func (m *Model) ChangedSinceSave() bool { return m.d.ChangedSinceSave }
|
|
func (m *Model) Dialog() Dialog { return m.dialog }
|
|
func (m *Model) Quitted() bool { return m.quitted }
|
|
|
|
// NewModelPlayer creates a new model and a player that communicates with it
|
|
func NewModelPlayer(synther sointu.Synther, recoveryFilePath string) (*Model, *Player) {
|
|
m := new(Model)
|
|
m.synther = synther
|
|
modelMessages := make(chan interface{}, 1024)
|
|
playerMessages := make(chan PlayerMsg, 1024)
|
|
m.modelMessages = modelMessages
|
|
m.PlayerMessages = playerMessages
|
|
m.d.Octave = 4
|
|
m.d.RecoveryFilePath = recoveryFilePath
|
|
m.resetSong()
|
|
if recoveryFilePath != "" {
|
|
if bytes2, err := os.ReadFile(m.d.RecoveryFilePath); err == nil {
|
|
json.Unmarshal(bytes2, &m.d)
|
|
}
|
|
}
|
|
p := &Player{
|
|
playerMsgs: playerMessages,
|
|
modelMsgs: modelMessages,
|
|
synther: synther,
|
|
song: m.d.Song.Copy(),
|
|
loop: m.d.Loop,
|
|
avgVolumeMeter: VolumeAnalyzer{Attack: 0.3, Release: 0.3, Min: -100, Max: 20},
|
|
peakVolumeMeter: VolumeAnalyzer{Attack: 1e-4, Release: 1, Min: -100, Max: 20},
|
|
}
|
|
p.compileOrUpdateSynth()
|
|
return m, p
|
|
}
|
|
|
|
func (m *Model) change(kind string, t ChangeType, severity ChangeSeverity) func() {
|
|
if m.changeLevel == 0 {
|
|
m.changeType = NoChange
|
|
m.undoStack = append(m.undoStack, m.d.Copy())
|
|
m.changeCancel = false
|
|
m.changeSeverity = severity
|
|
} else {
|
|
if m.changeSeverity < severity {
|
|
m.changeSeverity = severity
|
|
}
|
|
}
|
|
m.changeType |= t
|
|
m.changeLevel++
|
|
return func() {
|
|
m.changeLevel--
|
|
if m.changeLevel < 0 {
|
|
panic("changeLevel < 0, mismatched change() calls")
|
|
}
|
|
if m.changeLevel == 0 {
|
|
if m.changeCancel || m.d.Song.BPM <= 0 || m.d.Song.RowsPerBeat <= 0 || m.d.Song.Score.Length <= 0 {
|
|
// the change was cancelled or put the song in invalid state, so we don't save it
|
|
m.d = m.undoStack[len(m.undoStack)-1]
|
|
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
|
return
|
|
}
|
|
m.d.ChangedSinceSave = true
|
|
m.d.ChangedSinceRecovery = true
|
|
if m.changeType&ScoreChange != 0 {
|
|
m.updatePatternUseCount()
|
|
m.d.Cursor.SongPos = m.d.Song.Score.Wrap(m.d.Cursor.SongPos)
|
|
m.d.Cursor2.SongPos = m.d.Song.Score.Wrap(m.d.Cursor2.SongPos)
|
|
m.send(m.d.Song.Score.Copy())
|
|
}
|
|
if m.changeType&PatchChange != 0 {
|
|
m.d.InstrIndex = clamp(m.d.InstrIndex, 0, len(m.d.Song.Patch)-1)
|
|
m.d.InstrIndex2 = clamp(m.d.InstrIndex2, 0, len(m.d.Song.Patch)-1)
|
|
unitCount := 0
|
|
if m.d.InstrIndex >= 0 && m.d.InstrIndex < len(m.d.Song.Patch) {
|
|
unitCount = len(m.d.Song.Patch[m.d.InstrIndex].Units)
|
|
}
|
|
m.d.UnitIndex = clamp(m.d.UnitIndex, 0, unitCount-1)
|
|
m.d.UnitIndex2 = clamp(m.d.UnitIndex2, 0, unitCount-1)
|
|
m.d.UnitSearching = false // if we change anything in the patch, reset the unit searching
|
|
m.d.UnitSearchString = ""
|
|
m.send(m.d.Song.Patch.Copy())
|
|
}
|
|
if m.changeType&BPMChange != 0 {
|
|
m.send(BPMMsg{m.d.Song.BPM})
|
|
}
|
|
if m.changeType&RowsPerBeatChange != 0 {
|
|
m.send(RowsPerBeatMsg{m.d.Song.RowsPerBeat})
|
|
}
|
|
if m.changeType&LoopChange != 0 {
|
|
m.send(m.d.Loop)
|
|
}
|
|
m.undoSkipCounter++
|
|
var limit int
|
|
switch m.changeSeverity {
|
|
default:
|
|
case MajorChange:
|
|
limit = 1
|
|
case MinorChange:
|
|
limit = 10
|
|
}
|
|
if m.prevUndoKind == kind && m.undoSkipCounter < limit {
|
|
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
|
return
|
|
}
|
|
m.undoSkipCounter = 0
|
|
m.prevUndoKind = kind
|
|
m.redoStack = m.redoStack[:0]
|
|
if len(m.undoStack) > maxUndo {
|
|
copy(m.undoStack, m.undoStack[len(m.undoStack)-maxUndo:])
|
|
m.undoStack = m.undoStack[:maxUndo]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) MarshalRecovery() []byte {
|
|
out, err := json.Marshal(m.d)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if m.d.RecoveryFilePath != "" {
|
|
os.Remove(m.d.RecoveryFilePath)
|
|
}
|
|
m.d.ChangedSinceRecovery = false
|
|
return out
|
|
}
|
|
|
|
func (m *Model) SaveRecovery() error {
|
|
if !m.d.ChangedSinceRecovery {
|
|
return nil
|
|
}
|
|
if m.d.RecoveryFilePath == "" {
|
|
return errors.New("no backup file path")
|
|
}
|
|
out, err := json.Marshal(m.d)
|
|
if err != nil {
|
|
return fmt.Errorf("could not marshal recovery data: %w", err)
|
|
}
|
|
dir := filepath.Dir(m.d.RecoveryFilePath)
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
os.MkdirAll(dir, os.ModePerm)
|
|
}
|
|
file, err := os.Create(m.d.RecoveryFilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not create recovery file: %w", err)
|
|
}
|
|
_, err = file.Write(out)
|
|
if err != nil {
|
|
return fmt.Errorf("could not write recovery file: %w", err)
|
|
}
|
|
m.d.ChangedSinceRecovery = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) UnmarshalRecovery(bytes []byte) {
|
|
err := json.Unmarshal(bytes, &m.d)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if m.d.RecoveryFilePath != "" { // check if there's a recovery file on disk and load it instead
|
|
if bytes2, err := os.ReadFile(m.d.RecoveryFilePath); err == nil {
|
|
json.Unmarshal(bytes2, &m.d)
|
|
}
|
|
}
|
|
m.d.ChangedSinceRecovery = false
|
|
m.send(m.d.Song.Copy())
|
|
m.send(m.d.Loop)
|
|
m.updatePatternUseCount()
|
|
}
|
|
|
|
func (m *Model) ProcessPlayerMessage(msg PlayerMsg) {
|
|
m.playPosition = msg.SongPosition
|
|
m.voiceLevels = msg.VoiceLevels
|
|
m.avgVolume = msg.AverageVolume
|
|
m.peakVolume = msg.PeakVolume
|
|
if m.playing && m.noteTracking {
|
|
m.d.Cursor.SongPos = msg.SongPosition
|
|
m.d.Cursor2.SongPos = msg.SongPosition
|
|
}
|
|
m.panic = msg.Panic
|
|
switch e := msg.Inner.(type) {
|
|
case func():
|
|
e()
|
|
case Recording:
|
|
if e.BPM == 0 {
|
|
e.BPM = float64(m.d.Song.BPM)
|
|
}
|
|
score, err := e.Score(m.d.Song.Patch, m.d.Song.RowsPerBeat, m.d.Song.Score.RowsPerPattern)
|
|
if err != nil || score.Length <= 0 {
|
|
break
|
|
}
|
|
defer m.change("Recording", SongChange, MajorChange)()
|
|
m.d.Song.Score = score
|
|
m.d.Song.BPM = int(e.BPM + 0.5)
|
|
m.instrEnlarged = false
|
|
case Alert:
|
|
m.Alerts().AddAlert(e)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (m *Model) TrackNoteOn(track int, note byte) (id NoteID) {
|
|
id = NoteID{IsInstr: false, Track: track, Note: note, model: m}
|
|
m.send(NoteOnMsg{id})
|
|
return id
|
|
}
|
|
|
|
func (m *Model) InstrNoteOn(instr int, note byte) (id NoteID) {
|
|
id = NoteID{IsInstr: true, Instr: instr, Note: note, model: m}
|
|
m.send(NoteOnMsg{id})
|
|
return id
|
|
}
|
|
|
|
func (n NoteID) NoteOff() {
|
|
n.model.send(NoteOffMsg{n})
|
|
}
|
|
|
|
func (m *Model) FindUnit(id int) (instrIndex, unitIndex int, err error) {
|
|
// TODO: this only used for choosing send target; find a better way for this
|
|
return m.d.Song.Patch.FindUnit(id)
|
|
}
|
|
|
|
func (m *Model) Instrument(index int) sointu.Instrument {
|
|
// TODO: this only used for choosing send target; find a better way for this
|
|
// we make a copy just so that the gui can't accidentally modify the song
|
|
if index < 0 || index >= len(m.d.Song.Patch) {
|
|
return sointu.Instrument{}
|
|
}
|
|
return m.d.Song.Patch[index].Copy()
|
|
}
|
|
|
|
func (d *modelData) Copy() modelData {
|
|
ret := *d
|
|
ret.Song = d.Song.Copy()
|
|
return ret
|
|
}
|
|
|
|
func (m *Model) resetSong() {
|
|
m.d.Song = defaultSong.Copy()
|
|
for _, instr := range m.d.Song.Patch {
|
|
(*Model)(m).assignUnitIDs(instr.Units)
|
|
}
|
|
m.d.FilePath = ""
|
|
m.d.ChangedSinceSave = false
|
|
m.d.Loop = Loop{}
|
|
}
|
|
|
|
// send sends a message to the player
|
|
func (m *Model) send(message interface{}) {
|
|
m.modelMessages <- message
|
|
}
|
|
|
|
func (m *Model) assignUnitIDs(units []sointu.Unit) {
|
|
maxId := 0
|
|
usedIds := make(map[int]bool)
|
|
for _, instr := range m.d.Song.Patch {
|
|
for _, unit := range instr.Units {
|
|
usedIds[unit.ID] = true
|
|
if maxId < unit.ID {
|
|
maxId = unit.ID
|
|
}
|
|
}
|
|
}
|
|
rewrites := map[int]int{}
|
|
for i := range units {
|
|
if id := units[i].ID; id == 0 || usedIds[id] {
|
|
maxId++
|
|
if id > 0 {
|
|
rewrites[id] = maxId
|
|
}
|
|
units[i].ID = maxId
|
|
}
|
|
usedIds[units[i].ID] = true
|
|
if maxId < units[i].ID {
|
|
maxId = units[i].ID
|
|
}
|
|
}
|
|
for i, u := range units {
|
|
if target, ok := u.Parameters["target"]; u.Type == "send" && ok {
|
|
if newId, ok := rewrites[target]; ok {
|
|
units[i].Parameters["target"] = newId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) updatePatternUseCount() {
|
|
for i, track := range m.d.Song.Score.Tracks {
|
|
for len(m.cachePatternUseCount) <= i {
|
|
m.cachePatternUseCount = append(m.cachePatternUseCount, nil)
|
|
}
|
|
for j := range m.cachePatternUseCount[i] {
|
|
m.cachePatternUseCount[i][j] = 0
|
|
}
|
|
for j := 0; j < m.d.Song.Score.Length; j++ {
|
|
if j >= len(track.Order) {
|
|
break
|
|
}
|
|
p := track.Order[j]
|
|
for len(m.cachePatternUseCount[i]) <= p {
|
|
m.cachePatternUseCount[i] = append(m.cachePatternUseCount[i], 0)
|
|
}
|
|
if p < 0 {
|
|
continue
|
|
}
|
|
m.cachePatternUseCount[i][p]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func clamp(a, min, max int) int {
|
|
if a > max {
|
|
return max
|
|
}
|
|
if a < min {
|
|
return min
|
|
}
|
|
return a
|
|
}
|
|
|
|
func intMax(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func intMin(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|