feat!: implement vsti, along with various refactorings and api changes for it

The RPC and sync library mechanisms were removed for now; they never really worked and contained several obvious bugs. Need to consider if syncs are useful at all during the compose time, or just used during intro.
This commit is contained in:
5684185+vsariola@users.noreply.github.com
2023-05-09 11:24:49 +03:00
parent 70080c2b9d
commit cd700ed954
34 changed files with 1210 additions and 750 deletions

View File

@ -10,7 +10,6 @@ import (
"path/filepath"
"time"
"gioui.org/app"
"gopkg.in/yaml.v3"
"github.com/vsariola/sointu"
@ -84,7 +83,6 @@ func (t *Tracker) loadSong(filename string) {
}
t.SetSong(song)
t.SetFilePath(filename)
t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename)))
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
}
@ -107,7 +105,6 @@ func (t *Tracker) saveSong(filename string) bool {
}
ioutil.WriteFile(filename, contents, 0644)
t.SetFilePath(filename)
t.window.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", filename)))
t.SetChangedSinceSave(false)
return true
}
@ -117,7 +114,7 @@ func (t *Tracker) exportWav(filename string, pcm16 bool) {
if extension == "" {
filename = filename + ".wav"
}
data, _, err := sointu.Play(t.synthService, t.Song(), true) // render the song to calculate its length
data, err := sointu.Play(t.synthService, t.Song(), true) // render the song to calculate its length
if err != nil {
t.Alert.Update(fmt.Sprintf("Error rendering the song during export: %v", err), Error, time.Second*3)
return

View File

@ -4,7 +4,6 @@ import (
"fmt"
"image"
"image/color"
"math"
"strconv"
"strings"
"time"
@ -20,12 +19,14 @@ import (
"gioui.org/widget/material"
"gioui.org/x/eventx"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/vm"
"golang.org/x/exp/shiny/materialdesign/icons"
"gopkg.in/yaml.v3"
)
type InstrumentEditor struct {
newInstrumentBtn *widget.Clickable
enlargeBtn *widget.Clickable
deleteInstrumentBtn *widget.Clickable
copyInstrumentBtn *widget.Clickable
saveInstrumentBtn *widget.Clickable
@ -45,11 +46,13 @@ type InstrumentEditor struct {
tag bool
wasFocused bool
commentExpanded bool
voiceStates [vm.MAX_VOICES]float32
}
func NewInstrumentEditor() *InstrumentEditor {
return &InstrumentEditor{
newInstrumentBtn: new(widget.Clickable),
enlargeBtn: new(widget.Clickable),
deleteInstrumentBtn: new(widget.Clickable),
copyInstrumentBtn: new(widget.Clickable),
saveInstrumentBtn: new(widget.Clickable),
@ -97,10 +100,31 @@ func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D {
pointer.InputOp{Tag: &ie.tag,
Types: pointer.Press,
}.Add(gtx.Ops)
var icon []byte
if t.InstrEnlarged() {
icon = icons.NavigationFullscreenExit
} else {
icon = icons.NavigationFullscreen
}
fullscreenBtnStyle := IconButton(t.Theme, ie.enlargeBtn, icon, true)
for ie.enlargeBtn.Clicked() {
t.SetInstrEnlarged(!t.InstrEnlarged())
}
for ie.newInstrumentBtn.Clicked() {
t.AddInstrument(true)
}
btnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument())
octave := func(gtx C) D {
in := layout.UniformInset(unit.Dp(1))
t.OctaveNumberInput.Value = t.Octave()
numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9)
gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Px(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
t.SetOctave(t.OctaveNumberInput.Value)
return dims
}
newBtnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument())
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{}.Layout(
@ -116,7 +140,19 @@ func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D {
)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, btnStyle.Layout)
inset := layout.UniformInset(unit.Dp(6))
return inset.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("OCT:", white)),
layout.Rigid(octave),
)
})
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, fullscreenBtnStyle.Layout)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, newBtnStyle.Layout)
}),
)
}),
@ -205,10 +241,12 @@ func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D {
t.Alert.Update("Instrument copied to clipboard", Notify, time.Second*3)
}
}
for ie.deleteInstrumentBtn.Clicked() && t.ModalDialog == nil {
dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?")
ie.confirmInstrDelete.Visible = true
t.ModalDialog = dialogStyle.Layout
for ie.deleteInstrumentBtn.Clicked() {
if t.CanDeleteInstrument() {
dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?")
ie.confirmInstrDelete.Visible = true
t.ModalDialog = dialogStyle.Layout
}
}
for ie.confirmInstrDelete.BtnOk.Clicked() {
t.DeleteInstrument(false)
@ -236,14 +274,10 @@ func (ie *InstrumentEditor) layoutInstrumentNames(gtx C, t *Tracker) D {
grabhandle.Text = ":::"
}
label := func(gtx C) D {
c := 0.0
c := float32(0.0)
voice := t.Song().Patch.FirstVoiceForInstrument(i)
for j := 0; j < t.Song().Patch[i].NumVoices; j++ {
released, event := t.player.VoiceState(voice)
vc := math.Exp(-float64(event)/15000) * .5
if !released {
vc += .5
}
vc := ie.voiceStates[voice]
if c < vc {
c = vc
}

View File

@ -3,6 +3,7 @@ package gioui
import (
"time"
"gioui.org/app"
"gioui.org/io/key"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v3"
@ -44,7 +45,7 @@ var noteMap = map[string]int{
}
// KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event) bool {
func (t *Tracker) KeyEvent(e key.Event, window *app.Window) bool {
if e.State == key.Press {
if t.OpenSongDialog.Visible ||
t.SaveSongDialog.Visible ||
@ -58,14 +59,14 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
if e.Modifiers.Contain(key.ModShortcut) {
contents, err := yaml.Marshal(t.Song())
if err == nil {
t.window.WriteClipboard(string(contents))
window.WriteClipboard(string(contents))
t.Alert.Update("Song copied to clipboard", Notify, time.Second*3)
}
return true
}
case "V":
if e.Modifiers.Contain(key.ModShortcut) {
t.window.ReadClipboard()
window.ReadClipboard()
return true
}
case "Z":
@ -111,7 +112,7 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
if t.OrderEditor.Focused() {
startRow.Row = 0
}
t.player.Play(startRow)
t.PlayFromPosition(startRow)
return true
case "F6":
t.SetNoteTracking(false)
@ -119,19 +120,18 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
if t.OrderEditor.Focused() {
startRow.Row = 0
}
t.player.Play(startRow)
t.PlayFromPosition(startRow)
return true
case "F8":
t.player.Stop()
t.SetPlaying(false)
return true
case "Space":
_, playing := t.player.Position()
if !playing {
if !t.Playing() && !t.InstrEnlarged() {
t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut))
startRow := t.Cursor().SongRow
t.player.Play(startRow)
t.PlayFromPosition(startRow)
} else {
t.player.Stop()
t.SetPlaying(false)
}
case `\`, `<`, `>`:
if e.Modifiers.Contain(key.ModShift) {
@ -147,7 +147,11 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
case t.TrackEditor.Focused():
t.OrderEditor.Focus()
case t.InstrumentEditor.Focused():
t.TrackEditor.Focus()
if t.InstrEnlarged() {
t.InstrumentEditor.paramEditor.Focus()
} else {
t.TrackEditor.Focus()
}
default:
t.InstrumentEditor.Focus()
}
@ -160,7 +164,11 @@ func (t *Tracker) KeyEvent(e key.Event) bool {
case t.InstrumentEditor.Focused():
t.InstrumentEditor.paramEditor.Focus()
default:
t.OrderEditor.Focus()
if t.InstrEnlarged() {
t.InstrumentEditor.Focus()
} else {
t.OrderEditor.Focus()
}
}
}
}
@ -188,18 +196,18 @@ func (t *Tracker) JammingPressed(e key.Event) {
if _, ok := t.KeyPlaying[e.Name]; !ok {
n := tracker.NoteAsValue(t.OctaveNumberInput.Value, val)
instr := t.InstrIndex()
start := t.Song().Patch.FirstVoiceForInstrument(instr)
end := start + t.Instrument().NumVoices
t.KeyPlaying[e.Name] = t.player.Trigger(start, end, n)
noteID := tracker.NoteIDInstr(instr, n)
t.NoteOn(noteID)
t.KeyPlaying[e.Name] = noteID
}
}
}
func (t *Tracker) JammingReleased(e key.Event) {
if ID, ok := t.KeyPlaying[e.Name]; ok {
t.player.Release(ID)
if noteID, ok := t.KeyPlaying[e.Name]; ok {
t.NoteOff(noteID)
delete(t.KeyPlaying, e.Name)
if _, playing := t.player.Position(); t.TrackEditor.focused && playing && t.Note() == 1 && t.NoteTracking() {
if t.TrackEditor.focused && t.Playing() && t.Note() == 1 && t.NoteTracking() {
t.SetNote(0)
}
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"image"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
@ -15,9 +14,13 @@ type D = layout.Dimensions
func (t *Tracker) Layout(gtx layout.Context) {
paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
t.VerticalSplit.Layout(gtx,
t.layoutTop,
t.layoutBottom)
if t.InstrEnlarged() {
t.layoutTop(gtx)
} else {
t.VerticalSplit.Layout(gtx,
t.layoutTop,
t.layoutBottom)
}
t.Alert.Layout(gtx)
dstyle := ConfirmDialog(t.Theme, t.ConfirmSongDialog, "Do you want to save your changes to the song? Your changes will be lost if you don't save them.")
dstyle.ShowAlt = true
@ -114,7 +117,6 @@ func (t *Tracker) NewSong(forced bool) {
}
t.ResetSong()
t.SetFilePath("")
t.window.Option(app.Title("Sointu Tracker"))
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
}

View File

@ -73,20 +73,19 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D {
t.DeleteOrderRow(e.Name == key.NameDeleteForward)
} else {
t.DeletePatternSelection()
if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
}
case "Space":
_, playing := t.player.Position()
if !playing {
if !t.Playing() {
t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut))
startRow := t.Cursor().SongRow
startRow.Row = 0
t.player.Play(startRow)
t.PlayFromPosition(startRow)
} else {
t.player.Stop()
t.SetPlaying(false)
}
case key.NameReturn:
t.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut))
@ -143,14 +142,14 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D {
}
if iv, err := strconv.Atoi(e.Name); err == nil {
t.SetCurrentPattern(iv)
if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
}
if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 {
t.SetCurrentPattern(b + 10)
if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
@ -202,7 +201,7 @@ func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D {
gtx.Constraints.Max.Y -= patternCellHeight
gtx.Constraints.Min.Y -= patternCellHeight
element := func(gtx C, j int) D {
if playPos, ok := t.player.Position(); ok && j == playPos.Pattern {
if playPos := t.PlayPosition(); t.Playing() && j == playPos.Pattern {
paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op())
}
paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops)

View File

@ -24,7 +24,7 @@ func (t *Tracker) layoutRowMarkers(gtx C) D {
clip.Rect{Max: gtx.Constraints.Max}.Add(gtx.Ops)
op.Offset(f32.Pt(0, float32(gtx.Constraints.Max.Y-trackRowHeight)/2)).Add(gtx.Ops)
cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row
playPos, playing := t.player.Position()
playPos := t.PlayPosition()
playSongRow := playPos.Pattern*t.Song().Score.RowsPerPattern + playPos.Row
op.Offset(f32.Pt(0, (-1*trackRowHeight)*float32(cursorSongRow))).Add(gtx.Ops)
beatMarkerDensity := t.Song().RowsPerBeat
@ -39,7 +39,7 @@ func (t *Tracker) layoutRowMarkers(gtx C) D {
} else if mod(songRow, beatMarkerDensity) == 0 {
paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
}
if playing && songRow == playSongRow {
if t.Playing() && songRow == playSongRow {
paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
}
if j == 0 {

View File

@ -1,82 +0,0 @@
package gioui
import (
"fmt"
"os"
"time"
"gioui.org/app"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"github.com/vsariola/sointu"
)
func (t *Tracker) Run(w *app.Window) error {
var ops op.Ops
for {
if pos, playing := t.player.Position(); t.NoteTracking() && playing {
cursor := t.Cursor()
cursor.SongRow = pos
t.SetCursor(cursor)
t.SetSelectionCorner(cursor)
}
select {
case <-t.refresh:
w.Invalidate()
case v := <-t.volumeChan:
t.lastVolume = v
w.Invalidate()
case e := <-t.errorChannel:
t.Alert.Update(e.Error(), Error, time.Second*5)
w.Invalidate()
case e := <-w.Events():
switch e := e.(type) {
case system.DestroyEvent:
if !t.Quit(false) {
// TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog
w = app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
}
case key.Event:
if t.KeyEvent(e) {
w.Invalidate()
}
case clipboard.Event:
err := t.UnmarshalContent([]byte(e.Text))
if err == nil {
w.Invalidate()
}
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
t.Layout(gtx)
e.Frame(gtx.Ops)
}
}
if t.quitted {
return nil
}
}
}
func Main(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32) {
go func() {
w := app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
t := New(audioContext, synthService, syncChannel, w)
defer t.Close()
if err := t.Run(w); err != nil {
fmt.Println(err)
os.Exit(1)
}
os.Exit(0)
}()
app.Main()
}

View File

@ -3,7 +3,6 @@ package gioui
import (
"image"
"math"
"runtime"
"time"
"gioui.org/f32"
@ -19,6 +18,22 @@ import (
"gopkg.in/yaml.v3"
)
const shortcutKey = "Ctrl+"
var fileMenuItems []MenuItem = []MenuItem{
{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"},
{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"},
{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"},
{IconBytes: icons.ContentSave, Text: "Save Song As..."},
{IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."},
}
func init() {
if CAN_QUIT {
fileMenuItems = append(fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"})
}
}
func (t *Tracker) layoutSongPanel(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(t.layoutMenuBar),
@ -87,18 +102,9 @@ func (t *Tracker) layoutMenuBar(gtx C) D {
clickedItem, hasClicked = t.Menus[1].Clicked()
}
shortcutKey := "Ctrl+"
if runtime.GOOS == "darwin" {
shortcutKey = "Cmd+"
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200),
MenuItem{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"},
MenuItem{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"},
MenuItem{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"},
MenuItem{IconBytes: icons.ContentSave, Text: "Save Song As..."},
MenuItem{IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."},
MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"},
fileMenuItems...,
)),
layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200),
MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()},
@ -116,14 +122,25 @@ func (t *Tracker) layoutSongOptions(gtx C) D {
in := layout.UniformInset(unit.Dp(1))
var panicBtnStyle material.ButtonStyle
if t.player.Enabled() {
if !t.Panic() {
panicBtnStyle = LowEmphasisButton(t.Theme, t.PanicBtn, "Panic")
} else {
panicBtnStyle = HighEmphasisButton(t.Theme, t.PanicBtn, "Panic")
}
for t.PanicBtn.Clicked() {
t.player.Disable()
t.SetPanic(!t.Panic())
}
var recordBtnStyle material.ButtonStyle
if !t.Recording() {
recordBtnStyle = LowEmphasisButton(t.Theme, t.RecordBtn, "Record")
} else {
recordBtnStyle = HighEmphasisButton(t.Theme, t.RecordBtn, "Record")
}
for t.RecordBtn.Clicked() {
t.SetRecording(!t.Recording())
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@ -200,6 +217,10 @@ func (t *Tracker) layoutSongOptions(gtx C) D {
gtx.Constraints.Min = image.Pt(0, 0)
return panicBtnStyle.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min = image.Pt(0, 0)
return recordBtnStyle.Layout(gtx)
}),
layout.Rigid(VuMeter{Volume: t.lastVolume, Range: 100}.Layout),
)
}

View File

@ -76,7 +76,7 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions
switch e.Name {
case key.NameDeleteForward, key.NameDeleteBackward:
t.DeleteSelection()
if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
@ -159,14 +159,14 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions
t.SetNote(n)
step = true
trk := t.Cursor().Track
start := t.Song().Score.FirstVoiceForTrack(trk)
end := start + t.Song().Score.Tracks[trk].NumVoices
t.KeyPlaying[e.Name] = t.player.Trigger(start, end, n)
noteID := tracker.NoteIDTrack(trk, n)
t.NoteOn(noteID)
t.KeyPlaying[e.Name] = noteID
}
}
}
}
if step && !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if step && !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
@ -208,7 +208,7 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions
for te.NoteOffBtn.Clicked() {
t.SetNote(0)
if !(t.NoteTracking() && t.player.Playing()) && t.Step.Value > 0 {
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
@ -230,18 +230,9 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions
noteOffBtnStyle := LowEmphasisButton(t.Theme, te.NoteOffBtn, "Note Off")
deleteTrackBtnStyle := IconButton(t.Theme, te.DeleteTrackBtn, icons.ActionDelete, t.CanDeleteTrack())
newTrackBtnStyle := IconButton(t.Theme, te.NewTrackBtn, icons.ContentAdd, t.CanAddTrack())
in := layout.UniformInset(unit.Dp(1))
octave := func(gtx C) D {
t.OctaveNumberInput.Value = t.Octave()
numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9)
gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Px(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
t.SetOctave(t.OctaveNumberInput.Value)
return dims
}
n := t.Song().Score.Tracks[t.Cursor().Track].NumVoices
te.TrackVoices.Value = n
in := layout.UniformInset(unit.Dp(1))
voiceUpDown := func(gtx C) D {
numStyle := NumericUpDown(t.Theme, te.TrackVoices, 1, t.MaxTrackVoices())
gtx.Constraints.Min.Y = gtx.Px(unit.Dp(20))
@ -251,8 +242,6 @@ func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions
t.TrackHexCheckBox.Value = t.Song().Score.Tracks[t.Cursor().Track].Effect
hexCheckBoxStyle := material.CheckBox(t.Theme, t.TrackHexCheckBox, "Hex")
dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(Label("OCT:", white)),
layout.Rigid(octave),
layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Px(unit.Dp(12)), 0)} }),
layout.Rigid(addSemitoneBtnStyle.Layout),
layout.Rigid(subtractSemitoneBtnStyle.Layout),

View File

@ -4,10 +4,16 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu"
@ -33,12 +39,13 @@ type Tracker struct {
InstrumentVoices *NumberInput
SongLength *NumberInput
PanicBtn *widget.Clickable
RecordBtn *widget.Clickable
AddUnitBtn *widget.Clickable
TrackHexCheckBox *widget.Bool
TopHorizontalSplit *Split
BottomHorizontalSplit *Split
VerticalSplit *Split
KeyPlaying map[string]uint32
KeyPlaying map[string]tracker.NoteID
Alert Alert
ConfirmSongDialog *Dialog
WaveTypeDialog *Dialog
@ -48,22 +55,17 @@ type Tracker struct {
SaveInstrumentDialog *FileDialog
ExportWavDialog *FileDialog
ConfirmSongActionType int
window *app.Window
ModalDialog layout.Widget
InstrumentEditor *InstrumentEditor
OrderEditor *OrderEditor
TrackEditor *TrackEditor
lastVolume tracker.Volume
volumeChan chan tracker.Volume
wavFilePath string
player *tracker.Player
refresh chan struct{}
playerCloser chan struct{}
errorChannel chan error
quitted bool
audioContext sointu.AudioContext
synthService sointu.SynthService
*tracker.Model
@ -94,15 +96,9 @@ func (t *Tracker) UnmarshalContent(bytes []byte) error {
return errors.New("was able to unmarshal a song, but the bpm was 0")
}
func (t *Tracker) Close() {
t.playerCloser <- struct{}{}
t.audioContext.Close()
}
func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syncChannel chan<- []float32, window *app.Window) *Tracker {
func NewTracker(model *tracker.Model, synthService sointu.SynthService) *Tracker {
t := &Tracker{
Theme: material.NewTheme(gofont.Collection()),
audioContext: audioContext,
BPM: new(NumberInput),
OctaveNumberInput: &NumberInput{Value: 4},
SongLength: new(NumberInput),
@ -112,6 +108,7 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn
InstrumentVoices: new(NumberInput),
PanicBtn: new(widget.Clickable),
RecordBtn: new(widget.Clickable),
TrackHexCheckBox: new(widget.Bool),
Menus: make([]Menu, 2),
MenuBar: make([]widget.Clickable, 2),
@ -121,9 +118,7 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn
BottomHorizontalSplit: &Split{Ratio: -.6},
VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[string]uint32),
volumeChan: make(chan tracker.Volume, 1),
playerCloser: make(chan struct{}),
KeyPlaying: make(map[string]tracker.NoteID),
ConfirmSongDialog: new(Dialog),
WaveTypeDialog: new(Dialog),
OpenSongDialog: NewFileDialog(),
@ -136,40 +131,85 @@ func New(audioContext sointu.AudioContext, synthService sointu.SynthService, syn
ExportWavDialog: NewFileDialog(),
errorChannel: make(chan error, 32),
window: window,
synthService: synthService,
Model: model,
}
t.Model = tracker.NewModel()
vuBufferObserver := make(chan []float32)
go tracker.VuAnalyzer(0.3, 1e-4, 1, -100, 20, vuBufferObserver, t.volumeChan, t.errorChannel)
t.Theme.Palette.Fg = primaryColor
t.Theme.Palette.ContrastFg = black
t.TrackEditor.Focus()
t.SetOctave(4)
patchObserver := make(chan sointu.Patch, 16)
t.AddPatchObserver(patchObserver)
scoreObserver := make(chan sointu.Score, 16)
t.AddScoreObserver(scoreObserver)
sprObserver := make(chan int, 16)
t.AddSamplesPerRowObserver(sprObserver)
audioChannel := make(chan []float32)
t.player = tracker.NewPlayer(synthService, t.playerCloser, patchObserver, scoreObserver, sprObserver, t.refresh, syncChannel, audioChannel, vuBufferObserver)
audioOut := audioContext.Output()
go func() {
for buf := range audioChannel {
audioOut.WriteAudio(buf)
}
}()
t.ResetSong()
return t
}
func (t *Tracker) Quit(forced bool) bool {
if !forced && t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmQuit
t.ConfirmSongDialog.Visible = true
return false
func (t *Tracker) Main() {
titleFooter := ""
w := app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
var ops op.Ops
mainloop:
for {
if pos, playing := t.PlayPosition(), t.Playing(); t.NoteTracking() && playing {
cursor := t.Cursor()
cursor.SongRow = pos
t.SetCursor(cursor)
t.SetSelectionCorner(cursor)
}
if titleFooter != t.FilePath() {
titleFooter = t.FilePath()
if titleFooter != "" {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
} else {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker")))
}
}
select {
case <-t.refresh:
w.Invalidate()
case e := <-t.errorChannel:
t.Alert.Update(e.Error(), Error, time.Second*5)
w.Invalidate()
case e := <-t.PlayerMessages:
if err, ok := e.Inner.(tracker.PlayerCrashMessage); ok {
t.Alert.Update(err.Error(), Error, time.Second*3)
}
if err, ok := e.Inner.(tracker.PlayerVolumeErrorMessage); ok {
t.Alert.Update(err.Error(), Warning, time.Second*3)
}
t.lastVolume = e.Volume
t.InstrumentEditor.voiceStates = e.VoiceStates
t.ProcessPlayerMessage(e)
w.Invalidate()
case e := <-w.Events():
switch e := e.(type) {
case system.DestroyEvent:
if !t.Quit(false) {
// TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog
w = app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
}
case key.Event:
if t.KeyEvent(e, w) {
w.Invalidate()
}
case clipboard.Event:
err := t.UnmarshalContent([]byte(e.Text))
if err == nil {
w.Invalidate()
}
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
t.Layout(gtx)
e.Frame(gtx.Ops)
}
}
if t.quitted {
break mainloop
}
}
t.quitted = true
return true
w.Close()
}

View File

@ -0,0 +1,15 @@
//go:build !plugin
package gioui
const CAN_QUIT = true
func (t *Tracker) Quit(forced bool) bool {
if !forced && t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmQuit
t.ConfirmSongDialog.Visible = true
return false
}
t.quitted = true
return true
}

View File

@ -0,0 +1,10 @@
//go:build plugin
package gioui
const CAN_QUIT = false
func (t *Tracker) Quit(forced bool) bool {
t.quitted = forced
return forced
}

View File

@ -16,32 +16,75 @@ import (
// 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.
type Model struct {
song sointu.Song
selectionCorner SongPoint
cursor SongPoint
lowNibble bool
instrIndex int
unitIndex int
paramIndex int
octave int
noteTracking bool
usedIDs map[int]bool
maxID int
filePath string
changedSinceSave bool
patternUseCount [][]int
// 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 (
Model struct {
song sointu.Song
selectionCorner SongPoint
cursor SongPoint
lowNibble bool
instrIndex int
unitIndex int
paramIndex int
octave int
noteTracking bool
usedIDs map[int]bool
maxID int
filePath string
changedSinceSave bool
patternUseCount [][]int
panic bool
playing bool
recording bool
playPosition SongRow
instrEnlarged bool
prevUndoType string
undoSkipCounter int
undoStack []sointu.Song
redoStack []sointu.Song
prevUndoType string
undoSkipCounter int
undoStack []sointu.Song
redoStack []sointu.Song
samplesPerRowObservers []chan<- int
patchObservers []chan<- sointu.Patch
scoreObservers []chan<- sointu.Score
playingObservers []chan<- bool
}
PlayerMessages <-chan PlayerMessage
modelMessages chan<- interface{}
}
ModelPatchChangedMessage struct {
sointu.Patch
}
ModelScoreChangedMessage struct {
sointu.Score
}
ModelPlayingChangedMessage struct {
bool
}
ModelPlayFromPositionMessage struct {
SongRow
}
ModelSamplesPerRowChangedMessage struct {
int
}
ModelPanicMessage struct {
bool
}
ModelRecordingMessage struct {
bool
}
ModelNoteOnMessage struct {
id NoteID
}
ModelNoteOffMessage struct {
id NoteID
}
)
type Parameter struct {
Type ParameterType
@ -63,8 +106,10 @@ const (
const maxUndo = 256
func NewModel() *Model {
func NewModel(modelMessages chan<- interface{}, playerMessages <-chan PlayerMessage) *Model {
ret := new(Model)
ret.modelMessages = modelMessages
ret.PlayerMessages = playerMessages
ret.setSongNoUndo(defaultSong.Copy())
return ret
}
@ -110,6 +155,19 @@ func (m *Model) SetOctave(value int) bool {
return true
}
func (m *Model) ProcessPlayerMessage(msg PlayerMessage) {
m.playPosition = msg.SongRow
switch e := msg.Inner.(type) {
case PlayerCrashMessage:
m.panic = true
case PlayerRecordedMessage:
song := RecordingToSong(m.song.Patch, m.song.RowsPerBeat, m.song.Score.RowsPerPattern, e)
m.SetSong(song)
m.instrEnlarged = false
default:
}
}
func (m *Model) SetInstrument(instrument sointu.Instrument) bool {
if len(instrument.Units) == 0 {
return false
@ -300,6 +358,29 @@ func (m *Model) AddInstrument(after bool) {
m.notifyPatchChange()
}
func (m *Model) NoteOn(id NoteID) {
m.modelMessages <- ModelNoteOnMessage{id}
}
func (m *Model) NoteOff(id NoteID) {
m.modelMessages <- ModelNoteOffMessage{id}
}
func (m *Model) Playing() bool {
return m.playing
}
func (m *Model) SetPlaying(val bool) {
if m.playing != val {
m.playing = val
m.modelMessages <- ModelPlayingChangedMessage{val}
}
}
func (m *Model) PlayPosition() SongRow {
return m.playPosition
}
func (m *Model) CanAddInstrument() bool {
return m.song.Patch.NumVoices() < 32
}
@ -452,6 +533,42 @@ func (m *Model) AdjustPatternNumber(delta int, swap bool) {
m.notifyScoreChange()
}
func (m *Model) SetRecording(val bool) {
if m.recording != val {
m.recording = val
m.instrEnlarged = val
m.modelMessages <- ModelRecordingMessage{val}
}
}
func (m *Model) Recording() bool {
return m.recording
}
func (m *Model) SetPanic(val bool) {
if m.panic != val {
m.panic = val
m.modelMessages <- ModelPanicMessage{val}
}
}
func (m *Model) Panic() bool {
return m.panic
}
func (m *Model) SetInstrEnlarged(val bool) {
m.instrEnlarged = val
}
func (m *Model) InstrEnlarged() bool {
return m.instrEnlarged
}
func (m *Model) PlayFromPosition(sr SongRow) {
m.playing = true
m.modelMessages <- ModelPlayFromPositionMessage{sr}
}
func (m *Model) SetCurrentPattern(pat int) {
m.saveUndo("SetCurrentPattern", 0)
m.song.Score.Tracks[m.cursor.Track].Order.Set(m.cursor.Pattern, pat)
@ -1085,22 +1202,6 @@ func (m *Model) SetParam(value int) {
m.notifyPatchChange()
}
func (m *Model) AddPatchObserver(observer chan<- sointu.Patch) {
m.patchObservers = append(m.patchObservers, observer)
}
func (m *Model) AddScoreObserver(observer chan<- sointu.Score) {
m.scoreObservers = append(m.scoreObservers, observer)
}
func (m *Model) AddSamplesPerRowObserver(observer chan<- int) {
m.samplesPerRowObservers = append(m.samplesPerRowObservers, observer)
}
func (m *Model) AddPlayingObserver(observer chan<- bool) {
m.playingObservers = append(m.playingObservers, observer)
}
func (m *Model) setSongNoUndo(song sointu.Song) {
m.song = song
m.usedIDs = make(map[int]bool)
@ -1123,20 +1224,24 @@ func (m *Model) setSongNoUndo(song sointu.Song) {
}
func (m *Model) notifyPatchChange() {
for _, channel := range m.patchObservers {
channel <- m.song.Patch.Copy()
m.panic = false
select {
case m.modelMessages <- ModelPatchChangedMessage{m.song.Patch.Copy()}:
default:
}
}
func (m *Model) notifyScoreChange() {
for _, channel := range m.scoreObservers {
channel <- m.song.Score.Copy()
select {
case m.modelMessages <- ModelScoreChangedMessage{m.song.Score.Copy()}:
default:
}
}
func (m *Model) notifySamplesPerRowChange() {
for _, channel := range m.samplesPerRowObservers {
channel <- m.song.SamplesPerRow()
select {
case m.modelMessages <- ModelSamplesPerRowChangedMessage{m.song.SamplesPerRow()}:
default:
}
}

19
tracker/note_id.go Normal file
View File

@ -0,0 +1,19 @@
package tracker
// 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
type NoteID struct {
IsInstr bool
Instr int
Track int
Note byte
}
func NoteIDInstr(instr int, note byte) NoteID {
return NoteID{IsInstr: true, Instr: instr, Note: note}
}
func NoteIDTrack(track int, note byte) NoteID {
return NoteID{IsInstr: false, Track: track, Note: note}
}

View File

@ -1,268 +1,364 @@
package tracker
import (
"fmt"
"math"
"sync"
"sync/atomic"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
)
type Player struct {
packedPos uint64
type (
Player struct {
voiceNoteID []int
voiceReleased []bool
synth sointu.Synth
patch sointu.Patch
score sointu.Score
playing bool
rowtime int
position SongRow
samplesSinceEvent []int
samplesPerRow int
volume Volume
voiceStates [vm.MAX_VOICES]float32
playCmds chan uint64
recording bool
recordingNoteArrived bool
recordingFrames int
recordingEvents []PlayerProcessEvent
mutex sync.Mutex
runningID uint32
voiceNoteID []uint32
voiceReleased []int32
synth sointu.Synth
patch sointu.Patch
samplesSinceEvent []int32
synthNotNil int32
}
type voiceNote struct {
voice int
note byte
}
// Position returns the current play position (song row), and a bool indicating
// if the player is currently playing. The function is threadsafe.
func (p *Player) Position() (SongRow, bool) {
packedPos := atomic.LoadUint64(&p.packedPos)
if packedPos == math.MaxUint64 { // stopped
return SongRow{}, false
synthService sointu.SynthService
playerMessages chan<- PlayerMessage
modelMessages <-chan interface{}
}
return unpackPosition(packedPos), true
}
func (p *Player) Playing() bool {
packedPos := atomic.LoadUint64(&p.packedPos)
if packedPos == math.MaxUint64 { // stopped
return false
PlayerProcessContext interface {
NextEvent() (event PlayerProcessEvent, ok bool)
BPM() (bpm float64, ok bool)
}
return true
}
func (p *Player) Play(position SongRow) {
position.Row-- // we'll advance this very shortly
p.playCmds <- packPosition(position)
}
func (p *Player) Stop() {
p.playCmds <- math.MaxUint64
}
func (p *Player) Disable() {
p.mutex.Lock()
p.synth = nil
atomic.StoreInt32(&p.synthNotNil, 0)
p.mutex.Unlock()
}
func (p *Player) Enabled() bool {
return atomic.LoadInt32(&p.synthNotNil) == 1
}
func (p *Player) VoiceState(voice int) (bool, int) {
if voice >= len(p.samplesSinceEvent) || voice >= len(p.voiceReleased) {
return true, math.MaxInt32
PlayerProcessEvent struct {
Frame int
On bool
Channel int
Note byte
}
return atomic.LoadInt32(&p.voiceReleased[voice]) == 1, int(atomic.LoadInt32(&p.samplesSinceEvent[voice]))
}
func NewPlayer(service sointu.SynthService, closer <-chan struct{}, patchs <-chan sointu.Patch, scores <-chan sointu.Score, samplesPerRows <-chan int, posChanged chan<- struct{}, syncOutput chan<- []float32, outputs ...chan<- []float32) *Player {
p := &Player{playCmds: make(chan uint64, 16)}
go func() {
var score sointu.Score
buffer := make([]float32, 2048)
buffer2 := make([]float32, 2048)
zeros := make([]float32, 2048)
totalSyncs := 1 // just the beat
syncBuffer := make([]float32, (2048+255)/256*totalSyncs)
syncBuffer2 := make([]float32, (2048+255)/256*totalSyncs)
rowTime := 0
samplesPerRow := math.MaxInt32
var trackIDs []uint32
atomic.StoreUint64(&p.packedPos, math.MaxUint64)
for {
select {
case <-closer:
for _, o := range outputs {
close(o)
}
return
case patch := <-patchs:
p.mutex.Lock()
p.patch = patch
if p.synth != nil {
err := p.synth.Update(patch)
if err != nil {
p.synth = nil
atomic.StoreInt32(&p.synthNotNil, 0)
}
} else {
s, err := service.Compile(patch)
if err == nil {
p.synth = s
atomic.StoreInt32(&p.synthNotNil, 1)
for i := 0; i < 32; i++ {
s.Release(i)
}
}
}
totalSyncs = 1 + p.patch.NumSyncs()
syncBuffer = make([]float32, ((2048+255)/256)*totalSyncs)
syncBuffer2 = make([]float32, ((2048+255)/256)*totalSyncs)
p.mutex.Unlock()
case score = <-scores:
if row, playing := p.Position(); playing {
atomic.StoreUint64(&p.packedPos, packPosition(row.Wrap(score)))
}
case samplesPerRow = <-samplesPerRows:
case packedPos := <-p.playCmds:
atomic.StoreUint64(&p.packedPos, packedPos)
if packedPos == math.MaxUint64 {
p.mutex.Lock()
for _, id := range trackIDs {
p.release(id)
}
p.mutex.Unlock()
} else {
p.mutex.Lock()
for i, t := range score.Tracks {
if !t.Effect && i < len(trackIDs) { // when starting to play from another position, release only non-effect tracks
p.release(trackIDs[i])
}
}
p.mutex.Unlock()
}
rowTime = math.MaxInt32
default:
row, playing := p.Position()
if playing && rowTime >= samplesPerRow && score.Length > 0 && score.RowsPerPattern > 0 {
row.Row++ // advance row (this is why we subtracted one in Play())
row = row.Wrap(score)
atomic.StoreUint64(&p.packedPos, packPosition(row))
select {
case posChanged <- struct{}{}:
default:
}
p.mutex.Lock()
lastVoice := 0
for i, t := range score.Tracks {
start := lastVoice
lastVoice = start + t.NumVoices
if row.Pattern < 0 || row.Pattern >= len(t.Order) {
continue
}
o := t.Order[row.Pattern]
if o < 0 || o >= len(t.Patterns) {
continue
}
pat := t.Patterns[o]
if row.Row < 0 || row.Row >= len(pat) {
continue
}
n := pat[row.Row]
for len(trackIDs) <= i {
trackIDs = append(trackIDs, 0)
}
if n != 1 && trackIDs[i] > 0 {
p.release(trackIDs[i])
}
if n > 1 && p.synth != nil {
trackIDs[i] = p.trigger(start, lastVoice, n)
}
}
p.mutex.Unlock()
rowTime = 0
}
if p.synth != nil {
renderTime := samplesPerRow - rowTime
if !playing {
renderTime = math.MaxInt32
}
p.mutex.Lock()
rendered, syncs, timeAdvanced, err := p.synth.Render(buffer, syncBuffer, renderTime)
if err != nil {
p.synth = nil
atomic.StoreInt32(&p.synthNotNil, 0)
}
p.mutex.Unlock()
for i := 0; i < syncs; i++ {
a := syncBuffer[i*totalSyncs]
b := (a+float32(rowTime))/float32(samplesPerRow) + float32(row.Pattern*score.RowsPerPattern+row.Row)
syncBuffer[i*totalSyncs] = b
}
rowTime += timeAdvanced
for window := syncBuffer[:totalSyncs*syncs]; len(window) > 0; window = window[totalSyncs:] {
select {
case syncOutput <- window[:totalSyncs]:
default:
}
}
for i := range p.samplesSinceEvent {
atomic.AddInt32(&p.samplesSinceEvent[i], int32(timeAdvanced))
}
for _, o := range outputs {
o <- buffer[:rendered*2]
}
buffer2, buffer = buffer, buffer2
syncBuffer2, syncBuffer = syncBuffer, syncBuffer2
} else {
rowTime += len(zeros) / 2
for _, o := range outputs {
o <- zeros
}
}
}
}
}()
PlayerPlayingMessage struct {
bool
}
PlayerRecordedMessage struct {
BPM float64 // vsts allow bpms as floats so for accurate reconstruction, keep it as float for recording
Events []PlayerProcessEvent
TotalFrames int
}
// 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{}
}
PlayerCrashMessage struct {
error
}
PlayerVolumeErrorMessage struct {
error
}
voiceNote struct {
voice int
note byte
}
recordEvent struct {
frame int
}
)
const NUM_RENDER_TRIES = 10000
func NewPlayer(synthService sointu.SynthService, playerMessages chan<- PlayerMessage, modelMessages <-chan interface{}) *Player {
p := &Player{
playerMessages: playerMessages,
modelMessages: modelMessages,
synthService: synthService,
volume: Volume{Average: [2]float64{1e-9, 1e-9}, Peak: [2]float64{1e-9, 1e-9}},
}
return p
}
// Trigger is used to manually play a note on the sequencer when jamming. It is
// thread-safe. It starts to play one of the voice in the range voiceStart
// (inclusive) and voiceEnd (exclusive). It returns a id that can be called to
// release the voice playing the note (in case the voice has not been captured
// by someone else already).
func (p *Player) Trigger(voiceStart, voiceEnd int, note byte) uint32 {
if note <= 1 {
return 0
func (p *Player) Process(buffer []float32, context PlayerProcessContext) {
p.processMessages(context)
midi, midiOk := context.NextEvent()
frame := 0
if p.recording && p.recordingNoteArrived {
p.recordingFrames += len(buffer) / 2
}
p.mutex.Lock()
id := p.trigger(voiceStart, voiceEnd, note)
p.mutex.Unlock()
return id
oldBuffer := buffer
for i := 0; i < NUM_RENDER_TRIES; i++ {
for midiOk && frame >= midi.Frame {
if p.recording {
if !p.recordingNoteArrived {
p.recordingFrames = len(buffer) / 2
p.recordingNoteArrived = true
}
midiTotalFrame := midi
midiTotalFrame.Frame = p.recordingFrames - len(buffer)/2
p.recordingEvents = append(p.recordingEvents, midiTotalFrame)
}
if midi.On {
p.triggerInstrument(midi.Channel, midi.Note)
} else {
p.releaseInstrument(midi.Channel, midi.Note)
}
midi, midiOk = context.NextEvent()
}
framesUntilMidi := len(buffer) / 2
if delta := midi.Frame - frame; midiOk && delta < framesUntilMidi {
framesUntilMidi = delta
}
if p.playing && p.rowtime >= p.samplesPerRow {
p.advanceRow()
}
timeUntilRowAdvance := math.MaxInt32
if p.playing {
timeUntilRowAdvance = p.samplesPerRow - p.rowtime
}
var rendered, timeAdvanced int
var err error
if p.synth != nil {
rendered, timeAdvanced, err = p.synth.Render(buffer[:framesUntilMidi*2], timeUntilRowAdvance)
} else {
mx := framesUntilMidi
if timeUntilRowAdvance < mx {
mx = timeUntilRowAdvance
}
for i := 0; i < mx*2; i++ {
buffer[i] = 0
}
rendered = mx
timeAdvanced = mx
}
if err != nil {
p.synth = nil
p.trySend(PlayerCrashMessage{fmt.Errorf("synth.Render: %w", err)})
}
buffer = buffer[rendered*2:]
frame += rendered
p.rowtime += timeAdvanced
for i := range p.samplesSinceEvent {
p.samplesSinceEvent[i] += rendered
}
alpha := float32(math.Exp(-float64(rendered) / 15000))
for i, released := range p.voiceReleased {
if released {
p.voiceStates[i] *= alpha
} else {
p.voiceStates[i] = (p.voiceStates[i]-0.5)*alpha + 0.5
}
}
// when the buffer is full, return
if len(buffer) == 0 {
err := p.volume.Analyze(oldBuffer, 0.3, 1e-4, 1, -100, 20)
var msg interface{}
if err != nil {
msg = PlayerVolumeErrorMessage{err}
}
p.trySend(msg)
return
}
}
// we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error
p.synth = nil
p.trySend(PlayerCrashMessage{fmt.Errorf("synth did not fill the audio buffer even with %d render calls", NUM_RENDER_TRIES)})
}
// Release is used to manually release a note on the player when jamming.
// Expects an ID that was previously acquired by calling Trigger.
func (p *Player) Release(ID uint32) {
if ID == 0 {
func (p *Player) advanceRow() {
if p.score.Length == 0 || p.score.RowsPerPattern == 0 {
return
}
p.mutex.Lock()
p.release(ID)
p.mutex.Unlock()
p.position.Row++ // advance row (this is why we subtracted one in Play())
p.position = p.position.Wrap(p.score)
p.trySend(nil) // just send volume and song row information
lastVoice := 0
for i, t := range p.score.Tracks {
start := lastVoice
lastVoice = start + t.NumVoices
if p.position.Pattern < 0 || p.position.Pattern >= len(t.Order) {
continue
}
o := t.Order[p.position.Pattern]
if o < 0 || o >= len(t.Patterns) {
continue
}
pat := t.Patterns[o]
if p.position.Row < 0 || p.position.Row >= len(pat) {
continue
}
n := pat[p.position.Row]
switch {
case n == 0:
p.releaseTrack(i)
case n > 1:
p.triggerTrack(i, n)
default: // n == 1
}
}
p.rowtime = 0
}
func (p *Player) trigger(voiceStart, voiceEnd int, note byte) uint32 {
if p.synth == nil {
return 0
func (p *Player) processMessages(context PlayerProcessContext) {
loop:
for { // process new message
select {
case msg := <-p.modelMessages:
switch m := msg.(type) {
case ModelPanicMessage:
if m.bool {
p.synth = nil
} else {
p.compileOrUpdateSynth()
}
case ModelPatchChangedMessage:
p.patch = m.Patch
p.compileOrUpdateSynth()
case ModelScoreChangedMessage:
p.score = m.Score
case ModelPlayingChangedMessage:
p.playing = m.bool
if !p.playing {
for i := range p.score.Tracks {
p.releaseTrack(i)
}
}
case ModelSamplesPerRowChangedMessage:
p.samplesPerRow = m.int
case ModelPlayFromPositionMessage:
p.playing = true
p.position = m.SongRow
p.position.Row--
p.rowtime = math.MaxInt
for i, t := range p.score.Tracks {
if !t.Effect {
// when starting to play from another position, release only non-effect tracks
p.releaseTrack(i)
}
}
case ModelNoteOnMessage:
if m.id.IsInstr {
p.triggerInstrument(m.id.Instr, m.id.Note)
} else {
p.triggerTrack(m.id.Track, m.id.Note)
}
case ModelNoteOffMessage:
if m.id.IsInstr {
p.releaseInstrument(m.id.Instr, m.id.Note)
} else {
p.releaseTrack(m.id.Track)
}
case ModelRecordingMessage:
if m.bool {
p.recording = true
p.recordingEvents = make([]PlayerProcessEvent, 0)
p.recordingFrames = 0
p.recordingNoteArrived = false
} else {
if p.recording && len(p.recordingEvents) > 0 {
bpm, ok := context.BPM()
if !ok {
bpm = 120
}
p.trySend(PlayerRecordedMessage{
BPM: bpm,
Events: p.recordingEvents,
TotalFrames: p.recordingFrames,
})
}
p.recording = false
}
default:
// ignore unknown messages
}
default:
break loop
}
}
var oldestID uint32 = math.MaxUint32
p.runningID++
newID := p.runningID
}
func (p *Player) compileOrUpdateSynth() {
if p.synth != nil {
err := p.synth.Update(p.patch)
if err != nil {
p.synth = nil
p.trySend(PlayerCrashMessage{fmt.Errorf("synth.Update: %w", err)})
return
}
} else {
var err error
p.synth, err = p.synthService.Compile(p.patch)
if err != nil {
p.synth = nil
p.trySend(PlayerCrashMessage{fmt.Errorf("synthService.Compile: %w", err)})
return
}
for i := 0; i < 32; i++ {
p.synth.Release(i)
}
}
}
// 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}:
default:
}
}
func (p *Player) triggerInstrument(instrument int, note byte) {
ID := idForInstrumentNote(instrument, note)
p.release(ID)
voiceStart := p.patch.FirstVoiceForInstrument(instrument)
voiceEnd := voiceStart + p.patch[instrument].NumVoices
p.trigger(voiceStart, voiceEnd, note, ID)
}
func (p *Player) releaseInstrument(instrument int, note byte) {
p.release(idForInstrumentNote(instrument, note))
}
func (p *Player) triggerTrack(track int, note byte) {
ID := idForTrack(track)
p.release(ID)
voiceStart := p.score.FirstVoiceForTrack(track)
voiceEnd := voiceStart + p.score.Tracks[track].NumVoices
p.trigger(voiceStart, voiceEnd, note, ID)
}
func (p *Player) releaseTrack(track int) {
p.release(idForTrack(track))
}
func (p *Player) trigger(voiceStart, voiceEnd int, note byte, ID int) {
if p.synth == nil {
return
}
var age int = 0
oldestReleased := false
oldestVoice := 0
for i := voiceStart; i < voiceEnd; i++ {
for len(p.voiceReleased) <= i {
p.voiceReleased = append(p.voiceReleased, 1)
p.voiceReleased = append(p.voiceReleased, true)
}
for len(p.samplesSinceEvent) <= i {
p.samplesSinceEvent = append(p.samplesSinceEvent, 0)
@ -274,43 +370,44 @@ func (p *Player) trigger(voiceStart, voiceEnd int, note byte) uint32 {
// then we prefer to trigger that over a voice that is still playing. in
// case two voices are both playing or or both are released, we prefer
// the older one
id := p.voiceNoteID[i]
isReleased := atomic.LoadInt32(&p.voiceReleased[i]) == 1
if id < oldestID && (oldestReleased == isReleased) || (!oldestReleased && isReleased) {
if (p.voiceReleased[i] && !oldestReleased) ||
(p.voiceReleased[i] == oldestReleased && p.samplesSinceEvent[i] >= age) {
oldestVoice = i
oldestID = id
oldestReleased = isReleased
oldestReleased = p.voiceReleased[i]
age = p.samplesSinceEvent[i]
}
}
p.voiceNoteID[oldestVoice] = newID
atomic.StoreInt32(&p.voiceReleased[oldestVoice], 0)
atomic.StoreInt32(&p.samplesSinceEvent[oldestVoice], 0)
p.voiceNoteID[oldestVoice] = ID
p.voiceReleased[oldestVoice] = false
p.voiceStates[oldestVoice] = 1.0
p.samplesSinceEvent[oldestVoice] = 0
if p.synth != nil {
p.synth.Trigger(oldestVoice, note)
}
return newID
}
func (p *Player) release(ID uint32) {
func (p *Player) release(ID int) {
if p.synth == nil {
return
}
for i := 0; i < len(p.voiceNoteID); i++ {
if p.voiceNoteID[i] == ID && atomic.LoadInt32(&p.voiceReleased[i]) != 1 {
atomic.StoreInt32(&p.voiceReleased[i], 1)
atomic.StoreInt32(&p.samplesSinceEvent[i], 0)
if p.voiceNoteID[i] == ID && !p.voiceReleased[i] {
p.voiceReleased[i] = true
p.samplesSinceEvent[i] = 0
p.synth.Release(i)
return
}
}
}
func packPosition(pos SongRow) uint64 {
return (uint64(uint32(pos.Pattern)) << 32) + uint64(uint32(pos.Row))
// we need to give voices triggered by different sources a identifier who triggered it
// positive values are for voices triggered by instrument jamming i.e. MIDI message from
// host or pressing key on the keyboard
// negative values are for voices triggered by tracks when playing a song
func idForInstrumentNote(instrument int, note byte) int {
return instrument*256 + int(note)
}
func unpackPosition(packedPos uint64) SongRow {
pattern := int(int32(packedPos >> 32))
row := int(int32(packedPos & 0xFFFFFFFF))
return SongRow{Pattern: pattern, Row: row}
func idForTrack(track int) int {
return -1 - track
}

145
tracker/recording.go Normal file
View File

@ -0,0 +1,145 @@
package tracker
import (
"math"
"github.com/vsariola/sointu"
)
type (
recordingNote struct {
note byte
startRow int
endRow int
}
)
func RecordingToSong(patch sointu.Patch, rowsPerBeat, rowsPerPattern int, recording PlayerRecordedMessage) sointu.Song {
channelNotes := make([][]recordingNote, 0)
// find the length of each note and assign it to its respective channel
for i, m := range recording.Events {
if !m.On || m.Channel >= len(patch) {
continue
}
endFrame := math.MaxInt
for j := i + 1; j < len(recording.Events); j++ {
if recording.Events[j].Channel == m.Channel && recording.Events[j].Note == m.Note {
endFrame = recording.Events[j].Frame
break
}
}
for len(channelNotes) <= m.Channel {
channelNotes = append(channelNotes, make([]recordingNote, 0))
}
startRow := frameToRow(recording.BPM, rowsPerBeat, m.Frame)
endRow := frameToRow(recording.BPM, rowsPerBeat, endFrame)
channelNotes[m.Channel] = append(channelNotes[m.Channel], recordingNote{m.Note, startRow, endRow})
}
//assign notes to tracks, assigning it to left most track that is released
// if none is released, assign it to new track if there's any. otherwise, assign it to the left most track
tracks := make([][][]recordingNote, len(channelNotes))
for i, c := range channelNotes {
tracks[i] = make([][]recordingNote, 0)
noteloop:
for _, n := range c {
// if a track is release, assign the note to left-most released track
for k, t := range tracks[i] {
if len(t) == 0 || t[len(t)-1].endRow <= n.startRow {
tracks[i][k] = append(t, n)
continue noteloop
}
}
// if there's space for more tracks, create one
if len(tracks[i]) < patch[i].NumVoices {
tracks[i] = append(tracks[i], []recordingNote{n})
continue noteloop
}
// otherwise, put the note to the track that was triggered longest time ago
oldestIndex := -1
oldestRow := math.MaxInt
for k, t := range tracks[i] {
if r := t[len(t)-1].startRow; r < oldestRow {
oldestRow = r
oldestIndex = k
}
}
tracks[i][oldestIndex] = append(tracks[i][oldestIndex], n)
}
}
songLengthPatterns := (frameToRow(recording.BPM, rowsPerBeat, recording.TotalFrames) + rowsPerPattern - 1) / rowsPerPattern
songLengthRows := songLengthPatterns * rowsPerPattern
songTracks := make([]sointu.Track, 0)
for i, tg := range tracks {
for j, t := range tg {
// construct flat linear note arrays for tracks
flatPattern := make(sointu.Pattern, songLengthRows)
for k := range flatPattern {
flatPattern[k] = 1 // set all notes as holds at first
}
for _, n := range t {
flatPattern.Set(n.startRow, n.note)
if n.endRow < songLengthRows {
for l := n.startRow + 1; l < n.endRow; l++ {
flatPattern.Set(l, 1)
}
flatPattern.Set(n.endRow, 0)
} else {
for l := n.startRow + 1; l < songLengthRows; l++ {
flatPattern.Set(l, 1)
}
}
}
// calculate number of voices, distributing the total number of voices to the different tracks
numVoices := (patch[i].NumVoices + len(tg) - j - 1) / len(tg)
// construct patterns
order := make(sointu.Order, songLengthPatterns)
patterns := make([]sointu.Pattern, 0)
L:
for k := range order {
p := flatPattern[k*rowsPerPattern : (k+1)*rowsPerPattern]
allHolds := true
for _, n := range p {
if n != 1 {
allHolds = false
break
}
}
if allHolds {
order[k] = -1
continue L
}
for l, p2 := range patterns {
if testEq(p, p2) {
order[k] = l
continue L
}
}
// make a copy of the slice so they are all independent and don't accidentally expand to same memory
newPat := make(sointu.Pattern, len(p))
copy(newPat, p)
order[k] = len(patterns)
patterns = append(patterns, newPat)
}
track := sointu.Track{NumVoices: numVoices, Effect: false, Order: order, Patterns: patterns}
songTracks = append(songTracks, track)
}
}
score := sointu.Score{Length: songLengthPatterns, RowsPerPattern: rowsPerPattern, Tracks: songTracks}
return sointu.Song{BPM: int(recording.BPM + 0.5), RowsPerBeat: rowsPerBeat, Score: score, Patch: patch.Copy()}
}
func frameToRow(BPM float64, rowsPerBeat, frame int) int {
return int(float64(frame)/44100/60*BPM*float64(rowsPerBeat) + 0.5)
}
func testEq(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@ -29,40 +29,34 @@ type Volume struct {
//
// minVolume is just a hard limit for the vuanalyzer volumes, in decibels, just to
// prevent negative infinities for volumes
func VuAnalyzer(tau float64, attack float64, release float64, minVolume float64, maxVolume float64, bc <-chan []float32, vc chan<- Volume, ec chan<- error) {
v := Volume{Average: [2]float64{minVolume, minVolume}, Peak: [2]float64{minVolume, minVolume}}
func (v *Volume) Analyze(buffer []float32, 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))
for buffer := range bc {
for j := 0; j < 2; j++ {
for i := 0; i < len(buffer); i += 2 {
sample2 := float64(buffer[i+j] * buffer[i+j])
if math.IsNaN(sample2) {
select {
case ec <- errors.New("NaN detected in master output"):
default:
}
continue
var err error
for j := 0; j < 2; j++ {
for i := 0; i < len(buffer); i += 2 {
sample2 := float64(buffer[i+j] * buffer[i+j])
if math.IsNaN(sample2) {
if err == nil {
err = errors.New("NaN detected in master output")
}
dB := 10 * math.Log10(float64(sample2))
if dB < minVolume || math.IsNaN(dB) {
dB = minVolume
}
if dB > maxVolume {
dB = maxVolume
}
v.Average[j] += (dB - v.Average[j]) * alpha
alphaPeak := alphaAttack
if dB < v.Peak[j] {
alphaPeak = alphaRelease
}
v.Peak[j] += (dB - v.Peak[j]) * alphaPeak
continue
}
}
select {
case vc <- v:
default:
dB := 10 * math.Log10(float64(sample2))
if dB < minVolume || math.IsNaN(dB) {
dB = minVolume
}
if dB > maxVolume {
dB = maxVolume
}
v.Average[j] += (dB - v.Average[j]) * alpha
alphaPeak := alphaAttack
if dB < v.Peak[j] {
alphaPeak = alphaRelease
}
v.Peak[j] += (dB - v.Peak[j]) * alphaPeak
}
}
return err
}