272 Commits

Author SHA1 Message Date
02a9d9747f drafting 2026-01-28 21:22:57 +02:00
f88986dc64 draft enums 2026-01-27 23:26:13 +02:00
ca4b87d43d fix(vm): nans in the Go VM trisaw oscillator when the color was 0 2026-01-27 23:10:53 +02:00
86ca3fb300 refactor(tracker): group Model methods, with each group in one source file 2026-01-27 22:16:14 +02:00
b93304adab refactor(tracker) make StringValue implementations private 2026-01-24 00:22:21 +02:00
173648fbdb refactor(tracker): use strings to identify MIDI ports 2026-01-23 23:48:16 +02:00
651ceb3cbb refactor(tracker): make Doer implementations private 2026-01-23 23:02:45 +02:00
1693d7ed5e refactor(tracker): make Model methods return List, avoiding .List() 2026-01-23 22:42:25 +02:00
74beb6760c refactor(tracker): remove unused DoFunc type 2026-01-21 20:50:35 +02:00
6629a9fdfa refactor(tracker): add MakeBoolFromPtr constructor for Bool 2026-01-21 20:45:03 +02:00
60222dded4 refactor(tracker): Enabler is optionally implemented when needed 2026-01-21 19:53:24 +02:00
810998d95b build: upgrade oto, which now uses WASAPI's better resampler 2026-01-19 19:36:10 +02:00
3a7010f897 feat(tracker): spectrum analyzer
Closes #67
2026-01-18 17:09:57 +02:00
4d09e04a49 feat: implement bell filter unit for equalizing 2025-12-29 16:33:00 +02:00
33ee80a908 ci: remove native synth from MacOS and compile arm64 2025-12-29 13:08:12 +02:00
9b87589f7b docs(examples/code/C): add comment showing how to loop the song
Closes #216
2025-11-12 13:44:49 +02:00
16c652b2ba refactor(tracker): clear RailError alert with ClearNamed 2025-11-02 16:51:26 +02:00
dafd45fd81 feat(tracker): allow instrument have no thread, but warn about it 2025-11-02 16:42:30 +02:00
05b64dadc8 fix(tracker/gioui): unit comment editor flashing while cursor moved 2025-11-02 16:20:30 +02:00
6978dd4afe refactor(tracker/gioui): remove unused function parameters 2025-11-02 16:18:24 +02:00
fa9654d311 refactor(tracker/gioui): Surface is given relative Height, not Gray 2025-11-02 15:52:51 +02:00
3495d91a4a refactor(tracker/gioui): remove addUnitAction from InstrumentEditor 2025-11-02 15:51:23 +02:00
628365c486 refactor(tracker): use pointers to bools for simple booleans 2025-11-02 13:09:25 +02:00
9db6b669c9 refactor(tracker/gioui): clean up ScrollTableStyle layouts 2025-11-02 12:05:38 +02:00
a37990a7fa feat(tracker): make clear unit action clear all selected units 2025-11-01 01:06:34 +02:00
4a46d601f2 feat(tracker): decrease the display duration of RailErrors 2025-11-01 00:50:41 +02:00
da6226d3ff feat(tracker/gioui): unit comment in the rack is editable 2025-11-01 00:37:25 +02:00
1dbe351beb fix(tracker/gioui): crash when recovered synth was panicced 2025-11-01 00:29:27 +02:00
91c9701f14 refactor(tracker/gioui): combine UnitList & UnitEditor structs 2025-10-31 21:57:17 +02:00
48dc4a35bb feat(tracker/presets): rename two presets to harmonize naming 2025-10-31 20:11:23 +02:00
9b9dc3548f feat: add multithreaded rendering to the tracker side
The compiled player does not support multithreading, but with this,
users can already start composing songs with slightly less powerful
machines, even when targeting high-end machines.

Related to #199
2025-10-31 19:40:02 +02:00
c583156d1b style(tracker/presets): set the voices of all presets to 1
The voices are anyway set during presets loading, so this value is
not honored.
2025-10-26 19:18:46 +02:00
c1ea47a509 feat(tracker/presets): improving and adding presets 2025-10-26 19:13:19 +02:00
60ae8645b6 fix: tracker thought sync pops a value even if it didn't 2025-10-25 12:25:31 +03:00
362bc3029f feat(tracker/presets): add Noise sweep up & down presets 2025-10-23 15:27:54 +03:00
213202a7e0 feat(tracker/presets): add a modified version of Piano Whitespace 2025-10-19 20:00:36 +03:00
e08af03fb2 feat(tracker/presets): Sine bells and Chaos bass presets from Reaby 2025-10-19 19:34:53 +03:00
8e99c93d14 refactor: use yaml.v3 everywhere and remove dependency on yaml.v2 2025-10-19 17:13:00 +03:00
f4bb2bc754 feat(tracker/presets): add piano from noby's Whitespace intro 2025-10-19 17:02:03 +03:00
de366316d4 fix(tracker/gioui): short action key pressed without handler crash 2025-10-19 16:22:07 +03:00
7a43aec50e fix(tracker/presets): avoid NaNs from color:0 in Alpha_omega preset
The color was just flipped i.e. by using color:128.
2025-10-19 13:21:18 +03:00
82cf34a28f docs: update CHANGELOG.md 2025-10-19 12:07:28 +03:00
2336a135c6 feat(tracker): add a preset explorer with search and filters
Closes #91
2025-10-19 12:04:41 +03:00
3f365707c2 build: update gioui to latest 2025-10-15 10:04:24 +03:00
34c0045652 docs: update CHANGELOG.md 2025-10-14 22:26:31 +03:00
c64422767e fix(tracker/gioui): show minimized loudness in red when > 0 dB 2025-10-14 22:23:16 +03:00
1dcd3fe3c6 refactor(tracker/presets): clean name-fields from presets
The file name will be used as their name anyways.
2025-10-14 17:14:59 +03:00
c0488226d2 fix(tracker/presets): avoid NaNs (pulse instead of trisaw&shape:128) 2025-10-14 16:58:52 +03:00
f894e2ee86 feat(tracker/presets): remove dbgain units from presets 2025-10-14 16:29:10 +03:00
54a8358522 feat(tracker/presets): rework presets & normalize to approx. -12 dBFS true peak 2025-10-14 16:06:05 +03:00
bdfe2d37bf feat(tracker): panic synth if Inf or NaN, and handle these in detectors
Closes #210.
2025-10-08 08:53:46 +03:00
167f541a52 docs: update CHANGELOG.md 2025-10-06 20:49:20 +03:00
be48f5824f docs: update CHANGELOG.md for v0.5.0 2025-10-05 14:12:47 +03:00
989b6e605b feat(tracker/presets): improved and new presets from Reaby 2025-10-05 14:06:00 +03:00
7459437822 feat(tracker): don't save instrument name in instrument files
The filename is used as the instrument name when it is loaded.
2025-10-05 14:06:00 +03:00
55f9c36bd5 feat(tracker/gioui): show file explorer error messages to user 2025-09-27 21:06:26 +03:00
a09b52a912 feat: phase parameter hint is displayed in degrees (0 .. 360) 2025-09-17 20:53:51 +03:00
74fea4138f docs: Update README.md (-o . is not needed; it's the default behavior) 2025-08-13 15:18:39 +03:00
f13a5cd2df docs: Update README.md (#208)
Now it seems bulk memory is enabled by default in wat2wasm

See: https://github.com/WebAssembly/wabt/pull/1728
2025-08-13 07:31:23 +03:00
7f3010a4a6 docs: update CHANGELOG.md 2025-08-08 15:16:50 +03:00
5839471bcc fix(tracker/gioui): limit comments in unit list to single line 2025-07-30 18:10:15 +03:00
fe0106bb60 fix(tracker/gioui): show unit search list after clicking "Add Unit"
Closes #204
2025-07-24 11:03:34 +03:00
3163f46447 feat!: both native & Go synths are included in the same executables
Closes #200
2025-07-10 17:46:00 +03:00
13102aa7d6 docs: update README.md 2025-07-10 10:34:55 +03:00
399bac481c docs: update screenshot 2025-07-10 09:07:40 +03:00
072e4ee208 fix(tracker/gioui): knobs/switches capture scrollwheel only active 2025-07-09 01:37:18 +03:00
edc0782f5f feat: show resonance Q-factor as resonance peak height in dB 2025-07-09 01:17:47 +03:00
697fb05b5c feat: display various gain parameter values in decibels 2025-07-09 00:47:09 +03:00
cf86f3f1c8 feat(tracker/gioui): make knob/switch/port hit box the entire cell 2025-07-08 23:53:03 +03:00
8e5f3098a4 feat(tracker/gioui): switches just clickable & cycle between states 2025-07-08 23:01:55 +03:00
452a4cf04f feat(tracker/gioui): ctrl+drag changes knob value faster
Slow down the normal dragging 4 x slower.
2025-07-08 22:33:19 +03:00
5841848813 feat(tracker): reduce speed of parameter changes when ctrl pressed 2025-07-08 22:26:30 +03:00
0ce79978d5 feat(tracker/gioui): right click resets knobs instead of doubleclick 2025-07-08 22:22:01 +03:00
4138c34574 fix(tracker): make signal stack errors show for much longer time 2025-07-08 22:21:17 +03:00
172fbaeb2a feat(tracker/gioui): make switches left&right clickable when active 2025-07-08 22:10:54 +03:00
666af9433e feat!: display the parameters as knobs in a grid
Also removed the negbandpass & neghighpass parameters
and replaced them with bandpass & highpass set to -1, to
fit the switches better to the GUI.

Closes #51, closes #173
2025-07-08 19:47:32 +03:00
c3caa8de11 fix(tracker/gioui): backspace / delete reset param value 2025-06-26 09:04:05 +03:00
18d7848367 fix(tracker/gioui): using keys to choose Unit Type and tab ordering 2025-06-26 00:33:18 +03:00
192909328c fix(tracker/gioui): try to ensure that tooltip are never left behind
Closes #141
2025-06-25 19:13:52 +03:00
cb4c020061 style(tracker/gioui): rename songpanel.go to song_panel.go 2025-06-25 19:05:36 +03:00
d78ef98e73 refactor(tracker/gioui): upgrade gio & store Tracker to gtx.Values 2025-06-25 18:54:00 +03:00
08c36ed462 feat(tracker/gioui): new tab order logic and refactor instrument editor 2025-06-25 16:32:56 +03:00
d276f52942 docs: update README.md 2025-06-25 13:44:58 +03:00
b8cf70e8e9 refactor(tracker/gioui): use min(max(... instead of ifs 2025-06-24 20:43:48 +03:00
e59fbb50cf refactor(tracker/gioui): separate SplitStyle from SplitState 2025-06-24 20:39:27 +03:00
ba281ca7c0 fix(tracker/gioui): cancel dialog when user clicks outside it 2025-06-24 20:26:14 +03:00
b4ec136ab1 refactor(tracker/gioui): Popup in same style as other widgets 2025-06-24 20:25:52 +03:00
18d198d764 refactor(tracker/gioui): bind Alerts to Model during Layout 2025-06-24 19:59:31 +03:00
355ccefb6f refactor(tracker/gioui): refactor Scope in same style as others 2025-06-24 19:28:53 +03:00
7a030683c6 refactor(tracker/gioui): use precreated hex strings in OrderEditor 2025-06-24 19:10:29 +03:00
17ca15b205 refactor(tracker/gioui): minor optimizations in NoteEditor 2025-06-24 19:04:47 +03:00
58f6cceb9a refactor(tracker/gioui): Menu binds to Model during Layout 2025-06-24 18:39:40 +03:00
b79de95f91 refactor(tracker/gioui): remove unnecessary caching of Strings 2025-06-24 11:07:42 +03:00
f6bc5fffcd docs: update LICENSE to include reaby, for updating the presets 2025-06-24 10:41:57 +03:00
33f7b5fb6a refactor(tracker/gioui): Dialog binds to Model during Layout 2025-06-24 10:15:46 +03:00
5f43bc3067 feat(tracker/gioui): "Ask Help", "Report Bug" and "Manual" menuitems 2025-06-23 19:17:00 +03:00
fb0fa4af92 feat: embed license in executable and add menu item to show it 2025-06-23 18:45:13 +03:00
6f1db6b392 fix(tracker/gioui): make own TipArea ensuring tips don't stay around
Closes #141.
2025-06-23 18:02:05 +03:00
31007515b5 refactor(tracker/gioui): avoid heap escapes in NumericUpDown 2025-06-23 09:43:10 +03:00
db2ccf977d refactor(tracker/gioui): rewrote Button(s) to bind to Model during layout
The old mechanism made it difficult to follow exactly what happens
when a button was clicked, because the Action/Bool that gets
executed / toggled was declared ages ago, in the constructor. In the
new mechanism, the Action / Bool is bound to the button at the last
minute, right before Layout. ActionButton, ToggleButton,
ActionIconButton and ToggleIconButton were done to avoid heap
escapes: if the corresponding functions woudl've returned
layout.Widget, a heap allocation would've been needed.
2025-06-23 08:56:37 +03:00
0ea20ea5bf refactor(tracker/gioui): use enums (iota) for EditorEvent 2025-06-21 12:04:08 +03:00
beef8fe1e0 refactor(tracker/gioui): bind tracker.Int to NumericUpDown on Layout 2025-06-21 11:45:31 +03:00
289bfb0605 refactor: fix all unused parameter / variable warnings 2025-06-21 10:33:08 +03:00
a601b98b74 ci: switch to using clang++ on Mac for rtmidi and add missing libs 2025-06-21 09:57:58 +03:00
602b3b05cc feat(tracker): compile with midi support only when CGO is available
Also add the midi context to the VSTI, so VSTI can use MIDI if they
wish so.
2025-06-20 19:38:06 +03:00
3881b8eb22 fix(tracker/gioui): if user clears unit search box, set unit to "" 2025-06-20 19:10:10 +03:00
4fa0e04788 refactor(tracker/gioui): make iconCache part of Theme 2025-06-20 19:05:40 +03:00
b291959a97 refactor(tracker/gioui): rewrote Editor to link to String.Value() 2025-06-20 18:50:44 +03:00
840fe3ef0e refactor(tracker): remove SetCursorFloat method from TableData 2025-06-20 15:17:21 +03:00
430b01d143 refactor(tracker): remove unused code and improve style 2025-06-20 15:05:22 +03:00
28a0006b6a docs(tracker): improve comments and formatting 2025-06-20 14:57:12 +03:00
8eb5f17f73 style(tracker): oneline functions and remove spurious comments 2025-06-20 14:52:46 +03:00
f47bee37b0 style(tracker): clean up Alerts Push and Pop 2025-06-20 14:49:07 +03:00
4f2c73d0db refactor(tracker): Player sends PlayerStatus to the Model 2025-06-19 11:37:11 +03:00
c77d541dc6 docs: update README.md; claim that native synth is faster was false 2025-06-17 18:22:14 +03:00
340620ed49 feat(tracker): show CPU load percentage in the song panel 2025-06-17 17:59:54 +03:00
1a13fadd75 docs: add link to Discussions to the README.md 2025-06-17 14:12:23 +03:00
b6e8ab5c25 style(examples/code/C): rename .unix. examples to .linux.
Closes #191
2025-06-16 18:55:01 +03:00
c6b70560f6 fix(tracker): update derived data after undo/redo 2025-06-16 18:44:33 +03:00
1eea263dc9 fix(tracker/gioui): show muted instruments in different style 2025-06-16 18:35:06 +03:00
c023dc08b8 fix(tracker/gioui): BPM tooltip showed "Song length", not BPM 2025-06-11 19:43:05 +03:00
0e32608872 refactor(tracker): remove unnecessary Enabled function 2025-06-11 19:41:58 +03:00
283fbc1171 feat(tracker): rework the MIDI input and note event handling 2025-06-11 19:14:11 +03:00
7ef868a434 refactor(tracker): rewrite params to avoid heap allocations 2025-06-11 19:14:11 +03:00
4f779edb88 perf(tracker/gioui): avoid heap escapes in the menubar 2025-06-11 19:14:11 +03:00
d20a23d57b refactor(tracker/gioui): move element etc. functions away from style
Now the element / fg / bg functions are passed to the actual Layout
function, not first put to the style. This avoids moving of the
element function to heap.
2025-06-11 19:14:11 +03:00
de2e64533d refactor(tracker): refactor StringData to StringValue 2025-06-11 19:14:11 +03:00
74f37318d6 refactor(tracker): refactor IntData to IntValue, following Bool example 2025-06-11 19:14:11 +03:00
fb3a0da3ed refactor(tracker): make Bool have separate BoolValue and Enabler 2025-06-11 19:14:11 +03:00
036cb1f34d refactor(tracker): Make Action have separate Doer and Enabler 2025-06-11 19:14:11 +03:00
d6badb97be feat(examples): added playback timestamp extraction to ALSA example. (#190) 2025-06-04 16:44:31 +03:00
d342c9961d build: update gioui to latest version for performance optimization 2025-05-27 12:37:22 +03:00
32f1e1baea refactor(tracker/gioui): unify default & user config yaml handling 2025-05-23 23:35:51 +03:00
5b260d19f5 refactor(tracker/gioui): rename keyevent.go to keybindings.go
This way all the .go files that embed .yml files have matching
file names.
2025-05-23 21:46:34 +03:00
ddbaf6a4bb refactor(tracker): use UnmarshalStrict when decoding embedded yamls
Since we have 100% control over what data gets embedded, there is no
reason to embed anything that doesn't pass the strict yaml parsing
and it's better we throw a panic right away so it's easy to catch
this during development.
2025-05-23 21:44:23 +03:00
27bf8220c0 ci: use go version < 1.23.9 due to duplicate dlopen link error on mac
https://github.com/golang/go/issues/73617
2025-05-21 15:56:43 +03:00
448bc9f236 fix(tracker): OOB checks used index > len, but should've used >= 2025-05-20 19:05:17 +03:00
afb1fee4ed feat(tracker/gioui): add theme.yml which contains all styling 2025-05-20 19:02:16 +03:00
8245fbda24 feat(track/gioui): ctrl + scrollwheel adjusts global GUI zoom
Closes #153
2025-05-01 19:16:39 +03:00
0f42a993dc feat(tracker/gioui): oscilloscope allows y-scaling and shows limits
Closes #61
2025-05-01 12:05:32 +03:00
554a840982 refactor(tracker): new closing mechanism logic 2025-05-01 10:20:41 +03:00
9f89c37956 refactor(tracker): rename trySend to TrySend to make it public 2025-04-30 22:00:34 +03:00
0199658025 style(tracker): use for range loops everywhere in detector.go 2025-04-30 16:24:48 +03:00
afc6b1f4a9 build: update gioui to v0.8.0 2025-04-30 16:22:13 +03:00
3623bdf5b2 refactor(tracker): bake 1 kHz gain offset into filter coeffs 2025-04-29 20:45:14 +03:00
fe9daf7988 fix(tracker): loudness A- and C-weighting did not have proper scale 2025-04-29 15:12:57 +03:00
bf0d697b80 fix(tracker): reset also biquad filter states to avoid endless nans 2025-04-28 15:23:00 +03:00
f72f29188b feat(vm/compiler): increase native synth delaylines to 128
Closes #155
2025-04-27 21:47:27 +03:00
5fd78d8362 feat(tracker): buttons for loudness weighting and peak oversampling
Closes #186
2025-04-27 21:30:10 +03:00
805b98524c fix(tracker/gioui): use Clickables instead of widget.Clickables 2025-04-27 20:24:40 +03:00
54176cc2b3 refactor(tracker/gioui): separate MenuBar from SongPanel 2025-04-27 20:16:35 +03:00
845f0119c8 fix(tracker): peak amplitude dBs should be 20*log10, not 10*log10 2025-04-27 19:55:25 +03:00
5a3c859a51 fix(tracker): also peak detector windows were in wrong order 2025-04-27 19:29:23 +03:00
5c0b86a0f0 fix(tracker): the peak detector result was in wrong layout 2025-04-27 14:13:31 +03:00
e0392323c0 feat(tracker/gioui): add expander panel showing peaks 2025-04-27 14:13:31 +03:00
bb605ffa0b feat(tracker/gioui): add expanders into song panel 2025-04-27 13:03:34 +03:00
40be82de46 feat(tracker/gioui): refactor & rework playbar with the play buttons 2025-04-27 11:34:00 +03:00
42c95ab8ee feat(tracker/gioui): rework the labels of numeric updowns 2025-04-27 09:07:46 +03:00
d0413e0a13 feat(tracker/gioui): rewrite the numeric updown, with new appearance 2025-04-27 09:00:13 +03:00
bdf9e2ba0c feat(tracker/gioui): UI splitter bars snap better to window edges 2025-04-26 01:48:42 +03:00
95af8da939 fix(vm)!: first modulate delay time, then notetracking
BREAKING CHANGE: the order of these operations was inconsistent
across the different VMs. Go VM was the only one to first modulate
and then apply note tracking multiplication. But that made most
sense. So now all different VM versions work in this same way.
2025-04-16 23:17:08 +03:00
78fc6302a0 fix(tracker/gomidi): static cgo linking to avoid DLL dependencies
The linker flags -static -static-libgcc -static-libstdc++ tell mingw
to link statically; otherwise gcc_s_seh-1, stdc++-6 and winpthread-1
are needed.

Fixes #188.
2025-02-28 15:15:17 +02:00
ea4dee9285 docs: add reaby to contributors 2025-02-25 18:57:32 +02:00
ae217665bf feat(tracker/presets): new and tweaked presets from Reaby
The presets are also organized by their type into subfolders.

Closes #136
2025-02-25 18:53:28 +02:00
46a9c7dab3 feat(tracker): preset names include their directories 2025-01-25 22:52:11 +02:00
5ee7e44ed7 fix(tracker): ReadInstrument forgot to close the file 2025-01-25 22:18:28 +02:00
dd7b5ddc84 build: use macos-13 runners, as macos-12 is deprecated 2024-12-07 14:19:50 +02:00
ee229d8d94 build: build vst bundle binaries on macos
Closes #167
2024-12-07 14:13:28 +02:00
6ba595e7ff fix(vm/compiler): produce position independent code on amd64 2024-12-07 14:13:28 +02:00
7ff3c942cb feat(tracker/gioui): preferences.yml for window size or maximized (#185) 2024-12-07 13:54:08 +02:00
4169356845 docs: update CHANGELOG.md 2024-11-15 19:56:18 +02:00
8d71cf3ca7 fix(tracker): MakeSetLength did not handle invalid parameters
(cherry picked from commit 1b824f77ab40dbabba4586de4b97bb113e8ee264)
2024-11-10 00:10:17 +02:00
b255a68ebc fix: changes after review (see PR #176) 2024-11-10 00:02:13 +02:00
d517576a65 feat: introduce "cache" for derived model information 2024-11-10 00:02:13 +02:00
4d7c998fc2 doc: changelog 2024-11-10 00:02:13 +02:00
55c062a390 feat: highlight sliders that are controlled by a send, and add tooltip (over value) 2024-11-10 00:02:13 +02:00
b423d04c17 feat: separate unit type from comment (now in quotes) in target dropdowns 2024-11-10 00:02:13 +02:00
639b2266e3 feat: focus search editor after "add unit" 2024-11-10 00:02:13 +02:00
8d7d896375 docs: update README.md 2024-11-08 10:49:17 +02:00
04deac5722 fix(tracker): use non-blocking sends from Model to Player
This ensures that the GUI can never hang, even if the Player has
completely crashed.
2024-11-03 00:57:05 +02:00
6337101985 feat(tracker/gioui): remove maximum length from unit comment
Related to #115.
2024-11-03 00:05:57 +02:00
8074fd71d3 refactor(tracker): split NewModelPlayer into NewModel, NewPlayer 2024-11-02 23:58:38 +02:00
37769fcc9c refactor(tracker): get rid of execChan, use broker.ToModel instead 2024-11-02 23:44:52 +02:00
76322bb541 fix(tracker): the scope length is in beats, not in rows
Already the oscilloscope calculated its length in beats, but
everywhere the variable was called "lengthInRows." Renamed the
variable to lengthInBeats and also changed the tooltip to be correct
2024-11-02 23:13:48 +02:00
1c601858ae docs: update screenshot 2024-11-02 23:02:02 +02:00
65a7f060ec feat(tracker/gioui): make buttons never have focus
The exception to the rule is the dialog buttons (which use still
the default material buttons), because when there is a modal dialog
on screen, there is not much else the user would want to do.

Fixes #156
2024-11-02 22:57:09 +02:00
b08f5d4b1e fix: make the buttons non-responsive to the spacebar 2024-11-02 22:14:50 +02:00
2a2934b4e4 docs: update CHANGELOG.md 2024-11-02 22:12:06 +02:00
9d59cfb3b6 fix(tracker): unmarshal always into fresh, empty structs
A somewhat gotcha: when unmarshaling into &m.d with json.Unmarshal
or yaml.Unmarshal, maps were "merged" with the existing maps, which
is how we ended up with send units with color parameters, among
other things. This fix always unmarshals into fresh var data
modelData and only then sets m.d = data if the unmarshaling was
succesful.
2024-11-02 22:01:12 +02:00
19661f90ea feat(tracker/gioui): move panic button to the right of MIDI menu 2024-11-02 21:22:40 +02:00
94058c2603 fix(tracker): do not close Broker but rather just close the detector 2024-11-02 20:45:10 +02:00
943073d0cc perf: do not use TotalVoices as it causes heap allocations 2024-11-02 20:44:45 +02:00
b73fc0b95b refactor(tracker): use the Broker to communicate when exporting wav 2024-11-02 20:08:48 +02:00
ee3ab3bf86 feat(tracker): try to honor MIDI message timestamps 2024-11-02 19:55:40 +02:00
2aa0aaee0c refactor: AudioSource is a func instead of single function interface
This avoids defining Processor altogether.
2024-11-02 19:50:20 +02:00
3eb4d86d52 style(tracker/gioui): remove old commented code 2024-11-02 17:00:56 +02:00
ec222bd67d feat(tracker): oscilloscope and LUFS / true peak detection
In addition to the oscilloscope and loudness/peak detections, this
commit refactors all the channels between components (i.e.
ModelMessages and PlayerMessages) etc. into a new class Broker. This
was done because now we have one more goroutine running: a Detector,
where the loudness / true peak detection is done in another thread.
The different threads/components are only aware of the Broker and
communicate through it. Currently, it's just a collection of
channels, so it's many-to-one communication, but in the future,
we could change Broker to have many-to-one-to-many communication.

Related to #61
2024-11-02 15:08:09 +02:00
86c65939bb docs: Update CHANGELOG.md 2024-11-02 15:01:30 +02:00
7417170a8b docs: update README.md 2024-10-28 21:01:18 +02:00
daf7fb1519 feat(tracker/gioui): user-defined keybindings.yml override defaults
The behaviour of the user-defined keybindings.yml was changed.
It now documents just the changes from the default keybindings.
An empty line with no action means "unbind the key".
2024-10-28 10:04:13 +02:00
eb9413b9a0 fix: sointu-play should use cmd.MainSynther (#174)
This way you can choose if you want to use the native synth or the 
go synth by defining the build tag native.
2024-10-25 11:03:08 +03:00
8dfadacafe feat: midi note input for the tracker 2024-10-22 07:56:36 +03:00
216cde2365 feat: keeping instruments and tracks linked & splitting them
Also includes a refactoring of the List: all methods that accepted
or returned a [from, to] range now return a Range, which is
non-inclusive i.e. [start,end).

Also the assignUnitIds was slightly refactored & a new function
called assignUnitIdsForPatch was added, to assign all unit IDs for
an patch at once.

Closes #157, #163.
2024-10-20 12:23:25 +03:00
025f8832d9 fix(tracker): adding order row moved cursor incorrectly 2024-10-19 00:00:29 +03:00
1c42a51cc6 refactor(tracker): use built-in min & max instead of intMin & intMax 2024-10-18 23:43:27 +03:00
0ba6557f65 fix(tracker/presets): kick-adam.yml had some invalid parameters 2024-10-18 23:40:21 +03:00
3306c431c3 refactor(tracker): use List.DeleteElements to delete tracks/instrs 2024-10-16 13:44:50 +03:00
9bce1cb3d5 build: change go version in go.mod to fix manual linux builds. (#169) 2024-10-16 11:31:12 +03:00
63c08d53fe feat(tracker): solo and mute can toggle multiple instruments 2024-10-16 01:09:28 +03:00
063b2c29c5 feat: add mute and solo toggles for instruments
Closes #168
2024-10-16 00:44:34 +03:00
7b213bd8b0 feat(sointu): display compressor invgain and threshold in dB 2024-10-15 23:27:58 +03:00
27b6bc57d2 doc: update CHANGELOG.md 2024-10-15 14:49:47 +03:00
00b8e1872a feat(tracker/gioui): using mouse to select rectangles in tables 2024-10-15 13:24:14 +03:00
04ca0a3f6e fix(tracker/gioui): changing a hex played the previous value 2024-10-15 13:08:02 +03:00
08386323ed fix(tracker/gioui): all key filters for hexadecimals in note editor 2024-10-15 12:56:10 +03:00
7470413ad8 fix(tracker): click on hex track low/high nibble selects that nibble
Closes #160
2024-10-15 09:37:21 +03:00
5099c61705 chore: fix linter problems in work space (remove unuseds etc.) 2024-10-15 09:18:41 +03:00
b494a69a76 refactor(tracker): change Iterate() func(yield):s to Iterate(yield) 2024-10-15 09:09:17 +03:00
3986bbede7 fix(tracker/gomidi): consume all available midi.Messages 2024-10-15 00:25:07 +03:00
97e59c5650 refactor(tracker): use go v1.23 style iterators throughout 2024-10-15 00:01:02 +03:00
2b7ce39069 refactor(tracker/gioui): give Editor Text / SetText methods 2024-10-14 23:36:32 +03:00
03c994e4da refactor(tracker/gioui): wrap Editor to include common key.Filters 2024-10-14 23:12:58 +03:00
cd88ea0680 test(tracker): fix FuzzModel: NullContext implements MIDIContext 2024-10-14 17:25:56 +03:00
f8f0e11b76 docs: update CHANGELOG.md 2024-10-14 17:09:12 +03:00
2809526de6 refactor(tracker): ask for midiContext in the model constructor 2024-10-14 17:03:17 +03:00
f427eca1f4 fix(sointu-vsti): VST crashed due to Model.MIDI being nil 2024-10-14 16:57:21 +03:00
c07d8000c6 refactor(tracker): harmonize naming and use iterators in MIDI
using iterators requires go 1.23
2024-10-14 15:00:55 +03:00
577265b250 feat(tracker): add support for a MIDI controller to the standalone tracker
Closes #132.
2024-10-14 14:11:50 +03:00
9779beee99 feat: units can have comments
Closes #114
2024-10-13 23:02:13 +03:00
160eb8eea9 fix(tracker/gioui): typing notes respects the keybinding modifiers 2024-10-13 15:14:04 +03:00
3fb7f07c2c feat(tracker/gioui): keybindings file is keybindings.yml, not .yaml 2024-10-13 14:54:42 +03:00
10f021a497 feat: toggle button to duplicate non-unique patterns when changed
Closes #77.
2024-10-13 14:47:22 +03:00
3a7ab0416a fix(presets): remove invalid parameters from snare-adam.yml 2024-10-13 14:39:04 +03:00
4c096a3fac refactor(tracker): rename Notetracking to Follow
Notetracking was used in two completely different meanings: the
pitch/bpm notetracking in the delay unit and the cursor follow in
when playing. The word for the second meaning was changed to Follow,
to avoid confusion.
2024-10-12 21:35:45 +03:00
59c04ed4a1 refactor(tracker): shorten the names of model.PlayFrom... methods 2024-10-12 21:31:56 +03:00
a6bb5c2afc feat(tracker): make keybindings user configurable
Closes #94, closes #151.
2024-10-12 21:08:30 +03:00
5c51932f60 fix(tracker): autofix malformed songs with useless params 2024-10-11 20:34:04 +03:00
773655ef9c fix(tracker/gioui): avoid deadlock while changing window title 2024-10-11 16:08:55 +03:00
91b7850bf7 feat(tracker): change keyboard shortcuts to mimic old trackers 2024-10-11 15:31:54 +03:00
b4a63ce362 feat(tracker/gioui): label identifying instrument MIDI channel
Closes #154.
2024-10-11 12:16:40 +03:00
a94703deea fix(tracker/gioui): pressing a or 1 in hex mode created note off
Closes #162
2024-10-08 12:31:05 +03:00
ad5f7628a5 doc: improve filterFrequencyDispFunc comments 2024-10-08 11:45:27 +03:00
b538737643 feat(sointu): show filter frequency in Hz
Closes #158.
2024-10-06 21:54:19 +03:00
47d7568552 refactor: remove ParamHintString, add DisplayFunc for each param 2024-10-06 19:04:10 +03:00
81a6d1acea feat: upgrade oto and output float audio 2024-10-06 18:58:08 +03:00
890ebe3294 refactor(tracker/gioui): use layout.Background, not layout.Stacked 2024-10-05 13:24:31 +03:00
bf5579a2d2 build: upgrade to latest gioui 2024-10-05 12:34:02 +03:00
8fd2df19a1 fix(sointu-vsti): warn about sample rate only after plugin init 2024-09-22 09:59:58 +03:00
ce673578fd fix(amd64-386): crash with sample-based oscillator in 32-bit library 2024-09-22 09:30:42 +03:00
0e10cd2ae8 fix(amd64-386): sample oscillator hard crash
The sample-based oscillators converted the samplepos to an integer
and did samplepos < loop_end comparison to check if we are past
looping. Unfortunately, the < comparison was done in signed math.
Normally, this should never happen, but if the x87 FPU stack
overflowed exactly at right position, we then got 0x80000000 in
samplepos, which is equal to -2147483648. Thus, we considered that
sample is not looping and read the sample table at position
-2147483648, well out of bound. TL;DR changing jl to jb makes sure
we always wrap within to sample table, no matter what.

Fixes #149.
2024-09-22 09:04:47 +03:00
4ee355bb45 fix(tracker/gioui): DPI scaling of the numeric updown icons
Closes #150.
2024-09-21 14:01:32 +03:00
7d6daba3d2 fix(vm/compiler/bridge): empty patch should not crash native synth
Fixes #148.
2024-09-16 19:58:23 +03:00
2b38e11643 feat: include version info in the binaries 2024-09-15 19:45:00 +03:00
f8c522873c docs: update CHANGELOG.md for v0.4.1 2024-09-08 19:27:42 +03:00
e49f699f62 feat(tracker/gioui): clicking a parameter slider (etc.) selects it
Closes #112.
2024-09-08 14:46:24 +03:00
6924b63e02 test(vm): disabled units should not affect NecessaryFeatures for vm
Closes #140.
2024-09-08 11:22:05 +03:00
6fc9277113 fix(tracker): unit search gains focus when adding a unit on last row 2024-09-07 21:29:37 +03:00
877556b428 feat(tracker): do not wrap around when playing or moving cursor
The wrapping was usually unwanted behaviour. The user can use the
looping (Ctrl-L) to loop the song forever if this is really desired.
2024-09-07 18:52:52 +03:00
5e65410d27 fix(sointu): use proper modulo in SongPos
The previous implementations used remained, not modulo, which could
cause issues with negative values.
2024-09-07 18:45:14 +03:00
4e1fdf57d9 fix(tracker/gioui): advance row by step when inputting a note
Closes #144.
2024-09-07 17:45:32 +03:00
1daaf1829c fix(tracker): ensure numVoices of loaded instrument is ok 2024-09-07 15:25:06 +03:00
74972b5ff4 fix(tracker): ID collisions in ClearUnit and Instruments.unmarshal 2024-09-07 15:25:06 +03:00
9da6c2216c test(tracker): fuzz testing of ID collisions and file read/writes 2024-09-07 15:16:53 +03:00
61e7da5dab test(tracker): test loading presets in fuzz tests 2024-09-06 22:03:52 +03:00
59fb39d9b3 fix(tracker/gioui): move alert popups north to not overlay buttons
Closes #142.
2024-09-06 22:01:00 +03:00
9cb573d965 feat(tracker/gioui): cursor indicates split bars can be resized
Closes #145.
2024-09-06 20:29:33 +03:00
d46605c638 fix: assign new IDs to loaded instruments
Fixes #146.
2024-09-06 20:19:27 +03:00
569958547e fix(amd64-386): do not optimize away phase modulations with unisons 2024-08-17 11:06:18 +03:00
012ed10851 test: add unit test for unisons with phase = 0
This demonstrates a bug found by Virgill:
the x86 templates optimize away the phase
modulation when all phases are set to 0,
but the unisons need the phase modulation
internally to offset the phase of the different
unison oscillators.
2024-08-17 11:06:17 +03:00
5bc6dc6015 test(vm): test that disabled units do not affect results 2024-08-14 19:43:13 +03:00
350402f8f3 fix(vm): prevent crash when only disabled delay units & test it 2024-08-14 19:41:44 +03:00
75bd9c591e fix: do not include delay times from disabled delay units
Closes #139.
2024-08-14 15:41:13 +03:00
306 changed files with 20740 additions and 8585 deletions

View File

@ -39,31 +39,19 @@ jobs:
strategy:
matrix:
config:
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
output: sointu-track.exe
params: -ldflags -H=windowsgui cmd/sointu-track/main.go
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
output: sointu-compile.exe
params: cmd/sointu-compile/main.go
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
output: sointu-track-native.exe
params: -ldflags -H=windowsgui -tags=native cmd/sointu-track/main.go
output: sointu-track.exe
params: -tags=native cmd/sointu-track/main.go
ldflags: -H=windowsgui
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
output: sointu-vsti.dll
params: -buildmode=c-shared -tags=plugin ./cmd/sointu-vsti/
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
output: sointu-vsti-native.dll
params: -buildmode=c-shared -tags="plugin,native" ./cmd/sointu-vsti/
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
output: sointu-track
params: cmd/sointu-track/main.go
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
output: sointu-compile
@ -71,31 +59,28 @@ jobs:
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
output: sointu-track-native
output: sointu-track
params: -tags=native cmd/sointu-track/main.go
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
output: sointu-vsti.so
params: -buildmode=c-shared -tags=plugin ./cmd/sointu-vsti/
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
output: sointu-vsti-native.so
params: -buildmode=c-shared -tags="plugin,native" ./cmd/sointu-vsti/
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
- os: macos-latest
asmnasm: /Users/runner/nasm/nasm
output: sointu-compile
params: cmd/sointu-compile/main.go
- os: macos-latest
asmnasm: /Users/runner/nasm/nasm
output: sointu-track
params: cmd/sointu-track/main.go
- os: macos-latest
asmnasm: /Users/runner/nasm/nasm
output: sointu-compile
params: cmd/sointu-compile/main.go
- os: macos-12 # this is intel still
asmnasm: /Users/runner/nasm/nasm
output: sointu-track-native
params: -tags=native cmd/sointu-track/main.go
output: sointu-vsti.a
bundleoutput: sointu-vsti
params: -buildmode=c-archive -tags="plugin" ./cmd/sointu-vsti/
bundle: true
steps:
- uses: benjlevesque/short-sha@v3.0
id: short-sha
@ -103,9 +88,11 @@ jobs:
length: 7
- uses: lukka/get-cmake@latest
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5 # has to be after checkout, see https://medium.com/@s0k0mata/github-actions-and-go-the-new-cache-feature-in-actions-setup-go-v4-and-what-to-watch-out-for-aeea373ed07d
with:
go-version: '>=1.21.0'
go-version: '>=1.23.8 <1.23.9'
- uses: ilammy/setup-nasm@v1.5.1
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
@ -114,20 +101,73 @@ jobs:
if: runner.os == 'Linux'
- name: Build library
env:
ASM_NASM: ${{ matrix.config.asmnasm }}
ASM_NASM: ${{ matrix.config.asmnasm }}
run: |
mkdir build
cd build
cmake -GNinja ..
ninja sointu
- name: Build binary
- name: Build binary
run: |
go build -o ${{ matrix.config.output }} ${{ matrix.config.params }}
go build -ldflags "-X github.com/vsariola/sointu/version.Version=$(git describe) ${{ matrix.config.ldflags}}" -o ${{ matrix.config.output }} ${{ matrix.config.params }}
- name: Upload binary
if: matrix.config.bundle != true
uses: actions/upload-artifact@v4
with:
name: ${{ runner.os }}-${{ steps.short-sha.outputs.sha }}-${{ matrix.config.output }}
path: ${{ matrix.config.output }}
- name: Bundle VST
if: matrix.config.bundle
run: | # following https://github.com/RustAudio/vst-rs/blob/master/osx_vst_bundler.sh
mkdir -p "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/MacOS"
clang++ -D__MACOSX_CORE__ -framework CoreServices -framework CoreAudio -framework CoreMIDI -framework CoreFoundation -L./build/ -lsointu -bundle -o bundle/${{ matrix.config.bundleoutput }} -all_load ${{ matrix.config.output }}
echo "BNDL????" > "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/PkgInfo"
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>${{ matrix.config.bundleoutput }}</string>
<key>CFBundleGetInfoString</key>
<string>vst</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>com.vsariola.${{ matrix.config.bundleoutput }}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${{ matrix.config.bundleoutput }}</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>$((RANDOM % 9999))</string>
<key>CSResourcesFileMapped</key>
<string></string>
</dict>
</plist>" > "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/Info.plist"
mv "bundle/${{ matrix.config.bundleoutput }}" "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/MacOS/${{ matrix.config.bundleoutput }}"
- name: Upload bundle
if: matrix.config.bundle
uses: actions/upload-artifact@v4
with:
name: ${{ runner.os }}-${{ steps.short-sha.outputs.sha }}-${{ matrix.config.bundleoutput }}
path: bundle
upload_release_asset:
needs: [create_release, binaries]
runs-on: ubuntu-latest

View File

@ -20,14 +20,17 @@ jobs:
- os: ubuntu-latest
asmnasm: /home/runner/nasm/nasm
gotests: yes
gotestcases: ./vm ./vm/compiler/bridge ./vm/compiler
cgo_ldflags:
- os: windows-latest
asmnasm: C:\Users\runneradmin\nasm\nasm
gotests: yes
gotestcases: ./vm ./vm/compiler/bridge ./vm/compiler
cgo_ldflags:
- os: macos-12 # this is intel still
- os: macos-latest
asmnasm: /Users/runner/nasm/nasm
gotests: yes
gotestcases: ./vm ./vm/compiler
cgo_ldflags: # -Wl,-no_pie
# ld on mac is complaining about position dependent code so this would take the errors away, BUT
# suddenly this causes an error, even though worked last week. Let's accept the warnings rather
@ -44,7 +47,7 @@ jobs:
go-version: '>=1.21.0'
- uses: actions/setup-node@v4
with:
node-version: '15'
node-version: '22'
- uses: ilammy/setup-nasm@v1.5.1
- name: Run ctest
env:
@ -60,4 +63,4 @@ jobs:
env:
CGO_LDFLAGS: ${{ matrix.config.cgo_ldflags }}
run: |
go test ./vm ./vm/compiler/bridge ./vm/compiler
go test ${{ matrix.config.gotestcases }}

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

@ -245,7 +245,7 @@ func read4klangUnit(r io.Reader, version int) ([]Unit, error) {
}
}
func read4klangENV(vals [15]byte, version int) []Unit {
func read4klangENV(vals [15]byte, _ int) []Unit {
return []Unit{{
Type: "envelope",
Parameters: map[string]int{
@ -273,7 +273,7 @@ func read4klangVCO(vals [15]byte, version int) []Unit {
color, v = int(v[0]), v[1:]
shape, v = int(v[0]), v[1:]
gain, v = int(v[0]), v[1:]
flags, v = int(v[0]), v[1:]
flags, _ = int(v[0]), v[1:]
if flags&0x10 == 0x10 {
lfo = 1
}
@ -318,9 +318,9 @@ func read4klangVCO(vals [15]byte, version int) []Unit {
}}
}
func read4klangVCF(vals [15]byte, version int) []Unit {
func read4klangVCF(vals [15]byte, _ int) []Unit {
flags := vals[2]
var stereo, lowpass, bandpass, highpass, neghighpass int
var stereo, lowpass, bandpass, highpass int
if flags&0x01 == 0x01 {
lowpass = 1
}
@ -332,7 +332,7 @@ func read4klangVCF(vals [15]byte, version int) []Unit {
}
if flags&0x08 == 0x08 {
lowpass = 1
neghighpass = 1
highpass = -1
}
if flags&0x10 == 0x10 {
stereo = 1
@ -340,26 +340,24 @@ func read4klangVCF(vals [15]byte, version int) []Unit {
return []Unit{{
Type: "filter",
Parameters: map[string]int{
"stereo": stereo,
"frequency": int(vals[0]),
"resonance": int(vals[1]),
"lowpass": lowpass,
"bandpass": bandpass,
"highpass": highpass,
"negbandpass": 0,
"neghighpass": neghighpass,
"stereo": stereo,
"frequency": int(vals[0]),
"resonance": int(vals[1]),
"lowpass": lowpass,
"bandpass": bandpass,
"highpass": highpass,
}},
}
}
func read4klangDST(vals [15]byte, version int) []Unit {
func read4klangDST(vals [15]byte, _ int) []Unit {
return []Unit{
{Type: "distort", Parameters: map[string]int{"drive": int(vals[0]), "stereo": int(vals[2])}},
{Type: "hold", Parameters: map[string]int{"holdfreq": int(vals[1]), "stereo": int(vals[2])}},
}
}
func read4klangDLL(vals [15]byte, version int) []Unit {
func read4klangDLL(vals [15]byte, _ int) []Unit {
var delaytimes []int
var notetracking int
if vals[11] > 0 {
@ -400,7 +398,7 @@ func read4klangDLL(vals [15]byte, version int) []Unit {
}}
}
func read4klangFOP(vals [15]byte, version int) []Unit {
func read4klangFOP(vals [15]byte, _ int) []Unit {
var t string
var stereo int
switch vals[0] {
@ -434,7 +432,7 @@ func read4klangFOP(vals [15]byte, version int) []Unit {
}}
}
func read4klangFST(vals [15]byte, version int) []Unit {
func read4klangFST(vals [15]byte, _ int) []Unit {
sendpop := 0
if vals[1]&0x40 == 0x40 {
sendpop = 1
@ -484,7 +482,7 @@ func fix4klangTargets(instrIndex int, instr Instrument, m _4klangTargetMap) {
}
}
func read4klangPAN(vals [15]byte, version int) []Unit {
func read4klangPAN(vals [15]byte, _ int) []Unit {
return []Unit{{
Type: "pan",
Parameters: map[string]int{
@ -493,7 +491,7 @@ func read4klangPAN(vals [15]byte, version int) []Unit {
}}}
}
func read4klangOUT(vals [15]byte, version int) []Unit {
func read4klangOUT(vals [15]byte, _ int) []Unit {
return []Unit{{
Type: "outaux",
Parameters: map[string]int{
@ -503,7 +501,7 @@ func read4klangOUT(vals [15]byte, version int) []Unit {
}}
}
func read4klangACC(vals [15]byte, version int) []Unit {
func read4klangACC(vals [15]byte, _ int) []Unit {
c := 0
if vals[0] != 0 {
c = 2
@ -514,7 +512,7 @@ func read4klangACC(vals [15]byte, version int) []Unit {
}}
}
func read4klangFLD(vals [15]byte, version int) []Unit {
func read4klangFLD(vals [15]byte, _ int) []Unit {
return []Unit{{
Type: "loadval",
Parameters: map[string]int{"stereo": 0, "value": int(vals[0])},

View File

@ -3,7 +3,203 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## v0.4.0
## [Unreleased]
### Added
- Spectrum analyzer showing the spectrum. When the user has a filter or belleq
unit selected, it's frequency response is plotted on top. ([#67][i67])
- belleq unit: a bell-shaped second-order filter for equalization. Belleq unit
takes the center frequency, bandwidth (inverse of Q-factor) and gain (+-40
dB). Useful for boosting or reducing specific frequency ranges. Kudos to Reaby
for the initial implementation!
- Multithreaded synths: the user can split the patch up to four threads.
Selecting the thread can be done on the instrument properties pane.
Multithreading works only on the multithreaded synths, selectable from the CPU
panel. Currently the multithreaded rendering has not yet been implemented in
the compiled player and the thread information is disregarded while compiling
the song. ([#199][i199])
- Preset explorer, whichs allows 1) searching the presets by name; 2) filtering
them by category (directory); 3) filtering them by being builtin vs. user;
4) filtering them if they need gm.dls (for Linux/Mac users, who don't have
it); and 5) saving and deleting user presets. ([#91][i91])
- Panic the synth if it outputs NaN or Inf, and handle these more gracefully in
the loudness and peak detector. ([#210][i210])
- More presets from Reaby, and all new and existing presets were normalized
roughly to -12 dBFS true peak. ([#211][i211])
### Fixed
- Occasional NaNs in the Trisaw oscillator when the color was 0 in the Go VM.
- The tracker thought that "sync" unit pops the value from stack, even if the VM
did not, resulting it claiming errors in patches that worked once compiled.
### Changed
- The song panel can scroll if all the widgets don't fit into it
- The provided MacOS executables are now arm64, which means the x86 native
synths are not compiled in.
## [0.5.0]
### BREAKING CHANGES
- BREAKING CHANGE: always first modulate delay time, then apply notetracking. In
a delay unit, modulation adds to the delay time, while note tracking
multiplies it with a multiplier dependent on the note. The order of these
operations was different in the Go VM vs. x86 VM & WebAssembly VM. In the Go
VM, it first modulated, and then applied the note tracking multiplication. In
the two assembly VMs, it first applied the note tracking and then modulated.
Of these two behaviours, the Go VM behaviour made more sense: if you make a
vibrato of +-50 cents for C4, you probably want a vibrato of +-50 cents for C6
also. Thus, first modulating and then applying the note tracking
multiplication is now the behaviour accross all VMs.
- BREAKING CHANGE: the negbandpass and neghighpass parameters of the filter unit
were removed. Setting bandpass or highpass to -1 achieves now the same end
result. Setting both negbandpass and bandpass to 1 was previously a no-op. Old
patch and instrument files are converted to the new format when loaded, but
newer Sointu files should not be compiled with an old version of
sointu-compile.
### Added
- Signal rail that visualizes what happens in the stack, shown on the left side
of each unit in the rack.
- The parameters are now displayed in a grid as knobs, with units of the
instrument going from the top to the bottom. Bezier lines are used to indicate
which sends modulate which ports. ([#173][i173])
- Tabbing works more consistently, with widgets placed in a "tree", and plain
Tab moves to the next widget on the same level or more shallow in the tree,
while ctrl-Tab moves to next widget, regardless of its depth. This allows the
user to quickly move between different panels, but also tabbing into every
tiny widget if needed. Shift-* tab backwards.
- Help menu, with a menu item to show the license in a dialog, and also menu
items to open manual, Github Discussions & Github Issues in a browser
([#196][i196])
- Show CPU load percentage in the song panel ([#192][i192])
- Theme can be user configured, in theme.yml. This theme.yml should be placed in
the usual sointu config directory (i.e.
`os.UserConfigDir()/sointu/theme.yml`). See
[theme.yml](tracker/gioui/theme.yml) for the default theme, and
[theme.go](tracker/gioui/theme.go) for what can be changed.
- Ctrl + scroll wheel adjusts the global scaling of the GUI ([#153][i153])
- The loudness detection supports LUFS, A-weighting, C-weighting or
RMS-weighting, and peak detection supports true peak or sample peak detection.
The loudness and peak values are displayed in the song panel ([#186][i186])
- Oscilloscope to visualize the outputted waveform ([#61][i61])
- Toggle button to keep instruments and tracks linked, and buttons to split
instruments and tracks with more than 1 voice into parallel ones
([#163][i163], [#157][i157])
- Mute and solo toggles for instruments ([#168][i168])
- Many units (e.g. envelopes, oscillators and compressors) display values dB
- 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. ([#170][i170])
- Units can have comments, to make it easier to distinguish between units of
same type within an instrument and to use these as subsection titles.
([#114][i114])
- A toggle button for copying non-unique patterns before editing. When enabled
and if the pattern is used in multiple places, the pattern is copied first.
([#77][i77])
- User can define own keybindings in `os.UserConfigDir()/sointu/keybindings.yml`
([#94][i94], [#151][i151])
- User can define preferred window size in
`os.UserConfigDir()/sointu/preferences.yml` ([#184][i184])
- A small number above the instrument name identifies the MIDI channel /
instrument number, with numbering starting from 1 ([#154][i154])
- The filter unit frequency parameter is displayed in Hz, corresponding roughly
to the resonant frequency of the filter ([#158][i158])
- Include version info in the binaries, as given be `git describe`. This version
info is shown as a label in the tracker and can be checked with `-v` flag in
the command line tools.
- Performance improvement: values needed by the UI that are derived from the
score or patch are cached when score or patch changes, so they don't have to
be computed every draw. ([#176][i176])
### Fixed
- Tooltips will be hidden after certain amount of time has passed, to ensure
that the tooltips don't stay around ([#141][i141])
- Loading instrument forgot to close the file (in model.ReadInstrument)
- We try to honor the MIDI event time stamps, so that the timing between MIDI
events (as reported to us by RTMIDI) will be correct.
- When unmarshaling the recovery file, the unit parameter maps were "merged"
with the existing parameter maps, instead of overwriting. This created units
with unnecessary parameters, which was harmless, but would cause a warning to
the user.
- When changing a nibble of a hexadecimal note, the note played was the note
before changing the nibble
- Clicking on low nibble or high nibble of a hex track selects that nibble
([#160][i160])
- If units have useless parameters in their parameter maps, from bugs or from a
malformed yaml file, they are removed and user is warned about it
- Pressing `a` or `1` when editing note values in hex mode created a note off
line ([#162][i162])
- Warn about plugin sample rate being different from 44100 only after
ProcessFloatFunc has been called, so that host has time to set the sample rate
after initialization.
- Crashes with sample-based oscillators in the 32-bit library, as the pointer to
sample-table (edi) got accidentally overwritten by detune
- Sample-based oscillators could hard crash if a x87 stack overflow happened
when calculating the current position in the sample ([#149][i149])
- Numeric updown widget calculated dp-to-px conversion incorrectly, resulting in
wrong scaling ([#150][i150])
- Empty patch should not crash the native synth ([#148][i148])
- sointu-play allows choosing between the synths, assuming it was compiled with
`-tags=native`
- Most buttons never gain focus, so that clicking a button does not stop
whatever the user was currently doing and so that the user does not
accidentally trigger the buttons by having them focused and e.g. hitting space
([#156][i156])
### Changed
- When saving instrument to a file, the instrument name is not saved to the name
field, as Sointu will anyway use the filename as the instrument's name when it
is loaded.
- Native version of the tracker/VSTi was removed. Instead, you can change
between the two versions of the synth on the fly, by clicking on the "Synth"
option under the CPU group in the song panel ([#200][i200])
- Send amount defaults to 64 = 0.0 ([#178][i178])
- The maximum number of delaylines in the native synth was increased to 128,
with slight increase in memory usage ([#155][i155])
- The numeric updown widget has a new appearance.
- The draggable UI splitters snap more controllably to the window edges.
- New & better presets, organized by their type to subfolders (thanks Reaby!)
([#136][i136])
- Presets get their name by concatenating their subdirectory path (with path
separators replaced with spaces) to their filename
- The keyboard shortcuts are now again closer to what they were old trackers
([#151][i151])
- The stand-alone apps now output floating point sound, as made possible by
upgrading oto-library to latest version. This way the tracker sound output
matches the compiled output better, as usually compiled intros output sound in
floating point. This might be important if OS sound drivers apply some audio
enhancemenets e.g. compressors to the audio.
## [0.4.1]
### Added
- Clicking the parameter slider also selects that parameter ([#112][i112])
- The vertical and horizontal split bars indicate with a cursor that they can be
resized ([#145][i145])
### Fixed
- When adding a unit on the last row of the unit list, the editor for entering
the type of the unit by text did gain focus.
- When inputting a note to the note editor, advance the cursor by step
([#144][i144])
- When loading an instrument, make sure the total number of voices does not go
over the maximum number allowed by vm, and make sure a loaded instrument has
at least 1 voice
- Potential ID collisions when clearing unit or pasteing instruments
- Assign new IDs to loaded instruments, and fix ID collisions in case they
somehow still appear ([#146][i146])
- In x86 templates, do not optimize away phase modulations when unisons are used
even if all phase inputs are zeros, as unisons use the phase modulation
mechanism to offset the different oscillators
- Do not include delay times in the delay time table if the delay unit is
disabled ([#139][i139])
- Moved the error and warning popups slightly up so they don't block the unit
control buttons ([#142][i142])
### Changed
- Do not automatically wrap around the song when playing as it was usually
unwanted behaviour. There is already the looping mechanism if the user really
wants to loop the song forever.
## [0.4.0]
### Added
- User can drop preset instruments into `os.UserConfigDir()/sointu/presets/` and
they appear in the list of presets next time sointu is started.
@ -50,7 +246,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
up now increases the value, while scrolling down decreases the value. It was
vice versa. ([#112][i112])
## v0.3.0
## [0.3.0]
### Added
- Scroll bars to menus, shown when a menu is too long to fit.
- Save the GUI state periodically to a recovery file and load it on
@ -85,7 +281,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
stays the same, but the label was changed to "self", to highlight that
this means the voice modulates only itself and not other voices.
## v0.2.0
## [0.2.0]
### Added
- Saving and loading instruments
- Comment field to instruments
@ -123,7 +319,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
breaking change and changes the meaning of the resolution values. But
now there are more usable values in the resolution.
## v0.1.0
## [0.1.0]
### Added
- An instrument (set of opcodes & accompanying values) can have any
number of voices.
@ -151,14 +347,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- a command line utility to convert .yml songs to .asm
- a command line utility to play the songs on command line
[Unreleased]: https://github.com/vsariola/sointu/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/vsariola/sointu/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/vsariola/sointu/compare/v0.4.1...v0.5.0
[0.4.1]: https://github.com/vsariola/sointu/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/vsariola/sointu/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/vsariola/sointu/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/vsariola/sointu/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/vsariola/sointu/compare/4klang-3.11...v0.1.0
[i61]: https://github.com/vsariola/sointu/issues/61
[i65]: https://github.com/vsariola/sointu/issues/65
[i67]: https://github.com/vsariola/sointu/issues/67
[i68]: https://github.com/vsariola/sointu/issues/68
[i77]: https://github.com/vsariola/sointu/issues/77
[i91]: https://github.com/vsariola/sointu/issues/91
[i94]: https://github.com/vsariola/sointu/issues/94
[i112]: https://github.com/vsariola/sointu/issues/112
[i114]: https://github.com/vsariola/sointu/issues/114
[i116]: https://github.com/vsariola/sointu/issues/116
[i120]: https://github.com/vsariola/sointu/issues/120
[i121]: https://github.com/vsariola/sointu/issues/121
@ -167,3 +371,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
[i128]: https://github.com/vsariola/sointu/issues/128
[i129]: https://github.com/vsariola/sointu/issues/129
[i130]: https://github.com/vsariola/sointu/issues/130
[i136]: https://github.com/vsariola/sointu/issues/136
[i139]: https://github.com/vsariola/sointu/issues/139
[i141]: https://github.com/vsariola/sointu/issues/141
[i142]: https://github.com/vsariola/sointu/issues/142
[i144]: https://github.com/vsariola/sointu/issues/144
[i145]: https://github.com/vsariola/sointu/issues/145
[i146]: https://github.com/vsariola/sointu/issues/146
[i148]: https://github.com/vsariola/sointu/issues/148
[i149]: https://github.com/vsariola/sointu/issues/149
[i150]: https://github.com/vsariola/sointu/issues/150
[i151]: https://github.com/vsariola/sointu/issues/151
[i153]: https://github.com/vsariola/sointu/issues/153
[i154]: https://github.com/vsariola/sointu/issues/154
[i155]: https://github.com/vsariola/sointu/issues/155
[i156]: https://github.com/vsariola/sointu/issues/156
[i157]: https://github.com/vsariola/sointu/issues/157
[i158]: https://github.com/vsariola/sointu/issues/158
[i160]: https://github.com/vsariola/sointu/issues/160
[i162]: https://github.com/vsariola/sointu/issues/162
[i163]: https://github.com/vsariola/sointu/issues/163
[i166]: https://github.com/vsariola/sointu/issues/166
[i168]: https://github.com/vsariola/sointu/issues/168
[i170]: https://github.com/vsariola/sointu/issues/170
[i173]: https://github.com/vsariola/sointu/issues/173
[i176]: https://github.com/vsariola/sointu/issues/176
[i178]: https://github.com/vsariola/sointu/issues/178
[i184]: https://github.com/vsariola/sointu/issues/184
[i186]: https://github.com/vsariola/sointu/issues/186
[i192]: https://github.com/vsariola/sointu/issues/192
[i196]: https://github.com/vsariola/sointu/issues/196
[i199]: https://github.com/vsariola/sointu/issues/199
[i200]: https://github.com/vsariola/sointu/issues/200
[i210]: https://github.com/vsariola/sointu/issues/210
[i211]: https://github.com/vsariola/sointu/issues/211

View File

@ -1,7 +1,7 @@
MIT License
Copyright (c) 2018 Dominik Ries
(c) 2020 Veikko Sariola
(c) 2020-2025 Veikko Sariola, moitias, qm210, LeStahl, petersalomonsen, anticore, reaby
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

128
README.md
View File

@ -7,18 +7,27 @@ intros, forked from [4klang](https://github.com/hzdgopher/4klang). Targetable
architectures include 386, amd64, and WebAssembly; targetable platforms include
Windows, Mac, Linux (and related) + browser.
User manual will be in the [Wiki](https://github.com/vsariola/sointu/wiki).
- [User manual](https://github.com/vsariola/sointu/wiki) is in the Wiki
- [Discussions](https://github.com/vsariola/sointu/discussions) is for asking
help, sharing patches/instruments and brainstorming ideas
- [Issues](https://github.com/vsariola/sointu/issues) is for reporting bugs
Installation
------------
You can either 1) download the prebuilt release binaries from the
[releases](https://github.com/vsariola/sointu/releases); or 2) download the
latest build from the master branch from the
[actions](https://github.com/vsariola/sointu/actions) (find workflow "Binaries"
and scroll down for .zip files containing the artifacts). Then just run one of
the executables or, in the case of the VST plugins library files, copy them
wherever you keep you VST2 plugins.
You can either:
1) Download the latest build from the master branch from the
[actions](https://github.com/vsariola/sointu/actions) (find workflow
"Binaries" and scroll down for .zip files containing the artifacts.
**Note:** You have to be logged into Github to download artifacts!
or
2) Download the prebuilt release binaries from the
[releases](https://github.com/vsariola/sointu/releases). Then just run one
of the executables or, in the case of the VST plugins library files, copy
them wherever you keep you VST2 plugins.
The pre 1.0 version tags are mostly for reference: no backwards
compatibility will be guaranteed while upgrading to a newer version.
@ -73,7 +82,8 @@ for the audio, so the portability is currently limited by these.
#### Prerequisites
- [go](https://golang.org/)
- If you want to use the faster x86 assembly written synthesizer:
- If you want to also use the x86 assembly written synthesizer, to test that the
patch also works once compiled:
- Follow the instructions to build the [x86 native virtual machine](#native-virtual-machine)
before building the tracker.
- cgo compatible compiler e.g. [gcc](https://gcc.gnu.org/). On
@ -100,7 +110,7 @@ go build -o sointu-track.exe cmd/sointu-track/main.go
On other platforms than Windows, replace `-o sointu-track.exe` with
`-o sointu-track`.
If you want to use the [x86 native virtual machine](#native-virtual-machine),
If you want to include the [x86 native virtual machine](#native-virtual-machine),
add `-tags=native` to all the commands e.g.
```
@ -123,7 +133,7 @@ a dynamically linked library and ran inside a VST host.
if it is not set and go fails to find the compiler, go just excludes
all files with `import "C"` from the build, resulting in lots of
errors about missing types.
- If you want to use the faster x86 assembly written synthesizer:
- If you want to build the VSTI with the native x86 assembly written synthesizer:
- Follow the instructions to build the [x86 native virtual machine](#native-virtual-machine)
before building the plugin itself
@ -171,15 +181,15 @@ The compiler can then be used to compile a .yml song into .asm and .h files. For
example:
```
sointu-compile -o . -arch=386 tests/test_chords.yml
sointu-compile -arch=386 tests/test_chords.yml
nasm -f win32 test_chords.asm
```
WebAssembly example:
```
sointu-compile -o . -arch=wasm tests/test_chords.yml
wat2wasm --enable-bulk-memory test_chords.wat
sointu-compile -arch=wasm tests/test_chords.yml
wat2wasm test_chords.wat
```
If you are looking for an easy way to compile an executable from a Sointu song
@ -203,11 +213,17 @@ there by default.
### Native virtual machine
The native bridge allows Go to call the sointu compiled x86 native virtual
machine, through cgo, instead of using the Go written bytecode interpreter. It's
likely slightly faster than the interpreter. Before you can actually run it, you
need to build the bridge using CMake (thus, ***this will not work with go
get***).
The native bridge allows Go to call the Sointu compiled x86 native virtual
machine, through cgo, instead of using the Go written bytecode interpreter. With
the latest Go compiler, the native virtual machine is actually slower than the
Go-written one, but importantly, the native virtual machine allows you to test
that the patch also works within the stack limits of the x87 virtual machine,
which is the VM used in the compiled intros. In the tracker/VSTi, you can switch
between the native synth and the Go synth under the CPU panel in the Song
settings.
Before you can actually run it, you need to build the bridge using CMake (thus,
***this will not work with go get***).
#### Prerequisites
@ -323,7 +339,7 @@ New features since fork
`text/template` package, effectively working as a preprocessor. This allows
quite powerful combination: we can handcraft the assembly code to keep the
entropy as low as possible, yet we can call arbitrary go functions as
"macros". The templates are [here](templates/) and the compiler lives
"macros". The templates are [here](vm/compiler/templates/) and the compiler lives
[here](vm/compiler/).
- **Tracker**. Written in go. Can run either as a stand-alone app or a vsti
plugin.
@ -340,9 +356,9 @@ New features since fork
opcodes. So, you can have a single instrument with three voices, and three
tracks that use this instrument, to make chords. See
[here](tests/test_chords.yml) for an example and
[here](templates/amd64-386/patch.asm) for the implementation. The maximum
total number of voices is 32: you can have 32 monophonic instruments or any
combination of polyphonic instruments adding up to 32.
[here](vm/compiler/templates/amd64-386/patch.asm) for the implementation.
The maximum total number of voices is 32: you can have 32 monophonic
instruments or any combination of polyphonic instruments adding up to 32.
- **Any number of voices per track**. A single track can trigger more than one
voice. At every note, a new voice from the assigned voices is triggered and
the previous released. Combined with the previous, you can have a single
@ -351,13 +367,13 @@ New features since fork
Not only that, a track can even trigger voices of different instruments,
alternating between these two; maybe useful for example as an easy way to
alternate between an open and a closed hihat.
- **Easily extensible**. Instead of %ifdef hell, the primary extension
mechanism is through new opcodes for the virtual machine. Only the opcodes
actually used in a song are compiled into the virtual machine. The goal is
to try to write the code so that if two similar opcodes are used, the common
code in both is reused by moving it to a function. Macro and linker magic
ensure that also helper functions are only compiled in if they are actually
used.
- **Reasonably easily extensible**. Instead of %ifdef hell, the primary
extension mechanism is through new opcodes for the virtual machine. Only the
opcodes actually used in a song are compiled into the virtual machine. The
goal is to try to write the code so that if two similar opcodes are used,
the common code in both is reused by moving it to a function. Macro and
linker magic ensure that also helper functions are only compiled in if they
are actually used.
- **Songs are YAML files**. These markup files are simple data files,
describing the tracks, patterns and patch structure (see
[here](tests/test_oscillat_trisaw.yml) for an example). The sointu-compile
@ -412,36 +428,9 @@ New features since fork
releasing voices etc.)
- **Calling Sointu as a library from Go language**. The Go API is slighty more
sane than the low-level library API, offering more Go-like experience.
- **A bytecode interpreter written in pure go**. It's slightly slower than the
hand-written assembly code by sointu compiler, but with this, the tracker is
ultraportable and does not need cgo calls.
Future goals
------------
- **Find a more general solution for skipping opcodes / early outs**. It might
be a new opcode "skip" that skips from the opcode to the next out in case
the signal entering skip and the signal leaving out are both close to zero.
Need to investigate the best way to implement this.
- **Even more opcodes**. Some potentially useful additions could be:
- Equalizer / more flexible filters
- Very slow filters (~ DC-offset removal). Can be implemented using a single
bit flag in the existing filter
- Arbitrary envelopes; for easier automation.
- **MIDI support for the tracker**.
- **Find a solution for denormalized signals**. Denormalized floating point
numbers (floating point numbers that are very very small) can result in 100x
CPU slow down. We got hit by this already: the damp filters in delay units
were denormalizing, resulting in the synth being unusable in real time. Need
to investigate a) where denormalization can happen; b) how to prevent it:
add & substract value; c) make this optional to the user. For quick
explanation about the potential massive CPU hit, see
https://stackoverflow.com/questions/36781881/why-denormalized-floats-are-so-much-slower-than-other-floats-from-hardware-arch
Long-shot ideas
-----------
- **Hack deeper into audio sources from the OS**. Speech synthesis, I'm eyeing
at you.
- **A bytecode interpreter written in pure go**. With the latest Go compiler,
it's slightly faster hand-written one using x87 opcodes. With this, the
tracker is ultraportable and does not need cgo calls.
Design philosophy
-----------------
@ -463,8 +452,8 @@ Design philosophy
- Benchmark optimizations. Compression results are sometimes slightly
nonintuitive so alternative implementations should always be benchmarked
e.g. by compiling and linking a real-world song with
[Leviathan](https://github.com/armak/Leviathan-2.0) and observing how the
optimizations affect the byte size.
[one of the examples](examples/code/C) and observing how the optimizations
affect the byte size.
Background and history
----------------------
@ -510,6 +499,15 @@ Prods using Sointu
- [Phosphorescent Purple Pixel Peaks](https://www.pouet.net/prod.php?which=96198) by mrange & Virgill
- [21](https://demozoo.org/music/338597/) by NR4 / Team210
- [Tausendeins](https://www.pouet.net/prod.php?which=96192) by epoqe & Team210
- [Radiant](https://www.pouet.net/prod.php?which=97200) by Team210
- [Aurora Florae](https://www.pouet.net/prod.php?which=97516) by Team210 and
epoqe
- [Night Ride](https://www.pouet.net/prod.php?which=98212) by Ctrl-Alt-Test &
Alcatraz
- [Bicolor Challenge](https://demozoo.org/competitions/19410/) with [Sointu
song](https://files.scene.org/view/parties/2024/deadline24/bicolor_challenge/wayfinder_-_bicolor_soundtrack.zip)
provided by wayfinder
- [napolnitel](https://www.pouet.net/prod.php?which=104336) by jetlag
Contributing
------------
@ -536,5 +534,7 @@ The original 4klang: Dominik Ries ([gopher/Alcatraz](https://github.com/hzdgophe
& Paul Kraus (pOWL/Alcatraz) :heart:
Sointu: Veikko Sariola (pestis/bC!), [Apollo/bC!](https://github.com/moitias),
[NR4/Team210](https://github.com/LeStahL/), [PoroCYon](https://github.com/PoroCYon/4klang),
[kendfss](https://github.com/kendfss), [anticore](https://github.com/anticore)
[NR4/Team210](https://github.com/LeStahL/),
[PoroCYon](https://github.com/PoroCYon/4klang),
[kendfss](https://github.com/kendfss), [anticore](https://github.com/anticore),
[qm210](https://github.com/qm210), [reaby](https://github.com/reaby)

View File

@ -5,7 +5,9 @@ import (
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"time"
)
type (
@ -13,22 +15,28 @@ type (
// sample represented by [2]float32. [0] is left channel, [1] is right
AudioBuffer [][2]float32
// AudioOutput represents something where we can send audio e.g. audio output.
// WriteAudio should block if not ready to accept audio e.g. buffer full.
AudioOutput interface {
WriteAudio(buffer AudioBuffer) error
Close() error
CloserWaiter interface {
io.Closer
Wait()
}
// AudioContext represents the low-level audio drivers. There should be at most
// one AudioContext at a time. The interface is implemented at least by
// AudioContext represents the low-level audio drivers. There should be at
// most one AudioContext at a time. The interface is implemented at least by
// oto.OtoContext, but in future we could also mock it.
//
// AudioContext is used to create one or more AudioOutputs with Output(); each
// can be used to output separate sound & closed when done.
// AudioContext is used to play one or more AudioSources. Playing can be
// stopped by closing the returned io.Closer.
AudioContext interface {
Output() AudioOutput
Close() error
Play(r AudioSource) CloserWaiter
}
// AudioSource is an function for reading audio samples into an AudioBuffer.
// Returns error if the buffer is not filled.
AudioSource func(buf AudioBuffer) error
BufferSource struct {
buffer AudioBuffer
pos int
}
// Synth represents a state of a synthesizer, compiled from a Patch.
@ -55,13 +63,24 @@ type (
// Release releases the currently playing note for a given voice. Called
// between synth.Renders.
Release(voice int)
// Close disposes the synth, freeing any resources. No other functions should be called after Close.
Close()
// Populates the given array with the current CPU load of each thread,
// returning the number of threads / elements populated
CPULoad([]CPULoad) int
}
// Synther compiles a given Patch into a Synth, throwing errors if the
// Patch is malformed.
Synther interface {
Name() string // Name of the synther, e.g. "Go" or "Native"
Synth(patch Patch, bpm int) (Synth, error)
SupportsMultithreading() bool
}
CPULoad float32
)
// Play plays the Song by first compiling the patch with the given Synther,
@ -75,6 +94,7 @@ func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, erro
if err != nil {
return nil, fmt.Errorf("sointu.Play failed: %v", err)
}
defer synth.Close()
curVoices := make([]int, len(song.Score.Tracks))
for i := range curVoices {
curVoices[i] = song.Score.FirstVoiceForTrack(i)
@ -145,6 +165,28 @@ func (buffer AudioBuffer) Fill(synth Synth) error {
return nil
}
func (b AudioBuffer) Source() AudioSource {
return func(buf AudioBuffer) error {
n := copy(buf, b)
b = b[n:]
if n < len(buf) {
return io.EOF
}
return nil
}
}
// ReadAudio reads audio samples from an AudioSource into an AudioBuffer.
// Returns an error when the buffer is fully consumed.
func (a *BufferSource) ReadAudio(buf AudioBuffer) error {
n := copy(buf, a.buffer[a.pos:])
a.pos += n
if a.pos >= len(a.buffer) {
return io.EOF
}
return nil
}
// Wav converts an AudioBuffer into a valid WAV-file, returned as a []byte
// array.
//
@ -174,6 +216,17 @@ func (buffer AudioBuffer) Raw(pcm16 bool) ([]byte, error) {
return buf.Bytes(), nil
}
func (p *CPULoad) Update(duration time.Duration, frames int64) {
if frames <= 0 {
return // no frames rendered, so cannot compute CPU load
}
realtime := float64(duration) / 1e9
songtime := float64(frames) / 44100
newload := realtime / songtime
alpha := math.Exp(-songtime) // smoothing factor, time constant of 1 second
*p = CPULoad(float64(*p)*alpha + newload*(1-alpha))
}
func (data AudioBuffer) rawToBuffer(pcm16 bool, buf *bytes.Buffer) error {
var err error
if pcm16 {

View File

@ -1,7 +0,0 @@
//go:build native
package cmd
import "github.com/vsariola/sointu/vm/compiler/bridge"
var MainSynther = bridge.NativeSynther{}

View File

@ -1,7 +0,0 @@
//go:build !native
package cmd
import "github.com/vsariola/sointu/vm"
var MainSynther = vm.GoSynther{}

12
cmd/midi_cgo.go Normal file
View File

@ -0,0 +1,12 @@
//go:build cgo
package cmd
import (
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/tracker/gomidi"
)
func NewMidiContext(broker *tracker.Broker) tracker.MIDIContext {
return gomidi.NewContext(broker)
}

12
cmd/midi_not_cgo.go Normal file
View File

@ -0,0 +1,12 @@
//go:build !cgo
package cmd
import (
"github.com/vsariola/sointu/tracker"
)
func NewMidiContext(broker *tracker.Broker) tracker.MIDIContext {
// with no cgo, we cannot use MIDI, so return a null context
return tracker.NullMIDIContext{}
}

View File

@ -14,6 +14,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/version"
"github.com/vsariola/sointu/vm/compiler"
)
@ -43,8 +44,13 @@ func main() {
targetArch := flag.String("arch", runtime.GOARCH, "Target architecture. Defaults to OS architecture. Possible values: 386, amd64, wasm")
output16bit := flag.Bool("i", false, "Compiled song should output 16-bit integers, instead of floats.")
targetOs := flag.String("os", runtime.GOOS, "Target OS. Defaults to current OS. Possible values: windows, darwin, linux. Anything else is assumed linuxy. Ignored when targeting wasm.")
versionFlag := flag.Bool("v", false, "Print version.")
flag.Usage = printUsage
flag.Parse()
if *versionFlag {
fmt.Println(version.VersionOrHash)
os.Exit(0)
}
if (flag.NArg() == 0 && !*library) || *help {
flag.Usage()
os.Exit(0)

View File

@ -9,11 +9,13 @@ import (
"path/filepath"
"strings"
"github.com/vsariola/sointu/cmd"
"gopkg.in/yaml.v3"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/oto"
"github.com/vsariola/sointu/vm/compiler/bridge"
"github.com/vsariola/sointu/version"
)
func main() {
@ -27,8 +29,21 @@ func main() {
rawOut := flag.Bool("r", false, "Output the rendered song as .raw file. By default, saves stereo float32 buffer to disk.")
wavOut := flag.Bool("w", false, "Output the rendered song as .wav file. By default, saves stereo float32 buffer to disk.")
pcm := flag.Bool("c", false, "Convert audio to 16-bit signed PCM when outputting.")
versionFlag := flag.Bool("v", false, "Print version.")
syntherInt := flag.Int("synth", 0, "Select the synther to use. By default, uses the first one in the list of available synthers.")
flag.Usage = printUsage
flag.Parse()
if *versionFlag {
fmt.Println(version.VersionOrHash)
os.Exit(0)
}
if *syntherInt < 0 || *syntherInt >= len(cmd.Synthers) {
fmt.Fprintf(os.Stderr, "synth index %d is out of range; available synthers:\n", *syntherInt)
for i, s := range cmd.Synthers {
fmt.Fprintf(os.Stderr, " %d: %s\n", i, s.Name())
}
os.Exit(1)
}
if flag.NArg() == 0 || *help {
flag.Usage()
os.Exit(0)
@ -37,6 +52,7 @@ func main() {
*play = true // if the user gives nothing to output, then the default behaviour is just to play the file
}
var audioContext sointu.AudioContext
var playWaiter sointu.CloserWaiter
if *play {
var err error
audioContext, err = oto.NewContext()
@ -44,7 +60,6 @@ func main() {
fmt.Fprintf(os.Stderr, "could not acquire oto AudioContext: %v\n", err)
os.Exit(1)
}
defer audioContext.Close()
}
process := func(filename string) error {
output := func(extension string, contents []byte) error {
@ -87,16 +102,12 @@ 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.Synthers[*syntherInt], song, nil) // render the song to calculate its length
if err != nil {
return fmt.Errorf("sointu.Play failed: %v", err)
}
if *play {
output := audioContext.Output()
defer output.Close()
if err := output.WriteAudio(buffer); err != nil {
return fmt.Errorf("error playing: %v", err)
}
playWaiter = audioContext.Play(buffer.Source())
}
if *rawOut {
raw, err := buffer.Raw(*pcm)
@ -116,6 +127,9 @@ func main() {
return fmt.Errorf("error outputting .wav file: %v", err)
}
}
if *play {
playWaiter.Wait()
}
return nil
}
retval := 0

View File

@ -17,19 +17,9 @@ import (
"github.com/vsariola/sointu/tracker/gioui"
)
type NullContext struct {
}
func (NullContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
return tracker.MIDINoteEvent{}, false
}
func (NullContext) BPM() (bpm float64, ok bool) {
return 0, false
}
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 prefix")
func main() {
flag.Parse()
@ -49,32 +39,45 @@ func main() {
fmt.Println(err)
os.Exit(1)
}
defer audioContext.Close()
recoveryFile := ""
if configDir, err := os.UserConfigDir(); err == nil {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
}
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
broker := tracker.NewBroker()
midiContext := cmd.NewMidiContext(broker)
defer midiContext.Close()
if isFlagPassed("midi-input") {
input, ok := tracker.FindMIDIDeviceByPrefix(midiContext, *defaultMidiInput)
if ok {
err := midiContext.Open(input)
if err != nil {
log.Printf("failed to open MIDI input '%s': %v", input, err)
}
} else {
log.Printf("no MIDI input device found with prefix '%s'", *defaultMidiInput)
}
}
model := tracker.NewModel(broker, cmd.Synthers, midiContext, recoveryFile)
player := tracker.NewPlayer(broker, cmd.Synthers[0])
if a := flag.Args(); len(a) > 0 {
f, err := os.Open(a[0])
if err == nil {
model.ReadSong(f)
model.Song().Read(f)
}
f.Close()
}
tracker := gioui.NewTracker(model)
output := audioContext.Output()
defer output.Close()
trackerUi := gioui.NewTracker(model)
audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error {
player.Process(buf, tracker.NullPlayerProcessContext{})
return nil
})
go func() {
buf := make(sointu.AudioBuffer, 1024)
ctx := NullContext{}
for {
player.Process(buf, ctx)
output.WriteAudio(buf)
}
}()
go func() {
tracker.Main()
trackerUi.Main()
audioCloser.Close()
model.Close()
if *cpuprofile != "" {
pprof.StopCPUProfile()
f.Close()
@ -94,3 +97,13 @@ func main() {
}()
app.Main()
}
func isFlagPassed(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}

View File

@ -18,31 +18,13 @@ import (
"pipelined.dev/audio/vst2"
)
type VSTIProcessContext struct {
events []vst2.MIDIEvent
eventIndex int
host vst2.Host
}
func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
for c.eventIndex < len(c.events) {
ev := c.events[c.eventIndex]
c.eventIndex++
switch {
case ev.Data[0] >= 0x80 && ev.Data[0] < 0x90:
channel := ev.Data[0] - 0x80
note := ev.Data[1]
return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: false, Channel: int(channel), Note: note}, true
case ev.Data[0] >= 0x90 && ev.Data[0] < 0xA0:
channel := ev.Data[0] - 0x90
note := ev.Data[1]
return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: true, Channel: int(channel), Note: note}, true
default:
// ignore all other MIDI messages
}
type (
VSTIProcessContext struct {
events []vst2.MIDIEvent
eventIndex int
host vst2.Host
}
return tracker.MIDINoteEvent{}, false
}
)
func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) {
timeInfo := c.host.GetTimeInfo(vst2.TempoValid)
@ -63,41 +45,45 @@ func init() {
rand.Read(randBytes)
recoveryFile = filepath.Join(configDir, "sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes))
}
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
broker := tracker.NewBroker()
model := tracker.NewModel(broker, cmd.Synthers, cmd.NewMidiContext(broker), recoveryFile)
player := tracker.NewPlayer(broker, cmd.Synthers[0])
t := gioui.NewTracker(model)
tracker.Bool{BoolData: (*tracker.InstrEnlarged)(model)}.Set(true)
if s := h.GetSampleRate(); math.Abs(float64(h.GetSampleRate()-44100.0)) > 1e-6 {
model.Alerts().AddAlert(tracker.Alert{
Message: fmt.Sprintf("VSTi host sample rate is %.0f Hz; sointu supports 44100 Hz only", s),
Priority: tracker.Error,
Duration: 10 * time.Second,
})
}
model.Play().TrackerHidden().SetValue(true)
// since the VST is usually working without any regard for the tracks
// until recording, disable the Instrument-Track linking by default
// because it might just confuse the user why instrument cannot be
// swapped/added etc.
model.Track().LinkInstrument().SetValue(false)
go t.Main()
context := VSTIProcessContext{host: h}
context := &VSTIProcessContext{host: h}
buf := make(sointu.AudioBuffer, 1024)
var totalFrames int64 = 0
return vst2.Plugin{
UniqueID: PLUGIN_ID,
UniqueID: [4]byte{'S', 'n', 't', 'u'},
Version: version,
InputChannels: 0,
OutputChannels: 2,
Name: PLUGIN_NAME,
Name: "Sointu",
Vendor: "vsariola/sointu",
Category: vst2.PluginCategorySynth,
Flags: vst2.PluginIsSynth,
ProcessFloatFunc: func(in, out vst2.FloatBuffer) {
if s := h.GetSampleRate(); math.Abs(float64(h.GetSampleRate()-44100.0)) > 1e-6 {
player.SendAlert("WrongSampleRate", fmt.Sprintf("VSTi host sample rate is %.0f Hz; sointu supports 44100 Hz only", s), tracker.Error)
}
left := out.Channel(0)
right := out.Channel(1)
if len(buf) < out.Frames {
buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...)
}
buf = buf[:out.Frames]
player.Process(buf, &context)
player.Process(buf, context)
for i := 0; i < out.Frames; i++ {
left[i], right[i] = buf[i][0], buf[i][1]
}
context.events = context.events[:0] // reset buffer, but keep the allocated memory
context.eventIndex = 0
totalFrames += int64(out.Frames)
},
}, vst2.Dispatcher{
CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse {
@ -107,26 +93,36 @@ func init() {
}
return vst2.NoCanDo
},
ProcessEventsFunc: func(ev *vst2.EventsPtr) {
for i := 0; i < ev.NumEvents(); i++ {
a := ev.Event(i)
switch v := a.(type) {
ProcessEventsFunc: func(events *vst2.EventsPtr) {
for i := 0; i < events.NumEvents(); i++ {
switch ev := events.Event(i).(type) {
case *vst2.MIDIEvent:
context.events = append(context.events, *v)
if ev.Data[0] >= 0x80 && ev.Data[0] <= 0x9F {
channel := ev.Data[0] & 0x0F
note := ev.Data[1]
on := ev.Data[0] >= 0x90
trackerEvent := tracker.NoteEvent{Timestamp: int64(ev.DeltaFrames) + totalFrames, On: on, Channel: int(channel), Note: note, Source: &context}
tracker.TrySend(broker.MIDIChannel(), any(trackerEvent))
}
}
}
},
CloseFunc: func() {
t.Exec() <- func() { t.ForceQuit().Do() }
t.WaitQuitted()
tracker.TrySend(broker.CloseGUI, struct{}{})
model.Close()
tracker.TimeoutReceive(broker.FinishedGUI, 3*time.Second)
},
GetChunkFunc: func(isPreset bool) []byte {
retChn := make(chan []byte)
t.Exec() <- func() { retChn <- t.MarshalRecovery() }
return <-retChn
if !tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { retChn <- t.History().MarshalRecovery() }}) {
return nil
}
ret, _ := tracker.TimeoutReceive(retChn, 5*time.Second) // ret will be nil if timeout or channel closed
return ret
},
SetChunkFunc: func(data []byte, isPreset bool) {
t.Exec() <- func() { t.UnmarshalRecovery(data) }
tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { t.History().UnmarshalRecovery(data) }})
},
}

View File

@ -1,6 +0,0 @@
//go:build native
package main
var PLUGIN_ID = [4]byte{'S', 'n', 't', 'N'}
var PLUGIN_NAME = "Sointu Native"

View File

@ -1,6 +0,0 @@
//go:build !native
package main
var PLUGIN_ID = [4]byte{'S', 'n', 't', 'u'}
var PLUGIN_NAME = "Sointu"

11
cmd/synthers.go Normal file
View File

@ -0,0 +1,11 @@
package cmd
import (
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
)
var Synthers = []sointu.Synther{
vm.GoSynther{},
vm.MakeMultithreadSynther(vm.GoSynther{}),
}

13
cmd/synthers_native.go Normal file
View File

@ -0,0 +1,13 @@
//go:build native
package cmd
import (
"github.com/vsariola/sointu/vm"
"github.com/vsariola/sointu/vm/compiler/bridge"
)
func init() {
Synthers = append(Synthers, bridge.NativeSynther{})
Synthers = append(Synthers, vm.MakeMultithreadSynther(bridge.NativeSynther{}))
}

View File

@ -43,7 +43,7 @@ if(WIN32)
add_dependencies(examples cplay-directsound)
elseif(UNIX)
add_executable(cplay
cplay.unix.c
cplay.linux.c
physics_girl_st.h
)
target_link_libraries(cplay PRIVATE asound pthread)

View File

@ -2,12 +2,17 @@
#include <pthread.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#include "physics_girl_st.h"
static SUsample sound_buffer[SU_LENGTH_IN_SAMPLES * SU_CHANNEL_COUNT];
static snd_pcm_t *pcm_handle;
static pthread_t render_thread;
static uint32_t render_thread_handle;
static pthread_t render_thread, playback_thread;
static uint32_t render_thread_handle, playback_thread_handle;
void play() {
snd_pcm_writei(pcm_handle, sound_buffer, SU_LENGTH_IN_SAMPLES);
}
int main(int argc, char **args) {
// Unix does not have gm.dls, no need to ifdef and setup here.
@ -33,7 +38,21 @@ int main(int argc, char **args) {
0,
SU_LENGTH_IN_SAMPLES
);
snd_pcm_writei(pcm_handle, sound_buffer, SU_LENGTH_IN_SAMPLES);
playback_thread_handle = pthread_create(&playback_thread, 0, (void *(*)(void *))play, 0);
// This is for obtaining the playback time.
snd_pcm_status_t *status;
snd_pcm_status_malloc(&status);
snd_htimestamp_t htime, htstart;
snd_pcm_status(pcm_handle, status);
snd_pcm_status_get_htstamp(status, &htstart);
for(int sample; sample < SU_LENGTH_IN_SAMPLES; sample = (int)(((float)htime.tv_sec + (float)htime.tv_nsec * 1.e-9 - (float)htstart.tv_sec - (float)htstart.tv_nsec * 1.e-9) * SU_SAMPLE_RATE)) {
snd_pcm_status(pcm_handle, status);
snd_pcm_status_get_htstamp(status, &htime);
printf("Sample: %d\n", sample);
usleep(1000000 / 30);
}
snd_pcm_status_free(status);
return 0;
}

View File

@ -22,6 +22,11 @@ WAVEFORMATEX wave_format = {
SU_SAMPLE_SIZE*8,
0
};
// If you want to loop the song:
// 1) Change WHDR_PREPARED -> WHDR_BEGINLOOP | WHDR_ENDLOOP | WHDR_PREPARED
// 2) The next field should then contain the number of loops (for example, 4)
// 3) Remember also change the exit condition for main, e.g. if you plan to loop 4 times:
// mmtime.u.sample != SU_LENGTH_IN_SAMPLES -> mmtime.u.sample != 4 * SU_LENGTH_IN_SAMPLES
WAVEHDR wave_header = {
(LPSTR)sound_buffer,
SU_LENGTH_IN_SAMPLES * SU_SAMPLE_SIZE * SU_CHANNEL_COUNT,

View File

@ -49,7 +49,7 @@ patch:
parameters: {stereo: 0}
- type: filter
id: 10
parameters: {bandpass: 0, frequency: 0, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 58, stereo: 0}
parameters: {bandpass: 0, frequency: 0, highpass: 0, lowpass: 1, resonance: 58, stereo: 0}
- type: delay
id: 4
parameters: {damp: 0, dry: 128, feedback: 96, notetracking: 2, pregain: 40, stereo: 0}
@ -87,7 +87,7 @@ patch:
parameters: {color: 59, detune: 73, gain: 35, phase: 26, shape: 70, stereo: 0, transpose: 57, type: 2, unison: 3}
- type: filter
id: 31
parameters: {bandpass: 0, frequency: 37, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 60, stereo: 0}
parameters: {bandpass: 0, frequency: 37, highpass: 0, lowpass: 1, resonance: 60, stereo: 0}
- type: mulp
id: 24
parameters: {stereo: 0}
@ -198,7 +198,7 @@ patch:
parameters: {panning: 64, stereo: 0}
- type: filter
id: 76
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 1}
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, resonance: 64, stereo: 1}
- type: outaux
id: 73
parameters: {auxgain: 64, outgain: 64, stereo: 1}

33
go.mod
View File

@ -1,38 +1,41 @@
module github.com/vsariola/sointu
go 1.21
go 1.24.0
require (
gioui.org v0.5.0
gioui.org/x v0.5.0
gioui.org v0.9.0
gioui.org/x v0.8.1
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/hajimehoshi/oto v0.6.6
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
golang.org/x/text v0.9.0
gopkg.in/yaml.v2 v2.3.0
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43
github.com/viterin/vek v0.4.2
gitlab.com/gomidi/midi/v2 v2.2.10
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/text v0.24.0
gopkg.in/yaml.v3 v3.0.1
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2
)
require (
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/Masterminds/goutils v1.1.0 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 // indirect
github.com/chewxy/math32 v1.11.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/go-text/typesetting v0.3.0 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/google/uuid v1.1.2 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/stretchr/testify v1.6.1 // indirect
github.com/viterin/partial v1.1.0 // indirect
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/sys v0.40.0 // indirect
pipelined.dev/pipe v0.11.0 // indirect
pipelined.dev/signal v0.10.0 // indirect
)

139
go.sum
View File

@ -1,117 +1,92 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.5.0 h1:07g7/LY1MFuTncfO4A5DIKMMsQV6PkPHyx0MhDqgmYY=
gioui.org v0.5.0/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q=
gioui.org v0.8.1-0.20250526181049-1a17e9ea3725 h1:8dzkqzvWLIwW6HEQv5CinK53vMeANmUEETzpcbtPRp0=
gioui.org v0.8.1-0.20250526181049-1a17e9ea3725/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org v0.8.1-0.20250624114559-c3ce484b5e80 h1:cnimNlq1PEHY4z1Cy32n6In86VUF5/VLi7cWHAM1XcY=
gioui.org v0.8.1-0.20250624114559-c3ce484b5e80/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.5.0 h1:NVKTn5AZuYhkAnF7MYcy1dIes36+U1N4gUTsgBhfr4A=
gioui.org/x v0.5.0/go.mod h1:X4UBhvanAN+8S16L3K6jDMrVo7Dii7NptgBpOLBD7E4=
gioui.org/x v0.7.1 h1:7bnQHsV7qB36tIUit2WDcUx4Cnmo+6T9I38B9brLQ7o=
gioui.org/x v0.7.1/go.mod h1:5CzZ64oFpOaqb2kaMvj+QEr5T3nVuLKD0LizLH32ii0=
gioui.org/x v0.8.1 h1:Q2wumEOfjz3XfRa3TEi6w7dq8+cxV8zsYK8xXQkrCRk=
gioui.org/x v0.8.1/go.mod h1:v2g60aiZtIVR7lNFXZ123+U0kijJeOChODSuqr7MFSI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg=
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 h1:uF5Q/hWnDU1XZeT6CsrRSxHLroUSEYYO3kgES+yd+So=
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg=
github.com/chewxy/math32 v1.11.1 h1:b7PGHlp8KjylDoU8RrcEsRuGZhJuz8haxnKfuMMRqy8=
github.com/chewxy/math32 v1.11.1/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/oto/v3 v3.3.0 h1:34lJpJLqda0Iee9g9p8RWtVVwBcOOO2YSIS2x4yD1OQ=
github.com/ebitengine/oto/v3 v3.3.0/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43 h1:2sTZTp/Nc8srRyDdari4gS+clwfnuNmpLiLvmwxqPVE=
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hajimehoshi/oto v0.6.6 h1:HYSZ8cYZqOL4iHugvbcfhNN2smiSOsBMaoSBi4nnWcw=
github.com/hajimehoshi/oto v0.6.6/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/viterin/partial v1.1.0 h1:iH1l1xqBlapXsYzADS1dcbizg3iQUKTU1rbwkHv/80E=
github.com/viterin/partial v1.1.0/go.mod h1:oKGAo7/wylWkJTLrWX8n+f4aDPtQMQ6VG4dd2qur5QA=
github.com/viterin/vek v0.4.2 h1:Vyv04UjQT6gcjEFX82AS9ocgNbAJqsHviheIBdPlv5U=
github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc=
gitlab.com/gomidi/midi/v2 v2.2.10 h1:u9D+5TM0vkFWF5DcO6xGKG99ERYqksh6wPj2X2Rx5A8=
gitlab.com/gomidi/midi/v2 v2.2.10/go.mod h1:ENtYaJPOwb2N+y7ihv/L7R4GtWjbknouhIIkMrJ5C0g=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f h1:kgfVkAEEQXXQ0qc6dH7n6y37NAYmTFmz0YRwrRjgxKw=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2 h1:qrI7YY5ZH4pJflMfzum2TKvA1NaX+H4feaA6jweX2R8=
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8=
pipelined.dev/pipe v0.10.0/go.mod h1:aIt+NPlW0QLYByqYniG77lTxSvl7OtCNLws/m+Xz5ww=

View File

@ -1,32 +0,0 @@
package oto
import (
"math"
"github.com/vsariola/sointu"
)
// FloatBufferTo16BitLE is a naive helper method to convert []float32 buffers to
// 16-bit little-endian, but encoded in byte buffer
//
// Appends the encoded bytes into "to" slice, allowing you to preallocate the
// capacity or just use nil
func FloatBufferTo16BitLE(from sointu.AudioBuffer, to []byte) []byte {
for _, v := range from {
left := to16BitSample(v[0])
right := to16BitSample(v[1])
to = append(to, byte(left&255), byte(left>>8), byte(right&255), byte(right>>8))
}
return to
}
// convert float32 to int16, clamping to min and max
func to16BitSample(v float32) int16 {
if v < -1.0 {
return -math.MaxInt16
}
if v > 1.0 {
return math.MaxInt16
}
return int16(v * math.MaxInt16)
}

View File

@ -1,46 +0,0 @@
package oto_test
import (
"reflect"
"testing"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/oto"
)
func TestFloatBufferToBytes(t *testing.T) {
floats := sointu.AudioBuffer{{0, 0.000489128}, {0, 0.0019555532}, {0, 0.0043964}, {0, 0.007806882}, {0, 0.012180306}, {0, 0.017508084}, {0, 0.023779746}, {0, 0.030982954}, {0, 0.039103523}, {0, 0.04812544}, {0, 0.05803088}, {0, 0.068800256}, {0, 0.08041221}, {0, 0.09284368}, {0, 0.10606992}, {0, 0.120064534}, {0, 0.13479951}, {0, 0.1502453}, {0, 0.16637078}, {0, 0.18314338}, {0, 0.20052913}, {0, 0.21849263}, {0, 0.23699719}, {0, 0.2560048}, {0, 0.27547634}, {0, 0.29537144}, {0, 0.31564865}, {0, 0.33626547}, {0, 0.35717854}, {0, 0.37834346}, {0, 0.39971504}, {0, 0.4212474}, {0, 0.4428938}, {0, 0.46460703}, {0, 0.48633927}, {0, 0.50804216}, {0, 0.52966696}, {0, 0.5511646}, {0, 0.57248586}, {0, 0.5935812}, {0, 0.6144009}, {0, 0.63489544}, {0, 0.6550152}, {0, 0.67471063}, {0, 0.6939326}, {0, 0.712632}, {0, 0.7307603}, {0, 0.7482692}, {0, 0.7651111}, {0, 0.7812389}}
bytes := []byte{0x0, 0x0, 0x10, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, 0x90, 0x0, 0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x8f, 0x1, 0x0, 0x0, 0x3d, 0x2, 0x0, 0x0, 0xb, 0x3, 0x0, 0x0, 0xf7, 0x3, 0x0, 0x0, 0x1, 0x5, 0x0, 0x0, 0x28, 0x6, 0x0, 0x0, 0x6d, 0x7, 0x0, 0x0, 0xce, 0x8, 0x0, 0x0, 0x4a, 0xa, 0x0, 0x0, 0xe2, 0xb, 0x0, 0x0, 0x93, 0xd, 0x0, 0x0, 0x5e, 0xf, 0x0, 0x0, 0x40, 0x11, 0x0, 0x0, 0x3b, 0x13, 0x0, 0x0, 0x4b, 0x15, 0x0, 0x0, 0x71, 0x17, 0x0, 0x0, 0xaa, 0x19, 0x0, 0x0, 0xf7, 0x1b, 0x0, 0x0, 0x55, 0x1e, 0x0, 0x0, 0xc4, 0x20, 0x0, 0x0, 0x42, 0x23, 0x0, 0x0, 0xce, 0x25, 0x0, 0x0, 0x66, 0x28, 0x0, 0x0, 0xa, 0x2b, 0x0, 0x0, 0xb7, 0x2d, 0x0, 0x0, 0x6d, 0x30, 0x0, 0x0, 0x29, 0x33, 0x0, 0x0, 0xeb, 0x35, 0x0, 0x0, 0xb0, 0x38, 0x0, 0x0, 0x77, 0x3b, 0x0, 0x0, 0x3f, 0x3e, 0x0, 0x0, 0x7, 0x41, 0x0, 0x0, 0xcb, 0x43, 0x0, 0x0, 0x8c, 0x46, 0x0, 0x0, 0x46, 0x49, 0x0, 0x0, 0xf9, 0x4b, 0x0, 0x0, 0xa4, 0x4e, 0x0, 0x0, 0x43, 0x51, 0x0, 0x0, 0xd6, 0x53, 0x0, 0x0, 0x5c, 0x56, 0x0, 0x0, 0xd2, 0x58, 0x0, 0x0, 0x36, 0x5b, 0x0, 0x0, 0x88, 0x5d, 0x0, 0x0, 0xc6, 0x5f, 0x0, 0x0, 0xee, 0x61, 0x0, 0x0, 0xfe, 0x63}
converted := oto.FloatBufferTo16BitLE(floats, nil)
for i, v := range converted {
if bytes[i] != v {
t.Fail()
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
}
}
if !reflect.DeepEqual(converted, bytes) {
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
}
}
func TestFloatBufferToBytesLimits(t *testing.T) {
floats := sointu.AudioBuffer{{0, 1}, {-1, 0.999}, {-0.999, 0}}
bytes := []byte{
0x0, 0x0,
0xFF, 0x7F, // float 1 = 0x7FFF = 0111111111111111
0x01, 0x80, // float -1 = 0x8001 = 1000000000000001
0xDE, 0x7F, // float 0.999 = 0x7FDE = 0111111111011110
0x22, 0x80, // float -0.999 = 0x8022 = 1000000000100010
0x0, 0x0,
}
converted := oto.FloatBufferTo16BitLE(floats, nil)
for i, v := range converted {
if bytes[i] != v {
t.Fail()
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
}
}
if !reflect.DeepEqual(converted, bytes) {
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
}
}

View File

@ -1,55 +1,99 @@
package oto
import (
"encoding/binary"
"errors"
"fmt"
"math"
"sync"
"github.com/hajimehoshi/oto"
"github.com/ebitengine/oto/v3"
"github.com/vsariola/sointu"
)
type OtoContext oto.Context
type OtoOutput struct {
player *oto.Player
tmpBuffer []byte
}
const latency = 2048 // in samples at 44100 Hz = ~46 ms
func (c *OtoContext) Output() sointu.AudioOutput {
return &OtoOutput{player: (*oto.Context)(c).NewPlayer(), tmpBuffer: make([]byte, 0)}
}
type (
OtoContext oto.Context
const otoBufferSize = 8192
OtoPlayer struct {
player *oto.Player
reader *OtoReader
}
OtoReader struct {
audioSource sointu.AudioSource
tmpBuffer sointu.AudioBuffer
waitGroup sync.WaitGroup
err error
errMutex sync.RWMutex
}
)
// NewPlayer creates and initializes a new OtoPlayer
func NewContext() (*OtoContext, error) {
context, err := oto.NewContext(44100, 2, 2, otoBufferSize)
op := oto.NewContextOptions{}
op.SampleRate = 44100
op.ChannelCount = 2
op.Format = oto.FormatFloat32LE
context, readyChan, err := oto.NewContext(&op)
if err != nil {
return nil, fmt.Errorf("cannot create oto context: %w", err)
}
<-readyChan
return (*OtoContext)(context), nil
}
func (c *OtoContext) Close() error {
if err := (*oto.Context)(c).Close(); err != nil {
return fmt.Errorf("cannot close oto context: %w", err)
}
return nil
func (c *OtoContext) Play(r sointu.AudioSource) sointu.CloserWaiter {
reader := &OtoReader{audioSource: r}
reader.waitGroup.Add(1)
player := (*oto.Context)(c).NewPlayer(reader)
player.SetBufferSize(latency * 8)
player.Play()
return OtoPlayer{player: player, reader: reader}
}
// Play implements the audio.Player interface for OtoPlayer
func (o *OtoOutput) WriteAudio(floatBuffer sointu.AudioBuffer) (err error) {
// we reuse the old capacity tmpBuffer by setting its length to zero. then,
// we save the tmpBuffer so we can reuse it next time
o.tmpBuffer = FloatBufferTo16BitLE(floatBuffer, o.tmpBuffer[:0])
if _, err := o.player.Write(o.tmpBuffer); err != nil {
return fmt.Errorf("cannot write to player: %w", err)
}
return nil
func (o OtoPlayer) Wait() {
o.reader.waitGroup.Wait()
}
// Close disposes of resources
func (o *OtoOutput) Close() error {
if err := o.player.Close(); err != nil {
return fmt.Errorf("cannot close oto player: %w", err)
}
return nil
func (o OtoPlayer) Close() error {
o.reader.closeWithError(errors.New("OtoPlayer was closed"))
return o.player.Close()
}
func (o *OtoReader) Read(b []byte) (n int, err error) {
o.errMutex.RLock()
if o.err != nil {
o.errMutex.RUnlock()
return 0, o.err
}
o.errMutex.RUnlock()
if len(b)%8 != 0 {
return o.closeWithError(fmt.Errorf("oto: Read buffer length must be a multiple of 8"))
}
samples := len(b) / 8
if samples > len(o.tmpBuffer) {
o.tmpBuffer = append(o.tmpBuffer, make(sointu.AudioBuffer, samples-len(o.tmpBuffer))...)
} else if samples < len(o.tmpBuffer) {
o.tmpBuffer = o.tmpBuffer[:samples]
}
err = o.audioSource(o.tmpBuffer)
if err != nil {
return o.closeWithError(err)
}
for i := range o.tmpBuffer {
binary.LittleEndian.PutUint32(b[i*8:], math.Float32bits(o.tmpBuffer[i][0]))
binary.LittleEndian.PutUint32(b[i*8+4:], math.Float32bits(o.tmpBuffer[i][1]))
}
return samples * 8, nil
}
func (o *OtoReader) closeWithError(err error) (int, error) {
o.errMutex.Lock()
defer o.errMutex.Unlock()
if o.err == nil {
o.err = err
o.waitGroup.Done()
}
return 0, err
}

567
patch.go
View File

@ -4,7 +4,11 @@ import (
"errors"
"fmt"
"math"
"math/bits"
"sort"
"strconv"
"gopkg.in/yaml.v3"
)
type (
@ -16,7 +20,12 @@ type (
Name string `yaml:",omitempty"`
Comment string `yaml:",omitempty"`
NumVoices int
Units []Unit
Mute bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field
// ThreadMaskM1 is a bit mask of which threads are used, minus 1. Minus
// 1 is done so that the default value 0 means bit mask 0b0001 i.e. only
// thread 1 is rendering the instrument.
ThreadMaskM1 int `yaml:",omitempty"`
Units []Unit
}
// Unit is e.g. a filter, oscillator, envelope and its parameters
@ -37,7 +46,7 @@ type (
// an oscillator, unit.Type == "oscillator" and unit.Parameters["attack"]
// could be 64. Most parameters are either limites to 0 and 1 (e.g. stereo
// parameters) or between 0 and 128, inclusive.
Parameters map[string]int `yaml:",flow"`
Parameters ParamMap `yaml:",flow"`
// VarArgs is a list containing the variable number arguments that some
// units require, most notably the DELAY units. For example, for a DELAY
@ -48,16 +57,34 @@ type (
// Disabled is a flag that can be set to true to disable the unit.
// Disabled units are considered to be not present in the patch.
Disabled bool `yaml:",omitempty"`
// Comment is a free-form comment about the unit that can be displayed
// instead of/besides the type of the unit in the GUI, to make it easier
// to track what the unit is doing & to make it easier to target sends.
Comment string `yaml:",omitempty"`
}
ParamMap map[string]int
// UnitParameter documents one parameter that an unit takes
UnitParameter struct {
Name string // thould be found with this name in the Unit.Parameters map
MinValue int // minimum value of the parameter, inclusive
MaxValue int // maximum value of the parameter, inclusive
Neutral int // neutral value of the parameter
CanSet bool // if this parameter can be set before hand i.e. through the gui
CanModulate bool // if this parameter can be modulated i.e. has a port number in "send" unit
DisplayFunc UnitParameterDisplayFunc
}
// StackUse documents how a unit will affect the signal stack.
StackUse struct {
Inputs [][]int // Inputs documents which inputs contribute to which outputs. len(Inputs) is the number of inputs. Each input can contribute to multiple outputs, so its a slice.
Modifies []bool // Modifies documents which of the (mixed) inputs are actually modified by the unit
NumOutputs int // NumOutputs is the number of outputs produced by the unit. This is used to determine how many outputs are needed for the unit.
}
UnitParameterDisplayFunc func(int) (value string, unit string)
)
// UnitTypes documents all the available unit types and if they support stereo variant
@ -73,90 +100,96 @@ var UnitTypes = map[string]([]UnitParameter){
"xch": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
"distort": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "drive", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "drive", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
"hold": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "holdfreq", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
"crush": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "resolution", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "resolution", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(24 * float64(v) / 128), "bits" }}},
"gain": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
"invgain": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB" }}},
"dbgain": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "decibels", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "decibels", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(40 * (float64(v)/64 - 1)), "dB" }}},
"filter": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "resonance", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: filterFrequencyDispFunc},
{Name: "resonance", MinValue: 0, Neutral: 128, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "Q dB"
}},
{Name: "lowpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "bandpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "highpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "negbandpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "neghighpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
{Name: "bandpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "highpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false}},
"clip": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
"pan": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "panning", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "panning", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
"delay": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "pregain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "dry", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "feedback", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "damp", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "notetracking", MinValue: 0, MaxValue: 2, CanSet: true, CanModulate: false},
{Name: "notetracking", MinValue: 0, MaxValue: 2, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(noteTrackingNames[:])},
{Name: "delaytime", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
"compressor": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "threshold", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "ratio", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB"
}},
{Name: "threshold", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB"
}},
{Name: "ratio", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(1 - float64(v)/128), "" }}},
"speed": []UnitParameter{},
"out": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
"outaux": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "outgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "auxgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "outgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
{Name: "auxgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
"aux": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false}},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
"send": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "amount", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "voice", MinValue: 0, MaxValue: 32, CanSet: true, CanModulate: false},
{Name: "amount", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }},
{Name: "voice", MinValue: 0, MaxValue: 32, CanSet: true, CanModulate: false, DisplayFunc: sendVoiceDispFunc},
{Name: "target", MinValue: 0, MaxValue: math.MaxInt32, CanSet: true, CanModulate: false},
{Name: "port", MinValue: 0, MaxValue: 7, CanSet: true, CanModulate: false},
{Name: "sendpop", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
"envelope": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{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: "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: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
{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, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
"noise": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "shape", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
"oscillator": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "transpose", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "detune", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "phase", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "transpose", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: oscillatorTransposeDispFunc},
{Name: "detune", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v-64) / 64), "st" }},
{Name: "phase", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
return strconv.FormatFloat(float64(v)/128*360, 'f', 1, 64), "°"
}},
{Name: "color", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "shape", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
{Name: "frequency", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
{Name: "type", MinValue: int(Sine), MaxValue: int(Sample), CanSet: true, CanModulate: false},
{Name: "type", MinValue: int(Sine), MaxValue: int(Sample), CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(oscTypes[:])},
{Name: "lfo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "unison", MinValue: 0, MaxValue: 3, CanSet: true, CanModulate: false},
{Name: "samplestart", MinValue: 0, MaxValue: 1720329, CanSet: true, CanModulate: false},
@ -164,15 +197,127 @@ var UnitTypes = map[string]([]UnitParameter){
{Name: "looplength", MinValue: 0, MaxValue: 65535, CanSet: true, CanModulate: false}},
"loadval": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "value", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
{Name: "value", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }}},
"receive": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "left", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
{Name: "right", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
"in": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false}},
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
"sync": []UnitParameter{},
"belleq": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqFrequencyDisplay(v) }},
{Name: "bandwidth", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqBandwidthDisplay(v) }},
{Name: "gain", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqGainDisplay(v) }}},
}
// compile errors if interface is not implemented.
var _ yaml.Unmarshaler = &ParamMap{}
func (a *ParamMap) UnmarshalYAML(value *yaml.Node) error {
var m map[string]int
if err := value.Decode(&m); err != nil {
return err
}
// Backwards compatibility hack: if the patch was saved with an older
// version of Sointu, it might have used the negbandpass and neghighpass
// parameters, which now correspond to having bandpass as value -1 and
// highpass as value -1.
if n, ok := m["negbandpass"]; ok {
m["bandpass"] = m["bandpass"] - n
delete(m, "negbandpass")
}
if n, ok := m["neghighpass"]; ok {
m["highpass"] = m["highpass"] - n
delete(m, "neghighpass")
}
*a = m
return nil
}
var channelNames = [...]string{"left", "right", "aux1 left", "aux1 right", "aux2 left", "aux2 right", "aux3 left", "aux3 right"}
var noteTrackingNames = [...]string{"fixed", "pitch", "BPM"}
var oscTypes = [...]string{"sine", "trisaw", "pulse", "gate", "sample"}
func arrDispFunc(arr []string) UnitParameterDisplayFunc {
return func(v int) (string, string) {
if v < 0 || v >= len(arr) {
return "???", ""
}
return arr[v], ""
}
}
func filterFrequencyDispFunc(v int) (string, string) {
// In https://www.musicdsp.org/en/latest/Filters/23-state-variable.html,
// they call it "cutoff" but it's actually the location of the resonance
// peak
freq := float64(v) / 128
p := freq * freq
f := math.Asin(p/2) / math.Pi * 44100
return strconv.FormatFloat(f, 'f', 0, 64), "Hz"
}
func belleqFrequencyDisplay(v int) (string, string) {
freq := float64(v) / 128
p := 2 * freq * freq
f := 44100 * p / math.Pi / 2
return strconv.FormatFloat(f, 'f', 0, 64), "Hz"
}
func belleqBandwidthDisplay(v int) (string, string) {
p := float64(v) / 128
Q := 1 / (4 * p)
return strconv.FormatFloat(Q, 'f', 2, 64), "Q"
}
func belleqGainDisplay(v int) (string, string) {
return strconv.FormatFloat(40*(float64(v)/64-1), 'f', 2, 64), "dB"
}
func compressorTimeDispFunc(v int) (string, string) {
alpha := math.Pow(2, -24*float64(v)/128) // alpha is the "smoothing factor" of first order low pass iir
sec := -1 / (44100 * math.Log(1-alpha)) // from smoothing factor to time constant, https://en.wikipedia.org/wiki/Exponential_smoothing
return engineeringTime(sec)
}
func oscillatorTransposeDispFunc(v int) (string, string) {
relvalue := v - 64
if relvalue%12 == 0 {
return strconv.Itoa(relvalue / 12), "oct"
}
return strconv.Itoa(relvalue), "st"
}
func sendVoiceDispFunc(v int) (string, string) {
if v == 0 {
return "default", ""
}
return strconv.Itoa(v), ""
}
func engineeringTime(sec float64) (string, string) {
if sec < 1e-3 {
return fmt.Sprintf("%.2f", sec*1e6), "us"
} else if sec < 1 {
return fmt.Sprintf("%.2f", sec*1e3), "ms"
}
return fmt.Sprintf("%.2f", sec), "s"
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
func toDecibel(amplitude float64) float64 {
if amplitude <= 0 {
return math.Inf(-1)
}
// Decibels are defined as 20 * log10(amplitude)
// https://en.wikipedia.org/wiki/Decibel#Sound_pressure
return 20 * math.Log10(amplitude)
}
// When unit.Type = "oscillator", its unit.Parameter["Type"] tells the type of
@ -218,13 +363,112 @@ func init() {
// Copy makes a deep copy of a unit.
func (u *Unit) Copy() Unit {
parameters := make(map[string]int)
ret := *u
ret.Parameters = make(map[string]int, len(u.Parameters))
for k, v := range u.Parameters {
parameters[k] = v
ret.Parameters[k] = v
}
varArgs := make([]int, len(u.VarArgs))
copy(varArgs, u.VarArgs)
return Unit{Type: u.Type, Parameters: parameters, VarArgs: varArgs, ID: u.ID, Disabled: u.Disabled}
ret.VarArgs = make([]int, len(u.VarArgs))
copy(ret.VarArgs, u.VarArgs)
return ret
}
var stackUseSource = [2]StackUse{
{Inputs: [][]int{}, Modifies: []bool{true}, NumOutputs: 1}, // mono
{Inputs: [][]int{}, Modifies: []bool{true, true}, NumOutputs: 2}, // stereo
}
var stackUseSink = [2]StackUse{
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0}, // mono
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 0}, // stereo
}
var stackUseEffect = [2]StackUse{
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 1}, // mono
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // stereo
}
var stackUseMonoStereo = map[string][2]StackUse{
"add": {
{Inputs: [][]int{{0, 1}, {1}}, Modifies: []bool{false, true}, NumOutputs: 2},
{Inputs: [][]int{{0, 2}, {1, 3}, {2}, {3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4},
},
"mul": {
{Inputs: [][]int{{0, 1}, {1}}, Modifies: []bool{false, true}, NumOutputs: 2},
{Inputs: [][]int{{0, 2}, {1, 3}, {2}, {3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4},
},
"addp": {
{Inputs: [][]int{{0}, {0}}, Modifies: []bool{true}, NumOutputs: 1},
{Inputs: [][]int{{0}, {1}, {0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
},
"mulp": {
{Inputs: [][]int{{0}, {0}}, Modifies: []bool{true}, NumOutputs: 1},
{Inputs: [][]int{{0}, {1}, {0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
},
"xch": {
{Inputs: [][]int{{1}, {0}}, Modifies: []bool{false, false}, NumOutputs: 2},
{Inputs: [][]int{{2}, {3}, {0}, {1}}, Modifies: []bool{false, false, false, false}, NumOutputs: 4},
},
"push": {
{Inputs: [][]int{{0, 1}}, Modifies: []bool{false, false}, NumOutputs: 2},
{Inputs: [][]int{{0, 2}, {1, 3}}, Modifies: []bool{false, false, false, false}, NumOutputs: 4},
},
"pop": stackUseSink,
"envelope": stackUseSource,
"oscillator": stackUseSource,
"noise": stackUseSource,
"loadnote": stackUseSource,
"loadval": stackUseSource,
"receive": stackUseSource,
"in": stackUseSource,
"out": stackUseSink,
"outaux": stackUseSink,
"aux": stackUseSink,
"distort": stackUseEffect,
"hold": stackUseEffect,
"crush": stackUseEffect,
"gain": stackUseEffect,
"invgain": stackUseEffect,
"dbgain": stackUseEffect,
"filter": stackUseEffect,
"clip": stackUseEffect,
"delay": stackUseEffect,
"compressor": {
{Inputs: [][]int{{0, 1}}, Modifies: []bool{false, true}, NumOutputs: 2}, // mono
{Inputs: [][]int{{0, 2, 3}, {1, 2, 3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4}, // stereo
},
"pan": {
{Inputs: [][]int{{0, 1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // mono
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // mono
},
"speed": {
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0},
{},
},
"sync": {
{Inputs: [][]int{{0}}, Modifies: []bool{false}, NumOutputs: 1},
{},
},
"belleq": stackUseEffect,
}
var stackUseSendNoPop = [2]StackUse{
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 1},
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
}
var stackUseSendPop = [2]StackUse{
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0}, // mono
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 0}, // stereo
}
func (u *Unit) StackUse() StackUse {
if u.Disabled {
return StackUse{}
}
if u.Type == "send" {
// "send" unit is special, it has a different stack use depending on sendpop
if u.Parameters["sendpop"] == 0 {
return stackUseSendNoPop[u.Parameters["stereo"]]
}
return stackUseSendPop[u.Parameters["stereo"]]
}
return stackUseMonoStereo[u.Type][u.Parameters["stereo"]]
}
// StackChange returns how this unit will affect the signal stack. "pop" and
@ -234,49 +478,34 @@ func (u *Unit) Copy() Unit {
// unit). Effects that just change the topmost signal and will not change the
// number of signals on the stack and thus return 0.
func (u *Unit) StackChange() int {
if u.Disabled {
return 0
}
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":
return 1 + u.Parameters["stereo"]
case "pan":
return 1 - u.Parameters["stereo"]
case "speed":
return -1
case "send":
return (-1 - u.Parameters["stereo"]) * u.Parameters["sendpop"]
}
return 0
s := u.StackUse()
return s.NumOutputs - len(s.Inputs)
}
// StackNeed returns the number of signals that should be on the stack before
// this unit is executed. Used to prevent stack underflow. Units producing
// signals do not care what is on the stack before and will return 0.
func (u *Unit) StackNeed() int {
if u.Disabled {
return 0
}
switch u.Type {
case "", "envelope", "oscillator", "noise", "receive", "loadnote", "loadval", "in":
return 0
case "mulp", "mul", "add", "addp", "xch":
return 2 * (1 + u.Parameters["stereo"])
case "speed":
return 1
}
return 1 + u.Parameters["stereo"]
return len(u.StackUse().Inputs)
}
// Copy makes a deep copy of an Instrument
func (instr *Instrument) Copy() Instrument {
units := make([]Unit, len(instr.Units))
ret := *instr
ret.Units = make([]Unit, len(instr.Units))
for i, u := range instr.Units {
units[i] = u.Copy()
ret.Units[i] = u.Copy()
}
return Instrument{Name: instr.Name, Comment: instr.Comment, NumVoices: instr.NumVoices, Units: units}
return ret
}
// Implement the counter interface
func (i *Instrument) GetNumVoices() int {
return i.NumVoices
}
func (i *Instrument) SetNumVoices(count int) {
i.NumVoices = count
}
// Copy makes a deep copy of a Patch.
@ -326,15 +555,29 @@ func (p Patch) NumSyncs() int {
return total
}
func (p Patch) NumThreads() int {
numThreads := 1
for _, instr := range p {
if l := bits.Len((uint)(instr.ThreadMaskM1 + 1)); l > numThreads {
numThreads = l
}
}
return numThreads
}
// FirstVoiceForInstrument returns the index of the first voice of given
// instrument. For example, if the Patch has three instruments (0, 1 and 2),
// with 1, 3, 2 voices, respectively, then FirstVoiceForInstrument(0) returns 0,
// FirstVoiceForInstrument(1) returns 1 and FirstVoiceForInstrument(2) returns
// 4. Essentially computes just the cumulative sum.
func (p Patch) FirstVoiceForInstrument(instrIndex int) int {
if instrIndex < 0 {
return 0
}
instrIndex = min(instrIndex, len(p))
ret := 0
for _, t := range p[:instrIndex] {
ret += t.NumVoices
for i := 0; i < instrIndex; i++ {
ret += p[i].NumVoices
}
return ret
}
@ -375,157 +618,19 @@ func (p Patch) FindUnit(id int) (instrIndex int, unitIndex int, err error) {
return 0, 0, fmt.Errorf("could not find a unit with id %v", id)
}
// ParamHintString returns a human readable string representing the current
// value of a given unit parameter.
func (p Patch) ParamHintString(instrIndex, unitIndex int, param string) string {
if instrIndex < 0 || instrIndex >= len(p) {
return ""
func FindParamForModulationPort(unitName string, index int) (up UnitParameter, upIndex int, ok bool) {
unitType, ok := UnitTypes[unitName]
if !ok {
return UnitParameter{}, 0, false
}
instr := p[instrIndex]
if unitIndex < 0 || unitIndex >= len(instr.Units) {
return ""
for i, param := range unitType {
if !param.CanModulate {
continue
}
if index == 0 {
return param, i, true
}
index--
}
unit := instr.Units[unitIndex]
value := unit.Parameters[param]
switch unit.Type {
case "envelope":
switch param {
case "attack":
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100)
case "decay":
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100 * (1 - float64(unit.Parameters["sustain"])/128))
case "release":
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100 * float64(unit.Parameters["sustain"]) / 128)
}
case "oscillator":
switch param {
case "type":
switch value {
case Sine:
return "Sine"
case Trisaw:
return "Trisaw"
case Pulse:
return "Pulse"
case Gate:
return "Gate"
case Sample:
return "Sample"
default:
return "Unknown"
}
case "transpose":
relvalue := value - 64
octaves := relvalue / 12
semitones := relvalue % 12
if octaves != 0 {
return fmt.Sprintf("%v oct, %v st", octaves, semitones)
}
return fmt.Sprintf("%v st", semitones)
case "detune":
return fmt.Sprintf("%v st", float32(value-64)/64.0)
}
case "compressor":
switch param {
case "attack":
fallthrough
case "release":
alpha := math.Pow(2, -24*float64(value)/128) // alpha is the "smoothing factor" of first order low pass iir
sec := -1 / (44100 * math.Log(1-alpha)) // from smoothing factor to time constant, https://en.wikipedia.org/wiki/Exponential_smoothing
return engineeringTime(sec)
case "ratio":
return fmt.Sprintf("1 : %.3f", 1-float64(value)/128)
}
case "loadval":
switch param {
case "value":
return fmt.Sprintf("%.2f", float32(value)/64-1)
}
case "send":
switch param {
case "amount":
return fmt.Sprintf("%.2f", float32(value)/64-1)
case "voice":
if value == 0 {
targetIndex, _, err := p.FindUnit(unit.Parameters["target"])
if err == nil && targetIndex != instrIndex {
return "all"
}
return "self"
}
return fmt.Sprintf("%v", value)
case "target":
instrIndex, unitIndex, err := p.FindUnit(unit.Parameters["target"])
if err != nil {
return "invalid target"
}
instr := p[instrIndex]
unit := instr.Units[unitIndex]
return fmt.Sprintf("%v / %v%v", instr.Name, unit.Type, unitIndex)
case "port":
instrIndex, unitIndex, err := p.FindUnit(unit.Parameters["target"])
if err != nil {
return fmt.Sprintf("%v ???", value)
}
portList := Ports[p[instrIndex].Units[unitIndex].Type]
if value < 0 || value >= len(portList) {
return fmt.Sprintf("%v ???", value)
}
return fmt.Sprintf(portList[value])
}
case "delay":
switch param {
case "notetracking":
switch value {
case 0:
return "fixed"
case 1:
return "tracks pitch"
case 2:
return "tracks BPM"
}
}
case "in", "aux":
switch param {
case "channel":
switch value {
case 0:
return "left"
case 1:
return "right"
case 2:
return "aux1 left"
case 3:
return "aux1 right"
case 4:
return "aux2 left"
case 5:
return "aux2 right"
case 6:
return "aux3 left"
case 7:
return "aux3 right"
}
}
case "dbgain":
switch param {
case "decibels":
return fmt.Sprintf("%.2f dB", 40*(float32(value)/64-1))
}
case "crush":
switch param {
case "resolution":
return fmt.Sprintf("%v bits", 24*float32(value)/128)
}
}
return ""
}
func engineeringTime(sec float64) string {
if sec < 1e-3 {
return fmt.Sprintf("%.2f us", sec*1e6)
} else if sec < 1 {
return fmt.Sprintf("%.2f ms", sec*1e3)
}
return fmt.Sprintf("%.2f s", sec)
return UnitParameter{}, 0, false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 166 KiB

95
song.go
View File

@ -1,6 +1,7 @@
package sointu
import (
_ "embed"
"errors"
)
@ -75,14 +76,32 @@ type (
OrderRow int
PatternRow int
}
// NumVoicer is used for slices where elements have NumVoices, of which
// there are two: Tracks and Instruments.
NumVoicer interface {
GetNumVoices() int
SetNumVoices(count int)
}
// NumVoicerPointer is a helper interface for type constraints, as
// SetNumVoices needs to be defined with a pointer receiver to be able to
// actually modify the value.
NumVoicerPointer[M any] interface {
*M
NumVoicer
}
)
//go:embed LICENSE
var License string
func (s *Score) SongPos(songRow int) SongPos {
if s.RowsPerPattern == 0 {
return SongPos{OrderRow: 0, PatternRow: 0}
}
orderRow := songRow / s.RowsPerPattern
patternRow := songRow % s.RowsPerPattern
patternRow := (songRow%s.RowsPerPattern + s.RowsPerPattern) % s.RowsPerPattern
orderRow := ((songRow - patternRow) / s.RowsPerPattern)
return SongPos{OrderRow: orderRow, PatternRow: patternRow}
}
@ -92,7 +111,7 @@ func (s *Score) SongRow(songPos SongPos) int {
func (s *Score) Wrap(songPos SongPos) SongPos {
ret := s.SongPos(s.SongRow(songPos))
ret.OrderRow %= s.Length
ret.OrderRow = (ret.OrderRow%s.Length + s.Length) % s.Length
return ret
}
@ -137,7 +156,10 @@ func (s Track) Note(pos SongPos) byte {
return s.Patterns[pat][pos.PatternRow]
}
func (s *Track) SetNote(pos SongPos, note byte) {
// SetNote sets the note at the given position. If uniquePatterns is true, the
// pattern is copied to a new pattern if the pattern is used by more than one
// order row.
func (s *Track) SetNote(pos SongPos, note byte, uniquePatterns bool) {
if pos.OrderRow < 0 || pos.PatternRow < 0 {
return
}
@ -163,13 +185,31 @@ func (s *Track) SetNote(pos SongPos, note byte) {
for pat >= len(s.Patterns) {
s.Patterns = append(s.Patterns, Pattern{})
}
if pos.PatternRow >= len(s.Patterns[pat]) && note == 1 {
return
if uniquePatterns {
uses := 0
maxPat := 0
for _, p := range s.Order {
if p == pat {
uses++
}
if p > maxPat {
maxPat = p
}
}
if uses > 1 {
newPattern := append(Pattern{}, s.Patterns[pat]...)
pat = maxPat + 1
if pat >= 36 {
return
}
for pat >= len(s.Patterns) {
s.Patterns = append(s.Patterns, Pattern{})
}
s.Patterns[pat] = newPattern
s.Order.Set(pos.OrderRow, pat)
}
}
for pos.PatternRow >= len(s.Patterns[pat]) {
s.Patterns[pat] = append(s.Patterns[pat], 1)
}
s.Patterns[pat][pos.PatternRow] = note
s.Patterns[pat].Set(pos.PatternRow, note)
}
// Get returns the value at index; or 1 is the index is out of range
@ -182,6 +222,9 @@ func (s Pattern) Get(index int) byte {
// Set sets the value at index; appending 1s until the slice is long enough.
func (s *Pattern) Set(index int, value byte) {
if value == 1 && index >= len(*s) {
return
}
for len(*s) <= index {
*s = append(*s, 1)
}
@ -231,9 +274,13 @@ func (l Score) NumVoices() int {
// returns 1 and FirstVoiceForTrack(2) returns 4. Essentially computes just the
// cumulative sum.
func (l Score) FirstVoiceForTrack(track int) int {
if track < 0 {
return 0
}
track = min(track, len(l.Tracks))
ret := 0
for _, t := range l.Tracks[:track] {
ret += t.NumVoices
for i := 0; i < track; i++ {
ret += l.Tracks[i].NumVoices
}
return ret
}
@ -246,7 +293,10 @@ func (l Score) LengthInRows() int {
// Copy makes a deep copy of a Score.
func (s *Song) Copy() Song {
return Song{BPM: s.BPM, RowsPerBeat: s.RowsPerBeat, Score: s.Score.Copy(), Patch: s.Patch.Copy()}
ret := *s
ret.Score = s.Score.Copy()
ret.Patch = s.Patch.Copy()
return ret
}
// Assuming 44100 Hz playback speed, return the number of samples of each row of
@ -273,3 +323,22 @@ func (s *Song) Validate() error {
}
return nil
}
// *Track implements NumVoicer interface
func (t *Track) GetNumVoices() int {
return t.NumVoices
}
func (t *Track) SetNumVoices(c int) {
t.NumVoices = c
}
// TotalVoices returns the total number of voices used in the slice; summing the
// GetNumVoices of every element
func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int) {
for _, e := range slice {
ret += (P)(&e).GetNumVoices()
}
return
}

View File

@ -117,6 +117,7 @@ if(WIN32) # The samples are currently only GMDLs based, and thus require Windows
regression_test(test_oscillat_sample_stereo ENVELOPE)
endif()
regression_test(test_oscillat_unison ENVELOPE)
regression_test(test_oscillat_unison_phase ENVELOPE)
regression_test(test_oscillat_unison_stereo ENVELOPE)
regression_test(test_oscillat_lfo "ENVELOPE;VCO_SINE;VCO_PULSE;FOP_MULP2")
regression_test(test_oscillat_transposemod "VCO_SINE;ENVELOPE;FOP_MULP;FOP_PUSH;SEND")
@ -154,9 +155,13 @@ regression_test(test_filter_stereo "VCO_SINE;ENVELOPE;FOP_MULP")
regression_test(test_filter_freqmod "VCO_SINE;ENVELOPE;FOP_MULP;SEND")
regression_test(test_filter_resmod "VCO_SINE;ENVELOPE;FOP_MULP;SEND")
regression_test(test_belleq "VCO_SINE;ENVELOPE;FOP_MULP")
regression_test(test_belleq_stereo "VCO_SINE;ENVELOPE;FOP_MULP")
regression_test(test_delay "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
regression_test(test_delay_stereo "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
regression_test(test_delay_notetracking "ENVELOPE;FOP_MULP;PANNING;NOISE")
regression_test(test_delay_notetracking_modulation "ENVELOPE;FOP_MULP;PANNING;NOISE")
regression_test(test_delay_reverb "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
regression_test(test_delay_feedbackmod "ENVELOPE;FOP_MULP;PANNING;VCO_SINE;SEND")
regression_test(test_delay_pregainmod "ENVELOPE;FOP_MULP;PANNING;VCO_SINE;SEND")
@ -180,3 +185,8 @@ target_compile_definitions(test_render_samples PUBLIC TEST_HEADER="test_render_s
add_executable(test_render_samples_api test_render_samples_api.c)
target_link_libraries(test_render_samples_api ${STATICLIB})
add_test(test_render_samples_api test_render_samples_api)
add_executable(test_oscillator_crash test_oscillator_crash.c)
target_link_libraries(test_oscillator_crash ${STATICLIB})
add_test(test_oscillator_crash test_oscillator_crash)

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
tests/test_belleq.yml Normal file
View File

@ -0,0 +1,24 @@
bpm: 100
rowsperbeat: 4
score:
rowsperpattern: 16
length: 1
tracks:
- numvoices: 1
order: [0]
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
patch:
- numvoices: 1
units:
- type: envelope
parameters: {attack: 64, decay: 64, gain: 128, release: 72, stereo: 0, sustain: 64}
- type: oscillator
parameters: {color: 128, detune: 64, gain: 128, lfo: 0, phase: 0, shape: 64, stereo: 0, transpose: 64, type: 1, unison: 0}
- type: mulp
parameters: {stereo: 0}
- type: belleq
parameters: {frequency: 64, bandwidth: 64, gain: 96, stereo: 0}
- type: pan
parameters: {panning: 64, stereo: 0}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -0,0 +1,22 @@
bpm: 100
rowsperbeat: 4
score:
rowsperpattern: 16
length: 1
tracks:
- numvoices: 1
order: [0]
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
patch:
- numvoices: 1
units:
- type: envelope
parameters: {attack: 64, decay: 64, gain: 128, release: 72, stereo: 1, sustain: 64}
- type: oscillator
parameters: {color: 128, detune: 64, gain: 128, lfo: 0, phase: 0, shape: 64, stereo: 1, transpose: 64, type: 1, unison: 0}
- type: mulp
parameters: {stereo: 1}
- type: belleq
parameters: {frequency: 64, bandwidth: 64, gain: 96, stereo: 1}
- type: out
parameters: {gain: 64, stereo: 1}

View File

@ -19,12 +19,12 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 1, frequency: 32, highpass: 1, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 128, stereo: 0}
parameters: {bandpass: 1, frequency: 32, highpass: 1, lowpass: 1, resonance: 128, stereo: 0}
- type: delay
parameters: {damp: 16, dry: 128, feedback: 128, notetracking: 1, pregain: 128, stereo: 0}
varargs: [10787]
- type: filter
parameters: {bandpass: 1, frequency: 24, highpass: 1, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 128, stereo: 0}
parameters: {bandpass: 1, frequency: 24, highpass: 1, lowpass: 1, resonance: 128, stereo: 0}
- type: mulp
parameters: {stereo: 0}
- type: pan

View File

@ -0,0 +1,43 @@
bpm: 100
rowsperbeat: 4
score:
tracks:
- numvoices: 1
order: [0]
patterns: [[73, 1, 1, 1, 0, 1, 1, 1, 77, 1, 1, 1, 0]]
rowsperpattern: 16
length: 1
patch:
- name: Instr
numvoices: 1
units:
- type: envelope
id: 1
parameters: {attack: 64, decay: 64, gain: 64, release: 64, stereo: 0, sustain: 64}
- type: noise
id: 10
parameters: {gain: 64, shape: 64, stereo: 0}
- type: filter
id: 12
parameters: {bandpass: 0, frequency: 39, highpass: 0, lowpass: 1, resonance: 128, stereo: 0}
- type: delay
id: 11
parameters: {damp: 0, dry: 71, feedback: 114, notetracking: 1, pregain: 128, stereo: 0}
varargs: [21574]
- type: mulp
id: 3
parameters: {stereo: 0}
- type: pan
id: 5
parameters: {panning: 64, stereo: 0}
- type: out
id: 16
parameters: {gain: 128, stereo: 1}
- id: 13
parameters: {}
- type: oscillator
id: 14
parameters: {color: 128, detune: 64, gain: 5, lfo: 1, phase: 0, shape: 64, stereo: 0, transpose: 76, type: 0}
- type: send
id: 15
parameters: {amount: 96, port: 4, sendpop: 1, stereo: 0, target: 11, voice: 0}

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
- type: pan
parameters: {panning: 64, stereo: 0}
- type: out

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
id: 1
- type: pan
parameters: {panning: 64, stereo: 0}

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 0, frequency: 32, highpass: 1, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
parameters: {bandpass: 0, frequency: 32, highpass: 1, lowpass: 0, resonance: 64, stereo: 0}
- type: pan
parameters: {panning: 64, stereo: 0}
- type: out

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, resonance: 64, stereo: 0}
- type: pan
parameters: {panning: 64, stereo: 0}
- type: out

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 1, resonance: 64, stereo: 0}
parameters: {bandpass: 0, frequency: 32, highpass: -1, lowpass: 1, resonance: 64, stereo: 0}
- type: pan
parameters: {panning: 64, stereo: 0}
- type: out

View File

@ -17,7 +17,7 @@ patch:
- type: mulp
parameters: {stereo: 0}
- type: filter
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
id: 1
- type: pan
parameters: {panning: 64, stereo: 0}

View File

@ -19,6 +19,6 @@ patch:
- type: pan
parameters: {panning: 64, stereo: 0}
- type: filter
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 1}
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 1}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -0,0 +1,22 @@
bpm: 100
rowsperbeat: 4
score:
rowsperpattern: 16
length: 1
tracks:
- numvoices: 1
order: [0]
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
patch:
- numvoices: 1
units:
- type: envelope
parameters: {attack: 32, decay: 32, gain: 128, release: 64, stereo: 0, sustain: 64}
- type: oscillator
parameters: {color: 128, detune: 0, gain: 32, lfo: 0, phase: 0, shape: 64, stereo: 0, transpose: 64, type: 1, unison: 3}
- type: mulp
parameters: {stereo: 0}
- type: push
parameters: {stereo: 0}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -0,0 +1,55 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <sointu.h>
#define BPM 100
#define SAMPLE_RATE 44100
#define LENGTH_IN_ROWS 16
#define SAMPLES_PER_ROW SAMPLE_RATE * 4 * 60 / (BPM * 16)
const int su_max_samples = SAMPLES_PER_ROW * LENGTH_IN_ROWS;
int main(int argc, char* argv[])
{
Synth* synth;
float* buffer;
// The patch is invalid and overflows the stack. This should still exit cleanly, but used to hard crash.
// See: https://github.com/vsariola/sointu/issues/149
const unsigned char opcodes[] = { SU_OSCILLATOR_ID + 1, // STEREO
SU_ADVANCE_ID };
const unsigned char operands[] = { 69, 74, 0, 0, 82, 128, 128 };
int errcode;
int time;
int samples;
int totalrendered;
int retval;
// initialize Synth
synth = (Synth*)malloc(sizeof(Synth));
memset(synth, 0, sizeof(Synth));
memcpy(synth->Opcodes, opcodes, sizeof(opcodes));
memcpy(synth->Operands, operands, sizeof(operands));
synth->NumVoices = 3;
synth->Polyphony = 6;
synth->RandSeed = 1;
synth->SampleOffsets[0].Start = 91507;
synth->SampleOffsets[0].LoopStart = 5448;
synth->SampleOffsets[0].LoopLength = 563;
// initialize Buffer
buffer = (float*)malloc(2 * sizeof(float) * su_max_samples);
// triger first voice
synth->SynthWrk.Voices[0].Note = 64;
synth->SynthWrk.Voices[0].Sustain = 1;
totalrendered = 0;
samples = su_max_samples;
time = INT32_MAX;
retval = 0;
errcode = su_render(synth, buffer, &samples, &time);
if (errcode != 0x1041) {
retval = 1;
printf("su_render should have return errcode 0x1401, got 0x%08x\n", errcode);
}
free(synth);
free(buffer);
return retval;
}

View File

@ -1,409 +0,0 @@
package tracker
import (
"os"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
)
type (
// Action describes a user action that can be performed on the model. It is
// usually a button press or a menu item. Action advertises whether it is
// allowed to be performed or not.
Action struct {
do func()
allowed func() bool
}
)
// Action methods
func (e Action) Do() {
if e.allowed != nil && e.allowed() {
e.do()
}
}
func (e Action) Allowed() bool {
return e.allowed != nil && e.allowed()
}
func Allow(do func()) Action {
return Action{do: do, allowed: func() bool { return true }}
}
func Check(do func(), allowed func() bool) Action {
return Action{do: do, allowed: allowed}
}
// Model methods
func (m *Model) AddTrack() Action {
return Action{
allowed: func() bool { return m.d.Song.Score.NumVoices() < vm.MAX_VOICES },
do: func() {
defer (*Model)(m).change("AddTrackAction", ScoreChange, MajorChange)()
if len(m.d.Song.Score.Tracks) == 0 { // no instruments, add one
m.d.Cursor.Track = 0
} else {
m.d.Cursor.Track++
}
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)), 0)
newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)+1)
copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track])
copy(newTracks[m.d.Cursor.Track+1:], m.d.Song.Score.Tracks[m.d.Cursor.Track:])
newTracks[m.d.Cursor.Track] = sointu.Track{
NumVoices: 1,
Patterns: []sointu.Pattern{},
}
m.d.Song.Score.Tracks = newTracks
},
}
}
func (m *Model) DeleteTrack() Action {
return Action{
allowed: func() bool { return len(m.d.Song.Score.Tracks) > 0 },
do: func() {
defer (*Model)(m).change("DeleteTrackAction", ScoreChange, MajorChange)()
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)-1)
copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track])
copy(newTracks[m.d.Cursor.Track:], m.d.Song.Score.Tracks[m.d.Cursor.Track+1:])
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
m.d.Song.Score.Tracks = newTracks
m.d.Cursor2 = m.d.Cursor
},
}
}
func (m *Model) AddInstrument() Action {
return Action{
allowed: func() bool { return (*Model)(m).d.Song.Patch.NumVoices() < vm.MAX_VOICES },
do: func() {
defer (*Model)(m).change("AddInstrumentAction", PatchChange, MajorChange)()
if len(m.d.Song.Patch) == 0 { // no instruments, add one
m.d.InstrIndex = 0
} else {
m.d.InstrIndex++
}
m.d.Song.Patch = append(m.d.Song.Patch, sointu.Instrument{})
copy(m.d.Song.Patch[m.d.InstrIndex+1:], m.d.Song.Patch[m.d.InstrIndex:])
newInstr := defaultInstrument.Copy()
(*Model)(m).assignUnitIDs(newInstr.Units)
m.d.Song.Patch[m.d.InstrIndex] = newInstr
m.d.InstrIndex2 = m.d.InstrIndex
m.d.UnitIndex = 0
m.d.ParamIndex = 0
},
}
}
func (m *Model) DeleteInstrument() Action {
return Action{
allowed: func() bool { return len((*Model)(m).d.Song.Patch) > 0 },
do: func() {
defer (*Model)(m).change("DeleteInstrumentAction", PatchChange, MajorChange)()
m.d.Song.Patch = append(m.d.Song.Patch[:m.d.InstrIndex], m.d.Song.Patch[m.d.InstrIndex+1:]...)
},
}
}
func (m *Model) AddUnit(before bool) Action {
return Allow(func() {
defer (*Model)(m).change("AddUnitAction", PatchChange, MajorChange)()
if len(m.d.Song.Patch) == 0 { // no instruments, add one
instr := sointu.Instrument{NumVoices: 1}
instr.Units = make([]sointu.Unit, 0, 1)
m.d.Song.Patch = append(m.d.Song.Patch, instr)
m.d.UnitIndex = 0
} else {
if !before {
m.d.UnitIndex++
}
}
m.d.InstrIndex = intMax(intMin(m.d.InstrIndex, len(m.d.Song.Patch)-1), 0)
instr := m.d.Song.Patch[m.d.InstrIndex]
newUnits := make([]sointu.Unit, len(instr.Units)+1)
m.d.UnitIndex = clamp(m.d.UnitIndex, 0, len(newUnits)-1)
m.d.UnitIndex2 = m.d.UnitIndex
copy(newUnits, instr.Units[:m.d.UnitIndex])
copy(newUnits[m.d.UnitIndex+1:], instr.Units[m.d.UnitIndex:])
(*Model)(m).assignUnitIDs(newUnits[m.d.UnitIndex : m.d.UnitIndex+1])
m.d.Song.Patch[m.d.InstrIndex].Units = newUnits
m.d.ParamIndex = 0
})
}
func (m *Model) DeleteUnit() Action {
return Action{
allowed: func() bool {
return len((*Model)(m).d.Song.Patch) > 0 && len((*Model)(m).d.Song.Patch[(*Model)(m).d.InstrIndex].Units) > 1
},
do: func() {
defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)()
m.Units().List().DeleteElements(true)
},
}
}
func (m *Model) ClearUnit() Action {
return Action{
do: func() {
defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)()
m.d.UnitIndex = intMax(intMin(m.d.UnitIndex, len(m.d.Song.Patch[m.d.InstrIndex].Units)-1), 0)
m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = sointu.Unit{}
},
allowed: func() bool {
return m.d.InstrIndex >= 0 &&
m.d.InstrIndex < len(m.d.Song.Patch) &&
len(m.d.Song.Patch[m.d.InstrIndex].Units) > 0
},
}
}
func (m *Model) Undo() Action {
return Action{
allowed: func() bool { return len((*Model)(m).undoStack) > 0 },
do: func() {
m.redoStack = append(m.redoStack, m.d.Copy())
if len(m.redoStack) >= maxUndo {
copy(m.redoStack, m.redoStack[len(m.redoStack)-maxUndo:])
m.redoStack = m.redoStack[:maxUndo]
}
m.d = m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.prevUndoKind = ""
(*Model)(m).send(m.d.Song.Copy())
},
}
}
func (m *Model) Redo() Action {
return Action{
allowed: func() bool { return len((*Model)(m).redoStack) > 0 },
do: func() {
m.undoStack = append(m.undoStack, m.d.Copy())
if len(m.undoStack) >= maxUndo {
copy(m.undoStack, m.undoStack[len(m.undoStack)-maxUndo:])
m.undoStack = m.undoStack[:maxUndo]
}
m.d = m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
m.prevUndoKind = ""
(*Model)(m).send(m.d.Song.Copy())
},
}
}
func (m *Model) AddSemitone() Action {
return Allow(func() { Table{(*Notes)(m)}.Add(1) })
}
func (m *Model) SubtractSemitone() Action {
return Allow(func() { Table{(*Notes)(m)}.Add(-1) })
}
func (m *Model) AddOctave() Action {
return Allow(func() { Table{(*Notes)(m)}.Add(12) })
}
func (m *Model) SubtractOctave() Action {
return Allow(func() { Table{(*Notes)(m)}.Add(-12) })
}
func (m *Model) EditNoteOff() Action {
return Allow(func() { Table{(*Notes)(m)}.Fill(0) })
}
func (m *Model) RemoveUnused() Action {
return Allow(func() {
defer m.change("RemoveUnusedAction", ScoreChange, MajorChange)()
for trkIndex, trk := range m.d.Song.Score.Tracks {
// assign new indices to patterns
newIndex := map[int]int{}
runningIndex := 0
length := 0
if len(trk.Order) > m.d.Song.Score.Length {
trk.Order = trk.Order[:m.d.Song.Score.Length]
}
for i, p := range trk.Order {
// if the pattern hasn't been considered and is within limits
if _, ok := newIndex[p]; !ok && p >= 0 && p < len(trk.Patterns) {
pat := trk.Patterns[p]
useful := false
for _, n := range pat { // patterns that have anything else than all holds are useful and to be kept
if n != 1 {
useful = true
break
}
}
if useful {
newIndex[p] = runningIndex
runningIndex++
} else {
newIndex[p] = -1
}
}
if ind, ok := newIndex[p]; ok && ind > -1 {
length = i + 1
trk.Order[i] = ind
} else {
trk.Order[i] = -1
}
}
trk.Order = trk.Order[:length]
newPatterns := make([]sointu.Pattern, runningIndex)
for i, pat := range trk.Patterns {
if ind, ok := newIndex[i]; ok && ind > -1 {
patLength := 0
for j, note := range pat { // find last note that is something else that hold
if note != 1 {
patLength = j + 1
}
}
if patLength > m.d.Song.Score.RowsPerPattern {
patLength = m.d.Song.Score.RowsPerPattern
}
newPatterns[ind] = pat[:patLength] // crop to either RowsPerPattern or last row having something else than hold
}
}
trk.Patterns = newPatterns
m.d.Song.Score.Tracks[trkIndex] = trk
}
})
}
func (m *Model) Rewind() Action {
return Action{
allowed: func() bool {
return m.playing || !m.instrEnlarged
},
do: func() {
m.playing = true
m.send(StartPlayMsg{})
},
}
}
func (m *Model) AddOrderRow(before bool) Action {
return Allow(func() {
defer m.change("AddOrderRowAction", ScoreChange, MinorChange)()
if before {
m.d.Cursor.OrderRow++
}
m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow
from := m.d.Cursor.OrderRow
m.d.Song.Score.Length++
for i := range m.d.Song.Score.Tracks {
order := &m.d.Song.Score.Tracks[i].Order
if len(*order) > from {
*order = append(*order, -1)
copy((*order)[from+1:], (*order)[from:])
(*order)[from] = -1
}
}
})
}
func (m *Model) DeleteOrderRow(backwards bool) Action {
return Allow(func() {
defer m.change("AddOrderRowAction", ScoreChange, MinorChange)()
from := m.d.Cursor.OrderRow
m.d.Song.Score.Length--
for i := range m.d.Song.Score.Tracks {
order := &m.d.Song.Score.Tracks[i].Order
if len(*order) > from {
copy((*order)[from:], (*order)[from+1:])
*order = (*order)[:len(*order)-1]
}
}
if backwards {
if m.d.Cursor.OrderRow > 0 {
m.d.Cursor.OrderRow--
}
}
m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow
return
})
}
func (m *Model) NewSong() Action {
return Allow(func() {
m.dialog = NewSongChanges
m.completeAction(true)
})
}
func (m *Model) OpenSong() Action {
return Allow(func() {
m.dialog = OpenSongChanges
m.completeAction(true)
})
}
func (m *Model) Quit() Action {
return Allow(func() {
m.dialog = QuitChanges
m.completeAction(true)
})
}
func (m *Model) ForceQuit() Action {
return Allow(func() {
m.quitted = true
})
}
func (m *Model) SaveSong() Action {
return Allow(func() {
if m.d.FilePath == "" {
switch m.dialog {
case NoDialog:
m.dialog = SaveAsExplorer
case NewSongChanges:
m.dialog = NewSongSaveExplorer
case OpenSongChanges:
m.dialog = OpenSongSaveExplorer
case QuitChanges:
m.dialog = QuitSaveExplorer
}
return
}
f, err := os.Create(m.d.FilePath)
if err != nil {
m.Alerts().Add("Error creating file: "+err.Error(), Error)
return
}
m.WriteSong(f)
m.d.ChangedSinceSave = false
})
}
func (m *Model) DiscardSong() Action { return Allow(func() { m.completeAction(false) }) }
func (m *Model) SaveSongAs() Action { return Allow(func() { m.dialog = SaveAsExplorer }) }
func (m *Model) Cancel() Action { return Allow(func() { m.dialog = NoDialog }) }
func (m *Model) Export() Action { return Allow(func() { m.dialog = Export }) }
func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFloatExplorer }) }
func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) }
func (m *Model) completeAction(checkSave bool) {
if checkSave && m.d.ChangedSinceSave {
return
}
switch m.dialog {
case NewSongChanges, NewSongSaveExplorer:
c := m.change("NewSong", SongChange|LoopChange, MajorChange)
m.resetSong()
c()
m.d.ChangedSinceSave = false
m.dialog = NoDialog
case OpenSongChanges, OpenSongSaveExplorer:
m.dialog = OpenSongOpenExplorer
case QuitChanges, QuitSaveExplorer:
m.quitted = true
m.dialog = NoDialog
default:
m.dialog = NoDialog
}
}

View File

@ -17,9 +17,7 @@ type (
FadeLevel float64
}
AlertPriority int
AlertYieldFunc func(alert Alert)
Alerts Model
AlertPriority int
)
const (
@ -29,18 +27,22 @@ const (
Error
)
// Model methods
// Alerts returns the Alerts model from the main Model, used to manage alerts.
func (m *Model) Alerts() *Alerts { return (*Alerts)(m) }
// Alerts methods
type Alerts Model
func (m *Alerts) Iterate(yield AlertYieldFunc) {
for _, a := range m.alerts {
yield(a)
// Iterate through the alerts.
func (m *Alerts) Iterate(yield func(index int, alert Alert) bool) {
for i, a := range m.alerts {
if !yield(i, a) {
break
}
}
}
// Update the alerts, reducing their duration and updating their fade levels,
// given the elapsed time d.
func (m *Alerts) Update(d time.Duration) (animating bool) {
for i := len(m.alerts) - 1; i >= 0; i-- {
if m.alerts[i].Duration >= d {
@ -64,6 +66,7 @@ func (m *Alerts) Update(d time.Duration) (animating bool) {
return
}
// Add a new alert with the given message and priority.
func (m *Alerts) Add(message string, priority AlertPriority) {
m.AddAlert(Alert{
Priority: priority,
@ -72,6 +75,7 @@ func (m *Alerts) Add(message string, priority AlertPriority) {
})
}
// AddNamed adds a new alert with the given name, message, and priority.
func (m *Alerts) AddNamed(name, message string, priority AlertPriority) {
m.AddAlert(Alert{
Name: name,
@ -81,6 +85,17 @@ func (m *Alerts) AddNamed(name, message string, priority AlertPriority) {
})
}
// ClearNamed clears the alert with the given name.
func (m *Alerts) ClearNamed(name string) {
for i := range m.alerts {
if n := m.alerts[i].Name; n != "" && n == name {
m.alerts[i].Duration = 0
return
}
}
}
// AddAlert adds or updates an alert.
func (m *Alerts) AddAlert(a Alert) {
for i := range m.alerts {
if n := m.alerts[i].Name; n != "" && n == a.Name {
@ -94,15 +109,17 @@ func (m *Alerts) AddAlert(a Alert) {
}
func (m *Alerts) Push(x any) {
if _, ok := x.(Alert); !ok {
panic("invalid type for Alerts.Push, expected Alert")
}
m.alerts = append(m.alerts, x.(Alert))
}
func (m *Alerts) Pop() any {
old := m.alerts
n := len(old)
x := old[n-1]
m.alerts = old[0 : n-1]
return x
n := len(m.alerts)
last := m.alerts[n-1]
m.alerts = m.alerts[:n-1]
return last
}
func (m Alerts) Len() int { return len(m.alerts) }

568
tracker/basic_types.go Normal file
View File

@ -0,0 +1,568 @@
package tracker
import (
"iter"
"math"
"math/bits"
"strconv"
)
// Enabler is an interface that defines a single Enabled() method, which is used
// by the UI to check if UI Action/Bool/Int etc. is enabled or not.
type Enabler interface {
Enabled() bool
}
// Action
type (
// Action describes a user action that can be performed on the model, which
// can be initiated by calling the Do() method. It is usually initiated by a
// button press or a menu item. Action advertises whether it is enabled, so
// UI can e.g. gray out buttons when the underlying action is not allowed.
// The underlying Doer can optionally implement the Enabler interface to
// decide if the action is enabled or not; if it does not implement the
// Enabler interface, the action is always allowed.
Action struct {
doer Doer
}
// Doer is an interface that defines a single Do() method, which is called
// when an action is performed.
Doer interface {
Do()
}
)
func MakeAction(doer Doer) Action { return Action{doer: doer} }
func (a Action) Do() {
e, ok := a.doer.(Enabler)
if ok && !e.Enabled() {
return
}
if a.doer != nil {
a.doer.Do()
}
}
func (a Action) Enabled() bool {
if a.doer == nil {
return false // no doer, not allowed
}
e, ok := a.doer.(Enabler)
if !ok {
return true // not enabler, always allowed
}
return e.Enabled()
}
// Bool
type (
Bool struct {
value BoolValue
}
BoolValue interface {
Value() bool
SetValue(bool)
}
simpleBool bool
)
func MakeBool(value BoolValue) Bool { return Bool{value: value} }
func MakeBoolFromPtr(value *bool) Bool { return Bool{value: (*simpleBool)(value)} }
func (v Bool) Toggle() { v.SetValue(!v.Value()) }
func (v Bool) SetValue(value bool) (changed bool) {
if !v.Enabled() || v.Value() == value {
return false
}
v.value.SetValue(value)
return true
}
func (v Bool) Value() bool {
if v.value == nil {
return false
}
return v.value.Value()
}
func (v Bool) Enabled() bool {
if v.value == nil {
return false
}
e, ok := v.value.(Enabler)
if !ok {
return true
}
return e.Enabled()
}
func (v *simpleBool) Value() bool { return bool(*v) }
func (v *simpleBool) SetValue(value bool) { *v = simpleBool(value) }
// Int
type (
// Int represents an integer value in the tracker model e.g. BPM, song
// length, etc. It is a wrapper around an IntValue interface that provides
// methods to manipulate the value, but Int guard that all changes are
// within the range of the underlying IntValue implementation and that
// SetValue is not called when the value is unchanged. The IntValue can
// optionally implement the StringOfer interface to provide custom string
// representations of the integer values.
Int struct {
value IntValue
}
IntValue interface {
Value() int
SetValue(int) (changed bool)
Range() RangeInclusive
}
StringOfer interface {
StringOf(value int) string
}
)
func MakeInt(value IntValue) Int { return Int{value} }
func (v Int) Add(delta int) (changed bool) {
return v.SetValue(v.Value() + delta)
}
func (v Int) SetValue(value int) (changed bool) {
r := v.Range()
value = r.Clamp(value)
if value == v.Value() || value < r.Min || value > r.Max {
return false
}
return v.value.SetValue(value)
}
func (v Int) Range() RangeInclusive {
if v.value == nil {
return RangeInclusive{0, 0}
}
return v.value.Range()
}
func (v Int) Value() int {
if v.value == nil {
return 0
}
return v.value.Value()
}
func (v Int) String() string {
return v.StringOf(v.Value())
}
func (v Int) StringOf(value int) string {
if s, ok := v.value.(StringOfer); ok {
return s.StringOf(value)
}
return strconv.Itoa(value)
}
// String
type (
String struct {
value StringValue
}
StringValue interface {
Value() string
SetValue(string) (changed bool)
}
)
func MakeString(value StringValue) String { return String{value: value} }
func (v String) SetValue(value string) (changed bool) {
if v.value == nil || v.value.Value() == value {
return false
}
return v.value.SetValue(value)
}
func (v String) Value() string {
if v.value == nil {
return ""
}
return v.value.Value()
}
// List
type (
List struct {
data ListData
}
ListData interface {
Selected() int
Selected2() int
SetSelected(int)
SetSelected2(int)
Count() int
}
MutableListData interface {
Change(kind string, severity ChangeSeverity) func()
Cancel()
Move(r Range, delta int) (ok bool)
Delete(r Range) (ok bool)
Marshal(r Range) ([]byte, error)
Unmarshal([]byte) (r Range, err error)
}
)
func MakeList(data ListData) List { return List{data} }
func (l List) Selected() int { return max(min(l.data.Selected(), l.data.Count()-1), 0) }
func (l List) Selected2() int { return max(min(l.data.Selected2(), l.data.Count()-1), 0) }
func (l List) SetSelected(value int) { l.data.SetSelected(max(min(value, l.data.Count()-1), 0)) }
func (l List) SetSelected2(value int) { l.data.SetSelected2(max(min(value, l.data.Count()-1), 0)) }
func (l List) Count() int { return l.data.Count() }
// MoveElements moves the selected elements in a list by delta. The list must
// implement the MutableListData interface.
func (v List) MoveElements(delta int) bool {
s, ok := v.data.(MutableListData)
if !ok {
return false
}
r := v.listRange()
if delta == 0 || r.Start+delta < 0 || r.End+delta > v.Count() {
return false
}
defer s.Change("MoveElements", MajorChange)()
if !s.Move(r, delta) {
s.Cancel()
return false
}
v.SetSelected(v.Selected() + delta)
v.SetSelected2(v.Selected2() + delta)
return true
}
// DeleteElements deletes the selected elements in a list. The list must
// implement the MutableListData interface.
func (v List) DeleteElements(backwards bool) bool {
d, ok := v.data.(MutableListData)
if !ok {
return false
}
r := v.listRange()
if r.Len() == 0 {
return false
}
defer d.Change("DeleteElements", MajorChange)()
if !d.Delete(r) {
d.Cancel()
return false
}
if backwards && r.Start > 0 {
r.Start--
}
v.SetSelected(r.Start)
v.SetSelected2(r.Start)
return true
}
// CopyElements copies the selected elements in a list. The list must implement
// the MutableListData interface. Returns the copied data, marshaled into byte
// slice, and true if successful.
func (v List) CopyElements() ([]byte, bool) {
m, ok := v.data.(MutableListData)
if !ok {
return nil, false
}
r := v.listRange()
if r.Len() == 0 {
return nil, false
}
ret, err := m.Marshal(r)
if err != nil {
return nil, false
}
return ret, true
}
// PasteElements pastes the data into the list. The data is unmarshaled from the
// byte slice. The list must implement the MutableListData interface. Returns
// true if successful.
func (v List) PasteElements(data []byte) (ok bool) {
m, ok := v.data.(MutableListData)
if !ok {
return false
}
defer m.Change("PasteElements", MajorChange)()
r, err := m.Unmarshal(data)
if err != nil {
m.Cancel()
return false
}
v.SetSelected(r.Start)
v.SetSelected2(r.End - 1)
return true
}
func (v List) Mutable() bool {
_, ok := v.data.(MutableListData)
return ok
}
func (v *List) listRange() (r Range) {
r.Start = max(min(v.Selected(), v.Selected2()), 0)
r.End = min(max(v.Selected(), v.Selected2())+1, v.Count())
return
}
// RangeInclusive
// RangeInclusive represents a range of integers [Min, Max], inclusive.
type RangeInclusive struct{ Min, Max int }
func (r RangeInclusive) Clamp(value int) int { return max(min(value, r.Max), r.Min) }
// Range is used to represent a range [Start,End) of integers, excluding End
type Range struct{ Start, End int }
func (r Range) Len() int { return r.End - r.Start }
func (r Range) Swaps(delta int) iter.Seq2[int, int] {
if delta > 0 {
return func(yield func(int, int) bool) {
for i := r.End - 1; i >= r.Start; i-- {
if !yield(i, i+delta) {
return
}
}
}
}
return func(yield func(int, int) bool) {
for i := r.Start; i < r.End; i++ {
if !yield(i, i+delta) {
return
}
}
}
}
func (r Range) Intersect(s Range) (ret Range) {
ret.Start = max(r.Start, s.Start)
ret.End = max(min(r.End, s.End), ret.Start)
if ret.Len() == 0 {
return Range{}
}
return
}
func MakeMoveRanges(a Range, delta int) [4]Range {
if delta < 0 {
return [4]Range{
{math.MinInt, a.Start + delta},
{a.Start, a.End},
{a.Start + delta, a.Start},
{a.End, math.MaxInt},
}
}
return [4]Range{
{math.MinInt, a.Start},
{a.End, a.End + delta},
{a.Start, a.End},
{a.End + delta, math.MaxInt},
}
}
// MakeSetLength takes a range and a length, and returns a slice of ranges that
// can be used with VoiceSlice to expand or shrink the range to the given
// length, by either duplicating or removing elements. The function tries to
// duplicate elements so all elements are equally spaced, and tries to remove
// elements from the middle of the range.
func MakeSetLength(a Range, length int) []Range {
if length <= 0 || a.Len() <= 0 {
return []Range{{a.Start, a.Start}}
}
ret := make([]Range, a.Len(), max(a.Len(), length)+2)
for i := 0; i < a.Len(); i++ {
ret[i] = Range{a.Start + i, a.Start + i + 1}
}
for x := len(ret); x < length; x++ {
e := (x << 1) ^ (1 << bits.Len((uint)(x)))
ret = append(ret[0:e+1], ret[e:]...)
}
for x := len(ret); x > length; x-- {
e := (((x << 1) ^ (1 << bits.Len((uint)(x)))) + x - 1) % x
ret = append(ret[0:e], ret[e+1:]...)
}
ret = append([]Range{{math.MinInt, a.Start}}, ret...)
ret = append(ret, Range{a.End, math.MaxInt})
return ret
}
func Complement(a Range) [2]Range {
return [2]Range{
{math.MinInt, a.Start},
{a.End, math.MaxInt},
}
}
// Insert inserts elements into a slice at the given index. If the index is out
// of bounds, the function returns false.
func Insert[T any, S ~[]T](slice S, index int, inserted ...T) (ret S, ok bool) {
if index < 0 || index > len(slice) {
return nil, false
}
ret = make(S, 0, len(slice)+len(inserted))
ret = append(ret, slice[:index]...)
ret = append(ret, inserted...)
ret = append(ret, slice[index:]...)
return ret, true
}
// Table
type (
Table struct {
TableData
}
TableData interface {
Cursor() Point
Cursor2() Point
SetCursor(Point)
SetCursor2(Point)
Width() int
Height() int
MoveCursor(dx, dy int) (ok bool)
clear(p Point)
set(p Point, value int)
add(rect Rect, delta int, largestep bool) (ok bool)
marshal(rect Rect) (data []byte, ok bool)
unmarshalAtCursor(data []byte) (ok bool)
unmarshalRange(rect Rect, data []byte) (ok bool)
change(kind string, severity ChangeSeverity) func()
cancel()
}
Point struct {
X, Y int
}
Rect struct {
TopLeft, BottomRight Point
}
)
// Rect methods
func (r *Rect) Contains(p Point) bool {
return r.TopLeft.X <= p.X && p.X <= r.BottomRight.X &&
r.TopLeft.Y <= p.Y && p.Y <= r.BottomRight.Y
}
func (r *Rect) Width() int {
return r.BottomRight.X - r.TopLeft.X + 1
}
func (r *Rect) Height() int {
return r.BottomRight.Y - r.TopLeft.Y + 1
}
func (r *Rect) Limit(width, height int) {
if r.TopLeft.X < 0 {
r.TopLeft.X = 0
}
if r.TopLeft.Y < 0 {
r.TopLeft.Y = 0
}
if r.BottomRight.X >= width {
r.BottomRight.X = width - 1
}
if r.BottomRight.Y >= height {
r.BottomRight.Y = height - 1
}
}
func (v Table) Range() (rect Rect) {
rect.TopLeft.X = min(v.Cursor().X, v.Cursor2().X)
rect.TopLeft.Y = min(v.Cursor().Y, v.Cursor2().Y)
rect.BottomRight.X = max(v.Cursor().X, v.Cursor2().X)
rect.BottomRight.Y = max(v.Cursor().Y, v.Cursor2().Y)
return
}
func (v Table) Copy() ([]byte, bool) {
ret, ok := v.marshal(v.Range())
if !ok {
return nil, false
}
return ret, true
}
func (v Table) Paste(data []byte) bool {
defer v.change("Paste", MajorChange)()
if v.Cursor() == v.Cursor2() {
return v.unmarshalAtCursor(data)
} else {
return v.unmarshalRange(v.Range(), data)
}
}
func (v Table) Clear() {
defer v.change("Clear", MajorChange)()
rect := v.Range()
rect.Limit(v.Width(), v.Height())
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
v.clear(Point{x, y})
}
}
}
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()
rect.Limit(v.Width(), v.Height())
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
v.set(Point{x, y}, value)
}
}
}
func (v Table) Add(delta int, largeStep bool) {
defer v.change("Add", MinorChange)()
if !v.add(v.Range(), delta, largeStep) {
v.cancel()
}
}
func (v Table) SetCursorX(x int) {
p := v.Cursor()
p.X = x
v.SetCursor(p)
}
func (v Table) SetCursorY(y int) {
p := v.Cursor()
p.Y = y
v.SetCursor(p)
}

View File

@ -1,182 +0,0 @@
package tracker
type (
Bool struct {
BoolData
}
BoolData interface {
Value() bool
Enabled() bool
setValue(bool)
}
Panic Model
IsRecording Model
Playing Model
InstrEnlarged Model
Effect Model
CommentExpanded Model
NoteTracking Model
UnitSearching Model
UnitDisabled Model
LoopToggle Model
)
func (v Bool) Toggle() {
v.Set(!v.Value())
}
func (v Bool) Set(value bool) {
if v.Enabled() && v.Value() != value {
v.setValue(value)
}
}
// Model methods
func (m *Model) Panic() *Panic { return (*Panic)(m) }
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) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) }
func (m *Model) NoteTracking() *NoteTracking { return (*NoteTracking)(m) }
func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) }
func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) }
func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) }
// Panic methods
func (m *Panic) Bool() Bool { return Bool{m} }
func (m *Panic) Value() bool { return m.panic }
func (m *Panic) setValue(val bool) {
m.panic = val
(*Model)(m).send(PanicMsg{val})
}
func (m *Panic) Enabled() bool { return true }
// IsRecording methods
func (m *IsRecording) Bool() Bool { return Bool{m} }
func (m *IsRecording) Value() bool { return (*Model)(m).recording }
func (m *IsRecording) setValue(val bool) {
m.recording = val
m.instrEnlarged = val
(*Model)(m).send(RecordingMsg{val})
}
func (m *IsRecording) Enabled() bool { return true }
// Playing methods
func (m *Playing) Bool() Bool { return Bool{m} }
func (m *Playing) Value() bool { return m.playing }
func (m *Playing) setValue(val bool) {
m.playing = val
if m.playing {
(*Model)(m).send(StartPlayMsg{m.d.Cursor.SongPos})
} else {
(*Model)(m).send(IsPlayingMsg{val})
}
}
func (m *Playing) Enabled() bool { return m.playing || !m.instrEnlarged }
// InstrEnlarged methods
func (m *InstrEnlarged) Bool() Bool { return Bool{m} }
func (m *InstrEnlarged) Value() bool { return m.instrEnlarged }
func (m *InstrEnlarged) setValue(val bool) { m.instrEnlarged = val }
func (m *InstrEnlarged) Enabled() bool { return true }
// CommentExpanded methods
func (m *CommentExpanded) Bool() Bool { return Bool{m} }
func (m *CommentExpanded) Value() bool { return m.commentExpanded }
func (m *CommentExpanded) setValue(val bool) { m.commentExpanded = val }
func (m *CommentExpanded) Enabled() bool { return true }
// NoteTracking methods
func (m *NoteTracking) Bool() Bool { return Bool{m} }
func (m *NoteTracking) Value() bool { return m.playing && m.noteTracking }
func (m *NoteTracking) setValue(val bool) { m.noteTracking = val }
func (m *NoteTracking) Enabled() bool { return m.playing }
// Effect methods
func (m *Effect) Bool() Bool { return Bool{m} }
func (m *Effect) Value() bool {
if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) {
return false
}
return m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect
}
func (m *Effect) setValue(val bool) {
if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) {
return
}
m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect = val
}
func (m *Effect) Enabled() bool { return true }
// UnitSearching methods
func (m *UnitSearching) Bool() Bool { return Bool{m} }
func (m *UnitSearching) Value() bool { return m.d.UnitSearching }
func (m *UnitSearching) setValue(val bool) {
m.d.UnitSearching = val
if !val {
m.d.UnitSearchString = ""
}
}
func (m *UnitSearching) Enabled() bool { return true }
// UnitDisabled methods
func (m *UnitDisabled) Bool() Bool { return Bool{m} }
func (m *UnitDisabled) Value() bool {
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return false
}
if m.d.UnitIndex < 0 || m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) {
return false
}
return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Disabled
}
func (m *UnitDisabled) setValue(val bool) {
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return
}
l := ((*Model)(m)).Units().List()
a, b := l.listRange()
defer (*Model)(m).change("UnitDisabledSet", PatchChange, MajorChange)()
for i := a; i <= b; i++ {
m.d.Song.Patch[m.d.InstrIndex].Units[i].Disabled = val
}
}
func (m *UnitDisabled) Enabled() bool {
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return false
}
if len(m.d.Song.Patch[m.d.InstrIndex].Units) == 0 {
return false
}
return true
}
// LoopToggle methods
func (m *LoopToggle) Bool() Bool { return Bool{m} }
func (m *LoopToggle) Value() bool { return m.d.Loop.Length > 0 }
func (t *LoopToggle) setValue(val bool) {
m := (*Model)(t)
defer m.change("SetLoopAction", LoopChange, MinorChange)()
if !val {
m.d.Loop = Loop{}
return
}
l := m.OrderRows().List()
a, b := l.listRange()
m.d.Loop = Loop{a, b - a + 1}
}
func (m *LoopToggle) Enabled() bool { return true }

193
tracker/broker.go Normal file
View File

@ -0,0 +1,193 @@
package tracker
import (
"sync"
"sync/atomic"
"time"
"github.com/vsariola/sointu"
)
type (
// Broker is the centralized message broker for the tracker. It is used to
// communicate between the player, the model, and the loudness detector. At
// the moment, the broker is just many-to-one communication, implemented
// with one channel for each recipient. Additionally, the broker has a
// sync.pool for *sointu.AudioBuffers, from which the player can get and
// return buffers to pass buffers around without allocating new memory every
// time. We can later consider making many-to-many types of communication
// and more complex routing logic to the Broker if needed.
//
// For closing goroutines, the broker has two channels for each goroutine:
// CloseXXX and FinishedXXX. The CloseXXX channel has a capacity of 1, so
// you can always send a empty message (struct{}{}) to it without blocking.
// If the channel is already full, that means someone else has already
// requested its closure and the goroutine is already closing, so dropping
// the message is fine. Then, FinishedXXX is used to signal that a goroutine
// has succesfully closed and cleaned up. Nothing is ever sent to the
// channel, it is only closed. You can wait until the goroutines is done
// closing with "<- FinishedXXX", which for avoiding deadlocks can be
// combined with a timeout:
// select {
// case <-FinishedXXX:
// case <-time.After(3 * time.Second):
// }
Broker struct {
ToModel chan MsgToModel
ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
ToDetector chan MsgToDetector
ToGUI chan any
ToSpecAn chan MsgToSpecAn
CloseDetector chan struct{}
CloseGUI chan struct{}
CloseSpecAn chan struct{}
FinishedGUI chan struct{}
FinishedDetector chan struct{}
FinishedSpecAn chan struct{}
// mIDIEventsToGUI is true if all MIDI events should be sent to the GUI,
// for inputting notes to tracks. If false, they should be sent to the
// player instead.
mIDIEventsToGUI atomic.Bool
bufferPool sync.Pool
spectrumPool sync.Pool
}
// MsgToModel is a message sent to the model. The most often sent data
// (Panic, SongPosition, VoiceLevels and DetectorResult) are not boxed to
// avoid allocations. All the infrequently passed messages can be boxed &
// cast to any; casting pointer types to any is cheap (does not allocate).
MsgToModel struct {
HasPanicPlayerStatus bool
Panic bool
PlayerStatus PlayerStatus
HasDetectorResult bool
DetectorResult DetectorResult
TriggerChannel int // note: 0 = no trigger, 1 = first channel, etc.
Reset bool // true: playing started, so should reset the detector and the scope cursor
Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
}
// MsgToDetector is a message sent to the detector. It contains a reset flag
// and a data field. The data field can contain many different messages,
// including *sointu.AudioBuffer for the detector to analyze and func()
// which gets executed in the detector goroutine.
MsgToDetector struct {
Reset bool
Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
WeightingType WeightingType
HasWeightingType bool
Oversampling bool
HasOversampling bool
}
// MsgToGUI is a message sent to the GUI, as GUI stores part of the state.
// In particular, GUI knows about where lists / tables are centered, so the
// kind of messages we send to the GUI are about centering the view on a
// specific row, or ensuring that the cursor is visible.
MsgToGUI struct {
Kind GUIMessageKind
Param int
}
MsgToSpecAn struct {
SpecSettings specAnSettings
HasSettings bool
Data any
}
GUIMessageKind int
)
const (
GUIMessageKindNone GUIMessageKind = iota
GUIMessageCenterOnRow
GUIMessageEnsureCursorVisible
)
func NewBroker() *Broker {
return &Broker{
ToPlayer: make(chan any, 1024),
ToModel: make(chan MsgToModel, 1024),
ToDetector: make(chan MsgToDetector, 1024),
ToGUI: make(chan any, 1024),
ToSpecAn: make(chan MsgToSpecAn, 1024),
CloseDetector: make(chan struct{}, 1),
CloseGUI: make(chan struct{}, 1),
CloseSpecAn: make(chan struct{}, 1),
FinishedGUI: make(chan struct{}),
FinishedDetector: make(chan struct{}),
FinishedSpecAn: make(chan struct{}),
bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }},
spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }},
}
}
func (b *Broker) MIDIChannel() chan<- any {
if b.mIDIEventsToGUI.Load() {
return b.ToGUI
}
return b.ToPlayer
}
// GetAudioBuffer returns an audio buffer from the buffer pool. The buffer is
// guaranteed to be empty. After using the buffer, it should be returned to the
// pool with PutAudioBuffer.
func (b *Broker) GetAudioBuffer() *sointu.AudioBuffer {
return b.bufferPool.Get().(*sointu.AudioBuffer)
}
// PutAudioBuffer returns an audio buffer to the buffer pool. If the buffer is
// not empty, its length is resetted (but capacity kept) before returning it to
// the pool.
func (b *Broker) PutAudioBuffer(buf *sointu.AudioBuffer) {
if len(*buf) > 0 {
*buf = (*buf)[:0]
}
b.bufferPool.Put(buf)
}
func (b *Broker) GetSpectrum() *Spectrum {
return b.spectrumPool.Get().(*Spectrum)
}
func (b *Broker) PutSpectrum(s *Spectrum) {
if len((*s)[0]) > 0 {
(*s)[0] = (*s)[0][:0]
}
if len((*s)[1]) > 0 {
(*s)[1] = (*s)[1][:0]
}
b.spectrumPool.Put(s)
}
// TrySend is a helper function to send a value to a channel if it is not full.
// It is guaranteed to be non-blocking. Return true if the value was sent, false
// otherwise.
func TrySend[T any](c chan<- T, v T) bool {
select {
case c <- v:
return true
default:
return false
}
}
// TimeoutReceive is a helper function to block until a value is received from a
// channel, or timing out after t. ok will be false if the timeout occurred or
// if the channel is closed.
func TimeoutReceive[T any](c <-chan T, t time.Duration) (v T, ok bool) {
select {
case v, ok = <-c:
return v, ok
case <-time.After(t):
return v, false
}
}

319
tracker/derived.go Normal file
View File

@ -0,0 +1,319 @@
package tracker
import (
"fmt"
"time"
"github.com/vsariola/sointu"
)
type (
Rail struct {
PassThrough int
Send bool
StackUse sointu.StackUse
}
Wire struct {
From int
FromSet bool
To Point
ToSet bool
Hint string
Highlight bool
}
RailError struct {
InstrIndex, UnitIndex int
Err error
}
// derivedModelData contains useful information derived from the modelData,
// cached for performance and/or easy access. This needs to be updated when
// corresponding part of the model changes.
derivedModelData struct {
// map Unit by ID, other entities by their respective index
patch []derivedInstrument
tracks []derivedTrack
railError RailError
searchResults []string
}
derivedInstrument struct {
wires []Wire
rails []Rail
railWidth int
params [][]Parameter
paramsWidth int
}
derivedTrack struct {
title string
patternUseCounts []int
}
)
// init / update methods
func (m *Model) updateDeriveData(changeType ChangeType) {
setSliceLength(&m.derived.tracks, len(m.d.Song.Score.Tracks))
if changeType&ScoreChange != 0 {
for index, track := range m.d.Song.Score.Tracks {
m.derived.tracks[index].patternUseCounts = m.buildPatternUseCounts(track)
}
}
if changeType&ScoreChange != 0 || changeType&PatchChange != 0 {
for index := range m.d.Song.Score.Tracks {
m.derived.tracks[index].title = m.buildTrackTitle(index)
}
}
setSliceLength(&m.derived.patch, len(m.d.Song.Patch))
if changeType&PatchChange != 0 {
m.updateParams()
m.updateRails()
m.updateWires()
}
}
func (m *Model) updateParams() {
for i, instr := range m.d.Song.Patch {
setSliceLength(&m.derived.patch[i].params, len(instr.Units))
paramsWidth := 0
for u := range instr.Units {
p := m.deriveParams(&instr.Units[u], m.derived.patch[i].params[u])
m.derived.patch[i].params[u] = p
paramsWidth = max(paramsWidth, len(p))
}
m.derived.patch[i].paramsWidth = paramsWidth
}
}
func (m *Model) deriveParams(unit *sointu.Unit, ret []Parameter) []Parameter {
ret = ret[:0] // reset the slice
unitType, ok := sointu.UnitTypes[unit.Type]
if !ok {
return ret
}
portIndex := 0
for i, up := range unitType {
if !up.CanSet && !up.CanModulate {
continue // skip parameters that cannot be set or modulated
}
if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && (up.Name == "samplestart" || up.Name == "loopstart" || up.Name == "looplength") {
continue // don't show the sample related params unless necessary
}
if unit.Type == "send" && up.Name == "port" {
continue
}
q := 0
if up.CanModulate {
portIndex++
q = portIndex
}
ret = append(ret, Parameter{m: m, unit: unit, up: &unitType[i], vtable: &namedParameter{}, port: q})
}
if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample {
ret = append(ret, Parameter{m: m, unit: unit, vtable: &gmDlsEntryParameter{}})
}
if unit.Type == "delay" {
if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 {
unit.VarArgs = append(unit.VarArgs, 1)
}
ret = append(ret,
Parameter{m: m, unit: unit, vtable: &reverbParameter{}},
Parameter{m: m, unit: unit, vtable: &delayLinesParameter{}})
for i := range unit.VarArgs {
ret = append(ret, Parameter{m: m, unit: unit, index: i, vtable: &delayTimeParameter{}})
}
}
return ret
}
func (m *Model) instrumentRangeFor(trackIndex int) (int, int, error) {
track := m.d.Song.Score.Tracks[trackIndex]
if track.NumVoices <= 0 {
return 0, 0, fmt.Errorf("track %d has no voices", trackIndex)
}
firstVoice := m.d.Song.Score.FirstVoiceForTrack(trackIndex)
lastVoice := firstVoice + track.NumVoices - 1
firstIndex, err1 := m.d.Song.Patch.InstrumentForVoice(firstVoice)
if err1 != nil {
return trackIndex, trackIndex, err1
}
lastIndex, err2 := m.d.Song.Patch.InstrumentForVoice(lastVoice)
if err2 != nil {
return trackIndex, trackIndex, err2
}
return firstIndex, lastIndex, nil
}
func (m *Model) buildTrackTitle(track int) string {
if track < 0 || track >= len(m.d.Song.Score.Tracks) {
return "?"
}
firstIndex, lastIndex, err := m.instrumentRangeFor(track)
if err != nil {
return "?"
}
switch diff := lastIndex - firstIndex; diff {
case 0:
return nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name)
case 1:
return fmt.Sprintf("%s/%s",
nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name),
nilIsQuestionMark(m.d.Song.Patch[firstIndex+1].Name))
default:
return fmt.Sprintf("%s/%s/...",
nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name),
nilIsQuestionMark(m.d.Song.Patch[firstIndex+1].Name))
}
}
func nilIsQuestionMark(s string) string {
if len(s) == 0 {
return "?"
}
return s
}
func (m *Model) buildPatternUseCounts(track sointu.Track) []int {
result := make([]int, 0, len(track.Patterns))
for j := range min(len(track.Order), m.d.Song.Score.Length) {
if p := track.Order[j]; p >= 0 {
for len(result) <= p {
result = append(result, 0)
}
result[p]++
}
}
return result
}
func (m *Model) updateRails() {
type stackElem struct{ instr, unit int }
scratchArray := [32]stackElem{}
scratch := scratchArray[:0]
m.derived.railError = RailError{}
for i, instr := range m.d.Song.Patch {
setSliceLength(&m.derived.patch[i].rails, len(instr.Units))
start := len(scratch)
maxWidth := 0
for u, unit := range instr.Units {
stackUse := unit.StackUse()
numInputs := len(stackUse.Inputs)
if len(scratch) < numInputs {
if m.derived.railError == (RailError{}) {
m.derived.railError = RailError{
InstrIndex: i,
UnitIndex: u,
Err: fmt.Errorf("%s unit in instrument %d / %s needs %d inputs, but got only %d", unit.Type, i, instr.Name, numInputs, len(scratch)),
}
}
scratch = scratch[:0]
} else {
scratch = scratch[:len(scratch)-numInputs]
}
m.derived.patch[i].rails[u] = Rail{
PassThrough: len(scratch),
StackUse: stackUse,
Send: !unit.Disabled && unit.Type == "send",
}
maxWidth = max(maxWidth, len(scratch)+max(len(stackUse.Inputs), stackUse.NumOutputs))
for range stackUse.NumOutputs {
scratch = append(scratch, stackElem{instr: i, unit: u})
}
}
m.derived.patch[i].railWidth = maxWidth
diff := len(scratch) - start
if instr.NumVoices > 1 && diff != 0 {
if diff < 0 {
morepop := (instr.NumVoices - 1) * diff
if morepop > len(scratch) {
if m.derived.railError == (RailError{}) {
m.derived.railError = RailError{
InstrIndex: i,
UnitIndex: -1,
Err: fmt.Errorf("each voice of instrument %d / %s consumes %d signals, but there was not enough signals available", i, instr.Name, -diff),
}
}
scratch = scratch[:0]
} else {
scratch = scratch[:len(scratch)-morepop]
}
} else {
for range (instr.NumVoices - 1) * diff {
scratch = append(scratch, scratch[len(scratch)-diff])
}
}
}
}
if len(scratch) > 0 && m.derived.railError == (RailError{}) {
patch := m.d.Song.Patch
m.derived.railError = RailError{
InstrIndex: scratch[0].instr,
UnitIndex: scratch[0].unit,
Err: fmt.Errorf("instrument %d / %s unit %d / %s leaves a signal on stack", scratch[0].instr, patch[scratch[0].instr].Name, scratch[0].unit, patch[scratch[0].instr].Units[scratch[0].unit].Type),
}
}
if m.derived.railError.Err != nil {
m.Alerts().AddAlert(Alert{
Name: "RailError",
Message: m.derived.railError.Error(),
Priority: Error,
Duration: time.Second * 10,
})
} else { // clear the alert if it was set
m.Alerts().ClearNamed("RailError")
}
}
func (m *Model) updateWires() {
for i := range m.d.Song.Patch {
m.derived.patch[i].wires = m.derived.patch[i].wires[:0] // reset the wires
}
for i, instr := range m.d.Song.Patch {
for u, unit := range instr.Units {
if unit.Disabled || unit.Type != "send" {
continue
}
tI, tU, err := m.d.Song.Patch.FindUnit(unit.Parameters["target"])
if err != nil {
continue
}
up, tX, ok := sointu.FindParamForModulationPort(m.d.Song.Patch[tI].Units[tU].Type, unit.Parameters["port"])
if !ok {
continue
}
if tI == i {
// local send
m.derived.patch[i].wires = append(m.derived.patch[i].wires, Wire{
From: u,
FromSet: true,
To: Point{X: tX, Y: tU},
ToSet: true,
})
} else {
// remote send
m.derived.patch[i].wires = append(m.derived.patch[i].wires, Wire{
From: u,
FromSet: true,
Hint: fmt.Sprintf("To instrument #%d (%s), unit #%d (%s), port %s", tI, m.d.Song.Patch[tI].Name, tU, m.d.Song.Patch[tI].Units[tU].Type, up.Name),
})
toPt := Point{X: tX, Y: tU}
hint := fmt.Sprintf("From instrument #%d (%s), send #%d", i, m.d.Song.Patch[i].Name, u)
for i, w := range m.derived.patch[tI].wires {
if !w.FromSet && w.ToSet && w.To == toPt {
m.derived.patch[tI].wires[i].Hint += "; " + hint
goto skipAppend
}
}
m.derived.patch[tI].wires = append(m.derived.patch[tI].wires, Wire{
To: toPt,
ToSet: true,
Hint: hint,
})
skipAppend:
}
}
}
}

485
tracker/detector.go Normal file
View File

@ -0,0 +1,485 @@
package tracker
import (
"math"
"github.com/viterin/vek/vek32"
"github.com/vsariola/sointu"
)
const MAX_INTEGRATED_DATA = 10 * 60 * 60 // 1 hour of samples at 10 Hz (100 ms per sample)
// In the detector, we clamp the signal levels to +-MAX_SIGNAL_AMPLITUDE to
// avoid Inf results. This is 240 dBFS. max float32 is about 3.4e38, so squaring
// the amplitude values gives 1e24, and adding 4410 of those together (when
// taking the mean) gives a value < 1e37, which is still < max float32.
const MAX_SIGNAL_AMPLITUDE = 1e12
// Detector returns a DetectorModel which provides access to the detector
// settings and results.
func (m *Model) Detector() *DetectorModel { return (*DetectorModel)(m) }
type DetectorModel Model
// Result returns the latest DetectorResult from the detector.
func (m *DetectorModel) Result() DetectorResult { return m.detectorResult }
type (
DetectorResult struct {
Loudness LoudnessResult
Peaks PeakResult
}
LoudnessResult [NumLoudnessTypes]Decibel
PeakResult [NumPeakTypes][2]Decibel
Decibel float32
LoudnessType int
PeakType int
)
const (
LoudnessMomentary LoudnessType = iota
LoudnessShortTerm
LoudnessMaxMomentary
LoudnessMaxShortTerm
LoudnessIntegrated
NumLoudnessTypes
)
const (
PeakMomentary PeakType = iota
PeakShortTerm
PeakIntegrated
NumPeakTypes
)
// Weighting returns an Int property for setting the detector weighting type.
func (m *DetectorModel) Weighting() Int { return MakeInt((*detectorWeighting)(m)) }
type detectorWeighting Model
func (v *detectorWeighting) Value() int { return int(v.weightingType) }
func (v *detectorWeighting) SetValue(value int) bool {
v.weightingType = WeightingType(value)
TrySend(v.broker.ToDetector, MsgToDetector{HasWeightingType: true, WeightingType: WeightingType(value)})
return true
}
func (v *detectorWeighting) Range() RangeInclusive {
return RangeInclusive{0, int(NumWeightingTypes) - 1}
}
func (v *detectorWeighting) StringOf(value int) string {
switch WeightingType(value) {
case KWeighting:
return "K-weighting"
case AWeighting:
return "A-weighting"
case CWeighting:
return "C-weighting (LUFS)"
case NoWeighting:
return "No weighting (RMS)"
default:
return "Unknown"
}
}
type WeightingType int
const (
KWeighting WeightingType = iota
AWeighting
CWeighting
NoWeighting
NumWeightingTypes
)
// Oversampling returns a Bool property for setting whether the peak detector
// uses oversampling to calculate true peaks, or just sample peaks if not.
func (m *DetectorModel) Oversampling() Bool { return MakeBool((*detectorOversampling)(m)) }
type detectorOversampling Model
func (m *detectorOversampling) Value() bool { return m.oversampling }
func (m *detectorOversampling) SetValue(val bool) {
m.oversampling = val
TrySend(m.broker.ToDetector, MsgToDetector{HasOversampling: true, Oversampling: val})
}
type (
detector struct {
broker *Broker
loudnessDetector loudnessDetector
peakDetector peakDetector
chunker chunker
}
loudnessDetector struct {
weighting weighting
states [2][3]biquadState
powers [2]RingBuffer[float32] // 0 = momentary, 1 = short-term
averagedPowers [2][]float32
maxPowers [2]float32
integratedPower float32
tmp, tmp2 []float32
tmpbool []bool
}
biquadState struct {
x1, x2, y1, y2 float32
}
biquadCoeff struct {
b0, b1, b2, a1, a2 float32
}
weighting []biquadCoeff
peakDetector struct {
oversampling bool
states [2]oversamplerState
windows [2][2]RingBuffer[float32]
maxPower [2]float32
tmp, tmp2 []float32
}
oversamplerState struct {
history [11]float32
tmp, tmp2 []float32
}
)
func runDetector(b *Broker) {
s := &detector{
broker: b,
loudnessDetector: makeLoudnessDetector(KWeighting),
peakDetector: makePeakDetector(true),
}
for {
select {
case <-s.broker.CloseDetector:
close(s.broker.FinishedDetector)
return
case msg := <-s.broker.ToDetector:
s.handleMsg(msg)
}
}
}
func (s *detector) handleMsg(msg MsgToDetector) {
if msg.Reset {
s.loudnessDetector.reset()
s.peakDetector.reset()
}
if msg.HasWeightingType {
s.loudnessDetector.weighting = weightings[WeightingType(msg.WeightingType)]
s.loudnessDetector.reset()
}
if msg.HasOversampling {
s.peakDetector.oversampling = msg.Oversampling
s.peakDetector.reset()
}
switch data := msg.Data.(type) {
case *sointu.AudioBuffer:
buf := *data
s.chunker.Process(buf, 4410, 0, func(chunk sointu.AudioBuffer) {
TrySend(s.broker.ToModel, MsgToModel{
HasDetectorResult: true,
DetectorResult: DetectorResult{
Loudness: s.loudnessDetector.update(chunk),
Peaks: s.peakDetector.update(chunk),
},
})
})
s.broker.PutAudioBuffer(data)
}
}
func makeLoudnessDetector(weighting WeightingType) loudnessDetector {
return loudnessDetector{
weighting: weightings[weighting],
powers: [2]RingBuffer[float32]{
{Buffer: make([]float32, 4)}, // momentary loudness
{Buffer: make([]float32, 30)}, // short-term loudness
},
}
}
func makePeakDetector(oversampling bool) peakDetector {
return peakDetector{
oversampling: oversampling,
windows: [2][2]RingBuffer[float32]{
{{Buffer: make([]float32, 4)}, {Buffer: make([]float32, 4)}}, // momentary peaks
{{Buffer: make([]float32, 30)}, {Buffer: make([]float32, 30)}}, // short-term peaks
},
}
}
/*
From matlab: (we bake in the scale values to the numerator coefficients)
weightings = {'A-weighting','C-weighting','k-weighting'}
for j = 1:3
disp(weightings{j})
f = getFilter(weightingFilter(weightings{j},'SampleRate',44100)); f.Numerator, f.Denominator, f.ScaleValues
if j == 3 % k-weighting has non-zero gain at 1 kHz, so normalize it to 0 dB by scaling the first filter
[h,w] = freqz(f,[1000,1000],44100);
g = abs(h(1));
fprintf("Gain %f dB\n", 20*log10(abs(h(1))));
f.Numerator(1,:) = f.Numerator(1,:)/g;
end
for i = 1:size(f.Numerator,1); fprintf("b0: %.16f, b1: %.16f, b2: %.16f, a1: %.16f, a2: %.16f\n",f.Numerator(i,:)*f.ScaleValues(i),f.Denominator(i,2:end)); end
end
*/
var weightings = map[WeightingType]weighting{
AWeighting: {
{b0: 0.2556115104436430, b1: 0.5112230208872860, b2: 0.2556115104436430, a1: -0.1405360824207108, a2: 0.0049375976155402},
{b0: 1, b1: -2, b2: 1, a1: -1.8849012174287920, a2: 0.8864214718161675},
{b0: 1, b1: -2, b2: 1, a1: -1.9941388812663283, a2: 0.9941474694445309},
},
CWeighting: {
{b0: 0.2170124955461332, b1: 0.4340249910922664, b2: 0.2170124955461332, a1: -0.1405360824207108, a2: 0.0049375976155402},
{b0: 1, b1: -2, b2: 1, a1: -1.9941388812663283, a2: 0.9941474694445309},
},
KWeighting: {
{b0: 1.4128568659906546, b1: -2.4466647580657646, b2: 1.0789762991286349, a1: -1.6636551132560204, a2: 0.7125954280732254},
{b0: 0.9995600645425144, b1: -1.9991201290850289, b2: 0.9995600645425144, a1: -1.9891696736297957, a2: 0.9891990357870394},
},
NoWeighting: {},
}
// according to https://tech.ebu.ch/docs/tech/tech3341.pdf
// we have two sliding windows: momentary loudness = last 400 ms, short-term loudness = last 3 s
// display:
//
// momentary loudness = last analyzed 400 ms blcok
// short-term loudness = last analyzed 3 s block
//
// every 100 ms, we collect one data point of the momentary loudness (starting to play song again resets the data blocks)
// then:
//
// integrated loudness = the blocks are gated, and the average loudness of the gated blocks is calculated
// maximum momentary loudness = maximum of all the momentary blocks
// maximum short-term loudness = maximum of all the short-term blocks
func (d *loudnessDetector) update(chunk sointu.AudioBuffer) LoudnessResult {
l := max(len(chunk), MAX_INTEGRATED_DATA)
setSliceLength(&d.tmp, l)
setSliceLength(&d.tmp2, l)
setSliceLength(&d.tmpbool, l)
var total float32
for chn := range 2 {
// deinterleave the channels
for i := range chunk {
d.tmp[i] = removeNaNsAndClamp(chunk[i][chn])
}
// filter the signal with the weighting filter
for k := range d.weighting {
d.states[chn][k].Filter(d.tmp[:len(chunk)], d.weighting[k])
}
// square the samples
res := vek32.Mul_Into(d.tmp2, d.tmp[:len(chunk)], d.tmp[:len(chunk)])
// calculate the mean and add it to the total
total += vek32.Mean(res)
}
var ret [NumLoudnessTypes]Decibel
for i := range d.powers {
d.powers[i].WriteWrapSingle(total) // these are sliding windows of 4 and 30 power measurements (400 ms and 3 s aka momentary and short-term windows)
mean := vek32.Mean(d.powers[i].Buffer)
if len(d.averagedPowers[i]) < MAX_INTEGRATED_DATA { // we need to have some limit on how much data we keep
d.averagedPowers[i] = append(d.averagedPowers[i], mean)
}
if d.maxPowers[i] < mean {
d.maxPowers[i] = mean
}
ret[i+int(LoudnessMomentary)] = powerToDecibel(mean) // we assume the LoudnessMomentary is followed by LoudnessShortTerm
ret[i+int(LoudnessMaxMomentary)] = powerToDecibel(d.maxPowers[i])
}
if len(d.averagedPowers[0])%10 == 0 { // every 10 samples of 100 ms i.e. every 1 s, we recalculate the integrated power
absThreshold := decibelToPower(-70) // -70 dB is the first threshold
b := vek32.GtNumber_Into(d.tmpbool, d.averagedPowers[0], absThreshold)
m2 := vek32.Select_Into(d.tmp, d.averagedPowers[0], b)
if len(m2) > 0 {
relThreshold := vek32.Mean(m2) / 10 // the relative threshold is 10 dB below the mean of the values above the absolute threshold
b2 := vek32.GtNumber_Into(d.tmpbool, m2, relThreshold)
m3 := vek32.Select_Into(d.tmp2, m2, b2)
if len(m3) > 0 {
d.integratedPower = vek32.Mean(m3)
}
}
}
ret[LoudnessIntegrated] = powerToDecibel(d.integratedPower)
return ret
}
func (d *loudnessDetector) reset() {
for i := range d.powers {
d.powers[i].Cursor = 0
l := len(d.powers[i].Buffer)
d.powers[i].Buffer = d.powers[i].Buffer[:0]
d.powers[i].Buffer = append(d.powers[i].Buffer, make([]float32, l)...)
d.averagedPowers[i] = d.averagedPowers[i][:0]
d.maxPowers[i] = 0
}
// reset the biquad states
d.states = [2][3]biquadState{}
d.integratedPower = 0
}
func removeNaNsAndClamp(s float32) float32 {
if s != s { // NaN
return 0
}
return min(max(s, -MAX_SIGNAL_AMPLITUDE), MAX_SIGNAL_AMPLITUDE)
}
func powerToDecibel(power float32) Decibel {
return Decibel(float32(10 * math.Log10(float64(power))))
}
func amplitudeToDecibel(amplitude float32) Decibel {
return Decibel(float32(20 * math.Log10(float64(amplitude))))
}
func decibelToPower(loudness Decibel) float32 {
return (float32)(math.Pow(10, (float64(loudness))/10))
}
func (state *biquadState) Filter(buffer []float32, coeff biquadCoeff) {
s := *state
for i := range buffer {
x := buffer[i]
y := coeff.b0*x + coeff.b1*s.x1 + coeff.b2*s.x2 - coeff.a1*s.y1 - coeff.a2*s.y2
s.x2, s.x1 = s.x1, x
s.y2, s.y1 = s.y1, y
buffer[i] = y
}
*state = s
}
func setSliceLength[T any](slice *[]T, length int) {
if len(*slice) < length {
*slice = append(*slice, make([]T, length-len(*slice))...)
}
*slice = (*slice)[:length]
}
// ref: https://www.itu.int/dms_pubrec/itu-r/rec/bs/R-REC-BS.1770-5-202311-I!!PDF-E.pdf
var oversamplingCoeffs = [4][12]float32{
{0.0017089843750, 0.0109863281250, -0.0196533203125, 0.0332031250000, -0.0594482421875, 0.1373291015625, 0.9721679687500, -0.1022949218750, 0.0476074218750, -0.0266113281250, 0.0148925781250, -0.0083007812500},
{-0.0291748046875, 0.0292968750000, -0.0517578125000, 0.0891113281250, -0.1665039062500, 0.4650878906250, 0.7797851562500, -0.2003173828125, 0.1015625000000, -0.0582275390625, 0.0330810546875, -0.0189208984375},
{-0.0189208984375, 0.0330810546875, -0.058227539062, 0.1015625000000, -0.200317382812, 0.7797851562500, 0.4650878906250, -0.166503906250, 0.0891113281250, -0.051757812500, 0.0292968750000, -0.0291748046875},
{-0.0083007812500, 0.0148925781250, -0.0266113281250, 0.0476074218750, -0.1022949218750, 0.9721679687500, 0.1373291015625, -0.0594482421875, 0.0332031250000, -0.0196533203125, 0.0109863281250, 0.0017089843750},
}
// u[k] = x[k/4] if k%4 == 0, 0 otherwise
// y[k] = sum_{i=0}^{47} h[i] * u[k-i]
// h[i] = o[i%4][i/4]
// k = p*4+q, q=0..3
// y[p*4+q] = sum_{j=0}^{11} sum_{i=0}^{3} h[j*4+i] * u[p*4+q-j*4-i] = ...
// (q-i)%4 == 0 ==> i = q
// ... = sum_{j=0}^{11} o[q][j] * x[p-j]
// y should be at least 4 times the length of x
func (s *oversamplerState) Oversample(x []float32, y []float32) []float32 {
if len(s.tmp) < len(x) {
s.tmp = append(s.tmp, make([]float32, len(x)-len(s.tmp))...)
}
if len(s.tmp2) < len(x) {
s.tmp2 = append(s.tmp2, make([]float32, len(x)-len(s.tmp2))...)
}
for q, coeffs := range oversamplingCoeffs {
// tmp2 will be conv(o[q],x)
r := vek32.Zeros_Into(s.tmp2, len(x))
for j, c := range coeffs {
vek32.MulNumber_Into(s.tmp[:j], s.history[11-j:11], c) // convolution might pull values before x[0], so we need to use history for that
vek32.MulNumber_Into(s.tmp[j:], x[:len(x)-j], c)
vek32.Add_Inplace(r, s.tmp[:len(x)])
}
// interleave the phases
for p, v := range r {
y[p*4+q] = v
}
}
z := min(len(x), 11)
copy(s.history[:11-z], s.history[z:11])
copy(s.history[11-z:], x[len(x)-z:])
return y[:len(x)*4]
}
// we should perform the peak detection also momentary (last 400 ms), short term
// (last 3 s), and integrated (whole song) for display purposes, we can use
// always last arrived data for the integrated peak, we can use the maximum of
// all the peaks so far (there is no need show "maximum short term true peak" or
// "maximum momentary true peak" because they are same as the maximum for entire song)
//
// display:
//
// momentary true peak
// short-term true peak
// integrated true peak
func (d *peakDetector) update(buf sointu.AudioBuffer) (ret PeakResult) {
if len(d.tmp) < len(buf) {
d.tmp = append(d.tmp, make([]float32, len(buf)-len(d.tmp))...)
}
len4 := 4 * len(buf)
if len(d.tmp2) < len4 {
d.tmp2 = append(d.tmp2, make([]float32, len4-len(d.tmp2))...)
}
for chn := range 2 {
// deinterleave the channels
for i := range buf {
d.tmp[i] = removeNaNsAndClamp(buf[i][chn])
}
// 4x oversample the signal
var o []float32
if d.oversampling {
o = d.states[chn].Oversample(d.tmp[:len(buf)], d.tmp2)
} else {
o = d.tmp[:len(buf)]
}
// take absolute value of the oversampled signal
vek32.Abs_Inplace(o)
p := vek32.Max(o)
// find the maximum value in the window
for i := range d.windows {
d.windows[i][chn].WriteWrapSingle(p)
windowPeak := vek32.Max(d.windows[i][chn].Buffer)
ret[i+int(PeakMomentary)][chn] = amplitudeToDecibel(windowPeak)
}
if d.maxPower[chn] < p {
d.maxPower[chn] = p
}
ret[int(PeakIntegrated)][chn] = amplitudeToDecibel(d.maxPower[chn])
}
return
}
func (d *peakDetector) reset() {
for chn := range 2 {
d.states[chn].history = [11]float32{}
for i := range d.windows[chn] {
d.windows[i][chn].Cursor = 0
l := len(d.windows[i][chn].Buffer)
d.windows[i][chn].Buffer = d.windows[i][chn].Buffer[:0]
d.windows[i][chn].Buffer = append(d.windows[i][chn].Buffer, make([]float32, l)...)
}
d.maxPower[chn] = 0
}
}
// chunker maintains a buffer of audio data. Its Process method appends an input
// buffer to the buffer and calls a callback function with chunks of specified
// length and overlap. The remaining data is kept in the buffer for the next
// call.
type chunker struct {
buffer sointu.AudioBuffer
}
// Process appends input to the internal buffer and calls cb with chunks of
// windowLen length and overlap overlap. The remaining data is kept in the
// internal buffer.
func (c *chunker) Process(input sointu.AudioBuffer, windowLen, overlap int, cb func(sointu.AudioBuffer)) {
c.buffer = append(c.buffer, input...)
b := c.buffer
for len(b) >= windowLen {
cb(b[:windowLen])
b = b[windowLen-overlap:]
}
copy(c.buffer, b)
c.buffer = c.buffer[:len(b)]
}

View File

@ -1,4 +1,23 @@
/*
Package tracker contains the data model for the Sointu tracker GUI.
The tracker package defines the Model struct, which holds the entire application
state, including the song data, instruments, effects, and large part of the UI
state.
The GUI does not modify the Model data directly, rather, there are types Action,
Bool, Int, String, List and Table which can be used to manipulate the model data
in a controlled way. For example, model.ShowLicense() returns an Action to show
the license to the user, which can be executed with model.ShowLicense().Do().
The various Actions and other data manipulation methods are grouped based on
their functionalities. For example, model.Instrument() groups all the ways to
manipulate the instrument(s). Similarly, model.Play() groups all the ways to
start and stop playback.
The method naming aims at API fluency. For example, model.Play().FromBeginning()
returns an Action to start playing the song from the beginning. Similarly,
model.Instrument().Add() returns an Action to add a new instrument to the song
and model.Instrument().List() returns a List of all the instruments.
*/
package tracker

View File

@ -1,179 +0,0 @@
package tracker
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"github.com/vsariola/sointu"
)
func (m *Model) ReadSong(r io.ReadCloser) {
b, err := io.ReadAll(r)
if err != nil {
return
}
err = r.Close()
if err != nil {
return
}
var song sointu.Song
if errJSON := json.Unmarshal(b, &song); errJSON != nil {
if errYaml := yaml.Unmarshal(b, &song); errYaml != nil {
m.Alerts().Add(fmt.Sprintf("Error unmarshaling a song file: %v / %v", errYaml, errJSON), Error)
return
}
}
f := m.change("LoadSong", SongChange, MajorChange)
m.d.Song = song
if f, ok := r.(*os.File); ok {
m.d.FilePath = f.Name()
// when the song is loaded from a file, we are quite confident that the file is persisted and thus
// we can close sointu without worrying about losing changes
m.d.ChangedSinceSave = false
}
f()
m.completeAction(false)
}
func (m *Model) WriteSong(w io.WriteCloser) {
path := ""
var extension = filepath.Ext(path)
var contents []byte
var err error
if extension == ".json" {
contents, err = json.Marshal(m.d.Song)
} else {
contents, err = yaml.Marshal(m.d.Song)
}
if err != nil {
m.Alerts().Add(fmt.Sprintf("Error marshaling a song file: %v", err), Error)
return
}
if _, err := w.Write(contents); err != nil {
m.Alerts().Add(fmt.Sprintf("Error writing to file: %v", err), Error)
return
}
if f, ok := w.(*os.File); ok {
path = f.Name()
// when the song is saved to a file, we are quite confident that the file is persisted and thus
// we can close sointu without worrying about losing changes
m.d.ChangedSinceSave = false
}
if err := w.Close(); err != nil {
m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error)
return
}
m.d.FilePath = path
m.completeAction(false)
}
func (m *Model) WriteWav(w io.WriteCloser, pcm16 bool, execChan chan<- func()) {
m.dialog = NoDialog
song := m.d.Song.Copy()
go func() {
b := make([]byte, 32+2)
rand.Read(b)
name := fmt.Sprintf("%x", b)[2 : 32+2]
data, err := sointu.Play(m.synther, song, func(p float32) {
execChan <- func() {
m.Alerts().AddNamed(name, fmt.Sprintf("Exporting song: %.0f%%", p*100), Info)
}
}) // render the song to calculate its length
if err != nil {
execChan <- func() {
m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error)
}
return
}
buffer, err := data.Wav(pcm16)
if err != nil {
execChan <- func() {
m.Alerts().Add(fmt.Sprintf("Error converting to .wav: %v", err), Error)
}
return
}
w.Write(buffer)
w.Close()
}()
}
func (m *Model) SaveInstrument(w io.WriteCloser) bool {
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
m.Alerts().Add("No instrument selected", Error)
return false
}
path := ""
if f, ok := w.(*os.File); ok {
path = f.Name()
}
var extension = filepath.Ext(path)
var contents []byte
var err error
if extension == ".json" {
contents, err = json.Marshal(m.d.Song.Patch[m.d.InstrIndex])
} else {
contents, err = yaml.Marshal(m.d.Song.Patch[m.d.InstrIndex])
}
if err != nil {
m.Alerts().Add(fmt.Sprintf("Error marshaling a ínstrument file: %v", err), Error)
return false
}
w.Write(contents)
w.Close()
return true
}
func (m *Model) LoadInstrument(r io.ReadCloser) bool {
if m.d.InstrIndex < 0 {
return false
}
b, err := io.ReadAll(r)
if err != nil {
return false
}
var instrument sointu.Instrument
var errJSON, errYaml, err4ki, err4kp error
var patch sointu.Patch
errJSON = json.Unmarshal(b, &instrument)
if errJSON == nil {
goto success
}
errYaml = yaml.Unmarshal(b, &instrument)
if errYaml == nil {
goto success
}
patch, err4kp = sointu.Read4klangPatch(bytes.NewReader(b))
if err4kp == nil {
defer m.change("LoadInstrument", PatchChange, MajorChange)()
m.d.Song.Patch = patch
return true
}
instrument, err4ki = sointu.Read4klangInstrument(bytes.NewReader(b))
if err4ki == nil {
goto success
}
m.Alerts().Add(fmt.Sprintf("Error unmarshaling an instrument file: %v / %v / %v / %v", errYaml, errJSON, err4ki, err4kp), Error)
return false
success:
if f, ok := r.(*os.File); ok {
filename := f.Name()
// the 4klang instrument names are junk, replace them with the filename without extension
instrument.Name = filepath.Base(filename[:len(filename)-len(filepath.Ext(filename))])
}
defer m.change("LoadInstrument", PatchChange, MajorChange)()
for len(m.d.Song.Patch) <= m.d.InstrIndex {
m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy())
}
m.d.Song.Patch[m.d.InstrIndex] = instrument
if m.d.Song.Patch[m.d.InstrIndex].Comment != "" {
m.commentExpanded = true
}
return true
}

View File

@ -0,0 +1,48 @@
//go:build ignore
// +build ignore
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v3"
)
func main() {
filepath.WalkDir("presets", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var instr sointu.Instrument
if yaml.Unmarshal(data, &instr) != nil {
fmt.Fprintf(os.Stderr, "could not unmarshal the preset file %v: %v\n", path, err)
return nil
}
tracker.RemoveUnusedUnitParameters(&instr) // remove invalid parameters
instr.Name = "" // we don't need the names in the preset files as they are derived from the file path
instr.NumVoices = 1
outData, err := yaml.Marshal(instr)
if err != nil {
fmt.Fprintf(os.Stderr, "could not marshal the preset file %v: %v\n", path, err)
return nil
}
if err := os.WriteFile(path, outData, 0644); err != nil {
fmt.Fprintf(os.Stderr, "could not write the preset file %v: %v\n", path, err)
return nil
}
return nil
})
}

98
tracker/gioui/alerts.go Normal file
View File

@ -0,0 +1,98 @@
package gioui
import (
"image"
"image/color"
"time"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"github.com/vsariola/sointu/tracker"
)
type (
AlertsState struct {
prevUpdate time.Time
}
AlertStyle struct {
Bg color.NRGBA
Text LabelStyle
}
AlertStyles struct {
Info AlertStyle
Warning AlertStyle
Error AlertStyle
Margin layout.Inset
Inset layout.Inset
}
AlertsWidget struct {
Theme *Theme
Model *tracker.Alerts
State *AlertsState
}
)
func NewAlertsState() *AlertsState {
return &AlertsState{prevUpdate: time.Now()}
}
func Alerts(m *tracker.Alerts, th *Theme, st *AlertsState) AlertsWidget {
return AlertsWidget{
Theme: th,
Model: m,
State: st,
}
}
func (a *AlertsWidget) Layout(gtx C) D {
now := time.Now()
if a.Model.Update(now.Sub(a.State.prevUpdate)) {
gtx.Execute(op.InvalidateCmd{At: now.Add(50 * time.Millisecond)})
}
a.State.prevUpdate = now
var totalY float64 = float64(gtx.Dp(38))
for _, alert := range a.Model.Iterate {
var alertStyle *AlertStyle
switch alert.Priority {
case tracker.Warning:
alertStyle = &a.Theme.Alert.Warning
case tracker.Error:
alertStyle = &a.Theme.Alert.Error
default:
alertStyle = &a.Theme.Alert.Info
}
bgWidget := func(gtx C) D {
paint.FillShape(gtx.Ops, alertStyle.Bg, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
return D{Size: gtx.Constraints.Min}
}
labelStyle := Label(a.Theme, &alertStyle.Text, alert.Message)
a.Theme.Alert.Margin.Layout(gtx, func(gtx C) D {
return layout.S.Layout(gtx, func(gtx C) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
recording := op.Record(gtx.Ops)
dims := layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(bgWidget),
layout.Stacked(func(gtx C) D {
return a.Theme.Alert.Inset.Layout(gtx, labelStyle.Layout)
}),
)
macro := recording.Stop()
delta := float64(dims.Size.Y + gtx.Dp(a.Theme.Alert.Margin.Bottom))
op.Offset(image.Point{0, int(-totalY*alert.FadeLevel + delta*(1-alert.FadeLevel))}).Add((gtx.Ops))
totalY += delta
macro.Add(gtx.Ops)
return dims
})
})
}
return D{}
}

View File

@ -1,150 +1,559 @@
package gioui
import (
"image"
"image/color"
"math"
"time"
"gioui.org/font"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/component"
"github.com/vsariola/sointu/tracker"
)
type (
TipClickable struct {
Clickable widget.Clickable
TipArea component.TipArea
Clickable struct {
click gesture.Click
history []widget.Press
requestClicks int
TipArea TipArea // since almost all buttons have tooltips, we include the state for a tooltip here for convenience
}
ActionClickable struct {
Action tracker.Action
TipClickable
ButtonStyle struct {
// Color is the text color.
Color color.NRGBA
Font font.Font
TextSize unit.Sp
Background color.NRGBA
CornerRadius unit.Dp
Height unit.Dp
Inset layout.Inset
}
TipIconButtonStyle struct {
TipArea *component.TipArea
IconButtonStyle material.IconButtonStyle
Tooltip component.Tooltip
IconButtonStyle struct {
Background color.NRGBA
// Color is the icon color.
Color color.NRGBA
// Size is the icon size.
Size unit.Dp
Inset layout.Inset
}
BoolClickable struct {
Clickable widget.Clickable
TipArea component.TipArea
Bool tracker.Bool
// Button is a text button
Button struct {
Theme *Theme
Style *ButtonStyle
Text string
Tip string
Clickable *Clickable
}
// ActionButton is a text button that executes an action when clicked.
ActionButton struct {
Action tracker.Action
DisabledStyle *ButtonStyle
Button
}
// ToggleButton is a text button that toggles a boolean value when clicked.
ToggleButton struct {
Bool tracker.Bool
DisabledStyle *ButtonStyle
OffStyle *ButtonStyle
Button
}
// TabButton is a button used in a tab bar.
TabButton struct {
IndicatorHeight unit.Dp
IndicatorColor color.NRGBA
ToggleButton
}
// IconButton is a button with an icon.
IconButton struct {
Theme *Theme
Style *IconButtonStyle
Icon *widget.Icon
Tip string
Clickable *Clickable
}
// ActionIconButton is an icon button that executes an action when clicked.
ActionIconButton struct {
Action tracker.Action
DisabledStyle *IconButtonStyle
IconButton
}
// ToggleIconButton is an icon button that toggles a boolean value when clicked.
ToggleIconButton struct {
Bool tracker.Bool
DisabledStyle *IconButtonStyle
OffIcon *widget.Icon
OffTip string
IconButton
}
)
func NewActionClickable(a tracker.Action) *ActionClickable {
return &ActionClickable{
Action: a,
func Btn(th *Theme, st *ButtonStyle, c *Clickable, txt string, tip string) Button {
return Button{
Theme: th,
Style: st,
Clickable: c,
Text: txt,
Tip: tip,
}
}
func NewBoolClickable(b tracker.Bool) *BoolClickable {
return &BoolClickable{
Bool: b,
func ActionBtn(act tracker.Action, th *Theme, c *Clickable, txt string, tip string) ActionButton {
return ActionButton{
Action: act,
DisabledStyle: &th.Button.Disabled,
Button: Btn(th, &th.Button.Text, c, txt, tip),
}
}
func Tooltip(th *material.Theme, tip string) component.Tooltip {
tooltip := component.PlatformTooltip(th, tip)
tooltip.Bg = black
return tooltip
}
func ActionIcon(gtx C, th *material.Theme, w *ActionClickable, icon []byte, tip string) TipIconButtonStyle {
ret := TipIcon(th, &w.TipClickable, icon, tip)
for w.Clickable.Clicked(gtx) {
w.Action.Do()
}
if !w.Action.Allowed() {
ret.IconButtonStyle.Color = disabledTextColor
}
return ret
}
func TipIcon(th *material.Theme, w *TipClickable, icon []byte, tip string) TipIconButtonStyle {
iconButtonStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "")
iconButtonStyle.Color = primaryColor
iconButtonStyle.Background = transparent
iconButtonStyle.Inset = layout.UniformInset(unit.Dp(6))
return TipIconButtonStyle{
TipArea: &w.TipArea,
IconButtonStyle: iconButtonStyle,
Tooltip: Tooltip(th, tip),
func ToggleBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string) ToggleButton {
return ToggleButton{
Bool: b,
DisabledStyle: &th.Button.Disabled,
OffStyle: &th.Button.Text,
Button: Btn(th, &th.Button.Filled, c, text, tip),
}
}
func ToggleIcon(gtx C, th *material.Theme, w *BoolClickable, offIcon, onIcon []byte, offTip, onTip string) TipIconButtonStyle {
icon := offIcon
tip := offTip
if w.Bool.Value() {
icon = onIcon
tip = onTip
}
for w.Clickable.Clicked(gtx) {
w.Bool.Toggle()
}
ibStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "")
ibStyle.Background = transparent
ibStyle.Inset = layout.UniformInset(unit.Dp(6))
ibStyle.Color = primaryColor
if !w.Bool.Enabled() {
ibStyle.Color = disabledTextColor
}
return TipIconButtonStyle{
TipArea: &w.TipArea,
IconButtonStyle: ibStyle,
Tooltip: Tooltip(th, tip),
func TabBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string) TabButton {
return TabButton{
IndicatorHeight: th.Button.Tab.IndicatorHeight,
IndicatorColor: th.Button.Tab.IndicatorColor,
ToggleButton: ToggleButton{
Bool: b,
DisabledStyle: &th.Button.Disabled,
OffStyle: &th.Button.Tab.Inactive,
Button: Btn(th, &th.Button.Tab.Active, c, text, tip),
},
}
}
func (t *TipIconButtonStyle) Layout(gtx C) D {
return t.TipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout)
func IconBtn(th *Theme, st *IconButtonStyle, c *Clickable, icon []byte, tip string) IconButton {
return IconButton{
Theme: th,
Style: st,
Clickable: c,
Icon: th.Icon(icon),
Tip: tip,
}
}
func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) material.ButtonStyle {
for w.Clickable.Clicked(gtx) {
w.Action.Do()
func ActionIconBtn(act tracker.Action, th *Theme, c *Clickable, icon []byte, tip string) ActionIconButton {
return ActionIconButton{
Action: act,
DisabledStyle: &th.IconButton.Disabled,
IconButton: IconBtn(th, &th.IconButton.Enabled, c, icon, tip),
}
ret := material.Button(th, &w.Clickable, text)
ret.Color = th.Palette.Fg
if !w.Action.Allowed() {
ret.Color = disabledTextColor
}
ret.Background = transparent
ret.Inset = layout.UniformInset(unit.Dp(6))
return ret
}
func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) material.ButtonStyle {
func ToggleIconBtn(b tracker.Bool, th *Theme, c *Clickable, offIcon, onIcon []byte, offTip, onTip string) ToggleIconButton {
return ToggleIconButton{
Bool: b,
DisabledStyle: &th.IconButton.Disabled,
OffIcon: th.Icon(offIcon),
OffTip: offTip,
IconButton: IconBtn(th, &th.IconButton.Enabled, c, onIcon, onTip),
}
}
func (b *Button) Layout(gtx C) D {
if b.Tip != "" {
return b.Clickable.TipArea.Layout(gtx, Tooltip(b.Theme, b.Tip), b.actualLayout)
}
return b.actualLayout(gtx)
}
func (b *Button) actualLayout(gtx C) D {
min := gtx.Constraints.Min
min.Y = gtx.Dp(b.Style.Height)
return b.Clickable.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rr := gtx.Dp(b.Style.CornerRadius)
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
background := b.Style.Background
switch {
case b.Clickable.Hovered():
background = hoveredColor(background)
}
paint.Fill(gtx.Ops, background)
for _, c := range b.Clickable.History() {
drawInk(gtx, (widget.Press)(c))
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min = min
return layout.Center.Layout(gtx, func(gtx C) D {
return b.Style.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
colMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: b.Style.Color}.Add(gtx.Ops)
return widget.Label{Alignment: text.Middle}.Layout(gtx, b.Theme.Material.Shaper, b.Style.Font, b.Style.TextSize, b.Text, colMacro.Stop())
})
})
},
)
})
}
func (b *ActionButton) Layout(gtx C) D {
for b.Clickable.Clicked(gtx) {
b.Action.Do()
}
if !b.Action.Enabled() {
b.Style = b.DisabledStyle
}
return b.Button.Layout(gtx)
}
func (b *ToggleButton) Layout(gtx C) D {
for b.Clickable.Clicked(gtx) {
b.Bool.Toggle()
}
ret := material.Button(th, &b.Clickable, text)
ret.Background = transparent
ret.Inset = layout.UniformInset(unit.Dp(6))
if b.Bool.Value() {
ret.Color = th.Palette.ContrastFg
ret.Background = th.Palette.Fg
} else {
ret.Color = th.Palette.Fg
ret.Background = transparent
if !b.Bool.Enabled() {
b.Style = b.DisabledStyle
} else if !b.Bool.Value() {
b.Style = b.OffStyle
}
return ret
return b.Button.Layout(gtx)
}
func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {
ret := material.Button(th, w, text)
ret.Color = th.Palette.Fg
ret.Background = transparent
ret.Inset = layout.UniformInset(unit.Dp(6))
return ret
func (b *IconButton) Layout(gtx C) D {
if b.Tip != "" {
return b.Clickable.TipArea.Layout(gtx, Tooltip(b.Theme, b.Tip), b.actualLayout)
}
return b.actualLayout(gtx)
}
func HighEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {
ret := material.Button(th, w, text)
ret.Color = th.Palette.ContrastFg
ret.Background = th.Palette.Fg
ret.Inset = layout.UniformInset(unit.Dp(6))
return ret
func (b *IconButton) actualLayout(gtx C) D {
m := op.Record(gtx.Ops)
dims := b.Clickable.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rr := (gtx.Constraints.Min.X + gtx.Constraints.Min.Y) / 4
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
background := b.Style.Background
switch {
case b.Clickable.Hovered():
background = hoveredColor(background)
}
paint.Fill(gtx.Ops, background)
for _, c := range b.Clickable.History() {
drawInk(gtx, (widget.Press)(c))
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return b.Style.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
size := gtx.Dp(b.Style.Size)
if b.Icon != nil {
gtx.Constraints.Min = image.Point{X: size}
b.Icon.Layout(gtx, b.Style.Color)
}
return layout.Dimensions{
Size: image.Point{X: size, Y: size},
}
})
},
)
})
c := m.Stop()
bounds := image.Rectangle{Max: dims.Size}
defer clip.Ellipse(bounds).Push(gtx.Ops).Pop()
c.Add(gtx.Ops)
return dims
}
func (b *ActionIconButton) Layout(gtx C) D {
for b.Clickable.Clicked(gtx) {
b.Action.Do()
}
if !b.Action.Enabled() {
b.Style = b.DisabledStyle
}
return b.IconButton.Layout(gtx)
}
func (b *ToggleIconButton) Layout(gtx C) D {
for b.Clickable.Clicked(gtx) {
b.Bool.Toggle()
}
if !b.Bool.Enabled() {
b.Style = b.DisabledStyle
}
if !b.Bool.Value() {
b.Icon = b.OffIcon
b.Tip = b.OffTip
}
return b.IconButton.Layout(gtx)
}
func (b *TabButton) Layout(gtx C) D {
return layout.Stack{Alignment: layout.S}.Layout(gtx,
layout.Stacked(b.ToggleButton.Layout),
layout.Expanded(func(gtx C) D {
if !b.ToggleButton.Bool.Value() {
return D{}
}
w := gtx.Constraints.Min.X
h := gtx.Dp(b.IndicatorHeight)
r := clip.RRect{
Rect: image.Rect(0, 0, w, h),
NE: h, NW: h, SE: 0, SW: 0,
}
defer r.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, b.IndicatorColor)
return layout.Dimensions{Size: image.Pt(w, h)}
}),
)
}
// Click executes a simple programmatic click.
func (b *Clickable) Click() {
b.requestClicks++
}
// Clicked calls Update and reports whether a click was registered.
func (b *Clickable) Clicked(gtx layout.Context) bool {
return b.clicked(b, gtx)
}
func (b *Clickable) clicked(t event.Tag, gtx layout.Context) bool {
_, clicked := b.update(t, gtx)
return clicked
}
// Hovered reports whether a pointer is over the element.
func (b *Clickable) Hovered() bool {
return b.click.Hovered()
}
// Pressed reports whether a pointer is pressing the element.
func (b *Clickable) Pressed() bool {
return b.click.Pressed()
}
// History is the past pointer presses useful for drawing markers.
// History is retained for a short duration (about a second).
func (b *Clickable) History() []widget.Press {
return b.history
}
// Layout and update the button state.
func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
return b.layout(b, gtx, w)
}
func (b *Clickable) layout(t event.Tag, gtx layout.Context, w layout.Widget) layout.Dimensions {
for {
_, ok := b.update(t, gtx)
if !ok {
break
}
}
m := op.Record(gtx.Ops)
dims := w(gtx)
c := m.Stop()
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops)
b.click.Add(gtx.Ops)
event.Op(gtx.Ops, t)
c.Add(gtx.Ops)
return dims
}
// Update the button state by processing events, and return the next
// click, if any.
func (b *Clickable) Update(gtx layout.Context) (widget.Click, bool) {
return b.update(b, gtx)
}
func (b *Clickable) update(_ event.Tag, gtx layout.Context) (widget.Click, bool) {
for len(b.history) > 0 {
c := b.history[0]
if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second {
break
}
n := copy(b.history, b.history[1:])
b.history = b.history[:n]
}
if c := b.requestClicks; c > 0 {
b.requestClicks = 0
return widget.Click{
NumClicks: c,
}, true
}
for {
e, ok := b.click.Update(gtx.Source)
if !ok {
break
}
switch e.Kind {
case gesture.KindClick:
if l := len(b.history); l > 0 {
b.history[l-1].End = gtx.Now
}
return widget.Click{
Modifiers: e.Modifiers,
NumClicks: e.NumClicks,
}, true
case gesture.KindCancel:
for i := range b.history {
b.history[i].Cancelled = true
if b.history[i].End.IsZero() {
b.history[i].End = gtx.Now
}
}
case gesture.KindPress:
b.history = append(b.history, widget.Press{
Position: e.Position,
Start: gtx.Now,
})
}
}
return widget.Click{}, false
}
func drawInk(gtx layout.Context, c widget.Press) {
// duration is the number of seconds for the
// completed animation: expand while fading in, then
// out.
const (
expandDuration = float32(0.5)
fadeDuration = float32(0.9)
)
now := gtx.Now
t := float32(now.Sub(c.Start).Seconds())
end := c.End
if end.IsZero() {
// If the press hasn't ended, don't fade-out.
end = now
}
endt := float32(end.Sub(c.Start).Seconds())
// Compute the fade-in/out position in [0;1].
var alphat float32
{
var haste float32
if c.Cancelled {
// If the press was cancelled before the inkwell
// was fully faded in, fast forward the animation
// to match the fade-out.
if h := 0.5 - endt/fadeDuration; h > 0 {
haste = h
}
}
// Fade in.
half1 := t/fadeDuration + haste
if half1 > 0.5 {
half1 = 0.5
}
// Fade out.
half2 := float32(now.Sub(end).Seconds())
half2 /= fadeDuration
half2 += haste
if half2 > 0.5 {
// Too old.
return
}
alphat = half1 + half2
}
// Compute the expand position in [0;1].
sizet := t
if c.Cancelled {
// Freeze expansion of cancelled presses.
sizet = endt
}
sizet /= expandDuration
// Animate only ended presses, and presses that are fading in.
if !c.End.IsZero() || sizet <= 1.0 {
gtx.Execute(op.InvalidateCmd{})
}
if sizet > 1.0 {
sizet = 1.0
}
if alphat > .5 {
// Start fadeout after half the animation.
alphat = 1.0 - alphat
}
// Twice the speed to attain fully faded in at 0.5.
t2 := alphat * 2
// Beziér ease-in curve.
alphaBezier := t2 * t2 * (3.0 - 2.0*t2)
sizeBezier := sizet * sizet * (3.0 - 2.0*sizet)
size := gtx.Constraints.Min.X
if h := gtx.Constraints.Min.Y; h > size {
size = h
}
// Cover the entire constraints min rectangle and
// apply curve values to size and color.
size = int(float32(size) * 2 * float32(math.Sqrt(2)) * sizeBezier)
alpha := 0.7 * alphaBezier
const col = 0.8
ba, bc := byte(alpha*0xff), byte(col*0xff)
rgba := color.NRGBA{A: 0xff, R: bc, G: bc, B: bc}
rgba.A = uint8(uint32(rgba.A) * uint32(ba) / 0xFF)
ink := paint.ColorOp{Color: rgba}
ink.Add(gtx.Ops)
rr := size / 2
defer op.Offset(c.Position.Add(image.Point{
X: -rr,
Y: -rr,
})).Push(gtx.Ops).Pop()
defer clip.UniformRRect(image.Rectangle{Max: image.Pt(size, size)}, rr).Push(gtx.Ops).Pop()
paint.PaintOp{}.Add(gtx.Ops)
}
func hoveredColor(c color.NRGBA) (h color.NRGBA) {
if c.A == 0 {
// Provide a reasonable default for transparent widgets.
return color.NRGBA{A: 0x44, R: 0x88, G: 0x88, B: 0x88}
}
const ratio = 0x20
m := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: c.A}
if int(c.R)+int(c.G)+int(c.B) > 384 {
m = color.NRGBA{A: c.A}
}
return mix(m, c, ratio)
}
// mix mixes c1 and c2 weighted by (1 - a/256) and a/256 respectively.
func mix(c1, c2 color.NRGBA, a uint8) color.NRGBA {
ai := int(a)
return color.NRGBA{
R: byte((int(c1.R)*ai + int(c2.R)*(256-ai)) / 256),
G: byte((int(c1.G)*ai + int(c2.G)*(256-ai)) / 256),
B: byte((int(c1.B)*ai + int(c2.B)*(256-ai)) / 256),
A: byte((int(c1.A)*ai + int(c2.A)*(256-ai)) / 256),
}
}

View File

@ -1,68 +1,147 @@
package gioui
import (
"gioui.org/io/event"
"fmt"
"image/color"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type Dialog struct {
BtnAlt *ActionClickable
BtnOk *ActionClickable
BtnCancel *ActionClickable
tag bool
keyFilters []event.Filter
}
const DIALOG_MAX_BTNS = 3
type DialogStyle struct {
dialog *Dialog
Title string
Text string
Inset layout.Inset
TextInset layout.Inset
AltStyle material.ButtonStyle
OkStyle material.ButtonStyle
CancelStyle material.ButtonStyle
Shaper *text.Shaper
}
type (
// DialogState is the state that needs to be retained between frames
DialogState struct {
Clickables [DIALOG_MAX_BTNS]widget.Clickable
func NewDialog(ok, alt, cancel tracker.Action) *Dialog {
ret := &Dialog{
BtnOk: NewActionClickable(ok),
BtnAlt: NewActionClickable(alt),
BtnCancel: NewActionClickable(cancel),
visible bool // this is used to control the visibility of the dialog
}
// DialogStyle is the style for a dialog that is store in the theme.yml
DialogStyle struct {
TitleInset layout.Inset
TextInset layout.Inset
ButtonStyle ButtonStyle
Title LabelStyle
Text LabelStyle
Bg color.NRGBA
Buttons ButtonStyle
}
// Dialog is the widget with a Layout method that can be used to display a dialog.
Dialog struct {
Theme *Theme
State *DialogState
Style *DialogStyle
Btns [DIALOG_MAX_BTNS]DialogButton
NumBtns int
Title string
Text string
}
DialogButton struct {
Text string
Action tracker.Action
}
)
func MakeDialog(th *Theme, d *DialogState, title, text string, btns ...DialogButton) Dialog {
ret := Dialog{
Theme: th,
Style: &th.Dialog,
State: d,
Title: title,
Text: text,
}
if len(btns) > DIALOG_MAX_BTNS {
panic(fmt.Sprintf("too many buttons for dialog: %d, max is %d", len(btns), DIALOG_MAX_BTNS))
}
copy(ret.Btns[:], btns)
ret.NumBtns = len(btns)
d.visible = true
return ret
}
func ConfirmDialog(gtx C, th *material.Theme, dialog *Dialog, title, text string) DialogStyle {
ret := DialogStyle{
dialog: dialog,
Title: title,
Text: text,
Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)},
TextInset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12)},
AltStyle: ActionButton(gtx, th, dialog.BtnAlt, "Alt"),
OkStyle: ActionButton(gtx, th, dialog.BtnOk, "Ok"),
CancelStyle: ActionButton(gtx, th, dialog.BtnCancel, "Cancel"),
Shaper: th.Shaper,
}
return ret
func DialogBtn(text string, action tracker.Action) DialogButton {
return DialogButton{Text: text, Action: action}
}
func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *ActionClickable) {
func (d *Dialog) Layout(gtx C) D {
anyFocused := false
for i := 0; i < d.NumBtns; i++ {
anyFocused = anyFocused || gtx.Focused(&d.State.Clickables[i])
}
if !anyFocused {
gtx.Execute(key.FocusCmd{Tag: &d.State.Clickables[d.NumBtns-1]})
}
d.handleKeys(gtx)
paint.Fill(gtx.Ops, d.Style.Bg)
dims := layout.Center.Layout(gtx, func(gtx C) D {
return Popup(d.Theme, &d.State.visible).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return d.Style.TitleInset.Layout(gtx, Label(d.Theme, &d.Style.Title, d.Title).Layout)
}),
layout.Rigid(func(gtx C) D {
return d.Style.TextInset.Layout(gtx, Label(d.Theme, &d.Style.Text, d.Text).Layout)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, func(gtx C) D {
var fcs [DIALOG_MAX_BTNS]layout.FlexChild
var actBtns [DIALOG_MAX_BTNS]material.ButtonStyle
for i := 0; i < d.NumBtns; i++ {
actBtns[i] = material.Button(&d.Theme.Material, &d.State.Clickables[i], d.Btns[i].Text)
actBtns[i].Background = d.Style.Buttons.Background
actBtns[i].Color = d.Style.Buttons.Color
actBtns[i].TextSize = d.Style.Buttons.TextSize
actBtns[i].Font = d.Style.Buttons.Font
actBtns[i].Inset = d.Style.Buttons.Inset
actBtns[i].CornerRadius = d.Style.Buttons.CornerRadius
}
// putting this inside these inside the for loop
// cause heap escapes, so that's why this ugliness;
// remember to update if you change the
// DIAOLG_MAX_BTNS constant
fcs[0] = layout.Rigid(actBtns[0].Layout)
fcs[1] = layout.Rigid(actBtns[1].Layout)
fcs[2] = layout.Rigid(actBtns[2].Layout)
gtx.Constraints.Min.Y = gtx.Dp(d.Style.Buttons.Height)
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, fcs[:d.NumBtns]...)
})
}),
)
})
})
if !d.State.visible {
d.Btns[d.NumBtns-1].Action.Do()
}
return dims
}
func (d *Dialog) handleKeys(gtx C) {
for i := 0; i < d.NumBtns; i++ {
for d.State.Clickables[i].Clicked(gtx) {
d.Btns[i].Action.Do()
}
d.handleKeysForButton(gtx, (i+d.NumBtns-1)%d.NumBtns, i, (i+1)%d.NumBtns)
}
}
func (d *Dialog) handleKeysForButton(gtx C, prev, cur, next int) {
cPrev := &d.State.Clickables[prev]
cCur := &d.State.Clickables[cur]
cNext := &d.State.Clickables[next]
for {
e, ok := gtx.Event(
key.Filter{Focus: &btn.Clickable, Name: key.NameLeftArrow},
key.Filter{Focus: &btn.Clickable, Name: key.NameRightArrow},
key.Filter{Focus: &btn.Clickable, Name: key.NameEscape},
key.Filter{Focus: &btn.Clickable, Name: key.NameTab, Optional: key.ModShift},
key.Filter{Focus: cCur, Name: key.NameLeftArrow},
key.Filter{Focus: cCur, Name: key.NameRightArrow},
key.Filter{Focus: cCur, Name: key.NameEscape},
key.Filter{Focus: cCur, Name: key.NameTab, Optional: key.ModShift},
)
if !ok {
break
@ -70,61 +149,12 @@ func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *ActionClickable) {
if e, ok := e.(key.Event); ok && e.State == key.Press {
switch {
case e.Name == key.NameLeftArrow || (e.Name == key.NameTab && e.Modifiers.Contain(key.ModShift)):
gtx.Execute(key.FocusCmd{Tag: &prev.Clickable})
gtx.Execute(key.FocusCmd{Tag: cPrev})
case e.Name == key.NameRightArrow || (e.Name == key.NameTab && !e.Modifiers.Contain(key.ModShift)):
gtx.Execute(key.FocusCmd{Tag: &next.Clickable})
gtx.Execute(key.FocusCmd{Tag: cNext})
case e.Name == key.NameEscape:
d.BtnCancel.Action.Do()
d.Btns[d.NumBtns-1].Action.Do() // last button is always the cancel button
}
}
}
}
func (d *Dialog) handleKeys(gtx C) {
if d.BtnAlt.Action.Allowed() {
d.handleKeysForButton(gtx, d.BtnAlt, d.BtnCancel, d.BtnOk)
d.handleKeysForButton(gtx, d.BtnCancel, d.BtnOk, d.BtnAlt)
d.handleKeysForButton(gtx, d.BtnOk, d.BtnAlt, d.BtnCancel)
} else {
d.handleKeysForButton(gtx, d.BtnOk, d.BtnCancel, d.BtnCancel)
d.handleKeysForButton(gtx, d.BtnCancel, d.BtnOk, d.BtnOk)
}
}
func (d *DialogStyle) Layout(gtx C) D {
if !gtx.Source.Focused(&d.dialog.BtnOk.Clickable) && !gtx.Source.Focused(&d.dialog.BtnCancel.Clickable) && !gtx.Source.Focused(&d.dialog.BtnAlt.Clickable) {
gtx.Execute(key.FocusCmd{Tag: &d.dialog.BtnCancel.Clickable})
}
d.dialog.handleKeys(gtx)
paint.Fill(gtx.Ops, dialogBgColor)
text := func(gtx C) D {
return d.TextInset.Layout(gtx, LabelStyle{Text: d.Text, Color: highEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(14), Shaper: d.Shaper}.Layout)
}
visible := true
return layout.Center.Layout(gtx, func(gtx C) D {
return Popup(&visible).Layout(gtx, func(gtx C) D {
return d.Inset.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(Label(d.Title, highEmphasisTextColor, d.Shaper)),
layout.Rigid(text),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(120))
if d.dialog.BtnAlt.Action.Allowed() {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.AltStyle.Layout),
layout.Rigid(d.CancelStyle.Layout),
)
}
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.CancelStyle.Layout),
)
})
}),
)
})
})
})
}

View File

@ -15,8 +15,6 @@ import (
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
@ -29,32 +27,28 @@ type DragList struct {
dragID pointer.ID
tags []bool
swapped bool
focused bool
requestFocus bool
}
type FilledDragListStyle struct {
dragList *DragList
HoverColor color.NRGBA
SelectedColor color.NRGBA
CursorColor color.NRGBA
ScrollBarWidth unit.Dp
element, bg func(gtx C, i int) D
dragList *DragList
HoverColor color.NRGBA
Cursor CursorStyle
Selection CursorStyle
ScrollBar ScrollBarStyle
}
func NewDragList(model tracker.List, axis layout.Axis) *DragList {
return &DragList{TrackerList: model, List: &layout.List{Axis: axis}, HoverItem: -1, ScrollBar: &ScrollBar{Axis: axis}}
}
func FilledDragList(th *material.Theme, dragList *DragList, element, bg func(gtx C, i int) D) FilledDragListStyle {
func FilledDragList(th *Theme, dragList *DragList) FilledDragListStyle {
return FilledDragListStyle{
dragList: dragList,
element: element,
bg: bg,
HoverColor: dragListHoverColor,
SelectedColor: dragListSelectedColor,
CursorColor: cursorColor,
ScrollBarWidth: unit.Dp(10),
dragList: dragList,
HoverColor: hoveredColor(th.Selection.Active),
Cursor: th.Cursor,
Selection: th.Selection,
ScrollBar: th.ScrollBar,
}
}
@ -62,15 +56,11 @@ func (d *DragList) Focus() {
d.requestFocus = true
}
func (d *DragList) Focused() bool {
return d.focused
}
func (s FilledDragListStyle) LayoutScrollBar(gtx C) D {
return s.dragList.ScrollBar.Layout(gtx, s.ScrollBarWidth, s.dragList.TrackerList.Count(), &s.dragList.List.Position)
return s.dragList.ScrollBar.Layout(gtx, &s.ScrollBar, s.dragList.TrackerList.Count(), &s.dragList.List.Position)
}
func (s FilledDragListStyle) Layout(gtx C) D {
func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D {
swap := 0
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
@ -119,12 +109,13 @@ func (s FilledDragListStyle) Layout(gtx C) D {
}
switch ke := event.(type) {
case key.FocusEvent:
s.dragList.focused = ke.Focus
if !s.dragList.focused {
if !ke.Focus {
s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected())
} else {
s.dragList.EnsureVisible(s.dragList.TrackerList.Selected())
}
case key.Event:
if !s.dragList.focused || ke.State != key.Press {
if ke.State != key.Press {
break
}
s.dragList.command(gtx, ke)
@ -137,7 +128,7 @@ func (s FilledDragListStyle) Layout(gtx C) D {
gtx.Execute(op.InvalidateCmd{})
}
_, isMutable := s.dragList.TrackerList.ListData.(tracker.MutableListData)
isMutable := s.dragList.TrackerList.Mutable()
listElem := func(gtx C, index int) D {
for len(s.dragList.tags) <= index {
@ -146,13 +137,17 @@ func (s FilledDragListStyle) Layout(gtx C) D {
cursorBg := func(gtx C) D {
var color color.NRGBA
if s.dragList.TrackerList.Selected() == index {
if s.dragList.focused {
color = s.CursorColor
if gtx.Focused(s.dragList) {
color = s.Cursor.Active
} else {
color = s.SelectedColor
color = s.Cursor.Inactive
}
} else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) {
color = s.SelectedColor
if gtx.Focused(s.dragList) {
color = s.Selection.Active
} else {
color = s.Selection.Inactive
}
} else if s.dragList.HoverItem == index {
color = s.HoverColor
}
@ -194,7 +189,7 @@ func (s FilledDragListStyle) Layout(gtx C) D {
area.Pop()
if index == s.dragList.TrackerList.Selected() && isMutable {
for {
target := &s.dragList.focused
target := &s.dragList.drag
if s.dragList.drag {
target = nil
}
@ -234,18 +229,18 @@ func (s FilledDragListStyle) Layout(gtx C) D {
}
}
area := clip.Rect(rect).Push(gtx.Ops)
event.Op(gtx.Ops, &s.dragList.focused)
event.Op(gtx.Ops, &s.dragList.drag)
pointer.CursorGrab.Add(gtx.Ops)
area.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}
macro := op.Record(gtx.Ops)
dims := s.element(gtx, index)
dims := element(gtx, index)
call := macro.Stop()
gtx.Constraints.Min = dims.Size
if s.bg != nil {
s.bg(gtx, index)
if bg != nil {
bg(gtx, index)
}
cursorBg(gtx)
call.Add(gtx.Ops)
@ -357,17 +352,3 @@ func (l *DragList) CenterOn(item int) {
func between(a, b, c int) bool {
return (a <= b && b <= c) || (c <= b && b <= a)
}
func intMax(a, b int) int {
if a > b {
return a
}
return b
}
func intMin(a, b int) int {
if a < b {
return a
}
return b
}

114
tracker/gioui/editor.go Normal file
View File

@ -0,0 +1,114 @@
package gioui
import (
"image/color"
"gioui.org/font"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type (
// Editor wraps a widget.Editor and adds some additional key event filters,
// to prevent key presses from flowing through to the rest of the
// application while editing (particularly: to prevent triggering notes
// while editing).
Editor struct {
widgetEditor widget.Editor
filters []event.Filter
requestFocus bool
}
EditorStyle struct {
Color color.NRGBA
HintColor color.NRGBA
Font font.Font
TextSize unit.Sp
}
EditorEvent int
)
const (
EditorEventNone EditorEvent = iota
EditorEventSubmit
EditorEventCancel
)
func NewEditor(singleLine, submit bool, alignment text.Alignment) *Editor {
ret := &Editor{widgetEditor: widget.Editor{SingleLine: singleLine, Submit: submit, Alignment: alignment}}
for c := 'A'; c <= 'Z'; c++ {
ret.filters = append(ret.filters, key.Filter{Name: key.Name(c), Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
}
for c := '0'; c <= '9'; c++ {
ret.filters = append(ret.filters, key.Filter{Name: key.Name(c), Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
}
ret.filters = append(ret.filters, key.Filter{Name: key.NameSpace, Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
ret.filters = append(ret.filters, key.Filter{Name: key.NameEscape, Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
return ret
}
func (s *EditorStyle) AsLabelStyle() LabelStyle {
return LabelStyle{
Color: s.Color,
Font: s.Font,
TextSize: s.TextSize,
}
}
func (e *Editor) Layout(gtx C, str tracker.String, th *Theme, style *EditorStyle, hint string) D {
for e.Update(gtx, str) != EditorEventNone {
// just consume all events if the user did not consume them
}
if e.widgetEditor.Text() != str.Value() {
e.widgetEditor.SetText(str.Value())
l := len(e.widgetEditor.Text())
e.widgetEditor.SetCaret(l, l)
}
me := material.Editor(&th.Material, &e.widgetEditor, hint)
me.Font = style.Font
me.TextSize = style.TextSize
me.Color = style.Color
me.HintColor = style.HintColor
return me.Layout(gtx)
}
func (e *Editor) Update(gtx C, str tracker.String) EditorEvent {
if e.requestFocus {
e.requestFocus = false
gtx.Execute(key.FocusCmd{Tag: &e.widgetEditor})
l := len(e.widgetEditor.Text())
e.widgetEditor.SetCaret(l, l)
}
for {
ev, ok := e.widgetEditor.Update(gtx)
if !ok {
break
}
if _, ok := ev.(widget.ChangeEvent); ok {
str.SetValue(e.widgetEditor.Text())
}
if _, ok := ev.(widget.SubmitEvent); ok {
return EditorEventSubmit
}
}
for {
event, ok := gtx.Event(e.filters...)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape {
return EditorEventCancel
}
}
return EditorEventNone
}
func (e *Editor) Focus() {
e.requestFocus = true
}

91
tracker/gioui/focus.go Normal file
View File

@ -0,0 +1,91 @@
package gioui
import (
"math"
"gioui.org/io/event"
"gioui.org/io/key"
)
type TagYieldFunc func(level int, tag event.Tag) bool
type Tagged interface {
Tags(level int, yield TagYieldFunc) bool
}
// FocusNext navigates to the next focusable tag in the tracker. If stepInto is
// true, it will focus the next tag regardless of its depth; otherwise it will
// focus the next tag at the current level or shallower.
func (t *Tracker) FocusNext(gtx C, stepInto bool) {
_, next := t.findPrevNext(gtx, stepInto)
if next != nil {
gtx.Execute(key.FocusCmd{Tag: next})
}
}
// FocusPrev navigates to the previous focusable tag in the tracker. If stepInto
// is true, it will focus the previous tag regardless of its depth; otherwise it
// will focus the previous tag at the current level or shallower.
func (t *Tracker) FocusPrev(gtx C, stepInto bool) {
prev, _ := t.findPrevNext(gtx, stepInto)
if prev != nil {
gtx.Execute(key.FocusCmd{Tag: prev})
}
}
func (t *Tracker) findPrevNext(gtx C, stepInto bool) (prev, next event.Tag) {
var first, last event.Tag
found := false
maxLevel := math.MaxInt
if !stepInto {
if level, ok := t.findFocusedLevel(gtx); ok {
maxLevel = level // limit to the current focused tag's level
}
}
t.Tags(0, func(l int, t event.Tag) bool {
if l > maxLevel || t == nil {
return true // skip tags that are too deep or nils
}
if first == nil {
first = t
}
if found && next == nil {
next = t
}
if gtx.Focused(t) {
found = true
}
if !found {
prev = t
}
last = t
return true
})
if next == nil {
next = first
}
if prev == nil {
prev = last
}
return prev, next
}
func (t *Tracker) findFocusedLevel(gtx C) (level int, ok bool) {
t.Tags(0, func(l int, t event.Tag) bool {
if gtx.Focused(t) {
level = l
ok = true
return false // stop when we find the focused tag
}
return true // continue searching
})
return level, ok
}
func firstTag(t Tagged) (tag event.Tag, ok bool) {
t.Tags(0, func(level int, t event.Tag) bool {
tag = t
ok = true
return false
})
return tag, ok
}

View File

@ -1,22 +0,0 @@
package gioui
import (
"log"
"gioui.org/widget"
)
var iconCache = map[*byte]*widget.Icon{}
// widgetForIcon returns a widget for IconVG data, but caching the results
func widgetForIcon(icon []byte) *widget.Icon {
if widget, ok := iconCache[&icon[0]]; ok {
return widget
}
widget, err := widget.NewIcon(icon)
if err != nil {
log.Fatal(err)
}
iconCache[&icon[0]] = widget
return widget
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,211 @@
package gioui
import (
"image"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
InstrumentPresets struct {
searchEditor *Editor
gmDlsBtn *Clickable
userPresetsBtn *Clickable
builtinPresetsBtn *Clickable
clearSearchBtn *Clickable
saveUserPreset *Clickable
deleteUserPreset *Clickable
dirList *DragList
resultList *DragList
}
)
func NewInstrumentPresets(m *tracker.Model) *InstrumentPresets {
return &InstrumentPresets{
searchEditor: NewEditor(true, true, text.Start),
gmDlsBtn: new(Clickable),
clearSearchBtn: new(Clickable),
userPresetsBtn: new(Clickable),
builtinPresetsBtn: new(Clickable),
saveUserPreset: new(Clickable),
deleteUserPreset: new(Clickable),
dirList: NewDragList(m.Preset().DirList(), layout.Vertical),
resultList: NewDragList(m.Preset().SearchResultList(), layout.Vertical),
}
}
func (ip *InstrumentPresets) Tags(level int, yield TagYieldFunc) bool {
return yield(level, &ip.searchEditor.widgetEditor) &&
yield(level+1, ip.clearSearchBtn) &&
yield(level+1, ip.builtinPresetsBtn) &&
yield(level+1, ip.userPresetsBtn) &&
yield(level+1, ip.gmDlsBtn) &&
yield(level, ip.dirList) &&
yield(level, ip.resultList) &&
yield(level+1, ip.saveUserPreset) &&
yield(level+1, ip.deleteUserPreset)
}
func (ip *InstrumentPresets) update(gtx C) {
for {
event, ok := gtx.Event(
key.Filter{Focus: ip.resultList, Name: key.NameLeftArrow},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameLeftArrow:
ip.dirList.Focus()
}
}
}
for {
event, ok := gtx.Event(
key.Filter{Focus: ip.dirList, Name: key.NameRightArrow},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameRightArrow:
ip.resultList.Focus()
}
}
}
}
func (ip *InstrumentPresets) layout(gtx C) D {
ip.update(gtx)
// get tracker from values
tr := TrackerFromContext(gtx)
gmDlsBtn := ToggleBtn(tr.Preset().NoGmDls(), tr.Theme, ip.gmDlsBtn, "No gm.dls", "Exclude presets using gm.dls")
userPresetsFilterBtn := ToggleBtn(tr.Preset().UserFilter(), tr.Theme, ip.userPresetsBtn, "User", "Show only user presets")
builtinPresetsFilterBtn := ToggleBtn(tr.Preset().BuiltinFilter(), tr.Theme, ip.builtinPresetsBtn, "Builtin", "Show only builtin presets")
saveUserPresetBtn := ActionIconBtn(tr.Preset().Save(), tr.Theme, ip.saveUserPreset, icons.ContentSave, "Save instrument as user preset")
deleteUserPresetBtn := ActionIconBtn(tr.Preset().Delete(), tr.Theme, ip.deleteUserPreset, icons.ActionDelete, "Delete user preset")
dirElem := func(gtx C, i int) D {
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Directory, tr.Model.Preset().Dir(i)).Layout(gtx)
}
dirs := func(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(140), gtx.Constraints.Max.Y))
fdl := FilledDragList(tr.Theme, ip.dirList)
dims := fdl.Layout(gtx, dirElem, nil)
fdl.LayoutScrollBar(gtx)
return dims
}
dirSurface := func(gtx C) D {
return Surface{Height: 5, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, dirs)
}
resultElem := func(gtx C, i int) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
n, d, u := tr.Model.Preset().SearchResult(i)
if u {
ln := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.User, n)
ld := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.UserDir, d)
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(ln.Layout),
layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(ld.Layout),
)
}
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.Builtin, n).Layout(gtx)
}
floatButtons := func(gtx C) D {
if tr.Model.Preset().Delete().Enabled() {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(deleteUserPresetBtn.Layout),
layout.Rigid(saveUserPresetBtn.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(saveUserPresetBtn.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
)
}
results := func(gtx C) D {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
fdl := FilledDragList(tr.Theme, ip.resultList)
dims := fdl.Layout(gtx, resultElem, nil)
layout.SE.Layout(gtx, floatButtons)
fdl.LayoutScrollBar(gtx)
return dims
}
resultSurface := func(gtx C) D {
return Surface{Height: 4, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, results)
}
bottom := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(dirSurface),
layout.Flexed(1, resultSurface),
)
}
// layout
f := func(gtx C) D {
m := gtx.Constraints.Max
gtx.Constraints.Max.X = min(gtx.Dp(360), gtx.Constraints.Max.X)
layout.Flex{Axis: layout.Vertical, Alignment: layout.Start}.Layout(gtx,
layout.Rigid(ip.layoutSearch),
layout.Rigid(func(gtx C) D {
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(userPresetsFilterBtn.Layout),
layout.Rigid(builtinPresetsFilterBtn.Layout),
layout.Rigid(gmDlsBtn.Layout),
)
})
}),
layout.Rigid(bottom),
)
return D{Size: m}
}
return Surface{Height: 3, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, f)
}
func (ip *InstrumentPresets) layoutSearch(gtx C) D {
// draw search icon on left and clear button on right
// return ip.searchEditor.Layout(gtx, tr.Model.PresetSearchString(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Search presets")
tr := TrackerFromContext(gtx)
bg := func(gtx C) D {
rr := gtx.Dp(18)
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, tr.Theme.InstrumentEditor.Presets.SearchBg)
return D{Size: gtx.Constraints.Min}
}
// icon, search editor, clear button
icon := func(gtx C) D {
return tr.Theme.IconButton.Enabled.Inset.Layout(gtx, func(gtx C) D {
return tr.Theme.Icon(icons.ActionSearch).Layout(gtx, tr.Theme.Material.Fg)
})
}
ed := func(gtx C) D {
return ip.searchEditor.Layout(gtx, tr.Preset().SearchTerm(), tr.Theme, &tr.Theme.InstrumentEditor.UnitComment, "Search presets")
}
clr := func(gtx C) D {
btn := ActionIconBtn(tr.Preset().ClearSearch(), tr.Theme, ip.clearSearchBtn, icons.ContentClear, "Clear search")
return btn.Layout(gtx)
}
w := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(icon),
layout.Flexed(1, ed),
layout.Rigid(clr),
)
}
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Expanded(bg),
layout.Stacked(w),
)
})
}

View File

@ -0,0 +1,123 @@
package gioui
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
InstrumentProperties struct {
nameEditor *Editor
commentEditor *Editor
list *layout.List
soloBtn *Clickable
muteBtn *Clickable
threadBtns [4]*Clickable
soloHint string
unsoloHint string
muteHint string
unmuteHint string
voices *NumericUpDownState
splitInstrumentBtn *Clickable
splitInstrumentHint string
}
)
func NewInstrumentProperties() *InstrumentProperties {
ret := &InstrumentProperties{
list: &layout.List{Axis: layout.Vertical},
nameEditor: NewEditor(true, true, text.Start),
commentEditor: NewEditor(false, false, text.Start),
soloBtn: new(Clickable),
muteBtn: new(Clickable),
voices: NewNumericUpDownState(),
splitInstrumentBtn: new(Clickable),
threadBtns: [4]*Clickable{new(Clickable), new(Clickable), new(Clickable), new(Clickable)},
}
ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle")
ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle")
ret.muteHint = makeHint("Mute", " (%s)", "MuteToggle")
ret.unmuteHint = makeHint("Unmute", " (%s)", "MuteToggle")
ret.splitInstrumentHint = makeHint("Split instrument", " (%s)", "SplitInstrument")
return ret
}
func (ip *InstrumentProperties) Tags(level int, yield TagYieldFunc) bool {
return yield(level, &ip.commentEditor.widgetEditor)
}
// layout
func (ip *InstrumentProperties) layout(gtx C) D {
// get tracker from values
tr := TrackerFromContext(gtx)
voiceLine := func(gtx C) D {
splitInstrumentBtn := ActionIconBtn(tr.Instrument().Split(), tr.Theme, ip.splitInstrumentBtn, icons.CommunicationCallSplit, ip.splitInstrumentHint)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
instrumentVoices := NumUpDown(tr.Model.Instrument().Voices(), tr.Theme, ip.voices, "Number of voices for this instrument")
return instrumentVoices.Layout(gtx)
}),
layout.Rigid(splitInstrumentBtn.Layout),
)
}
thread1btn := ToggleIconBtn(tr.Instrument().Thread1(), tr.Theme, ip.threadBtns[0], icons.ImageCropSquare, icons.ImageFilter1, "Do not render instrument on thread 1", "Render instrument on thread 1")
thread2btn := ToggleIconBtn(tr.Instrument().Thread2(), tr.Theme, ip.threadBtns[1], icons.ImageCropSquare, icons.ImageFilter2, "Do not render instrument on thread 2", "Render instrument on thread 2")
thread3btn := ToggleIconBtn(tr.Instrument().Thread3(), tr.Theme, ip.threadBtns[2], icons.ImageCropSquare, icons.ImageFilter3, "Do not render instrument on thread 3", "Render instrument on thread 3")
thread4btn := ToggleIconBtn(tr.Instrument().Thread4(), tr.Theme, ip.threadBtns[3], icons.ImageCropSquare, icons.ImageFilter4, "Do not render instrument on thread 4", "Render instrument on thread 4")
threadbtnline := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(thread1btn.Layout),
layout.Rigid(thread2btn.Layout),
layout.Rigid(thread3btn.Layout),
layout.Rigid(thread4btn.Layout),
)
}
return ip.list.Layout(gtx, 11, func(gtx C, index int) D {
switch index {
case 0:
return layoutInstrumentPropertyLine(gtx, "Name", func(gtx C) D {
return ip.nameEditor.Layout(gtx, tr.Instrument().Name(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Instr")
})
case 2:
return layoutInstrumentPropertyLine(gtx, "Voices", voiceLine)
case 4:
muteBtn := ToggleIconBtn(tr.Instrument().Mute(), tr.Theme, ip.muteBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.muteHint, ip.unmuteHint)
return layoutInstrumentPropertyLine(gtx, "Mute", muteBtn.Layout)
case 6:
soloBtn := ToggleIconBtn(tr.Instrument().Solo(), tr.Theme, ip.soloBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.soloHint, ip.unsoloHint)
return layoutInstrumentPropertyLine(gtx, "Solo", soloBtn.Layout)
case 8:
return layoutInstrumentPropertyLine(gtx, "Thread", threadbtnline)
case 10:
return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D {
return ip.commentEditor.Layout(gtx, tr.Instrument().Comment(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Comment")
})
default: // odd valued list items are dividers
px := max(gtx.Dp(unit.Dp(1)), 1)
paint.FillShape(gtx.Ops, color.NRGBA{255, 255, 255, 3}, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, px)).Op())
return D{Size: image.Pt(gtx.Constraints.Max.X, px)}
}
})
}
func layoutInstrumentPropertyLine(gtx C, text string, content layout.Widget) D {
tr := TrackerFromContext(gtx)
gtx.Constraints.Max.X = min(gtx.Dp(300), gtx.Constraints.Max.X)
label := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, text)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6, Height: 36}.Layout),
layout.Rigid(label.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(content),
)
}

View File

@ -0,0 +1,297 @@
package gioui
import (
"bytes"
_ "embed"
"fmt"
"strconv"
"strings"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v3"
)
type (
KeyAction string
KeyBinding struct {
Key string
Shortcut, Ctrl, Command, Shift, Alt, Super bool
Action string
}
)
var keyBindingMap = map[key.Event]string{}
var keyActionMap = map[KeyAction]string{} // holds an informative string of the first key bound to an action
//go:embed keybindings.yml
var defaultKeyBindings []byte
func init() {
var keyBindings, userKeybindings []KeyBinding
dec := yaml.NewDecoder(bytes.NewReader(defaultKeyBindings))
dec.KnownFields(true)
if err := dec.Decode(&keyBindings); err != nil {
panic(fmt.Errorf("failed to unmarshal default keybindings: %w", err))
}
if err := ReadCustomConfig("keybindings.yml", &userKeybindings); err == nil {
keyBindings = append(keyBindings, userKeybindings...)
}
for _, kb := range keyBindings {
var mods key.Modifiers
if kb.Shortcut {
mods |= key.ModShortcut
}
if kb.Ctrl {
mods |= key.ModCtrl
}
if kb.Command {
mods |= key.ModCommand
}
if kb.Shift {
mods |= key.ModShift
}
if kb.Alt {
mods |= key.ModAlt
}
if kb.Super {
mods |= key.ModSuper
}
keyEvent := key.Event{Name: key.Name(kb.Key), Modifiers: mods, State: key.Press}
action, ok := keyBindingMap[keyEvent] // if this key has been previously bound, remove it from the hint map
if ok {
delete(keyActionMap, KeyAction(action))
}
if kb.Action == "" { // unbind
delete(keyBindingMap, keyEvent)
} else { // bind
keyBindingMap[keyEvent] = kb.Action
// last binding of the some action wins for displaying the hint
modString := strings.Replace(mods.String(), "-", "+", -1)
text := kb.Key
if modString != "" {
text = modString + "+" + text
}
keyActionMap[KeyAction(kb.Action)] = text
}
}
}
func makeHint(hint, format, action string) string {
if keyActionMap[KeyAction(action)] != "" {
return hint + fmt.Sprintf(format, keyActionMap[KeyAction(action)])
}
return hint
}
// KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event, gtx C) {
if e.State == key.Release {
t.KeyNoteMap.Release(e.Name)
return
}
action, ok := keyBindingMap[e]
if !ok {
return
}
switch action {
// Actions
case "AddTrack":
t.Track().Add().Do()
case "DeleteTrack":
t.Track().Delete().Do()
case "AddInstrument":
t.Instrument().Add().Do()
case "DeleteInstrument":
t.Instrument().Delete().Do()
case "AddUnitAfter":
t.Unit().Add(false).Do()
case "AddUnitBefore":
t.Unit().Add(true).Do()
case "DeleteUnit":
t.Unit().Delete().Do()
case "ClearUnit":
t.Unit().Clear().Do()
case "Undo":
t.History().Undo().Do()
case "Redo":
t.History().Redo().Do()
case "AddSemitone":
t.Note().AddSemitone().Do()
case "SubtractSemitone":
t.Note().SubtractSemitone().Do()
case "AddOctave":
t.Note().AddOctave().Do()
case "SubtractOctave":
t.Note().SubtractOctave().Do()
case "EditNoteOff":
t.Note().NoteOff().Do()
case "RemoveUnused":
t.Order().RemoveUnusedPatterns().Do()
case "PlayCurrentPosFollow":
t.Play().IsFollowing().SetValue(true)
t.Play().FromCurrentPos().Do()
case "PlayCurrentPosUnfollow":
t.Play().IsFollowing().SetValue(false)
t.Play().FromCurrentPos().Do()
case "PlaySongStartFollow":
t.Play().IsFollowing().SetValue(true)
t.Play().FromBeginning().Do()
case "PlaySongStartUnfollow":
t.Play().IsFollowing().SetValue(false)
t.Play().FromBeginning().Do()
case "PlaySelectedFollow":
t.Play().IsFollowing().SetValue(true)
t.Play().FromSelected().Do()
case "PlaySelectedUnfollow":
t.Play().IsFollowing().SetValue(false)
t.Play().FromSelected().Do()
case "PlayLoopFollow":
t.Play().IsFollowing().SetValue(true)
t.Play().FromLoopBeginning().Do()
case "PlayLoopUnfollow":
t.Play().IsFollowing().SetValue(false)
t.Play().FromLoopBeginning().Do()
case "StopPlaying":
t.Play().Stop().Do()
case "AddOrderRowBefore":
t.Order().AddRow(true).Do()
case "AddOrderRowAfter":
t.Order().AddRow(false).Do()
case "DeleteOrderRowBackwards":
t.Order().DeleteRow(true).Do()
case "DeleteOrderRowForwards":
t.Order().DeleteRow(false).Do()
case "NewSong":
t.Song().New().Do()
case "OpenSong":
t.Song().Open().Do()
case "Quit":
if canQuit {
t.RequestQuit().Do()
}
case "SaveSong":
t.Song().Save().Do()
case "SaveSongAs":
t.Song().SaveAs().Do()
case "ExportWav":
t.Song().Export().Do()
case "ExportFloat":
t.Song().ExportFloat().Do()
case "ExportInt16":
t.Song().ExportInt16().Do()
case "SplitTrack":
t.Track().Split().Do()
case "SplitInstrument":
t.Instrument().Split().Do()
case "ShowManual":
t.ShowManual().Do()
case "AskHelp":
t.AskHelp().Do()
case "ReportBug":
t.ReportBug().Do()
case "ShowLicense":
t.ShowLicense().Do()
// Booleans
case "PanicToggle":
t.Play().Panicked().Toggle()
case "RecordingToggle":
t.Play().IsRecording().Toggle()
case "PlayingToggleFollow":
t.Play().IsFollowing().SetValue(true)
t.Play().Started().Toggle()
case "PlayingToggleUnfollow":
t.Play().IsFollowing().SetValue(false)
t.Play().Started().Toggle()
case "InstrEnlargedToggle":
t.Play().TrackerHidden().Toggle()
case "LinkInstrTrackToggle":
t.Track().LinkInstrument().Toggle()
case "FollowToggle":
t.Play().IsFollowing().Toggle()
case "UnitDisabledToggle":
t.Unit().Disabled().Toggle()
case "LoopToggle":
t.Play().IsLooping().Toggle()
case "UniquePatternsToggle":
t.Note().UniquePatterns().Toggle()
case "MuteToggle":
t.Instrument().Mute().Toggle()
case "SoloToggle":
t.Instrument().Solo().Toggle()
// Integers
case "InstrumentVoicesAdd":
t.Instrument().Voices().Add(1)
case "InstrumentVoicesSubtract":
t.Instrument().Voices().Add(-1)
case "TrackVoicesAdd":
t.Track().Voices().Add(1)
case "TrackVoicesSubtract":
t.Track().Voices().Add(-1)
case "SongLengthAdd":
t.Song().Length().Add(1)
case "SongLengthSubtract":
t.Song().Length().Add(-1)
case "BPMAdd":
t.Song().BPM().Add(1)
case "BPMSubtract":
t.Song().BPM().Add(-1)
case "RowsPerPatternAdd":
t.Song().RowsPerPattern().Add(1)
case "RowsPerPatternSubtract":
t.Song().RowsPerPattern().Add(-1)
case "RowsPerBeatAdd":
t.Song().RowsPerBeat().Add(1)
case "RowsPerBeatSubtract":
t.Song().RowsPerBeat().Add(-1)
case "StepAdd":
t.Note().Step().Add(1)
case "StepSubtract":
t.Note().Step().Add(-1)
case "OctaveAdd":
t.Note().Octave().Add(1)
case "OctaveSubtract":
t.Note().Octave().Add(-1)
// Other miscellaneous
case "Paste":
gtx.Execute(clipboard.ReadCmd{Tag: t})
case "OrderEditorFocus":
t.Play().TrackerHidden().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.OrderEditor.scrollTable})
case "TrackEditorFocus":
t.Play().TrackerHidden().SetValue(false)
gtx.Execute(key.FocusCmd{Tag: t.TrackEditor.scrollTable})
case "InstrumentListFocus":
gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.instrList.instrumentDragList})
case "UnitListFocus":
var tag event.Tag
t.PatchPanel.BottomTags(0, func(level int, t event.Tag) bool {
tag = t
return false
})
gtx.Execute(key.FocusCmd{Tag: tag})
case "FocusPrev":
t.FocusPrev(gtx, false)
case "FocusPrevInto":
t.FocusPrev(gtx, true)
case "FocusNext":
t.FocusNext(gtx, false)
case "FocusNextInto":
t.FocusNext(gtx, true)
default:
if len(action) > 4 && action[:4] == "Note" {
val, err := strconv.Atoi(string(action[4:]))
if err != nil {
break
}
instr := t.Model.Instrument().List().Selected()
n := noteAsValue(t.Model.Note().Octave().Value(), val-12)
t.KeyNoteMap.Press(e.Name, tracker.NoteEvent{Channel: instr, Note: n})
}
}
}

View File

@ -0,0 +1,98 @@
# You can place your custom keybindings.yml in the Sointu config directory e.g.
# AppData\Roaming\sointu\keybindings.yml on Windows. There, you can override the
# default keybindings with your own. The format is the same as below. A
# keybinding without any action means unbinding the key. For example, the line
#
# - {key: "A"}
#
# will stop the A key from sending NoteOff events.
- { key: "C", shortcut: true, action: "Copy" }
- { key: "V", shortcut: true, action: "Paste" }
- { key: "A", shortcut: true, action: "SelectAll" }
- { key: "X", shortcut: true, action: "Cut" }
- { key: "Z", shortcut: true, action: "Undo" }
- { key: "Y", shortcut: true, action: "Redo" }
- { key: "D", shortcut: true, action: "UnitDisabledToggle" }
- { key: "L", shortcut: true, action: "LoopToggle" }
- { key: "N", shortcut: true, action: "NewSong" }
- { key: "S", shortcut: true, action: "SaveSong" }
- { key: "M", shortcut: true, action: "MuteToggle" }
- { key: ",", shortcut: true, action: "SoloToggle" }
- { key: "O", shortcut: true, action: "OpenSong" }
- { key: "I", shortcut: true, shift: true, action: "DeleteInstrument" }
- { key: "I", shortcut: true, action: "AddInstrument" }
- { key: "I", shortcut: true, alt: true, action: "SplitInstrument" }
- { key: "T", shortcut: true, shift: true, action: "DeleteTrack" }
- { key: "T", shortcut: true, alt: true, action: "SplitTrack" }
- { key: "T", shortcut: true, action: "AddTrack" }
- { key: "E", shortcut: true, action: "InstrEnlargedToggle" }
- { key: "K", shortcut: true, action: "LinkInstrTrackToggle" }
- { key: "W", shortcut: true, action: "Quit" }
- { key: "Space", action: "PlayingToggleUnfollow" }
- { key: "Space", shift: true, action: "PlayingToggleFollow" }
- { key: "F1", action: "OrderEditorFocus" }
- { key: "F2", action: "TrackEditorFocus" }
- { key: "F3", action: "InstrumentListFocus" }
- { key: "F4", action: "UnitListFocus" }
- { key: "F5", action: "PlayCurrentPosUnfollow" }
- { key: "F5", shift: true, action: "PlayCurrentPosFollow" }
- { key: "F5", shortcut: true, action: "PlaySongStartUnfollow" }
- { key: "F5", shortcut: true, shift: true, action: "PlaySongStartFollow" }
- { key: "F6", action: "PlaySelectedUnfollow" }
- { key: "F6", shift: true, action: "PlaySelectedFollow" }
- { key: "F6", shortcut: true, action: "PlayLoopUnfollow" }
- { key: "F6", shortcut: true, shift: true, action: "PlayLoopFollow" }
- { key: "F7", action: "RecordingToggle" }
- { key: "F8", action: "StopPlaying" }
- { key: "F9", action: "FollowToggle" }
- { key: "F12", action: "PanicToggle" }
- { key: "\\", shift: true, action: "OctaveAdd" }
- { key: "\\", action: "OctaveSubtract" }
- { key: ">", shift: true, action: "OctaveAdd" }
- { key: ">", action: "OctaveSubtract" }
- { key: "<", shift: true, action: "OctaveAdd" }
- { key: "<", action: "OctaveSubtract" }
- { key: "⎋", action: "FocusPrev" } # Esc key
- { key: "Tab", shift: true, action: "FocusPrev" }
- { key: "Tab", shift: true, shortcut: true, action: "FocusPrevInto" }
- { key: "Tab", action: "FocusNext" }
- { key: "Tab", shortcut: true, action: "FocusNextInto" }
- { key: "A", action: "NoteOff" }
- { key: "1", action: "NoteOff" }
- { key: "Z", action: "Note0" }
- { key: "S", action: "Note1" }
- { key: "X", action: "Note2" }
- { key: "D", action: "Note3" }
- { key: "C", action: "Note4" }
- { key: "V", action: "Note5" }
- { key: "G", action: "Note6" }
- { key: "B", action: "Note7" }
- { key: "H", action: "Note8" }
- { key: "N", action: "Note9" }
- { key: "J", action: "Note10" }
- { key: "M", action: "Note11" }
- { key: ",", action: "Note12" }
- { key: "L", action: "Note13" }
- { key: ".", action: "Note14" }
- { key: "Q", action: "Note12" }
- { key: "2", action: "Note13" }
- { key: "W", action: "Note14" }
- { key: "3", action: "Note15" }
- { key: "E", action: "Note16" }
- { key: "R", action: "Note17" }
- { key: "5", action: "Note18" }
- { key: "T", action: "Note19" }
- { key: "6", action: "Note20" }
- { key: "Y", action: "Note21" }
- { key: "7", action: "Note22" }
- { key: "U", action: "Note23" }
- { key: "I", action: "Note24" }
- { key: "9", action: "Note25" }
- { key: "O", action: "Note26" }
- { key: "0", action: "Note27" }
- { key: "P", action: "Note28" }
- { key: "+", action: "Increase" }
- { key: "-", action: "Decrease" }
- { key: "+", shortcut: true, action: "IncreaseMore" } # increase a large step
- { key: "-", shortcut: true, action: "DecreaseMore" } # decrease a large step

58
tracker/gioui/keyboard.go Normal file
View File

@ -0,0 +1,58 @@
package gioui
import (
"time"
"github.com/vsariola/sointu/tracker"
)
type (
// Keyboard is used to associate the keys of a keyboard (e.g. computer or a
// MIDI keyboard) to currently playing notes. You can use any type T to
// identify each key; T should be a comparable type.
Keyboard[T comparable] struct {
broker *tracker.Broker
pressed map[T]tracker.NoteEvent
}
)
func MakeKeyboard[T comparable](broker *tracker.Broker) Keyboard[T] {
return Keyboard[T]{
broker: broker,
pressed: make(map[T]tracker.NoteEvent),
}
}
func (t *Keyboard[T]) Press(key T, ev tracker.NoteEvent) {
if _, ok := t.pressed[key]; ok {
return // already playing a note with this key, do not send a new event
}
t.Release(key) // unset any previous note
if ev.Note > 1 {
ev.Source = t // set the source to this keyboard
ev.On = true
ev.Timestamp = t.now()
if tracker.TrySend(t.broker.ToPlayer, any(ev)) {
t.pressed[key] = ev
}
}
}
func (t *Keyboard[T]) Release(key T) {
if ev, ok := t.pressed[key]; ok {
ev.Timestamp = t.now()
ev.On = false // the pressed contains the event we need to send to release the note
tracker.TrySend(t.broker.ToPlayer, any(ev))
delete(t.pressed, key)
}
}
func (t *Keyboard[T]) ReleaseAll() {
for key := range t.pressed {
t.Release(key)
}
}
func (t *Keyboard[T]) now() int64 {
return time.Now().UnixMilli() * 441 / 10 // convert to 44100Hz frames
}

View File

@ -1,205 +0,0 @@
package gioui
import (
"gioui.org/io/clipboard"
"gioui.org/io/key"
)
var noteMap = map[key.Name]int{
"Z": -12,
"S": -11,
"X": -10,
"D": -9,
"C": -8,
"V": -7,
"G": -6,
"B": -5,
"H": -4,
"N": -3,
"J": -2,
"M": -1,
",": 0,
"L": 1,
".": 2,
"Q": 0,
"2": 1,
"W": 2,
"3": 3,
"E": 4,
"R": 5,
"5": 6,
"T": 7,
"6": 8,
"Y": 9,
"7": 10,
"U": 11,
"I": 12,
"9": 13,
"O": 14,
"0": 15,
"P": 16,
}
// KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event, gtx C) {
if e.State == key.Press {
switch e.Name {
case "V":
if e.Modifiers.Contain(key.ModShortcut) {
gtx.Execute(clipboard.ReadCmd{Tag: t})
return
}
case "Z":
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.Undo().Do()
return
}
case "Y":
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.Redo().Do()
return
}
case "D":
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.UnitDisabled().Bool().Toggle()
return
}
case "L":
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.LoopToggle().Bool().Toggle()
return
}
case "N":
if e.Modifiers.Contain(key.ModShortcut) {
t.NewSong().Do()
return
}
case "S":
if e.Modifiers.Contain(key.ModShortcut) {
t.SaveSong().Do()
return
}
case "O":
if e.Modifiers.Contain(key.ModShortcut) {
t.OpenSong().Do()
return
}
case "I":
if e.Modifiers.Contain(key.ModShortcut) {
if e.Modifiers.Contain(key.ModShift) {
t.DeleteInstrument().Do()
} else {
t.AddInstrument().Do()
}
return
}
case "T":
if e.Modifiers.Contain(key.ModShortcut) {
if e.Modifiers.Contain(key.ModShift) {
t.DeleteTrack().Do()
} else {
t.AddTrack().Do()
}
return
}
case "E":
if e.Modifiers.Contain(key.ModShortcut) {
t.InstrEnlarged().Bool().Toggle()
return
}
case "W":
if e.Modifiers.Contain(key.ModShortcut) && canQuit {
t.Quit().Do()
return
}
case "F1":
t.OrderEditor.scrollTable.Focus()
return
case "F2":
t.TrackEditor.scrollTable.Focus()
return
case "F3":
t.InstrumentEditor.Focus()
return
case "F5":
t.SongPanel.RewindBtn.Action.Do()
t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
return
case "F6", "Space":
t.SongPanel.PlayingBtn.Bool.Toggle()
t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
return
case "F7":
t.SongPanel.RecordBtn.Bool.Toggle()
return
case "F8":
t.SongPanel.NoteTracking.Bool.Toggle()
return
case "F12":
t.Panic().Bool().Toggle()
return
case `\`, `<`, `>`:
if e.Modifiers.Contain(key.ModShift) {
t.OctaveNumberInput.Int.Add(1)
} else {
t.OctaveNumberInput.Int.Add(-1)
}
case key.NameTab:
if e.Modifiers.Contain(key.ModShift) {
switch {
case t.OrderEditor.scrollTable.Focused():
t.InstrumentEditor.unitEditor.sliderList.Focus()
case t.TrackEditor.scrollTable.Focused():
t.OrderEditor.scrollTable.Focus()
case t.InstrumentEditor.Focused():
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.unitEditor.sliderList.Focus()
} else {
t.TrackEditor.scrollTable.Focus()
}
default:
t.InstrumentEditor.Focus()
}
} else {
switch {
case t.OrderEditor.scrollTable.Focused():
t.TrackEditor.scrollTable.Focus()
case t.TrackEditor.scrollTable.Focused():
t.InstrumentEditor.Focus()
case t.InstrumentEditor.Focused():
t.InstrumentEditor.unitEditor.sliderList.Focus()
default:
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.Focus()
} else {
t.OrderEditor.scrollTable.Focus()
}
}
}
}
t.JammingPressed(e)
} else { // e.State == key.Release
t.JammingReleased(e)
}
}
func (t *Tracker) JammingPressed(e key.Event) byte {
if val, ok := noteMap[e.Name]; ok {
if _, ok := t.KeyPlaying[e.Name]; !ok {
n := noteAsValue(t.OctaveNumberInput.Int.Value(), val)
instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected()
t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n)
return n
}
}
return 0
}
func (t *Tracker) JammingReleased(e key.Event) bool {
if noteID, ok := t.KeyPlaying[e.Name]; ok {
noteID.NoteOff()
delete(t.KeyPlaying, e.Name)
return true
}
return false
}

View File

@ -5,7 +5,6 @@ import (
"image/color"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
@ -14,37 +13,39 @@ import (
)
type LabelStyle struct {
Text string
Color color.NRGBA
ShadeColor color.NRGBA
Alignment layout.Direction
Font font.Font
FontSize unit.Sp
Shaper *text.Shaper
Color color.NRGBA
ShadowColor color.NRGBA
Alignment text.Alignment
Font font.Font
TextSize unit.Sp
MaxLines int
}
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
return l.Alignment.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min = image.Point{}
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
type LabelWidget struct {
Text string
Shaper *text.Shaper
LabelStyle
}
func (l LabelWidget) Layout(gtx C) D {
textColorMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
textColor := textColorMacro.Stop()
t := widget.Label{
Alignment: l.Alignment,
MaxLines: l.MaxLines,
}
if l.ShadowColor.A > 0 {
shadowColorMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: l.ShadowColor}.Add(gtx.Ops)
shadowColor := shadowColorMacro.Stop()
offs := op.Offset(image.Pt(2, 2)).Push(gtx.Ops)
widget.Label{
Alignment: text.Start,
MaxLines: 1,
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
t.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, shadowColor)
offs.Pop()
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
dims := widget.Label{
Alignment: text.Start,
MaxLines: 1,
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
return layout.Dimensions{
Size: dims.Size,
Baseline: dims.Baseline,
}
})
}
return t.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, textColor)
}
func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget {
return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout
func Label(th *Theme, style *LabelStyle, txt string) LabelWidget {
return LabelWidget{Text: txt, Shaper: th.Material.Shaper, LabelStyle: *style}
}

View File

@ -5,186 +5,371 @@ import (
"image/color"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type Menu struct {
Visible bool
clickable widget.Clickable
tags []bool
clicks []int
hover int
list layout.List
scrollBar ScrollBar
}
type (
// MenuState is the part of the menu that needs to be retained between frames.
MenuState struct {
tags []bool
hover int
hoverOk bool
list layout.List
scrollBar ScrollBar
type MenuStyle struct {
Menu *Menu
Title string
IconColor color.NRGBA
TextColor color.NRGBA
ShortCutColor color.NRGBA
FontSize unit.Sp
IconSize unit.Dp
HoverColor color.NRGBA
Shaper *text.Shaper
}
tag bool
visible bool
type MenuItem struct {
IconBytes []byte
Text string
ShortcutText string
Doer tracker.Action
}
func (m *Menu) Clicked() (int, bool) {
if len(m.clicks) == 0 {
return 0, false
itemTmp []menuItem
}
first := m.clicks[0]
for i := 1; i < len(m.clicks); i++ {
m.clicks[i-1] = m.clicks[i]
// MenuStyle is the style for a menu that is stored in the theme.yml.
MenuStyle struct {
Text LabelStyle
Shortcut LabelStyle
Disabled color.NRGBA
Hover color.NRGBA
Width unit.Dp
Height unit.Dp
}
m.clicks = m.clicks[:len(m.clicks)-1]
return first, true
// MenuWidget has a Layout method to display a menu
MenuWidget struct {
State *MenuState
Style *MenuStyle
}
)
func Menu(state *MenuState) MenuWidget { return MenuWidget{State: state} }
func (w MenuWidget) WithStyle(style *MenuStyle) MenuWidget { w.Style = style; return w }
func (ms *MenuState) Tags(level int, yield TagYieldFunc) bool {
if ms.visible {
return yield(level, &ms.tag)
}
return true
}
func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
contents := func(gtx C) D {
for i, item := range items {
// make sure we have a tag for every item
for len(m.Menu.tags) <= i {
m.Menu.tags = append(m.Menu.tags, false)
// MenuChild describes one or more menu items; if MenuChild is an Action or
// Bool, it's one item per child, but Ints are treated as enumerations and
// create one item per different possible values of the int.
type MenuChild struct {
Icon []byte
Text string
Shortcut string
kind menuChildKind
action tracker.Action
bool tracker.Bool
int tracker.Int
widget layout.Widget // these should be passive separators and such
}
type menuChildKind int
const (
menuChildAction menuChildKind = iota
menuChildBool
menuChildInt
menuChildList
menuChildWidget
)
func ActionMenuChild(act tracker.Action, text, shortcut string, icon []byte) MenuChild {
return MenuChild{
Icon: icon,
Text: text,
Shortcut: shortcut,
kind: menuChildAction,
action: act,
}
}
func BoolMenuChild(b tracker.Bool, text, shortcut string, icon []byte) MenuChild {
return MenuChild{
Icon: icon,
Text: text,
Shortcut: shortcut,
kind: menuChildBool,
bool: b,
}
}
func IntMenuChild(i tracker.Int, text, shortcut string, icon []byte) MenuChild {
return MenuChild{
Icon: icon,
Text: text,
Shortcut: shortcut,
kind: menuChildInt,
int: i,
}
}
// Layout the menu with the given items
func (m MenuWidget) Layout(gtx C, children ...MenuChild) D {
t := TrackerFromContext(gtx)
if m.Style == nil {
m.Style = &t.Theme.Menu.Main
}
// unfortunately, there was no way to include items into the MenuWidget
// without causing heap escapes, so they are passed as a parameter to the Layout
m.State.itemTmp = m.State.itemTmp[:0]
for i, c := range children {
switch c.kind {
case menuChildAction:
m.State.itemTmp = append(m.State.itemTmp, menuItem{childIndex: i, icon: c.Icon, text: c.Text, shortcut: c.Shortcut, enabled: c.enabled()})
case menuChildBool:
mi := menuItem{childIndex: i, text: c.Text, shortcut: c.Shortcut, enabled: c.enabled()}
if c.bool.Value() {
mi.icon = c.Icon
}
// handle pointer events for this item
for {
ev, ok := gtx.Event(pointer.Filter{
Target: &m.Menu.tags[i],
Kinds: pointer.Press | pointer.Enter | pointer.Leave,
})
if !ok {
break
m.State.itemTmp = append(m.State.itemTmp, mi)
case menuChildInt:
for i := c.int.Range().Min; i <= c.int.Range().Max; i++ {
mi := menuItem{childIndex: i, text: c.int.StringOf(i), value: i, enabled: c.enabled()}
if c.int.Value() == i {
mi.icon = c.Icon
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
item.Doer.Do()
m.Menu.Visible = false
case pointer.Enter:
m.Menu.hover = i + 1
case pointer.Leave:
if m.Menu.hover == i+1 {
m.Menu.hover = 0
}
if i == c.int.Range().Min {
mi.shortcut = c.Shortcut
}
m.State.itemTmp = append(m.State.itemTmp, mi)
}
}
m.Menu.list.Axis = layout.Vertical
m.Menu.scrollBar.Axis = layout.Vertical
}
m.update(gtx, children, m.State.itemTmp)
listItem := func(gtx C, i int) D {
item := m.State.itemTmp[i]
icon := t.Theme.Icon(item.icon)
iconColor := m.Style.Text.Color
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)}
textLabel := Label(t.Theme, &m.Style.Text, item.text)
shortcutLabel := Label(t.Theme, &m.Style.Shortcut, item.shortcut)
if !item.enabled {
iconColor = m.Style.Disabled
textLabel.Color = m.Style.Disabled
shortcutLabel.Color = m.Style.Disabled
}
shortcutInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(12), Bottom: unit.Dp(2), Top: unit.Dp(2)}
fg := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return iconInset.Layout(gtx, func(gtx C) D {
p := gtx.Dp(unit.Dp(m.Style.Text.TextSize))
gtx.Constraints.Min = image.Pt(p, p)
return icon.Layout(gtx, iconColor)
})
}),
layout.Rigid(textLabel.Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }),
layout.Rigid(func(gtx C) D {
return shortcutInset.Layout(gtx, shortcutLabel.Layout)
}),
)
}
bg := func(gtx C) D {
rect := clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}
if item.enabled && m.State.hoverOk && m.State.hover == i {
paint.FillShape(gtx.Ops, m.Style.Hover, rect.Op())
}
if item.enabled {
area := rect.Push(gtx.Ops)
event.Op(gtx.Ops, &m.State.tags[i])
area.Pop()
}
return D{Size: rect.Max}
}
return layout.Background{}.Layout(gtx, bg, fg)
}
menuList := func(gtx C) D {
gtx.Constraints.Max.X = gtx.Dp(m.Style.Width)
gtx.Constraints.Max.Y = gtx.Dp(m.Style.Height)
r := clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops)
event.Op(gtx.Ops, &m.State.tag)
r.Pop()
m.State.list.Axis = layout.Vertical
m.State.scrollBar.Axis = layout.Vertical
return layout.Stack{Alignment: layout.SE}.Layout(gtx,
layout.Expanded(func(gtx C) D { return m.State.list.Layout(gtx, len(m.State.itemTmp), listItem) }),
layout.Expanded(func(gtx C) D {
return m.Menu.list.Layout(gtx, len(items), func(gtx C, i int) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
var macro op.MacroOp
item := &items[i]
if i == m.Menu.hover-1 && item.Doer.Allowed() {
macro = op.Record(gtx.Ops)
}
icon := widgetForIcon(item.IconBytes)
iconColor := m.IconColor
if !item.Doer.Allowed() {
iconColor = mediumEmphasisTextColor
}
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)}
textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper}
if !item.Doer.Allowed() {
textLabel.Color = mediumEmphasisTextColor
}
shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper}
shortcutInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(12), Bottom: unit.Dp(2), Top: unit.Dp(2)}
dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return iconInset.Layout(gtx, func(gtx C) D {
p := gtx.Dp(unit.Dp(m.IconSize))
gtx.Constraints.Min = image.Pt(p, p)
return icon.Layout(gtx, iconColor)
})
}),
layout.Rigid(textLabel.Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }),
layout.Rigid(func(gtx C) D {
return shortcutInset.Layout(gtx, shortcutLabel.Layout)
}),
)
if i == m.Menu.hover-1 && item.Doer.Allowed() {
recording := macro.Stop()
paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{
Max: image.Pt(dims.Size.X, dims.Size.Y),
}.Op())
recording.Add(gtx.Ops)
}
if item.Doer.Allowed() {
rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y)
area := clip.Rect(rect).Push(gtx.Ops)
event.Op(gtx.Ops, &m.Menu.tags[i])
area.Pop()
}
return dims
})
}),
layout.Expanded(func(gtx C) D {
return m.Menu.scrollBar.Layout(gtx, unit.Dp(10), len(items), &m.Menu.list.Position)
return m.State.scrollBar.Layout(gtx, &t.Theme.ScrollBar, len(m.State.itemTmp), &m.State.list.Position)
}),
)
}
popup := Popup(&m.Menu.Visible)
popup.NE = unit.Dp(0)
popup.ShadowN = unit.Dp(0)
popup.NW = unit.Dp(0)
return popup.Layout(gtx, contents)
popup := Popup(t.Theme, &m.State.visible)
popup.Style = &t.Theme.Popup.Menu
return popup.Layout(gtx, menuList)
}
func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle {
return MenuStyle{
Menu: menu,
IconColor: white,
TextColor: white,
ShortCutColor: mediumEmphasisTextColor,
FontSize: unit.Sp(16),
IconSize: unit.Dp(16),
HoverColor: menuHoverColor,
Shaper: shaper,
type menuItem struct {
childIndex int
value int
icon []byte
text, shortcut string
enabled bool
}
func (m *MenuWidget) update(gtx C, children []MenuChild, items []menuItem) {
// handle keyboard events for the menu
for {
ev, ok := gtx.Event(
key.FocusFilter{Target: &m.State.tag},
key.Filter{Focus: &m.State.tag, Name: key.NameUpArrow},
key.Filter{Focus: &m.State.tag, Name: key.NameDownArrow},
key.Filter{Focus: &m.State.tag, Name: key.NameEnter},
key.Filter{Focus: &m.State.tag, Name: key.NameReturn},
)
if !ok {
break
}
switch e := ev.(type) {
case key.Event:
if e.State != key.Press {
continue
}
switch e.Name {
case key.NameUpArrow:
if !m.State.hoverOk {
m.State.hover = 0 // if nothing is selected, select the first item before starting to move backwards
}
for i := 1; i < len(items); i++ {
idx := (m.State.hover - i + len(items)) % len(items)
child := &children[items[idx].childIndex]
if child.enabled() {
m.State.hover = idx
m.State.hoverOk = true
break
}
}
case key.NameDownArrow:
if !m.State.hoverOk {
m.State.hover = len(items) - 1 // if nothing is selected, select the last item before starting to move backwards
}
for i := 1; i < len(items); i++ {
idx := (m.State.hover + i) % len(items)
child := &children[items[idx].childIndex]
if child.enabled() {
m.State.hover = idx
m.State.hoverOk = true
break
}
}
case key.NameEnter, key.NameReturn:
if m.State.hoverOk && m.State.hover >= 0 && m.State.hover < len(items) {
m.activateItem(items[m.State.hover], children)
}
}
case key.FocusEvent:
if !m.State.hoverOk {
m.State.hover = 0
}
m.State.hoverOk = e.Focus
}
}
for i := range items {
// make sure we have a tag for every item
for len(m.State.tags) <= i {
m.State.tags = append(m.State.tags, false)
}
// handle pointer events for this item
for {
ev, ok := gtx.Event(pointer.Filter{Target: &m.State.tags[i], Kinds: pointer.Press | pointer.Enter | pointer.Leave})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
m.activateItem(items[i], children)
case pointer.Enter:
m.State.hover = i
m.State.hoverOk = true
if !gtx.Focused(&m.State.tag) {
gtx.Execute(key.FocusCmd{Tag: &m.State.tag})
}
case pointer.Leave:
if m.State.hover == i {
m.State.hoverOk = false
}
}
}
}
}
func (tr *Tracker) layoutMenu(gtx C, title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget {
for clickable.Clicked(gtx) {
menu.Visible = true
func (m *MenuWidget) activateItem(item menuItem, children []MenuChild) {
if item.childIndex < 0 || item.childIndex >= len(children) {
return
}
m := PopupMenu(menu, tr.Theme.Shaper)
return func(gtx C) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
titleBtn := material.Button(tr.Theme, clickable, title)
titleBtn.Color = white
titleBtn.Background = transparent
titleBtn.CornerRadius = unit.Dp(0)
dims := titleBtn.Layout(gtx)
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
gtx.Constraints.Max.X = gtx.Dp(width)
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(300))
m.Layout(gtx, items...)
return dims
child := &children[item.childIndex]
if !child.enabled() {
return
}
switch child.kind {
case menuChildAction:
child.action.Do()
case menuChildBool:
child.bool.Toggle()
case menuChildInt:
child.int.SetValue(item.value)
}
m.State.visible = false
}
func (c *MenuChild) enabled() bool {
switch c.kind {
case menuChildAction:
return c.action.Enabled()
case menuChildBool:
return c.bool.Enabled()
case menuChildWidget:
return false // the widget are passive separators and such
default:
return true
}
}
// MenuButton displays a button with text that opens a menu when clicked.
type MenuButton struct {
Title string
Style *ButtonStyle
Clickable *Clickable
MenuState *MenuState
Width unit.Dp
}
func MenuBtn(ms *MenuState, cl *Clickable, title string) MenuButton {
return MenuButton{MenuState: ms, Clickable: cl, Title: title}
}
func (mb MenuButton) WithStyle(style *ButtonStyle) MenuButton { mb.Style = style; return mb }
func (mb MenuButton) Layout(gtx C, children ...MenuChild) D {
for mb.Clickable.Clicked(gtx) {
mb.MenuState.visible = true
gtx.Execute(key.FocusCmd{Tag: &mb.MenuState.tag})
}
t := TrackerFromContext(gtx)
if mb.Style == nil {
mb.Style = &t.Theme.Button.Menu
}
btn := Btn(t.Theme, mb.Style, mb.Clickable, mb.Title, "")
dims := btn.Layout(gtx)
if mb.MenuState.visible {
defer op.Offset(image.Pt(0, dims.Size.Y)).Push(gtx.Ops).Pop()
m := Menu(mb.MenuState)
m.Layout(gtx, children...)
}
return dims
}

View File

@ -3,9 +3,10 @@ package gioui
import (
"fmt"
"image"
"image/color"
"strconv"
"strings"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
@ -24,126 +25,131 @@ const trackColTitleHeight = unit.Dp(16)
const trackPatMarkWidth = unit.Dp(25)
const trackRowMarkWidth = unit.Dp(25)
var noteStr [256]string
var noteName [256]string
var noteHex [256]string
var hexStr [256]string
func init() {
// initialize these strings once, so we don't have to do it every time we draw the note editor
hexStr[0] = "--"
hexStr[1] = ".."
noteStr[0] = "---"
noteStr[1] = "..."
for i := range 256 {
hexStr[i] = fmt.Sprintf("%02X", i)
}
noteHex[0] = "--"
noteHex[1] = ".."
noteName[0] = "---"
noteName[1] = "..."
for i := 2; i < 256; i++ {
hexStr[i] = fmt.Sprintf("%02x", i)
noteHex[i] = fmt.Sprintf("%02x", i)
oNote := mod(i-baseNote, 12)
octave := (i - oNote - baseNote) / 12
switch {
case octave < 0:
noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave)))
noteName[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave)))
case octave >= 10:
noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
noteName[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
default:
noteStr[i] = fmt.Sprintf("%s%d", notes[oNote], octave)
noteName[i] = fmt.Sprintf("%s%d", notes[oNote], octave)
}
}
}
type NoteEditor struct {
TrackVoices *NumberInput
NewTrackBtn *ActionClickable
DeleteTrackBtn *ActionClickable
AddSemitoneBtn *ActionClickable
SubtractSemitoneBtn *ActionClickable
AddOctaveBtn *ActionClickable
SubtractOctaveBtn *ActionClickable
NoteOffBtn *ActionClickable
EffectBtn *BoolClickable
TrackVoices *NumericUpDownState
NewTrackBtn *Clickable
DeleteTrackBtn *Clickable
SplitTrackBtn *Clickable
scrollTable *ScrollTable
tag struct{}
AddSemitoneBtn *Clickable
SubtractSemitoneBtn *Clickable
AddOctaveBtn *Clickable
SubtractOctaveBtn *Clickable
NoteOffBtn *Clickable
EffectBtn *Clickable
UniqueBtn *Clickable
TrackMidiInBtn *Clickable
scrollTable *ScrollTable
eventFilters []event.Filter
deleteTrackHint string
addTrackHint string
uniqueOffTip, uniqueOnTip string
splitTrackHint string
}
func NewNoteEditor(model *tracker.Model) *NoteEditor {
return &NoteEditor{
TrackVoices: NewNumberInput(model.TrackVoices().Int()),
NewTrackBtn: NewActionClickable(model.AddTrack()),
DeleteTrackBtn: NewActionClickable(model.DeleteTrack()),
AddSemitoneBtn: NewActionClickable(model.AddSemitone()),
SubtractSemitoneBtn: NewActionClickable(model.SubtractSemitone()),
AddOctaveBtn: NewActionClickable(model.AddOctave()),
SubtractOctaveBtn: NewActionClickable(model.SubtractOctave()),
NoteOffBtn: NewActionClickable(model.EditNoteOff()),
EffectBtn: NewBoolClickable(model.Effect().Bool()),
ret := &NoteEditor{
TrackVoices: NewNumericUpDownState(),
NewTrackBtn: new(Clickable),
DeleteTrackBtn: new(Clickable),
SplitTrackBtn: new(Clickable),
AddSemitoneBtn: new(Clickable),
SubtractSemitoneBtn: new(Clickable),
AddOctaveBtn: new(Clickable),
SubtractOctaveBtn: new(Clickable),
NoteOffBtn: new(Clickable),
EffectBtn: new(Clickable),
UniqueBtn: new(Clickable),
TrackMidiInBtn: new(Clickable),
scrollTable: NewScrollTable(
model.Notes().Table(),
model.Tracks().List(),
model.NoteRows().List(),
model.Note().Table(),
model.Track().List(),
model.Note().RowList(),
),
}
for k, a := range keyBindingMap {
if len(a) < 4 || a[:4] != "Note" {
continue
}
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Required: k.Modifiers, Name: k.Name})
}
for c := 'A'; c <= 'F'; c++ {
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)})
}
for c := '0'; c <= '9'; c++ {
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)})
}
ret.deleteTrackHint = makeHint("Delete\ntrack", "\n(%s)", "DeleteTrack")
ret.addTrackHint = makeHint("Add\ntrack", "\n(%s)", "AddTrack")
ret.uniqueOnTip = makeHint("Duplicate non-unique patterns", " (%s)", "UniquePatternsToggle")
ret.uniqueOffTip = makeHint("Allow editing non-unique patterns", " (%s)", "UniquePatternsToggle")
ret.splitTrackHint = makeHint("Split track", " (%s)", "SplitTrack")
return ret
}
func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
func (te *NoteEditor) Layout(gtx layout.Context) layout.Dimensions {
t := TrackerFromContext(gtx)
for {
e, ok := gtx.Event(
key.Filter{Focus: te.scrollTable, Name: "A"},
key.Filter{Focus: te.scrollTable, Name: "B"},
key.Filter{Focus: te.scrollTable, Name: "C"},
key.Filter{Focus: te.scrollTable, Name: "D"},
key.Filter{Focus: te.scrollTable, Name: "E"},
key.Filter{Focus: te.scrollTable, Name: "F"},
key.Filter{Focus: te.scrollTable, Name: "G"},
key.Filter{Focus: te.scrollTable, Name: "H"},
key.Filter{Focus: te.scrollTable, Name: "I"},
key.Filter{Focus: te.scrollTable, Name: "J"},
key.Filter{Focus: te.scrollTable, Name: "K"},
key.Filter{Focus: te.scrollTable, Name: "L"},
key.Filter{Focus: te.scrollTable, Name: "M"},
key.Filter{Focus: te.scrollTable, Name: "N"},
key.Filter{Focus: te.scrollTable, Name: "O"},
key.Filter{Focus: te.scrollTable, Name: "P"},
key.Filter{Focus: te.scrollTable, Name: "Q"},
key.Filter{Focus: te.scrollTable, Name: "R"},
key.Filter{Focus: te.scrollTable, Name: "S"},
key.Filter{Focus: te.scrollTable, Name: "T"},
key.Filter{Focus: te.scrollTable, Name: "U"},
key.Filter{Focus: te.scrollTable, Name: "V"},
key.Filter{Focus: te.scrollTable, Name: "W"},
key.Filter{Focus: te.scrollTable, Name: "X"},
key.Filter{Focus: te.scrollTable, Name: "Y"},
key.Filter{Focus: te.scrollTable, Name: "Z"},
key.Filter{Focus: te.scrollTable, Name: "0"},
key.Filter{Focus: te.scrollTable, Name: "1"},
key.Filter{Focus: te.scrollTable, Name: "2"},
key.Filter{Focus: te.scrollTable, Name: "3"},
key.Filter{Focus: te.scrollTable, Name: "4"},
key.Filter{Focus: te.scrollTable, Name: "5"},
key.Filter{Focus: te.scrollTable, Name: "6"},
key.Filter{Focus: te.scrollTable, Name: "7"},
key.Filter{Focus: te.scrollTable, Name: "8"},
key.Filter{Focus: te.scrollTable, Name: "9"},
key.Filter{Focus: te.scrollTable, Name: ","},
key.Filter{Focus: te.scrollTable, Name: "."},
)
e, ok := gtx.Event(te.eventFilters...)
if !ok {
break
}
switch e := e.(type) {
case key.Event:
if e.State == key.Release {
if noteID, ok := t.KeyPlaying[e.Name]; ok {
noteID.NoteOff()
delete(t.KeyPlaying, e.Name)
}
t.KeyNoteMap.Release(e.Name)
continue
}
te.command(gtx, t, e)
te.command(t, e)
}
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
for gtx.Focused(te.scrollTable) && len(t.noteEvents) > 0 {
ev := t.noteEvents[0]
ev.IsTrack = true
ev.Channel = t.Model.Note().Cursor().X
ev.Source = te
if ev.On {
t.Model.Note().Input(ev.Note)
}
copy(t.noteEvents, t.noteEvents[1:])
t.noteEvents = t.noteEvents[:len(t.noteEvents)-1]
tracker.TrySend(t.Broker().ToPlayer, any(ev))
}
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
return Surface{Gray: 24, Focus: te.scrollTable.Focused()}.Layout(gtx, func(gtx C) D {
return Surface{Height: 3, Focus: te.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return te.layoutButtons(gtx, t)
@ -156,33 +162,41 @@ func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
}
func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
return Surface{Gray: 37, Focus: te.scrollTable.Focused() || te.scrollTable.ChildFocused(), FitSize: true}.Layout(gtx, func(gtx C) D {
addSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.AddSemitoneBtn, "+1")
subtractSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.SubtractSemitoneBtn, "-1")
addOctaveBtnStyle := ActionButton(gtx, t.Theme, te.AddOctaveBtn, "+12")
subtractOctaveBtnStyle := ActionButton(gtx, t.Theme, te.SubtractOctaveBtn, "-12")
noteOffBtnStyle := ActionButton(gtx, t.Theme, te.NoteOffBtn, "Note Off")
deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, "Delete track\n(Ctrl+Shift+T)")
newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, "Add track\n(Ctrl+T)")
return Surface{Height: 4, Focus: te.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
addSemitoneBtn := ActionBtn(t.Note().AddSemitone(), t.Theme, te.AddSemitoneBtn, "+1", "Add semitone")
subtractSemitoneBtn := ActionBtn(t.Note().SubtractSemitone(), t.Theme, te.SubtractSemitoneBtn, "-1", "Subtract semitone")
addOctaveBtn := ActionBtn(t.Note().AddOctave(), t.Theme, te.AddOctaveBtn, "+12", "Add octave")
subtractOctaveBtn := ActionBtn(t.Note().SubtractOctave(), t.Theme, te.SubtractOctaveBtn, "-12", "Subtract octave")
noteOffBtn := ActionBtn(t.Note().NoteOff(), t.Theme, te.NoteOffBtn, "Note Off", "")
deleteTrackBtn := ActionIconBtn(t.Track().Delete(), t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint)
splitTrackBtn := ActionIconBtn(t.Track().Split(), t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint)
newTrackBtn := ActionIconBtn(t.Track().Add(), t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint)
trackVoices := NumUpDown(t.Model.Track().Voices(), t.Theme, te.TrackVoices, "Track voices")
in := layout.UniformInset(unit.Dp(1))
voiceUpDown := func(gtx C) D {
numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track")
return in.Layout(gtx, numStyle.Layout)
trackVoicesInsetted := func(gtx C) D {
return in.Layout(gtx, trackVoices.Layout)
}
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
effectBtn := ToggleBtn(t.Track().Effect(), t.Theme, te.EffectBtn, "Hex", "Input notes as hex values")
uniqueBtn := ToggleIconBtn(t.Note().UniquePatterns(), t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
midiInBtn := ToggleBtn(t.MIDI().InputtingNotes(), t.Theme, te.TrackMidiInBtn, "MIDI", "Input notes from MIDI keyboard")
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),
layout.Rigid(subtractSemitoneBtnStyle.Layout),
layout.Rigid(addOctaveBtnStyle.Layout),
layout.Rigid(subtractOctaveBtnStyle.Layout),
layout.Rigid(noteOffBtnStyle.Layout),
layout.Rigid(effectBtnStyle.Layout),
layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)),
layout.Rigid(voiceUpDown),
layout.Rigid(addSemitoneBtn.Layout),
layout.Rigid(subtractSemitoneBtn.Layout),
layout.Rigid(addOctaveBtn.Layout),
layout.Rigid(subtractOctaveBtn.Layout),
layout.Rigid(noteOffBtn.Layout),
layout.Rigid(effectBtn.Layout),
layout.Rigid(uniqueBtn.Layout),
layout.Rigid(layout.Spacer{Width: 10}.Layout),
layout.Rigid(Label(t.Theme, &t.Theme.NoteEditor.Header, "Voices").Layout),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(trackVoicesInsetted),
layout.Rigid(splitTrackBtn.Layout),
layout.Rigid(midiInBtn.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout))
layout.Rigid(deleteTrackBtn.Layout),
layout.Rigid(newTrackBtn.Layout))
})
}
@ -204,123 +218,142 @@ var notes = []string{
}
func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
beatMarkerDensity := t.RowsPerBeat().Value()
beatMarkerDensity := t.Song().RowsPerBeat().Value()
switch beatMarkerDensity {
case 0, 1, 2:
beatMarkerDensity = 4
}
playSongRow := t.PlaySongRow()
playSongRow := t.Play().SongRow()
pxWidth := gtx.Dp(trackColWidth)
pxHeight := gtx.Dp(trackRowHeight)
pxPatMarkWidth := gtx.Dp(trackPatMarkWidth)
pxRowMarkWidth := gtx.Dp(trackRowMarkWidth)
colTitle := func(gtx C, i int) D {
h := gtx.Dp(unit.Dp(trackColTitleHeight))
title := ((*tracker.Order)(t.Model)).Title(i)
h := gtx.Dp(trackColTitleHeight)
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)
Label(t.Theme, &t.Theme.NoteEditor.TrackTitle, t.Model.Track().Item(i).Title).Layout(gtx)
return D{Size: image.Pt(pxWidth, h)}
}
rowTitleBg := func(gtx C, j int) D {
if mod(j, beatMarkerDensity*2) == 0 {
paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.TwoBeat, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
} else if mod(j, beatMarkerDensity) == 0 {
paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.OneBeat, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
}
if t.SongPanel.PlayingBtn.Bool.Value() && j == playSongRow {
paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
if t.Model.Play().Started().Value() && j == playSongRow {
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.Play, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
}
return D{}
}
orderRowOp := colorOp(gtx, t.Theme.NoteEditor.OrderRow.Color)
loopColorOp := colorOp(gtx, t.Theme.OrderEditor.Loop)
patternRowOp := colorOp(gtx, t.Theme.NoteEditor.PatternRow.Color)
rowTitle := func(gtx C, j int) D {
rpp := intMax(t.RowsPerPattern().Value(), 1)
rpp := max(t.Song().RowsPerPattern().Value(), 1)
pat := j / rpp
row := j % rpp
w := pxPatMarkWidth + pxRowMarkWidth
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
if row == 0 {
color := rowMarkerPatternTextColor
if l := t.Loop(); pat >= l.Start && pat < l.Start+l.Length {
color = loopMarkerColor
op := orderRowOp
if l := t.Play().Loop(); pat >= l.Start && pat < l.Start+l.Length {
op = loopColorOp
}
paint.ColorOp{Color: color}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{})
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.OrderRow.Font, t.Theme.NoteEditor.OrderRow.TextSize, hexStr[pat&255], op)
}
defer op.Offset(image.Pt(pxPatMarkWidth, 0)).Push(gtx.Ops).Pop()
paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", row)), op.CallOp{})
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.PatternRow.Font, t.Theme.NoteEditor.PatternRow.TextSize, hexStr[row&255], patternRowOp)
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 := t.MIDI().InputtingNotes().Value()
patternNoOp := colorOp(gtx, t.Theme.NoteEditor.PatternNo.Color)
uniqueOp := colorOp(gtx, t.Theme.NoteEditor.Unique.Color)
noteOp := colorOp(gtx, t.Theme.NoteEditor.Note.Color)
cell := func(gtx C, x, y int) D {
// draw the background, to indicate selection
color := transparent
point := tracker.Point{X: x, Y: y}
if drawSelection && selection.Contains(point) {
color = inactiveSelectionColor
if te.scrollTable.Focused() {
color = selectionColor
color := t.Theme.Selection.Inactive
if gtx.Focused(te.scrollTable) {
color = t.Theme.Selection.Active
}
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
}
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 := t.Theme.Cursor.Inactive
if gtx.Focused(te.scrollTable) {
c = t.Theme.Cursor.Active
}
c := inactiveSelectionColor
if te.scrollTable.Focused() {
c = cursorColor
if hasTrackMidiIn {
c = t.Theme.Cursor.ActiveAlt
}
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
te.paintColumnCell(gtx, x, t, c)
}
// draw the pattern marker
rpp := intMax(t.RowsPerPattern().Value(), 1)
rpp := max(t.Song().RowsPerPattern().Value(), 1)
pat := y / rpp
row := y % rpp
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
s := t.Model.Order().Value(tracker.Point{X: x, Y: pat})
if row == 0 { // draw the pattern marker
paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{})
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.PatternNo.Font, t.Theme.NoteEditor.PatternNo.TextSize, patternIndexToString(s), patternNoOp)
}
if row == 1 && t.Model.Notes().Unique(x, s) { // draw a * if the pattern is unique
paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, "*", op.CallOp{})
if row == 1 && t.Order().PatternUnique(x, s) { // draw a * if the pattern is unique
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.Unique.Font, t.Theme.NoteEditor.Unique.TextSize, "*", uniqueOp)
}
if te.scrollTable.Table.Cursor() == point && te.scrollTable.Focused() {
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
} else {
paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops)
op := noteOp
val := noteName[byte(t.Model.Note().At(tracker.Point{X: x, Y: y}))]
if t.Model.Track().Item(x).Effect {
val = noteHex[byte(t.Model.Note().At(tracker.Point{X: x, Y: y}))]
}
val := noteStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))]
if t.Model.Notes().Effect(x) {
val = hexStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))]
}
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{})
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.Note.Font, t.Theme.NoteEditor.Note.TextSize, val, op)
return D{Size: image.Pt(pxWidth, pxHeight)}
}
table := FilledScrollTable(t.Theme, te.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
table := FilledScrollTable(t.Theme, te.scrollTable)
table.RowTitleWidth = trackPatMarkWidth + trackRowMarkWidth
table.ColumnTitleHeight = trackColTitleHeight
table.CellWidth = trackColWidth
table.CellHeight = trackRowHeight
return table.Layout(gtx)
return table.Layout(gtx, cell, colTitle, rowTitle, nil, rowTitleBg)
}
func (t *NoteEditor) Tags(level int, yield TagYieldFunc) bool {
return yield(level+1, t.scrollTable.RowTitleList) &&
yield(level+1, t.scrollTable.ColTitleList) &&
yield(level, t.scrollTable)
}
func colorOp(gtx C, c color.NRGBA) op.CallOp {
macro := op.Record(gtx.Ops)
paint.ColorOp{Color: c}.Add(gtx.Ops)
return macro.Stop()
}
func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) {
cw := gtx.Constraints.Min.X
cx := 0
if t.Model.Track().Item(x).Effect {
cw /= 2
if t.Model.Note().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 {
@ -338,47 +371,31 @@ func noteAsValue(octave, note int) byte {
return byte(baseNote + (octave * 12) + note)
}
func (te *NoteEditor) command(gtx C, t *Tracker, e key.Event) {
if e.Name == "A" || e.Name == "1" {
t.Model.Notes().Table().Fill(0)
te.scrollTable.EnsureCursorVisible()
return
}
func (te *NoteEditor) command(t *Tracker, e key.Event) {
var n byte
if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) {
if t.Model.Track().Item(te.scrollTable.Table.Cursor().X).Effect {
if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil {
n = t.Model.Notes().Value(te.scrollTable.Table.Cursor())
t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble())
goto validNote
ev := t.Model.Note().InputNibble(byte(nibbleValue))
t.KeyNoteMap.Press(e.Name, ev)
}
} else {
if val, ok := noteMap[e.Name]; ok {
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val)
t.Model.Notes().Table().Fill(int(n))
goto validNote
action, ok := keyBindingMap[e]
if !ok {
return
}
if action == "NoteOff" {
ev := t.Model.Note().Input(0)
t.KeyNoteMap.Press(e.Name, ev)
return
}
if action[:4] == "Note" {
val, err := strconv.Atoi(string(action[4:]))
if err != nil {
return
}
n = noteAsValue(t.Note().Octave().Value(), val-12)
ev := t.Model.Note().Input(n)
t.KeyNoteMap.Press(e.Name, ev)
}
}
return
validNote:
te.scrollTable.EnsureCursorVisible()
if _, ok := t.KeyPlaying[e.Name]; !ok {
trk := te.scrollTable.Table.Cursor().X
t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n)
}
}
/*
case "+":
if e.Modifiers.Contain(key.ModShortcut) {
te.AddOctaveBtn.Action.Do()
} else {
te.AddSemitoneBtn.Action.Do()
}
case "-":
if e.Modifiers.Contain(key.ModShortcut) {
te.SubtractSemitoneBtn.Action.Do()
} else {
te.SubtractOctaveBtn.Action.Do()
}
}*/

View File

@ -1,220 +1,158 @@
package gioui
import (
"fmt"
"image"
"image/color"
"strconv"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"gioui.org/font"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/x/component"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
)
type NumberInput struct {
Int tracker.Int
dragStartValue int
dragStartXY float32
clickDecrease gesture.Click
clickIncrease gesture.Click
tipArea component.TipArea
type (
NumericUpDownState struct {
DpPerStep unit.Dp
dragStartValue int
dragStartXY float32
clickDecrease gesture.Click
clickIncrease gesture.Click
tipArea TipArea
}
NumericUpDownStyle struct {
TextColor color.NRGBA `yaml:",flow"`
IconColor color.NRGBA `yaml:",flow"`
BgColor color.NRGBA `yaml:",flow"`
CornerRadius unit.Dp
ButtonWidth unit.Dp
Width unit.Dp
Height unit.Dp
TextSize unit.Sp
Font font.Font
}
NumericUpDown struct {
Int tracker.Int
Theme *Theme
State *NumericUpDownState
Style *NumericUpDownStyle
Tip string
}
)
func NewNumericUpDownState() *NumericUpDownState {
return &NumericUpDownState{DpPerStep: unit.Dp(8)}
}
type NumericUpDownStyle struct {
NumberInput *NumberInput
Color color.NRGBA
Font font.Font
TextSize unit.Sp
BorderColor color.NRGBA
IconColor color.NRGBA
BackgroundColor color.NRGBA
CornerRadius unit.Dp
Border unit.Dp
ButtonWidth unit.Dp
UnitsPerStep unit.Dp
Tooltip component.Tooltip
Width unit.Dp
Height unit.Dp
shaper text.Shaper
}
func NewNumberInput(v tracker.Int) *NumberInput {
return &NumberInput{Int: v}
}
func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle {
bgColor := th.Palette.Fg
bgColor.R /= 4
bgColor.G /= 4
bgColor.B /= 4
return NumericUpDownStyle{
NumberInput: number,
Color: white,
BorderColor: th.Palette.Fg,
IconColor: th.Palette.ContrastFg,
BackgroundColor: bgColor,
CornerRadius: unit.Dp(4),
ButtonWidth: unit.Dp(16),
Border: unit.Dp(1),
UnitsPerStep: unit.Dp(8),
TextSize: th.TextSize * 14 / 16,
Tooltip: Tooltip(th, tooltip),
Width: unit.Dp(70),
Height: unit.Dp(20),
shaper: *th.Shaper,
func NumUpDown(v tracker.Int, th *Theme, n *NumericUpDownState, tip string) NumericUpDown {
return NumericUpDown{
Int: v,
Theme: th,
State: n,
Style: &th.NumericUpDown,
Tip: tip,
}
}
func (s *NumericUpDownStyle) Layout(gtx C) D {
if s.Tooltip.Text.Text != "" {
return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout)
}
return s.actualLayout(gtx)
}
func (s *NumericUpDownStyle) actualLayout(gtx C) D {
size := image.Pt(gtx.Dp(s.Width), gtx.Dp(s.Height))
gtx.Constraints.Min = size
rr := gtx.Dp(s.CornerRadius)
border := gtx.Dp(s.Border)
c := clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops)
paint.Fill(gtx.Ops, s.BorderColor)
c.Pop()
off := op.Offset(image.Pt(border, border)).Push(gtx.Ops)
c2 := clip.UniformRRect(image.Rectangle{Max: image.Pt(
gtx.Constraints.Min.X-border*2,
gtx.Constraints.Min.Y-border*2,
)}, rr-border).Push(gtx.Ops)
gtx.Constraints.Min.X -= int(border * 2)
gtx.Constraints.Min.Y -= int(border * 2)
gtx.Constraints.Max = gtx.Constraints.Min
layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowBack), -1, &s.NumberInput.clickDecrease)),
layout.Flexed(1, s.layoutText),
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowForward), 1, &s.NumberInput.clickIncrease)),
)
off.Pop()
c2.Pop()
return layout.Dimensions{Size: size}
}
func (s *NumericUpDownStyle) button(height int, icon *widget.Icon, delta int, click *gesture.Click) layout.Widget {
return func(gtx C) D {
btnWidth := gtx.Dp(s.ButtonWidth)
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
//paint.FillShape(gtx.Ops, black, clip.Rect(image.Rect(0, 0, btnWidth, height)).Op())
return layout.Dimensions{Size: image.Point{X: btnWidth, Y: height}}
}),
layout.Expanded(func(gtx C) D {
size := btnWidth
if height < size {
size = height
}
if size < 1 {
size = 1
}
if icon != nil {
p := gtx.Dp(unit.Dp(size))
if p < 1 {
p = 1
}
gtx.Constraints = layout.Exact(image.Pt(p, p))
return icon.Layout(gtx, s.IconColor)
}
return layout.Dimensions{}
}),
layout.Expanded(func(gtx C) D {
return s.layoutClick(gtx, delta, click)
}),
)
}
}
func (s *NumericUpDownStyle) layoutText(gtx C) D {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Stacked(func(gtx C) D {
paint.FillShape(gtx.Ops, s.BackgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: s.Color}.Add(gtx.Ops)
return widget.Label{Alignment: text.Middle}.Layout(gtx, &s.shaper, s.Font, s.TextSize, fmt.Sprintf("%v", s.NumberInput.Int.Value()), op.CallOp{})
}),
layout.Expanded(s.layoutDrag),
)
}
func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
{ // handle dragging
pxPerStep := float32(gtx.Dp(s.UnitsPerStep))
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s.NumberInput,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
})
if !ok {
break
}
if e, ok := ev.(pointer.Event); ok {
switch e.Kind {
case pointer.Press:
s.NumberInput.dragStartValue = s.NumberInput.Int.Value()
s.NumberInput.dragStartXY = e.Position.X - e.Position.Y
case pointer.Drag:
var deltaCoord float32
deltaCoord = e.Position.X - e.Position.Y - s.NumberInput.dragStartXY
s.NumberInput.Int.Set(s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5))
}
}
}
// Avoid affecting the input tree with pointer events.
stack := op.Offset(image.Point{}).Push(gtx.Ops)
// register for input
dragRect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area := clip.Rect(dragRect).Push(gtx.Ops)
event.Op(gtx.Ops, s.NumberInput)
area.Pop()
stack.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}
func (s *NumericUpDownStyle) layoutClick(gtx layout.Context, delta int, click *gesture.Click) layout.Dimensions {
// handle clicking
func (s *NumericUpDownState) Update(gtx layout.Context, v tracker.Int) {
// handle dragging
pxPerStep := float32(gtx.Dp(s.DpPerStep))
for {
ev, ok := click.Update(gtx.Source)
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
})
if !ok {
break
}
switch ev.Kind {
case gesture.KindClick:
s.NumberInput.Int.Add(delta)
if e, ok := ev.(pointer.Event); ok {
switch e.Kind {
case pointer.Press:
s.dragStartValue = v.Value()
s.dragStartXY = e.Position.X - e.Position.Y
case pointer.Drag:
var deltaCoord float32
deltaCoord = e.Position.X - e.Position.Y - s.dragStartXY
v.SetValue(s.dragStartValue + int(deltaCoord/pxPerStep+0.5))
}
}
}
// handle decrease clicks
for ev, ok := s.clickDecrease.Update(gtx.Source); ok; ev, ok = s.clickDecrease.Update(gtx.Source) {
if ev.Kind == gesture.KindClick {
v.Add(-1)
}
}
// handle increase clicks
for ev, ok := s.clickIncrease.Update(gtx.Source); ok; ev, ok = s.clickIncrease.Update(gtx.Source) {
if ev.Kind == gesture.KindClick {
v.Add(1)
}
}
// Avoid affecting the input tree with pointer events.
stack := op.Offset(image.Point{}).Push(gtx.Ops)
// register for input
clickRect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area := clip.Rect(clickRect).Push(gtx.Ops)
click.Add(gtx.Ops)
area.Pop()
stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Min}
}
func (n *NumericUpDown) Layout(gtx C) D {
n.State.Update(gtx, n.Int)
if n.Tip != "" {
return n.State.tipArea.Layout(gtx, Tooltip(n.Theme, n.Tip), n.actualLayout)
}
return n.actualLayout(gtx)
}
func (n *NumericUpDown) actualLayout(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(n.Style.Width), gtx.Dp(n.Style.Height)))
width := gtx.Dp(n.Style.ButtonWidth)
height := gtx.Dp(n.Style.Height)
return layout.Background{}.Layout(gtx,
func(gtx C) D {
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, gtx.Dp(n.Style.CornerRadius)).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, n.Style.BgColor)
event.Op(gtx.Ops, n.State) // register drag inputs, if not hitting the clicks
return D{Size: gtx.Constraints.Min}
},
func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(width, height))
return layout.Background{}.Layout(gtx,
func(gtx C) D {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
n.State.clickDecrease.Add(gtx.Ops)
return D{Size: gtx.Constraints.Min}
},
func(gtx C) D { return n.Theme.Icon(icons.ContentRemove).Layout(gtx, n.Style.IconColor) },
)
}),
layout.Flexed(1, func(gtx C) D {
paint.ColorOp{Color: n.Style.TextColor}.Add(gtx.Ops)
return widget.Label{Alignment: text.Middle}.Layout(gtx, n.Theme.Material.Shaper, n.Style.Font, n.Style.TextSize, strconv.Itoa(n.Int.Value()), op.CallOp{})
}),
layout.Rigid(func(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(width, height))
return layout.Background{}.Layout(gtx,
func(gtx C) D {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
n.State.clickIncrease.Add(gtx.Ops)
return D{Size: gtx.Constraints.Min}
},
func(gtx C) D { return n.Theme.Icon(icons.ContentAdd).Layout(gtx, n.Style.IconColor) },
)
}),
)
},
)
}

View File

@ -1,11 +1,9 @@
package gioui
import (
"fmt"
"image"
"math"
"strconv"
"strings"
"gioui.org/f32"
"gioui.org/io/event"
@ -22,7 +20,6 @@ import (
const patternCellHeight = unit.Dp(16)
const patternCellWidth = unit.Dp(16)
const patternRowMarkerWidth = unit.Dp(30)
const orderTitleHeight = unit.Dp(52)
type OrderEditor struct {
@ -45,13 +42,14 @@ func NewOrderEditor(m *tracker.Model) *OrderEditor {
return &OrderEditor{
scrollTable: NewScrollTable(
m.Order().Table(),
m.Tracks().List(),
m.OrderRows().List(),
m.Track().List(),
m.Order().RowList(),
),
}
}
func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
func (oe *OrderEditor) Layout(gtx C) D {
t := TrackerFromContext(gtx)
if oe.scrollTable.CursorMoved() {
cursor := t.TrackEditor.scrollTable.Table.Cursor()
t.TrackEditor.scrollTable.ColTitleList.CenterOn(cursor.X)
@ -69,56 +67,62 @@ func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(0, 0), -90*math.Pi/180).Offset(f32.Point{X: 0, Y: float32(h)})).Push(gtx.Ops).Pop()
gtx.Constraints = layout.Exact(image.Pt(1e6, 1e6))
title := t.Model.Order().Title(i)
LabelStyle{Alignment: layout.NW, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx)
Label(t.Theme, &t.Theme.OrderEditor.TrackTitle, t.Model.Track().Item(i).Title).Layout(gtx)
return D{Size: image.Pt(gtx.Dp(patternCellWidth), h)}
}
rowTitleBg := func(gtx C, j int) D {
if t.SongPanel.PlayingBtn.Bool.Value() && j == t.PlayPosition().OrderRow {
paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, gtx.Dp(patternCellHeight))}.Op())
if t.Model.Play().Started().Value() && j == t.Play().Position().OrderRow {
paint.FillShape(gtx.Ops, t.Theme.OrderEditor.Play, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, gtx.Dp(patternCellHeight))}.Op())
}
return D{}
}
rowMarkerPatternTextColorOp := colorOp(gtx, t.Theme.OrderEditor.RowTitle.Color)
loopMarkerColorOp := colorOp(gtx, t.Theme.OrderEditor.Loop)
rowTitle := func(gtx C, j int) D {
w := gtx.Dp(unit.Dp(30))
color := rowMarkerPatternTextColor
if l := t.Loop(); j >= l.Start && j < l.Start+l.Length {
color = loopMarkerColor
callOp := rowMarkerPatternTextColorOp
if l := t.Play().Loop(); j >= l.Start && j < l.Start+l.Length {
callOp = loopMarkerColorOp
}
paint.ColorOp{Color: color}.Add(gtx.Ops)
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{})
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.OrderEditor.RowTitle.Font, t.Theme.OrderEditor.RowTitle.TextSize, hexStr[j&255], callOp)
return D{Size: image.Pt(w, gtx.Dp(patternCellHeight))}
}
selection := oe.scrollTable.Table.Range()
cellColorOp := colorOp(gtx, t.Theme.OrderEditor.Cell.Color)
cell := func(gtx C, x, y int) D {
val := patternIndexToString(t.Model.Order().Value(tracker.Point{X: x, Y: y}))
color := patternCellColor
color := t.Theme.OrderEditor.CellBg
point := tracker.Point{X: x, Y: y}
if selection.Contains(point) {
color = inactiveSelectionColor
if oe.scrollTable.Focused() {
color = selectionColor
if point == oe.scrollTable.Table.Cursor() {
color = cursorColor
color = t.Theme.Selection.Inactive
if gtx.Focused(oe.scrollTable) {
color = t.Theme.Selection.Active
}
if point == oe.scrollTable.Table.Cursor() {
color = t.Theme.Cursor.Inactive
if gtx.Focused(oe.scrollTable) {
color = t.Theme.Cursor.Active
}
}
}
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(1, 1), Max: image.Pt(gtx.Constraints.Min.X-1, gtx.Constraints.Min.X-1)}.Op())
paint.ColorOp{Color: patternTextColor}.Add(gtx.Ops)
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{})
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.OrderEditor.Cell.Font, t.Theme.OrderEditor.Cell.TextSize, val, cellColorOp)
return D{Size: image.Pt(gtx.Dp(patternCellWidth), gtx.Dp(patternCellHeight))}
}
table := FilledScrollTable(t.Theme, oe.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
table := FilledScrollTable(t.Theme, oe.scrollTable)
table.ColumnTitleHeight = orderTitleHeight
return table.Layout(gtx)
return Surface{Height: 3, Focus: oe.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
return table.Layout(gtx, cell, colTitle, rowTitle, nil, rowTitleBg)
})
}
func (oe *OrderEditor) handleEvents(gtx C, t *Tracker) {
@ -171,27 +175,23 @@ func (oe *OrderEditor) handleEvents(gtx C, t *Tracker) {
if e.State != key.Press {
continue
}
oe.command(gtx, t, e)
oe.command(t, e)
}
}
}
func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) {
func (oe *OrderEditor) command(t *Tracker, e key.Event) {
switch e.Name {
case key.NameDeleteBackward:
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.DeleteOrderRow(true).Do()
t.Model.Order().DeleteRow(true).Do()
}
case key.NameDeleteForward:
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.DeleteOrderRow(false).Do()
t.Model.Order().DeleteRow(false).Do()
}
case key.NameReturn:
if e.Modifiers.Contain(key.ModShortcut) {
oe.scrollTable.Table.MoveCursor(0, -1)
oe.scrollTable.Table.SetCursor2(oe.scrollTable.Table.Cursor())
}
t.Model.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut)).Do()
t.Model.Order().AddRow(e.Modifiers.Contain(key.ModShortcut)).Do()
}
if iv, err := strconv.Atoi(string(e.Name)); err == nil {
t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), iv)
@ -203,6 +203,10 @@ func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) {
}
}
func (t *OrderEditor) Tags(level int, yield TagYieldFunc) bool {
return yield(level+1, t.scrollTable.RowTitleList) && yield(level+1, t.scrollTable.ColTitleList) && yield(level, t.scrollTable)
}
func patternIndexToString(index int) string {
if index < 0 {
return ""

View File

@ -0,0 +1,125 @@
package gioui
import (
"math"
"strconv"
"gioui.org/layout"
"gioui.org/unit"
)
type (
OscilloscopeState struct {
onceBtn *Clickable
wrapBtn *Clickable
lengthInBeatsNumber *NumericUpDownState
triggerChannelNumber *NumericUpDownState
plot *Plot
}
Oscilloscope struct {
Theme *Theme
State *OscilloscopeState
}
)
func NewOscilloscope() *OscilloscopeState {
return &OscilloscopeState{
plot: NewPlot(plotRange{0, 1}, plotRange{-1, 1}, 0),
onceBtn: new(Clickable),
wrapBtn: new(Clickable),
lengthInBeatsNumber: NewNumericUpDownState(),
triggerChannelNumber: NewNumericUpDownState(),
}
}
func Scope(th *Theme, st *OscilloscopeState) Oscilloscope {
return Oscilloscope{
Theme: th,
State: st,
}
}
func (s *Oscilloscope) Layout(gtx C) D {
t := TrackerFromContext(gtx)
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
triggerChannel := NumUpDown(t.Scope().TriggerChannel(), s.Theme, s.State.triggerChannelNumber, "Trigger channel")
lengthInBeats := NumUpDown(t.Scope().LengthInBeats(), s.Theme, s.State.lengthInBeatsNumber, "Buffer length in beats")
onceBtn := ToggleBtn(t.Scope().Once(), s.Theme, s.State.onceBtn, "Once", "Trigger once on next event")
wrapBtn := ToggleBtn(t.Scope().Wrap(), s.Theme, s.State.wrapBtn, "Wrap", "Wrap buffer when full")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
w := t.Scope().Waveform()
cx := float32(w.Cursor) / float32(len(w.Buffer))
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
x1 := max(int(xr.a*float32(len(w.Buffer))), 0)
x2 := min(int(xr.b*float32(len(w.Buffer))), len(w.Buffer)-1)
if x1 > x2 {
return plotRange{}, false
}
step := max((x2-x1)/1000, 1) // if the range is too large, sample only ~ 1000 points
y1 := float32(math.Inf(-1))
y2 := float32(math.Inf(+1))
for i := x1; i <= x2; i += step {
sample := w.Buffer[i][chn]
y1 = max(y1, sample)
y2 = min(y2, sample)
}
return plotRange{-y1, -y2}, true
}
rpb := max(t.Song().RowsPerBeat().Value(), 1)
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
l := t.Scope().LengthInBeats().Value() * rpb
a := max(int(math.Ceil(float64(r.a*float32(l)))), 0)
b := min(int(math.Floor(float64(r.b*float32(l)))), l)
step := 1
n := rpb
for (b-a+1)/step > count {
step *= n
n = 2
}
a = (a / step) * step
for i := a; i <= b; i += step {
if i%rpb == 0 {
beat := i / rpb
yield(float32(i)/float32(l), strconv.Itoa(beat))
} else {
yield(float32(i)/float32(l), "")
}
}
}
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
yield(-1, "")
yield(1, "")
}
return s.State.plot.Layout(gtx, data, xticks, yticks, cx, 2)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(s.Theme, &s.Theme.SongPanel.RowHeader, "Trigger").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(onceBtn.Layout),
layout.Rigid(triggerChannel.Layout),
layout.Rigid(rightSpacer),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(s.Theme, &s.Theme.SongPanel.RowHeader, "Buffer").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(wrapBtn.Layout),
layout.Rigid(lengthInBeats.Layout),
layout.Rigid(rightSpacer),
)
}),
)
}

467
tracker/gioui/param.go Normal file
View File

@ -0,0 +1,467 @@
package gioui
import (
"image"
"image/color"
"math"
"strconv"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/x/stroke"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
ParamState struct {
drag gesture.Drag
dragStartPt f32.Point // used to calculate the drag amount
dragStartVal int
tipArea TipArea
clickable Clickable
}
ParamWidget struct {
Parameter tracker.Parameter
State *ParamState
Theme *Theme
Focus bool
Disabled bool
}
PortStyle struct {
Diameter unit.Dp
StrokeWidth unit.Dp
Color color.NRGBA
}
PortWidget struct {
Theme *Theme
Style *PortStyle
State *ParamState
}
KnobStyle struct {
Diameter unit.Dp
StrokeWidth unit.Dp
Bg color.NRGBA
Pos struct {
Color color.NRGBA
Bg color.NRGBA
}
Neg struct {
Color color.NRGBA
Bg color.NRGBA
}
Indicator struct {
Color color.NRGBA
Width unit.Dp
InnerDiam unit.Dp
OuterDiam unit.Dp
}
Value LabelStyle
Title LabelStyle
}
KnobWidget struct {
Theme *Theme
Value tracker.Parameter
State *ParamState
Style *KnobStyle
Hint string
Scroll bool
}
SwitchStyle struct {
Neutral struct {
Fg color.NRGBA
Bg color.NRGBA
}
Pos struct {
Fg color.NRGBA
Bg color.NRGBA
}
Neg struct {
Fg color.NRGBA
Bg color.NRGBA
}
Width unit.Dp
Height unit.Dp
Outline unit.Dp
Handle unit.Dp
Icon unit.Dp
}
SwitchWidget struct {
Theme *Theme
Value tracker.Parameter
State *ParamState
Style *SwitchStyle
Hint string
Scroll bool
Disabled bool
}
)
// ParamState
func Param(Parameter tracker.Parameter, th *Theme, paramWidget *ParamState, focus, disabled bool) ParamWidget {
return ParamWidget{
Theme: th,
State: paramWidget,
Parameter: Parameter,
Focus: focus,
Disabled: disabled,
}
}
func (p ParamWidget) Layout(gtx C) D {
title := Label(p.Theme, &p.Theme.UnitEditor.Name, p.Parameter.Name())
t := TrackerFromContext(gtx)
widget := func(gtx C) D {
if port, ok := p.Parameter.Port(); t.Params().IsChoosingSendTarget() && ok {
for p.State.clickable.Clicked(gtx) {
t.Params().ChooseSendTarget(p.Parameter.UnitID(), port).Do()
}
k := Port(p.Theme, p.State)
return k.Layout(gtx)
}
switch p.Parameter.Type() {
case tracker.IntegerParameter:
k := Knob(p.Parameter, p.Theme, p.State, p.Parameter.Hint().Label, p.Focus, p.Disabled)
return k.Layout(gtx)
case tracker.BoolParameter:
s := Switch(p.Parameter, p.Theme, p.State, p.Parameter.Hint().Label, p.Focus, p.Disabled)
return s.Layout(gtx)
case tracker.IDParameter:
for p.State.clickable.Clicked(gtx) {
t.Params().ChooseSendSource(p.Parameter.UnitID()).Do()
}
btn := Btn(t.Theme, &t.Theme.Button.Text, &p.State.clickable, "Set", p.Parameter.Hint().Label)
if p.Disabled {
btn.Style = &t.Theme.Button.Disabled
}
return layout.Center.Layout(gtx, btn.Layout)
}
if _, ok := p.Parameter.Port(); ok {
k := Port(p.Theme, p.State)
return k.Layout(gtx)
}
return D{}
}
title.Layout(gtx)
widget(gtx)
return D{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
}
func (s *ParamState) update(gtx C, param tracker.Parameter, scroll bool) {
for scroll {
e, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Scroll,
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
})
if !ok {
break
}
if ev, ok := e.(pointer.Event); ok && ev.Kind == pointer.Scroll {
delta := -int(math.Min(math.Max(float64(ev.Scroll.Y), -1), 1))
param.Add(delta, ev.Modifiers.Contain(key.ModShortcut))
s.tipArea.Appear(gtx.Now)
}
}
}
// KnobWidget
func Knob(v tracker.Parameter, th *Theme, state *ParamState, hint string, scroll, disabled bool) KnobWidget {
ret := KnobWidget{
Theme: th,
Value: v,
State: state,
Style: &th.Knob,
Hint: hint,
Scroll: scroll,
}
if disabled {
ret.Style = &th.DisabledKnob
}
return ret
}
func (k *KnobWidget) Layout(gtx C) D {
k.State.update(gtx, k.Value, k.Scroll)
for {
p, ok := k.State.drag.Update(gtx.Metric, gtx.Source, gesture.Both)
if !ok {
break
}
switch p.Kind {
case pointer.Press:
k.State.dragStartPt = p.Position
k.State.dragStartVal = k.Value.Value()
case pointer.Drag:
// update the value based on the drag amount
m := k.Value.Range()
d := p.Position.Sub(k.State.dragStartPt)
speed := gtx.Dp(512)
if p.Modifiers.Contain(key.ModCtrl) {
speed = gtx.Dp(128)
}
amount := float32(d.X-d.Y) / float32(speed)
newValue := int(float32(k.State.dragStartVal) + amount*float32(m.Max-m.Min))
k.Value.SetValue(newValue)
k.State.tipArea.Appear(gtx.Now)
}
}
for k.Scroll {
ev, ok := gtx.Event(pointer.Filter{Target: k.State, Kinds: pointer.Press})
if !ok {
break
}
if pe, ok := ev.(pointer.Event); ok && pe.Kind == pointer.Press && pe.Buttons == pointer.ButtonSecondary {
k.Value.Reset()
k.State.tipArea.Appear(gtx.Now)
}
}
d := gtx.Dp(k.Style.Diameter)
knob := func(gtx C) D {
m := k.Value.Range()
amount := float32(k.Value.Value()-m.Min) / float32(m.Max-m.Min)
sw := gtx.Dp(k.Style.StrokeWidth)
middle := float32(k.Value.Neutral()-m.Min) / float32(m.Max-m.Min)
pos := max(amount, middle)
neg := min(amount, middle)
if middle > 0 {
k.strokeKnobArc(gtx, k.Style.Neg.Bg, sw, d, 0, neg)
}
if middle < 1 {
k.strokeKnobArc(gtx, k.Style.Pos.Bg, sw, d, pos, 1)
}
if pos > middle {
k.strokeKnobArc(gtx, k.Style.Pos.Color, sw, d, middle, pos)
}
if neg < middle {
k.strokeKnobArc(gtx, k.Style.Neg.Color, sw, d, neg, middle)
}
k.strokeIndicator(gtx, amount)
return D{Size: image.Pt(d, d)}
}
label := Label(k.Theme, &k.Style.Value, strconv.Itoa(k.Value.Value()))
w := func(gtx C) D {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Stacked(knob),
layout.Stacked(label.Layout))
}
if !k.Scroll {
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
}
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
if k.Scroll {
event.Op(gtx.Ops, k.State)
}
k.State.drag.Add(gtx.Ops)
if k.Hint != "" {
c := gtx.Constraints
gtx.Constraints.Max = image.Pt(1e6, 1e6)
return k.State.tipArea.Layout(gtx, Tooltip(k.Theme, k.Hint), func(gtx C) D {
gtx.Constraints = c
return layout.Center.Layout(gtx, w)
})
}
return layout.Center.Layout(gtx, w)
}
func (k *KnobWidget) strokeKnobArc(gtx C, color color.NRGBA, strokeWidth, diameter int, start, end float32) {
rad := float32(diameter) / 2
end = min(max(end, 0), 1)
if end <= 0 {
return
}
startAngle := float64((start*8 + 1) / 10 * 2 * math.Pi)
deltaAngle := (end - start) * 8 * math.Pi / 5
center := f32.Point{X: rad, Y: rad}
r2 := rad - float32(strokeWidth)/2
startPt := f32.Point{X: rad - r2*float32(math.Sin(startAngle)), Y: rad + r2*float32(math.Cos(startAngle))}
segments := [...]stroke.Segment{
stroke.MoveTo(startPt),
stroke.ArcTo(center, deltaAngle),
}
s := stroke.Stroke{
Path: stroke.Path{Segments: segments[:]},
Width: float32(strokeWidth),
Cap: stroke.FlatCap,
}
paint.FillShape(gtx.Ops, color, s.Op(gtx.Ops))
}
func (k *KnobWidget) strokeIndicator(gtx C, amount float32) {
innerRad := float32(gtx.Dp(k.Style.Indicator.InnerDiam)) / 2
outerRad := float32(gtx.Dp(k.Style.Indicator.OuterDiam)) / 2
center := float32(gtx.Dp(k.Style.Diameter)) / 2
angle := (float64(amount)*8 + 1) / 10 * 2 * math.Pi
start := f32.Point{
X: center - innerRad*float32(math.Sin(angle)),
Y: center + innerRad*float32(math.Cos(angle)),
}
end := f32.Point{
X: center - outerRad*float32(math.Sin(angle)),
Y: center + outerRad*float32(math.Cos(angle)),
}
segments := [...]stroke.Segment{
stroke.MoveTo(start),
stroke.LineTo(end),
}
s := stroke.Stroke{
Path: stroke.Path{Segments: segments[:]},
Width: float32(k.Style.Indicator.Width),
Cap: stroke.FlatCap,
}
paint.FillShape(gtx.Ops, k.Style.Indicator.Color, s.Op(gtx.Ops))
}
// SwitchWidget
func Switch(v tracker.Parameter, th *Theme, state *ParamState, hint string, scroll, disabled bool) SwitchWidget {
return SwitchWidget{
Theme: th,
Value: v,
State: state,
Style: &th.Switch,
Hint: hint,
Scroll: scroll,
Disabled: disabled,
}
}
func (s *SwitchWidget) Layout(gtx C) D {
s.State.update(gtx, s.Value, s.Scroll)
for s.Scroll {
ev, ok := gtx.Event(pointer.Filter{Target: s.State, Kinds: pointer.Press})
if !ok {
break
}
if pe, ok := ev.(pointer.Event); ok && pe.Kind == pointer.Press {
delta := 0
if pe.Buttons == pointer.ButtonPrimary {
delta = 1
}
if pe.Buttons == pointer.ButtonSecondary {
delta = -1
}
r := s.Value.Range()
if r.Max < r.Min {
continue
}
newVal := mod(s.Value.Value()+delta-r.Min, r.Max-r.Min+1) + r.Min
s.Value.SetValue(newVal)
s.State.tipArea.Appear(gtx.Now)
}
}
if s.Scroll {
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, s.State)
}
return layout.Center.Layout(gtx, s.layoutSwitch)
}
func (s *SwitchWidget) layoutSwitch(gtx C) D {
width := gtx.Dp(s.Style.Width)
height := gtx.Dp(s.Style.Height)
var fg, bg color.NRGBA
o := 0
switch {
case s.Disabled || s.Value.Value() == 0:
fg = s.Style.Neutral.Fg
bg = s.Style.Neutral.Bg
o = gtx.Dp(s.Style.Outline)
case s.Value.Value() < 0:
fg = s.Style.Neg.Fg
bg = s.Style.Neg.Bg
case s.Value.Value() > 0:
fg = s.Style.Pos.Fg
bg = s.Style.Pos.Bg
}
r := min(width, height) / 2
fillRoundRect := func(ops *op.Ops, rect image.Rectangle, r int, c color.NRGBA) {
defer clip.UniformRRect(rect, r).Push(ops).Pop()
paint.ColorOp{Color: c}.Add(ops)
paint.PaintOp{}.Add(ops)
}
if o > 0 {
fillRoundRect(gtx.Ops, image.Rect(0, 0, width, height), r, fg)
}
fillRoundRect(gtx.Ops, image.Rect(o, o, width-o, height-o), r-o, bg)
a := r
b := width - r
p := a + (b-a)*(s.Value.Value()-s.Value.Range().Min)/(s.Value.Range().Max-s.Value.Range().Min)
circle := func(x, y, r int) clip.Op {
b := image.Rectangle{
Min: image.Pt(x-r, y-r),
Max: image.Pt(x+r, y+r),
}
return clip.Ellipse(b).Op(gtx.Ops)
}
paint.FillShape(gtx.Ops, fg, circle(p, height/2, gtx.Dp(s.Style.Handle)/2))
icon := icons.NavigationClose
if s.Value.Range().Min < 0 {
if s.Value.Value() < 0 {
icon = icons.ImageExposureNeg1
} else if s.Value.Value() > 0 {
icon = icons.ImageExposurePlus1
}
} else if s.Value.Value() > 0 {
icon = icons.NavigationCheck
}
w := s.Theme.Icon(icon)
i := gtx.Dp(s.Style.Icon)
defer op.Offset(image.Pt(p-i/2, (height-i)/2)).Push(gtx.Ops).Pop()
gtx.Constraints = layout.Exact(image.Pt(i, i))
w.Layout(gtx, bg)
return D{Size: image.Pt(width, height)}
}
//
func Port(t *Theme, p *ParamState) PortWidget {
return PortWidget{Theme: t, Style: &t.Port, State: p}
}
func (p *PortWidget) Layout(gtx C) D {
w := func(gtx C) D {
d := gtx.Dp(p.Style.Diameter)
defer clip.Rect(image.Rectangle{Max: image.Pt(d, d)}).Push(gtx.Ops).Pop()
p.strokeCircle(gtx)
return D{Size: image.Pt(d, d)}
}
return p.State.clickable.layout(p.State, gtx, func(gtx C) D {
layout.Center.Layout(gtx, w)
return D{Size: gtx.Constraints.Max}
})
}
func (p *PortWidget) strokeCircle(gtx C) {
sw := float32(gtx.Dp(p.Style.StrokeWidth))
d := float32(gtx.Dp(p.Style.Diameter))
rad := d / 2
center := f32.Point{X: rad, Y: rad}
var path clip.Path
path.Begin(gtx.Ops)
path.MoveTo(f32.Pt(sw/2, rad))
path.ArcTo(center, center, float32(math.Pi*2))
paint.FillShape(gtx.Ops, p.Style.Color,
clip.Stroke{
Path: path.End(),
Width: sw,
}.Op())
}

View File

@ -0,0 +1,334 @@
package gioui
import (
"bytes"
"image"
"image/color"
"io"
"strconv"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type (
PatchPanel struct {
instrList InstrumentList
tools InstrumentTools
instrProps InstrumentProperties
instrPresets InstrumentPresets
instrEditor InstrumentEditor
*tracker.Model
}
InstrumentList struct {
instrumentDragList *DragList
nameEditor *Editor
}
InstrumentTools struct {
EditorTab *Clickable
PresetsTab *Clickable
CommentTab *Clickable
saveInstrumentBtn *Clickable
loadInstrumentBtn *Clickable
copyInstrumentBtn *Clickable
deleteInstrumentBtn *Clickable
octave *NumericUpDownState
enlargeBtn *Clickable
linkInstrTrackBtn *Clickable
newInstrumentBtn *Clickable
octaveHint string
linkDisabledHint string
linkEnabledHint string
enlargeHint, shrinkHint string
addInstrumentHint string
deleteInstrumentHint string
}
)
// PatchPanel methods
func NewPatchPanel(model *tracker.Model) *PatchPanel {
return &PatchPanel{
instrEditor: *NewInstrumentEditor(model),
instrList: MakeInstrList(model),
tools: MakeInstrumentTools(model),
instrProps: *NewInstrumentProperties(),
instrPresets: *NewInstrumentPresets(model),
Model: model,
}
}
func (pp *PatchPanel) Layout(gtx C) D {
tr := TrackerFromContext(gtx)
bottom := func(gtx C) D {
switch {
case tr.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
return pp.instrProps.layout(gtx)
case tr.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
return pp.instrPresets.layout(gtx)
default: // editor
return pp.instrEditor.layout(gtx)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(pp.instrList.Layout),
layout.Rigid(pp.tools.Layout),
layout.Flexed(1, bottom),
)
}
func (pp *PatchPanel) BottomTags(level int, yield TagYieldFunc) bool {
switch {
case pp.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
return pp.instrProps.Tags(level, yield)
case pp.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
return pp.instrPresets.Tags(level, yield)
default: // editor
return pp.instrEditor.Tags(level, yield)
}
}
func (pp *PatchPanel) Tags(level int, yield TagYieldFunc) bool {
return pp.instrList.Tags(level, yield) &&
pp.tools.Tags(level, yield) &&
pp.BottomTags(level, yield)
}
// TreeFocused returns true if any of the tags in the patch panel is focused
func (pp *PatchPanel) TreeFocused(gtx C) bool {
return !pp.Tags(0, func(_ int, tag event.Tag) bool {
return !gtx.Focused(tag)
})
}
// InstrumentTools methods
func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
ret := InstrumentTools{
EditorTab: new(Clickable),
PresetsTab: new(Clickable),
CommentTab: new(Clickable),
deleteInstrumentBtn: new(Clickable),
copyInstrumentBtn: new(Clickable),
saveInstrumentBtn: new(Clickable),
loadInstrumentBtn: new(Clickable),
deleteInstrumentHint: makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument"),
octave: NewNumericUpDownState(),
enlargeBtn: new(Clickable),
linkInstrTrackBtn: new(Clickable),
newInstrumentBtn: new(Clickable),
octaveHint: makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd"),
linkDisabledHint: makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle"),
linkEnabledHint: makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle"),
enlargeHint: makeHint("Enlarge", " (%s)", "InstrEnlargedToggle"),
shrinkHint: makeHint("Shrink", " (%s)", "InstrEnlargedToggle"),
addInstrumentHint: makeHint("Add\ninstrument", "\n(%s)", "AddInstrument"),
}
return ret
}
func (it *InstrumentTools) Layout(gtx C) D {
t := TrackerFromContext(gtx)
it.update(gtx, t)
editorBtn := TabBtn(tracker.MakeBool((*editorTab)(t.Model)), t.Theme, it.EditorTab, "Editor", "")
presetsBtn := TabBtn(tracker.MakeBool((*presetsTab)(t.Model)), t.Theme, it.PresetsTab, "Presets", "")
commentBtn := TabBtn(tracker.MakeBool((*commentTab)(t.Model)), t.Theme, it.CommentTab, "Properties", "")
octave := NumUpDown(t.Note().Octave(), t.Theme, t.OctaveNumberInput, "Octave")
linkInstrTrackBtn := ToggleIconBtn(t.Track().LinkInstrument(), t.Theme, it.linkInstrTrackBtn, icons.NotificationSyncDisabled, icons.NotificationSync, it.linkDisabledHint, it.linkEnabledHint)
instrEnlargedBtn := ToggleIconBtn(t.Play().TrackerHidden(), t.Theme, it.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, it.enlargeHint, it.shrinkHint)
addInstrumentBtn := ActionIconBtn(t.Model.Instrument().Add(), t.Theme, it.newInstrumentBtn, icons.ContentAdd, it.addInstrumentHint)
saveInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.saveInstrumentBtn, icons.ContentSave, "Save instrument")
loadInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument")
copyInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument")
deleteInstrumentBtn := ActionIconBtn(t.Instrument().Delete(), t.Theme, it.deleteInstrumentBtn, icons.ActionDelete, it.deleteInstrumentHint)
btns := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: 6}.Layout),
layout.Rigid(editorBtn.Layout),
layout.Rigid(presetsBtn.Layout),
layout.Rigid(commentBtn.Layout),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(layout.Spacer{Width: 4}.Layout),
layout.Rigid(Label(t.Theme, &t.Theme.InstrumentEditor.Octave, "Octave").Layout),
layout.Rigid(octave.Layout),
layout.Rigid(linkInstrTrackBtn.Layout),
layout.Rigid(instrEnlargedBtn.Layout),
layout.Rigid(copyInstrumentBtn.Layout),
layout.Rigid(saveInstrumentBtn.Layout),
layout.Rigid(loadInstrumentBtn.Layout),
layout.Rigid(deleteInstrumentBtn.Layout),
layout.Rigid(addInstrumentBtn.Layout),
)
}
return Surface{Height: 4, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, btns)
}
type (
editorTab tracker.Model
presetsTab tracker.Model
commentTab tracker.Model
)
func (e *editorTab) Value() bool {
return (*tracker.Model)(e).Instrument().Tab().Value() == int(tracker.InstrumentEditorTab)
}
func (e *editorTab) SetValue(val bool) {
if val {
(*tracker.Model)(e).Instrument().Tab().SetValue(int(tracker.InstrumentEditorTab))
}
}
func (p *presetsTab) Value() bool {
return (*tracker.Model)(p).Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab)
}
func (p *presetsTab) SetValue(val bool) {
if val {
(*tracker.Model)(p).Instrument().Tab().SetValue(int(tracker.InstrumentPresetsTab))
}
}
func (c *commentTab) Value() bool {
return (*tracker.Model)(c).Instrument().Tab().Value() == int(tracker.InstrumentCommentTab)
}
func (c *commentTab) SetValue(val bool) {
if val {
(*tracker.Model)(c).Instrument().Tab().SetValue(int(tracker.InstrumentCommentTab))
}
}
func (it *InstrumentTools) update(gtx C, tr *Tracker) {
for it.copyInstrumentBtn.Clicked(gtx) {
if contents, ok := tr.Instrument().List().CopyElements(); ok {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
tr.Alerts().Add("Instrument copied to clipboard", tracker.Info)
}
}
for it.saveInstrumentBtn.Clicked(gtx) {
writer, err := tr.Explorer.CreateFile(tr.Instrument().Name().Value() + ".yml")
if err != nil {
continue
}
tr.Instrument().Write(writer)
}
for it.loadInstrumentBtn.Clicked(gtx) {
reader, err := tr.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp")
if err != nil {
continue
}
tr.Instrument().Read(reader)
}
}
func (it *InstrumentTools) Tags(level int, yield TagYieldFunc) bool {
return true
}
// InstrumentList methods
func MakeInstrList(model *tracker.Model) InstrumentList {
return InstrumentList{
instrumentDragList: NewDragList(model.Instrument().List(), layout.Horizontal),
nameEditor: NewEditor(true, true, text.Middle),
}
}
func (il *InstrumentList) Layout(gtx C) D {
t := TrackerFromContext(gtx)
il.update(gtx, t)
gtx.Constraints.Max.Y = gtx.Dp(36)
gtx.Constraints.Min.Y = gtx.Dp(36)
element := func(gtx C, i int) D {
grabhandle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, strconv.Itoa(i+1))
label := func(gtx C) D {
name, level, mute, ok := t.Instrument().Item(i)
if !ok {
labelStyle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, "")
return layout.Center.Layout(gtx, labelStyle.Layout)
}
s := t.Theme.InstrumentEditor.InstrumentList.NameMuted
if !mute {
s = t.Theme.InstrumentEditor.InstrumentList.Name
k := byte(255 - level*127)
s.Color = color.NRGBA{R: 255, G: k, B: 255, A: 255}
}
if i == il.instrumentDragList.TrackerList.Selected() {
for il.nameEditor.Update(gtx, t.Instrument().Name()) != EditorEventNone {
il.instrumentDragList.Focus()
}
return layout.Center.Layout(gtx, func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
return il.nameEditor.Layout(gtx, t.Instrument().Name(), t.Theme, &s, "Instr")
})
}
if name == "" {
name = "Instr"
}
l := s.AsLabelStyle()
return layout.Center.Layout(gtx, Label(t.Theme, &l, name).Layout)
}
return layout.Center.Layout(gtx, func(gtx C) D {
return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(grabhandle.Layout),
layout.Rigid(label),
)
})
})
}
instrumentList := FilledDragList(t.Theme, il.instrumentDragList)
instrumentList.ScrollBar = t.Theme.InstrumentEditor.InstrumentList.ScrollBar
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
dims := instrumentList.Layout(gtx, element, nil)
gtx.Constraints = layout.Exact(dims.Size)
instrumentList.LayoutScrollBar(gtx)
return dims
}
func (il *InstrumentList) update(gtx C, t *Tracker) {
for {
event, ok := gtx.Event(
key.Filter{Focus: il.instrumentDragList, Name: key.NameDownArrow},
key.Filter{Focus: il.instrumentDragList, Name: key.NameReturn},
key.Filter{Focus: il.instrumentDragList, Name: key.NameEnter},
)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press {
switch e.Name {
case key.NameDownArrow:
var tagged Tagged
switch {
case t.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
tagged = &t.PatchPanel.instrProps
case t.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
tagged = &t.PatchPanel.instrPresets
default: // editor
tagged = &t.PatchPanel.instrEditor
}
if tag, ok := firstTag(tagged); ok {
gtx.Execute(key.FocusCmd{Tag: tag})
}
case key.NameReturn, key.NameEnter:
il.nameEditor.Focus()
}
}
}
}
func (il *InstrumentList) Tags(level int, yield TagYieldFunc) bool {
return yield(level, il.instrumentDragList)
}

186
tracker/gioui/plot.go Normal file
View File

@ -0,0 +1,186 @@
package gioui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
type (
Plot struct {
origXlim, origYlim plotRange
fixedYLevel float32
xScale, yScale float32
xOffset float32
dragging bool
dragId pointer.ID
dragStartPoint f32.Point
}
PlotStyle struct {
CurveColors [3]color.NRGBA `yaml:",flow"`
LimitColor color.NRGBA `yaml:",flow"`
CursorColor color.NRGBA `yaml:",flow"`
Ticks LabelStyle
DpPerTick unit.Dp
}
PlotDataFunc func(chn int, xr plotRange) (yr plotRange, ok bool)
PlotTickFunc func(r plotRange, num int, yield func(pos float32, label string))
plotRange struct{ a, b float32 }
plotRel float32
plotPx int
plotLogScale float32
)
func NewPlot(xlim, ylim plotRange, fixedYLevel float32) *Plot {
return &Plot{
origXlim: xlim,
origYlim: ylim,
fixedYLevel: fixedYLevel,
}
}
func (p *Plot) Layout(gtx C, data PlotDataFunc, xticks, yticks PlotTickFunc, cursornx float32, numchns int) D {
p.update(gtx)
t := TrackerFromContext(gtx)
style := t.Theme.Plot
s := gtx.Constraints.Max
if s.X <= 1 || s.Y <= 1 {
return D{}
}
defer clip.Rect(image.Rectangle{Max: s}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, p)
xlim := p.xlim()
ylim := p.ylim()
// draw tick marks
numxticks := s.X / gtx.Dp(style.DpPerTick)
xticks(xlim, numxticks, func(x float32, txt string) {
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
sx := plotPx(s.X).toScreen(xlim.toRelative(x))
fillRect(gtx, clip.Rect{Min: image.Pt(sx, 0), Max: image.Pt(sx+1, s.Y)})
defer op.Offset(image.Pt(sx, gtx.Dp(2))).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
})
numyticks := s.Y / gtx.Dp(style.DpPerTick)
yticks(ylim, numyticks, func(y float32, txt string) {
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
sy := plotPx(s.Y).toScreen(ylim.toRelative(y))
fillRect(gtx, clip.Rect{Min: image.Pt(0, sy), Max: image.Pt(s.X, sy+1)})
defer op.Offset(image.Pt(gtx.Dp(2), sy)).Push(gtx.Ops).Pop()
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
})
// draw cursor
if cursornx == cursornx { // check for NaN
paint.ColorOp{Color: style.CursorColor}.Add(gtx.Ops)
csx := plotPx(s.X).toScreen(xlim.toRelative(cursornx))
fillRect(gtx, clip.Rect{Min: image.Pt(csx, 0), Max: image.Pt(csx+1, s.Y)})
}
// draw curves
for chn := range numchns {
paint.ColorOp{Color: style.CurveColors[chn]}.Add(gtx.Ops)
right := xlim.fromRelative(plotPx(s.X).fromScreen(0))
for sx := range s.X {
// left and right is the sample range covered by the pixel
left := right
right = xlim.fromRelative(plotPx(s.X).fromScreen(sx + 1))
yr, ok := data(chn, plotRange{left, right})
if !ok {
continue
}
y1 := plotPx(s.Y).toScreen(ylim.toRelative(yr.a))
y2 := plotPx(s.Y).toScreen(ylim.toRelative(yr.b))
fillRect(gtx, clip.Rect{Min: image.Pt(sx, min(y1, y2)), Max: image.Pt(sx+1, max(y1, y2)+1)})
}
}
return D{Size: s}
}
func (r plotRange) toRelative(f float32) plotRel { return plotRel((f - r.a) / (r.b - r.a)) }
func (r plotRange) fromRelative(pr plotRel) float32 { return float32(pr)*(r.b-r.a) + r.a }
func (r plotRange) offset(o float32) plotRange { return plotRange{r.a + o, r.b + o} }
func (r plotRange) scale(logScale float32) plotRange {
s := float32(math.Exp(float64(logScale)))
return plotRange{r.a * s, r.b * s}
}
func (s plotPx) toScreen(pr plotRel) int { return int(float32(pr)*float32(s-1) + 0.5) }
func (s plotPx) fromScreen(px int) plotRel { return plotRel(float32(px) / float32(s-1)) }
func (s plotPx) fromScreenF32(px float32) plotRel { return plotRel(px / float32(s-1)) }
func (o *Plot) xlim() plotRange { return o.origXlim.scale(o.xScale).offset(o.xOffset) }
func (o *Plot) ylim() plotRange {
return o.origYlim.offset(-o.fixedYLevel).scale(o.yScale).offset(o.fixedYLevel)
}
func fillRect(gtx C, rect clip.Rect) {
stack := rect.Push(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}
func (o *Plot) update(gtx C) {
s := gtx.Constraints.Max
for {
ev, ok := gtx.Event(pointer.Filter{
Target: o,
Kinds: pointer.Scroll | pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
})
if !ok {
break
}
if e, ok := ev.(pointer.Event); ok {
switch e.Kind {
case pointer.Scroll:
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xScale += float32(min(max(-1, int(e.Scroll.Y)), 1)) * 0.1
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xOffset += x1 - x2
case pointer.Press:
if e.Buttons&pointer.ButtonSecondary != 0 {
o.xOffset = 0
o.xScale = 0
o.yScale = 0
}
if e.Buttons&pointer.ButtonPrimary != 0 {
o.dragging = true
o.dragId = e.PointerID
o.dragStartPoint = e.Position
}
case pointer.Drag:
if e.Buttons&pointer.ButtonPrimary != 0 && o.dragging && e.PointerID == o.dragId {
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(o.dragStartPoint.X))
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
o.xOffset += x1 - x2
num := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(e.Position.Y))
den := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(o.dragStartPoint.Y))
num -= o.fixedYLevel
den -= o.fixedYLevel
if l := math.Abs(float64(num / den)); l > 1e-3 && l < 1e3 {
o.yScale -= float32(math.Log(l))
o.yScale = min(max(o.yScale, -1e3), 1e3)
}
o.dragStartPoint = e.Position
}
case pointer.Release | pointer.Cancel:
o.dragging = false
}
}
}
}

View File

@ -13,69 +13,51 @@ import (
"gioui.org/unit"
)
type PopupStyle struct {
Visible *bool
SurfaceColor color.NRGBA
ShadowColor color.NRGBA
ShadowN unit.Dp
ShadowE unit.Dp
ShadowW unit.Dp
ShadowS unit.Dp
SE, SW, NW, NE unit.Dp
}
type (
PopupStyle struct {
Color color.NRGBA
CornerRadii struct {
SE, SW, NW, NE unit.Dp
}
Shadow struct {
Color color.NRGBA
N, E, W, S unit.Dp
}
}
func Popup(visible *bool) PopupStyle {
return PopupStyle{
Visible: visible,
SurfaceColor: popupSurfaceColor,
ShadowColor: popupShadowColor,
ShadowN: unit.Dp(2),
ShadowE: unit.Dp(2),
ShadowS: unit.Dp(2),
ShadowW: unit.Dp(2),
SE: unit.Dp(6),
SW: unit.Dp(6),
NW: unit.Dp(6),
NE: unit.Dp(6),
PopupWidget struct {
Style *PopupStyle
Visible *bool
}
)
func Popup(th *Theme, visible *bool) PopupWidget {
return PopupWidget{
Style: &th.Popup.Dialog,
Visible: visible,
}
}
func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
func (s PopupWidget) Layout(gtx C, contents layout.Widget) D {
s.update(gtx)
if !*s.Visible {
return D{}
}
for {
event, ok := gtx.Event(pointer.Filter{
Target: s.Visible,
Kinds: pointer.Press,
})
if !ok {
break
}
e, ok := event.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
*s.Visible = false
}
}
bg := func(gtx C) D {
rrect := clip.RRect{
Rect: image.Rectangle{Max: gtx.Constraints.Min},
SE: gtx.Dp(s.SE),
SW: gtx.Dp(s.SW),
NW: gtx.Dp(s.NW),
NE: gtx.Dp(s.NE),
SE: gtx.Dp(s.Style.CornerRadii.SE),
SW: gtx.Dp(s.Style.CornerRadii.SW),
NW: gtx.Dp(s.Style.CornerRadii.NW),
NE: gtx.Dp(s.Style.CornerRadii.NE),
}
rrect2 := rrect
rrect2.Rect.Min = rrect2.Rect.Min.Sub(image.Pt(gtx.Dp(s.ShadowW), gtx.Dp(s.ShadowN)))
rrect2.Rect.Max = rrect2.Rect.Max.Add(image.Pt(gtx.Dp(s.ShadowE), gtx.Dp(s.ShadowS)))
paint.FillShape(gtx.Ops, s.ShadowColor, rrect2.Op(gtx.Ops))
paint.FillShape(gtx.Ops, s.SurfaceColor, rrect.Op(gtx.Ops))
rrect2.Rect.Min = rrect2.Rect.Min.Sub(image.Pt(gtx.Dp(s.Style.Shadow.W), gtx.Dp(s.Style.Shadow.N)))
rrect2.Rect.Max = rrect2.Rect.Max.Add(image.Pt(gtx.Dp(s.Style.Shadow.E), gtx.Dp(s.Style.Shadow.S)))
paint.FillShape(gtx.Ops, s.Style.Shadow.Color, rrect2.Op(gtx.Ops))
paint.FillShape(gtx.Ops, s.Style.Color, rrect.Op(gtx.Ops))
area := clip.Rect(image.Rect(-1e6, -1e6, 1e6, 1e6)).Push(gtx.Ops)
event.Op(gtx.Ops, s.Visible)
area.Pop()
@ -94,4 +76,24 @@ func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
return dims
}
func (s *PopupWidget) update(gtx C) {
for {
event, ok := gtx.Event(pointer.Filter{
Target: s.Visible,
Kinds: pointer.Press,
})
if !ok {
break
}
e, ok := event.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
*s.Visible = false
}
}
}
var dummyTag bool

View File

@ -1,81 +0,0 @@
package gioui
import (
"image"
"image/color"
"time"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
)
type PopupAlert struct {
alerts *tracker.Alerts
prevUpdate time.Time
shaper *text.Shaper
}
var alertSpeed = 150 * time.Millisecond
var alertMargin = layout.UniformInset(unit.Dp(6))
var alertInset = layout.UniformInset(unit.Dp(6))
func NewPopupAlert(alerts *tracker.Alerts, shaper *text.Shaper) *PopupAlert {
return &PopupAlert{alerts: alerts, shaper: shaper, prevUpdate: time.Now()}
}
func (a *PopupAlert) Layout(gtx C) D {
now := time.Now()
if a.alerts.Update(now.Sub(a.prevUpdate)) {
gtx.Execute(op.InvalidateCmd{At: now.Add(50 * time.Millisecond)})
}
a.prevUpdate = now
var totalY float64
a.alerts.Iterate(func(alert tracker.Alert) {
var color, textColor, shadeColor color.NRGBA
switch alert.Priority {
case tracker.Warning:
color = warningColor
textColor = black
case tracker.Error:
color = errorColor
textColor = black
default:
color = popupSurfaceColor
textColor = white
shadeColor = black
}
bgWidget := func(gtx C) D {
paint.FillShape(gtx.Ops, color, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
return D{Size: gtx.Constraints.Min}
}
labelStyle := LabelStyle{Text: alert.Message, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper}
alertMargin.Layout(gtx, func(gtx C) D {
return layout.S.Layout(gtx, func(gtx C) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
recording := op.Record(gtx.Ops)
dims := layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(bgWidget),
layout.Stacked(func(gtx C) D {
return alertInset.Layout(gtx, labelStyle.Layout)
}),
)
macro := recording.Stop()
delta := float64(dims.Size.Y + gtx.Dp(alertMargin.Bottom))
op.Offset(image.Point{0, int(-totalY*alert.FadeLevel + delta*(1-alert.FadeLevel))}).Add((gtx.Ops))
totalY += delta
macro.Add(gtx.Ops)
return dims
})
})
})
return D{}
}

View File

@ -0,0 +1,67 @@
package gioui
import (
"bytes"
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"gioui.org/unit"
)
type (
Preferences struct {
Window WindowPreferences
}
WindowPreferences struct {
Width int
Height int
Maximized bool `yaml:",omitempty"`
}
)
//go:embed preferences.yml
var defaultPreferences []byte
// ReadCustomConfig modifies the target argument, i.e. needs a pointer. Just
// fails silently if the file cannot be found/read, but will warn about
// malformed files.
func ReadCustomConfig(filename string, target any) error {
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
path := filepath.Join(configDir, "sointu", filename)
bytes, err := os.ReadFile(path)
if err != nil {
return nil
}
if err := yaml.Unmarshal(bytes, target); err != nil {
return fmt.Errorf("ReadCustomConfig %v: %w", filename, err)
}
return nil
}
// ReadConfig first unmarshals the defaultConfig which should be the embedded
// default config, and then tries to read the custom config with
// ReadCustomConfig. It panics right away if the embedded defaultConfig could
// not be parsed as yaml as this should never happen except during development.
// The returned error should be treated as a warning: this function will always
// return at least the default config, and the warning will just tell if there
// was a problem parsing the custom config.
func ReadConfig(defaultConfig []byte, path string, target any) (warn error) {
dec := yaml.NewDecoder(bytes.NewReader(defaultConfig))
dec.KnownFields(true)
if err := dec.Decode(target); err != nil {
panic(fmt.Errorf("ReadConfig %v failed to unmarshal the embedded default config: %w", path, err))
}
return ReadCustomConfig(path, target)
}
func (p Preferences) WindowSize() (unit.Dp, unit.Dp) {
return unit.Dp(p.Window.Width), unit.Dp(p.Window.Height)
}

View File

@ -0,0 +1,4 @@
window:
width: 800
height: 600
maximized: false

View File

@ -14,7 +14,6 @@ import (
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/unit"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
@ -22,9 +21,11 @@ type ScrollTable struct {
ColTitleList *DragList
RowTitleList *DragList
Table tracker.Table
focused bool
requestFocus bool
cursorMoved bool
eventFilters []event.Filter
drag bool
dragID pointer.ID
}
type ScrollTableStyle struct {
@ -36,23 +37,43 @@ type ScrollTableStyle struct {
ColumnTitleHeight unit.Dp
CellWidth unit.Dp
CellHeight unit.Dp
element func(gtx C, x, y int) D
}
func NewScrollTable(table tracker.Table, vertList, horizList tracker.List) *ScrollTable {
return &ScrollTable{
ret := &ScrollTable{
Table: table,
ColTitleList: NewDragList(vertList, layout.Horizontal),
RowTitleList: NewDragList(horizList, layout.Vertical),
}
ret.eventFilters = []event.Filter{
key.FocusFilter{Target: ret},
transfer.TargetFilter{Target: ret, Type: "application/text"},
pointer.Filter{Target: ret, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel},
key.Filter{Focus: ret, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: ret, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: ret, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: ret, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: ret, Name: key.NamePageUp, Optional: key.ModShift},
key.Filter{Focus: ret, Name: key.NamePageDown, Optional: key.ModShift},
key.Filter{Focus: ret, Name: key.NameHome, Optional: key.ModShift},
key.Filter{Focus: ret, Name: key.NameEnd, Optional: key.ModShift},
key.Filter{Focus: ret, Name: key.NameDeleteBackward},
key.Filter{Focus: ret, Name: key.NameDeleteForward},
}
for k, a := range keyBindingMap {
switch a {
case "Copy", "Paste", "Cut", "Increase", "Decrease", "IncreaseMore", "DecreaseMore":
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret, Name: k.Name, Required: k.Modifiers})
}
}
return ret
}
func FilledScrollTable(th *material.Theme, scrollTable *ScrollTable, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) ScrollTableStyle {
func FilledScrollTable(th *Theme, scrollTable *ScrollTable) ScrollTableStyle {
return ScrollTableStyle{
RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList, rowTitle, rowTitleBg),
ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList, colTitle, colTitleBg),
RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList),
ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList),
ScrollTable: scrollTable,
element: element,
ScrollBarWidth: unit.Dp(14),
RowTitleWidth: unit.Dp(30),
ColumnTitleHeight: unit.Dp(16),
@ -71,8 +92,17 @@ func (st *ScrollTable) Focus() {
st.requestFocus = true
}
func (st *ScrollTable) Focused() bool {
return st.focused
func (st *ScrollTable) Tags(level int, yield TagYieldFunc) bool {
return yield(level+1, st.RowTitleList) &&
yield(level+1, st.ColTitleList) &&
yield(level, st)
}
// TreeFocused return true if any of the tags in the scroll table has focus.
func (st *ScrollTable) TreeFocused(gtx C) bool {
return !st.Tags(0, func(_ int, tag event.Tag) bool {
return !gtx.Focused(tag)
})
}
func (st *ScrollTable) EnsureCursorVisible() {
@ -80,87 +110,82 @@ func (st *ScrollTable) EnsureCursorVisible() {
st.RowTitleList.EnsureVisible(st.Table.Cursor().Y)
}
func (st *ScrollTable) ChildFocused() bool {
return st.ColTitleList.Focused() || st.RowTitleList.Focused()
}
func (s ScrollTableStyle) Layout(gtx C) D {
func (s ScrollTableStyle) Layout(gtx C, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) D {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, s.ScrollTable)
p := image.Pt(gtx.Dp(s.RowTitleWidth), gtx.Dp(s.ColumnTitleHeight))
s.handleEvents(gtx, p)
return Surface{Gray: 24, Focus: s.ScrollTable.Focused() || s.ScrollTable.ChildFocused()}.Layout(gtx, func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
dims := gtx.Constraints.Max
s.layoutColTitles(gtx, p)
s.layoutRowTitles(gtx, p)
defer op.Offset(p).Push(gtx.Ops).Pop()
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X-p.X, gtx.Constraints.Max.Y-p.Y))
s.layoutTable(gtx, p)
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
s.layoutOffset(gtx, image.Pt(p.X, 0), func(gtx C) D { return s.ColTitleStyle.Layout(gtx, colTitle, colTitleBg) })
s.layoutOffset(gtx, image.Pt(0, p.Y), func(gtx C) D { return s.RowTitleStyle.Layout(gtx, rowTitle, rowTitleBg) })
s.layoutOffset(gtx, p, func(gtx C) D {
s.layoutTable(gtx, element)
s.RowTitleStyle.LayoutScrollBar(gtx)
s.ColTitleStyle.LayoutScrollBar(gtx)
return D{Size: dims}
return D{Size: gtx.Constraints.Max}
})
return D{Size: gtx.Constraints.Max}
}
func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) {
for {
e, ok := gtx.Event(
key.FocusFilter{Target: s.ScrollTable},
transfer.TargetFilter{Target: s.ScrollTable, Type: "application/text"},
pointer.Filter{Target: s.ScrollTable, Kinds: pointer.Press},
key.Filter{Focus: s.ScrollTable, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: s.ScrollTable, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: s.ScrollTable, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: s.ScrollTable, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
key.Filter{Focus: s.ScrollTable, Name: key.NamePageUp, Optional: key.ModShift},
key.Filter{Focus: s.ScrollTable, Name: key.NamePageDown, Optional: key.ModShift},
key.Filter{Focus: s.ScrollTable, Name: key.NameHome, Optional: key.ModShift},
key.Filter{Focus: s.ScrollTable, Name: key.NameEnd, Optional: key.ModShift},
key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteBackward},
key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteForward},
key.Filter{Focus: s.ScrollTable, Name: "C", Required: key.ModShortcut},
key.Filter{Focus: s.ScrollTable, Name: "V", Required: key.ModShortcut},
key.Filter{Focus: s.ScrollTable, Name: "X", Required: key.ModShortcut},
key.Filter{Focus: s.ScrollTable, Name: "+"},
key.Filter{Focus: s.ScrollTable, Name: "-"},
)
e, ok := gtx.Event(s.ScrollTable.eventFilters...)
if !ok {
break
}
switch e := e.(type) {
case key.FocusEvent:
s.ScrollTable.focused = e.Focus
case pointer.Event:
if int(e.Position.X) < p.X || int(e.Position.Y) < p.Y {
break
switch e.Kind {
case pointer.Press:
if s.ScrollTable.drag {
break
}
s.ScrollTable.dragID = e.PointerID
s.ScrollTable.drag = true
fallthrough
case pointer.Drag:
if s.ScrollTable.dragID != e.PointerID {
break
}
if int(e.Position.X) < p.X || int(e.Position.Y) < p.Y {
break
}
e.Position.X -= float32(p.X)
e.Position.Y -= float32(p.Y)
if e.Kind == pointer.Press {
gtx.Execute(key.FocusCmd{Tag: s.ScrollTable})
}
dx := (e.Position.X + float32(s.ScrollTable.ColTitleList.List.Position.Offset)) / float32(gtx.Dp(s.CellWidth))
dy := (e.Position.Y + float32(s.ScrollTable.RowTitleList.List.Position.Offset)) / float32(gtx.Dp(s.CellHeight))
x := dx + float32(s.ScrollTable.ColTitleList.List.Position.First)
y := dy + float32(s.ScrollTable.RowTitleList.List.Position.First)
cursorPoint := tracker.Point{X: int(x), Y: int(y)}
s.ScrollTable.Table.SetCursor2(cursorPoint)
if e.Kind == pointer.Press && !e.Modifiers.Contain(key.ModShift) {
s.ScrollTable.Table.SetCursor(cursorPoint)
}
s.ScrollTable.cursorMoved = true
case pointer.Release:
fallthrough
case pointer.Cancel:
s.ScrollTable.drag = false
}
e.Position.X -= float32(p.X)
e.Position.Y -= float32(p.Y)
if e.Kind == pointer.Press {
gtx.Execute(key.FocusCmd{Tag: s.ScrollTable})
}
dx := (int(e.Position.X) + s.ScrollTable.ColTitleList.List.Position.Offset) / gtx.Dp(s.CellWidth)
dy := (int(e.Position.Y) + s.ScrollTable.RowTitleList.List.Position.Offset) / gtx.Dp(s.CellHeight)
x := dx + s.ScrollTable.ColTitleList.List.Position.First
y := dy + s.ScrollTable.RowTitleList.List.Position.First
s.ScrollTable.Table.SetCursor(
tracker.Point{X: x, Y: y},
)
if !e.Modifiers.Contain(key.ModShift) {
s.ScrollTable.Table.SetCursor2(s.ScrollTable.Table.Cursor())
}
s.ScrollTable.cursorMoved = true
case key.Event:
if e.State == key.Press {
s.ScrollTable.command(gtx, e)
s.ScrollTable.command(gtx, e, p)
}
case transfer.DataEvent:
if b, err := io.ReadAll(e.Open()); err == nil {
s.ScrollTable.Table.Paste(b)
}
case key.FocusEvent:
if e.Focus {
s.ScrollTable.ColTitleList.EnsureVisible(s.ScrollTable.Table.Cursor().X)
s.ScrollTable.RowTitleList.EnsureVisible(s.ScrollTable.Table.Cursor().Y)
}
}
}
@ -189,7 +214,7 @@ func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) {
}
}
func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) {
func (s ScrollTableStyle) layoutTable(gtx C, element func(gtx C, x, y int) D) {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
if s.ScrollTable.requestFocus {
@ -207,85 +232,75 @@ func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) {
for x := 0; x < colP.Count; x++ {
for y := 0; y < rowP.Count; y++ {
o := op.Offset(image.Pt(cellWidth*x, cellHeight*y)).Push(gtx.Ops)
s.element(gtx, x+colP.First, y+rowP.First)
element(gtx, x+colP.First, y+rowP.First)
o.Pop()
}
}
}
func (s *ScrollTableStyle) layoutRowTitles(gtx C, p image.Point) {
defer op.Offset(image.Pt(0, p.Y)).Push(gtx.Ops).Pop()
gtx.Constraints.Min.X = p.X
gtx.Constraints.Max.Y -= p.Y
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
s.RowTitleStyle.Layout(gtx)
func (s ScrollTableStyle) layoutOffset(gtx C, offset image.Point, element func(gtx C) D) {
gtx.Constraints = layout.Exact(gtx.Constraints.Max.Sub(offset))
defer op.Offset(offset).Push(gtx.Ops).Pop()
element(gtx)
}
func (s *ScrollTableStyle) layoutColTitles(gtx C, p image.Point) {
defer op.Offset(image.Pt(p.X, 0)).Push(gtx.Ops).Pop()
gtx.Constraints.Min.Y = p.Y
gtx.Constraints.Max.X -= p.X
gtx.Constraints.Min.X = gtx.Constraints.Max.X
s.ColTitleStyle.Layout(gtx)
}
func (s *ScrollTable) command(gtx C, e key.Event) {
func (s *ScrollTable) command(gtx C, e key.Event, p image.Point) {
stepX := 1
stepY := 1
if e.Modifiers.Contain(key.ModAlt) {
stepX = intMax(s.ColTitleList.List.Position.Count-3, 8)
stepY = intMax(s.RowTitleList.List.Position.Count-3, 8)
stepX = max(s.ColTitleList.List.Position.Count-3, 8)
stepY = max(s.RowTitleList.List.Position.Count-3, 8)
} else if e.Modifiers.Contain(key.ModCtrl) {
stepX = 1e6
stepY = 1e6
}
switch e.Name {
case "X", "C":
if e.Modifiers.Contain(key.ModShortcut) {
contents, ok := s.Table.Copy()
if !ok {
return
}
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
if e.Name == "X" {
s.Table.Clear()
}
return
}
case "V":
if e.Modifiers.Contain(key.ModShortcut) {
gtx.Execute(clipboard.ReadCmd{Tag: s})
}
return
case key.NameDeleteBackward, key.NameDeleteForward:
s.Table.Clear()
return
case key.NameUpArrow:
if !s.Table.MoveCursor(0, -stepY) && stepY == 1 {
if !s.Table.MoveCursor(0, -stepY) && stepY == 1 && p.Y > 0 {
s.ColTitleList.Focus()
}
case key.NameDownArrow:
s.Table.MoveCursor(0, stepY)
case key.NameLeftArrow:
if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 {
if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 && p.X > 0 {
s.RowTitleList.Focus()
}
case key.NameRightArrow:
s.Table.MoveCursor(stepX, 0)
case key.NamePageUp:
s.Table.MoveCursor(0, -intMax(s.RowTitleList.List.Position.Count-3, 8))
s.Table.MoveCursor(0, -max(s.RowTitleList.List.Position.Count-3, 8))
case key.NamePageDown:
s.Table.MoveCursor(0, intMax(s.RowTitleList.List.Position.Count-3, 8))
s.Table.MoveCursor(0, max(s.RowTitleList.List.Position.Count-3, 8))
case key.NameHome:
s.Table.SetCursorX(0)
case key.NameEnd:
s.Table.SetCursorX(s.Table.Width() - 1)
case "+":
s.Table.Add(1)
return
case "-":
s.Table.Add(-1)
return
default:
a := keyBindingMap[e]
switch a {
case "Copy", "Cut":
contents, ok := s.Table.Copy()
if !ok {
return
}
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
if a == "Cut" {
s.Table.Clear()
}
return
case "Paste":
gtx.Execute(clipboard.ReadCmd{Tag: s})
return
case "Increase", "IncreaseMore":
s.Table.Add(1, a == "IncreaseMore")
return
case "Decrease", "DecreaseMore":
s.Table.Add(-1, a == "DecreaseMore")
return
}
}
if !e.Modifiers.Contain(key.ModShift) {
s.Table.SetCursor2(s.Table.Cursor())

View File

@ -2,6 +2,7 @@ package gioui
import (
"image"
"image/color"
"gioui.org/f32"
"gioui.org/io/event"
@ -21,19 +22,27 @@ type ScrollBar struct {
tag bool
}
func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Position) D {
type ScrollBarStyle struct {
Color color.NRGBA
Width unit.Dp
Gradient color.NRGBA
}
func (s *ScrollBar) Layout(gtx C, style *ScrollBarStyle, numItems int, pos *layout.Position) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
gradientSize := gtx.Dp(unit.Dp(4))
var totalPixelsEstimate, scrollBarRelLength float32
transparent := style.Gradient
transparent.A = 0
switch s.Axis {
case layout.Vertical:
if pos.First > 0 || pos.Offset > 0 {
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop2: f32.Pt(0, float32(gradientSize))}.Add(gtx.Ops)
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop2: f32.Pt(0, float32(gradientSize))}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
if pos.BeforeEnd {
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop1: f32.Pt(0, float32(gtx.Constraints.Min.Y)), Stop2: f32.Pt(0, float32(gtx.Constraints.Min.Y-gradientSize))}.Add(gtx.Ops)
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop1: f32.Pt(0, float32(gtx.Constraints.Min.Y)), Stop2: f32.Pt(0, float32(gtx.Constraints.Min.Y-gradientSize))}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
totalPixelsEstimate = float32(gtx.Constraints.Min.Y+pos.Offset-pos.OffsetLast) * float32(numItems) / float32(pos.Count)
@ -41,11 +50,11 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
case layout.Horizontal:
if pos.First > 0 || pos.Offset > 0 {
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop2: f32.Pt(float32(gradientSize), 0)}.Add(gtx.Ops)
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop2: f32.Pt(float32(gradientSize), 0)}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
if pos.BeforeEnd {
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop1: f32.Pt(float32(gtx.Constraints.Min.X), 0), Stop2: f32.Pt(float32(gtx.Constraints.Min.X-gradientSize), 0)}.Add(gtx.Ops)
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop1: f32.Pt(float32(gtx.Constraints.Min.X), 0), Stop2: f32.Pt(float32(gtx.Constraints.Min.X-gradientSize), 0)}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
totalPixelsEstimate = float32(gtx.Constraints.Min.X+pos.Offset-pos.OffsetLast) * float32(numItems) / float32(pos.Count)
@ -56,7 +65,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
}
scrollBarRelStart := (float32(pos.First)*totalPixelsEstimate/float32(numItems) + float32(pos.Offset)) / totalPixelsEstimate
scrWidth := gtx.Dp(width)
scrWidth := gtx.Dp(style.Width)
stack := op.Offset(image.Point{}).Push(gtx.Ops)
var area clip.Stack
@ -65,7 +74,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
if scrollBarRelLength < 1 && (s.dragging || s.hovering) {
y1 := int(scrollBarRelStart * float32(gtx.Constraints.Min.Y))
y2 := int((scrollBarRelStart + scrollBarRelLength) * float32(gtx.Constraints.Min.Y))
paint.FillShape(gtx.Ops, scrollBarColor, clip.Rect{Min: image.Pt(gtx.Constraints.Min.X-scrWidth, y1), Max: image.Pt(gtx.Constraints.Min.X, y2)}.Op())
paint.FillShape(gtx.Ops, style.Color, clip.Rect{Min: image.Pt(gtx.Constraints.Min.X-scrWidth, y1), Max: image.Pt(gtx.Constraints.Min.X, y2)}.Op())
}
rect := image.Rect(gtx.Constraints.Min.X-scrWidth, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area = clip.Rect(rect).Push(gtx.Ops)
@ -73,7 +82,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
if scrollBarRelLength < 1 && (s.dragging || s.hovering) {
x1 := int(scrollBarRelStart * float32(gtx.Constraints.Min.X))
x2 := int((scrollBarRelStart + scrollBarRelLength) * float32(gtx.Constraints.Min.X))
paint.FillShape(gtx.Ops, scrollBarColor, clip.Rect{Min: image.Pt(x1, gtx.Constraints.Min.Y-scrWidth), Max: image.Pt(x2, gtx.Constraints.Min.Y)}.Op())
paint.FillShape(gtx.Ops, style.Color, clip.Rect{Min: image.Pt(x1, gtx.Constraints.Min.Y-scrWidth), Max: image.Pt(x2, gtx.Constraints.Min.Y)}.Op())
}
rect := image.Rect(0, gtx.Constraints.Min.Y-scrWidth, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area = clip.Rect(rect).Push(gtx.Ops)
@ -143,28 +152,3 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
return D{Size: gtx.Constraints.Min}
}
func scrollToView(l *layout.List, index int, length int) {
pmin := index + 2 - l.Position.Count
pmax := index - 1
if pmin < 0 {
pmin = 0
}
if pmax < 0 {
pmax = 0
}
m := length - 1
if pmin > m {
pmin = m
}
if pmax > m {
pmax = m
}
if l.Position.First > pmax {
l.Position.First = pmax
l.Position.Offset = 0
}
if l.Position.First < pmin {
l.Position.First = pmin
}
}

View File

@ -0,0 +1,106 @@
package gioui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
)
const maxSignalsDrawn = 16
type (
RailStyle struct {
Color color.NRGBA
LineWidth unit.Dp
SignalWidth unit.Dp
PortDiameter unit.Dp
PortColor color.NRGBA
}
RailWidget struct {
Style *RailStyle
Signal tracker.Rail
Height unit.Dp
}
)
func Rail(th *Theme, signal tracker.Rail) RailWidget {
return RailWidget{
Style: &th.SignalRail,
Signal: signal,
Height: th.UnitEditor.Height,
}
}
func (s RailWidget) Layout(gtx C) D {
sw := gtx.Dp(s.Style.SignalWidth)
h := gtx.Dp(s.Height)
if s.Signal.PassThrough == 0 && len(s.Signal.StackUse.Inputs) == 0 && s.Signal.StackUse.NumOutputs == 0 {
return D{Size: image.Pt(sw, h)}
}
lw := gtx.Dp(s.Style.LineWidth)
pd := gtx.Dp(s.Style.PortDiameter)
center := sw / 2
var path clip.Path
path.Begin(gtx.Ops)
// Draw pass through signals
for i := range min(maxSignalsDrawn, s.Signal.PassThrough) {
x := float32(i*sw + center)
path.MoveTo(f32.Pt(x, 0))
path.LineTo(f32.Pt(x, float32(h)))
}
// Draw the routing of input signals
for i := range min(len(s.Signal.StackUse.Inputs), maxSignalsDrawn-s.Signal.PassThrough) {
input := s.Signal.StackUse.Inputs[i]
x1 := float32((i+s.Signal.PassThrough)*sw + center)
for _, link := range input {
x2 := float32((link+s.Signal.PassThrough)*sw + center)
path.MoveTo(f32.Pt(x1, 0))
path.LineTo(f32.Pt(x2, float32(h/2)))
}
}
if s.Signal.Send {
for i := range min(len(s.Signal.StackUse.Inputs), maxSignalsDrawn-s.Signal.PassThrough) {
d := gtx.Dp(8)
from := f32.Pt(float32((i+s.Signal.PassThrough)*sw+center), float32(h/2))
to := f32.Pt(float32(gtx.Constraints.Max.X), float32(h)-float32(d))
ctrl := f32.Pt(from.X, to.Y)
path.MoveTo(from)
path.QuadTo(ctrl, to)
}
}
// Draw the routing of output signals
for i := range min(s.Signal.StackUse.NumOutputs, maxSignalsDrawn-s.Signal.PassThrough) {
x := float32((i+s.Signal.PassThrough)*sw + center)
path.MoveTo(f32.Pt(x, float32(h/2)))
path.LineTo(f32.Pt(x, float32(h)))
}
// Signal paths finished
paint.FillShape(gtx.Ops, s.Style.Color,
clip.Stroke{
Path: path.End(),
Width: float32(lw),
}.Op())
// Draw the circles on signals that get modified
var circle clip.Path
circle.Begin(gtx.Ops)
for i := range min(len(s.Signal.StackUse.Modifies), maxSignalsDrawn-s.Signal.PassThrough) {
if !s.Signal.StackUse.Modifies[i] {
continue
}
f := f32.Pt(float32((i+s.Signal.PassThrough)*sw+center), float32(h/2))
circle.MoveTo(f32.Pt(f.X-float32(pd/2), float32(h/2)))
circle.ArcTo(f, f, float32(2*math.Pi))
}
p := clip.Outline{Path: circle.End()}.Op().Push(gtx.Ops)
paint.ColorOp{Color: s.Style.PortColor}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
p.Pop()
return D{Size: image.Pt(sw, h)}
}

601
tracker/gioui/song_panel.go Normal file
View File

@ -0,0 +1,601 @@
package gioui
import (
"fmt"
"image"
"image/color"
"slices"
"strconv"
"strings"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/version"
"github.com/vsariola/sointu/vm"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type SongPanel struct {
SongSettingsExpander *Expander
ScopeExpander *Expander
LoudnessExpander *Expander
PeakExpander *Expander
CPUExpander *Expander
SpectrumExpander *Expander
WeightingTypeBtn *Clickable
OversamplingBtn *Clickable
SynthBtn *Clickable
BPM *NumericUpDownState
RowsPerPattern *NumericUpDownState
RowsPerBeat *NumericUpDownState
Step *NumericUpDownState
SongLength *NumericUpDownState
List *layout.List
ScrollBar *ScrollBar
Scope *OscilloscopeState
ScopeScaleBar *ScaleBar
SpectrumState *SpectrumState
SpectrumScaleBar *ScaleBar
MenuBar *MenuBar
PlayBar *PlayBar
}
func NewSongPanel(tr *Tracker) *SongPanel {
ret := &SongPanel{
BPM: NewNumericUpDownState(),
RowsPerPattern: NewNumericUpDownState(),
RowsPerBeat: NewNumericUpDownState(),
Step: NewNumericUpDownState(),
SongLength: NewNumericUpDownState(),
Scope: NewOscilloscope(),
MenuBar: NewMenuBar(tr),
PlayBar: NewPlayBar(),
WeightingTypeBtn: new(Clickable),
OversamplingBtn: new(Clickable),
SynthBtn: new(Clickable),
SongSettingsExpander: &Expander{Expanded: true},
ScopeExpander: &Expander{},
LoudnessExpander: &Expander{},
PeakExpander: &Expander{},
CPUExpander: &Expander{},
SpectrumExpander: &Expander{},
List: &layout.List{Axis: layout.Vertical},
ScrollBar: &ScrollBar{Axis: layout.Vertical},
SpectrumState: NewSpectrumState(),
SpectrumScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
ScopeScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
}
return ret
}
func (s *SongPanel) Update(gtx C, t *Tracker) {
for s.WeightingTypeBtn.Clicked(gtx) {
t.Model.Detector().Weighting().SetValue((t.Detector().Weighting().Value() + 1) % int(tracker.NumWeightingTypes))
}
for s.OversamplingBtn.Clicked(gtx) {
t.Model.Detector().Oversampling().SetValue(!t.Detector().Oversampling().Value())
}
for s.SynthBtn.Clicked(gtx) {
r := t.Model.Play().SyntherIndex().Range()
t.Model.Play().SyntherIndex().SetValue((t.Play().SyntherIndex().Value()+1)%(r.Max-r.Min+1) + r.Min)
}
}
func (s *SongPanel) Layout(gtx C) D {
t := TrackerFromContext(gtx)
s.Update(gtx, t)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.MenuBar.Layout),
layout.Rigid(s.PlayBar.Layout),
layout.Rigid(s.layoutSongOptions),
)
}
func (t *SongPanel) layoutSongOptions(gtx C) D {
tr := TrackerFromContext(gtx)
paint.FillShape(gtx.Ops, tr.Theme.SongPanel.Bg, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
var weightingTxt string
switch tracker.WeightingType(tr.Model.Detector().Weighting().Value()) {
case tracker.KWeighting:
weightingTxt = "K-weight (LUFS)"
case tracker.AWeighting:
weightingTxt = "A-weight"
case tracker.CWeighting:
weightingTxt = "C-weight"
case tracker.NoWeighting:
weightingTxt = "No weight (RMS)"
}
weightingBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.WeightingTypeBtn, weightingTxt, "")
oversamplingTxt := "Sample peak"
if tr.Model.Detector().Oversampling().Value() {
oversamplingTxt = "True peak"
}
oversamplingBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.OversamplingBtn, oversamplingTxt, "")
cpuSmallLabel := func(gtx C) D {
var a [vm.MAX_THREADS]sointu.CPULoad
c := tr.Play().CPULoad(a[:])
if c < 1 {
return D{}
}
load := slices.Max(a[:c])
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, fmt.Sprintf("%d%%", int(load*100+0.5)))
if load >= 1 {
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
}
return cpuLabel.Layout(gtx)
}
cpuEnlargedWidget := func(gtx C) D {
var sb strings.Builder
var a [vm.MAX_THREADS]sointu.CPULoad
c := tr.Play().CPULoad(a[:])
high := false
for i := range c {
if i > 0 {
fmt.Fprint(&sb, ", ")
}
cpuLoad := a[i]
fmt.Fprintf(&sb, "%d%%", int(cpuLoad*100+0.5))
if cpuLoad >= 1 {
high = true
}
}
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, sb.String())
if high {
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
}
return cpuLabel.Layout(gtx)
}
synthBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.SynthBtn, tr.Model.Play().SyntherName(), "")
listItem := func(gtx C, index int) D {
switch index {
case 0:
return t.SongSettingsExpander.Layout(gtx, tr.Theme, "Song",
func(gtx C) D {
return Label(tr.Theme, &tr.Theme.SongPanel.RowHeader, strconv.Itoa(tr.Song().BPM().Value())+" BPM").Layout(gtx)
},
func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
bpm := NumUpDown(tr.Song().BPM(), tr.Theme, t.BPM, "BPM")
return layoutSongOptionRow(gtx, tr.Theme, "BPM", bpm.Layout)
}),
layout.Rigid(func(gtx C) D {
songLength := NumUpDown(tr.Song().Length(), tr.Theme, t.SongLength, "Song length")
return layoutSongOptionRow(gtx, tr.Theme, "Song length", songLength.Layout)
}),
layout.Rigid(func(gtx C) D {
rowsPerPattern := NumUpDown(tr.Song().RowsPerPattern(), tr.Theme, t.RowsPerPattern, "Rows per pattern")
return layoutSongOptionRow(gtx, tr.Theme, "Rows per pat", rowsPerPattern.Layout)
}),
layout.Rigid(func(gtx C) D {
rowsPerBeat := NumUpDown(tr.Song().RowsPerBeat(), tr.Theme, t.RowsPerBeat, "Rows per beat")
return layoutSongOptionRow(gtx, tr.Theme, "Rows per beat", rowsPerBeat.Layout)
}),
layout.Rigid(func(gtx C) D {
step := NumUpDown(tr.Note().Step(), tr.Theme, t.Step, "Cursor step")
return layoutSongOptionRow(gtx, tr.Theme, "Cursor step", step.Layout)
}),
)
})
case 1:
return t.CPUExpander.Layout(gtx, tr.Theme, "CPU", cpuSmallLabel,
func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Load", cpuEnlargedWidget) }),
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Synth", synthBtn.Layout) }),
)
},
)
case 2:
return t.LoudnessExpander.Layout(gtx, tr.Theme, "Loudness",
func(gtx C) D {
loudness := tr.Model.Detector().Result().Loudness[tracker.LoudnessShortTerm]
return dbLabel(tr.Theme, loudness).Layout(gtx)
},
func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Momentary", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMomentary]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Short term", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessShortTerm]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Integrated", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessIntegrated]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Max. momentary", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMaxMomentary]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Max. short term", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMaxShortTerm]).Layout)
}),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return weightingBtn.Layout(gtx)
}),
)
},
)
case 3:
return t.PeakExpander.Layout(gtx, tr.Theme, "Peaks",
func(gtx C) D {
maxPeak := max(tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][0], tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][1])
return dbLabel(tr.Theme, maxPeak).Layout(gtx)
},
func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
// no need to show momentary peak, it does not have too much meaning
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Short term L", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][0]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Short term R", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][1]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Integrated L", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakIntegrated][0]).Layout)
}),
layout.Rigid(func(gtx C) D {
return layoutSongOptionRow(gtx, tr.Theme, "Integrated R", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakIntegrated][1]).Layout)
}),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return oversamplingBtn.Layout(gtx)
}),
)
},
)
case 4:
scope := Scope(tr.Theme, t.Scope)
scopeScaleBar := func(gtx C) D {
return t.ScopeScaleBar.Layout(gtx, scope.Layout)
}
return t.ScopeExpander.Layout(gtx, tr.Theme, "Oscilloscope", func(gtx C) D { return D{} }, scopeScaleBar)
case 5:
spectrumScaleBar := func(gtx C) D {
return t.SpectrumScaleBar.Layout(gtx, t.SpectrumState.Layout)
}
return t.SpectrumExpander.Layout(gtx, tr.Theme, "Spectrum", func(gtx C) D { return D{} }, spectrumScaleBar)
case 6:
return Label(tr.Theme, &tr.Theme.SongPanel.Version, version.VersionOrHash).Layout(gtx)
default:
return D{}
}
}
gtx.Constraints.Min = gtx.Constraints.Max
dims := t.List.Layout(gtx, 7, listItem)
t.ScrollBar.Layout(gtx, &tr.Theme.SongPanel.ScrollBar, 7, &t.List.Position)
tr.Spectrum().Enabled().SetValue(t.SpectrumExpander.Expanded)
return dims
}
func dbLabel(th *Theme, value tracker.Decibel) LabelWidget {
ret := Label(th, &th.SongPanel.RowValue, fmt.Sprintf("%.1f dB", value))
if value >= 0 {
ret.Color = th.SongPanel.ErrorColor
}
return ret
}
func layoutSongOptionRow(gtx C, th *Theme, label string, widget layout.Widget) D {
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(th, &th.SongPanel.RowHeader, label).Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(widget),
layout.Rigid(rightSpacer),
)
}
type ScaleBar struct {
Size, BarSize unit.Dp
Axis layout.Axis
drag bool
dragID pointer.ID
dragStart f32.Point
}
func (s *ScaleBar) Layout(gtx C, w layout.Widget) D {
s.Update(gtx)
pxBar := gtx.Dp(s.BarSize)
pxTot := gtx.Dp(s.Size) + pxBar
var rect image.Rectangle
var size image.Point
if s.Axis == layout.Horizontal {
pxTot = min(max(gtx.Constraints.Min.X, pxTot), gtx.Constraints.Max.X)
px := pxTot - pxBar
rect = image.Rect(px, 0, pxTot, gtx.Constraints.Max.Y)
size = image.Pt(pxTot, gtx.Constraints.Max.Y)
gtx.Constraints.Max.X = px
gtx.Constraints.Min.X = min(gtx.Constraints.Min.X, px)
} else {
pxTot = min(max(gtx.Constraints.Min.Y, pxTot), gtx.Constraints.Max.Y)
px := pxTot - pxBar
rect = image.Rect(0, px, gtx.Constraints.Max.X, pxTot)
size = image.Pt(gtx.Constraints.Max.X, pxTot)
gtx.Constraints.Max.Y = px
gtx.Constraints.Min.Y = min(gtx.Constraints.Min.Y, px)
}
area := clip.Rect(rect).Push(gtx.Ops)
event.Op(gtx.Ops, s)
if s.Axis == layout.Horizontal {
pointer.CursorColResize.Add(gtx.Ops)
} else {
pointer.CursorRowResize.Add(gtx.Ops)
}
area.Pop()
w(gtx)
return D{Size: size}
}
func (s *ScaleBar) Update(gtx C) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
s.dragStart = e.Position
s.drag = true
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
if s.Axis == layout.Horizontal {
s.Size += gtx.Metric.PxToDp(int(e.Position.X - s.dragStart.X))
} else {
s.Size += gtx.Metric.PxToDp(int(e.Position.Y - s.dragStart.Y))
}
s.Size = max(s.Size, unit.Dp(50))
s.dragStart = e.Position
case pointer.Release, pointer.Cancel:
s.drag = false
}
}
}
type Expander struct {
Expanded bool
click gesture.Click
}
func (e *Expander) Update(gtx C) {
for ev, ok := e.click.Update(gtx.Source); ok; ev, ok = e.click.Update(gtx.Source) {
switch ev.Kind {
case gesture.KindClick:
e.Expanded = !e.Expanded
}
}
}
func (e *Expander) Layout(gtx C, th *Theme, title string, smallWidget, largeWidget layout.Widget) D {
e.Update(gtx)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return e.layoutHeader(gtx, th, title, smallWidget) }),
layout.Rigid(func(gtx C) D {
if e.Expanded {
return largeWidget(gtx)
}
return D{}
}),
layout.Rigid(func(gtx C) D {
px := max(gtx.Dp(unit.Dp(1)), 1)
paint.FillShape(gtx.Ops, color.NRGBA{255, 255, 255, 3}, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, px)).Op())
return D{Size: image.Pt(gtx.Constraints.Max.X, px)}
}),
)
}
func (e *Expander) layoutHeader(gtx C, th *Theme, title string, smallWidget layout.Widget) D {
return layout.Background{}.Layout(gtx,
func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops).Pop()
// add click op
e.click.Add(gtx.Ops)
return D{Size: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}
},
func(gtx C) D {
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(th, &th.SongPanel.Expander, title).Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(func(gtx C) D {
if !e.Expanded {
return smallWidget(gtx)
}
return D{}
}),
layout.Rigid(func(gtx C) D {
// draw icon
icon := icons.NavigationExpandMore
if e.Expanded {
icon = icons.NavigationExpandLess
}
gtx.Constraints.Min = image.Pt(gtx.Dp(unit.Dp(24)), gtx.Dp(unit.Dp(24)))
return th.Icon(icon).Layout(gtx, th.SongPanel.Expander.Color)
}),
)
},
)
}
type MenuBar struct {
Clickables []Clickable
MenuStates []MenuState
midiMenuItems []MenuChild
panicHint string
PanicBtn *Clickable
}
func NewMenuBar(tr *Tracker) *MenuBar {
ret := &MenuBar{
Clickables: make([]Clickable, 4),
MenuStates: make([]MenuState, 4),
PanicBtn: new(Clickable),
panicHint: makeHint("Panic", " (%s)", "PanicToggle"),
}
for input := range tr.MIDI().InputDevices {
ret.midiMenuItems = append(ret.midiMenuItems,
ActionMenuChild(tr.MIDI().Open(input), input, "", icons.ImageControlPoint),
)
}
return ret
}
func (t *MenuBar) Layout(gtx C) D {
tr := TrackerFromContext(gtx)
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
flex := layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}
fileBtn := MenuBtn(&t.MenuStates[0], &t.Clickables[0], "File")
fileFC := layout.Rigid(func(gtx C) D {
items := [...]MenuChild{
ActionMenuChild(tr.Song().New(), "New Song", keyActionMap["NewSong"], icons.ContentClear),
ActionMenuChild(tr.Song().Open(), "Open Song", keyActionMap["OpenSong"], icons.FileFolder),
ActionMenuChild(tr.Song().Save(), "Save Song", keyActionMap["SaveSong"], icons.ContentSave),
ActionMenuChild(tr.Song().SaveAs(), "Save Song As...", keyActionMap["SaveSongAs"], icons.ContentSave),
ActionMenuChild(tr.Song().Export(), "Export Wav...", keyActionMap["ExportWav"], icons.ImageAudiotrack),
ActionMenuChild(tr.RequestQuit(), "Quit", keyActionMap["Quit"], icons.ActionExitToApp),
}
if !canQuit {
return fileBtn.Layout(gtx, items[:len(items)-1]...)
}
return fileBtn.Layout(gtx, items[:]...)
})
editBtn := MenuBtn(&t.MenuStates[1], &t.Clickables[1], "Edit")
editFC := layout.Rigid(func(gtx C) D {
return editBtn.Layout(gtx,
ActionMenuChild(tr.History().Undo(), "Undo", keyActionMap["Undo"], icons.ContentUndo),
ActionMenuChild(tr.History().Redo(), "Redo", keyActionMap["Redo"], icons.ContentRedo),
ActionMenuChild(tr.Order().RemoveUnusedPatterns(), "Remove unused data", keyActionMap["RemoveUnused"], icons.ImageCrop),
)
})
midiBtn := MenuBtn(&t.MenuStates[2], &t.Clickables[2], "MIDI")
midiFC := layout.Rigid(func(gtx C) D {
return midiBtn.Layout(gtx, t.midiMenuItems...)
})
helpBtn := MenuBtn(&t.MenuStates[3], &t.Clickables[3], "?")
helpFC := layout.Rigid(func(gtx C) D {
return helpBtn.Layout(gtx,
ActionMenuChild(tr.ShowManual(), "Manual", keyActionMap["ShowManual"], icons.AVLibraryBooks),
ActionMenuChild(tr.AskHelp(), "Ask help", keyActionMap["AskHelp"], icons.ActionHelp),
ActionMenuChild(tr.ReportBug(), "Report bug", keyActionMap["ReportBug"], icons.ActionBugReport),
ActionMenuChild(tr.ShowLicense(), "License", keyActionMap["ShowLicense"], icons.ActionCopyright))
})
panicBtn := ToggleIconBtn(tr.Play().Panicked(), tr.Theme, t.PanicBtn, icons.AlertErrorOutline, icons.AlertError, t.panicHint, t.panicHint)
if tr.Play().Panicked().Value() {
panicBtn.Style = &tr.Theme.IconButton.Error
}
panicFC := layout.Flexed(1, func(gtx C) D { return layout.E.Layout(gtx, panicBtn.Layout) })
if len(t.midiMenuItems) > 0 {
return flex.Layout(gtx, fileFC, editFC, midiFC, helpFC, panicFC)
}
return flex.Layout(gtx, fileFC, editFC, helpFC, panicFC)
}
func (sp *SongPanel) Tags(level int, yield TagYieldFunc) bool {
for i := range sp.MenuBar.MenuStates {
if !sp.MenuBar.MenuStates[i].Tags(level, yield) {
return false
}
}
return true
}
type PlayBar struct {
RewindBtn *Clickable
PlayingBtn *Clickable
RecordBtn *Clickable
FollowBtn *Clickable
LoopBtn *Clickable
// Hints
rewindHint string
playHint, stopHint string
recordHint, stopRecordHint string
followOnHint, followOffHint string
loopOffHint, loopOnHint string
}
func NewPlayBar() *PlayBar {
ret := &PlayBar{
LoopBtn: new(Clickable),
RecordBtn: new(Clickable),
FollowBtn: new(Clickable),
PlayingBtn: new(Clickable),
RewindBtn: new(Clickable),
}
ret.rewindHint = makeHint("Rewind", "\n(%s)", "PlaySongStartUnfollow")
ret.playHint = makeHint("Play", " (%s)", "PlayCurrentPosUnfollow")
ret.stopHint = makeHint("Stop", " (%s)", "StopPlaying")
ret.recordHint = makeHint("Record", " (%s)", "RecordingToggle")
ret.stopRecordHint = makeHint("Stop", " (%s)", "RecordingToggle")
ret.followOnHint = makeHint("Follow on", " (%s)", "FollowToggle")
ret.followOffHint = makeHint("Follow off", " (%s)", "FollowToggle")
ret.loopOffHint = makeHint("Loop off", " (%s)", "LoopToggle")
ret.loopOnHint = makeHint("Loop on", " (%s)", "LoopToggle")
return ret
}
func (pb *PlayBar) Layout(gtx C) D {
tr := TrackerFromContext(gtx)
playBtn := ToggleIconBtn(tr.Play().Started(), tr.Theme, pb.PlayingBtn, icons.AVPlayArrow, icons.AVStop, pb.playHint, pb.stopHint)
rewindBtn := ActionIconBtn(tr.Play().FromBeginning(), tr.Theme, pb.RewindBtn, icons.AVFastRewind, pb.rewindHint)
recordBtn := ToggleIconBtn(tr.Play().IsRecording(), tr.Theme, pb.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, pb.recordHint, pb.stopRecordHint)
followBtn := ToggleIconBtn(tr.Play().IsFollowing(), tr.Theme, pb.FollowBtn, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, pb.followOffHint, pb.followOnHint)
loopBtn := ToggleIconBtn(tr.Play().IsLooping(), tr.Theme, pb.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, pb.loopOffHint, pb.loopOnHint)
return Surface{Height: 4}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, playBtn.Layout),
layout.Rigid(rewindBtn.Layout),
layout.Rigid(recordBtn.Layout),
layout.Rigid(followBtn.Layout),
layout.Rigid(loopBtn.Layout),
)
})
}

View File

@ -1,185 +0,0 @@
package gioui
import (
"image"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type SongPanel struct {
MenuBar []widget.Clickable
Menus []Menu
BPM *NumberInput
RowsPerPattern *NumberInput
RowsPerBeat *NumberInput
Step *NumberInput
SongLength *NumberInput
RewindBtn *ActionClickable
PlayingBtn *BoolClickable
RecordBtn *BoolClickable
NoteTracking *BoolClickable
PanicBtn *BoolClickable
LoopBtn *BoolClickable
// File menu items
fileMenuItems []MenuItem
NewSong tracker.Action
OpenSongFile tracker.Action
SaveSongFile tracker.Action
SaveSongAsFile tracker.Action
ExportWav tracker.Action
Quit tracker.Action
// Edit menu items
editMenuItems []MenuItem
}
func NewSongPanel(model *tracker.Model) *SongPanel {
ret := &SongPanel{
MenuBar: make([]widget.Clickable, 2),
Menus: make([]Menu, 2),
BPM: NewNumberInput(model.BPM().Int()),
RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()),
RowsPerBeat: NewNumberInput(model.RowsPerBeat().Int()),
Step: NewNumberInput(model.Step().Int()),
SongLength: NewNumberInput(model.SongLength().Int()),
PanicBtn: NewBoolClickable(model.Panic().Bool()),
LoopBtn: NewBoolClickable(model.LoopToggle().Bool()),
RecordBtn: NewBoolClickable(model.IsRecording().Bool()),
NoteTracking: NewBoolClickable(model.NoteTracking().Bool()),
PlayingBtn: NewBoolClickable(model.Playing().Bool()),
RewindBtn: NewActionClickable(model.Rewind()),
}
ret.fileMenuItems = []MenuItem{
{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N", Doer: model.NewSong()},
{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O", Doer: model.OpenSong()},
{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S", Doer: model.SaveSong()},
{IconBytes: icons.ContentSave, Text: "Save Song As...", Doer: model.SaveSongAs()},
{IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", Doer: model.Export()},
}
if canQuit {
ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", Doer: model.Quit()})
}
ret.editMenuItems = []MenuItem{
{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Doer: model.Undo()},
{IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Doer: model.Redo()},
{IconBytes: icons.ImageCrop, Text: "Remove unused data", Doer: model.RemoveUnused()},
}
return ret
}
const shortcutKey = "Ctrl+"
func (s *SongPanel) Layout(gtx C, t *Tracker) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return s.layoutMenuBar(gtx, t)
}),
layout.Rigid(func(gtx C) D {
return s.layoutSongOptions(gtx, t)
}),
)
}
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}.Layout(gtx,
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...)),
)
}
func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {
paint.FillShape(gtx.Ops, songSurfaceColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
in := layout.UniformInset(unit.Dp(1))
panicBtnStyle := ToggleButton(gtx, tr.Theme, t.PanicBtn, "Panic (F12)")
rewindBtnStyle := ActionIcon(gtx, tr.Theme, t.RewindBtn, icons.AVFastRewind, "Rewind\n(F5)")
playBtnStyle := ToggleIcon(gtx, tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F6 / Space)", "Stop (F6 / Space)")
recordBtnStyle := ToggleIcon(gtx, tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)")
noteTrackBtnStyle := ToggleIcon(gtx, tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff\n(F8)", "Follow\nOn\n(F8)")
loopBtnStyle := ToggleIcon(gtx, tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, "Loop\nOff\n(Ctrl+L)", "Loop\nOn\n(Ctrl+L)")
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("LEN:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(tr.Theme, t.SongLength, "Song length")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("BPM:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(tr.Theme, t.BPM, "Beats per minute")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("RPP:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(tr.Theme, t.RowsPerPattern, "Rows per pattern")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("RPB:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(tr.Theme, t.RowsPerBeat, "Rows per beat")
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
dims := in.Layout(gtx, numStyle.Layout)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("STP:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(tr.Theme, t.Step, "Cursor step")
numStyle.UnitsPerStep = unit.Dp(20)
dims := in.Layout(gtx, numStyle.Layout)
return dims
}),
)
}),
layout.Rigid(VuMeter{AverageVolume: tr.Model.AverageVolume(), PeakVolume: tr.Model.PeakVolume(), Range: 100}.Layout),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(rewindBtnStyle.Layout),
layout.Rigid(playBtnStyle.Layout),
layout.Rigid(recordBtnStyle.Layout),
layout.Rigid(noteTrackBtnStyle.Layout),
layout.Rigid(loopBtnStyle.Layout),
)
}),
layout.Rigid(panicBtnStyle.Layout),
)
}

View File

@ -0,0 +1,217 @@
package gioui
import (
"fmt"
"math"
"strconv"
"gioui.org/layout"
"gioui.org/unit"
"github.com/vsariola/sointu/tracker"
)
type (
SpectrumState struct {
resolutionNumber *NumericUpDownState
speed *NumericUpDownState
chnModeBtn *Clickable
plot *Plot
}
)
const (
SpectrumDbMin = -60
SpectrumDbMax = 12
)
func NewSpectrumState() *SpectrumState {
return &SpectrumState{
plot: NewPlot(plotRange{-3.8, 0}, plotRange{SpectrumDbMax, SpectrumDbMin}, SpectrumDbMin),
resolutionNumber: NewNumericUpDownState(),
speed: NewNumericUpDownState(),
chnModeBtn: new(Clickable),
}
}
func (s *SpectrumState) Layout(gtx C) D {
s.Update(gtx)
t := TrackerFromContext(gtx)
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(36)}.Layout
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
var chnModeTxt string = "???"
switch tracker.SpecChnMode(t.Model.Spectrum().Channels().Value()) {
case tracker.SpecChnModeSum:
chnModeTxt = "Sum"
case tracker.SpecChnModeSeparate:
chnModeTxt = "Separate"
}
resolution := NumUpDown(t.Model.Spectrum().Resolution(), t.Theme, s.resolutionNumber, "Resolution")
chnModeBtn := Btn(t.Theme, &t.Theme.Button.Text, s.chnModeBtn, chnModeTxt, "Channel mode")
speed := NumUpDown(t.Model.Spectrum().Speed(), t.Theme, s.speed, "Speed")
numchns := 0
speclen := len(t.Model.Spectrum().Result()[0])
if speclen > 0 {
numchns = 1
if len(t.Model.Spectrum().Result()[1]) == speclen {
numchns = 2
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
biquad, biquadok := t.Model.Spectrum().BiquadCoeffs()
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
if chn == 2 {
if xr.a >= 0 {
return plotRange{}, false
}
ya := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.a)))))) * 20
yb := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.b)))))) * 20
return plotRange{float32(ya), float32(yb)}, true
}
if chn >= numchns {
return plotRange{}, false
}
xr.a = float32(math.Pow(10, float64(xr.a)))
xr.b = float32(math.Pow(10, float64(xr.b)))
w1, f1 := math.Modf(float64(xr.a)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
w2, f2 := math.Modf(float64(xr.b)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
x1 := max(int(w1), 0)
x2 := min(int(w2), speclen-1)
if x1 > x2 {
return plotRange{}, false
}
y1 := float32(math.Inf(-1))
y2 := float32(math.Inf(+1))
switch {
case x2 <= x1+1 && x2 < speclen-1: // perform smoothstep interpolation when we are overlapping only a few bins
l := t.Model.Spectrum().Result()[chn][x1]
r := t.Model.Spectrum().Result()[chn][x1+1]
y1 = smoothInterpolate(l, r, float32(f1))
l = t.Model.Spectrum().Result()[chn][x2]
r = t.Model.Spectrum().Result()[chn][x2+1]
y2 = smoothInterpolate(l, r, float32(f2))
y1, y2 = max(y1, y2), min(y1, y2)
default:
for i := x1; i <= x2; i++ {
sample := t.Model.Spectrum().Result()[chn][i]
y1 = max(y1, sample)
y2 = min(y2, sample)
}
}
y1 = softplus((y1-SpectrumDbMin)/5)*5 + SpectrumDbMin // we "squash" the low volumes so the -Inf dB becomes -SpectrumDbMin
y2 = softplus((y2-SpectrumDbMin)/5)*5 + SpectrumDbMin
return plotRange{y1, y2}, true
}
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
type pair struct {
freq float64
label string
}
const offset = 0.343408593803857 // log10(22050/10000)
const startdiv = 3 * (1 << 8)
step := nextPowerOfTwo(int(float64(r.b-r.a)*startdiv/float64(count)) + 1)
start := int(math.Floor(float64(r.a+offset) * startdiv / float64(step)))
end := int(math.Ceil(float64(r.b+offset) * startdiv / float64(step)))
for i := start; i <= end; i++ {
lognormfreq := float32(i*step)/startdiv - offset
freq := math.Pow(10, float64(lognormfreq)) * 22050
df := freq * math.Log(10) * float64(step) / startdiv // this is roughly the difference in Hz between the ticks currently
rounding := int(math.Floor(math.Log10(df)))
r := math.Pow(10, float64(rounding))
freq = math.Round(freq/r) * r
tickpos := float32(math.Log10(freq / 22050))
if rounding >= 3 {
yield(tickpos, fmt.Sprintf("%.0f kHz", freq/1000))
} else {
yield(tickpos, fmt.Sprintf("%s Hz", strconv.FormatFloat(freq, 'f', -rounding, 64)))
}
}
}
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
step := 3
var start, end int
for {
start = int(math.Ceil(float64(r.b) / float64(step)))
end = int(math.Floor(float64(r.a) / float64(step)))
if end-start+1 <= count*4 { // we use 4x density for the y-lines in the spectrum
break
}
step *= 2
}
for i := start; i <= end; i++ {
yield(float32(i*step), strconv.Itoa(i*step))
}
}
n := numchns
if biquadok {
n = 3
}
return s.plot.Layout(gtx, data, xticks, yticks, float32(math.NaN()), n)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Resolution").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(resolution.Layout),
layout.Rigid(rightSpacer),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Speed").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(speed.Layout),
layout.Rigid(rightSpacer),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(leftSpacer),
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Channels").Layout),
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
layout.Rigid(chnModeBtn.Layout),
layout.Rigid(rightSpacer),
)
}),
)
}
func softplus(f float32) float32 {
return float32(math.Log(1 + math.Exp(float64(f))))
}
func smoothInterpolate(a, b float32, t float32) float32 {
t = t * t * (3 - 2*t)
return (1-t)*a + t*b
}
func nextPowerOfTwo(v int) int {
if v <= 0 {
return 1
}
v--
v |= v >> 1
v |= v >> 2
v |= v >> 4
v |= v >> 8
v |= v >> 16
v |= v >> 32
v++
return v
}
func (s *SpectrumState) Update(gtx C) {
t := TrackerFromContext(gtx)
for s.chnModeBtn.Clicked(gtx) {
t.Model.Spectrum().Channels().SetValue((t.Model.Spectrum().Channels().Value() + 1) % int(tracker.NumSpecChnModes))
}
s.resolutionNumber.Update(gtx, t.Model.Spectrum().Resolution())
s.speed.Update(gtx, t.Model.Spectrum().Speed())
}

View File

@ -11,127 +11,46 @@ import (
"gioui.org/unit"
)
type Split struct {
// Ratio keeps the current layout.
// 0 is center, -1 completely to the left, 1 completely to the right.
Ratio float32
// Bar is the width for resizing the layout
Bar unit.Dp
// Axis is the split direction: layout.Horizontal splits the view in left
// and right, layout.Vertical splits the view in top and bottom
Axis layout.Axis
drag bool
dragID pointer.ID
dragCoord float32
}
var defaultBarWidth = unit.Dp(10)
func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.Dimensions {
bar := gtx.Dp(s.Bar)
if bar <= 1 {
bar = gtx.Dp(defaultBarWidth)
type (
SplitState struct {
// Ratio keeps the current layout.
// 0 is center, -1 completely to the left, 1 completely to the right.
Ratio float32
// Axis is the split direction: layout.Horizontal splits the view in left
// and right, layout.Vertical splits the view in top and bottom
Axis layout.Axis
drag bool
dragID pointer.ID
dragCoord float32
}
var coord int
if s.Axis == layout.Horizontal {
coord = gtx.Constraints.Max.X
} else {
coord = gtx.Constraints.Max.Y
SplitStyle struct {
Bar unit.Dp
MinSize1, MinSize2 unit.Dp
}
)
proportion := (s.Ratio + 1) / 2
firstSize := int(proportion*float32(coord) - float32(bar))
func (s *SplitState) Layout(gtx layout.Context, st *SplitStyle, first, second layout.Widget) layout.Dimensions {
s.update(gtx, st)
secondOffset := firstSize + bar
secondSize := coord - secondOffset
{ // handle input
// Avoid affecting the input tree with pointer events.
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
// TODO: there should be a grab; there was Grab: s.drag,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
if s.Axis == layout.Horizontal {
s.dragCoord = e.Position.X
} else {
s.dragCoord = e.Position.Y
}
s.drag = true
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
var deltaCoord, deltaRatio float32
if s.Axis == layout.Horizontal {
deltaCoord = e.Position.X - s.dragCoord
s.dragCoord = e.Position.X
deltaRatio = deltaCoord * 2 / float32(gtx.Constraints.Max.X)
} else {
deltaCoord = e.Position.Y - s.dragCoord
s.dragCoord = e.Position.Y
deltaRatio = deltaCoord * 2 / float32(gtx.Constraints.Max.Y)
}
s.Ratio += deltaRatio
case pointer.Release:
fallthrough
case pointer.Cancel:
s.drag = false
}
}
low := -1 + float32(bar)/float32(coord)*2
const snapMargin = 0.1
if s.Ratio < low {
s.Ratio = low
}
if s.Ratio > 1 {
s.Ratio = 1
}
if s.Ratio < low+snapMargin {
firstSize = 0
secondOffset = bar
secondSize = coord - bar
} else if s.Ratio > 1-snapMargin {
firstSize = coord - bar
secondOffset = coord
secondSize = 0
}
size1, size2, bar := s.calculateSplitSizes(gtx, st)
secondOffset := size1 + bar
{
// register for input
var barRect image.Rectangle
if s.Axis == layout.Horizontal {
barRect = image.Rect(firstSize, 0, secondOffset, gtx.Constraints.Max.Y)
barRect = image.Rect(size1, 0, secondOffset, gtx.Constraints.Max.Y)
} else {
barRect = image.Rect(0, firstSize, gtx.Constraints.Max.X, secondOffset)
barRect = image.Rect(0, size1, gtx.Constraints.Max.X, secondOffset)
}
area := clip.Rect(barRect).Push(gtx.Ops)
event.Op(gtx.Ops, s)
if s.Axis == layout.Horizontal {
pointer.CursorColResize.Add(gtx.Ops)
} else {
pointer.CursorRowResize.Add(gtx.Ops)
}
area.Pop()
}
@ -139,9 +58,9 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
gtx := gtx
if s.Axis == layout.Horizontal {
gtx.Constraints = layout.Exact(image.Pt(firstSize, gtx.Constraints.Max.Y))
gtx.Constraints = layout.Exact(image.Pt(size1, gtx.Constraints.Max.Y))
} else {
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, firstSize))
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, size1))
}
area := clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops)
first(gtx)
@ -154,10 +73,10 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
var transform op.TransformStack
if s.Axis == layout.Horizontal {
transform = op.Offset(image.Pt(secondOffset, 0)).Push(gtx.Ops)
gtx.Constraints = layout.Exact(image.Pt(secondSize, gtx.Constraints.Max.Y))
gtx.Constraints = layout.Exact(image.Pt(size2, gtx.Constraints.Max.Y))
} else {
transform = op.Offset(image.Pt(0, secondOffset)).Push(gtx.Ops)
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, secondSize))
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, size2))
}
area := clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops)
@ -168,3 +87,107 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
return layout.Dimensions{Size: gtx.Constraints.Max}
}
func (s *SplitState) update(gtx layout.Context, st *SplitStyle) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
// TODO: there should be a grab; there was Grab: s.drag,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
if s.Axis == layout.Horizontal {
s.dragCoord = e.Position.X
} else {
s.dragCoord = e.Position.Y
}
s.drag = true
// when the user start dragging, the new display ratio becomes the underlying ratio
s.Ratio = s.calculateRatio(gtx, st)
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
if s.Axis == layout.Horizontal {
s.Ratio += (e.Position.X - s.dragCoord) / float32(gtx.Constraints.Max.X) * 2
s.dragCoord = e.Position.X
} else {
s.Ratio += (e.Position.Y - s.dragCoord) / float32(gtx.Constraints.Max.Y) * 2
s.dragCoord = e.Position.Y
}
case pointer.Release, pointer.Cancel:
if s.dragID == e.PointerID {
// when the user release the grab, the new display ratio becomes the underlying ratio
s.Ratio = s.calculateRatio(gtx, st)
}
s.drag = false
}
}
}
func (s *SplitState) calculateRatio(gtx layout.Context, st *SplitStyle) float32 {
size1, size2, bar := s.calculateSplitSizes(gtx, st)
total := size1 + size2 + bar
if total <= 0 {
return 0
}
return 2*float32(size1+bar/2)/float32(total) - 1
}
func (s *SplitState) calculateSplitSizes(gtx layout.Context, st *SplitStyle) (size1, size2, bar int) {
bar = gtx.Dp(st.Bar)
if bar <= 1 {
bar = gtx.Dp(1)
}
total := gtx.Constraints.Max.Y
if s.Axis == layout.Horizontal {
total = gtx.Constraints.Max.X
}
if total < 0 {
total = 0
}
if total < bar {
return 0, 0, total
}
totalSize := total - bar
size1 = int((s.Ratio+1)/2*float32(total) - float32(bar)/2)
minSize1 := gtx.Dp(st.MinSize1)
minSize2 := gtx.Dp(st.MinSize2)
// we always hide the smaller split first
if s.Ratio < 0 {
size1 = limitSplitSize(size1, totalSize, minSize1, minSize2)
} else {
size1 = totalSize - limitSplitSize(totalSize-size1, totalSize, minSize2, minSize1)
}
size2 = totalSize - size1
return size1, size2, bar
}
// limitSplitSize hides the first split if it is smaller than minSize1/2 or if
// the total size is smaller than minSize1+minSize2. Otherwise, it clamps the
// size so that both split get at least minSize1 and minSize2 respectively.
func limitSplitSize(size, totalPx, minSize1, minSize2 int) int {
if size < minSize1/2 || totalPx < minSize1+minSize2 {
return 0 // the first split is completely hidden
}
return min(max(size, minSize1), totalPx-minSize2)
}

View File

@ -4,52 +4,31 @@ import (
"image/color"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
type Surface struct {
Gray int
Inset layout.Inset
FitSize bool
Focus bool
Height int
Inset layout.Inset
Focus bool
}
func (s Surface) Layout(gtx C, widget layout.Widget) D {
t := TrackerFromContext(gtx)
t.surfaceHeight += s.Height
bg := func(gtx C) D {
grayInt := s.Gray
gray := s.Height * 8
if s.Focus {
grayInt += 8
gray += 8
}
var grayUint8 uint8
if grayInt < 0 {
grayUint8 = 0
} else if grayInt > 255 {
grayUint8 = 255
} else {
grayUint8 = uint8(grayInt)
}
color := color.NRGBA{R: grayUint8, G: grayUint8, B: grayUint8, A: 255}
paint.FillShape(gtx.Ops, color, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
gray8 := uint8(min(max(gray, 0), 255))
color := color.NRGBA{R: gray8, G: gray8, B: gray8, A: 255}
paint.FillShape(gtx.Ops, color, clip.Rect{Max: gtx.Constraints.Min}.Op())
return D{Size: gtx.Constraints.Min}
}
fg := func(gtx C) D {
return s.Inset.Layout(gtx, widget)
}
if s.FitSize {
macro := op.Record(gtx.Ops)
dims := fg(gtx)
call := macro.Stop()
gtx.Constraints = layout.Exact(dims.Size)
bg(gtx)
call.Add(gtx.Ops)
return dims
}
gtxbg := gtx
gtxbg.Constraints.Min = gtxbg.Constraints.Max
bg(gtxbg)
return fg(gtx)
fg := func(gtx C) D { return s.Inset.Layout(gtx, widget) }
dims := layout.Background{}.Layout(gtx, bg, fg)
t.surfaceHeight -= s.Height
return dims
}

View File

@ -1,78 +1,182 @@
package gioui
import (
_ "embed"
"image/color"
"gioui.org/font/gofont"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"golang.org/x/exp/shiny/materialdesign/icons"
)
var fontCollection []text.FontFace = gofont.Collection()
type Theme struct {
Define any // this is just needed for yaml.UnmarshalStrict, so we can have "defines" in the yaml
Material material.Theme
Button struct {
Filled ButtonStyle
Text ButtonStyle
Disabled ButtonStyle
Menu ButtonStyle
Tab struct {
Active ButtonStyle
Inactive ButtonStyle
IndicatorHeight unit.Dp
IndicatorColor color.NRGBA
}
}
IconButton struct {
Enabled IconButtonStyle
Disabled IconButtonStyle
Emphasis IconButtonStyle
Error IconButtonStyle
}
Plot PlotStyle
NumericUpDown NumericUpDownStyle
SongPanel struct {
RowHeader LabelStyle
RowValue LabelStyle
Expander LabelStyle
Version LabelStyle
ErrorColor color.NRGBA
Bg color.NRGBA
ScrollBar ScrollBarStyle
}
Alert AlertStyles
NoteEditor struct {
TrackTitle LabelStyle
OrderRow LabelStyle
PatternRow LabelStyle
Note LabelStyle
PatternNo LabelStyle
Unique LabelStyle
Loop color.NRGBA
Header LabelStyle
Play color.NRGBA
OneBeat color.NRGBA
TwoBeat color.NRGBA
}
Dialog DialogStyle
OrderEditor struct {
TrackTitle LabelStyle
RowTitle LabelStyle
Cell LabelStyle
Loop color.NRGBA
CellBg color.NRGBA
Play color.NRGBA
}
Menu struct {
Main MenuStyle
Preset MenuStyle
}
InstrumentEditor struct {
Octave LabelStyle
Properties struct {
Label LabelStyle
}
InstrumentComment EditorStyle
UnitComment EditorStyle
InstrumentList struct {
Number LabelStyle
Name EditorStyle
NameMuted EditorStyle
ScrollBar ScrollBarStyle
}
UnitList struct {
Name EditorStyle
NameDisabled EditorStyle
Comment LabelStyle
Stack LabelStyle
Disabled LabelStyle
Warning color.NRGBA
Error color.NRGBA
}
Presets struct {
SearchBg color.NRGBA
Directory LabelStyle
Results struct {
Builtin LabelStyle
User LabelStyle
UserDir LabelStyle
}
}
}
UnitEditor struct {
Name LabelStyle
Chooser LabelStyle
Hint LabelStyle
WireColor color.NRGBA
WireHint LabelStyle
WireHighlight color.NRGBA
Width unit.Dp
Height unit.Dp
RackComment LabelStyle
UnitList struct {
LabelWidth unit.Dp
Name LabelStyle
Disabled LabelStyle
Error color.NRGBA
}
Error color.NRGBA
Divider color.NRGBA
}
Cursor CursorStyle
Selection CursorStyle
Tooltip struct {
Color color.NRGBA
Bg color.NRGBA
}
Popup struct {
Menu PopupStyle
Dialog PopupStyle
}
Split SplitStyle
ScrollBar ScrollBarStyle
Knob KnobStyle
DisabledKnob KnobStyle
Switch SwitchStyle
SignalRail RailStyle
Port PortStyle
var white = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
var black = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
var transparent = color.NRGBA{A: 0}
// iconCache is used to cache the icons created from iconvg data
iconCache map[*byte]*widget.Icon
}
var primaryColor = color.NRGBA{R: 206, G: 147, B: 216, A: 255}
var secondaryColor = color.NRGBA{R: 128, G: 222, B: 234, A: 255}
type CursorStyle struct {
Active color.NRGBA
ActiveAlt color.NRGBA // alternative color for the cursor, used e.g. when the midi input is active
Inactive color.NRGBA
}
var highEmphasisTextColor = color.NRGBA{R: 222, G: 222, B: 222, A: 222}
var mediumEmphasisTextColor = color.NRGBA{R: 153, G: 153, B: 153, A: 153}
var disabledTextColor = color.NRGBA{R: 255, G: 255, B: 255, A: 97}
//go:embed theme.yml
var defaultTheme []byte
var backgroundColor = color.NRGBA{R: 18, G: 18, B: 18, A: 255}
// NewTheme returns a new theme and potentially a warning if the theme file was not found or could not be read
func NewTheme() (*Theme, error) {
var ret Theme
warn := ReadConfig(defaultTheme, "theme.yml", &ret)
ret.Material.Shaper = &text.Shaper{}
ret.Material.Icon.CheckBoxChecked = must(widget.NewIcon(icons.ToggleCheckBox))
ret.Material.Icon.CheckBoxUnchecked = must(widget.NewIcon(icons.ToggleCheckBoxOutlineBlank))
ret.Material.Icon.RadioChecked = must(widget.NewIcon(icons.ToggleRadioButtonChecked))
ret.Material.Icon.RadioUnchecked = must(widget.NewIcon(icons.ToggleRadioButtonUnchecked))
ret.iconCache = make(map[*byte]*widget.Icon)
return &ret, warn
}
var labelDefaultColor = highEmphasisTextColor
var labelDefaultBgColor = transparent
var labelDefaultFont = fontCollection[6].Font
var labelDefaultFontSize = unit.Sp(18)
func (th *Theme) Icon(data []byte) *widget.Icon {
if icon, ok := th.iconCache[&data[0]]; ok {
return icon
}
icon := must(widget.NewIcon(data))
th.iconCache[&data[0]] = icon
return icon
}
var rowMarkerSurfaceColor = color.NRGBA{R: 0, G: 0, B: 0, A: 0}
var rowMarkerPatternTextColor = secondaryColor
var rowMarkerRowTextColor = mediumEmphasisTextColor
var trackerFont = fontCollection[6].Font
var trackerFontSize = unit.Sp(16)
var trackerInactiveTextColor = highEmphasisTextColor
var trackerActiveTextColor = color.NRGBA{R: 255, G: 255, B: 130, A: 255}
var trackerPlayColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
var trackerPatMarker = primaryColor
var oneBeatHighlight = color.NRGBA{R: 31, G: 37, B: 38, A: 255}
var twoBeatHighlight = color.NRGBA{R: 31, G: 51, B: 53, A: 255}
var patternPlayColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
var patternTextColor = primaryColor
var patternCellColor = color.NRGBA{R: 255, G: 255, B: 255, A: 3}
var loopMarkerColor = color.NRGBA{R: 252, G: 186, B: 3, A: 255}
var instrumentHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255}
var instrumentNameColor = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
var instrumentNameHintColor = color.NRGBA{R: 200, G: 200, B: 200, A: 255}
var songSurfaceColor = color.NRGBA{R: 37, G: 37, B: 38, A: 255}
var popupSurfaceColor = color.NRGBA{R: 50, G: 50, B: 51, A: 255}
var popupShadowColor = color.NRGBA{R: 0, G: 0, B: 0, A: 192}
var dragListSelectedColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
var dragListHoverColor = color.NRGBA{R: 42, G: 45, B: 61, A: 255}
var unitTypeListHighlightColor = color.NRGBA{R: 42, G: 45, B: 61, A: 255}
var inactiveLightSurfaceColor = color.NRGBA{R: 37, G: 37, B: 38, A: 255}
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 errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255}
var menuHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255}
var scrollBarColor = color.NRGBA{R: 255, G: 255, B: 255, A: 32}
var warningColor = color.NRGBA{R: 251, G: 192, B: 45, A: 255}
var dialogBgColor = color.NRGBA{R: 0, G: 0, B: 0, A: 224}
func must[T any](ic T, err error) T {
if err != nil {
panic(err)
}
return ic
}

288
tracker/gioui/theme.yml Normal file
View File

@ -0,0 +1,288 @@
# Because we use yaml.UnmarshalStrict, we needed to have "Define any" field for
# all the defines; UnmarshalStrict thrwows an error if a field is not defined
define:
[
&primarycolor { r: 206, g: 147, b: 216, a: 255 },
&secondarycolor { r: 128, g: 222, b: 234, a: 255 },
&transparentcolor { r: 0, g: 0, b: 0, a: 0 },
&mediumemphasis { r: 153, g: 153, b: 153, a: 255 },
&highemphasis { r: 222, g: 222, b: 222, a: 255 },
&disabled { r: 255, g: 255, b: 255, a: 97 },
&errorcolor { r: 207, g: 102, b: 121, a: 255 },
&warningcolor { r: 251, g: 192, b: 45, a: 255 },
&white { r: 255, g: 255, b: 255, a: 255 },
&black { r: 0, g: 0, b: 0, a: 255 },
&loopcolor { r: 252, g: 186, b: 3, a: 255 },
&scrollbarcolor { r: 255, g: 255, b: 255, a: 32 },
]
# from here on starts the structs defined in the theme.go
material:
textsize: 16
fingersize: 38
palette:
bg: &bg { r: 18, g: 18, b: 18, a: 255 }
fg: &fg { r: 255, g: 255, b: 255, a: 255 }
contrastbg: *primarycolor
contrastfg: &contrastfg { r: 0, g: 0, b: 0, a: 255 }
button:
filled:
background: *primarycolor
color: *contrastfg
textsize: &buttontextsize 14
cornerradius: &buttoncornerradius 18
height: &buttonheight 36
inset: &buttoninset { top: 0, bottom: 0, left: 6, right: 6 }
text: &textbutton
background: *transparentcolor
color: *primarycolor
textsize: *buttontextsize
cornerradius: *buttoncornerradius
height: *buttonheight
inset: *buttoninset
disabled:
background: { r: 53, g: 51, b: 55, a: 255 }
color: { r: 120, g: 116, b: 121, a: 255 }
textsize: *buttontextsize
cornerradius: *buttoncornerradius
height: *buttonheight
inset: *buttoninset
menu:
background: *transparentcolor
color: { r: 255, g: 255, b: 255, a: 255 }
textsize: *buttontextsize
cornerradius: 0
height: *buttonheight
inset: *buttoninset
tab:
active: *textbutton
inactive:
background: *transparentcolor
color: *highemphasis
textsize: *buttontextsize
cornerradius: *buttoncornerradius
height: *buttonheight
inset: *buttoninset
indicatorheight: 2
indicatorcolor: *primarycolor
iconbutton:
enabled:
color: *primarycolor
size: 24
inset: { top: 6, bottom: 6, left: 6, right: 6 }
disabled:
color: *disabled
size: 24
inset: { top: 6, bottom: 6, left: 6, right: 6 }
emphasis:
color: *contrastfg
background: *primarycolor
size: 24
inset: { top: 6, bottom: 6, left: 6, right: 6 }
error:
color: *errorcolor
size: 24
inset: { top: 6, bottom: 6, left: 6, right: 6 }
plot:
curvecolors: [*primarycolor, *secondarycolor,*disabled]
limitcolor: { r: 255, g: 255, b: 255, a: 8 }
cursorcolor: { r: 252, g: 186, b: 3, a: 255 }
ticks: { textsize: 12, color: *disabled, maxlines: 1}
dppertick: 50
numericupdown:
bgcolor: { r: 255, g: 255, b: 255, a: 3 }
textcolor: *fg
iconcolor: *primarycolor
cornerradius: 4
buttonwidth: 16
textsize: 14
width: 70
height: 20
songpanel:
bg: { r: 24, g: 24, b: 24, a: 255 }
rowheader:
textsize: 14
color: *mediumemphasis
rowvalue:
textsize: 14
color: *mediumemphasis
expander:
textsize: 14
color: *highemphasis
errorcolor: *errorcolor
version:
textsize: 12
color: *mediumemphasis
scrollbar: { width: 6, color: *scrollbarcolor }
alert:
error:
bg: *errorcolor
text: { textsize: 16, color: *black }
warning:
bg: *warningcolor
text: { textsize: 16, color: *black }
info:
bg: { r: 50, g: 50, b: 51, a: 255 }
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
margin: { top: 6, bottom: 6, left: 6, right: 6 }
inset: { top: 6, bottom: 6, left: 6, right: 6 }
ordereditor:
tracktitle: { textsize: 12, color: *mediumemphasis }
rowtitle:
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
cell: { textsize: 16, color: *primarycolor, font: { typeface: "Go Mono" } }
loop: *loopcolor
cellbg: { r: 255, g: 255, b: 255, a: 3 }
play: { r: 55, g: 55, b: 61, a: 255 }
noteeditor:
tracktitle: { textsize: 12, color: *mediumemphasis, alignment: 2 }
orderrow:
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
patternrow:
{ textsize: 16, color: *mediumemphasis, font: { typeface: "Go Mono" } }
note: { textsize: 16, color: *highemphasis, font: { typeface: "Go Mono" } }
patternno:
{ textsize: 16, color: *primarycolor, font: { typeface: "Go Mono" } }
unique:
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
loop: *loopcolor
header: { textsize: 14, color: *disabled }
play: { r: 55, g: 55, b: 61, a: 255 }
onebeat: { r: 31, g: 37, b: 38, a: 255 }
twobeat: { r: 31, g: 51, b: 53, a: 255 }
menu:
main:
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
shortcut: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
hover: { r: 100, g: 140, b: 255, a: 48 }
disabled: *disabled
width: 200
height: 300
preset:
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
shortcut: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
hover: { r: 100, g: 140, b: 255, a: 48 }
disabled: *disabled
width: 180
height: 300
instrumenteditor:
octave: { textsize: 14, color: *disabled }
properties:
label: { textsize: 14, color: *highemphasis }
instrumentcomment:
{ textsize: 14, color: *highemphasis, hintcolor: *disabled }
unitcomment: { textsize: 14, color: *mediumemphasis, hintcolor: *disabled }
instrumentlist:
number: { textsize: 10, color: *mediumemphasis }
name: { textsize: 12, color: *white, hintcolor: *disabled }
namemuted:
textsize: 12
color: *disabled
hintcolor: *disabled
font: { style: 1 }
scrollbar: { width: 6, color: *scrollbarcolor }
unitlist:
name: { textsize: 12, color: *white, hintcolor: *disabled }
namedisabled:
textsize: 12
color: *disabled
hintcolor: *disabled
font: { style: 1 }
comment: { textsize: 12, color: *disabled, maxlines: 1}
stack: { textsize: 12, color: *mediumemphasis, shadowcolor: *black }
disabled: { textsize: 12, color: *disabled }
warning: *warningcolor
error: *errorcolor
presets:
searchbg: { r: 255, g: 255, b: 255, a: 6 }
directory: { textsize: 12, color: *white, maxlines: 1 }
results:
builtin: { textsize: 12, color: *white, maxlines: 1 }
user: { textsize: 12, color: *secondarycolor, maxlines: 1 }
userdir: { textsize: 12, color: *mediumemphasis, maxlines: 1 }
cursor:
active: { r: 100, g: 140, b: 255, a: 48 }
activealt: { r: 255, g: 100, b: 140, a: 48 }
inactive: { r: 140, g: 140, b: 140, a: 48 }
selection:
active: { r: 100, g: 140, b: 255, a: 16 }
activealt: { r: 255, g: 100, b: 140, a: 24 }
inactive: { r: 140, g: 140, b: 140, a: 16 }
scrollbar: { width: 10, color: *scrollbarcolor, gradient: *black }
tooltip: { color: *white, bg: *black }
popup:
dialog:
color: { r: 50, g: 50, b: 51, a: 255 }
cornerradii: { nw: 6, ne: 6, se: 6, sw: 6 }
shadow: { n: 2, s: 2, e: 2, w: 2, color: { r: 0, g: 0, b: 0, a: 192 } }
menu:
color: { r: 50, g: 50, b: 51, a: 255 }
cornerradii: { nw: 0, ne: 0, se: 6, sw: 6 }
shadow: { n: 0, s: 2, e: 2, w: 2, color: { r: 0, g: 0, b: 0, a: 192 } }
dialog:
bg: { r: 0, g: 0, b: 0, a: 224 }
title: { textsize: 16, color: *highemphasis, shadowcolor: *black }
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
titleinset: { top: 12, left: 20, right: 20 }
textinset: { top: 12, bottom: 12, left: 20, right: 20 }
buttons: *textbutton
split: { bar: 10, minsize1: 180, minsize2: 180 }
uniteditor:
hint: { textsize: 16, color: *highemphasis, shadowcolor: *black }
chooser: { textsize: 12, color: *white, shadowcolor: *black }
name:
{ textsize: 12, alignment: 2, color: *highemphasis, shadowcolor: *black }
wirecolor: *secondarycolor
wirehighlight: *white
wirehint: { textsize: 12, color: *disabled, shadowcolor: *black }
width: 60
height: 70
unitlist:
labelwidth: 16
name: { textsize: 12, color: *white, alignment: 2 }
disabled:
{ textsize: 12, color: *disabled, font: { style: 1 }, alignment: 2 }
error: *errorcolor
divider: { r: 255, g: 255, b: 255, a: 5 }
rackcomment: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
knob:
diameter: 36
value: { textsize: 12, color: *highemphasis }
strokewidth: 4
bg: { r: 40, g: 40, b: 40, a: 255 }
pos: { color: *primarycolor, bg: { r: 51, g: 36, b: 54, a: 255 } }
neg: { color: *secondarycolor, bg: { r: 32, g: 55, b: 58, a: 255 } }
indicator: { color: *white, width: 2, innerdiam: 24, outerdiam: 36 }
disabledknob:
diameter: 36
value: { textsize: 12, color: { r: 147, g: 143, b: 153, a: 255 }}
strokewidth: 4
bg: { r: 40, g: 40, b: 40, a: 255 }
pos: { color: { r: 147, g: 143, b: 153, a: 255 }, bg: { r: 54, g: 52, b: 59, a: 255 } }
neg: { color: { r: 147, g: 143, b: 153, a: 255 }, bg: { r: 54, g: 52, b: 59, a: 255 } }
indicator: { color: { r: 147, g: 143, b: 153, a: 255 }, width: 2, innerdiam: 24, outerdiam: 36 }
signalrail:
color: *secondarycolor
signalwidth: 10
linewidth: 2
portdiameter: 8
portcolor: *primarycolor
port:
diameter: 36
strokewidth: 4
color: { r: 32, g: 55, b: 58, a: 255 }
switch:
width: 36
height: 20
handle: 16
neutral:
fg: { r: 147, g: 143, b: 153, a: 255 }
bg: { r: 54, g: 52, b: 59, a: 255 }
pos:
fg: *white
bg: { r: 125, g: 87, b: 128, a: 255 }
neg:
fg: *white
bg: { r: 70, g: 128, b: 131, a: 255 }
icon: 10
outline: 1

151
tracker/gioui/tooltip.go Normal file
View File

@ -0,0 +1,151 @@
package gioui
import (
"image"
"image/color"
"time"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/x/component"
)
// TipArea holds the state information for displaying a tooltip. The zero
// value will choose sensible defaults for all fields.
type TipArea struct {
component.VisibilityAnimation
Hover component.InvalidateDeadline
Press component.InvalidateDeadline
LongPress component.InvalidateDeadline
Exit component.InvalidateDeadline
init bool
// HoverDelay is the delay between the cursor entering the tip area
// and the tooltip appearing.
HoverDelay time.Duration
// LongPressDelay is the required duration of a press in the area for
// it to count as a long press.
LongPressDelay time.Duration
// LongPressDuration is the amount of time the tooltip should be displayed
// after being triggered by a long press.
LongPressDuration time.Duration
// FadeDuration is the amount of time it takes the tooltip to fade in
// and out.
FadeDuration time.Duration
// ExitDuration is the amount of time the tooltip will remain visible at
// maximum, to avoid tooltips staying visible indefinitely if the user
// managed to leave the area without triggering a pointer.Leave event.
ExitDuration time.Duration
}
const (
tipAreaHoverDelay = time.Millisecond * 500
tipAreaLongPressDuration = time.Millisecond * 1500
tipAreaFadeDuration = time.Millisecond * 250
longPressTheshold = time.Millisecond * 500
tipAreaExitDelay = time.Millisecond * 5000
)
// Layout renders the provided widget with the provided tooltip. The tooltip
// will be summoned if the widget is hovered or long-pressed.
func (t *TipArea) Layout(gtx C, tip component.Tooltip, w layout.Widget) D {
if !t.init {
t.init = true
t.VisibilityAnimation.State = component.Invisible
if t.HoverDelay == time.Duration(0) {
t.HoverDelay = tipAreaHoverDelay
}
if t.LongPressDelay == time.Duration(0) {
t.LongPressDelay = longPressTheshold
}
if t.LongPressDuration == time.Duration(0) {
t.LongPressDuration = tipAreaLongPressDuration
}
if t.FadeDuration == time.Duration(0) {
t.FadeDuration = tipAreaFadeDuration
}
if t.ExitDuration == time.Duration(0) {
t.ExitDuration = tipAreaExitDelay
}
t.VisibilityAnimation.Duration = t.FadeDuration
}
for {
ev, ok := gtx.Event(pointer.Filter{
Target: t,
Kinds: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
// regardless of the event, we reset the exit timer to avoid tooltips
// staying visible indefinitely
t.Exit.SetTarget(gtx.Now.Add(t.ExitDuration))
switch e.Kind {
case pointer.Enter:
t.Hover.SetTarget(gtx.Now.Add(t.HoverDelay))
t.Exit.SetTarget(gtx.Now.Add(t.ExitDuration))
case pointer.Leave:
t.VisibilityAnimation.Disappear(gtx.Now)
t.Hover.ClearTarget()
case pointer.Press:
t.Press.SetTarget(gtx.Now.Add(t.LongPressDelay))
case pointer.Release:
t.Press.ClearTarget()
case pointer.Cancel:
t.Hover.ClearTarget()
t.Press.ClearTarget()
}
}
if t.Hover.Process(gtx) {
t.VisibilityAnimation.Appear(gtx.Now)
}
if t.Press.Process(gtx) {
t.VisibilityAnimation.Appear(gtx.Now)
t.LongPress.SetTarget(gtx.Now.Add(t.LongPressDuration))
}
if t.LongPress.Process(gtx) {
t.VisibilityAnimation.Disappear(gtx.Now)
}
if t.Exit.Process(gtx) {
t.VisibilityAnimation.Disappear(gtx.Now)
}
return layout.Stack{}.Layout(gtx,
layout.Stacked(w),
layout.Expanded(func(gtx C) D {
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, t)
originalMin := gtx.Constraints.Min
gtx.Constraints.Min = image.Point{}
if t.Visible() {
macro := op.Record(gtx.Ops)
tip.Bg = component.Interpolate(color.NRGBA{}, tip.Bg, t.VisibilityAnimation.Revealed(gtx))
dims := tip.Layout(gtx)
call := macro.Stop()
xOffset := (originalMin.X / 2) - (dims.Size.X / 2)
yOffset := originalMin.Y
macro = op.Record(gtx.Ops)
op.Offset(image.Pt(xOffset, yOffset)).Add(gtx.Ops)
call.Add(gtx.Ops)
call = macro.Stop()
op.Defer(gtx.Ops, call)
}
return D{}
}),
)
}
func Tooltip(th *Theme, tip string) component.Tooltip {
tooltip := component.PlatformTooltip(&th.Material, tip)
tooltip.Bg = th.Tooltip.Bg
tooltip.Text.Color = th.Tooltip.Color
return tooltip
}

View File

@ -4,13 +4,16 @@ import (
"fmt"
"image"
"io"
"os/exec"
"path/filepath"
"sync"
"runtime"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/io/transfer"
"gioui.org/layout"
@ -18,9 +21,8 @@ import (
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
"gioui.org/x/explorer"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
)
@ -28,34 +30,40 @@ var canQuit = true // set to false in init() if plugin tag is enabled
type (
Tracker struct {
Theme *material.Theme
OctaveNumberInput *NumberInput
InstrumentVoices *NumberInput
TopHorizontalSplit *Split
BottomHorizontalSplit *Split
VerticalSplit *Split
KeyPlaying map[key.Name]tracker.NoteID
PopupAlert *PopupAlert
Theme *Theme
OctaveNumberInput *NumericUpDownState
InstrumentVoices *NumericUpDownState
TopHorizontalSplit *SplitState
BottomHorizontalSplit *SplitState
VerticalSplit *SplitState
KeyNoteMap Keyboard[key.Name]
PopupAlert *AlertsState
Zoom int
SaveChangesDialog *Dialog
WaveTypeDialog *Dialog
DialogState *DialogState
ModalDialog layout.Widget
InstrumentEditor *InstrumentEditor
OrderEditor *OrderEditor
TrackEditor *NoteEditor
Explorer *explorer.Explorer
Exploring bool
SongPanel *SongPanel
ModalDialog layout.Widget
PatchPanel *PatchPanel
OrderEditor *OrderEditor
TrackEditor *NoteEditor
Explorer *explorer.Explorer
Exploring bool
SongPanel *SongPanel
filePathString tracker.String
noteEvents []tracker.NoteEvent
quitWG sync.WaitGroup
execChan chan func()
preferences Preferences
*tracker.Model
surfaceHeight int
}
ShowManual Tracker
AskHelp Tracker
ReportBug Tracker
C = layout.Context
D = layout.Dimensions
)
@ -66,139 +74,168 @@ const (
ConfirmNew
)
var ZoomFactors = []float32{.25, 1. / 3, .5, 2. / 3, .75, .8, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5}
func NewTracker(model *tracker.Model) *Tracker {
t := &Tracker{
Theme: material.NewTheme(),
OctaveNumberInput: NewNumberInput(model.Octave().Int()),
InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
OctaveNumberInput: NewNumericUpDownState(),
InstrumentVoices: NewNumericUpDownState(),
TopHorizontalSplit: &Split{Ratio: -.5},
BottomHorizontalSplit: &Split{Ratio: -.6},
VerticalSplit: &Split{Axis: layout.Vertical},
TopHorizontalSplit: &SplitState{Ratio: -.5},
BottomHorizontalSplit: &SplitState{Ratio: -.6},
VerticalSplit: &SplitState{Axis: layout.Vertical},
KeyPlaying: make(map[key.Name]tracker.NoteID),
SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
InstrumentEditor: NewInstrumentEditor(model),
OrderEditor: NewOrderEditor(model),
TrackEditor: NewNoteEditor(model),
SongPanel: NewSongPanel(model),
DialogState: new(DialogState),
PatchPanel: NewPatchPanel(model),
OrderEditor: NewOrderEditor(model),
TrackEditor: NewNoteEditor(model),
Zoom: 6,
Model: model,
filePathString: model.FilePath().String(),
execChan: make(chan func(), 1024),
filePathString: model.Song().FilePath(),
}
t.SongPanel = NewSongPanel(t)
t.KeyNoteMap = MakeKeyboard[key.Name](model.Broker())
t.PopupAlert = NewAlertsState()
var warn error
if t.Theme, warn = NewTheme(); warn != nil {
model.Alerts().AddAlert(tracker.Alert{
Priority: tracker.Warning,
Message: warn.Error(),
Duration: 10 * time.Second,
})
}
t.Theme.Material.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
if warn := ReadConfig(defaultPreferences, "preferences.yml", &t.preferences); warn != nil {
model.Alerts().AddAlert(tracker.Alert{
Priority: tracker.Warning,
Message: warn.Error(),
Duration: 10 * time.Second,
})
}
t.Theme.Shaper = text.NewShaper(text.WithCollection(fontCollection))
t.PopupAlert = NewPopupAlert(model.Alerts(), t.Theme.Shaper)
t.Theme.Palette.Fg = primaryColor
t.Theme.Palette.ContrastFg = black
t.TrackEditor.scrollTable.Focus()
t.quitWG.Add(1)
return t
}
func (t *Tracker) Main() {
titleFooter := ""
w := app.NewWindow(
app.Size(unit.Dp(800), unit.Dp(600)),
app.Title("Sointu Tracker"),
)
t.InstrumentEditor.Focus()
recoveryTicker := time.NewTicker(time.Second * 30)
t.Explorer = explorer.NewExplorer(w)
// Make a channel to read window events from.
events := make(chan event.Event)
// Make a channel to signal the end of processing a window event.
acks := make(chan struct{})
go eventLoop(w, events, acks)
var ops op.Ops
for {
if titleFooter != t.filePathString.Value() {
titleFooter = t.filePathString.Value()
if titleFooter != "" {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
} else {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker")))
titlePath := ""
globals := make(map[string]any, 1)
globals["Tracker"] = t
for !t.Quitted() {
w := t.newWindow()
w.Option(app.Title(titleFromPath(titlePath)))
t.Explorer = explorer.NewExplorer(w)
acks := make(chan struct{})
events := make(chan event.Event)
go func() {
for {
ev := w.Event()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
}
}
select {
case e := <-t.PlayerMessages:
t.ProcessPlayerMessage(e)
w.Invalidate()
case e := <-events:
switch e := e.(type) {
case app.DestroyEvent:
acks <- struct{}{}
if canQuit {
t.Quit().Do()
}()
F:
for {
select {
case e := <-t.Broker().ToGUI:
switch e := e.(type) {
case tracker.NoteEvent:
t.noteEvents = append(t.noteEvents, e)
case tracker.MsgToGUI:
switch e.Kind {
case tracker.GUIMessageCenterOnRow:
t.TrackEditor.scrollTable.RowTitleList.CenterOn(e.Param)
case tracker.GUIMessageEnsureCursorVisible:
t.TrackEditor.scrollTable.EnsureCursorVisible()
}
}
if !t.Quitted() {
// 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"),
)
t.Explorer = explorer.NewExplorer(w)
go eventLoop(w, events, acks)
w.Invalidate()
case e := <-t.Broker().ToModel:
t.ProcessMsg(e)
w.Invalidate()
case <-t.Broker().CloseGUI:
t.ForceQuit().Do()
w.Perform(system.ActionClose)
case e := <-events:
switch e := e.(type) {
case app.DestroyEvent:
if canQuit {
t.RequestQuit().Do()
}
acks <- struct{}{}
break F // this window is done, we need to create a new one
case app.FrameEvent:
if titlePath != t.filePathString.Value() {
titlePath = t.filePathString.Value()
w.Option(app.Title(titleFromPath(titlePath)))
}
gtx := app.NewContext(&ops, e)
gtx.Values = globals
t.Layout(gtx)
e.Frame(gtx.Ops)
if t.Quitted() {
w.Perform(system.ActionClose)
}
}
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
if t.SongPanel.PlayingBtn.Bool.Value() && t.SongPanel.NoteTracking.Bool.Value() {
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
}
t.Layout(gtx, w)
e.Frame(gtx.Ops)
acks <- struct{}{}
default:
acks <- struct{}{}
case <-recoveryTicker.C:
t.History().SaveRecovery()
}
case <-recoveryTicker.C:
t.SaveRecovery()
case f := <-t.execChan:
f()
}
if t.Quitted() {
break
}
}
recoveryTicker.Stop()
w.Perform(system.ActionClose)
t.SaveRecovery()
t.quitWG.Done()
t.History().SaveRecovery()
close(t.Broker().FinishedGUI)
}
func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) {
// Iterate window events, sending each to the old event loop and waiting for
// a signal that processing is complete before iterating again.
for {
ev := w.NextEvent()
events <- ev
<-acks
if _, ok := ev.(app.DestroyEvent); ok {
return
}
func TrackerFromContext(gtx C) *Tracker {
t, ok := gtx.Values["Tracker"]
if !ok {
panic("Tracker not found in context values")
}
return t.(*Tracker)
}
func (t *Tracker) Exec() chan<- func() {
return t.execChan
func (t *Tracker) newWindow() *app.Window {
w := new(app.Window)
w.Option(app.Size(t.preferences.WindowSize()))
if t.preferences.Window.Maximized {
w.Option(app.Maximized.Option())
}
return w
}
func (t *Tracker) WaitQuitted() {
t.quitWG.Wait()
func titleFromPath(path string) string {
if path == "" {
return "Sointu Tracker"
}
return fmt.Sprintf("Sointu Tracker - %s", path)
}
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
func (t *Tracker) Layout(gtx layout.Context) {
zoomFactor := ZoomFactors[t.Zoom]
gtx.Metric.PxPerDp *= zoomFactor
gtx.Metric.PxPerSp *= zoomFactor
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, t.Theme.Material.Bg)
event.Op(gtx.Ops, t) // area for capturing scroll events
if t.Play().TrackerHidden().Value() {
t.layoutTop(gtx)
} else {
t.VerticalSplit.Layout(gtx,
&t.Theme.Split,
t.layoutTop,
t.layoutBottom)
}
t.PopupAlert.Layout(gtx)
alerts := Alerts(t.Alerts(), t.Theme, t.PopupAlert)
alerts.Layout(gtx)
t.showDialog(gtx)
// this is the top level input handler for the whole app
// it handles all the global key events and clipboard events
@ -207,20 +244,38 @@ func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
for {
ev, ok := gtx.Event(
key.Filter{Name: "", Optional: key.ModAlt | key.ModCommand | key.ModShift | key.ModShortcut | key.ModSuper},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameTab, Optional: key.ModShift | key.ModShortcut},
transfer.TargetFilter{Target: t, Type: "application/text"},
pointer.Filter{Target: t, Kinds: pointer.Scroll, ScrollY: pointer.ScrollRange{Min: -1, Max: 1}},
)
if !ok {
break
}
switch e := ev.(type) {
case pointer.Event:
switch e.Kind {
case pointer.Scroll:
if e.Modifiers.Contain(key.ModShortcut) {
t.Zoom = min(max(t.Zoom-int(e.Scroll.Y), 0), len(ZoomFactors)-1)
t.Alerts().AddNamed("ZoomFactor", fmt.Sprintf("%.0f%%", ZoomFactors[t.Zoom]*100), tracker.Info)
}
}
case key.Event:
t.KeyEvent(e, gtx)
case transfer.DataEvent:
t.ReadSong(e.Open())
t.Song().Read(e.Open())
}
}
// if no-one else handled the note events, we handle them here
for len(t.noteEvents) > 0 {
ev := t.noteEvents[0]
ev.IsTrack = false
ev.Channel = t.Model.Instrument().List().Selected()
ev.Source = t
copy(t.noteEvents, t.noteEvents[1:])
t.noteEvents = t.noteEvents[:len(t.noteEvents)-1]
tracker.TrySend(t.Broker().ToPlayer, any(ev))
}
}
func (t *Tracker) showDialog(gtx C) {
@ -229,31 +284,52 @@ func (t *Tracker) showDialog(gtx C) {
}
switch t.Dialog() {
case tracker.NewSongChanges, tracker.OpenSongChanges, tracker.QuitChanges:
dstyle := ConfirmDialog(gtx, t.Theme, t.SaveChangesDialog, "Save changes to song?", "Your changes will be lost if you don't save them.")
dstyle.OkStyle.Text = "Save"
dstyle.AltStyle.Text = "Don't save"
dstyle.Layout(gtx)
dialog := MakeDialog(t.Theme, t.DialogState, "Save changes to song?", "Your changes will be lost if you don't save them.",
DialogBtn("Save", t.Song().Save()),
DialogBtn("Don't save", t.Song().Discard()),
DialogBtn("Cancel", t.CancelDialog()),
)
dialog.Layout(gtx)
case tracker.Export:
dstyle := ConfirmDialog(gtx, t.Theme, t.WaveTypeDialog, "", "Export .wav in int16 or float32 sample format?")
dstyle.OkStyle.Text = "Int16"
dstyle.AltStyle.Text = "Float32"
dstyle.Layout(gtx)
dialog := MakeDialog(t.Theme, t.DialogState, "Export format", "Choose the sample format for the exported .wav file.",
DialogBtn("Int16", t.Song().ExportInt16()),
DialogBtn("Float32", t.Song().ExportFloat()),
DialogBtn("Cancel", t.CancelDialog()),
)
dialog.Layout(gtx)
case tracker.OpenSongOpenExplorer:
t.explorerChooseFile(t.ReadSong, ".yml", ".json")
t.explorerChooseFile(t.Song().Read, ".yml", ".json")
case tracker.NewSongSaveExplorer, tracker.OpenSongSaveExplorer, tracker.QuitSaveExplorer, tracker.SaveAsExplorer:
filename := t.filePathString.Value()
if filename == "" {
filename = "song.yml"
}
t.explorerCreateFile(t.WriteSong, filename)
t.explorerCreateFile(t.Song().Write, filename)
case tracker.ExportFloatExplorer, tracker.ExportInt16Explorer:
filename := "song.wav"
if p := t.filePathString.Value(); p != "" {
filename = p[:len(p)-len(filepath.Ext(p))] + ".wav"
}
t.explorerCreateFile(func(wc io.WriteCloser) {
t.WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer, t.execChan)
t.Song().WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer)
}, filename)
case tracker.License:
dialog := MakeDialog(t.Theme, t.DialogState, "License", sointu.License,
DialogBtn("Close", t.CancelDialog()),
)
dialog.Layout(gtx)
case tracker.DeleteUserPresetDialog:
dialog := MakeDialog(t.Theme, t.DialogState, "Delete user preset?", "Are you sure you want to delete the selected user preset?\nThis action cannot be undone.",
DialogBtn("Delete", t.Preset().ConfirmDelete()),
DialogBtn("Cancel", t.CancelDialog()),
)
dialog.Layout(gtx)
case tracker.OverwriteUserPresetDialog:
dialog := MakeDialog(t.Theme, t.DialogState, "Overwrite user preset?", "Are you sure you want to overwrite the existing user preset with the same name?",
DialogBtn("Save", t.Preset().Overwrite()),
DialogBtn("Cancel", t.CancelDialog()),
)
dialog.Layout(gtx)
}
}
@ -261,14 +337,17 @@ func (t *Tracker) explorerChooseFile(success func(io.ReadCloser), extensions ...
t.Exploring = true
go func() {
file, err := t.Explorer.ChooseFile(extensions...)
t.Exec() <- func() {
t.Broker().ToModel <- tracker.MsgToModel{Data: func() {
t.Exploring = false
if err == nil {
success(file)
} else {
t.Cancel().Do()
t.CancelDialog().Do()
if err != explorer.ErrUserDecline {
t.Alerts().Add(err.Error(), tracker.Error)
}
}
}
}}
}()
}
@ -276,35 +355,71 @@ func (t *Tracker) explorerCreateFile(success func(io.WriteCloser), filename stri
t.Exploring = true
go func() {
file, err := t.Explorer.CreateFile(filename)
t.Exec() <- func() {
t.Broker().ToModel <- tracker.MsgToModel{Data: func() {
t.Exploring = false
if err == nil {
success(file)
} else {
t.Cancel().Do()
t.CancelDialog().Do()
if err != explorer.ErrUserDecline {
t.Alerts().Add(err.Error(), tracker.Error)
}
}
}
}}
}()
}
func (t *Tracker) layoutBottom(gtx layout.Context) layout.Dimensions {
return t.BottomHorizontalSplit.Layout(gtx,
func(gtx C) D {
return t.OrderEditor.Layout(gtx, t)
},
func(gtx C) D {
return t.TrackEditor.Layout(gtx, t)
},
&t.Theme.Split,
t.OrderEditor.Layout,
t.TrackEditor.Layout,
)
}
func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
return t.TopHorizontalSplit.Layout(gtx,
func(gtx C) D {
return t.SongPanel.Layout(gtx, t)
},
func(gtx C) D {
return t.InstrumentEditor.Layout(gtx, t)
},
&t.Theme.Split,
t.SongPanel.Layout,
t.PatchPanel.Layout,
)
}
func (t *Tracker) ShowManual() tracker.Action { return tracker.MakeAction((*ShowManual)(t)) }
func (t *ShowManual) Do() { (*Tracker)(t).openUrl("https://github.com/vsariola/sointu/wiki") }
func (t *Tracker) AskHelp() tracker.Action { return tracker.MakeAction((*AskHelp)(t)) }
func (t *AskHelp) Do() {
(*Tracker)(t).openUrl("https://github.com/vsariola/sointu/discussions/categories/help-needed")
}
func (t *Tracker) ReportBug() tracker.Action { return tracker.MakeAction((*ReportBug)(t)) }
func (t *ReportBug) Do() { (*Tracker)(t).openUrl("https://github.com/vsariola/sointu/issues") }
func (t *Tracker) openUrl(url string) {
var err error
// following https://gist.github.com/hyg/9c4afcd91fe24316cbf0
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform for opening urls %s", runtime.GOOS)
}
if err != nil {
t.Alerts().Add(err.Error(), tracker.Error)
}
}
func (t *Tracker) Tags(curLevel int, yield TagYieldFunc) bool {
curLevel++
ret := t.SongPanel.Tags(curLevel, yield) && t.PatchPanel.Tags(curLevel, yield)
if !t.Play().TrackerHidden().Value() {
ret = ret && t.OrderEditor.Tags(curLevel, yield) &&
t.TrackEditor.Tags(curLevel, yield)
}
return ret
}

Some files were not shown because too many files have changed in this diff Show More