mirror of
https://github.com/vsariola/sointu.git
synced 2025-07-14 02:54:37 -04:00
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:
parent
70080c2b9d
commit
cd700ed954
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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()
|
||||
}
|
||||
|
15
tracker/gioui/tracker_not_plugin.go
Normal file
15
tracker/gioui/tracker_not_plugin.go
Normal 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
|
||||
}
|
10
tracker/gioui/tracker_plugin.go
Normal file
10
tracker/gioui/tracker_plugin.go
Normal 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
|
||||
}
|
199
tracker/model.go
199
tracker/model.go
@ -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
19
tracker/note_id.go
Normal 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}
|
||||
}
|
@ -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
145
tracker/recording.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
Reference in New Issue
Block a user