7 Commits
v0.5.0 ... 1.0

28 changed files with 563 additions and 90 deletions

3
.gitignore vendored
View File

@ -17,8 +17,9 @@ build/
# Project specific
old/
# VS Code
# IDEs
.vscode/
.idea/
# project specific
# this is autogenerated from bridge.go.in

View File

@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Dragging mouse to select rectangles in the tables
- The standalone tracker can open a MIDI port for receiving MIDI notes
([#166][i166])
- the note editor has a button to allow entering notes by MIDI.
Polyphony is supported if there are tracks available. ([#170][i170])
- Units can have comments, to make it easier to distinguish between units of
same type within an instrument. These comments are also shown when choosing
the send target. ([#114][i114])

View File

@ -511,6 +511,27 @@ Prods using Sointu
- [21](https://demozoo.org/music/338597/) by NR4 / Team210
- [Tausendeins](https://www.pouet.net/prod.php?which=96192) by epoqe & Team210
Notes on Adding New OpCodes
---------------------------
Think of a great new, not already taken name, and extend
* ./patch.go
* `UnitTypes`
* `StackChange()`
* `StackNeed()`
* ./tracker/presets.go
* `defaultUnits`
Call `./vm/generate/generate.go` (e.g. `generate go` inside the ./vm folder)
Now, you need a case distinction inside
* ./vm/go_synth.go -> `Render()`
* for the Go Bytecode VM
* this should work with the next `go build` / `go run`
* ./vm/compiler/templates/<architecture>/sources.asm
* with your given architecture for `-tags=native`, e.g "amd64-386" or "wasm"
* this needs a `ninja sointu` inside your build directory, then `go build` / `go run`
* it seems not to be necessary to call `go clean -cache`, but in doubt, give it a try.
Contributing
------------

15
build_vst_plugin.ps1 Normal file
View File

@ -0,0 +1,15 @@
# Specify "native" or "go" if you only want one VST version.
if ($args -notcontains "go") {
Write-Host "Build VST with ASM synth"
go build -buildmode=c-shared -tags="plugin","native" -o sointu-vsti-native.dll .\cmd\sointu-vsti\
}
if ($args -notcontains "native") {
Write-Host "Build VST with GO synth"
go build -buildmode=c-shared -tags="plugin" -o sointu-vsti-go.dll .\cmd\sointu-vsti\
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/vsariola/sointu/cmd"
"io/ioutil"
"os"
"path/filepath"
@ -14,7 +15,6 @@ import (
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/oto"
"github.com/vsariola/sointu/version"
"github.com/vsariola/sointu/vm/compiler/bridge"
)
func main() {
@ -93,7 +93,7 @@ func main() {
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml)
}
}
buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) // render the song to calculate its length
buffer, err := sointu.Play(cmd.MainSynther, song, nil) // render the song to calculate its length
if err != nil {
return fmt.Errorf("sointu.Play failed: %v", err)
}

View File

@ -10,7 +10,6 @@ import (
"runtime/pprof"
"gioui.org/app"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/cmd"
"github.com/vsariola/sointu/oto"
"github.com/vsariola/sointu/tracker"
@ -18,18 +17,10 @@ import (
"github.com/vsariola/sointu/tracker/gomidi"
)
type PlayerAudioSource struct {
*tracker.Player
playerProcessContext tracker.PlayerProcessContext
}
func (p *PlayerAudioSource) ReadAudio(buf sointu.AudioBuffer) error {
p.Player.Process(buf, p.playerProcessContext)
return nil
}
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
var defaultMidiInput = flag.String("midi-input", "", "connect MIDI input to matching device name")
var firstMidiInput = flag.Bool("first-midi-input", false, "connect MIDI input to first device found")
func main() {
flag.Parse()
@ -54,8 +45,10 @@ func main() {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
}
midiContext := gomidi.NewContext()
defer midiContext.Close()
midiContext.TryToOpenBy(*defaultMidiInput, *firstMidiInput)
model, player := tracker.NewModelPlayer(cmd.MainSynther, midiContext, recoveryFile)
defer model.MIDI.Close()
if a := flag.Args(); len(a) > 0 {
f, err := os.Open(a[0])
if err == nil {
@ -63,10 +56,13 @@ func main() {
}
f.Close()
}
tracker := gioui.NewTracker(model)
audioCloser := audioContext.Play(&PlayerAudioSource{player, midiContext})
trackerUi := gioui.NewTracker(model)
processor := tracker.NewProcessor(player, midiContext, trackerUi)
audioCloser := audioContext.Play(processor)
go func() {
tracker.Main()
trackerUi.Main()
audioCloser.Close()
if *cpuprofile != "" {
pprof.StopCPUProfile()

View File

@ -31,6 +31,8 @@ func (m NullMIDIContext) InputDevices(yield func(tracker.MIDIDevice) bool) {}
func (m NullMIDIContext) Close() {}
func (m NullMIDIContext) HasDeviceOpen() bool { return false }
func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
for c.eventIndex < len(c.events) {
ev := c.events[c.eventIndex]
@ -101,7 +103,7 @@ func init() {
buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...)
}
buf = buf[:out.Frames]
player.Process(buf, &context)
player.Process(buf, &context, nil)
for i := 0; i < out.Frames; i++ {
left[i], right[i] = buf[i][0], buf[i][1]
}

View File

@ -157,6 +157,15 @@ var UnitTypes = map[string]([]UnitParameter){
{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
"envelopexp": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
{Name: "exp_attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: envelopExpDisplayFunc},
{Name: "decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
{Name: "exp_decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: envelopExpDisplayFunc},
{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
"noise": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "shape", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
@ -248,6 +257,10 @@ func engineeringTime(sec float64) (string, string) {
return fmt.Sprintf("%.2f", sec), "s"
}
func envelopExpDisplayFunc(v int) (string, string) {
return fmt.Sprintf("= %.3f", math.Pow(2, 2*float64(64-v)/32)), ""
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
@ -317,7 +330,7 @@ func (u *Unit) StackChange() int {
switch u.Type {
case "addp", "mulp", "pop", "out", "outaux", "aux":
return -1 - u.Parameters["stereo"]
case "envelope", "oscillator", "push", "noise", "receive", "loadnote", "loadval", "in", "compressor":
case "envelope", "envelopexp", "oscillator", "push", "noise", "receive", "loadnote", "loadval", "in", "compressor":
return 1 + u.Parameters["stereo"]
case "pan":
return 1 - u.Parameters["stereo"]
@ -337,7 +350,7 @@ func (u *Unit) StackNeed() int {
return 0
}
switch u.Type {
case "", "envelope", "oscillator", "noise", "receive", "loadnote", "loadval", "in":
case "", "envelope", "envelopexp", "oscillator", "noise", "receive", "loadnote", "loadval", "in":
return 0
case "mulp", "mul", "add", "addp", "xch":
return 2 * (1 + u.Parameters["stereo"])

BIN
sointu-vsti-go-v1.0.dll Normal file

Binary file not shown.

BIN
sointu-vsti-native-v1.0.dll Normal file

Binary file not shown.

30
song.go
View File

@ -2,6 +2,7 @@ package sointu
import (
"errors"
"iter"
)
type (
@ -331,3 +332,32 @@ func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int) {
}
return
}
func (s *Song) InstrumentForTrack(trackIndex int) (int, bool) {
voiceIndex := s.Score.FirstVoiceForTrack(trackIndex)
instrument, err := s.Patch.InstrumentForVoice(voiceIndex)
return instrument, err == nil
}
func (s *Song) AllTracksWithSameInstrument(trackIndex int) iter.Seq[int] {
return func(yield func(int) bool) {
currentInstrument, currentExists := s.InstrumentForTrack(trackIndex)
if !currentExists {
return
}
for i := 0; i < len(s.Score.Tracks); i++ {
instrument, exists := s.InstrumentForTrack(i)
if !exists {
return
}
if instrument != currentInstrument {
continue
}
if !yield(i) {
return
}
}
}
}

View File

@ -475,12 +475,12 @@ func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFl
func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) }
func (m *Model) SelectMidiInput(item MIDIDevice) Action {
return Allow(func() {
if err := item.Open(); err != nil {
message := fmt.Sprintf("Could not open MIDI device: %s", item)
m.Alerts().Add(message, Error)
} else {
if err := item.Open(); err == nil {
message := fmt.Sprintf("Opened MIDI device: %s", item)
m.Alerts().Add(message, Info)
} else {
message := fmt.Sprintf("Could not open MIDI device: %s", item)
m.Alerts().Add(message, Error)
}
})
}

View File

@ -16,6 +16,7 @@ type (
Playing Model
InstrEnlarged Model
Effect Model
TrackMidiIn Model
CommentExpanded Model
Follow Model
UnitSearching Model
@ -44,6 +45,7 @@ func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) }
func (m *Model) Playing() *Playing { return (*Playing)(m) }
func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) }
func (m *Model) Effect() *Effect { return (*Effect)(m) }
func (m *Model) TrackMidiIn() *TrackMidiIn { return (*TrackMidiIn)(m) }
func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) }
func (m *Model) Follow() *Follow { return (*Follow)(m) }
func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) }
@ -110,6 +112,13 @@ func (m *Follow) Value() bool { return m.follow }
func (m *Follow) setValue(val bool) { m.follow = val }
func (m *Follow) Enabled() bool { return true }
// TrackMidiIn (Midi Input for notes in the tracks)
func (m *TrackMidiIn) Bool() Bool { return Bool{m} }
func (m *TrackMidiIn) Value() bool { return m.trackMidiIn }
func (m *TrackMidiIn) setValue(val bool) { m.trackMidiIn = val }
func (m *TrackMidiIn) Enabled() bool { return m.MIDI.HasDeviceOpen() }
// Effect methods
func (m *Effect) Bool() Bool { return Bool{m} }

View File

@ -3,6 +3,7 @@ package gioui
import (
"fmt"
"image"
"image/color"
"strconv"
"strings"
@ -62,6 +63,7 @@ type NoteEditor struct {
NoteOffBtn *ActionClickable
EffectBtn *BoolClickable
UniqueBtn *BoolClickable
TrackMidiInBtn *BoolClickable
scrollTable *ScrollTable
eventFilters []event.Filter
@ -85,6 +87,7 @@ func NewNoteEditor(model *tracker.Model) *NoteEditor {
NoteOffBtn: NewActionClickable(model.EditNoteOff()),
EffectBtn: NewBoolClickable(model.Effect().Bool()),
UniqueBtn: NewBoolClickable(model.UniquePatterns().Bool()),
TrackMidiInBtn: NewBoolClickable(model.TrackMidiIn().Bool()),
scrollTable: NewScrollTable(
model.Notes().Table(),
model.Tracks().List(),
@ -162,6 +165,7 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
}
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
uniqueBtnStyle := ToggleIcon(gtx, t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
midiInBtnStyle := ToggleButton(gtx, t.Theme, te.TrackMidiInBtn, "MIDI")
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }),
layout.Rigid(addSemitoneBtnStyle.Layout),
@ -175,6 +179,8 @@ func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
layout.Rigid(voiceUpDown),
layout.Rigid(splitTrackBtnStyle.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(midiInBtnStyle.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout))
})
@ -217,7 +223,13 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
h := gtx.Dp(unit.Dp(trackColTitleHeight))
title := ((*tracker.Order)(t.Model)).Title(i)
gtx.Constraints = layout.Exact(image.Pt(pxWidth, h))
LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx)
LabelStyle{
Alignment: layout.N,
Text: title,
FontSize: unit.Sp(12),
Color: mediumEmphasisTextColor,
Shaper: t.Theme.Shaper,
}.Layout(gtx)
return D{Size: image.Pt(pxWidth, h)}
}
@ -253,8 +265,10 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
return D{Size: image.Pt(w, pxHeight)}
}
drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2()
cursor := te.scrollTable.Table.Cursor()
drawSelection := cursor != te.scrollTable.Table.Cursor2()
selection := te.scrollTable.Table.Range()
hasTrackMidiIn := te.TrackMidiInBtn.Bool.Value()
cell := func(gtx C, x, y int) D {
// draw the background, to indicate selection
@ -268,21 +282,25 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
}
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
// draw the cursor
if point == te.scrollTable.Table.Cursor() {
cw := gtx.Constraints.Min.X
cx := 0
if t.Model.Notes().Effect(x) {
cw /= 2
if t.Model.Notes().LowNibble() {
cx += cw
}
}
if point == cursor {
c := inactiveSelectionColor
if te.scrollTable.Focused() {
c = cursorColor
}
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
if hasTrackMidiIn {
c = cursorForTrackMidiInColor
}
te.paintColumnCell(gtx, x, t, c)
}
// draw the corresponding "fake cursors" for instrument-track-groups (for polyphony)
if hasTrackMidiIn {
for trackIndex := range ((*tracker.Order)(t.Model)).TrackIndicesForCurrentInstrument() {
if x == trackIndex && y == cursor.Y {
te.paintColumnCell(gtx, x, t, cursorNeighborForTrackMidiInColor)
}
}
}
// draw the pattern marker
rpp := max(t.RowsPerPattern().Value(), 1)
pat := y / rpp
@ -317,6 +335,18 @@ func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
return table.Layout(gtx)
}
func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) {
cw := gtx.Constraints.Min.X
cx := 0
if t.Model.Notes().Effect(x) {
cw /= 2
if t.Model.Notes().LowNibble() {
cx += cw
}
}
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
}
func mod(x, d int) int {
x = x % d
if x >= 0 {
@ -338,7 +368,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil {
t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble())
n = t.Model.Notes().Value(te.scrollTable.Table.Cursor())
goto validNote
te.finishNoteInsert(t, n, e.Name)
}
} else {
action, ok := keyBindingMap[e]
@ -347,11 +377,7 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
}
if action == "NoteOff" {
t.Model.Notes().Table().Fill(0)
if step := t.Model.Step().Value(); step > 0 {
te.scrollTable.Table.MoveCursor(0, step)
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
}
te.scrollTable.EnsureCursorVisible()
te.finishNoteInsert(t, 0, "")
return
}
if action[:4] == "Note" {
@ -361,20 +387,43 @@ func (te *NoteEditor) command(t *Tracker, e key.Event) {
}
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val-12)
t.Model.Notes().Table().Fill(int(n))
goto validNote
te.finishNoteInsert(t, n, e.Name)
}
}
return
validNote:
}
func (te *NoteEditor) finishNoteInsert(t *Tracker, note byte, keyName key.Name) {
if step := t.Model.Step().Value(); step > 0 {
te.scrollTable.Table.MoveCursor(0, step)
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
}
te.scrollTable.EnsureCursorVisible()
if _, ok := t.KeyPlaying[e.Name]; !ok {
trk := te.scrollTable.Table.Cursor().X
t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n)
if keyName == "" {
return
}
if _, ok := t.KeyPlaying[keyName]; !ok {
trk := te.scrollTable.Table.Cursor().X
t.KeyPlaying[keyName] = t.TrackNoteOn(trk, note)
}
}
func (te *NoteEditor) HandleMidiInput(t *Tracker) {
inputDeactivated := !t.Model.TrackMidiIn().Value()
if inputDeactivated {
return
}
te.scrollTable.Table.SetCursor2(te.scrollTable.Table.Cursor())
remaining := (*tracker.Order)(t.Model).CountNextTracksForCurrentInstrument()
for i, note := range t.MidiNotePlaying {
t.Model.Notes().Table().Set(note)
te.scrollTable.Table.MoveCursor(1, 0)
te.scrollTable.EnsureCursorVisible()
if i >= remaining {
break
}
}
te.scrollTable.Table.SetCursor(te.scrollTable.Table.Cursor2())
}
/*

View File

@ -120,11 +120,17 @@ func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D {
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx,
menuLayouts := []layout.FlexChild{
layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)),
layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)),
layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)),
)
}
if len(t.midiMenuItems) > 0 {
menuLayouts = append(
menuLayouts,
layout.Rigid(tr.layoutMenu(gtx, "MIDI", &t.MenuBar[2], &t.Menus[2], unit.Dp(200), t.midiMenuItems...)),
)
}
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}.Layout(gtx, menuLayouts...)
}
func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {

View File

@ -60,6 +60,8 @@ var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255}
var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48}
var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12}
var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16}
var cursorForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 48}
var cursorNeighborForTrackMidiInColor = color.NRGBA{R: 255, G: 100, B: 140, A: 24}
var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255}

View File

@ -35,6 +35,7 @@ type (
BottomHorizontalSplit *Split
VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID
MidiNotePlaying []byte
PopupAlert *PopupAlert
SaveChangesDialog *Dialog
@ -77,6 +78,7 @@ func NewTracker(model *tracker.Model) *Tracker {
VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[key.Name]tracker.NoteID),
MidiNotePlaying: make([]byte, 0, 32),
SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
InstrumentEditor: NewInstrumentEditor(model),
@ -306,3 +308,46 @@ func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
},
)
}
/// Event Handling (for UI updates when playing etc.)
func (t *Tracker) ProcessMessage(msg interface{}) {
switch msg.(type) {
case tracker.StartPlayMsg:
fmt.Println("Tracker received StartPlayMsg")
case tracker.RecordingMsg:
fmt.Println("Tracker received RecordingMsg")
default:
break
}
}
func (t *Tracker) ProcessEvent(event tracker.MIDINoteEvent) {
// MIDINoteEvent can be only NoteOn / NoteOff, i.e. its On field
if event.On {
t.addToMidiNotePlaying(event.Note)
} else {
t.removeFromMidiNotePlaying(event.Note)
}
t.TrackEditor.HandleMidiInput(t)
}
func (t *Tracker) addToMidiNotePlaying(note byte) {
for _, n := range t.MidiNotePlaying {
if n == note {
return
}
}
t.MidiNotePlaying = append(t.MidiNotePlaying, note)
}
func (t *Tracker) removeFromMidiNotePlaying(note byte) {
for i, n := range t.MidiNotePlaying {
if n == note {
t.MidiNotePlaying = append(
t.MidiNotePlaying[:i],
t.MidiNotePlaying[i+1:]...,
)
}
}
}

View File

@ -8,6 +8,7 @@ import (
"gitlab.com/gomidi/midi/v2"
"gitlab.com/gomidi/midi/v2/drivers"
"gitlab.com/gomidi/midi/v2/drivers/rtmididrv"
"strings"
)
type (
@ -56,7 +57,7 @@ func (m RTMIDIDevice) Open() error {
if m.context.driver == nil {
return errors.New("no driver available")
}
if m.context.currentIn != nil && m.context.currentIn.IsOpen() {
if m.context.HasDeviceOpen() {
m.context.currentIn.Close()
}
m.context.currentIn = m.in
@ -120,3 +121,24 @@ func (c *RTMIDIContext) Close() {
}
c.driver.Close()
}
func (c *RTMIDIContext) HasDeviceOpen() bool {
return c.currentIn != nil && c.currentIn.IsOpen()
}
func (c *RTMIDIContext) TryToOpenBy(namePrefix string, takeFirst bool) {
if namePrefix == "" && !takeFirst {
return
}
for input := range c.InputDevices {
if takeFirst || strings.HasPrefix(input.String(), namePrefix) {
input.Open()
return
}
}
if takeFirst {
fmt.Errorf("Could not find any MIDI Input.\n")
} else {
fmt.Errorf("Could not find any default MIDI Input starting with \"%s\".\n", namePrefix)
}
}

View File

@ -78,9 +78,10 @@ type (
synther sointu.Synther // the synther used to create new synths
PlayerMessages chan PlayerMsg
modelMessages chan<- interface{}
ModelMessages chan<- interface{}
MIDI MIDIContext
MIDI MIDIContext
trackMidiIn bool
}
// Cursor identifies a row and a track in a song score.
@ -130,6 +131,7 @@ type (
MIDIContext interface {
InputDevices(yield func(MIDIDevice) bool)
Close()
HasDeviceOpen() bool
}
MIDIDevice interface {
@ -183,9 +185,10 @@ func NewModelPlayer(synther sointu.Synther, midiContext MIDIContext, recoveryFil
m := new(Model)
m.synther = synther
m.MIDI = midiContext
m.trackMidiIn = midiContext.HasDeviceOpen()
modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan PlayerMsg, 1024)
m.modelMessages = modelMessages
m.ModelMessages = modelMessages
m.PlayerMessages = playerMessages
m.d.Octave = 4
m.linkInstrTrack = true
@ -421,7 +424,7 @@ func (m *Model) resetSong() {
// send sends a message to the player
func (m *Model) send(message interface{}) {
m.modelMessages <- message
m.ModelMessages <- message
}
func (m *Model) maxID() int {

View File

@ -271,7 +271,7 @@ func FuzzModel(f *testing.F) {
break loop
default:
ctx := NullContext{}
player.Process(buf, ctx)
player.Process(buf, ctx, nil)
}
}
}()

View File

@ -41,6 +41,11 @@ type (
BPM() (bpm float64, ok bool)
}
EventProcessor interface {
ProcessMessage(msg interface{})
ProcessEvent(event MIDINoteEvent)
}
// MIDINoteEvent is a MIDI event triggering or releasing a note. In
// processing, the Frame is relative to the start of the current buffer. In
// a Recording, the Frame is relative to the start of the recording.
@ -89,9 +94,11 @@ const numRenderTries = 10000
// model. context tells the player which MIDI events happen during the current
// buffer. It is used to trigger and release notes during processing. The
// context is also used to get the current BPM from the host.
func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext) {
p.processMessages(context)
func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext, ui EventProcessor) {
p.processMessages(context, ui)
midi, midiOk := context.NextEvent()
frame := 0
if p.recState == recStateRecording {
@ -116,6 +123,10 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
} else {
p.releaseInstrument(midi.Channel, midi.Note)
}
if ui != nil {
ui.ProcessEvent(midi)
}
midi, midiOk = context.NextEvent()
}
framesUntilMidi := len(buffer)
@ -224,7 +235,7 @@ func (p *Player) advanceRow() {
p.rowtime = 0
}
func (p *Player) processMessages(context PlayerProcessContext) {
func (p *Player) processMessages(context PlayerProcessContext, uiProcessor EventProcessor) {
loop:
for { // process new message
select {
@ -296,6 +307,9 @@ loop:
default:
// ignore unknown messages
}
if uiProcessor != nil {
uiProcessor.ProcessMessage(msg)
}
default:
break loop
}

View File

@ -44,6 +44,7 @@ func init() {
var defaultUnits = map[string]sointu.Unit{
"envelope": {Type: "envelope", Parameters: map[string]int{"stereo": 0, "attack": 64, "decay": 64, "sustain": 64, "release": 64, "gain": 64}},
"envelopexp": {Type: "envelopexp", Parameters: map[string]int{"stereo": 0, "attack": 64, "exp_attack": 64, "decay": 64, "exp_decay": 64, "sustain": 64, "release": 64, "gain": 64}},
"oscillator": {Type: "oscillator", Parameters: map[string]int{"stereo": 0, "transpose": 64, "detune": 64, "phase": 0, "color": 64, "shape": 64, "gain": 64, "type": sointu.Sine}},
"noise": {Type: "noise", Parameters: map[string]int{"stereo": 0, "shape": 64, "gain": 64}},
"mulp": {Type: "mulp", Parameters: map[string]int{"stereo": 0}},

20
tracker/processor.go Normal file
View File

@ -0,0 +1,20 @@
package tracker
import (
"github.com/vsariola/sointu"
)
type Processor struct {
*Player
playerProcessContext PlayerProcessContext
uiProcessor EventProcessor
}
func NewProcessor(player *Player, context PlayerProcessContext, uiProcessor EventProcessor) *Processor {
return &Processor{player, context, uiProcessor}
}
func (p *Processor) ReadAudio(buf sointu.AudioBuffer) error {
p.Player.Process(buf, p.playerProcessContext, p.uiProcessor)
return nil
}

View File

@ -1,6 +1,7 @@
package tracker
import (
"iter"
"math"
"github.com/vsariola/sointu"
@ -117,6 +118,13 @@ func (v Table) Clear() {
}
}
func (v Table) Set(value byte) {
defer v.change("Set", MajorChange)()
cursor := v.Cursor()
// TODO: might check for visibility
v.set(cursor, int(value))
}
func (v Table) Fill(value int) {
defer v.change("Fill", MajorChange)()
rect := v.Range()
@ -364,12 +372,8 @@ func (e *Order) Title(x int) (title string) {
if x < 0 || x >= len(e.d.Song.Score.Tracks) {
return
}
t := e.d.Song.Score.Tracks[x]
firstVoice := e.d.Song.Score.FirstVoiceForTrack(x)
lastVoice := firstVoice + t.NumVoices - 1
firstIndex, err := e.d.Song.Patch.InstrumentForVoice(firstVoice)
lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice)
if err != nil || err2 != nil {
firstIndex, lastIndex, err := e.instrumentListFor(x)
if err != nil {
return
}
switch diff := lastIndex - firstIndex; diff {
@ -397,6 +401,37 @@ func (e *Order) Title(x int) (title string) {
return
}
func (e *Order) instrumentListFor(trackIndex int) (int, int, error) {
track := e.d.Song.Score.Tracks[trackIndex]
firstVoice := e.d.Song.Score.FirstVoiceForTrack(trackIndex)
lastVoice := firstVoice + track.NumVoices - 1
firstIndex, err1 := e.d.Song.Patch.InstrumentForVoice(firstVoice)
if err1 != nil {
return trackIndex, trackIndex, err1
}
lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice)
if err2 != nil {
return trackIndex, trackIndex, err2
}
return firstIndex, lastIndex, nil
}
func (e *Order) TrackIndicesForCurrentInstrument() iter.Seq[int] {
currentTrack := e.d.Cursor.Track
return e.d.Song.AllTracksWithSameInstrument(currentTrack)
}
func (e *Order) CountNextTracksForCurrentInstrument() int {
currentTrack := e.d.Cursor.Track
count := 0
for t := range e.TrackIndicesForCurrentInstrument() {
if t > currentTrack {
count++
}
}
return count
}
// NoteTable
func (v *Notes) Table() Table {

View File

@ -3,7 +3,7 @@
; ENVELOPE opcode: pushes an ADSR envelope value on stack [0,1]
;-------------------------------------------------------------------------------
; Mono: push the envelope value on stack
; Stereo: push the envelope valeu on stack twice
; Stereo: push the envelope value on stack twice
;-------------------------------------------------------------------------------
{{.Func "su_op_envelope" "Opcode"}}
{{- if .StereoAndMono "envelope"}}
@ -63,6 +63,130 @@ su_op_envelope_leave2:
{{end}}
{{if .HasOp "envelopexp" -}}
;-------------------------------------------------------------------------------
; envelopexp opcode: pushes an ADSR envelopeXPERIMENTAL value on stack [0,1]
;-------------------------------------------------------------------------------
; Mono: push the envelopexp value on stack
; Stereo: push the envelopexp value on stack twice
;-------------------------------------------------------------------------------
{{.Func "su_op_envelopexp" "Opcode"}}
{{- if .StereoAndMono "envelopexp"}}
jnc su_op_envelopexp_mono ; Carry Flag tells us whether Stereo
{{- end}}
{{- if .Stereo "envelopexp"}}
call su_op_envelopexp_mono
fld st0 ; clone the mono value to the stack -> makes it stereo
ret
su_op_envelopexp_mono:
{{- end}}
; qm210: I read that the general registers are fastest, so for this calculation, store
; - r10: the exponent of the current segment (A, D, or the default 0.5)
; - in unit.state[3] : the baseline of the current segment ( = sustain for D, S and 0 otherwise)
; ( I tried r8 beforehand, didn't make it work. now it is .WRK + 12 )
; PS: r9 is always used as temporary space for constants or su_nonlinear_map
; and if you change registers that are use somewhere unknown... you know -- danger zone :)
{{.Prepare (.Float 0.5)}} ; this produces mov r9, qword FCONST_0_500000
mov r10, {{.Use (.Float 0.5)}} ; default exponent = 0.5
mov dword [{{.WRK}} + 12], 0 ; default baseline = 0
; <-- qm210
mov eax, dword [{{.INP}}-su_voice.inputs+su_voice.sustain] ; eax = su_instrument.sustain
test eax, eax ; if (eax != 0)
jne su_op_envelopexp_process ; goto process
mov al, {{.InputNumber "envelopexp" "release"}} ; [state]=RELEASE
mov dword [{{.WRK}}], eax ; note that mov al, XXX; mov ..., eax is less bytes than doing it directly
su_op_envelopexp_process:
mov eax, dword [{{.WRK}}] ; al=[state]
fld dword [{{.WRK}}+4] ; x=[level]
cmp al, {{.InputNumber "envelopexp" "sustain"}} ; if (al==SUSTAIN)
je su_op_envelopexp_sustain
su_op_envelopexp_attac:
cmp al, {{.InputNumber "envelopexp" "attack"}} ; if (al!=ATTAC)
jne short su_op_envelopexp_decay ; goto decay
; qm210: see above. if in attack, let r10 point to exp_attack
lea r10, [{{.Input "envelopexp" "exp_attack"}}]
{{.Call "su_nonlinear_map"}} ; a x, where a=attack
faddp st1, st0 ; a+x
fld1 ; 1 a+x
fucomi st1 ; if (a+x<=1) // is attack complete?
fcmovnb st0, st1 ; a+x a+x
jbe short su_op_envelopexp_statechange ; else goto statechange
su_op_envelopexp_decay:
; <-- qm210: storing baseline
cmp al, {{.InputNumber "envelopexp" "decay"}} ; if (al!=DECAY)
jne short su_op_envelopexp_release ; goto release
; qm210: see above. if in decay, let r10 point to exp_decay, and load the sustain into unit.state[3]
lea r10, [{{.Input "envelopexp" "exp_decay"}}]
fld dword [{{.Input "envelopexp" "sustain"}}]
fstp dword[{{.WRK}} + 12]
; <-- qm210
{{.Call "su_nonlinear_map"}} ; d x, where d=decay
fsubp st1, st0 ; x-d
; qm210: we can ignore the sustain here, it will be applied via the "baseline" (cf. above / below)
fldz ; 0 x-d
fucomi st1 ; if (x-d>0) // is decay complete?
fcmovb st0, st1 ; x-d x-d
jnc short su_op_envelopexp_statechange ; else goto statechange
su_op_envelopexp_release:
cmp al, {{.InputNumber "envelopexp" "release"}} ; if (al!=RELEASE)
jne short su_op_envelopexp_applyexp ; goto leave
mov dword [{{.WRK}} + 12], 0 ; <-- qm210: no baseline anymore
{{.Call "su_nonlinear_map"}} ; r x, where r=release
fsubp st1, st0 ; x-r
fldz ; 0 x-r
fucomi st1 ; if (x-r>0) // is release complete?
fcmovb st0, st1 ; x-r x-r, then goto leave
jc short su_op_envelopexp_skipexp
su_op_envelopexp_statechange:
; qm210: this was:
; inc dword [{{.WRK}}] ; [state]++
; but as we land here after attack and decay, which now have another exp_ parameter, skip 2 to reach next state
add dword [{{.WRK}}], 2 ; [state]+=2
su_op_envelopexp_applyexp:
fstp st1 ; x', where x' is the new value
; qm120: store the linear envelope in [level] because this is read again for the next value (cf. "envelope")
fst dword [{{.WRK}} + 4] ; [level]=x'
; qm210: NOW THE ACTUAL EXPONENTIAL SCALING
; - scale the exponent in [0; 1] to [0.125; 8], call that kappa = 2^(6*(expo-0.5))
fld dword [r10] ; stack: [ expo, x' ]
{{.Prepare (.Float 0.5)}}
fld dword [{{.Use (.Float 0.5)}}] ; stack: [ 0.5, expo, x' ]
fsubrp st1, st0 ; stack: [ expo-0.5, x' ]
{{.Prepare (.Int 6)}}
fimul dword [{{.Use (.Int 6)}}] ; stack: [ 6*(expo-0.5), x' ]
{{.Call "su_power"}} ; stack: [ kappa, x' ]
fxch st1 ; stack: [ x', kappa ]
; - now we need (x')^(kappa), but care for x' == 0 first
fldz ; stack: [ 0, x', kappa ]
fucomip st1 ; stack [ x', kappa ] and ZF = (x' == 0)
jz su_op_envelopexp_avoid_zero_glitch
; - still around? calculate the actual x'' = x^kappa then
fyl2x ; stack: [ kappa * log2 x' ]
{{.Call "su_power"}} ; stack: [ x ^ kappa ]
jmp short su_op_envelopexp_applybaseline
su_op_envelopexp_avoid_zero_glitch:
fstp st1
su_op_envelopexp_applybaseline:
; - and scale the result to a different baseline: x''' = (B + (1 - B) * x'') for B != 0 (check not required)
fld dword [{{.WRK}} + 12] ; stack: [ B, x'' ]
fld1 ; stack: [ 1, B, x'' ]
fsub st0, st1 ; stack: [ 1-B, B, x'' ]
fmulp st2, st0 ; stack: [ (1-B) * x'', B ]
faddp st1, st0 ; stack: [ (1-B) * x'' + B ]
jmp short su_op_envelopexp_leave
su_op_envelopexp_sustain:
; qm210: overwrite level, because else the release cannot work
fld dword [{{.Input "envelopexp" "sustain"}}]
su_op_envelopexp_skipexp:
fst dword [{{.WRK}} + 4]
fstp st1
su_op_envelopexp_leave:
; qm210: scaling because I use my wave editor as a function plotter ;)
fmul dword [{{.Input "envelopexp" "gain"}}] ; [gain]*x''
ret
{{end}}
{{- if .HasOp "noise"}}
;-------------------------------------------------------------------------------
; NOISE opcode: creates noise

View File

@ -329,6 +329,45 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t
if stereo {
stack = append(stack, output)
}
case opEnvelopexp:
if !voices[0].sustain {
unit.state[0] = envStateRelease // set state to release
}
state := unit.state[0]
level := unit.state[1]
exponent := float64(1)
baseline := float32(0)
switch state {
case envStateAttack:
exponent = scaledEnvelopExponent(params[1])
level += nonLinearMap(params[0])
if level >= 1 {
level = 1
state = envStateDecay
}
case envStateDecay:
exponent = scaledEnvelopExponent(params[3])
sustain := params[4]
baseline = sustain
level -= nonLinearMap(params[2])
if level <= sustain {
level = sustain
}
case envStateRelease:
level -= nonLinearMap(params[5])
if level <= 0 {
level = 0
}
}
unit.state[0] = state
unit.state[1] = level
expLevel := float32(math.Pow(float64(level), exponent))
output := (baseline + (1-baseline)*expLevel) * params[6]
stack = append(stack, output)
if stereo {
stack = append(stack, output)
}
// <-- END TODO @qm210 -- ACTUALLY IMPLEMENT, BUT FIRST DO THE NATIVE ASM PART
case opNoise:
if stereo {
value := waveshape(synth.rand(), params[0]) * params[1]
@ -610,6 +649,10 @@ func nonLinearMap(value float32) float32 {
return float32(math.Exp2(float64(-24 * value)))
}
func scaledEnvelopExponent(value float32) float64 {
return math.Pow(2, 6*(0.5-float64(value)))
}
func clip(value float32) float32 {
if value < -1 {
return -1

View File

@ -12,27 +12,28 @@ const (
opDelay = 8
opDistort = 9
opEnvelope = 10
opFilter = 11
opGain = 12
opHold = 13
opIn = 14
opInvgain = 15
opLoadnote = 16
opLoadval = 17
opMul = 18
opMulp = 19
opNoise = 20
opOscillator = 21
opOut = 22
opOutaux = 23
opPan = 24
opPop = 25
opPush = 26
opReceive = 27
opSend = 28
opSpeed = 29
opSync = 30
opXch = 31
opEnvelopexp = 11
opFilter = 12
opGain = 13
opHold = 14
opIn = 15
opInvgain = 16
opLoadnote = 17
opLoadval = 18
opMul = 19
opMulp = 20
opNoise = 21
opOscillator = 22
opOut = 23
opOutaux = 24
opPan = 25
opPop = 26
opPush = 27
opReceive = 28
opSend = 29
opSpeed = 30
opSync = 31
opXch = 32
)
var transformCounts = [...]int{0, 0, 1, 0, 5, 1, 1, 4, 1, 5, 2, 1, 1, 0, 1, 0, 1, 0, 0, 2, 6, 1, 2, 1, 0, 0, 0, 1, 0, 0, 0}
var transformCounts = [...]int{0, 0, 1, 0, 5, 1, 1, 4, 1, 5, 7, 2, 1, 1, 0, 1, 0, 1, 0, 0, 2, 6, 1, 2, 1, 0, 0, 0, 1, 0, 0, 0}

19
write_envelexp_wav.ps1 Normal file
View File

@ -0,0 +1,19 @@
cd build
ninja sointu
cd ..
if ($LASTEXITCODE -eq 0) {
if ($args -contains "native") {
Write-Host "Render with ASM synth"
go run -tags=native .\cmd\sointu-play\main.go -w .\examples\envelopexp_dev.yml
} elseif ($args -contains "go") {
Write-Host "Render with GO synth"
go run .\cmd\sointu-play\main.go -w .\examples\envelopexp_dev.yml
} else {
Write-Host "specify either ""native"" or ""go"" argument."
}
}