42 Commits

Author SHA1 Message Date
2667c3c72c docs: update CHANGELOG.md for v0.4.0 2024-08-10 15:29:49 +03:00
e09af5ab34 fix(tracker): loading preset did not update the ids
When a preset was loaded, its IDs were not updated,
causing ID collisions in the song and send targets
going wrong.
2024-08-10 15:20:25 +03:00
db2d9cac9d fix(vm): x87 native filter unit was denormalizing and eating up CPU
When voice was silent, the exponential decays in the filter unit
were causing the high pass component to eventually denormalize,
causing high CPU loads. The solution is the same as in the delay
unit: add and subtract a small number from the value, causing
essentially a flush to zero.
https://en.wikipedia.org/wiki/Subnormal_number

Fixes #68.
2024-06-19 18:58:20 +03:00
a14e21dff6 ci: macos-latest is now arm64 and breaks, use macos-12 for now 2024-05-05 13:19:25 +03:00
58916d3c6d docs: update README.md, to recommend using nasm instead of yasm 2024-05-05 11:37:42 +03:00
84d90cf0f3 fix(vm/compiler): use more yasm-compatible syntax
Closes #134.
2024-05-05 11:34:47 +03:00
10d20cd26f fix(vm/compiler): export as su_pow, instead of su_power
The export redefinition of label, even though the labels were on the
same line. This was an issue for yasm.

Related to #134.
2024-05-05 11:33:47 +03:00
4a8d4c5a29 fix(vm/compiler/templates): modulating delaytime in wasm could crash
The modulated delay time was converted to int with i32.trunc_f32_u.
This throws runtime error if the modulations caused the delaytime
to become negative, because _u implied that it should be unsigned
integer and negative numbers were out of range. Using
i32.trunc_f32_s fixed this.
2024-04-08 20:06:20 +03:00
f074c392f6 docs: add anticore to contributors 2024-04-08 19:05:20 +03:00
20fc12c529 feat(examples): add example demonstrating wasm playback in browser 2024-04-08 19:01:39 +03:00
6d4529971c feat(vm/compiler): export su_power function in case user needs it 2024-04-05 15:49:00 +03:00
beb84d7652 fix(tracker/gioui): deleting a cell in the order list did not work 2024-03-14 20:37:09 +02:00
c55b27b23b fix(tracker): recording creates empty track when no notes triggered 2024-03-10 20:01:13 +02:00
e488cd391b fix(gioui): scroll wheel works in tables, not just table row titles 2024-03-02 00:40:06 +02:00
7f20bd8baf fix(tracker): remember to tell player when m.d.Loop is updated 2024-03-01 23:54:19 +02:00
07bf8f6cdf fix(gioui): draw cursor in front of play marker in order editor 2024-03-01 23:31:05 +02:00
f0f391356c fix(gioui): arrow keys leave table row/col titles 2024-03-01 23:25:30 +02:00
b18a284252 feat(gioui): + and - keys add/subtract elements in tables
Closes #65.
2024-03-01 22:43:27 +02:00
1c020fffa3 refactor(gioui): update gioui to v0.5.0 2024-03-01 22:11:44 +02:00
267973e061 build: upgrade deprecated actions and fix warnings 2024-02-24 19:06:42 +02:00
6b3aaf6cc9 docs: update README.md 2024-02-24 15:34:40 +02:00
dfc72cd2c4 build: build VSTi instrument binaries also for linux 2024-02-23 20:15:44 +02:00
8a9cbdea62 build: update vst2 to latest version, because it compiles on linux 2024-02-23 19:56:09 +02:00
edee3452f4 feat(tracker): load presets from os.UserConfigDir()/sointu/presets/
This is related to #125, but is very crude way of implementing it.
2024-02-20 20:17:59 +02:00
b70db4d394 docs: add links to 21 and Tausendeins 2024-02-20 19:39:32 +02:00
d5af39e324 docs: add link to Phosphorescent Purple Pixel Peaks 2024-02-20 19:33:46 +02:00
aa1b4d371b fix(tracker): notify player that loop changed when resetting song 2024-02-20 19:28:59 +02:00
dc12f58082 feat(tracker): add ability to loop part of song during playback
Closes #128.
2024-02-20 19:10:15 +02:00
aa7a2e56fa feat(gioui): flip the unit parameter slider scroll wheel behaviour
Closes #112.
2024-02-19 21:49:51 +02:00
17312bbe4e feat: add ability to disable units temporarily
Quite often the user wants to experiment what particular unit(s) add
to the sound. This commit adds ability to disable any set of units
temporarily, without actually deleting them. Ctrl-D disables and
re-enables the units. Disabled units are considered non-existent in
the patch.

Closes #116.
2024-02-19 21:36:14 +02:00
2b3f6d8200 fix(tracker): unit searching to work more reliably 2024-02-17 20:54:46 +02:00
db6c9f6052 fix: warn user if sample rate other than 44100 Hz
Closes #129.
2024-02-17 19:45:36 +02:00
954b306cc8 docs: update CHANGELOG.md to have links to issues 2024-02-17 19:22:14 +02:00
aec756f921 feat(sointu-track): accept filename as command line parameter
Closes #122.
2024-02-17 19:17:47 +02:00
ca4a98eb50 fix(gioui): reduce the default height of popup menus so they fit
Closes #121.
2024-02-17 19:03:49 +02:00
65cfcb045c build: update setup-go to v5 and ask go version >=1.21.0 2024-02-17 18:28:25 +02:00
bb32403c78 build: require go 1.21 as it is needed by slices package 2024-02-17 18:22:31 +02:00
d92426a100 feat!: rewrote the GUI and model for better testability
The Model was getting unmaintanable mess. This is an attempt to refactor/rewrite the Model so that data of certain type is exposed in standardized way, offering certain standard manipulations for that data type, and on the GUI side, certain standard widgets to tied to that data.

This rewrite closes #72, #106 and #120.
2024-02-17 18:16:06 +02:00
6d3c65e11d fix(templates): avoid clobbering ebx in su_load_gmdls
Fixes #130
2024-02-16 20:09:20 +02:00
c08a319eb7 docs: add link to NR4's tool 2023-11-20 08:55:22 +02:00
8227691523 test: the filenames of test_gain and test_gain_stereo were flipped 2023-10-23 22:05:28 +03:00
04fbc9f6a7 feat(vm): add dbgain unit, where gain is defined in decibels
Closes #78
2023-10-23 21:57:29 +03:00
84 changed files with 6977 additions and 4728 deletions

View File

@ -20,7 +20,7 @@ jobs:
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Create release
id: create_release
uses: actions/create-release@v1
@ -74,6 +74,16 @@ jobs:
output: sointu-track-native
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-track
@ -82,19 +92,21 @@ jobs:
asmnasm: /Users/runner/nasm/nasm
output: sointu-compile
params: cmd/sointu-compile/main.go
- os: macos-latest
- os: macos-12 # this is intel still
asmnasm: /Users/runner/nasm/nasm
output: sointu-track-native
params: -tags=native cmd/sointu-track/main.go
steps:
- uses: benjlevesque/short-sha@v2.2
- uses: benjlevesque/short-sha@v3.0
id: short-sha
with:
length: 7
- uses: lukka/get-cmake@v3.18.3
- uses: actions/setup-go@v2
- uses: actions/checkout@v2
- uses: ilammy/setup-nasm@v1.4.0
- uses: lukka/get-cmake@latest
- uses: actions/checkout@v4
- 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'
- uses: ilammy/setup-nasm@v1.5.1
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ${{ matrix.config.packages }}
@ -112,9 +124,9 @@ jobs:
run: |
go build -o ${{ matrix.config.output }} ${{ matrix.config.params }}
- name: Upload binary
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: sointu-${{ runner.os }}-${{ steps.short-sha.outputs.sha }}
name: ${{ runner.os }}-${{ steps.short-sha.outputs.sha }}-${{ matrix.config.output }}
path: ${{ matrix.config.output }}
upload_release_asset:
needs: [create_release, binaries]
@ -132,9 +144,10 @@ jobs:
with:
length: 7
- name: Download artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: sointu-${{ matrix.config.os }}-${{ steps.short-sha.outputs.sha }}
pattern: ${{ matrix.config.os }}-${{ steps.short-sha.outputs.sha }}-*
merge-multiple: true
path: sointu-${{ matrix.config.os }}
- name: Zip binaries
run: |

View File

@ -25,7 +25,7 @@ jobs:
asmnasm: C:\Users\runneradmin\nasm\nasm
gotests: yes
cgo_ldflags:
- os: macos-latest
- os: macos-12 # this is intel still
asmnasm: /Users/runner/nasm/nasm
gotests: yes
cgo_ldflags: # -Wl,-no_pie
@ -34,16 +34,18 @@ jobs:
# than let the tests fail because of this.
# TODO: win32 builds didn't quite work out, complains gcc broken
steps:
- uses: lukka/get-cmake@v3.18.3
- uses: vsariola/setup-wabt@v1.0.1
- uses: lukka/get-cmake@latest
- uses: vsariola/setup-wabt@v1.0.2
with:
version: 1.0.29
- uses: actions/setup-go@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- 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'
- uses: actions/setup-node@v4
with:
node-version: '15'
- uses: actions/checkout@v2
- uses: ilammy/setup-nasm@v1.4.0
- uses: ilammy/setup-nasm@v1.5.1
- name: Run ctest
env:
ASM_NASM: ${{ matrix.config.asmnasm }}

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ actual_output/
**/__debug_bin
*.exe
*.dll
**/testdata/fuzz/
.DS_Store

View File

@ -3,6 +3,53 @@ 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
### Added
- User can drop preset instruments into `os.UserConfigDir()/sointu/presets/` and
they appear in the list of presets next time sointu is started.
([#125][i125])
- Ability to loop certain section of the song when playing. The loop can be set
by using the toggle button in the song panel, or by hitting Ctrl+L.
([#128][i128])
- Disable units temporarily. The disabled units are shown in gray and are not
compiled into the patch and are considered for all purposes non-existent.
Hitting Ctrl-D disables/re-enables the selected unit(s). The yaml file has
field `disabled: true` for the unit. ([#116][i116])
- Passing a file name on command line immediately tries loading that file ([#122][i122])
- Massive rewrite of the GUI, in particular allowing better copying, pasting and
scrolling of table-based data (order list and note data).
- Dbgain unit, which allows defining the gain in decibels (-40 dB to +40dB)
- `+` and `-` keys add/subtract values in order editor and pattern editor
([#65][i65])
- The function `su_power` is exported so people can reuse it in the main code;
however, as it assumes the parameter passed in st0 on the x87 stack and
similarly returns it value in st0 on the x87 stack, to my knowledge there is
no calling convention that would correspond this behaviour, so you need to
define a header for it yourself and take care of putting the float value on
x87 stack.
### Fixed
- Loading a preset did not update the IDs of the newly loaded instrument,
causing ID collisions and sends target wrong units.
- The x87 native filter unit was denormalizing and eating up a lot of CPU ([#68][i68])
- Modulating delaytime in wasm could crash, because delay time was converted to
int with i32.trunc_f32_u. Using i32.trunc_f32_s fixed this.
- When recording notes from VSTI, no track was created for instruments that had
no notes triggered, resulting in misalignment of the tracks from instruments.
- 32-bit su_load_gmdls clobbered ebx, even though __stdcall demands it to be not
touched ([#130][i130])
- Spaces are allowed in instrument names ([#120][i120])
- Fixed the dropdown for targeting sends making it impossible to choose certain
ops. This was done just by reducing the default height of popup menus so they
fit on screen ([#121][i121])
- Warn user about sample rate being other than 44100 Hz, as this lead to weird
behaviour. Sointu assumes the samplerate always to be 44100 Hz. ([#129][i129])
### Changed
- The scroll wheel behavior for unit integer parameters was flipped: scrolling
up now increases the value, while scrolling down decreases the value. It was
vice versa. ([#112][i112])
## v0.3.0
### Added
- Scroll bars to menus, shown when a menu is too long to fit.
@ -104,7 +151,19 @@ 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.3.0...HEAD
[Unreleased]: https://github.com/vsariola/sointu/compare/v0.4.0...HEAD
[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
[0.1.0]: https://github.com/vsariola/sointu/compare/4klang-3.11...v0.1.0
[i65]: https://github.com/vsariola/sointu/issues/65
[i68]: https://github.com/vsariola/sointu/issues/68
[i112]: https://github.com/vsariola/sointu/issues/112
[i116]: https://github.com/vsariola/sointu/issues/116
[i120]: https://github.com/vsariola/sointu/issues/120
[i121]: https://github.com/vsariola/sointu/issues/121
[i122]: https://github.com/vsariola/sointu/issues/122
[i125]: https://github.com/vsariola/sointu/issues/125
[i128]: https://github.com/vsariola/sointu/issues/128
[i129]: https://github.com/vsariola/sointu/issues/129
[i130]: https://github.com/vsariola/sointu/issues/130

View File

@ -26,6 +26,8 @@ endif()
IF(APPLE)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-no_pie")
# https://stackoverflow.com/questions/69803659/what-is-the-proper-way-to-build-for-macos-x86-64-using-cmake-on-apple-m1-arm
set(CMAKE_OSX_ARCHITECTURES "x86_64" CACHE INTERNAL "" FORCE)
endif()
find_program(GO NAMES go)

109
README.md
View File

@ -12,11 +12,13 @@ User manual will be in the [Wiki](https://github.com/vsariola/sointu/wiki).
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 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.
The pre 1.0 version tags are mostly for reference: no backwards
compatibility will be guaranteed while upgrading to a newer version.
@ -48,9 +50,9 @@ Sointu consists of two core elements:
.yml files.
- A compiler, likewise written in go, which can be invoked from the command line
to compile these .yml files into .asm or .wat code. For x86/amd64, the
resulting .asm can be then compiled by [nasm](https://www.nasm.us/) or
[yasm](https://yasm.tortall.net). For browsers, the resulting .wat can be
compiled by [wat2wasm](https://github.com/WebAssembly/wabt).
resulting .asm can be then compiled by [nasm](https://www.nasm.us/). For
browsers, the resulting .wat can be compiled by
[wat2wasm](https://github.com/WebAssembly/wabt).
This is how the current prototype app looks like:
@ -131,15 +133,16 @@ a dynamically linked library and ran inside a VST host.
go build -buildmode=c-shared -tags=plugin -o sointu-vsti.dll .\cmd\sointu-vsti\
```
On other platforms than Windows, replace `-o sointu-track.dll`
appropriately e.g. `-o sointu-track.so`; however, the VST instrument is
completely untested on all other platforms than Windows at the moment.
On other platforms than Windows, replace `-o sointu-vsti.dll` appropriately e.g.
`-o sointu-vsti.so`; so far, the VST instrument has been built & tested on
Windows and Linux.
Notice the `-tags=plugin` build tag definition. This is required by the [vst2 library](https://github.com/pipelined/vst2);
otherwise, you will get a lot of build errors.
Notice the `-tags=plugin` build tag definition. This is required by the [vst2
library](https://github.com/pipelined/vst2); otherwise, you will get a lot of
build errors.
Add `-tags=native,plugin` to use the [x86 native virtual machine](#native-virtual-machine)
instead of the virtual machine written in Go.
Add `-tags=native,plugin` to use the [x86 native virtual
machine](#native-virtual-machine) instead of the virtual machine written in Go.
### Sointu-compile
@ -159,7 +162,7 @@ go run cmd/sointu-compile/main.go
go build -o sointu-compile.exe cmd/sointu-compile/main.go
```
On other platforms than Windows, replace `-o sointu-compile-exe` with
On other platforms than Windows, replace `-o sointu-compile.exe` with
`-o sointu-compile`.
#### Usage
@ -179,32 +182,37 @@ sointu-compile -o . -arch=wasm tests/test_chords.yml
wat2wasm --enable-bulk-memory test_chords.wat
```
If you are looking for an easy way to compile an executable from a Sointu song
(e.g. for a executable music compo), take a look at [NR4's Python-based
tool](https://github.com/LeStahL/sointu-executable-msx) for it.
#### Examples
The folder `examples/code` contains usage examples for Sointu with winmm
and dsound playback under Windows and asound playback under Unix. Source
code is available in C and x86 assembly (win32, elf32 and elf64
versions).
The folder `examples/code` contains usage examples for Sointu with winmm and
dsound playback under Windows and asound playback under Unix. Source code is
available in C and x86 assembly (win32, elf32 and elf64 versions).
To build the examples, use `ninja examples`.
If you want to target smaller executable sizes, using a compressing linker
like [Crinkler](https://github.com/runestubbe/Crinkler) on Windows is recommended.
If you want to target smaller executable sizes, using a compressing linker like
[Crinkler](https://github.com/runestubbe/Crinkler) on Windows is recommended.
The linux examples use ALSA and need libasound2-dev (or libasound2-dev:386) installed. The 386 version also needs pipewire-alsa:386 installed, which is not there by default.
The linux examples use ALSA and need libasound2-dev (or libasound2-dev:386)
installed. The 386 version also needs pipewire-alsa:386 installed, which is not
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. 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***).
#### Prerequisites
- [CMake](https://cmake.org)
- [nasm](https://www.nasm.us/) or [yasm](https://yasm.tortall.net)
- [nasm](https://www.nasm.us/)
- *cgo compatible compiler* e.g. [gcc](https://gcc.gnu.org/). On windows, you
best bet is [MinGW](http://www.mingw.org/). We use the
[tdm-gcc](https://jmeubank.github.io/tdm-gcc/)
@ -252,15 +260,16 @@ go run -tags=native cmd/sointu-play/main.go tests/test_chords.yml
> opcodes). In future, the app should give warnings if the user is about to
> exceed the capabilities of a target platform.
> :warning: **If you are using MinGW and Yasm**: Yasm 1.3.0 (currently still the
> latest stable release) and GNU linker do not play nicely along, trashing the
> BSS layout. See
> :warning: **If you are using Yasm instead of Nasm, and you are using MinGW**:
> Yasm 1.3.0 (currently still the latest stable release) and GNU linker do not
> play nicely along, trashing the BSS layout. The linker had placed our synth
> object overlapping with DLL call addresses; very funny stuff to debug. See
> [here](https://tortall.lighthouseapp.com/projects/78676/tickets/274-bss-problem-with-windows-win64)
> and the fix
> [here](https://github.com/yasm/yasm/commit/1910e914792399137dec0b047c59965207245df5).
> Use a newer nightly build of yasm that includes the fix. The linker had placed
> our synth object overlapping with DLL call addresses; very funny stuff to
> debug.
> Since Nasm is nowadays under BSD license, there is absolutely no reason to use
> Yasm. However, if you do, use a newer nightly build of Yasm that includes the
> fix.
### Tests
@ -272,7 +281,7 @@ intro.
- [go](https://golang.org/)
- [CMake](https://cmake.org) with CTest
- [nasm](https://www.nasm.us/) or [yasm](https://yasm.tortall.net)
- [nasm](https://www.nasm.us/)
- Your favorite CMake compatible c-compiler & build tool. Results have been
obtained using Visual Studio 2019, gcc&make on linux, MinGW&mingw32-make, and
ninja&AppleClang.
@ -316,8 +325,8 @@ New features since fork
entropy as low as possible, yet we can call arbitrary go functions as
"macros". The templates are [here](templates/) and the compiler lives
[here](vm/compiler/).
- **Tracker**. Written in go. A crude version exists. Can run either
as a stand-alone app or a vsti plugin.
- **Tracker**. Written in go. Can run either as a stand-alone app or a vsti
plugin.
- **Supports 32 and 64 bit builds**. The 64-bit version is done with minimal
changes to get it work, using template macros to change the lines between
32-bit and 64-bit modes. Mostly, it's as easy as writing {{.AX}} instead of
@ -406,20 +415,6 @@ New features since fork
- **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.
- **Using Sointu as a sync-tracker**. Similar to [GNU
Rocket](https://github.com/rocket/rocket), but (ab)using the tracker we
already have for music. We use the Go rpc package to send current sync
values from the new SYNC opcode + optionally the current fractional row the
song is on. The syncs are saved every 256th sample (approximately 172 Hz).
For 4k intro development, the idea is to write a debug version of the intro
that merely loads the shader and listens to the RPC messages, and then draws
the shader with those as the uniforms. Then, during the actual 4k intro, one
can get the sync data from Sointu: if the song uses syncs, su_render_song
writes the syncs to a float array. During each time step, a slice of this
array can be sent to the shader as a uniform float array. A track with two
voices, triggering an instrument with a single envelope and a slow filter,
can even be used as a cheap smooth interpolation mechanism, provided the
syncs are added to each other in the shader.
Future goals
------------
@ -511,8 +506,10 @@ Prods using Sointu
- [Delusions of mediocrity](https://www.pouet.net/prod.php?which=95222) by
mrange & Virgill
- [Xorverse](https://www.pouet.net/prod.php?which=95221) by Alcatraz
- [l'enveloppe](https://www.pouet.net/prod.php?which=95215) by Team210 &
epoqe
- [l'enveloppe](https://www.pouet.net/prod.php?which=95215) by Team210 & epoqe
- [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
Contributing
------------
@ -528,7 +525,7 @@ Distributed under the MIT License. See [LICENSE](LICENSE) for more information.
Contact
-------
Veikko Sariola - pestis_bc on discord - firstname.lastname@gmail.com
Veikko Sariola - pestis_bc on Demoscene discord - firstname.lastname@gmail.com
Project Link: [https://github.com/vsariola/sointu](https://github.com/vsariola/sointu)
@ -540,4 +537,4 @@ The original 4klang: Dominik Ries ([gopher/Alcatraz](https://github.com/hzdgophe
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)
[kendfss](https://github.com/kendfss), [anticore](https://github.com/anticore)

View File

@ -66,7 +66,7 @@ type (
// Play plays the Song by first compiling the patch with the given Synther,
// returning the stereo audio buffer as a result (and possible errors).
func Play(synther Synther, song Song) (AudioBuffer, error) {
func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, error) {
err := song.Validate()
if err != nil {
return nil, err
@ -125,6 +125,9 @@ func Play(synther Synther, song Song) (AudioBuffer, error) {
return nil, fmt.Errorf("Song speed modulation likely so slow that row never advances; error at pattern %v, row %v", pattern, patternRow)
}
}
if progress != nil {
progress(float32(row+1) / float32(song.Score.LengthInRows()))
}
}
return buffer, nil
}

View File

@ -87,7 +87,7 @@ func main() {
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml)
}
}
buffer, err := sointu.Play(bridge.NativeSynther{}, song) // render the song to calculate its length
buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) // render the song to calculate its length
if err != nil {
return fmt.Errorf("sointu.Play failed: %v", err)
}

View File

@ -33,16 +33,16 @@ var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
func main() {
flag.Parse()
var f *os.File
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
var err error
f, err = os.Create(*cpuprofile)
if err != nil {
log.Fatal("could not create CPU profile: ", err)
}
defer f.Close() // error handling omitted for example
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
defer pprof.StopCPUProfile()
}
audioContext, err := oto.NewContext()
if err != nil {
@ -50,15 +50,19 @@ func main() {
os.Exit(1)
}
defer audioContext.Close()
modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan tracker.PlayerMessage, 1024)
recoveryFile := ""
if configDir, err := os.UserConfigDir(); err == nil {
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
}
model := tracker.NewModel(modelMessages, playerMessages, recoveryFile)
player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages)
tracker := gioui.NewTracker(model, cmd.MainSynther)
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
if a := flag.Args(); len(a) > 0 {
f, err := os.Open(a[0])
if err == nil {
model.ReadSong(f)
}
f.Close()
}
tracker := gioui.NewTracker(model)
output := audioContext.Output()
defer output.Close()
go func() {
@ -71,6 +75,10 @@ func main() {
}()
go func() {
tracker.Main()
if *cpuprofile != "" {
pprof.StopCPUProfile()
f.Close()
}
if *memprofile != "" {
f, err := os.Create(*memprofile)
if err != nil {

View File

@ -5,8 +5,11 @@ package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"os"
"path/filepath"
"time"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/cmd"
@ -54,19 +57,23 @@ func init() {
version = int32(100)
)
vst2.PluginAllocator = func(h vst2.Host) (vst2.Plugin, vst2.Dispatcher) {
modelMessages := make(chan interface{}, 1024)
playerMessages := make(chan tracker.PlayerMessage, 1024)
recoveryFile := ""
if configDir, err := os.UserConfigDir(); err == nil {
randBytes := make([]byte, 16)
rand.Read(randBytes)
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes))
recoveryFile = filepath.Join(configDir, "sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes))
}
model := tracker.NewModel(modelMessages, playerMessages, recoveryFile)
player := tracker.NewPlayer(cmd.MainSynther, playerMessages, modelMessages)
tracker := gioui.NewTracker(model, cmd.MainSynther)
tracker.SetInstrEnlarged(true) // start the vsti with the instrument editor enlarged
go tracker.Main()
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
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,
})
}
go t.Main()
context := VSTIProcessContext{host: h}
buf := make(sointu.AudioBuffer, 1024)
return vst2.Plugin{
@ -110,14 +117,16 @@ func init() {
}
},
CloseFunc: func() {
tracker.Quit(true)
tracker.WaitQuitted()
t.Exec() <- func() { t.ForceQuit().Do() }
t.WaitQuitted()
},
GetChunkFunc: func(isPreset bool) []byte {
return tracker.SafeMarshalRecovery()
retChn := make(chan []byte)
t.Exec() <- func() { retChn <- t.MarshalRecovery() }
return <-retChn
},
SetChunkFunc: func(data []byte, isPreset bool) {
tracker.SafeUnmarshalRecovery(data)
t.Exec() <- func() { t.UnmarshalRecovery(data) }
},
}

View File

@ -0,0 +1,14 @@
Requirements: sointu binaries, `wabt`
To generate the .wasm file:
```
sointu-compile -o . -arch=wasm tests/test_chords.yml
wat2wasm --enable-bulk-memory test_chords.wat
```
To run the example:
```
npx serve examples/code/wasm
```

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<title>sointu WASM example</title>
</head>
<body>
<script type="module">
// button to start audio context
const button = document.createElement("button");
button.innerHTML = "start";
document.body.appendChild(button);
button.onclick = () => {
document.body.removeChild(button);
fetch("test_chords.wasm")
.then((response) => response.arrayBuffer())
.then((bytes) => WebAssembly.instantiate(bytes, { m: Math }))
.then(({ instance }) => {
const context = new AudioContext({ sampleRate: 44100 });
let frames = instance.exports.t.value
? instance.exports.l.value / 4
: instance.exports.l.value / 8;
let wasmBuffer = new Float32Array(
instance.exports.m.buffer,
instance.exports.s.value,
frames * 2
);
const buffer = context.createBuffer(2, frames, context.sampleRate);
// convert wasm buffer to audio buffer
for (let channel = 0; channel < 2; channel++) {
const buffering = buffer.getChannelData(channel);
for (let i = 0; i < frames; i++) {
buffering[i] = wasmBuffer[i * 2 + channel];
}
}
// connect to output and start playing
const src = context.createBufferSource();
src.buffer = buffer;
src.connect(context.destination);
src.start();
});
};
</script>
</body>
</html>

Binary file not shown.

14
go.mod
View File

@ -1,17 +1,17 @@
module github.com/vsariola/sointu
go 1.19
go 1.21
require (
gioui.org v0.3.1
gioui.org/x v0.1.0
gioui.org v0.5.0
gioui.org/x v0.5.0
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/hajimehoshi/oto v0.6.6
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
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
pipelined.dev/audio/vst2 v0.10.1-0.20231016195025-8c5c6a64c826
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2
)
require (
@ -29,10 +29,10 @@ require (
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/stretchr/testify v1.6.1 // 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.7.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/sys v0.12.0 // indirect
pipelined.dev/pipe v0.11.0 // indirect
pipelined.dev/signal v0.10.0 // indirect
)

22
go.sum
View File

@ -1,13 +1,14 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
gioui.org v0.3.1 h1:hslYkrkIWvx28Mxe3A87opl+8s9mnWsnWmPDh11+zco=
gioui.org v0.3.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q=
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/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.1.0 h1:CvphvaQSroRaNEZ+JbXBkV3J3klA76U3JpieyEwHFX4=
gioui.org/x v0.1.0/go.mod h1:5qZxjtK/TVznMlcEOyn8OheiCZlArxF3IKnLqSehKXQ=
gioui.org/x v0.5.0 h1:NVKTn5AZuYhkAnF7MYcy1dIes36+U1N4gUTsgBhfr4A=
gioui.org/x v0.5.0/go.mod h1:X4UBhvanAN+8S16L3K6jDMrVo7Dii7NptgBpOLBD7E4=
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=
@ -22,6 +23,7 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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/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=
@ -49,8 +51,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5U
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-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
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=
@ -84,8 +86,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.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=
@ -110,8 +112,8 @@ 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=
pipelined.dev/audio/vst2 v0.10.1-0.20231016195025-8c5c6a64c826 h1:4c7O6PJ/Zl677O2VhXHUZK7LJyVBhUI7Q39+ri+gKUs=
pipelined.dev/audio/vst2 v0.10.1-0.20231016195025-8c5c6a64c826/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8=
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=
pipelined.dev/pipe v0.11.0 h1:yRrbntKdqw/nbFqkz9dPaSHBoM7pK1LRHHDqdBJuqtc=
pipelined.dev/pipe v0.11.0/go.mod h1:aIt+NPlW0QLYByqYniG77lTxSvl7OtCNLws/m+Xz5ww=

View File

@ -44,6 +44,10 @@ type (
// unit, VarArgs is the delaytimes, in samples, of the different delaylines
// in the unit.
VarArgs []int `yaml:",flow,omitempty"`
// 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"`
}
// UnitParameter documents one parameter that an unit takes
@ -82,6 +86,9 @@ var UnitTypes = map[string]([]UnitParameter){
"invgain": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
"dbgain": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "decibels", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
"filter": []UnitParameter{
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
@ -217,7 +224,7 @@ func (u *Unit) Copy() Unit {
}
varArgs := make([]int, len(u.VarArgs))
copy(varArgs, u.VarArgs)
return Unit{Type: u.Type, Parameters: parameters, VarArgs: varArgs, ID: u.ID}
return Unit{Type: u.Type, Parameters: parameters, VarArgs: varArgs, ID: u.ID, Disabled: u.Disabled}
}
// StackChange returns how this unit will affect the signal stack. "pop" and
@ -227,6 +234,9 @@ 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"]
@ -246,6 +256,9 @@ func (u *Unit) StackChange() int {
// 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
@ -347,14 +360,14 @@ func (p Patch) InstrumentForVoice(voice int) (int, error) {
// given id. Two units should never have the same id, but if they do, then the
// first match is returned. Id 0 is interpreted as "no id", thus searching for
// id 0 returns an error. Error is also returned if the searched id is not
// found.
// found. FindUnit considers disabled units as non-existent.
func (p Patch) FindUnit(id int) (instrIndex int, unitIndex int, err error) {
if id == 0 {
return 0, 0, errors.New("FindUnit called with id 0")
}
for i, instr := range p {
for u, unit := range instr.Units {
if unit.ID == id {
if unit.ID == id && !unit.Disabled {
return i, u, nil
}
}
@ -494,6 +507,11 @@ func (p Patch) ParamHintString(instrIndex, unitIndex int, param string) string {
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":

92
song.go
View File

@ -67,8 +67,46 @@ type (
// the slice only by necessary amount when a new item is added, filling the
// unused slots with -1s.
Order []int
// SongPos represents a position in a song, in terms of order row and
// pattern row. The order row is the index of the pattern in the order list,
// and the pattern row is the index of the row in the pattern.
SongPos struct {
OrderRow int
PatternRow int
}
)
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
return SongPos{OrderRow: orderRow, PatternRow: patternRow}
}
func (s *Score) SongRow(songPos SongPos) int {
return songPos.OrderRow*s.RowsPerPattern + songPos.PatternRow
}
func (s *Score) Wrap(songPos SongPos) SongPos {
ret := s.SongPos(s.SongRow(songPos))
ret.OrderRow %= s.Length
return ret
}
func (s *Score) Clamp(songPos SongPos) SongPos {
r := s.SongRow(songPos)
if l := s.LengthInRows(); r >= l {
r = l - 1
}
if r < 0 {
r = 0
}
return s.SongPos(r)
}
// Get returns the value at index; or -1 is the index is out of range
func (s Order) Get(index int) int {
if index < 0 || index >= len(s) {
@ -85,6 +123,55 @@ func (s *Order) Set(index, value int) {
(*s)[index] = value
}
func (s Track) Note(pos SongPos) byte {
if pos.OrderRow < 0 || pos.OrderRow >= len(s.Order) {
return 1
}
pat := s.Order[pos.OrderRow]
if pat < 0 || pat >= len(s.Patterns) {
return 1
}
if pos.PatternRow < 0 || pos.PatternRow >= len(s.Patterns[pat]) {
return 1
}
return s.Patterns[pat][pos.PatternRow]
}
func (s *Track) SetNote(pos SongPos, note byte) {
if pos.OrderRow < 0 || pos.PatternRow < 0 {
return
}
pat := s.Order.Get(pos.OrderRow)
if pat < 0 {
if note == 1 {
return
}
for _, o := range s.Order {
if pat <= o {
pat = o
}
}
pat += 1
if pat >= 36 {
return
}
s.Order.Set(pos.OrderRow, pat)
}
if pat >= len(s.Patterns) && note == 1 {
return
}
for pat >= len(s.Patterns) {
s.Patterns = append(s.Patterns, Pattern{})
}
if pos.PatternRow >= len(s.Patterns[pat]) && note == 1 {
return
}
for pos.PatternRow >= len(s.Patterns[pat]) {
s.Patterns[pat] = append(s.Patterns[pat], 1)
}
s.Patterns[pat][pos.PatternRow] = note
}
// Get returns the value at index; or 1 is the index is out of range
func (s Pattern) Get(index int) byte {
if index < 0 || index >= len(s) {
@ -165,7 +252,10 @@ func (s *Song) Copy() Song {
// Assuming 44100 Hz playback speed, return the number of samples of each row of
// the song.
func (s *Song) SamplesPerRow() int {
return 44100 * 60 / (s.BPM * s.RowsPerBeat)
if divisor := s.BPM * s.RowsPerBeat; divisor > 0 {
return 44100 * 60 / divisor
}
return 0
}
// Validate checks if the Song looks like a valid song: BPM > 0, one or more

View File

@ -72,6 +72,8 @@ regression_test(test_gain LOADVAL GAIN)
regression_test(test_gain_stereo GAIN)
regression_test(test_invgain LOADVAL INVGAIN)
regression_test(test_invgain_stereo INVGAIN)
regression_test(test_dbgain LOADVAL DBGAIN)
regression_test(test_dbgain_stereo DBGAIN)
regression_test(test_send LOADVAL SEND)
regression_test(test_send_stereo SEND)
regression_test(test_send_global SEND SEND_GLOBAL)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

22
tests/test_dbgain.yml Normal file
View File

@ -0,0 +1,22 @@
bpm: 100
rowsperbeat: 4
score:
rowsperpattern: 16
length: 1
tracks:
- numvoices: 1
order: [0]
patterns: [[64, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]]
patch:
- numvoices: 1
units:
- type: loadval
parameters: {stereo: 0, value: 0}
- type: dbgain
parameters: {decibels: 32, stereo: 0}
- type: loadval
parameters: {stereo: 0, value: 128}
- type: dbgain
parameters: {decibels: 32, stereo: 0}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -0,0 +1,20 @@
bpm: 100
rowsperbeat: 4
score:
rowsperpattern: 16
length: 1
tracks:
- numvoices: 1
order: [0]
patterns: [[64, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]]
patch:
- numvoices: 1
units:
- type: loadval
parameters: {stereo: 0, value: 0}
- type: loadval
parameters: {stereo: 0, value: 128}
- type: dbgain
parameters: {decibels: 32, stereo: 1}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -11,10 +11,12 @@ patch:
- numvoices: 1
units:
- type: loadval
parameters: {stereo: 0, value: 0}
parameters: {stereo: 0, value: 32}
- type: gain
parameters: {gain: 128, stereo: 0}
- type: loadval
parameters: {stereo: 0, value: 128}
- type: gain
parameters: {gain: 64, stereo: 1}
parameters: {gain: 64, stereo: 0}
- type: out
parameters: {gain: 128, stereo: 1}

View File

@ -11,12 +11,10 @@ patch:
- numvoices: 1
units:
- type: loadval
parameters: {stereo: 0, value: 32}
- type: gain
parameters: {gain: 128, stereo: 0}
parameters: {stereo: 0, value: 0}
- type: loadval
parameters: {stereo: 0, value: 128}
- type: gain
parameters: {gain: 64, stereo: 0}
parameters: {gain: 64, stereo: 1}
- type: out
parameters: {gain: 128, stereo: 1}

409
tracker/action.go Normal file
View File

@ -0,0 +1,409 @@
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
}
}

110
tracker/alert.go Normal file
View File

@ -0,0 +1,110 @@
package tracker
import (
"container/heap"
"time"
)
const alertSpeed = 10 // units: fadeLevels per second
const defaultAlertDuration = time.Second * 3
type (
Alert struct {
Name string
Priority AlertPriority
Message string
Duration time.Duration
FadeLevel float64
}
AlertPriority int
AlertYieldFunc func(alert Alert)
Alerts Model
)
const (
None AlertPriority = iota
Info
Warning
Error
)
// Model methods
func (m *Model) Alerts() *Alerts { return (*Alerts)(m) }
// Alerts methods
func (m *Alerts) Iterate(yield AlertYieldFunc) {
for _, a := range m.alerts {
yield(a)
}
}
func (m *Alerts) Update(d time.Duration) (animating bool) {
for i := len(m.alerts) - 1; i >= 0; i-- {
if m.alerts[i].Duration >= d {
m.alerts[i].Duration -= d
if m.alerts[i].FadeLevel < 1 {
animating = true
m.alerts[i].FadeLevel += float64(alertSpeed*d) / float64(time.Second)
if m.alerts[i].FadeLevel > 1 {
m.alerts[i].FadeLevel = 1
}
}
} else {
m.alerts[i].Duration = 0
m.alerts[i].FadeLevel -= float64(alertSpeed*d) / float64(time.Second)
animating = true
if m.alerts[i].FadeLevel < 0 {
heap.Remove(m, i)
}
}
}
return
}
func (m *Alerts) Add(message string, priority AlertPriority) {
m.AddAlert(Alert{
Priority: priority,
Message: message,
Duration: defaultAlertDuration,
})
}
func (m *Alerts) AddNamed(name, message string, priority AlertPriority) {
m.AddAlert(Alert{
Name: name,
Priority: priority,
Message: message,
Duration: defaultAlertDuration,
})
}
func (m *Alerts) AddAlert(a Alert) {
for i := range m.alerts {
if n := m.alerts[i].Name; n != "" && n == a.Name {
a.FadeLevel = m.alerts[i].FadeLevel
m.alerts[i] = a
heap.Fix(m, i)
return
}
}
heap.Push(m, a)
}
func (m *Alerts) Push(x any) {
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
}
func (m Alerts) Len() int { return len(m.alerts) }
func (m Alerts) Less(i, j int) bool { return m.alerts[i].Priority < m.alerts[j].Priority }
func (m Alerts) Swap(i, j int) { m.alerts[i], m.alerts[j] = m.alerts[j], m.alerts[i] }

182
tracker/bool.go Normal file
View File

@ -0,0 +1,182 @@
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 }

179
tracker/files.go Normal file
View File

@ -0,0 +1,179 @@
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

@ -1,118 +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"
)
type Alert struct {
message string
alertType AlertType
duration time.Duration
showMessage string
showAlertType AlertType
showDuration time.Duration
showTime time.Time
pos float64
lastUpdate time.Time
shaper *text.Shaper
}
type AlertType int
const (
None AlertType = iota
Notify
Warning
Error
)
var alertSpeed = 150 * time.Millisecond
var alertMargin = layout.UniformInset(unit.Dp(6))
var alertInset = layout.UniformInset(unit.Dp(6))
func (a *Alert) Update(message string, alertType AlertType, duration time.Duration) {
if a.alertType < alertType {
a.message = message
a.alertType = alertType
a.duration = duration
}
}
func (a *Alert) Layout(gtx C) D {
now := time.Now()
if a.alertType != None {
a.showMessage = a.message
a.showAlertType = a.alertType
a.showTime = now
a.showDuration = a.duration
}
a.alertType = None
var targetPos float64 = 0.0
if now.Sub(a.showTime) <= a.showDuration {
targetPos = 1.0
}
delta := float64(now.Sub(a.lastUpdate)) / float64(alertSpeed)
if a.pos < targetPos {
a.pos += delta
if a.pos > targetPos {
a.pos = targetPos
} else {
op.InvalidateOp{At: now.Add(50 * time.Millisecond)}.Add(gtx.Ops)
}
} else if a.pos > targetPos {
a.pos -= delta
if a.pos < targetPos {
a.pos = targetPos
} else {
op.InvalidateOp{At: now.Add(50 * time.Millisecond)}.Add(gtx.Ops)
}
}
a.lastUpdate = now
var color, textColor, shadeColor color.NRGBA
switch a.showAlertType {
case Warning:
color = warningColor
textColor = black
case 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: a.showMessage, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper}
return 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()
totalY := dims.Size.Y + gtx.Dp(alertMargin.Bottom)
op.Offset(image.Point{0, int((1 - a.pos) * float64(totalY))}).Add((gtx.Ops))
macro.Add(gtx.Ops)
return dims
})
})
}

View File

@ -6,17 +6,43 @@ import (
"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
type (
TipClickable struct {
Clickable widget.Clickable
TipArea component.TipArea
}
ActionClickable struct {
Action tracker.Action
TipClickable
}
TipIconButtonStyle struct {
TipArea *component.TipArea
IconButtonStyle material.IconButtonStyle
Tooltip component.Tooltip
}
BoolClickable struct {
Clickable widget.Clickable
TipArea component.TipArea
Bool tracker.Bool
}
)
func NewActionClickable(a tracker.Action) *ActionClickable {
return &ActionClickable{
Action: a,
}
}
type TipIconButtonStyle struct {
IconButtonStyle material.IconButtonStyle
Tooltip component.Tooltip
tipArea *component.TipArea
func NewBoolClickable(b tracker.Bool) *BoolClickable {
return &BoolClickable{
Bool: b,
}
}
func Tooltip(th *material.Theme, tip string) component.Tooltip {
@ -25,24 +51,86 @@ func Tooltip(th *material.Theme, tip string) component.Tooltip {
return tooltip
}
func IconButton(th *material.Theme, w *TipClickable, icon []byte, enabled bool, tip string) TipIconButtonStyle {
ret := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "")
ret.Background = transparent
ret.Inset = layout.UniformInset(unit.Dp(6))
if enabled {
ret.Color = primaryColor
} else {
ret.Color = disabledTextColor
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 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{
IconButtonStyle: ret,
TipArea: &w.TipArea,
IconButtonStyle: ibStyle,
Tooltip: Tooltip(th, tip),
tipArea: &w.TipArea,
}
}
func (t *TipIconButtonStyle) Layout(gtx C) D {
return t.tipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout)
return t.TipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout)
}
func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) material.ButtonStyle {
for w.Clickable.Clicked(gtx) {
w.Action.Do()
}
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 {
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
}
return ret
}
func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {

View File

@ -1,56 +1,116 @@
package gioui
import (
"gioui.org/io/event"
"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 {
Visible bool
BtnAlt widget.Clickable
BtnOk widget.Clickable
BtnCancel widget.Clickable
BtnAlt *ActionClickable
BtnOk *ActionClickable
BtnCancel *ActionClickable
tag bool
keyFilters []event.Filter
}
type DialogStyle struct {
dialog *Dialog
Title string
Text string
Inset layout.Inset
ShowAlt bool
TextInset layout.Inset
AltStyle material.ButtonStyle
OkStyle material.ButtonStyle
CancelStyle material.ButtonStyle
Shaper *text.Shaper
}
func ConfirmDialog(th *material.Theme, dialog *Dialog, text string, shaper *text.Shaper) DialogStyle {
func NewDialog(ok, alt, cancel tracker.Action) *Dialog {
ret := &Dialog{
BtnOk: NewActionClickable(ok),
BtnAlt: NewActionClickable(alt),
BtnCancel: NewActionClickable(cancel),
}
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)},
AltStyle: HighEmphasisButton(th, &dialog.BtnAlt, "Alt"),
OkStyle: HighEmphasisButton(th, &dialog.BtnOk, "Ok"),
CancelStyle: HighEmphasisButton(th, &dialog.BtnCancel, "Cancel"),
Shaper: shaper,
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 (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *ActionClickable) {
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},
)
if !ok {
break
}
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})
case e.Name == key.NameRightArrow || (e.Name == key.NameTab && !e.Modifiers.Contain(key.ModShift)):
gtx.Execute(key.FocusCmd{Tag: &next.Clickable})
case e.Name == key.NameEscape:
d.BtnCancel.Action.Do()
}
}
}
}
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 d.dialog.Visible {
paint.Fill(gtx.Ops, dialogBgColor)
return layout.Center.Layout(gtx, func(gtx C) D {
return Popup(&d.dialog.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.Text, highEmphasisTextColor, d.Shaper)),
layout.Rigid(func(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.ShowAlt {
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),
@ -61,11 +121,10 @@ func (d *DialogStyle) Layout(gtx C) D {
layout.Rigid(d.OkStyle.Layout),
layout.Rigid(d.CancelStyle.Layout),
)
}),
)
})
})
}),
)
})
})
}
return D{}
})
}

View File

@ -1,51 +1,60 @@
package gioui
import (
"bytes"
"image"
"image/color"
"io"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/transfer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type DragList struct {
SelectedItem int
SelectedItem2 int
HoverItem int
List *layout.List
drag bool
dragID pointer.ID
tags []bool
swapped bool
focused bool
requestFocus bool
mainTag bool
TrackerList tracker.List
HoverItem int
List *layout.List
ScrollBar *ScrollBar
drag bool
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
Count int
element func(gtx C, i int) D
swap func(i, j int)
dragList *DragList
HoverColor color.NRGBA
SelectedColor color.NRGBA
CursorColor color.NRGBA
ScrollBarWidth unit.Dp
element, bg func(gtx C, i int) D
}
func FilledDragList(th *material.Theme, dragList *DragList, count int, element func(gtx C, i int) D, swap func(i, j int)) FilledDragListStyle {
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 {
return FilledDragListStyle{
dragList: dragList,
element: element,
swap: swap,
Count: count,
HoverColor: dragListHoverColor,
SelectedColor: dragListSelectedColor,
CursorColor: cursorColor,
dragList: dragList,
element: element,
bg: bg,
HoverColor: dragListHoverColor,
SelectedColor: dragListSelectedColor,
CursorColor: cursorColor,
ScrollBarWidth: unit.Dp(10),
}
}
@ -57,16 +66,16 @@ func (d *DragList) Focused() bool {
return d.focused
}
func (s *FilledDragListStyle) Layout(gtx C) D {
func (s FilledDragListStyle) LayoutScrollBar(gtx C) D {
return s.dragList.ScrollBar.Layout(gtx, s.ScrollBarWidth, s.dragList.TrackerList.Count(), &s.dragList.List.Position)
}
func (s FilledDragListStyle) Layout(gtx C) D {
swap := 0
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
keys := key.Set("↑|↓|Ctrl-↑|Ctrl-↓|Shift-↑|Shift-↓")
if s.dragList.List.Axis == layout.Horizontal {
keys = key.Set("←|→|Ctrl-←|Ctrl-→|Shift-←|Shift-→")
}
key.InputOp{Tag: &s.dragList.mainTag, Keys: keys}.Add(gtx.Ops)
event.Op(gtx.Ops, s.dragList)
if s.dragList.List.Axis == layout.Horizontal {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
@ -76,74 +85,92 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
if s.dragList.requestFocus {
s.dragList.requestFocus = false
key.FocusOp{Tag: &s.dragList.mainTag}.Add(gtx.Ops)
gtx.Execute(key.FocusCmd{Tag: s.dragList})
}
if !s.dragList.focused {
s.dragList.SelectedItem2 = s.dragList.SelectedItem
prevKey := key.NameUpArrow
nextKey := key.NameDownArrow
firstKey := key.NamePageUp
lastKey := key.NamePageDown
if s.dragList.List.Axis == layout.Horizontal {
prevKey = key.NameLeftArrow
nextKey = key.NameRightArrow
firstKey = key.NameHome
lastKey = key.NameEnd
}
for _, ke := range gtx.Events(&s.dragList.mainTag) {
switch ke := ke.(type) {
for {
event, ok := gtx.Event(
key.FocusFilter{Target: s.dragList},
transfer.TargetFilter{Target: s.dragList, Type: "application/text"},
key.Filter{Focus: s.dragList, Name: prevKey, Optional: key.ModShift | key.ModShortcut},
key.Filter{Focus: s.dragList, Name: nextKey, Optional: key.ModShift | key.ModShortcut},
key.Filter{Focus: s.dragList, Name: firstKey, Optional: key.ModShift | key.ModShortcut},
key.Filter{Focus: s.dragList, Name: lastKey, Optional: key.ModShift | key.ModShortcut},
key.Filter{Focus: s.dragList, Name: "A", Required: key.ModShortcut},
key.Filter{Focus: s.dragList, Name: "C", Required: key.ModShortcut},
key.Filter{Focus: s.dragList, Name: "X", Required: key.ModShortcut},
key.Filter{Focus: s.dragList, Name: "V", Required: key.ModShortcut},
key.Filter{Focus: s.dragList, Name: key.NameDeleteBackward, Required: key.ModShortcut},
key.Filter{Focus: s.dragList, Name: key.NameDeleteForward},
)
if !ok {
break
}
switch ke := event.(type) {
case key.FocusEvent:
s.dragList.focused = ke.Focus
if !s.dragList.focused {
s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected())
}
case key.Event:
if !s.dragList.focused || ke.State != key.Press {
break
}
delta := 0
switch {
case s.dragList.List.Axis == layout.Horizontal && ke.Name == key.NameLeftArrow && s.dragList.SelectedItem > 0:
delta = -1
case s.dragList.List.Axis == layout.Horizontal && ke.Name == key.NameRightArrow && s.dragList.SelectedItem < s.Count-1:
delta = 1
case s.dragList.List.Axis == layout.Vertical && ke.Name == key.NameUpArrow && s.dragList.SelectedItem > 0:
delta = -1
case s.dragList.List.Axis == layout.Vertical && ke.Name == key.NameDownArrow && s.dragList.SelectedItem < s.Count-1:
delta = 1
}
if delta != 0 {
if ke.Modifiers.Contain(key.ModShortcut) {
swap = delta
} else {
s.dragList.SelectedItem += delta
if !ke.Modifiers.Contain(key.ModShift) {
s.dragList.SelectedItem2 = s.dragList.SelectedItem
}
}
s.dragList.command(gtx, ke)
case transfer.DataEvent:
if b, err := io.ReadAll(ke.Open()); err == nil {
s.dragList.TrackerList.PasteElements([]byte(b))
}
}
gtx.Execute(op.InvalidateCmd{})
}
_, isMutable := s.dragList.TrackerList.ListData.(tracker.MutableListData)
listElem := func(gtx C, index int) D {
for len(s.dragList.tags) <= index {
s.dragList.tags = append(s.dragList.tags, false)
}
bg := func(gtx C) D {
cursorBg := func(gtx C) D {
var color color.NRGBA
if s.dragList.SelectedItem == index {
if s.dragList.TrackerList.Selected() == index {
if s.dragList.focused {
color = s.CursorColor
} else {
color = s.SelectedColor
}
} else if between(s.dragList.SelectedItem, index, s.dragList.SelectedItem2) {
} else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) {
color = s.SelectedColor
} else if s.dragList.HoverItem == index {
color = s.HoverColor
}
paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
return D{Size: gtx.Constraints.Min}
}
inputFg := func(gtx C) D {
//defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
for _, ev := range gtx.Events(&s.dragList.tags[index]) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: &s.dragList.tags[index],
Kinds: pointer.Press | pointer.Enter | pointer.Leave,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Enter:
s.dragList.HoverItem = index
case pointer.Leave:
@ -154,26 +181,32 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
if s.dragList.drag {
break
}
s.dragList.SelectedItem = index
s.dragList.TrackerList.SetSelected(index)
if !e.Modifiers.Contain(key.ModShift) {
s.dragList.SelectedItem2 = index
s.dragList.TrackerList.SetSelected2(index)
}
key.FocusOp{Tag: &s.dragList.mainTag}.Add(gtx.Ops)
gtx.Execute(key.FocusCmd{Tag: s.dragList})
}
}
rect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &s.dragList.tags[index],
Types: pointer.Press | pointer.Enter | pointer.Leave,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &s.dragList.tags[index])
area.Pop()
if index == s.dragList.SelectedItem {
for _, ev := range gtx.Events(&s.dragList.focused) {
if index == s.dragList.TrackerList.Selected() && isMutable {
for {
target := &s.dragList.focused
if s.dragList.drag {
target = nil
}
ev, ok := gtx.Event(pointer.Filter{Target: target, Kinds: pointer.Drag | pointer.Press | pointer.Release | pointer.Cancel})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Press:
s.dragList.dragID = e.PointerID
s.dragList.drag = true
@ -196,45 +229,42 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
swap = 1
}
}
case pointer.Release:
fallthrough
case pointer.Cancel:
case pointer.Release, pointer.Cancel:
s.dragList.drag = false
}
}
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &s.dragList.focused,
Types: pointer.Drag | pointer.Press | pointer.Release,
Grab: s.dragList.drag,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &s.dragList.focused)
pointer.CursorGrab.Add(gtx.Ops)
area.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}
return layout.Stack{Alignment: layout.W}.Layout(gtx,
layout.Expanded(bg),
layout.Expanded(inputFg),
layout.Stacked(func(gtx C) D {
return s.element(gtx, index)
}),
)
}
dims := s.dragList.List.Layout(gtx, s.Count, listElem)
a := intMin(s.dragList.SelectedItem, s.dragList.SelectedItem2)
b := intMax(s.dragList.SelectedItem, s.dragList.SelectedItem2)
if !s.dragList.swapped && swap != 0 && a+swap >= 0 && b+swap < s.Count {
if swap < 0 {
for i := a; i <= b; i++ {
s.swap(i, i+swap)
}
} else {
for i := b; i >= a; i-- {
s.swap(i, i+swap)
}
macro := op.Record(gtx.Ops)
dims := s.element(gtx, index)
call := macro.Stop()
gtx.Constraints.Min = dims.Size
if s.bg != nil {
s.bg(gtx, index)
}
cursorBg(gtx)
call.Add(gtx.Ops)
if s.dragList.List.Axis == layout.Horizontal {
dims.Size.Y = gtx.Constraints.Max.Y
} else {
dims.Size.X = gtx.Constraints.Max.X
}
return dims
}
count := s.dragList.TrackerList.Count()
if count < 1 {
count = 1 // draw at least one empty element to get the correct size
}
dims := s.dragList.List.Layout(gtx, count, listElem)
if !s.dragList.swapped && swap != 0 {
if s.dragList.TrackerList.MoveElements(swap) {
gtx.Execute(op.InvalidateCmd{})
}
s.dragList.SelectedItem += swap
s.dragList.SelectedItem2 += swap
s.dragList.swapped = true
} else {
s.dragList.swapped = false
@ -242,6 +272,88 @@ func (s *FilledDragListStyle) Layout(gtx C) D {
return dims
}
func (e *DragList) command(gtx layout.Context, k key.Event) {
if k.Modifiers.Contain(key.ModShortcut) {
switch k.Name {
case "V":
gtx.Execute(clipboard.ReadCmd{Tag: e})
return
case "C", "X":
data, ok := e.TrackerList.CopyElements()
if ok && (k.Name == "C" || e.TrackerList.DeleteElements(false)) {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(data))})
}
return
case "A":
e.TrackerList.SetSelected(0)
e.TrackerList.SetSelected2(e.TrackerList.Count() - 1)
return
}
}
delta := 0
switch k.Name {
case key.NameDeleteBackward:
if k.Modifiers.Contain(key.ModShortcut) {
e.TrackerList.DeleteElements(true)
}
return
case key.NameDeleteForward:
e.TrackerList.DeleteElements(false)
return
case key.NameLeftArrow:
delta = -1
case key.NameRightArrow:
delta = 1
case key.NameHome:
delta = -1e6
case key.NameEnd:
delta = 1e6
case key.NameUpArrow:
delta = -1
case key.NameDownArrow:
delta = 1
case key.NamePageUp:
delta = -1e6
case key.NamePageDown:
delta = 1e6
}
if k.Modifiers.Contain(key.ModShortcut) {
e.TrackerList.MoveElements(delta)
} else {
e.TrackerList.SetSelected(e.TrackerList.Selected() + delta)
if !k.Modifiers.Contain(key.ModShift) {
e.TrackerList.SetSelected2(e.TrackerList.Selected())
}
}
e.EnsureVisible(e.TrackerList.Selected())
}
func (l *DragList) EnsureVisible(item int) {
first := l.List.Position.First
last := l.List.Position.First + l.List.Position.Count - 1
if item < first || (item == first && l.List.Position.Offset > 0) {
l.List.ScrollTo(item)
}
if item > last || (item == last && l.List.Position.OffsetLast < 0) {
o := -l.List.Position.OffsetLast + l.List.Position.Offset
l.List.ScrollTo(item - l.List.Position.Count + 1)
l.List.Position.Offset = o
}
}
func (l *DragList) CenterOn(item int) {
lenPerChildPx := l.List.Position.Length / l.TrackerList.Count()
if lenPerChildPx == 0 {
return
}
listLengthPx := l.List.Position.Count*l.List.Position.Length/l.TrackerList.Count() + l.List.Position.OffsetLast - l.List.Position.Offset
lenBeforeItem := (listLengthPx - lenPerChildPx) / 2
quot := lenBeforeItem / lenPerChildPx
rem := lenBeforeItem % lenPerChildPx
l.List.ScrollTo(item - quot - 1)
l.List.Position.Offset = lenPerChildPx - rem
}
func between(a, b, c int) bool {
return (a <= b && b <= c) || (c <= b && b <= a)
}

View File

@ -1,224 +0,0 @@
//go:build !js
// +build !js
package gioui
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
"github.com/vsariola/sointu"
)
func (t *Tracker) OpenSongFile(forced bool) {
if !forced && t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmLoad
t.ConfirmSongDialog.Visible = true
return
}
reader, err := t.Explorer.ChooseFile(".yml", ".json")
if err != nil {
return
}
t.loadSong(reader)
}
func (t *Tracker) SaveSongFile() bool {
if p := t.FilePath(); p != "" {
if f, err := os.Create(p); err == nil {
return t.saveSong(f)
}
}
t.SaveSongAsFile()
return false
}
func (t *Tracker) SaveSongAsFile() {
p := t.FilePath()
if p == "" {
p = "song.yml"
}
writer, err := t.Explorer.CreateFile(p)
if err != nil {
return
}
t.saveSong(writer)
}
func (t *Tracker) ExportWav(pcm16 bool) {
filename := "song.wav"
if p := t.FilePath(); p != "" {
filename = p[:len(p)-len(filepath.Ext(p))] + ".wav"
}
writer, err := t.Explorer.CreateFile(filename)
if err != nil {
return
}
t.exportWav(writer, pcm16)
}
func (t *Tracker) LoadInstrument() {
reader, err := t.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp")
if err != nil {
return
}
t.loadInstrument(reader)
}
func (t *Tracker) SaveInstrument() {
writer, err := t.Explorer.CreateFile(t.Instrument().Name + ".yml")
if err != nil {
return
}
t.saveInstrument(writer)
}
func (t *Tracker) loadSong(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 {
t.Alert.Update(fmt.Sprintf("Error unmarshaling a song file: %v / %v", errYaml, errJSON), Error, time.Second*3)
}
}
if song.Score.Length <= 0 || len(song.Score.Tracks) == 0 || len(song.Patch) == 0 {
t.Alert.Update("The song file is malformed", Error, time.Second*3)
return
}
t.SetSong(song)
path := ""
if f, ok := r.(*os.File); ok {
path = f.Name()
}
t.SetFilePath(path)
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
}
func (t *Tracker) saveSong(w io.WriteCloser) bool {
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(t.Song())
} else {
contents, err = yaml.Marshal(t.Song())
}
if err != nil {
t.Alert.Update(fmt.Sprintf("Error marshaling a song file: %v", err), Error, time.Second*3)
return false
}
if _, err := w.Write(contents); err != nil {
t.Alert.Update(fmt.Sprintf("Error writing to file: %v", err), Error, time.Second*3)
return false
}
if err := w.Close(); err != nil {
t.Alert.Update(fmt.Sprintf("Error closing file: %v", err), Error, time.Second*3)
return false
}
t.SetFilePath(path)
t.SetChangedSinceSave(false)
return true
}
func (t *Tracker) exportWav(w io.WriteCloser, pcm16 bool) {
data, err := sointu.Play(t.synther, t.Song()) // render the song to calculate its length
if err != nil {
t.Alert.Update(fmt.Sprintf("Error rendering the song during export: %v", err), Error, time.Second*3)
return
}
buffer, err := data.Wav(pcm16)
if err != nil {
t.Alert.Update(fmt.Sprintf("Error converting to .wav: %v", err), Error, time.Second*3)
return
}
w.Write(buffer)
w.Close()
}
func (t *Tracker) saveInstrument(w io.WriteCloser) bool {
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(t.Instrument())
} else {
contents, err = yaml.Marshal(t.Instrument())
}
if err != nil {
t.Alert.Update(fmt.Sprintf("Error marshaling a ínstrument file: %v", err), Error, time.Second*3)
return false
}
w.Write(contents)
w.Close()
return true
}
func (t *Tracker) loadInstrument(r io.ReadCloser) bool {
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 {
song := t.Song()
song.Score = t.Song().Score.Copy()
song.Patch = patch
t.SetSong(song)
return true
}
instrument, err4ki = sointu.Read4klangInstrument(bytes.NewReader(b))
if err4ki == nil {
goto success
}
t.Alert.Update(fmt.Sprintf("Error unmarshaling an instrument file: %v / %v / %v / %v", errYaml, errJSON, err4ki, err4kp), Error, time.Second*3)
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))])
}
if len(instrument.Units) == 0 {
t.Alert.Update("The instrument file is malformed", Error, time.Second*3)
return false
}
t.SetInstrument(instrument)
if t.Instrument().Comment != "" {
t.InstrumentEditor.ExpandComment()
}
return true
}

View File

@ -0,0 +1,536 @@
package gioui
import (
"bytes"
"fmt"
"image"
"image/color"
"io"
"strconv"
"strings"
"gioui.org/font"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type InstrumentEditor struct {
newInstrumentBtn *ActionClickable
enlargeBtn *BoolClickable
deleteInstrumentBtn *ActionClickable
copyInstrumentBtn *TipClickable
saveInstrumentBtn *TipClickable
loadInstrumentBtn *TipClickable
addUnitBtn *ActionClickable
presetMenuBtn *TipClickable
commentExpandBtn *BoolClickable
commentEditor *widget.Editor
commentString tracker.String
nameEditor *widget.Editor
nameString tracker.String
searchEditor *widget.Editor
instrumentDragList *DragList
unitDragList *DragList
presetDragList *DragList
unitEditor *UnitEditor
tag bool
wasFocused bool
presetMenuItems []MenuItem
presetMenu Menu
commentKeyFilters []event.Filter
searchkeyFilters []event.Filter
nameKeyFilters []event.Filter
}
func NewInstrumentEditor(model *tracker.Model) *InstrumentEditor {
ret := &InstrumentEditor{
newInstrumentBtn: NewActionClickable(model.AddInstrument()),
enlargeBtn: NewBoolClickable(model.InstrEnlarged().Bool()),
deleteInstrumentBtn: NewActionClickable(model.DeleteInstrument()),
copyInstrumentBtn: new(TipClickable),
saveInstrumentBtn: new(TipClickable),
loadInstrumentBtn: new(TipClickable),
addUnitBtn: NewActionClickable(model.AddUnit(false)),
commentExpandBtn: NewBoolClickable(model.CommentExpanded().Bool()),
presetMenuBtn: new(TipClickable),
commentEditor: new(widget.Editor),
nameEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle},
searchEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start},
commentString: model.InstrumentComment().String(),
nameString: model.InstrumentName().String(),
instrumentDragList: NewDragList(model.Instruments().List(), layout.Horizontal),
unitDragList: NewDragList(model.Units().List(), layout.Vertical),
unitEditor: NewUnitEditor(model),
presetMenuItems: []MenuItem{},
}
model.IterateInstrumentPresets(func(index int, name string) bool {
ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: name, IconBytes: icons.ImageAudiotrack, Doer: model.LoadPreset(index)})
return true
})
for k := range noteMap {
ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: k, Focus: ret.commentEditor})
ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: k, Focus: ret.searchEditor})
ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: k, Focus: ret.nameEditor})
}
ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: key.NameEscape, Focus: ret.commentEditor})
ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: key.NameEscape, Focus: ret.searchEditor})
ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: key.NameEscape, Focus: ret.nameEditor})
ret.commentKeyFilters = append(ret.commentKeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.commentEditor})
ret.searchkeyFilters = append(ret.searchkeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.searchEditor})
ret.nameKeyFilters = append(ret.nameKeyFilters, key.Filter{Name: key.NameSpace, Focus: ret.nameEditor})
return ret
}
func (ie *InstrumentEditor) Focus() {
ie.unitDragList.Focus()
}
func (ie *InstrumentEditor) Focused() bool {
return ie.unitDragList.focused
}
func (ie *InstrumentEditor) childFocused(gtx C) bool {
return ie.unitEditor.sliderList.Focused() ||
ie.instrumentDragList.Focused() || gtx.Source.Focused(ie.commentEditor) || gtx.Source.Focused(ie.nameEditor) || gtx.Source.Focused(ie.searchEditor) ||
gtx.Source.Focused(ie.addUnitBtn.Clickable) || gtx.Source.Focused(ie.commentExpandBtn.Clickable) || gtx.Source.Focused(ie.presetMenuBtn.Clickable) ||
gtx.Source.Focused(ie.deleteInstrumentBtn.Clickable) || gtx.Source.Focused(ie.copyInstrumentBtn.Clickable)
}
func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D {
ie.wasFocused = ie.Focused() || ie.childFocused(gtx)
fullscreenBtnStyle := ToggleIcon(gtx, t.Theme, ie.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, "Enlarge (Ctrl+E)", "Shrink (Ctrl+E)")
octave := func(gtx C) D {
in := layout.UniformInset(unit.Dp(1))
numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, "Octave down (<) or up (>)")
dims := in.Layout(gtx, numStyle.Layout)
return dims
}
newBtnStyle := ActionIcon(gtx, t.Theme, ie.newInstrumentBtn, icons.ContentAdd, "Add\ninstrument\n(Ctrl+I)")
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{}.Layout(
gtx,
layout.Flexed(1, func(gtx C) D {
return ie.layoutInstrumentList(gtx, t)
}),
layout.Rigid(func(gtx C) D {
inset := layout.UniformInset(unit.Dp(6))
return inset.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("OCT:", white, t.Theme.Shaper)),
layout.Rigid(octave),
)
})
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, fullscreenBtnStyle.Layout)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, newBtnStyle.Layout)
}),
)
}),
layout.Rigid(func(gtx C) D {
return ie.layoutInstrumentHeader(gtx, t)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return ie.layoutUnitList(gtx, t)
}),
layout.Flexed(1, func(gtx C) D {
return ie.unitEditor.Layout(gtx, t)
}),
)
}))
return ret
}
func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D {
header := func(gtx C) D {
commentExpandBtnStyle := ToggleIcon(gtx, t.Theme, ie.commentExpandBtn, icons.NavigationExpandMore, icons.NavigationExpandLess, "Expand comment", "Collapse comment")
presetMenuBtnStyle := TipIcon(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, "Load preset")
copyInstrumentBtnStyle := TipIcon(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument")
saveInstrumentBtnStyle := TipIcon(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, "Save instrument")
loadInstrumentBtnStyle := TipIcon(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument")
deleteInstrumentBtnStyle := ActionIcon(gtx, t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, "Delete\ninstrument")
m := PopupMenu(&ie.presetMenu, t.Theme.Shaper)
for ie.copyInstrumentBtn.Clickable.Clicked(gtx) {
if contents, ok := t.Instruments().List().CopyElements(); ok {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
t.Alerts().Add("Instrument copied to clipboard", tracker.Info)
}
}
for ie.saveInstrumentBtn.Clickable.Clicked(gtx) {
writer, err := t.Explorer.CreateFile(t.InstrumentName().Value() + ".yml")
if err != nil {
continue
}
t.SaveInstrument(writer)
}
for ie.loadInstrumentBtn.Clickable.Clicked(gtx) {
reader, err := t.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp")
if err != nil {
continue
}
t.LoadInstrument(reader)
}
header := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(Label("Voices: ", white, t.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(t.Theme, t.InstrumentVoices, "Number of voices for this instrument")
dims := numStyle.Layout(gtx)
return dims
}),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(commentExpandBtnStyle.Layout),
layout.Rigid(func(gtx C) D {
//defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
dims := presetMenuBtnStyle.Layout(gtx)
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(500))
gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180))
m.Layout(gtx, ie.presetMenuItems...)
return dims
}),
layout.Rigid(saveInstrumentBtnStyle.Layout),
layout.Rigid(loadInstrumentBtnStyle.Layout),
layout.Rigid(copyInstrumentBtnStyle.Layout),
layout.Rigid(deleteInstrumentBtnStyle.Layout))
}
for ie.presetMenuBtn.Clickable.Clicked(gtx) {
ie.presetMenu.Visible = true
}
if ie.commentExpandBtn.Bool.Value() || gtx.Source.Focused(ie.commentEditor) { // we draw once the widget after it manages to lose focus
if ie.commentEditor.Text() != ie.commentString.Value() {
ie.commentEditor.SetText(ie.commentString.Value())
}
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(header),
layout.Rigid(func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
for {
event, ok := gtx.Event(ie.commentKeyFilters...)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape {
ie.instrumentDragList.Focus()
}
}
editorStyle := material.Editor(t.Theme, ie.commentEditor, "Comment")
editorStyle.Color = highEmphasisTextColor
return layout.UniformInset(unit.Dp(6)).Layout(gtx, editorStyle.Layout)
}),
)
ie.commentString.Set(ie.commentEditor.Text())
return ret
}
return header(gtx)
}
return Surface{Gray: 37, Focus: ie.wasFocused}.Layout(gtx, header)
}
func (ie *InstrumentEditor) layoutInstrumentList(gtx C, t *Tracker) D {
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
element := func(gtx C, i int) D {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30))
grabhandle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.Theme.Shaper}
if i == ie.instrumentDragList.TrackerList.Selected() {
grabhandle.Text = ":::"
}
label := func(gtx C) D {
name, level, ok := (*tracker.Instruments)(t.Model).Item(i)
if !ok {
labelStyle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper}
return layout.Center.Layout(gtx, labelStyle.Layout)
}
k := byte(255 - level*127)
color := color.NRGBA{R: 255, G: k, B: 255, A: 255}
if i == ie.instrumentDragList.TrackerList.Selected() {
if n := name; n != ie.nameEditor.Text() {
ie.nameEditor.SetText(n)
}
editor := material.Editor(t.Theme, ie.nameEditor, "Instr")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
editor.Font = labelDefaultFont
for {
ev, ok := ie.nameEditor.Update(gtx)
if !ok {
break
}
_, ok = ev.(widget.SubmitEvent)
if ok {
ie.instrumentDragList.Focus()
continue
}
}
dims := 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 editor.Layout(gtx)
})
for { // don't let key presses flow through from the editor
event, ok := gtx.Event(ie.nameKeyFilters...)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape {
ie.instrumentDragList.Focus()
}
}
ie.nameString.Set(ie.nameEditor.Text())
return dims
}
if name == "" {
name = "Instr"
}
labelStyle := LabelStyle{Text: name, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper}
return layout.Center.Layout(gtx, labelStyle.Layout)
}
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),
)
})
}
color := inactiveLightSurfaceColor
if ie.wasFocused {
color = activeLightSurfaceColor
}
instrumentList := FilledDragList(t.Theme, ie.instrumentDragList, element, nil)
instrumentList.SelectedColor = color
instrumentList.HoverColor = instrumentHoverColor
instrumentList.ScrollBarWidth = unit.Dp(6)
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
for {
event, ok := gtx.Event(
key.Filter{Focus: ie.instrumentDragList, Name: key.NameDownArrow},
key.Filter{Focus: ie.instrumentDragList, Name: key.NameReturn},
key.Filter{Focus: ie.instrumentDragList, Name: key.NameEnter},
)
if !ok {
break
}
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameDownArrow:
ie.unitDragList.Focus()
case key.NameReturn, key.NameEnter:
gtx.Execute(key.FocusCmd{Tag: ie.nameEditor})
l := len(ie.nameEditor.Text())
ie.nameEditor.SetCaret(l, l)
}
}
}
}
dims := instrumentList.Layout(gtx)
gtx.Constraints = layout.Exact(dims.Size)
instrumentList.LayoutScrollBar(gtx)
return dims
}
func (ie *InstrumentEditor) layoutUnitList(gtx C, t *Tracker) D {
// TODO: how to ie.unitDragList.Focus()
addUnitBtnStyle := ActionIcon(gtx, t.Theme, ie.addUnitBtn, icons.ContentAdd, "Add unit (Enter)")
addUnitBtnStyle.IconButtonStyle.Color = t.Theme.ContrastFg
addUnitBtnStyle.IconButtonStyle.Background = t.Theme.Fg
addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4))
index := 0
var units [256]tracker.UnitListItem
(*tracker.Units)(t.Model).Iterate(func(item tracker.UnitListItem) (ok bool) {
units[index] = item
index++
return index <= 256
})
count := intMin(ie.unitDragList.TrackerList.Count(), 256)
element := func(gtx C, i int) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Dp(unit.Dp(20))))
if i < 0 || i >= count {
return layout.Dimensions{Size: gtx.Constraints.Min}
}
u := units[i]
var color color.NRGBA = white
f := labelDefaultFont
var stackText string
stackText = strconv.FormatInt(int64(u.StackAfter), 10)
if u.StackNeed > u.StackBefore {
color = errorColor
(*tracker.Alerts)(t.Model).AddNamed("UnitNeedsInputs", fmt.Sprintf("%v needs at least %v input signals, got %v", u.Type, u.StackNeed, u.StackBefore), tracker.Error)
} else if i == count-1 && u.StackAfter != 0 {
color = warningColor
(*tracker.Alerts)(t.Model).AddNamed("InstrumentLeavesSignals", fmt.Sprintf("Instrument leaves %v signal(s) on the stack", u.StackAfter), tracker.Warning)
}
if u.Disabled {
color = disabledTextColor
f.Style = font.Italic
}
stackLabel := LabelStyle{Text: stackText, ShadeColor: black, Color: mediumEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper}
rightMargin := layout.Inset{Right: unit.Dp(10)}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
if i == ie.unitDragList.TrackerList.Selected() {
editor := material.Editor(t.Theme, ie.searchEditor, "---")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
editor.Font = f
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
txt := u.Type
str := tracker.String{StringData: (*tracker.UnitSearch)(t.Model)}
if t.UnitSearching().Value() {
txt = str.Value()
}
if ie.searchEditor.Text() != txt {
ie.searchEditor.SetText(txt)
}
for {
ev, ok := ie.searchEditor.Update(gtx)
if !ok {
break
}
_, ok = ev.(widget.SubmitEvent)
if ok {
txt := ""
ie.unitDragList.Focus()
if text := ie.searchEditor.Text(); text != "" {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, ie.searchEditor.Text()) {
txt = n
break
}
}
}
t.Units().SetSelectedType(txt)
t.UnitSearching().Bool().Set(false)
continue
}
}
ret := editor.Layout(gtx)
for { // don't let key presses flow through from the editor
event, ok := gtx.Event(ie.searchkeyFilters...)
if !ok {
break
}
if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape {
ie.instrumentDragList.Focus()
}
}
if ie.searchEditor.Text() != txt {
str.Set(ie.searchEditor.Text())
}
return ret
} else {
unitNameLabel := LabelStyle{Text: u.Type, ShadeColor: black, Color: color, Font: f, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper}
if unitNameLabel.Text == "" {
unitNameLabel.Text = "---"
}
return unitNameLabel.Layout(gtx)
}
}),
layout.Rigid(func(gtx C) D {
return rightMargin.Layout(gtx, stackLabel.Layout)
}),
)
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
unitList := FilledDragList(t.Theme, ie.unitDragList, element, nil)
return Surface{Gray: 30, Focus: ie.wasFocused}.Layout(gtx, func(gtx C) D {
return layout.Stack{Alignment: layout.SE}.Layout(gtx,
layout.Expanded(func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
for {
event, ok := gtx.Event(
key.Filter{Focus: ie.unitDragList, Name: key.NameRightArrow},
key.Filter{Focus: ie.unitDragList, Name: key.NameEnter, Optional: key.ModCtrl},
key.Filter{Focus: ie.unitDragList, Name: key.NameReturn, Optional: key.ModCtrl},
key.Filter{Focus: ie.unitDragList, Name: key.NameDeleteBackward},
key.Filter{Focus: ie.unitDragList, Name: key.NameEscape},
)
if !ok {
break
}
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameEscape:
ie.instrumentDragList.Focus()
case key.NameRightArrow:
ie.unitEditor.sliderList.Focus()
case key.NameDeleteBackward:
t.Units().SetSelectedType("")
gtx.Execute(key.FocusCmd{Tag: ie.searchEditor})
l := len(ie.searchEditor.Text())
ie.searchEditor.SetCaret(l, l)
case key.NameEnter, key.NameReturn:
t.Model.AddUnit(e.Modifiers.Contain(key.ModCtrl)).Do()
ie.searchEditor.SetText("")
gtx.Execute(key.FocusCmd{Tag: ie.searchEditor})
l := len(ie.searchEditor.Text())
ie.searchEditor.SetCaret(l, l)
}
}
}
}
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Constraints.Max.Y))
dims := unitList.Layout(gtx)
unitList.LayoutScrollBar(gtx)
return dims
}),
layout.Stacked(func(gtx C) D {
margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)}
return margin.Layout(gtx, addUnitBtnStyle.Layout)
}),
)
})
}
func clamp(i, min, max int) int {
if i < min {
return min
}
if i > max {
return max
}
return i
}

View File

@ -1,578 +0,0 @@
package gioui
import (
"fmt"
"image"
"image/color"
"strconv"
"strings"
"time"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/eventx"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/vm"
"golang.org/x/exp/shiny/materialdesign/icons"
"gopkg.in/yaml.v3"
)
type InstrumentEditor struct {
newInstrumentBtn *TipClickable
enlargeBtn *TipClickable
deleteInstrumentBtn *TipClickable
copyInstrumentBtn *TipClickable
saveInstrumentBtn *TipClickable
loadInstrumentBtn *TipClickable
addUnitBtn *TipClickable
commentExpandBtn *TipClickable
presetMenuBtn *TipClickable
commentEditor *widget.Editor
nameEditor *widget.Editor
unitTypeEditor *widget.Editor
instrumentDragList *DragList
instrumentScrollBar *ScrollBar
unitDragList *DragList
unitScrollBar *ScrollBar
confirmInstrDelete *Dialog
paramEditor *ParamEditor
stackUse []int
tag bool
wasFocused bool
commentExpanded bool
voiceLevels [vm.MAX_VOICES]float32
presetMenuItems []MenuItem
presetMenu Menu
}
func NewInstrumentEditor() *InstrumentEditor {
ret := &InstrumentEditor{
newInstrumentBtn: new(TipClickable),
enlargeBtn: new(TipClickable),
deleteInstrumentBtn: new(TipClickable),
copyInstrumentBtn: new(TipClickable),
saveInstrumentBtn: new(TipClickable),
loadInstrumentBtn: new(TipClickable),
addUnitBtn: new(TipClickable),
commentExpandBtn: new(TipClickable),
presetMenuBtn: new(TipClickable),
commentEditor: new(widget.Editor),
nameEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Middle},
unitTypeEditor: &widget.Editor{SingleLine: true, Submit: true, Alignment: text.Start},
instrumentDragList: &DragList{List: &layout.List{Axis: layout.Horizontal}, HoverItem: -1},
instrumentScrollBar: &ScrollBar{Axis: layout.Horizontal},
unitDragList: &DragList{List: &layout.List{Axis: layout.Vertical}, HoverItem: -1},
unitScrollBar: &ScrollBar{Axis: layout.Vertical},
confirmInstrDelete: new(Dialog),
paramEditor: NewParamEditor(),
presetMenuItems: []MenuItem{},
}
for _, instr := range tracker.InstrumentPresets {
ret.presetMenuItems = append(ret.presetMenuItems, MenuItem{Text: instr.Name, IconBytes: icons.ImageAudiotrack})
}
return ret
}
func (t *InstrumentEditor) ExpandComment() {
t.commentExpanded = true
}
func (ie *InstrumentEditor) Focus() {
ie.unitDragList.Focus()
}
func (ie *InstrumentEditor) Focused() bool {
return ie.unitDragList.focused
}
func (ie *InstrumentEditor) ChildFocused() bool {
return ie.paramEditor.Focused() || ie.instrumentDragList.Focused() || ie.commentEditor.Focused() || ie.nameEditor.Focused() || ie.unitTypeEditor.Focused() ||
ie.addUnitBtn.Clickable.Focused() || ie.commentExpandBtn.Clickable.Focused() || ie.presetMenuBtn.Clickable.Focused() || ie.deleteInstrumentBtn.Clickable.Focused() || ie.copyInstrumentBtn.Clickable.Focused()
}
func (ie *InstrumentEditor) Layout(gtx C, t *Tracker) D {
ie.wasFocused = ie.Focused() || ie.ChildFocused()
for _, e := range gtx.Events(&ie.tag) {
switch e.(type) {
case pointer.Event:
ie.unitDragList.Focus()
}
}
rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &ie.tag,
Types: pointer.Press,
}.Add(gtx.Ops)
area.Pop()
enlargeTip := "Enlarge"
icon := icons.NavigationFullscreen
if t.InstrEnlarged() {
icon = icons.NavigationFullscreenExit
enlargeTip = "Shrink"
}
fullscreenBtnStyle := IconButton(t.Theme, ie.enlargeBtn, icon, true, enlargeTip)
for ie.enlargeBtn.Clickable.Clicked() {
t.SetInstrEnlarged(!t.InstrEnlarged())
}
for ie.newInstrumentBtn.Clickable.Clicked() {
t.AddInstrument(true)
}
octave := func(gtx C) D {
in := layout.UniformInset(unit.Dp(1))
t.OctaveNumberInput.Value = t.Octave()
numStyle := NumericUpDown(t.Theme, t.OctaveNumberInput, 0, 9, "Octave down (<) or up (>)")
dims := in.Layout(gtx, numStyle.Layout)
t.SetOctave(t.OctaveNumberInput.Value)
return dims
}
newBtnStyle := IconButton(t.Theme, ie.newInstrumentBtn, icons.ContentAdd, t.CanAddInstrument(), "Add\ninstrument")
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{}.Layout(
gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
return ie.layoutInstrumentNames(gtx, t)
}),
layout.Expanded(func(gtx C) D {
return ie.instrumentScrollBar.Layout(gtx, unit.Dp(6), len(t.Song().Patch), &ie.instrumentDragList.List.Position)
}),
)
}),
layout.Rigid(func(gtx C) D {
inset := layout.UniformInset(unit.Dp(6))
return inset.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("OCT:", white, t.TextShaper)),
layout.Rigid(octave),
)
})
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, fullscreenBtnStyle.Layout)
}),
layout.Rigid(func(gtx C) D {
return layout.E.Layout(gtx, newBtnStyle.Layout)
}),
)
}),
layout.Rigid(func(gtx C) D {
return ie.layoutInstrumentHeader(gtx, t)
}),
layout.Flexed(1, func(gtx C) D {
return ie.layoutInstrumentEditor(gtx, t)
}))
return ret
}
func (ie *InstrumentEditor) layoutInstrumentHeader(gtx C, t *Tracker) D {
header := func(gtx C) D {
collapseIcon := icons.NavigationExpandLess
commentTip := "Collapse comment"
if !ie.commentExpanded {
collapseIcon = icons.NavigationExpandMore
commentTip = "Expand comment"
}
commentExpandBtnStyle := IconButton(t.Theme, ie.commentExpandBtn, collapseIcon, true, commentTip)
presetMenuBtnStyle := IconButton(t.Theme, ie.presetMenuBtn, icons.NavigationMenu, true, "Load preset")
copyInstrumentBtnStyle := IconButton(t.Theme, ie.copyInstrumentBtn, icons.ContentContentCopy, true, "Copy instrument")
saveInstrumentBtnStyle := IconButton(t.Theme, ie.saveInstrumentBtn, icons.ContentSave, true, "Save instrument")
loadInstrumentBtnStyle := IconButton(t.Theme, ie.loadInstrumentBtn, icons.FileFolderOpen, true, "Load instrument")
deleteInstrumentBtnStyle := IconButton(t.Theme, ie.deleteInstrumentBtn, icons.ActionDelete, t.CanDeleteInstrument(), "Delete\ninstrument")
m := t.PopupMenu(&ie.presetMenu)
for item, clicked := ie.presetMenu.Clicked(); clicked; item, clicked = ie.presetMenu.Clicked() {
t.SetInstrument(tracker.InstrumentPresets[item])
}
header := func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(Label("Voices: ", white, t.TextShaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
maxRemain := t.MaxInstrumentVoices()
t.InstrumentVoices.Value = t.Instrument().NumVoices
numStyle := NumericUpDown(t.Theme, t.InstrumentVoices, 0, maxRemain, "Number of voices for this instrument")
dims := numStyle.Layout(gtx)
t.SetInstrumentVoices(t.InstrumentVoices.Value)
return dims
}),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(commentExpandBtnStyle.Layout),
layout.Rigid(func(gtx C) D {
//defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
dims := presetMenuBtnStyle.Layout(gtx)
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(500))
gtx.Constraints.Max.X = gtx.Dp(unit.Dp(180))
m.Layout(gtx, ie.presetMenuItems...)
return dims
}),
layout.Rigid(saveInstrumentBtnStyle.Layout),
layout.Rigid(loadInstrumentBtnStyle.Layout),
layout.Rigid(copyInstrumentBtnStyle.Layout),
layout.Rigid(deleteInstrumentBtnStyle.Layout))
}
for ie.presetMenuBtn.Clickable.Clicked() {
ie.presetMenu.Visible = true
}
for ie.commentExpandBtn.Clickable.Clicked() {
ie.commentExpanded = !ie.commentExpanded
if !ie.commentExpanded {
key.FocusOp{Tag: &ie.tag}.Add(gtx.Ops) // clear focus
}
}
if ie.commentExpanded || ie.commentEditor.Focused() { // we draw once the widget after it manages to lose focus
if ie.commentEditor.Text() != t.Instrument().Comment {
ie.commentEditor.SetText(t.Instrument().Comment)
}
editorStyle := material.Editor(t.Theme, ie.commentEditor, "Comment")
editorStyle.Color = highEmphasisTextColor
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(header),
layout.Rigid(func(gtx C) D {
spy, spiedGtx := eventx.Enspy(gtx)
ret := layout.UniformInset(unit.Dp(6)).Layout(spiedGtx, editorStyle.Layout)
for _, group := range spy.AllEvents() {
for _, event := range group.Items {
switch e := event.(type) {
case key.Event:
if e.Name == key.NameEscape {
ie.instrumentDragList.Focus()
}
}
}
}
return ret
}),
)
t.SetInstrumentComment(ie.commentEditor.Text())
return ret
}
return header(gtx)
}
for ie.copyInstrumentBtn.Clickable.Clicked() {
contents, err := yaml.Marshal(t.Instrument())
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Instrument copied to clipboard", Notify, time.Second*3)
}
}
for ie.deleteInstrumentBtn.Clickable.Clicked() {
if t.CanDeleteInstrument() {
dialogStyle := ConfirmDialog(t.Theme, ie.confirmInstrDelete, "Are you sure you want to delete this instrument?", t.TextShaper)
ie.confirmInstrDelete.Visible = true
t.ModalDialog = dialogStyle.Layout
}
}
for ie.confirmInstrDelete.BtnOk.Clicked() {
t.DeleteInstrument(false)
t.ModalDialog = nil
}
for ie.confirmInstrDelete.BtnCancel.Clicked() {
t.ModalDialog = nil
}
for ie.saveInstrumentBtn.Clickable.Clicked() {
t.SaveInstrument()
}
for ie.loadInstrumentBtn.Clickable.Clicked() {
t.LoadInstrument()
}
return Surface{Gray: 37, Focus: ie.wasFocused}.Layout(gtx, header)
}
func (ie *InstrumentEditor) layoutInstrumentNames(gtx C, t *Tracker) D {
element := func(gtx C, i int) D {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(30))
grabhandle := LabelStyle{Text: "", ShadeColor: black, Color: white, FontSize: unit.Sp(10), Alignment: layout.Center, Shaper: t.TextShaper}
if i == t.InstrIndex() {
grabhandle.Text = ":::"
}
label := func(gtx C) D {
c := float32(0.0)
voice := t.Song().Patch.FirstVoiceForInstrument(i)
loopMax := t.Song().Patch[i].NumVoices
if loopMax > vm.MAX_VOICES {
loopMax = vm.MAX_VOICES
}
for j := 0; j < loopMax; j++ {
vc := ie.voiceLevels[voice]
if c < vc {
c = vc
}
voice++
}
k := byte(255 - c*127)
color := color.NRGBA{R: 255, G: k, B: 255, A: 255}
if i == t.InstrIndex() {
for _, ev := range ie.nameEditor.Events() {
_, ok := ev.(widget.SubmitEvent)
if ok {
ie.instrumentDragList.Focus()
continue
}
}
if n := t.Instrument().Name; n != ie.nameEditor.Text() {
ie.nameEditor.SetText(n)
}
editor := material.Editor(t.Theme, ie.nameEditor, "Instr")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
dims := layout.Center.Layout(gtx, editor.Layout)
t.SetInstrumentName(ie.nameEditor.Text())
return dims
}
text := t.Song().Patch[i].Name
if text == "" {
text = "Instr"
}
labelStyle := LabelStyle{Text: text, ShadeColor: black, Color: color, FontSize: unit.Sp(12), Shaper: t.TextShaper}
return layout.Center.Layout(gtx, labelStyle.Layout)
}
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),
)
})
}
color := inactiveLightSurfaceColor
if ie.wasFocused {
color = activeLightSurfaceColor
}
instrumentList := FilledDragList(t.Theme, ie.instrumentDragList, len(t.Song().Patch), element, t.SwapInstruments)
instrumentList.SelectedColor = color
instrumentList.HoverColor = instrumentHoverColor
ie.instrumentDragList.SelectedItem = t.InstrIndex()
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
key.InputOp{Tag: ie.instrumentDragList, Keys: "↓|⏎|⌤"}.Add(gtx.Ops)
for _, event := range gtx.Events(ie.instrumentDragList) {
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameDownArrow:
ie.unitDragList.Focus()
case key.NameReturn, key.NameEnter:
ie.nameEditor.Focus()
l := len(ie.nameEditor.Text())
ie.nameEditor.SetCaret(l, l)
}
}
}
}
dims := instrumentList.Layout(gtx)
if t.InstrIndex() != ie.instrumentDragList.SelectedItem {
t.SetInstrIndex(ie.instrumentDragList.SelectedItem)
op.InvalidateOp{}.Add(gtx.Ops)
}
return dims
}
func (ie *InstrumentEditor) layoutInstrumentEditor(gtx C, t *Tracker) D {
for ie.addUnitBtn.Clickable.Clicked() {
t.AddUnit(true)
ie.unitDragList.Focus()
}
addUnitBtnStyle := IconButton(t.Theme, ie.addUnitBtn, icons.ContentAdd, true, "Add unit (Ctrl+Enter)")
addUnitBtnStyle.IconButtonStyle.Color = t.Theme.ContrastFg
addUnitBtnStyle.IconButtonStyle.Background = t.Theme.Fg
addUnitBtnStyle.IconButtonStyle.Inset = layout.UniformInset(unit.Dp(4))
units := t.Instrument().Units
for len(ie.stackUse) < len(units) {
ie.stackUse = append(ie.stackUse, 0)
}
stackHeight := 0
for i, u := range units {
stackHeight += u.StackChange()
ie.stackUse[i] = stackHeight
}
element := func(gtx C, i int) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(unit.Dp(120)), gtx.Dp(unit.Dp(20))))
u := units[i]
var color color.NRGBA = white
var stackText string
if i < len(ie.stackUse) {
stackText = strconv.FormatInt(int64(ie.stackUse[i]), 10)
var prevStackUse int
if i > 0 {
prevStackUse = ie.stackUse[i-1]
}
if stackNeed := u.StackNeed(); stackNeed > prevStackUse {
color = errorColor
typeString := u.Type
if u.Parameters["stereo"] == 1 {
typeString += " (stereo)"
}
t.Alert.Update(fmt.Sprintf("%v needs at least %v input signals, got %v", typeString, stackNeed, prevStackUse), Error, 0)
} else if i == len(units)-1 && ie.stackUse[i] != 0 {
color = warningColor
t.Alert.Update(fmt.Sprintf("Instrument leaves %v signal(s) on the stack", ie.stackUse[i]), Warning, 0)
}
}
var unitName layout.Widget
if i == t.UnitIndex() {
for _, ev := range ie.unitTypeEditor.Events() {
_, ok := ev.(widget.SubmitEvent)
if ok {
ie.unitDragList.Focus()
if text := ie.unitTypeEditor.Text(); text != "" {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, ie.unitTypeEditor.Text()) {
t.SetUnitType(n)
break
}
}
} else {
t.SetUnitType("")
}
continue
}
}
if !ie.unitTypeEditor.Focused() && !ie.paramEditor.Focused() && ie.unitTypeEditor.Text() != t.Unit().Type {
ie.unitTypeEditor.SetText(t.Unit().Type)
}
editor := material.Editor(t.Theme, ie.unitTypeEditor, "---")
editor.Color = color
editor.HintColor = instrumentNameHintColor
editor.TextSize = unit.Sp(12)
editor.Font = labelDefaultFont
unitName = editor.Layout
} else {
unitNameLabel := LabelStyle{Text: u.Type, ShadeColor: black, Color: color, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper}
if unitNameLabel.Text == "" {
unitNameLabel.Text = "---"
}
unitName = unitNameLabel.Layout
}
stackLabel := LabelStyle{Text: stackText, ShadeColor: black, Color: mediumEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper}
rightMargin := layout.Inset{Right: unit.Dp(10)}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(1, unitName),
layout.Rigid(func(gtx C) D {
return rightMargin.Layout(gtx, stackLabel.Layout)
}),
)
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
unitList := FilledDragList(t.Theme, ie.unitDragList, len(units), element, t.SwapUnits)
return Surface{Gray: 30, Focus: ie.wasFocused}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Stack{Alignment: layout.SE}.Layout(gtx,
layout.Expanded(func(gtx C) D {
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
key.InputOp{Tag: ie.unitDragList, Keys: "→|⏎|⌫|⌦|⎋|Ctrl-⏎|Ctrl-C|Ctrl-X"}.Add(gtx.Ops)
for _, event := range gtx.Events(ie.unitDragList) {
switch e := event.(type) {
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameEscape:
ie.instrumentDragList.Focus()
case key.NameRightArrow:
ie.paramEditor.Focus()
case key.NameDeleteBackward:
t.SetUnitType("")
ie.unitTypeEditor.Focus()
l := len(ie.unitTypeEditor.Text())
ie.unitTypeEditor.SetCaret(l, l)
case key.NameDeleteForward:
t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2)
ie.unitDragList.SelectedItem2 = t.UnitIndex()
case "X":
units := t.DeleteUnits(true, ie.unitDragList.SelectedItem, ie.unitDragList.SelectedItem2)
ie.unitDragList.SelectedItem2 = t.UnitIndex()
contents, err := yaml.Marshal(units)
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Unit(s) cut to clipboard", Notify, time.Second*3)
}
case "C":
a := clamp(ie.unitDragList.SelectedItem, 0, len(t.Instrument().Units)-1)
b := clamp(ie.unitDragList.SelectedItem2, 0, len(t.Instrument().Units)-1)
if a > b {
a, b = b, a
}
units := t.Instrument().Units[a : b+1]
contents, err := yaml.Marshal(units)
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Unit(s) copied to clipboard", Notify, time.Second*3)
}
case key.NameReturn:
if e.Modifiers.Contain(key.ModShortcut) {
t.AddUnit(true)
ie.unitDragList.SelectedItem2 = ie.unitDragList.SelectedItem
ie.unitTypeEditor.SetText("")
}
ie.unitTypeEditor.Focus()
l := len(ie.unitTypeEditor.Text())
ie.unitTypeEditor.SetCaret(l, l)
}
}
}
}
ie.unitDragList.SelectedItem = t.UnitIndex()
dims := unitList.Layout(gtx)
if t.UnitIndex() != ie.unitDragList.SelectedItem {
t.SetUnitIndex(ie.unitDragList.SelectedItem)
ie.unitTypeEditor.SetText(t.Unit().Type)
}
return dims
}),
layout.Stacked(func(gtx C) D {
margin := layout.Inset{Right: unit.Dp(20), Bottom: unit.Dp(1)}
return margin.Layout(gtx, addUnitBtnStyle.Layout)
}),
layout.Expanded(func(gtx C) D {
return ie.unitScrollBar.Layout(gtx, unit.Dp(10), len(t.Instrument().Units), &ie.unitDragList.List.Position)
}))
}),
layout.Rigid(ie.paramEditor.Bind(t)))
})
}
func clamp(i, min, max int) int {
if i < min {
return min
}
if i > max {
return max
}
return i
}

View File

@ -1,16 +1,11 @@
package gioui
import (
"time"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/op"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v3"
)
var noteMap = map[string]int{
var noteMap = map[key.Name]int{
"Z": -12,
"S": -11,
"X": -10,
@ -46,116 +41,138 @@ var noteMap = map[string]int{
}
// KeyEvent handles incoming key events and returns true if repaint is needed.
func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) {
func (t *Tracker) KeyEvent(e key.Event, gtx C) {
if e.State == key.Press {
switch e.Name {
case "C":
if e.Modifiers.Contain(key.ModShortcut) {
contents, err := yaml.Marshal(t.Song())
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(o)
t.Alert.Update("Song copied to clipboard", Notify, time.Second*3)
}
return
}
case "V":
if e.Modifiers.Contain(key.ModShortcut) {
clipboard.ReadOp{Tag: t}.Add(o)
gtx.Execute(clipboard.ReadCmd{Tag: t})
return
}
case "Z":
if e.Modifiers.Contain(key.ModShortcut) {
t.Undo()
t.Model.Undo().Do()
return
}
case "Y":
if e.Modifiers.Contain(key.ModShortcut) {
t.Redo()
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(false)
t.NewSong().Do()
return
}
case "S":
if e.Modifiers.Contain(key.ModShortcut) {
t.SaveSongFile()
t.SaveSong().Do()
return
}
case "O":
if e.Modifiers.Contain(key.ModShortcut) {
t.OpenSongFile(false)
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.Focus()
t.OrderEditor.scrollTable.Focus()
return
case "F2":
t.TrackEditor.Focus()
t.TrackEditor.scrollTable.Focus()
return
case "F3":
t.InstrumentEditor.Focus()
return
case "F4":
t.TrackEditor.Focus()
return
case "F5":
t.SetNoteTracking(true)
startRow := t.Cursor().ScoreRow
t.PlayFromPosition(startRow)
t.SongPanel.RewindBtn.Action.Do()
t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
return
case "F6":
t.SetNoteTracking(false)
startRow := t.Cursor().ScoreRow
t.PlayFromPosition(startRow)
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.SetPlaying(false)
t.SongPanel.NoteTracking.Bool.Toggle()
return
case "F12":
t.Panic().Bool().Toggle()
return
case "Space":
if !t.Playing() && !t.InstrEnlarged() {
t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut))
startRow := t.Cursor().ScoreRow
t.PlayFromPosition(startRow)
} else {
t.SetPlaying(false)
}
case `\`, `<`, `>`:
if e.Modifiers.Contain(key.ModShift) {
t.SetOctave(t.Octave() + 1)
t.OctaveNumberInput.Int.Add(1)
} else {
t.SetOctave(t.Octave() - 1)
t.OctaveNumberInput.Int.Add(-1)
}
case key.NameTab:
if e.Modifiers.Contain(key.ModShift) {
switch {
case t.OrderEditor.Focused():
t.InstrumentEditor.paramEditor.Focus()
case t.TrackEditor.Focused():
t.OrderEditor.Focus()
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.InstrEnlarged() {
t.InstrumentEditor.paramEditor.Focus()
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.unitEditor.sliderList.Focus()
} else {
t.TrackEditor.Focus()
t.TrackEditor.scrollTable.Focus()
}
default:
t.InstrumentEditor.Focus()
}
} else {
switch {
case t.OrderEditor.Focused():
t.TrackEditor.Focus()
case t.TrackEditor.Focused():
case t.OrderEditor.scrollTable.Focused():
t.TrackEditor.scrollTable.Focus()
case t.TrackEditor.scrollTable.Focused():
t.InstrumentEditor.Focus()
case t.InstrumentEditor.Focused():
t.InstrumentEditor.paramEditor.Focus()
t.InstrumentEditor.unitEditor.sliderList.Focus()
default:
if t.InstrEnlarged() {
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
t.InstrumentEditor.Focus()
} else {
t.OrderEditor.Focus()
t.OrderEditor.scrollTable.Focus()
}
}
}
@ -166,28 +183,12 @@ func (t *Tracker) KeyEvent(e key.Event, o *op.Ops) {
}
}
// NumberPressed handles incoming presses while in either of the hex number columns
func (t *Tracker) NumberPressed(iv byte) {
val := t.Note()
if val == 1 {
val = 0
}
if t.LowNibble() {
val = (val & 0xF0) | (iv & 0xF)
} else {
val = ((iv & 0xF) << 4) | (val & 0xF)
}
t.SetNote(val)
}
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.Value, val)
instr := t.InstrIndex()
noteID := tracker.NoteIDInstr(instr, n)
t.NoteOn(noteID)
t.KeyPlaying[e.Name] = noteID
n := noteAsValue(t.OctaveNumberInput.Int.Value(), val)
instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected()
t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n)
return n
}
}
@ -196,7 +197,7 @@ func (t *Tracker) JammingPressed(e key.Event) byte {
func (t *Tracker) JammingReleased(e key.Event) bool {
if noteID, ok := t.KeyPlaying[e.Name]; ok {
t.NoteOff(noteID)
noteID.NoteOff()
delete(t.KeyPlaying, e.Name)
return true
}

View File

@ -24,28 +24,25 @@ type LabelStyle struct {
}
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
return layout.Stack{Alignment: l.Alignment}.Layout(gtx,
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
op.Offset(image.Pt(2, 2)).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.Add(image.Pt(2, 2)),
Baseline: dims.Baseline,
}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
return widget.Label{
Alignment: text.Start,
MaxLines: 1,
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
}),
)
return l.Alignment.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min = image.Point{}
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
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{})
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,
}
})
}
func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget {

View File

@ -1,121 +0,0 @@
package gioui
import (
"image"
"gioui.org/app"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
type C = layout.Context
type D = layout.Dimensions
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
// this is the top level input handler for the whole app
// it handles all the global key events and clipboard events
// we need to tell gio that we handle tabs too; otherwise
// it will steal them for focus switching
key.InputOp{Tag: t, Keys: "Tab|Shift-Tab"}.Add(gtx.Ops)
for _, ev := range gtx.Events(t) {
switch e := ev.(type) {
case key.Event:
t.KeyEvent(e, gtx.Ops)
case clipboard.Event:
t.UnmarshalContent([]byte(e.Text))
}
}
paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
if t.InstrEnlarged() {
t.layoutTop(gtx)
} else {
t.VerticalSplit.Layout(gtx,
t.layoutTop,
t.layoutBottom)
}
t.Alert.Layout(gtx)
dstyle := ConfirmDialog(t.Theme, t.ConfirmSongDialog, "Do you want to save your changes to the song? Your changes will be lost if you don't save them.", t.TextShaper)
dstyle.ShowAlt = true
dstyle.OkStyle.Text = "Save"
dstyle.AltStyle.Text = "Don't save"
dstyle.Layout(gtx)
for t.ConfirmSongDialog.BtnOk.Clicked() {
if t.SaveSongFile() {
t.confirmedSongAction()
}
t.ConfirmSongDialog.Visible = false
}
for t.ConfirmSongDialog.BtnAlt.Clicked() {
t.confirmedSongAction()
t.ConfirmSongDialog.Visible = false
}
for t.ConfirmSongDialog.BtnCancel.Clicked() {
t.ConfirmSongDialog.Visible = false
}
dstyle = ConfirmDialog(t.Theme, t.WaveTypeDialog, "Export .wav in int16 or float32 sample format?", t.TextShaper)
dstyle.ShowAlt = true
dstyle.OkStyle.Text = "Int16"
dstyle.AltStyle.Text = "Float32"
dstyle.Layout(gtx)
for t.WaveTypeDialog.BtnOk.Clicked() {
t.ExportWav(true)
t.WaveTypeDialog.Visible = false
}
for t.WaveTypeDialog.BtnAlt.Clicked() {
t.ExportWav(false)
t.WaveTypeDialog.Visible = false
}
for t.WaveTypeDialog.BtnCancel.Clicked() {
t.WaveTypeDialog.Visible = false
}
if t.ModalDialog != nil {
t.ModalDialog(gtx)
}
}
func (t *Tracker) confirmedSongAction() {
switch t.ConfirmSongActionType {
case ConfirmLoad:
t.OpenSongFile(true)
case ConfirmNew:
t.NewSong(true)
case ConfirmQuit:
t.Quit(true)
}
}
func (t *Tracker) NewSong(forced bool) {
if !forced && t.ChangedSinceSave() {
t.ConfirmSongActionType = ConfirmNew
t.ConfirmSongDialog.Visible = true
return
}
t.ResetSong()
t.SetFilePath("")
t.ClearUndoHistory()
t.SetChangedSinceSave(false)
}
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)
},
)
}
func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
return t.TopHorizontalSplit.Layout(gtx,
t.layoutSongPanel,
func(gtx C) D {
return t.InstrumentEditor.Layout(gtx, t)
},
)
}

View File

@ -4,6 +4,7 @@ import (
"image"
"image/color"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -12,6 +13,8 @@ import (
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type Menu struct {
@ -40,7 +43,7 @@ type MenuItem struct {
IconBytes []byte
Text string
ShortcutText string
Disabled bool
Doer tracker.Action
}
func (m *Menu) Clicked() (int, bool) {
@ -57,20 +60,27 @@ func (m *Menu) Clicked() (int, bool) {
func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
contents := func(gtx C) D {
for i := range items {
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)
}
// handle pointer events for this item
for _, ev := range gtx.Events(&m.Menu.tags[i]) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: &m.Menu.tags[i],
Kinds: pointer.Press | pointer.Enter | pointer.Leave,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Press:
m.Menu.clicks = append(m.Menu.clicks, i)
item.Doer.Do()
m.Menu.Visible = false
case pointer.Enter:
m.Menu.hover = i + 1
@ -89,17 +99,17 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
var macro op.MacroOp
item := &items[i]
if i == m.Menu.hover-1 && !item.Disabled {
if i == m.Menu.hover-1 && item.Doer.Allowed() {
macro = op.Record(gtx.Ops)
}
icon := widgetForIcon(item.IconBytes)
iconColor := m.IconColor
if item.Disabled {
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.Disabled {
if !item.Doer.Allowed() {
textLabel.Color = mediumEmphasisTextColor
}
shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper}
@ -118,19 +128,17 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
return shortcutInset.Layout(gtx, shortcutLabel.Layout)
}),
)
if i == m.Menu.hover-1 && !item.Disabled {
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.Disabled {
if item.Doer.Allowed() {
rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &m.Menu.tags[i],
Types: pointer.Press | pointer.Enter | pointer.Leave,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &m.Menu.tags[i])
area.Pop()
}
return dims
@ -148,7 +156,7 @@ func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
return popup.Layout(gtx, contents)
}
func (t *Tracker) PopupMenu(menu *Menu) MenuStyle {
func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle {
return MenuStyle{
Menu: menu,
IconColor: white,
@ -157,6 +165,26 @@ func (t *Tracker) PopupMenu(menu *Menu) MenuStyle {
FontSize: unit.Sp(16),
IconSize: unit.Dp(16),
HoverColor: menuHoverColor,
Shaper: t.TextShaper,
Shaper: shaper,
}
}
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
}
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
}
}

View File

@ -0,0 +1,384 @@
package gioui
import (
"fmt"
"image"
"strconv"
"strings"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
const trackRowHeight = unit.Dp(16)
const trackColWidth = unit.Dp(54)
const trackColTitleHeight = unit.Dp(16)
const trackPatMarkWidth = unit.Dp(25)
const trackRowMarkWidth = unit.Dp(25)
var noteStr [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 := 2; i < 256; i++ {
hexStr[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)))
case octave >= 10:
noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
default:
noteStr[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
scrollTable *ScrollTable
tag struct{}
}
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()),
scrollTable: NewScrollTable(
model.Notes().Table(),
model.Tracks().List(),
model.NoteRows().List(),
),
}
}
func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
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: "."},
)
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)
}
continue
}
te.command(gtx, t, e)
}
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
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 layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return te.layoutButtons(gtx, t)
}),
layout.Flexed(1, func(gtx C) D {
return te.layoutTracks(gtx, t)
}),
)
})
}
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)")
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)
}
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
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.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout))
})
}
const baseNote = 24
var notes = []string{
"C-",
"C#",
"D-",
"D#",
"E-",
"F-",
"F#",
"G-",
"G#",
"A-",
"A#",
"B-",
}
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()
switch beatMarkerDensity {
case 0, 1, 2:
beatMarkerDensity = 4
}
playSongRow := t.PlaySongRow()
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)
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)
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())
} else if mod(j, beatMarkerDensity) == 0 {
paint.FillShape(gtx.Ops, oneBeatHighlight, 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())
}
return D{}
}
rowTitle := func(gtx C, j int) D {
rpp := intMax(t.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
}
paint.ColorOp{Color: color}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{})
}
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{})
return D{Size: image.Pt(w, pxHeight)}
}
drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2()
selection := te.scrollTable.Table.Range()
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
}
}
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
}
}
c := inactiveSelectionColor
if te.scrollTable.Focused() {
c = cursorColor
}
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
}
// draw the pattern marker
rpp := intMax(t.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{})
}
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 te.scrollTable.Table.Cursor() == point && te.scrollTable.Focused() {
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
} else {
paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops)
}
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{})
return D{Size: image.Pt(pxWidth, pxHeight)}
}
table := FilledScrollTable(t.Theme, te.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
table.RowTitleWidth = trackPatMarkWidth + trackRowMarkWidth
table.ColumnTitleHeight = trackColTitleHeight
table.CellWidth = trackColWidth
table.CellHeight = trackRowHeight
return table.Layout(gtx)
}
func mod(x, d int) int {
x = x % d
if x >= 0 {
return x
}
if d < 0 {
return x - d
}
return x + d
}
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
}
var n byte
if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) {
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
}
} else {
if val, ok := noteMap[e.Name]; ok {
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val)
t.Model.Notes().Table().Fill(int(n))
goto validNote
}
}
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

@ -5,6 +5,7 @@ import (
"image"
"image/color"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"gioui.org/font"
@ -14,6 +15,7 @@ import (
"gioui.org/x/component"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -23,7 +25,7 @@ import (
)
type NumberInput struct {
Value int
Int tracker.Int
dragStartValue int
dragStartXY float32
clickDecrease gesture.Click
@ -33,8 +35,6 @@ type NumberInput struct {
type NumericUpDownStyle struct {
NumberInput *NumberInput
Min int
Max int
Color color.NRGBA
Font font.Font
TextSize unit.Sp
@ -51,15 +51,17 @@ type NumericUpDownStyle struct {
shaper text.Shaper
}
func NumericUpDown(th *material.Theme, number *NumberInput, min, max int, tooltip string) NumericUpDownStyle {
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,
Min: min,
Max: max,
Color: white,
BorderColor: th.Palette.Fg,
IconColor: th.Palette.ContrastFg,
@ -104,12 +106,6 @@ func (s *NumericUpDownStyle) actualLayout(gtx C) D {
layout.Flexed(1, s.layoutText),
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowForward), 1, &s.NumberInput.clickIncrease)),
)
if s.NumberInput.Value < s.Min {
s.NumberInput.Value = s.Min
}
if s.NumberInput.Value > s.Max {
s.NumberInput.Value = s.Max
}
off.Pop()
c2.Pop()
return layout.Dimensions{Size: size}
@ -156,7 +152,7 @@ func (s *NumericUpDownStyle) layoutText(gtx C) D {
}),
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.Value), op.CallOp{})
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),
)
@ -165,17 +161,24 @@ func (s *NumericUpDownStyle) layoutText(gtx C) D {
func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
{ // handle dragging
pxPerStep := float32(gtx.Dp(s.UnitsPerStep))
for _, ev := range gtx.Events(s.NumberInput) {
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.Type {
switch e.Kind {
case pointer.Press:
s.NumberInput.dragStartValue = s.NumberInput.Value
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.Value = s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5)
s.NumberInput.Int.Set(s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5))
}
}
}
@ -185,10 +188,7 @@ func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
// register for input
dragRect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area := clip.Rect(dragRect).Push(gtx.Ops)
pointer.InputOp{
Tag: s.NumberInput,
Types: pointer.Press | pointer.Drag | pointer.Release,
}.Add(gtx.Ops)
event.Op(gtx.Ops, s.NumberInput)
area.Pop()
stack.Pop()
}
@ -197,10 +197,14 @@ func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
func (s *NumericUpDownStyle) layoutClick(gtx layout.Context, delta int, click *gesture.Click) layout.Dimensions {
// handle clicking
for _, e := range click.Events(gtx) {
switch e.Type {
case gesture.TypeClick:
s.NumberInput.Value += delta
for {
ev, ok := click.Update(gtx.Source)
if !ok {
break
}
switch ev.Kind {
case gesture.KindClick:
s.NumberInput.Int.Add(delta)
}
}
// Avoid affecting the input tree with pointer events.

View File

@ -0,0 +1,213 @@
package gioui
import (
"fmt"
"image"
"math"
"strconv"
"strings"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"github.com/vsariola/sointu/tracker"
)
const patternCellHeight = unit.Dp(16)
const patternCellWidth = unit.Dp(16)
const patternRowMarkerWidth = unit.Dp(30)
const orderTitleHeight = unit.Dp(52)
type OrderEditor struct {
scrollTable *ScrollTable
tag struct{}
}
var patternIndexStrings [36]string
func init() {
for i := 0; i < 10; i++ {
patternIndexStrings[i] = string('0' + byte(i))
}
for i := 10; i < 36; i++ {
patternIndexStrings[i] = string('A' + byte(i-10))
}
}
func NewOrderEditor(m *tracker.Model) *OrderEditor {
return &OrderEditor{
scrollTable: NewScrollTable(
m.Order().Table(),
m.Tracks().List(),
m.OrderRows().List(),
),
}
}
func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
if oe.scrollTable.CursorMoved() {
cursor := t.TrackEditor.scrollTable.Table.Cursor()
t.TrackEditor.scrollTable.ColTitleList.CenterOn(cursor.X)
t.TrackEditor.scrollTable.RowTitleList.CenterOn(cursor.Y)
}
oe.handleEvents(gtx, t)
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
event.Op(gtx.Ops, &oe.tag)
colTitle := func(gtx C, i int) D {
h := gtx.Dp(orderTitleHeight)
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)
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())
}
return D{}
}
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
}
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{})
return D{Size: image.Pt(w, gtx.Dp(patternCellHeight))}
}
selection := oe.scrollTable.Table.Range()
cell := func(gtx C, x, y int) D {
val := patternIndexToString(t.Model.Order().Value(tracker.Point{X: x, Y: y}))
color := patternCellColor
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
}
}
}
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{})
return D{Size: image.Pt(gtx.Dp(patternCellWidth), gtx.Dp(patternCellHeight))}
}
table := FilledScrollTable(t.Theme, oe.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
table.ColumnTitleHeight = orderTitleHeight
return table.Layout(gtx)
}
func (oe *OrderEditor) handleEvents(gtx C, t *Tracker) {
for {
e, ok := gtx.Event(
key.Filter{Focus: oe.scrollTable, Name: key.NameDeleteBackward, Required: key.ModShortcut},
key.Filter{Focus: oe.scrollTable, Name: key.NameDeleteForward, Required: key.ModShortcut},
key.Filter{Focus: oe.scrollTable, Name: key.NameReturn, Optional: key.ModShortcut},
key.Filter{Focus: oe.scrollTable, Name: "0"},
key.Filter{Focus: oe.scrollTable, Name: "1"},
key.Filter{Focus: oe.scrollTable, Name: "2"},
key.Filter{Focus: oe.scrollTable, Name: "3"},
key.Filter{Focus: oe.scrollTable, Name: "4"},
key.Filter{Focus: oe.scrollTable, Name: "5"},
key.Filter{Focus: oe.scrollTable, Name: "6"},
key.Filter{Focus: oe.scrollTable, Name: "7"},
key.Filter{Focus: oe.scrollTable, Name: "8"},
key.Filter{Focus: oe.scrollTable, Name: "9"},
key.Filter{Focus: oe.scrollTable, Name: "A"},
key.Filter{Focus: oe.scrollTable, Name: "B"},
key.Filter{Focus: oe.scrollTable, Name: "C"},
key.Filter{Focus: oe.scrollTable, Name: "D"},
key.Filter{Focus: oe.scrollTable, Name: "E"},
key.Filter{Focus: oe.scrollTable, Name: "F"},
key.Filter{Focus: oe.scrollTable, Name: "G"},
key.Filter{Focus: oe.scrollTable, Name: "H"},
key.Filter{Focus: oe.scrollTable, Name: "I"},
key.Filter{Focus: oe.scrollTable, Name: "J"},
key.Filter{Focus: oe.scrollTable, Name: "K"},
key.Filter{Focus: oe.scrollTable, Name: "L"},
key.Filter{Focus: oe.scrollTable, Name: "M"},
key.Filter{Focus: oe.scrollTable, Name: "N"},
key.Filter{Focus: oe.scrollTable, Name: "O"},
key.Filter{Focus: oe.scrollTable, Name: "P"},
key.Filter{Focus: oe.scrollTable, Name: "Q"},
key.Filter{Focus: oe.scrollTable, Name: "R"},
key.Filter{Focus: oe.scrollTable, Name: "S"},
key.Filter{Focus: oe.scrollTable, Name: "T"},
key.Filter{Focus: oe.scrollTable, Name: "U"},
key.Filter{Focus: oe.scrollTable, Name: "V"},
key.Filter{Focus: oe.scrollTable, Name: "W"},
key.Filter{Focus: oe.scrollTable, Name: "X"},
key.Filter{Focus: oe.scrollTable, Name: "Y"},
key.Filter{Focus: oe.scrollTable, Name: "Z"},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok {
if e.State != key.Press {
continue
}
oe.command(gtx, t, e)
}
}
}
func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) {
switch e.Name {
case key.NameDeleteBackward:
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.DeleteOrderRow(true).Do()
}
case key.NameDeleteForward:
if e.Modifiers.Contain(key.ModShortcut) {
t.Model.DeleteOrderRow(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()
}
if iv, err := strconv.Atoi(string(e.Name)); err == nil {
t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), iv)
oe.scrollTable.EnsureCursorVisible()
}
if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 {
t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), b+10)
oe.scrollTable.EnsureCursorVisible()
}
}
func patternIndexToString(index int) string {
if index < 0 {
return ""
} else if index < len(patternIndexStrings) {
return patternIndexStrings[index]
}
return "?"
}

View File

@ -1,262 +0,0 @@
package gioui
import (
"fmt"
"image"
"strconv"
"strings"
"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"
"github.com/vsariola/sointu/tracker"
)
const patternCellHeight = 16
const patternCellWidth = 16
const patternRowMarkerWidth = 30
type OrderEditor struct {
list *layout.List
titleList *DragList
scrollBar *ScrollBar
tag bool
focused bool
requestFocus bool
}
func NewOrderEditor() *OrderEditor {
return &OrderEditor{
list: &layout.List{Axis: layout.Vertical},
titleList: &DragList{List: &layout.List{Axis: layout.Horizontal}},
scrollBar: &ScrollBar{Axis: layout.Vertical},
}
}
func (oe *OrderEditor) Focus() {
oe.requestFocus = true
}
func (oe *OrderEditor) Focused() bool {
return oe.focused
}
func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
return Surface{Gray: 24, Focus: oe.focused}.Layout(gtx, func(gtx C) D {
return oe.doLayout(gtx, t)
})
}
func (oe *OrderEditor) doLayout(gtx C, t *Tracker) D {
for _, e := range gtx.Events(&oe.tag) {
switch e := e.(type) {
case key.FocusEvent:
oe.focused = e.Focus
case pointer.Event:
if e.Type == pointer.Press {
key.FocusOp{Tag: &oe.tag}.Add(gtx.Ops)
}
case key.Event:
if e.State != key.Press {
continue
}
switch e.Name {
case key.NameDeleteForward, key.NameDeleteBackward:
if e.Modifiers.Contain(key.ModShortcut) {
t.DeleteOrderRow(e.Name == key.NameDeleteForward)
} else {
t.DeletePatternSelection()
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
}
case "Space":
if !t.Playing() {
t.SetNoteTracking(!e.Modifiers.Contain(key.ModShortcut))
startRow := t.Cursor().ScoreRow
startRow.Row = 0
t.PlayFromPosition(startRow)
} else {
t.SetPlaying(false)
}
case key.NameReturn:
t.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut))
case key.NameUpArrow:
cursor := t.Cursor()
if e.Modifiers.Contain(key.ModShortcut) {
cursor.ScoreRow = tracker.ScoreRow{}
} else {
cursor.Row -= t.Song().Score.RowsPerPattern
}
t.SetNoteTracking(false)
t.SetCursor(cursor)
case key.NameDownArrow:
cursor := t.Cursor()
if e.Modifiers.Contain(key.ModShortcut) {
cursor.Row = t.Song().Score.LengthInRows() - 1
} else {
cursor.Row += t.Song().Score.RowsPerPattern
}
t.SetNoteTracking(false)
t.SetCursor(cursor)
case key.NameLeftArrow:
cursor := t.Cursor()
if e.Modifiers.Contain(key.ModShortcut) {
cursor.Track = 0
} else {
cursor.Track--
}
t.SetCursor(cursor)
case key.NameRightArrow:
cursor := t.Cursor()
if e.Modifiers.Contain(key.ModShortcut) {
cursor.Track = len(t.Song().Score.Tracks) - 1
} else {
cursor.Track++
}
t.SetCursor(cursor)
case "+":
t.AdjustPatternNumber(1, e.Modifiers.Contain(key.ModShortcut))
continue
case "-":
t.AdjustPatternNumber(-1, e.Modifiers.Contain(key.ModShortcut))
continue
case key.NameHome:
cursor := t.Cursor()
cursor.Track = 0
t.SetCursor(cursor)
case key.NameEnd:
cursor := t.Cursor()
cursor.Track = len(t.Song().Score.Tracks) - 1
t.SetCursor(cursor)
}
if (e.Name != key.NameLeftArrow &&
e.Name != key.NameRightArrow &&
e.Name != key.NameUpArrow &&
e.Name != key.NameDownArrow) ||
!e.Modifiers.Contain(key.ModShift) {
t.SetSelectionCorner(t.Cursor())
}
if e.Modifiers.Contain(key.ModShortcut) {
continue
}
if iv, err := strconv.Atoi(e.Name); err == nil {
t.SetCurrentPattern(iv)
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
}
if b := int(e.Name[0]) - 'A'; len(e.Name) == 1 && b >= 0 && b < 26 {
t.SetCurrentPattern(b + 10)
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddPatterns(1))
t.SetSelectionCorner(t.Cursor())
}
}
}
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
if oe.requestFocus {
oe.requestFocus = false
key.FocusOp{Tag: &oe.tag}.Add(gtx.Ops)
}
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
pointer.InputOp{Tag: &oe.tag,
Types: pointer.Press,
}.Add(gtx.Ops)
key.InputOp{Tag: &oe.tag, Keys: "←|→|↑|↓|Shift-←|Shift-→|Shift-↑|Shift-↓|⏎|⇱|⇲|⌫|⌦|Ctrl-⌫|Ctrl-⌦|+|-|Space|0|1|2|3|4|5|6|7|8|9|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z"}.Add(gtx.Ops)
patternRect := tracker.ScoreRect{
Corner1: tracker.ScorePoint{ScoreRow: tracker.ScoreRow{Pattern: t.Cursor().Pattern}, Track: t.Cursor().Track},
Corner2: tracker.ScorePoint{ScoreRow: tracker.ScoreRow{Pattern: t.SelectionCorner().Pattern}, Track: t.SelectionCorner().Track},
}
// draw the single letter titles for tracks
{
gtx := gtx
stack := op.Offset(image.Pt(patternRowMarkerWidth, 0)).Push(gtx.Ops)
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X-patternRowMarkerWidth, patternCellHeight))
elem := func(gtx C, i int) D {
gtx.Constraints = layout.Exact(image.Pt(patternCellWidth, patternCellHeight))
instr, err := t.Song().Patch.InstrumentForVoice(t.Song().Score.FirstVoiceForTrack(i))
var title string
if err == nil && len(t.Song().Patch[instr].Name) > 0 {
title = string(t.Song().Patch[instr].Name[0])
} else {
title = "?"
}
LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.TextShaper}.Layout(gtx)
return D{Size: gtx.Constraints.Min}
}
style := FilledDragList(t.Theme, oe.titleList, len(t.Song().Score.Tracks), elem, t.SwapTracks)
style.HoverColor = transparent
style.SelectedColor = transparent
style.Layout(gtx)
stack.Pop()
}
op.Offset(image.Pt(0, patternCellHeight)).Add(gtx.Ops)
gtx.Constraints.Max.Y -= patternCellHeight
gtx.Constraints.Min.Y -= patternCellHeight
element := func(gtx C, j int) D {
if playPos := t.PlayPosition(); t.Playing() && j == playPos.Pattern {
paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}.Op())
}
paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{})
stack := op.Offset(image.Pt(patternRowMarkerWidth, 0)).Push(gtx.Ops)
for i, track := range t.Song().Score.Tracks {
paint.FillShape(gtx.Ops, patternCellColor, clip.Rect{Min: image.Pt(1, 1), Max: image.Pt(patternCellWidth-1, patternCellHeight-1)}.Op())
paint.ColorOp{Color: patternTextColor}.Add(gtx.Ops)
if j >= 0 && j < len(track.Order) && track.Order[j] >= 0 {
gtx := gtx
gtx.Constraints.Max.X = patternCellWidth
op.Offset(image.Pt(0, -2)).Add(gtx.Ops)
widget.Label{Alignment: text.Middle}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, patternIndexToString(track.Order[j]), op.CallOp{})
op.Offset(image.Pt(0, 2)).Add(gtx.Ops)
}
point := tracker.ScorePoint{Track: i, ScoreRow: tracker.ScoreRow{Pattern: j}}
if oe.focused || t.TrackEditor.Focused() {
if patternRect.Contains(point) {
color := inactiveSelectionColor
if oe.focused {
color = selectionColor
if point.Pattern == t.Cursor().Pattern && point.Track == t.Cursor().Track {
color = cursorColor
}
}
paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(patternCellWidth, patternCellHeight)}.Op())
}
}
op.Offset(image.Pt(patternCellWidth, 0)).Add(gtx.Ops)
}
stack.Pop()
return D{Size: image.Pt(gtx.Constraints.Max.X, patternCellHeight)}
}
return layout.Stack{Alignment: layout.NE}.Layout(gtx,
layout.Expanded(func(gtx C) D {
return oe.list.Layout(gtx, t.Song().Score.Length, element)
}),
layout.Expanded(func(gtx C) D {
return oe.scrollBar.Layout(gtx, unit.Dp(10), t.Song().Score.Length, &oe.list.Position)
}),
)
}
func patternIndexToString(index int) string {
if index < 0 {
return ""
} else if index < 10 {
return string('0' + byte(index))
}
return string('A' + byte(index-10))
}

View File

@ -1,256 +0,0 @@
package gioui
import (
"image"
"image/color"
"strings"
"time"
"gioui.org/io/clipboard"
"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/widget"
"github.com/vsariola/sointu"
"golang.org/x/exp/shiny/materialdesign/icons"
"gopkg.in/yaml.v3"
)
type ParamEditor struct {
list *layout.List
scrollBar *ScrollBar
Parameters []*ParameterWidget
DeleteUnitBtn *TipClickable
CopyUnitBtn *TipClickable
ClearUnitBtn *TipClickable
ChooseUnitTypeBtns []*widget.Clickable
tag bool
focused bool
requestFocus bool
}
func (pe *ParamEditor) Focus() {
pe.requestFocus = true
}
func (pe *ParamEditor) Focused() bool {
return pe.focused
}
func NewParamEditor() *ParamEditor {
ret := &ParamEditor{
DeleteUnitBtn: new(TipClickable),
ClearUnitBtn: new(TipClickable),
CopyUnitBtn: new(TipClickable),
list: &layout.List{Axis: layout.Vertical},
scrollBar: &ScrollBar{Axis: layout.Vertical},
}
for range sointu.UnitNames {
ret.ChooseUnitTypeBtns = append(ret.ChooseUnitTypeBtns, new(widget.Clickable))
}
return ret
}
func (pe *ParamEditor) Bind(t *Tracker) layout.Widget {
return func(gtx C) D {
for _, e := range gtx.Events(&pe.tag) {
switch e := e.(type) {
case key.FocusEvent:
pe.focused = e.Focus
case pointer.Event:
if e.Type == pointer.Press {
key.FocusOp{Tag: &pe.tag}.Add(gtx.Ops)
}
case key.Event:
if e.Modifiers.Contain(key.ModShortcut) {
continue
}
switch e.State {
case key.Press:
switch e.Name {
case key.NameUpArrow:
t.SetParamIndex(t.ParamIndex() - 1)
case key.NameDownArrow:
t.SetParamIndex(t.ParamIndex() + 1)
case key.NameLeftArrow:
p, err := t.Param(t.ParamIndex())
if err != nil {
break
}
if e.Modifiers.Contain(key.ModShift) {
t.SetParam(p.Value - p.LargeStep)
} else {
t.SetParam(p.Value - 1)
}
case key.NameRightArrow:
p, err := t.Param(t.ParamIndex())
if err != nil {
break
}
if e.Modifiers.Contain(key.ModShift) {
t.SetParam(p.Value + p.LargeStep)
} else {
t.SetParam(p.Value + 1)
}
case key.NameEscape:
t.InstrumentEditor.unitDragList.Focus()
}
}
}
}
if pe.requestFocus {
pe.requestFocus = false
key.FocusOp{Tag: &pe.tag}.Add(gtx.Ops)
}
editorFunc := pe.layoutUnitSliders
if y := t.Unit().Type; y == "" || y != t.InstrumentEditor.unitTypeEditor.Text() {
editorFunc = pe.layoutUnitTypeChooser
}
return Surface{Gray: 24, Focus: t.InstrumentEditor.wasFocused}.Layout(gtx, func(gtx C) D {
ret := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return editorFunc(gtx, t)
}),
layout.Rigid(pe.layoutUnitFooter(t)))
rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
area := clip.Rect(rect).Push(gtx.Ops)
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
pointer.InputOp{Tag: &pe.tag,
Types: pointer.Press,
}.Add(gtx.Ops)
key.InputOp{Tag: &pe.tag, Keys: "←|Shift-←|→|Shift-→|↑|↓|⎋"}.Add(gtx.Ops)
area.Pop()
return ret
})
}
}
func (pe *ParamEditor) layoutUnitSliders(gtx C, t *Tracker) D {
numItems := t.NumParams()
for len(pe.Parameters) <= numItems {
pe.Parameters = append(pe.Parameters, new(ParameterWidget))
}
listItem := func(gtx C, index int) D {
for pe.Parameters[index].Clicked() {
if t.ParamIndex() != index {
t.SetParamIndex(index)
} else {
t.ResetParam()
}
pe.Focus()
}
param, err := t.Param(index)
if err != nil {
return D{}
}
oldVal := param.Value
paramStyle := t.ParamStyle(t.Theme, &param, pe.Parameters[index])
paramStyle.Focus = pe.focused && t.ParamIndex() == index
dims := paramStyle.Layout(gtx)
if oldVal != param.Value {
pe.Focus()
t.SetParamIndex(index)
t.SetParam(param.Value)
}
return dims
}
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
return pe.list.Layout(gtx, numItems, listItem)
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return pe.scrollBar.Layout(gtx, unit.Dp(10), numItems, &pe.list.Position)
}))
}
func (pe *ParamEditor) layoutUnitFooter(t *Tracker) layout.Widget {
return func(gtx C) D {
for pe.ClearUnitBtn.Clickable.Clicked() {
t.SetUnitType("")
op.InvalidateOp{}.Add(gtx.Ops)
t.InstrumentEditor.unitDragList.Focus()
}
for pe.DeleteUnitBtn.Clickable.Clicked() {
t.DeleteUnits(false, t.UnitIndex(), t.UnitIndex())
op.InvalidateOp{}.Add(gtx.Ops)
t.InstrumentEditor.unitDragList.Focus()
}
for pe.CopyUnitBtn.Clickable.Clicked() {
op.InvalidateOp{}.Add(gtx.Ops)
contents, err := yaml.Marshal([]sointu.Unit{t.Unit()})
if err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Unit copied to clipboard", Notify, time.Second*3)
}
}
copyUnitBtnStyle := IconButton(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, true, "Copy unit (Ctrl+C)")
deleteUnitBtnStyle := IconButton(t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, t.CanDeleteUnit(), "Delete unit (Del)")
text := t.Unit().Type
if text == "" {
text = "Choose unit type"
} else {
text = strings.Title(text)
}
hintText := Label(text, white, t.TextShaper)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(deleteUnitBtnStyle.Layout),
layout.Rigid(copyUnitBtnStyle.Layout),
layout.Rigid(func(gtx C) D {
var dims D
if t.Unit().Type != "" {
clearUnitBtnStyle := IconButton(t.Theme, pe.ClearUnitBtn, icons.ContentClear, true, "Clear unit")
dims = clearUnitBtnStyle.Layout(gtx)
}
return D{Size: image.Pt(gtx.Dp(unit.Dp(48)), dims.Size.Y)}
}),
layout.Flexed(1, hintText),
)
}
}
func (pe *ParamEditor) layoutUnitTypeChooser(gtx C, t *Tracker) D {
listElem := func(gtx C, i int) D {
for pe.ChooseUnitTypeBtns[i].Clicked() {
t.SetUnitType(sointu.UnitNames[i])
t.InstrumentEditor.unitTypeEditor.SetText(sointu.UnitNames[i])
}
text := sointu.UnitNames[i]
if t.InstrumentEditor.unitTypeEditor.Focused() && !strings.HasPrefix(text, t.InstrumentEditor.unitTypeEditor.Text()) {
return D{}
}
labelStyle := LabelStyle{Text: text, ShadeColor: black, Color: white, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.TextShaper}
bg := func(gtx C) D {
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, 20))
var color color.NRGBA
if pe.ChooseUnitTypeBtns[i].Hovered() {
color = unitTypeListHighlightColor
}
paint.FillShape(gtx.Ops, color, clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
return D{Size: gtx.Constraints.Min}
}
leftMargin := layout.Inset{Left: unit.Dp(10)}
return layout.Stack{Alignment: layout.W}.Layout(gtx,
layout.Stacked(bg),
layout.Expanded(func(gtx C) D {
return pe.ChooseUnitTypeBtns[i].Layout(gtx, func(gtx C) D {
return leftMargin.Layout(gtx, labelStyle.Layout)
})
}))
}
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
return pe.list.Layout(gtx, len(sointu.UnitNames), listElem)
}),
layout.Expanded(func(gtx C) D {
return pe.scrollBar.Layout(gtx, unit.Dp(10), len(sointu.UnitNames), &pe.list.Position)
}),
)
}

View File

@ -1,218 +0,0 @@
package gioui
import (
"fmt"
"image"
"math"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type ParameterWidget struct {
floatWidget widget.Float
boolWidget widget.Bool
labelBtn widget.Clickable
instrBtn widget.Clickable
instrMenu Menu
unitBtn widget.Clickable
unitMenu Menu
}
type ParameterStyle struct {
tracker *Tracker
Parameter *tracker.Parameter
ParameterWidget *ParameterWidget
Theme *material.Theme
Focus bool
}
func (t *Tracker) ParamStyle(th *material.Theme, param *tracker.Parameter, paramWidget *ParameterWidget) ParameterStyle {
return ParameterStyle{
tracker: t, // TODO: we need this to pull the instrument names for ID style parameters, find out another way
Parameter: param,
Theme: th,
ParameterWidget: paramWidget,
}
}
func (p *ParameterWidget) Clicked() bool {
return p.labelBtn.Clicked()
}
func (p ParameterStyle) Layout(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return p.ParameterWidget.labelBtn.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(110))
return layout.E.Layout(gtx, Label(p.Parameter.Name, white, p.tracker.TextShaper))
})
}),
layout.Rigid(func(gtx C) D {
switch p.Parameter.Type {
case tracker.IntegerParameter:
for _, e := range gtx.Events(&p.ParameterWidget.floatWidget) {
switch ev := e.(type) {
case pointer.Event:
if ev.Type == pointer.Scroll {
delta := math.Min(math.Max(float64(ev.Scroll.Y), -1), 1)
p.Parameter.Value += int(math.Round(delta))
}
}
}
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
if p.Focus {
paint.FillShape(gtx.Ops, cursorColor, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
}
if !p.ParameterWidget.floatWidget.Dragging() {
p.ParameterWidget.floatWidget.Value = float32(p.Parameter.Value)
}
sliderStyle := material.Slider(p.Theme, &p.ParameterWidget.floatWidget, float32(p.Parameter.Min), float32(p.Parameter.Max))
sliderStyle.Color = p.Theme.Fg
r := image.Rectangle{Max: gtx.Constraints.Min}
area := clip.Rect(r).Push(gtx.Ops)
pointer.InputOp{Tag: &p.ParameterWidget.floatWidget, Types: pointer.Scroll, ScrollBounds: image.Rectangle{Min: image.Pt(0, -1e6), Max: image.Pt(0, 1e6)}}.Add(gtx.Ops)
dims := sliderStyle.Layout(gtx)
area.Pop()
p.Parameter.Value = int(p.ParameterWidget.floatWidget.Value + 0.5)
return dims
case tracker.BoolParameter:
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(60))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
if p.Focus {
paint.FillShape(gtx.Ops, cursorColor, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
}
p.ParameterWidget.boolWidget.Value = p.Parameter.Value > p.Parameter.Min
boolStyle := material.Switch(p.Theme, &p.ParameterWidget.boolWidget, "Toggle boolean parameter")
boolStyle.Color.Disabled = p.Theme.Fg
boolStyle.Color.Enabled = white
dims := layout.Center.Layout(gtx, boolStyle.Layout)
if p.ParameterWidget.boolWidget.Value {
p.Parameter.Value = p.Parameter.Max
} else {
p.Parameter.Value = p.Parameter.Min
}
return dims
case tracker.IDParameter:
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
if p.Focus {
paint.FillShape(gtx.Ops, cursorColor, clip.Rect{
Max: gtx.Constraints.Min,
}.Op())
}
for clickedItem, hasClicked := p.ParameterWidget.instrMenu.Clicked(); hasClicked; {
p.Parameter.Value = p.tracker.Song().Patch[clickedItem].Units[0].ID
clickedItem, hasClicked = p.ParameterWidget.instrMenu.Clicked()
}
instrItems := make([]MenuItem, len(p.tracker.Song().Patch))
for i, instr := range p.tracker.Song().Patch {
instrItems[i].Text = instr.Name
instrItems[i].IconBytes = icons.NavigationChevronRight
}
var unitItems []MenuItem
instrName := "<instr>"
unitName := "<unit>"
targetI, targetU, err := p.tracker.Song().Patch.FindUnit(p.Parameter.Value)
if err == nil {
targetInstrument := p.tracker.Song().Patch[targetI]
instrName = targetInstrument.Name
units := targetInstrument.Units
unitName = fmt.Sprintf("%v: %v", targetU, units[targetU].Type)
unitItems = make([]MenuItem, len(units))
for clickedItem, hasClicked := p.ParameterWidget.unitMenu.Clicked(); hasClicked; {
p.Parameter.Value = units[clickedItem].ID
clickedItem, hasClicked = p.ParameterWidget.unitMenu.Clicked()
}
for j, unit := range units {
unitItems[j].Text = fmt.Sprintf("%v: %v", j, unit.Type)
unitItems[j].IconBytes = icons.NavigationChevronRight
}
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(p.tracker.layoutMenu(instrName, &p.ParameterWidget.instrBtn, &p.ParameterWidget.instrMenu, unit.Dp(200),
instrItems...,
)),
layout.Rigid(p.tracker.layoutMenu(unitName, &p.ParameterWidget.unitBtn, &p.ParameterWidget.unitMenu, unit.Dp(200),
unitItems...,
)),
)
}
return D{}
}),
layout.Rigid(func(gtx C) D {
if p.Parameter.Type != tracker.IDParameter {
return Label(p.Parameter.Hint, white, p.tracker.TextShaper)(gtx)
}
return D{}
}),
)
}
/*
func (t *Tracker) layoutParameter(gtx C, index int) D {
u := t.Unit()
ut, _ := sointu.UnitTypes[u.Type]
params := u.Parameters
var name string
var value, min, max int
var valueText string
if u.Type == "oscillator" && index == len(ut) {
name = "sample"
key := compiler.SampleOffset{Start: uint32(params["samplestart"]), LoopStart: uint16(params["loopstart"]), LoopLength: uint16(params["looplength"])}
if v, ok := tracker.GmDlsEntryMap[key]; ok {
value = v + 1
valueText = fmt.Sprintf("%v / %v", value, tracker.GmDlsEntries[v].Name)
} else {
value = 0
valueText = "0 / custom"
}
min, max = 0, len(tracker.GmDlsEntries)
} else {
if ut[index].MaxValue < ut[index].MinValue {
return layout.Dimensions{}
}
name = ut[index].Name
if u.Type == "oscillator" && (name == "samplestart" || name == "loopstart" || name == "looplength") {
if params["type"] != sointu.Sample {
return layout.Dimensions{}
}
}
value = params[name]
min, max = ut[index].MinValue, ut[index].MaxValue
if u.Type == "send" && name == "voice" {
max = t.Song().Patch.NumVoices()
} else if u.Type == "send" && name == "unit" { // set the maximum values depending on the send target
instrIndex, _, _ := t.Song().Patch.FindSendTarget(t.Unit().Parameters["target"])
if instrIndex != -1 {
max = len(t.Song().Patch[instrIndex].Units) - 1
}
} else if u.Type == "send" && name == "port" { // set the maximum values depending on the send target
instrIndex, unitIndex, _ := t.Song().Patch.FindSendTarget(t.Unit().Parameters["target"])
if instrIndex != -1 && unitIndex != -1 {
max = len(sointu.Ports[t.Song().Patch[instrIndex].Units[unitIndex].Type]) - 1
}
}
hint := t.Song().Patch.ParamHintString(t.InstrIndex(), t.UnitIndex(), name)
if hint != "" {
valueText = fmt.Sprintf("%v / %v", value, hint)
} else {
valueText = fmt.Sprintf("%v", value)
}
}
}*/

View File

@ -4,6 +4,7 @@ import (
"image"
"image/color"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -44,13 +45,19 @@ func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
return D{}
}
for _, ev := range gtx.Events(s.Visible) {
e, ok := ev.(pointer.Event)
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.Type {
switch e.Kind {
case pointer.Press:
*s.Visible = false
}
@ -70,16 +77,10 @@ func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
paint.FillShape(gtx.Ops, s.ShadowColor, rrect2.Op(gtx.Ops))
paint.FillShape(gtx.Ops, s.SurfaceColor, rrect.Op(gtx.Ops))
area := clip.Rect(image.Rect(-1e6, -1e6, 1e6, 1e6)).Push(gtx.Ops)
pointer.InputOp{Tag: s.Visible,
Types: pointer.Press,
Grab: true,
}.Add(gtx.Ops)
event.Op(gtx.Ops, s.Visible)
area.Pop()
area = clip.Rect(rrect2.Rect).Push(gtx.Ops)
pointer.InputOp{Tag: &dummyTag,
Types: pointer.Press,
Grab: true,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &dummyTag)
area.Pop()
return D{Size: gtx.Constraints.Min}
}

View File

@ -0,0 +1,81 @@
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

@ -1,70 +0,0 @@
package gioui
import (
"fmt"
"image"
"strings"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/widget"
)
const rowMarkerWidth = 50
func (t *Tracker) layoutRowMarkers(gtx C) D {
gtx.Constraints.Min.X = rowMarkerWidth
paint.FillShape(gtx.Ops, rowMarkerSurfaceColor, clip.Rect{
Max: gtx.Constraints.Max,
}.Op())
//defer op.Save(gtx.Ops).Load()
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
op.Offset(image.Pt(0, (gtx.Constraints.Max.Y-trackRowHeight)/2)).Add(gtx.Ops)
cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row
playPos := t.PlayPosition()
playSongRow := playPos.Pattern*t.Song().Score.RowsPerPattern + playPos.Row
op.Offset(image.Pt(0, (-1*trackRowHeight)*(cursorSongRow))).Add(gtx.Ops)
beatMarkerDensity := t.Song().RowsPerBeat
for beatMarkerDensity <= 2 {
beatMarkerDensity *= 2
}
for i := 0; i < t.Song().Score.Length; i++ {
for j := 0; j < t.Song().Score.RowsPerPattern; j++ {
songRow := i*t.Song().Score.RowsPerPattern + j
if mod(songRow, beatMarkerDensity*2) == 0 {
paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
} else if mod(songRow, beatMarkerDensity) == 0 {
paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
}
if t.Playing() && songRow == playSongRow {
paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, trackRowHeight)}.Op())
}
if j == 0 {
paint.ColorOp{Color: rowMarkerPatternTextColor}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", i)), op.CallOp{})
}
if t.TrackEditor.Focused() && songRow == cursorSongRow {
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
} else {
paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops)
}
op.Offset(image.Pt(rowMarkerWidth/2, 0)).Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{})
op.Offset(image.Pt(-rowMarkerWidth/2, trackRowHeight)).Add(gtx.Ops)
}
}
return layout.Dimensions{Size: image.Pt(rowMarkerWidth, gtx.Constraints.Max.Y)}
}
func mod(a, b int) int {
m := a % b
if a < 0 && b < 0 {
m -= b
}
if a < 0 && b > 0 {
m += b
}
return m
}

View File

@ -0,0 +1,296 @@
package gioui
import (
"bytes"
"image"
"io"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/transfer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/unit"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
)
type ScrollTable struct {
ColTitleList *DragList
RowTitleList *DragList
Table tracker.Table
focused bool
requestFocus bool
cursorMoved bool
}
type ScrollTableStyle struct {
RowTitleStyle FilledDragListStyle
ColTitleStyle FilledDragListStyle
ScrollTable *ScrollTable
ScrollBarWidth unit.Dp
RowTitleWidth unit.Dp
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{
Table: table,
ColTitleList: NewDragList(vertList, layout.Horizontal),
RowTitleList: NewDragList(horizList, layout.Vertical),
}
}
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 {
return ScrollTableStyle{
RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList, rowTitle, rowTitleBg),
ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList, colTitle, colTitleBg),
ScrollTable: scrollTable,
element: element,
ScrollBarWidth: unit.Dp(14),
RowTitleWidth: unit.Dp(30),
ColumnTitleHeight: unit.Dp(16),
CellWidth: unit.Dp(16),
CellHeight: unit.Dp(16),
}
}
func (st *ScrollTable) CursorMoved() bool {
ret := st.cursorMoved
st.cursorMoved = false
return ret
}
func (st *ScrollTable) Focus() {
st.requestFocus = true
}
func (st *ScrollTable) Focused() bool {
return st.focused
}
func (st *ScrollTable) EnsureCursorVisible() {
st.ColTitleList.EnsureVisible(st.Table.Cursor().X)
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 {
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)
s.RowTitleStyle.LayoutScrollBar(gtx)
s.ColTitleStyle.LayoutScrollBar(gtx)
return D{Size: dims}
})
}
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: "-"},
)
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
}
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)
}
case transfer.DataEvent:
if b, err := io.ReadAll(e.Open()); err == nil {
s.ScrollTable.Table.Paste(b)
}
}
}
for {
e, ok := gtx.Event(
key.Filter{Focus: s.ScrollTable.RowTitleList, Name: "→"},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok && e.State == key.Press {
s.ScrollTable.Focus()
}
}
for {
e, ok := gtx.Event(
key.Filter{Focus: s.ScrollTable.ColTitleList, Name: "↓"},
)
if !ok {
break
}
if e, ok := e.(key.Event); ok && e.State == key.Press {
s.ScrollTable.Focus()
}
}
}
func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) {
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
if s.ScrollTable.requestFocus {
s.ScrollTable.requestFocus = false
gtx.Execute(key.FocusCmd{Tag: s.ScrollTable})
}
cellWidth := gtx.Dp(s.CellWidth)
cellHeight := gtx.Dp(s.CellHeight)
gtx.Constraints = layout.Exact(image.Pt(cellWidth, cellHeight))
colP := s.ColTitleStyle.dragList.List.Position
rowP := s.RowTitleStyle.dragList.List.Position
defer op.Offset(image.Pt(-colP.Offset, -rowP.Offset)).Push(gtx.Ops).Pop()
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)
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) 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) {
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)
} 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 {
s.ColTitleList.Focus()
}
case key.NameDownArrow:
s.Table.MoveCursor(0, stepY)
case key.NameLeftArrow:
if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 {
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))
case key.NamePageDown:
s.Table.MoveCursor(0, intMax(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
}
if !e.Modifiers.Contain(key.ModShift) {
s.Table.SetCursor2(s.Table.Cursor())
}
s.ColTitleList.EnsureVisible(s.Table.Cursor().X)
s.RowTitleList.EnsureVisible(s.Table.Cursor().Y)
s.cursorMoved = true
}

View File

@ -4,6 +4,7 @@ import (
"image"
"gioui.org/f32"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -77,19 +78,22 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
rect := image.Rect(0, gtx.Constraints.Min.Y-scrWidth, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area = clip.Rect(rect).Push(gtx.Ops)
}
pointer.InputOp{Tag: &s.dragStart,
Types: pointer.Drag | pointer.Press | pointer.Cancel | pointer.Release,
Grab: s.dragging,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &s.dragStart)
area.Pop()
stack.Pop()
for _, ev := range gtx.Events(&s.dragStart) {
for {
ev, ok := gtx.Event(
pointer.Filter{Target: &s.dragStart, Kinds: pointer.Press | pointer.Cancel | pointer.Release | pointer.Drag},
)
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Press:
if s.Axis == layout.Horizontal {
s.dragStart = e.Position.X
@ -114,17 +118,22 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
rect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
area2 := clip.Rect(rect).Push(gtx.Ops)
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
pointer.InputOp{Tag: &s.tag,
Types: pointer.Enter | pointer.Leave,
}.Add(gtx.Ops)
event.Op(gtx.Ops, &s.tag)
area2.Pop()
for _, ev := range gtx.Events(&s.tag) {
for {
ev, ok := gtx.Event(pointer.Filter{
Target: &s.tag,
Kinds: pointer.Enter | pointer.Leave,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Enter:
s.hovering = true
case pointer.Leave:

View File

@ -2,222 +2,184 @@ package gioui
import (
"image"
"math"
"time"
"gioui.org/io/clipboard"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"gopkg.in/yaml.v3"
)
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+"
var fileMenuItems []MenuItem = []MenuItem{
{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N"},
{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O"},
{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S"},
{IconBytes: icons.ContentSave, Text: "Save Song As..."},
{IconBytes: icons.ImageAudiotrack, Text: "Export Wav..."},
}
func init() {
if CAN_QUIT {
fileMenuItems = append(fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit"})
}
}
func (t *Tracker) layoutSongPanel(gtx C) D {
func (s *SongPanel) Layout(gtx C, t *Tracker) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(t.layoutMenuBar),
layout.Rigid(t.layoutSongOptions),
layout.Rigid(func(gtx C) D {
return s.layoutMenuBar(gtx, t)
}),
layout.Rigid(func(gtx C) D {
return s.layoutSongOptions(gtx, t)
}),
)
}
func (t *Tracker) layoutMenu(title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget {
for clickable.Clicked() {
menu.Visible = true
}
m := t.PopupMenu(menu)
return func(gtx C) D {
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
titleBtn := material.Button(t.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(1000))
m.Layout(gtx, items...)
return dims
}
}
func (t *Tracker) layoutMenuBar(gtx C) D {
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))
for clickedItem, hasClicked := t.Menus[0].Clicked(); hasClicked; {
switch clickedItem {
case 0:
t.NewSong(false)
case 1:
t.OpenSongFile(false)
case 2:
t.SaveSongFile()
case 3:
t.SaveSongAsFile()
case 4:
t.WaveTypeDialog.Visible = true
case 5:
t.Quit(false)
}
clickedItem, hasClicked = t.Menus[0].Clicked()
}
for clickedItem, hasClicked := t.Menus[1].Clicked(); hasClicked; {
switch clickedItem {
case 0:
t.Undo()
case 1:
t.Redo()
case 2:
if contents, err := yaml.Marshal(t.Song()); err == nil {
clipboard.WriteOp{Text: string(contents)}.Add(gtx.Ops)
t.Alert.Update("Song copied to clipboard", Notify, time.Second*3)
}
case 3:
clipboard.ReadOp{Tag: t}.Add(gtx.Ops)
case 4:
t.RemoveUnusedData()
}
clickedItem, hasClicked = t.Menus[1].Clicked()
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(t.layoutMenu("File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200),
fileMenuItems...,
)),
layout.Rigid(t.layoutMenu("Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200),
MenuItem{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Disabled: !t.CanUndo()},
MenuItem{IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Disabled: !t.CanRedo()},
MenuItem{IconBytes: icons.ContentContentCopy, Text: "Copy", ShortcutText: shortcutKey + "C"},
MenuItem{IconBytes: icons.ContentContentPaste, Text: "Paste", ShortcutText: shortcutKey + "V"},
MenuItem{IconBytes: icons.ImageCrop, Text: "Remove unused data"},
)),
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 *Tracker) layoutSongOptions(gtx C) D {
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))
var panicBtnStyle material.ButtonStyle
if !t.Panic() {
panicBtnStyle = LowEmphasisButton(t.Theme, t.PanicBtn, "Panic")
} else {
panicBtnStyle = HighEmphasisButton(t.Theme, t.PanicBtn, "Panic")
}
for t.PanicBtn.Clicked() {
t.SetPanic(!t.Panic())
}
var recordBtnStyle material.ButtonStyle
if !t.Recording() {
recordBtnStyle = LowEmphasisButton(t.Theme, t.RecordBtn, "Record")
} else {
recordBtnStyle = HighEmphasisButton(t.Theme, t.RecordBtn, "Record")
}
for t.RecordBtn.Clicked() {
t.SetRecording(!t.Recording())
}
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, t.TextShaper)),
layout.Rigid(Label("LEN:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.SongLength.Value = t.Song().Score.Length
numStyle := NumericUpDown(t.Theme, t.SongLength, 1, math.MaxInt32, "Song length")
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)
t.SetSongLength(t.SongLength.Value)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("BPM:", white, t.TextShaper)),
layout.Rigid(Label("BPM:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.BPM.Value = t.Song().BPM
numStyle := NumericUpDown(t.Theme, t.BPM, 1, 999, "Beats per minute")
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)
t.SetBPM(t.BPM.Value)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("RPP:", white, t.TextShaper)),
layout.Rigid(Label("RPP:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.RowsPerPattern.Value = t.Song().Score.RowsPerPattern
numStyle := NumericUpDown(t.Theme, t.RowsPerPattern, 1, 255, "Rows per pattern")
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)
t.SetRowsPerPattern(t.RowsPerPattern.Value)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("RPB:", white, t.TextShaper)),
layout.Rigid(Label("RPB:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
t.RowsPerBeat.Value = t.Song().RowsPerBeat
numStyle := NumericUpDown(t.Theme, t.RowsPerBeat, 1, 32, "Rows per beat")
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)
t.SetRowsPerBeat(t.RowsPerBeat.Value)
return dims
}),
)
}),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(Label("STP:", white, t.TextShaper)),
layout.Rigid(Label("STP:", white, tr.Theme.Shaper)),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
numStyle := NumericUpDown(t.Theme, t.Step, 0, 8, "Cursor step")
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 {
gtx.Constraints.Min = image.Pt(0, 0)
return panicBtnStyle.Layout(gtx)
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(func(gtx C) D {
gtx.Constraints.Min = image.Pt(0, 0)
return recordBtnStyle.Layout(gtx)
}),
layout.Rigid(VuMeter{AverageVolume: t.lastAvgVolume, PeakVolume: t.lastPeakVolume, Range: 100}.Layout),
layout.Rigid(panicBtnStyle.Layout),
)
}

View File

@ -3,6 +3,7 @@ package gioui
import (
"image"
"gioui.org/io/event"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
@ -48,14 +49,21 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
{ // handle input
// Avoid affecting the input tree with pointer events.
for _, ev := range gtx.Events(s) {
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.Type {
switch e.Kind {
case pointer.Press:
if s.drag {
break
@ -123,10 +131,7 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
barRect = image.Rect(0, firstSize, gtx.Constraints.Max.X, secondOffset)
}
area := clip.Rect(barRect).Push(gtx.Ops)
pointer.InputOp{Tag: s,
Types: pointer.Press | pointer.Drag | pointer.Release,
Grab: s.drag,
}.Add(gtx.Ops)
event.Op(gtx.Ops, s)
area.Pop()
}

View File

@ -4,6 +4,7 @@ import (
"image/color"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
@ -39,10 +40,13 @@ func (s Surface) Layout(gtx C, widget layout.Widget) D {
return s.Inset.Layout(gtx, widget)
}
if s.FitSize {
return layout.Stack{}.Layout(gtx,
layout.Expanded(bg),
layout.Stacked(fg),
)
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

View File

@ -44,6 +44,7 @@ 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}
@ -63,7 +64,7 @@ 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: 8}
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}

View File

@ -1,500 +0,0 @@
package gioui
import (
"fmt"
"image"
"strconv"
"strings"
"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/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
)
const trackRowHeight = 16
const trackColWidth = 54
const patmarkWidth = 16
type TrackEditor struct {
TrackVoices *NumberInput
NewTrackBtn *TipClickable
DeleteTrackBtn *TipClickable
AddSemitoneBtn *widget.Clickable
SubtractSemitoneBtn *widget.Clickable
AddOctaveBtn *widget.Clickable
SubtractOctaveBtn *widget.Clickable
NoteOffBtn *widget.Clickable
trackPointerTag bool
trackJumpPointerTag bool
tag bool
focused bool
requestFocus bool
}
func NewTrackEditor() *TrackEditor {
return &TrackEditor{
TrackVoices: new(NumberInput),
NewTrackBtn: new(TipClickable),
DeleteTrackBtn: new(TipClickable),
AddSemitoneBtn: new(widget.Clickable),
SubtractSemitoneBtn: new(widget.Clickable),
AddOctaveBtn: new(widget.Clickable),
SubtractOctaveBtn: new(widget.Clickable),
NoteOffBtn: new(widget.Clickable),
}
}
func (te *TrackEditor) Focus() {
te.requestFocus = true
}
func (te *TrackEditor) Focused() bool {
return te.focused || te.ChildFocused()
}
func (te *TrackEditor) ChildFocused() bool {
return te.AddOctaveBtn.Focused() || te.AddSemitoneBtn.Focused() || te.DeleteTrackBtn.Clickable.Focused() || te.NewTrackBtn.Clickable.Focused() || te.NoteOffBtn.Focused() || te.SubtractOctaveBtn.Focused() || te.SubtractSemitoneBtn.Focused() || te.SubtractSemitoneBtn.Focused() || te.SubtractSemitoneBtn.Focused()
}
var trackerEditorKeys key.Set = "+|-|←|→|↑|↓|Ctrl-←|Ctrl-→|Ctrl-↑|Ctrl-↓|Shift-←|Shift-→|Shift-↑|Shift-↓|⏎|⇱|⇲|⌫|⌦|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z|0|1|2|3|4|5|6|7|8|9|,|."
func (te *TrackEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
for _, e := range gtx.Events(te) {
switch e := e.(type) {
case key.FocusEvent:
te.focused = e.Focus
case pointer.Event:
if e.Type == pointer.Press {
key.FocusOp{Tag: te}.Add(gtx.Ops)
}
case key.Event:
switch e.State {
case key.Press:
switch e.Name {
case key.NameDeleteForward, key.NameDeleteBackward:
t.DeleteSelection()
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
case key.NameUpArrow, key.NameDownArrow:
sign := -1
if e.Name == key.NameDownArrow {
sign = 1
}
cursor := t.Cursor()
if e.Modifiers.Contain(key.ModShortcut) {
cursor.Row += t.Song().Score.RowsPerPattern * sign
} else {
if t.Step.Value > 0 {
cursor.Row += t.Step.Value * sign
} else {
cursor.Row += sign
}
}
t.SetNoteTracking(false)
t.SetCursor(cursor)
if !e.Modifiers.Contain(key.ModShift) {
t.SetSelectionCorner(t.Cursor())
}
//scrollToView(t.PatternOrderList, t.Cursor().Pattern, t.Song().Score.Length)
case key.NameLeftArrow:
cursor := t.Cursor()
if !t.LowNibble() || !t.Song().Score.Tracks[t.Cursor().Track].Effect || e.Modifiers.Contain(key.ModShortcut) {
cursor.Track--
t.SetLowNibble(true)
} else {
t.SetLowNibble(false)
}
t.SetCursor(cursor)
if !e.Modifiers.Contain(key.ModShift) {
t.SetSelectionCorner(t.Cursor())
}
case key.NameRightArrow:
if t.LowNibble() || !t.Song().Score.Tracks[t.Cursor().Track].Effect || e.Modifiers.Contain(key.ModShortcut) {
cursor := t.Cursor()
cursor.Track++
t.SetCursor(cursor)
t.SetLowNibble(false)
} else {
t.SetLowNibble(true)
}
if !e.Modifiers.Contain(key.ModShift) {
t.SetSelectionCorner(t.Cursor())
}
case "+":
if e.Modifiers.Contain(key.ModShortcut) {
t.AdjustSelectionPitch(12)
} else {
t.AdjustSelectionPitch(1)
}
case "-":
if e.Modifiers.Contain(key.ModShortcut) {
t.AdjustSelectionPitch(-12)
} else {
t.AdjustSelectionPitch(-1)
}
}
if e.Modifiers.Contain(key.ModShortcut) {
continue
}
step := false
if t.Song().Score.Tracks[t.Cursor().Track].Effect {
if iv, err := strconv.ParseInt(e.Name, 16, 8); err == nil {
t.NumberPressed(byte(iv))
step = true
}
} else {
if e.Name == "A" || e.Name == "1" {
t.SetNote(0)
step = true
} else {
if val, ok := noteMap[e.Name]; ok {
if _, ok := t.KeyPlaying[e.Name]; !ok {
n := noteAsValue(t.OctaveNumberInput.Value, val)
t.SetNote(n)
step = true
trk := t.Cursor().Track
noteID := tracker.NoteIDTrack(trk, n)
t.NoteOn(noteID)
t.KeyPlaying[e.Name] = noteID
}
}
}
}
if step && !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
case key.Release:
if noteID, ok := t.KeyPlaying[e.Name]; ok {
t.NoteOff(noteID)
delete(t.KeyPlaying, e.Name)
}
}
}
}
if te.requestFocus || te.ChildFocused() {
te.requestFocus = false
key.FocusOp{Tag: te}.Add(gtx.Ops)
}
rowMarkers := layout.Rigid(t.layoutRowMarkers)
for te.NewTrackBtn.Clickable.Clicked() {
t.AddTrack(true)
}
for te.DeleteTrackBtn.Clickable.Clicked() {
t.DeleteTrack(false)
}
//t.TrackHexCheckBoxes[i2].Value = t.TrackShowHex[i2]
//cbStyle := material.CheckBox(t.Theme, t.TrackHexCheckBoxes[i2], "hex")
//cbStyle.Color = white
//cbStyle.IconColor = t.Theme.Fg
for te.AddSemitoneBtn.Clicked() {
t.AdjustSelectionPitch(1)
}
for te.SubtractSemitoneBtn.Clicked() {
t.AdjustSelectionPitch(-1)
}
for te.NoteOffBtn.Clicked() {
t.SetNote(0)
if !(t.NoteTracking() && t.Playing()) && t.Step.Value > 0 {
t.SetCursor(t.Cursor().AddRows(t.Step.Value))
t.SetSelectionCorner(t.Cursor())
}
}
for te.AddOctaveBtn.Clicked() {
t.AdjustSelectionPitch(12)
}
for te.SubtractOctaveBtn.Clicked() {
t.AdjustSelectionPitch(-12)
}
menu := func(gtx C) D {
addSemitoneBtnStyle := LowEmphasisButton(t.Theme, te.AddSemitoneBtn, "+1")
subtractSemitoneBtnStyle := LowEmphasisButton(t.Theme, te.SubtractSemitoneBtn, "-1")
addOctaveBtnStyle := LowEmphasisButton(t.Theme, te.AddOctaveBtn, "+12")
subtractOctaveBtnStyle := LowEmphasisButton(t.Theme, te.SubtractOctaveBtn, "-12")
noteOffBtnStyle := LowEmphasisButton(t.Theme, te.NoteOffBtn, "Note Off")
deleteTrackBtnStyle := IconButton(t.Theme, te.DeleteTrackBtn, icons.ActionDelete, t.CanDeleteTrack(), "Delete track")
newTrackBtnStyle := IconButton(t.Theme, te.NewTrackBtn, icons.ContentAdd, t.CanAddTrack(), "Add track")
n := t.Song().Score.Tracks[t.Cursor().Track].NumVoices
te.TrackVoices.Value = n
in := layout.UniformInset(unit.Dp(1))
voiceUpDown := func(gtx C) D {
numStyle := NumericUpDown(t.Theme, te.TrackVoices, 1, t.MaxTrackVoices(), "Number of voices for this track")
return in.Layout(gtx, numStyle.Layout)
}
t.TrackHexCheckBox.Value = t.Song().Score.Tracks[t.Cursor().Track].Effect
hexCheckBoxStyle := material.CheckBox(t.Theme, t.TrackHexCheckBox, "Hex")
dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(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(hexCheckBoxStyle.Layout),
layout.Rigid(Label(" Voices:", white, t.TextShaper)),
layout.Rigid(voiceUpDown),
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
layout.Rigid(deleteTrackBtnStyle.Layout),
layout.Rigid(newTrackBtnStyle.Layout))
t.Song().Score.Tracks[t.Cursor().Track].Effect = t.TrackHexCheckBox.Value // TODO: we should not modify the model, but how should this be done
t.SetTrackVoices(te.TrackVoices.Value)
return dims
}
rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: te,
Types: pointer.Press,
}.Add(gtx.Ops)
key.InputOp{Tag: te, Keys: trackerEditorKeys}.Add(gtx.Ops)
dims := Surface{Gray: 24, Focus: te.Focused()}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return Surface{Gray: 37, Focus: te.Focused(), FitSize: true}.Layout(gtx, menu)
}),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
rowMarkers,
layout.Flexed(1, func(gtx C) D {
return te.layoutTracks(gtx, t)
}))
}),
)
})
area.Pop()
return dims
}
const baseNote = 24
var notes = []string{
"C-",
"C#",
"D-",
"D#",
"E-",
"F-",
"F#",
"G-",
"G#",
"A-",
"A#",
"B-",
}
func noteStr(val byte) string {
if val == 1 {
return "..." // hold
}
if val == 0 {
return "---" // release
}
oNote := mod(int(val-baseNote), 12)
octave := (int(val) - oNote - baseNote) / 12
if octave < 0 {
return fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave)))
}
if octave >= 10 {
return fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
}
return fmt.Sprintf("%s%d", notes[oNote], octave)
}
func noteAsValue(octave, note int) byte {
return byte(baseNote + (octave * 12) + note)
}
func (te *TrackEditor) 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()
cursorSongRow := t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row
for _, ev := range gtx.Events(&te.trackJumpPointerTag) {
e, ok := ev.(pointer.Event)
if !ok {
continue
}
if e.Type == pointer.Press {
te.Focus()
track := int(e.Position.X) / trackColWidth
row := int((e.Position.Y-float32(gtx.Constraints.Max.Y-trackRowHeight)/2)/trackRowHeight + float32(cursorSongRow))
cursor := tracker.ScorePoint{Track: track, ScoreRow: tracker.ScoreRow{Row: row}}.Clamp(t.Song().Score)
t.SetCursor(cursor)
t.SetSelectionCorner(cursor)
cursorSongRow = cursor.Pattern*t.Song().Score.RowsPerPattern + cursor.Row
}
}
rect := image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)
area := clip.Rect(rect).Push(gtx.Ops)
pointer.InputOp{Tag: &te.trackJumpPointerTag,
Types: pointer.Press,
}.Add(gtx.Ops)
area.Pop()
stack := op.Offset(image.Point{}).Push(gtx.Ops)
curVoice := 0
for _, trk := range t.Song().Score.Tracks {
gtx := gtx
instrName := "?"
firstIndex, err := t.Song().Patch.InstrumentForVoice(curVoice)
lastIndex, err2 := t.Song().Patch.InstrumentForVoice(curVoice + trk.NumVoices - 1)
if err == nil && err2 == nil {
switch diff := lastIndex - firstIndex; diff {
case 0:
instrName = t.Song().Patch[firstIndex].Name
default:
n1 := t.Song().Patch[firstIndex].Name
n2 := t.Song().Patch[firstIndex+1].Name
if len(n1) > 0 {
n1 = string(n1[0])
} else {
n1 = "?"
}
if len(n2) > 0 {
n2 = string(n2[0])
} else {
n2 = "?"
}
if diff > 1 {
instrName = n1 + "/" + n2 + "..."
} else {
instrName = n1 + "/" + n2
}
}
if len(instrName) > 7 {
instrName = instrName[:7]
}
}
gtx.Constraints.Max.X = trackColWidth
LabelStyle{Alignment: layout.N, Text: instrName, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.TextShaper}.Layout(gtx)
op.Offset(image.Point{trackColWidth, 0}).Add(gtx.Ops)
curVoice += trk.NumVoices
}
stack.Pop()
op.Offset(image.Point{0, (gtx.Constraints.Max.Y - trackRowHeight) / 2}).Add(gtx.Ops)
op.Offset(image.Point{0, int((-1 * trackRowHeight) * (cursorSongRow))}).Add(gtx.Ops)
if te.Focused() || t.OrderEditor.Focused() {
x1, y1 := t.Cursor().Track, t.Cursor().Pattern
x2, y2 := t.SelectionCorner().Track, t.SelectionCorner().Pattern
if x1 > x2 {
x1, x2 = x2, x1
}
if y1 > y2 {
y1, y2 = y2, y1
}
x2++
y2++
x1 *= trackColWidth
y1 *= trackRowHeight * t.Song().Score.RowsPerPattern
x2 *= trackColWidth
y2 *= trackRowHeight * t.Song().Score.RowsPerPattern
paint.FillShape(gtx.Ops, inactiveSelectionColor, clip.Rect{Min: image.Pt(x1, y1), Max: image.Pt(x2, y2)}.Op())
}
if te.Focused() {
x1, y1 := t.Cursor().Track, t.Cursor().Pattern*t.Song().Score.RowsPerPattern+t.Cursor().Row
x2, y2 := t.SelectionCorner().Track, t.SelectionCorner().Pattern*t.Song().Score.RowsPerPattern+t.SelectionCorner().Row
if x1 > x2 {
x1, x2 = x2, x1
}
if y1 > y2 {
y1, y2 = y2, y1
}
x2++
y2++
x1 *= trackColWidth
y1 *= trackRowHeight
x2 *= trackColWidth
y2 *= trackRowHeight
paint.FillShape(gtx.Ops, selectionColor, clip.Rect{Min: image.Pt(x1, y1), Max: image.Pt(x2, y2)}.Op())
cx := t.Cursor().Track * trackColWidth
cy := (t.Cursor().Pattern*t.Song().Score.RowsPerPattern + t.Cursor().Row) * trackRowHeight
cw := trackColWidth
if t.Song().Score.Tracks[t.Cursor().Track].Effect {
cw /= 2
if t.LowNibble() {
cx += cw
}
}
paint.FillShape(gtx.Ops, cursorColor, clip.Rect{Min: image.Pt(cx, cy), Max: image.Pt(cx+cw, cy+trackRowHeight)}.Op())
}
delta := (gtx.Constraints.Max.Y/2 + trackRowHeight - 1) / trackRowHeight
firstRow := cursorSongRow - delta
lastRow := cursorSongRow + delta
if firstRow < 0 {
firstRow = 0
}
if l := t.Song().Score.LengthInRows(); lastRow >= l {
lastRow = l - 1
}
op.Offset(image.Point{0, trackRowHeight * firstRow}).Add(gtx.Ops)
for trkIndex, trk := range t.Song().Score.Tracks {
stack := op.Offset(image.Point{}).Push(gtx.Ops)
for row := firstRow; row <= lastRow; row++ {
pat := row / t.Song().Score.RowsPerPattern
patRow := row % t.Song().Score.RowsPerPattern
s := trk.Order.Get(pat)
if s < 0 {
op.Offset(image.Point{0, trackRowHeight}).Add(gtx.Ops)
continue
}
if s >= 0 && patRow == 0 {
paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{})
}
if s >= 0 && patRow == 1 && t.IsPatternUnique(trkIndex, s) {
paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops)
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, "*", op.CallOp{})
}
op.Offset(image.Point{patmarkWidth, 0}).Add(gtx.Ops)
if te.Focused() && t.Cursor().Row == patRow && t.Cursor().Pattern == pat {
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
} else {
paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops)
}
var c byte = 1
if s >= 0 && s < len(trk.Patterns) {
c = trk.Patterns[s].Get(patRow)
}
if trk.Effect {
var text string
switch c {
case 0:
text = "--"
case 1:
text = ".."
default:
text = fmt.Sprintf("%02x", c)
}
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, strings.ToUpper(text), op.CallOp{})
} else {
widget.Label{}.Layout(gtx, t.TextShaper, trackerFont, trackerFontSize, noteStr(c), op.CallOp{})
}
op.Offset(image.Point{-patmarkWidth, trackRowHeight}).Add(gtx.Ops)
}
stack.Pop()
op.Offset(image.Point{trackColWidth, 0}).Add(gtx.Ops)
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}

View File

@ -1,24 +1,63 @@
package gioui
import (
"encoding/json"
"errors"
"fmt"
"image"
"io"
"path/filepath"
"sync"
"time"
"gioui.org/app"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/io/transfer"
"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/explorer"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/tracker"
"gopkg.in/yaml.v3"
)
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
SaveChangesDialog *Dialog
WaveTypeDialog *Dialog
ModalDialog layout.Widget
InstrumentEditor *InstrumentEditor
OrderEditor *OrderEditor
TrackEditor *NoteEditor
Explorer *explorer.Explorer
Exploring bool
SongPanel *SongPanel
filePathString tracker.String
quitWG sync.WaitGroup
execChan chan func()
*tracker.Model
}
C = layout.Context
D = layout.Dimensions
)
const (
@ -27,140 +66,34 @@ const (
ConfirmNew
)
type Tracker struct {
Theme *material.Theme
MenuBar []widget.Clickable
Menus []Menu
OctaveNumberInput *NumberInput
BPM *NumberInput
RowsPerPattern *NumberInput
RowsPerBeat *NumberInput
Step *NumberInput
InstrumentVoices *NumberInput
SongLength *NumberInput
PanicBtn *widget.Clickable
RecordBtn *widget.Clickable
AddUnitBtn *widget.Clickable
TrackHexCheckBox *widget.Bool
TopHorizontalSplit *Split
BottomHorizontalSplit *Split
VerticalSplit *Split
KeyPlaying map[string]tracker.NoteID
Alert Alert
ConfirmSongDialog *Dialog
WaveTypeDialog *Dialog
ConfirmSongActionType int
ModalDialog layout.Widget
InstrumentEditor *InstrumentEditor
OrderEditor *OrderEditor
TrackEditor *TrackEditor
Explorer *explorer.Explorer
TextShaper *text.Shaper
lastAvgVolume tracker.Volume
lastPeakVolume tracker.Volume
wavFilePath string
quitChannel chan struct{}
quitWG sync.WaitGroup
errorChannel chan error
quitted bool
unmarshalRecoveryChannel chan []byte
marshalRecoveryChannel chan (chan []byte)
synther sointu.Synther
*trackerModel
}
type trackerModel = tracker.Model
func (t *Tracker) UnmarshalContent(bytes []byte) error {
var units []sointu.Unit
if errJSON := json.Unmarshal(bytes, &units); errJSON == nil {
if len(units) == 0 {
return nil
}
t.PasteUnits(units)
// TODO: this is a bit hacky, but works for now. How to change the selection to the pasted units more elegantly?
t.InstrumentEditor.unitDragList.SelectedItem = t.UnitIndex()
t.InstrumentEditor.unitDragList.SelectedItem2 = t.UnitIndex() + len(units) - 1
return nil
}
if errYaml := yaml.Unmarshal(bytes, &units); errYaml == nil {
if len(units) == 0 {
return nil
}
t.PasteUnits(units)
t.InstrumentEditor.unitDragList.SelectedItem = t.UnitIndex()
t.InstrumentEditor.unitDragList.SelectedItem2 = t.UnitIndex() + len(units) - 1
return nil
}
var instr sointu.Instrument
if errJSON := json.Unmarshal(bytes, &instr); errJSON == nil {
if t.SetInstrument(instr) {
return nil
}
}
if errYaml := yaml.Unmarshal(bytes, &instr); errYaml == nil {
if t.SetInstrument(instr) {
return nil
}
}
var song sointu.Song
if errJSON := json.Unmarshal(bytes, &song); errJSON != nil {
if errYaml := yaml.Unmarshal(bytes, &song); errYaml != nil {
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml)
}
}
if song.BPM > 0 {
t.SetSong(song)
return nil
}
return errors.New("was able to unmarshal a song, but the bpm was 0")
}
func NewTracker(model *tracker.Model, synther sointu.Synther) *Tracker {
func NewTracker(model *tracker.Model) *Tracker {
t := &Tracker{
Theme: material.NewTheme(),
BPM: new(NumberInput),
OctaveNumberInput: &NumberInput{Value: 4},
SongLength: new(NumberInput),
RowsPerPattern: new(NumberInput),
RowsPerBeat: new(NumberInput),
Step: &NumberInput{Value: 1},
InstrumentVoices: new(NumberInput),
OctaveNumberInput: NewNumberInput(model.Octave().Int()),
InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
PanicBtn: new(widget.Clickable),
RecordBtn: new(widget.Clickable),
TrackHexCheckBox: new(widget.Bool),
Menus: make([]Menu, 2),
MenuBar: make([]widget.Clickable, 2),
quitChannel: make(chan struct{}, 1), // use non-blocking sends; no need to queue extra ticks if one is queued already
TopHorizontalSplit: &Split{Ratio: -.6},
TopHorizontalSplit: &Split{Ratio: -.5},
BottomHorizontalSplit: &Split{Ratio: -.6},
VerticalSplit: &Split{Axis: layout.Vertical},
KeyPlaying: make(map[string]tracker.NoteID),
ConfirmSongDialog: new(Dialog),
WaveTypeDialog: new(Dialog),
InstrumentEditor: NewInstrumentEditor(),
OrderEditor: NewOrderEditor(),
TrackEditor: NewTrackEditor(),
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),
errorChannel: make(chan error, 32),
synther: synther,
trackerModel: model,
Model: model,
marshalRecoveryChannel: make(chan (chan []byte)),
unmarshalRecoveryChannel: make(chan []byte),
filePathString: model.FilePath().String(),
execChan: make(chan func(), 1024),
}
t.TextShaper = text.NewShaper(text.WithCollection(fontCollection))
t.Alert.shaper = t.TextShaper
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.Focus()
t.TrackEditor.scrollTable.Focus()
t.quitWG.Add(1)
return t
}
@ -171,19 +104,18 @@ func (t *Tracker) Main() {
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
mainloop:
for {
if pos, playing := t.PlayPosition(), t.Playing(); t.NoteTracking() && playing {
cursor := t.Cursor()
cursor.ScoreRow = pos
t.SetCursor(cursor)
t.SetSelectionCorner(cursor)
}
if titleFooter != t.FilePath() {
titleFooter = t.FilePath()
if titleFooter != t.filePathString.Value() {
titleFooter = t.filePathString.Value()
if titleFooter != "" {
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
} else {
@ -191,72 +123,188 @@ mainloop:
}
}
select {
case <-t.quitChannel:
recoveryTicker.Stop()
break mainloop
case e := <-t.errorChannel:
t.Alert.Update(e.Error(), Error, time.Second*5)
w.Invalidate()
case e := <-t.PlayerMessages:
if err, ok := e.Inner.(tracker.PlayerCrashMessage); ok {
t.Alert.Update(err.Error(), Error, time.Second*3)
}
if err, ok := e.Inner.(tracker.PlayerVolumeErrorMessage); ok {
t.Alert.Update(err.Error(), Warning, time.Second*3)
}
t.lastAvgVolume = e.AverageVolume
t.lastPeakVolume = e.PeakVolume
t.InstrumentEditor.voiceLevels = e.VoiceLevels
t.ProcessPlayerMessage(e)
w.Invalidate()
case e := <-w.Events():
case e := <-events:
switch e := e.(type) {
case system.DestroyEvent:
if !t.Quit(false) {
case app.DestroyEvent:
acks <- struct{}{}
if canQuit {
t.Quit().Do()
}
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)
}
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())
}
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
t.Layout(gtx, w)
e.Frame(gtx.Ops)
acks <- struct{}{}
default:
acks <- struct{}{}
}
case <-recoveryTicker.C:
t.SaveRecovery()
case retChn := <-t.marshalRecoveryChannel:
retChn <- t.MarshalRecovery()
case bytes := <-t.unmarshalRecoveryChannel:
t.UnmarshalRecovery(bytes)
case f := <-t.execChan:
f()
}
if t.Quitted() {
break
}
}
recoveryTicker.Stop()
w.Perform(system.ActionClose)
t.SaveRecovery()
t.quitWG.Done()
}
// thread safe, executed in the GUI thread
func (t *Tracker) SafeMarshalRecovery() []byte {
retChn := make(chan []byte)
t.marshalRecoveryChannel <- retChn
return <-retChn
}
// thread safe, executed in the GUI thread
func (t *Tracker) SafeUnmarshalRecovery(data []byte) {
t.unmarshalRecoveryChannel <- data
}
func (t *Tracker) sendQuit() {
select {
case t.quitChannel <- struct{}{}:
default:
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 (t *Tracker) Exec() chan<- func() {
return t.execChan
}
func (t *Tracker) WaitQuitted() {
t.quitWG.Wait()
}
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() {
t.layoutTop(gtx)
} else {
t.VerticalSplit.Layout(gtx,
t.layoutTop,
t.layoutBottom)
}
t.PopupAlert.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
// we need to tell gio that we handle tabs too; otherwise
// it will steal them for focus switching
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},
transfer.TargetFilter{Target: t, Type: "application/text"},
)
if !ok {
break
}
switch e := ev.(type) {
case key.Event:
t.KeyEvent(e, gtx)
case transfer.DataEvent:
t.ReadSong(e.Open())
}
}
}
func (t *Tracker) showDialog(gtx C) {
if t.Exploring {
return
}
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)
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)
case tracker.OpenSongOpenExplorer:
t.explorerChooseFile(t.ReadSong, ".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)
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)
}, filename)
}
}
func (t *Tracker) explorerChooseFile(success func(io.ReadCloser), extensions ...string) {
t.Exploring = true
go func() {
file, err := t.Explorer.ChooseFile(extensions...)
t.Exec() <- func() {
t.Exploring = false
if err == nil {
success(file)
} else {
t.Cancel().Do()
}
}
}()
}
func (t *Tracker) explorerCreateFile(success func(io.WriteCloser), filename string) {
t.Exploring = true
go func() {
file, err := t.Explorer.CreateFile(filename)
t.Exec() <- func() {
t.Exploring = false
if err == nil {
success(file)
} else {
t.Cancel().Do()
}
}
}()
}
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)
},
)
}
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)
},
)
}

View File

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

View File

@ -2,11 +2,6 @@
package gioui
const CAN_QUIT = false
func (t *Tracker) Quit(forced bool) bool {
if forced {
t.sendQuit()
}
return forced
func init() {
canQuit = false
}

View File

@ -0,0 +1,341 @@
package gioui
import (
"bytes"
"fmt"
"image"
"io"
"math"
"gioui.org/io/clipboard"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"github.com/vsariola/sointu/tracker"
"golang.org/x/exp/shiny/materialdesign/icons"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type UnitEditor struct {
sliderList *DragList
searchList *DragList
Parameters []*ParameterWidget
DeleteUnitBtn *ActionClickable
CopyUnitBtn *TipClickable
ClearUnitBtn *ActionClickable
DisableUnitBtn *BoolClickable
SelectTypeBtn *widget.Clickable
caser cases.Caser
}
func NewUnitEditor(m *tracker.Model) *UnitEditor {
ret := &UnitEditor{
DeleteUnitBtn: NewActionClickable(m.DeleteUnit()),
ClearUnitBtn: NewActionClickable(m.ClearUnit()),
DisableUnitBtn: NewBoolClickable(m.UnitDisabled().Bool()),
CopyUnitBtn: new(TipClickable),
SelectTypeBtn: new(widget.Clickable),
sliderList: NewDragList(m.Params().List(), layout.Vertical),
searchList: NewDragList(m.SearchResults().List(), layout.Vertical),
}
ret.caser = cases.Title(language.English)
return ret
}
func (pe *UnitEditor) Layout(gtx C, t *Tracker) D {
for {
e, ok := gtx.Event(
key.Filter{Focus: pe.sliderList, Name: key.NameLeftArrow, Optional: key.ModShift},
key.Filter{Focus: pe.sliderList, Name: key.NameRightArrow, Optional: key.ModShift},
key.Filter{Focus: pe.sliderList, Name: key.NameEscape},
)
if !ok {
break
}
switch e := e.(type) {
case key.Event:
if e.State == key.Press {
pe.command(e, t)
}
}
}
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
editorFunc := pe.layoutSliders
if t.UnitSearching().Value() || pe.sliderList.TrackerList.Count() == 0 {
editorFunc = pe.layoutUnitTypeChooser
}
return Surface{Gray: 24, Focus: t.InstrumentEditor.wasFocused}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return editorFunc(gtx, t)
}),
layout.Rigid(func(gtx C) D {
return pe.layoutFooter(gtx, t)
}),
)
})
}
func (pe *UnitEditor) layoutSliders(gtx C, t *Tracker) D {
numItems := pe.sliderList.TrackerList.Count()
for len(pe.Parameters) < numItems {
pe.Parameters = append(pe.Parameters, new(ParameterWidget))
}
index := 0
t.Model.Params().Iterate(func(param tracker.Parameter) {
pe.Parameters[index].Parameter = param
index++
})
element := func(gtx C, index int) D {
if index < 0 || index >= numItems {
return D{}
}
paramStyle := t.ParamStyle(t.Theme, pe.Parameters[index])
paramStyle.Focus = pe.sliderList.TrackerList.Selected() == index
dims := paramStyle.Layout(gtx)
return D{Size: image.Pt(gtx.Constraints.Max.X, dims.Size.Y)}
}
fdl := FilledDragList(t.Theme, pe.sliderList, element, nil)
dims := fdl.Layout(gtx)
gtx.Constraints = layout.Exact(dims.Size)
fdl.LayoutScrollBar(gtx)
return dims
}
func (pe *UnitEditor) layoutFooter(gtx C, t *Tracker) D {
for pe.CopyUnitBtn.Clickable.Clicked(gtx) {
if contents, ok := t.Units().List().CopyElements(); ok {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
t.Alerts().Add("Unit copied to clipboard", tracker.Info)
}
}
copyUnitBtnStyle := TipIcon(t.Theme, pe.CopyUnitBtn, icons.ContentContentCopy, "Copy unit (Ctrl+C)")
deleteUnitBtnStyle := ActionIcon(gtx, t.Theme, pe.DeleteUnitBtn, icons.ActionDelete, "Delete unit (Ctrl+Backspace)")
disableUnitBtnStyle := ToggleIcon(gtx, t.Theme, pe.DisableUnitBtn, icons.AVVolumeUp, icons.AVVolumeOff, "Disable unit (Ctrl-D)", "Enable unit (Ctrl-D)")
text := t.Units().SelectedType()
if text == "" {
text = "Choose unit type"
} else {
text = pe.caser.String(text)
}
hintText := Label(text, white, t.Theme.Shaper)
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(deleteUnitBtnStyle.Layout),
layout.Rigid(copyUnitBtnStyle.Layout),
layout.Rigid(disableUnitBtnStyle.Layout),
layout.Rigid(func(gtx C) D {
var dims D
if t.Units().SelectedType() != "" {
clearUnitBtnStyle := ActionIcon(gtx, t.Theme, pe.ClearUnitBtn, icons.ContentClear, "Clear unit")
dims = clearUnitBtnStyle.Layout(gtx)
}
return D{Size: image.Pt(gtx.Dp(unit.Dp(48)), dims.Size.Y)}
}),
layout.Flexed(1, hintText),
)
}
func (pe *UnitEditor) layoutUnitTypeChooser(gtx C, t *Tracker) D {
var names [256]string
index := 0
t.Model.SearchResults().Iterate(func(item string) (ok bool) {
names[index] = item
index++
return index <= 256
})
element := func(gtx C, i int) D {
w := LabelStyle{Text: names[i], ShadeColor: black, Color: white, Font: labelDefaultFont, FontSize: unit.Sp(12), Shaper: t.Theme.Shaper}
if i == pe.searchList.TrackerList.Selected() {
for pe.SelectTypeBtn.Clicked(gtx) {
t.Units().SetSelectedType(names[i])
}
return pe.SelectTypeBtn.Layout(gtx, w.Layout)
}
return w.Layout(gtx)
}
fdl := FilledDragList(t.Theme, pe.searchList, element, nil)
dims := fdl.Layout(gtx)
gtx.Constraints = layout.Exact(dims.Size)
fdl.LayoutScrollBar(gtx)
return dims
}
func (pe *UnitEditor) command(e key.Event, t *Tracker) {
params := (*tracker.Params)(t.Model)
switch e.State {
case key.Press:
switch e.Name {
case key.NameLeftArrow:
sel := params.SelectedItem()
if sel == nil {
return
}
i := (&tracker.Int{IntData: sel})
if e.Modifiers.Contain(key.ModShift) {
i.Set(i.Value() - sel.LargeStep())
} else {
i.Set(i.Value() - 1)
}
case key.NameRightArrow:
sel := params.SelectedItem()
if sel == nil {
return
}
i := (&tracker.Int{IntData: sel})
if e.Modifiers.Contain(key.ModShift) {
i.Set(i.Value() + sel.LargeStep())
} else {
i.Set(i.Value() + 1)
}
case key.NameEscape:
t.InstrumentEditor.unitDragList.Focus()
}
}
}
type ParameterWidget struct {
floatWidget widget.Float
boolWidget widget.Bool
instrBtn widget.Clickable
instrMenu Menu
unitBtn widget.Clickable
unitMenu Menu
Parameter tracker.Parameter
}
type ParameterStyle struct {
tracker *Tracker
w *ParameterWidget
Theme *material.Theme
Focus bool
}
func (t *Tracker) ParamStyle(th *material.Theme, paramWidget *ParameterWidget) ParameterStyle {
return ParameterStyle{
tracker: t, // TODO: we need this to pull the instrument names for ID style parameters, find out another way
Theme: th,
w: paramWidget,
}
}
func (p ParameterStyle) Layout(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(110))
return layout.E.Layout(gtx, Label(p.w.Parameter.Name(), white, p.tracker.Theme.Shaper))
}),
layout.Rigid(func(gtx C) D {
switch p.w.Parameter.Type() {
case tracker.IntegerParameter:
for p.Focus {
e, ok := gtx.Event(pointer.Filter{
Target: &p.w.floatWidget,
Kinds: pointer.Scroll,
ScrollBounds: image.Rectangle{Min: image.Pt(0, -1e6), Max: image.Pt(0, 1e6)},
})
if !ok {
break
}
if ev, ok := e.(pointer.Event); ok && ev.Kind == pointer.Scroll {
delta := math.Min(math.Max(float64(ev.Scroll.Y), -1), 1)
tracker.Int{IntData: p.w.Parameter}.Add(-int(delta))
}
}
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
ra := p.w.Parameter.Range()
if !p.w.floatWidget.Dragging() {
p.w.floatWidget.Value = (float32(p.w.Parameter.Value()) - float32(ra.Min)) / float32(ra.Max-ra.Min)
}
sliderStyle := material.Slider(p.Theme, &p.w.floatWidget)
sliderStyle.Color = p.Theme.Fg
r := image.Rectangle{Max: gtx.Constraints.Min}
area := clip.Rect(r).Push(gtx.Ops)
if p.Focus {
event.Op(gtx.Ops, &p.w.floatWidget)
}
dims := sliderStyle.Layout(gtx)
area.Pop()
tracker.Int{IntData: p.w.Parameter}.Set(int(p.w.floatWidget.Value*float32(ra.Max-ra.Min) + float32(ra.Min) + 0.5))
return dims
case tracker.BoolParameter:
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(60))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
ra := p.w.Parameter.Range()
p.w.boolWidget.Value = p.w.Parameter.Value() > ra.Min
boolStyle := material.Switch(p.Theme, &p.w.boolWidget, "Toggle boolean parameter")
boolStyle.Color.Disabled = p.Theme.Fg
boolStyle.Color.Enabled = white
dims := layout.Center.Layout(gtx, boolStyle.Layout)
if p.w.boolWidget.Value {
tracker.Int{IntData: p.w.Parameter}.Set(ra.Max)
} else {
tracker.Int{IntData: p.w.Parameter}.Set(ra.Min)
}
return dims
case tracker.IDParameter:
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(200))
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(40))
instrItems := make([]MenuItem, p.tracker.Instruments().Count())
for i := range instrItems {
i := i
name, _, _ := p.tracker.Instruments().Item(i)
instrItems[i].Text = name
instrItems[i].IconBytes = icons.NavigationChevronRight
instrItems[i].Doer = tracker.Allow(func() {
if id, ok := p.tracker.Instruments().FirstID(i); ok {
tracker.Int{IntData: p.w.Parameter}.Set(id)
}
})
}
var unitItems []MenuItem
instrName := "<instr>"
unitName := "<unit>"
targetI, targetU, err := p.tracker.FindUnit(p.w.Parameter.Value())
if err == nil {
targetInstrument := p.tracker.Instrument(targetI)
instrName = targetInstrument.Name
units := targetInstrument.Units
unitName = fmt.Sprintf("%v: %v", targetU, units[targetU].Type)
unitItems = make([]MenuItem, len(units))
for j, unit := range units {
id := unit.ID
unitItems[j].Text = fmt.Sprintf("%v: %v", j, unit.Type)
unitItems[j].IconBytes = icons.NavigationChevronRight
unitItems[j].Doer = tracker.Allow(func() {
tracker.Int{IntData: p.w.Parameter}.Set(id)
})
}
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(p.tracker.layoutMenu(gtx, instrName, &p.w.instrBtn, &p.w.instrMenu, unit.Dp(200),
instrItems...,
)),
layout.Rigid(p.tracker.layoutMenu(gtx, unitName, &p.w.unitBtn, &p.w.unitMenu, unit.Dp(200),
unitItems...,
)),
)
}
return D{}
}),
layout.Rigid(func(gtx C) D {
if p.w.Parameter.Type() != tracker.IDParameter {
return Label(p.w.Parameter.Hint(), white, p.tracker.Theme.Shaper)(gtx)
}
return D{}
}),
)
}

191
tracker/int.go Normal file
View File

@ -0,0 +1,191 @@
package tracker
import (
"math"
"github.com/vsariola/sointu/vm"
)
type (
Int struct {
IntData
}
IntData interface {
Value() int
Range() intRange
setValue(int)
change(kind string) func()
}
intRange struct {
Min, Max int
}
InstrumentVoices Model
TrackVoices Model
SongLength Model
BPM Model
RowsPerPattern Model
RowsPerBeat Model
Step Model
Octave Model
)
func (v Int) Add(delta int) (ok bool) {
r := v.Range()
value := r.Clamp(v.Value() + delta)
if value == v.Value() || value < r.Min || value > r.Max {
return false
}
defer v.change("Add")()
v.setValue(value)
return true
}
func (v Int) Set(value int) (ok bool) {
r := v.Range()
value = v.Range().Clamp(value)
if value == v.Value() || value < r.Min || value > r.Max {
return false
}
defer v.change("Set")()
v.setValue(value)
return true
}
func (r intRange) Clamp(value int) int {
return intMax(intMin(value, r.Max), r.Min)
}
// Model methods
func (m *Model) InstrumentVoices() *InstrumentVoices { return (*InstrumentVoices)(m) }
func (m *Model) TrackVoices() *TrackVoices { return (*TrackVoices)(m) }
func (m *Model) SongLength() *SongLength { return (*SongLength)(m) }
func (m *Model) BPM() *BPM { return (*BPM)(m) }
func (m *Model) RowsPerPattern() *RowsPerPattern { return (*RowsPerPattern)(m) }
func (m *Model) RowsPerBeat() *RowsPerBeat { return (*RowsPerBeat)(m) }
func (m *Model) Step() *Step { return (*Step)(m) }
func (m *Model) Octave() *Octave { return (*Octave)(m) }
// BeatsPerMinuteInt
func (v *BPM) Int() Int { return Int{v} }
func (v *BPM) Value() int { return v.d.Song.BPM }
func (v *BPM) setValue(value int) { v.d.Song.BPM = value }
func (v *BPM) Range() intRange { return intRange{1, 999} }
func (v *BPM) change(kind string) func() {
return (*Model)(v).change("BPMInt."+kind, SongChange, MinorChange)
}
// RowsPerPatternInt
func (v *RowsPerPattern) Int() Int { return Int{v} }
func (v *RowsPerPattern) Value() int { return v.d.Song.Score.RowsPerPattern }
func (v *RowsPerPattern) setValue(value int) { v.d.Song.Score.RowsPerPattern = value }
func (v *RowsPerPattern) Range() intRange { return intRange{1, 256} }
func (v *RowsPerPattern) change(kind string) func() {
return (*Model)(v).change("RowsPerPatternInt."+kind, SongChange, MinorChange)
}
// SongLengthInt
func (v *SongLength) Int() Int { return Int{v} }
func (v *SongLength) Value() int { return v.d.Song.Score.Length }
func (v *SongLength) setValue(value int) { v.d.Song.Score.Length = value }
func (v *SongLength) Range() intRange { return intRange{1, math.MaxInt32} }
func (v *SongLength) change(kind string) func() {
return (*Model)(v).change("SongLengthInt."+kind, SongChange, MinorChange)
}
// StepInt
func (v *Step) Int() Int { return Int{v} }
func (v *Step) Value() int { return v.d.Step }
func (v *Step) setValue(value int) { v.d.Step = value }
func (v *Step) Range() intRange { return intRange{0, 8} }
func (v *Step) change(kind string) func() {
return (*Model)(v).change("StepInt"+kind, NoChange, MinorChange)
}
// OctaveInt
func (v *Octave) Int() Int { return Int{v} }
func (v *Octave) Value() int { return v.d.Octave }
func (v *Octave) setValue(value int) { v.d.Octave = value }
func (v *Octave) Range() intRange { return intRange{0, 9} }
func (v *Octave) change(kind string) func() { return func() {} }
// RowsPerBeatInt
func (v *RowsPerBeat) Int() Int { return Int{v} }
func (v *RowsPerBeat) Value() int { return v.d.Song.RowsPerBeat }
func (v *RowsPerBeat) setValue(value int) { v.d.Song.RowsPerBeat = value }
func (v *RowsPerBeat) Range() intRange { return intRange{1, 32} }
func (v *RowsPerBeat) change(kind string) func() {
return (*Model)(v).change("RowsPerBeatInt."+kind, SongChange, MinorChange)
}
// InstrumentVoicesInt
func (v *InstrumentVoices) Int() Int {
return Int{v}
}
func (v *InstrumentVoices) Value() int {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return 1
}
return intMax(v.d.Song.Patch[v.d.InstrIndex].NumVoices, 1)
}
func (v *InstrumentVoices) setValue(value int) {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return
}
v.d.Song.Patch[v.d.InstrIndex].NumVoices = value
}
func (v *InstrumentVoices) Range() intRange {
return intRange{1, vm.MAX_VOICES - v.d.Song.Patch.NumVoices() + v.Value()}
}
func (v *InstrumentVoices) change(kind string) func() {
return (*Model)(v).change("InstrumentVoicesInt."+kind, PatchChange, MinorChange)
}
// TrackVoicesInt
func (v *TrackVoices) Int() Int {
return Int{v}
}
func (v *TrackVoices) Value() int {
t := v.d.Cursor.Track
if t < 0 || t >= len(v.d.Song.Score.Tracks) {
return 1
}
return intMax(v.d.Song.Score.Tracks[t].NumVoices, 1)
}
func (v *TrackVoices) setValue(value int) {
t := v.d.Cursor.Track
if t < 0 || t >= len(v.d.Song.Score.Tracks) {
return
}
v.d.Song.Score.Tracks[t].NumVoices = value
}
func (v *TrackVoices) Range() intRange {
t := v.d.Cursor.Track
if t < 0 || t >= len(v.d.Song.Score.Tracks) {
return intRange{1, 1}
}
return intRange{1, vm.MAX_VOICES - v.d.Song.Score.NumVoices() + v.d.Song.Score.Tracks[t].NumVoices}
}
func (v *TrackVoices) change(kind string) func() {
return (*Model)(v).change("TrackVoicesInt."+kind, ScoreChange, MinorChange)
}

772
tracker/list.go Normal file
View File

@ -0,0 +1,772 @@
package tracker
import (
"errors"
"fmt"
"strings"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
"gopkg.in/yaml.v2"
)
type (
List struct {
ListData
}
ListData interface {
Selected() int
Selected2() int
SetSelected(int)
SetSelected2(int)
Count() int
}
MutableListData interface {
change(kind string, severity ChangeSeverity) func()
cancel()
swap(i, j int) (ok bool)
delete(i int) (ok bool)
marshal(from, to int) ([]byte, error)
unmarshal([]byte) (from, to int, err error)
}
UnitListItem struct {
Type string
Disabled bool
StackNeed, StackBefore, StackAfter int
}
UnitYieldFunc func(item UnitListItem) (ok bool)
UnitSearchYieldFunc func(item string) (ok bool)
Instruments Model // Instruments is a list of instruments, implementing ListData & MutableListData interfaces
Units Model // Units is a list of all the units in the selected instrument, implementing ListData & MutableListData interfaces
Tracks Model // Tracks is a list of all the tracks, implementing ListData & MutableListData interfaces
OrderRows Model // OrderRows is a list of all the order rows, implementing ListData & MutableListData interfaces
NoteRows Model // NoteRows is a list of all the note rows, implementing ListData & MutableListData interfaces
SearchResults Model // SearchResults is a unmutable list of all the search results, implementing ListData interface
Presets Model // Presets is a unmutable list of all the presets, implementing ListData interface
)
// Model methods
func (m *Model) Instruments() *Instruments { return (*Instruments)(m) }
func (m *Model) Units() *Units { return (*Units)(m) }
func (m *Model) Tracks() *Tracks { return (*Tracks)(m) }
func (m *Model) OrderRows() *OrderRows { return (*OrderRows)(m) }
func (m *Model) NoteRows() *NoteRows { return (*NoteRows)(m) }
func (m *Model) SearchResults() *SearchResults { return (*SearchResults)(m) }
// MoveElements moves the selected elements in a list by delta. If delta is
// negative, the elements move up, otherwise down. The list must implement the
// MutableListData interface.
func (v List) MoveElements(delta int) (ok bool) {
if delta == 0 {
return false
}
s, ok := v.ListData.(MutableListData)
if !ok {
return
}
defer s.change("MoveElements", MajorChange)()
a, b := v.listRange()
if a+delta < 0 {
delta = -a
}
if b+delta >= v.Count() {
delta = v.Count() - 1 - b
}
if delta < 0 {
for i := a; i <= b; i++ {
if !s.swap(i, i+delta) {
s.cancel()
return false
}
}
} else {
for i := b; i >= a; i-- {
if !s.swap(i, i+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) (ok bool) {
d, ok := v.ListData.(MutableListData)
if !ok {
return
}
defer d.change("DeleteElements", MajorChange)()
a, b := v.listRange()
for i := b; i >= a; i-- {
if !d.delete(i) {
d.cancel()
return false
}
}
if backwards && a > 0 {
a--
}
v.SetSelected(a)
v.SetSelected2(a)
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) {
a, b := v.listRange()
m, ok := v.ListData.(MutableListData)
if !ok {
return nil, false
}
ret, err := m.marshal(a, b)
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.ListData.(MutableListData)
if !ok {
return false
}
defer m.change("PasteElements", MajorChange)()
from, to, err := m.unmarshal(data)
if err != nil {
m.cancel()
return false
}
v.SetSelected(from)
v.SetSelected2(to)
return true
}
func (v *List) listRange() (lower, higher int) {
lower = intMin(v.Selected(), v.Selected2())
higher = intMax(v.Selected(), v.Selected2())
return
}
// Instruments methods
func (v *Instruments) List() List {
return List{v}
}
func (v *Instruments) Item(i int) (name string, maxLevel float32, ok bool) {
if i < 0 || i >= len(v.d.Song.Patch) {
return "", 0, false
}
name = v.d.Song.Patch[i].Name
start := v.d.Song.Patch.FirstVoiceForInstrument(i)
end := start + v.d.Song.Patch[i].NumVoices
if end >= vm.MAX_VOICES {
end = vm.MAX_VOICES
}
if start < end {
for _, level := range v.voiceLevels[start:end] {
if maxLevel < level {
maxLevel = level
}
}
}
ok = true
return
}
func (v *Instruments) FirstID(i int) (id int, ok bool) {
if i < 0 || i >= len(v.d.Song.Patch) {
return 0, false
}
if len(v.d.Song.Patch[i].Units) == 0 {
return 0, false
}
return v.d.Song.Patch[i].Units[0].ID, true
}
func (v *Instruments) Selected() int {
return intMax(intMin(v.d.InstrIndex, v.Count()-1), 0)
}
func (v *Instruments) Selected2() int {
return intMax(intMin(v.d.InstrIndex2, v.Count()-1), 0)
}
func (v *Instruments) SetSelected(value int) {
v.d.InstrIndex = intMax(intMin(value, v.Count()-1), 0)
v.d.UnitIndex = 0
v.d.UnitIndex2 = 0
v.d.UnitSearching = false
v.d.UnitSearchString = ""
}
func (v *Instruments) SetSelected2(value int) {
v.d.InstrIndex2 = intMax(intMin(value, v.Count()-1), 0)
}
func (v *Instruments) swap(i, j int) (ok bool) {
if i < 0 || j < 0 || i >= len(v.d.Song.Patch) || j >= len(v.d.Song.Patch) || i == j {
return false
}
instr := v.d.Song.Patch
instr[i], instr[j] = instr[j], instr[i]
return true
}
func (v *Instruments) delete(i int) (ok bool) {
if i < 0 || i >= len(v.d.Song.Patch) {
return false
}
v.d.Song.Patch = append(v.d.Song.Patch[:i], v.d.Song.Patch[i+1:]...)
return true
}
func (v *Instruments) change(n string, severity ChangeSeverity) func() {
return (*Model)(v).change("InstrumentListView."+n, PatchChange, severity)
}
func (v *Instruments) cancel() {
v.changeCancel = true
}
func (v *Instruments) Count() int {
return len(v.d.Song.Patch)
}
func (v *Instruments) marshal(from, to int) ([]byte, error) {
if from < 0 || to >= len(v.d.Song.Patch) || from > to {
return nil, fmt.Errorf("InstrumentListView.marshal: index out of range: %d, %d", from, to)
}
ret, err := yaml.Marshal(struct{ Patch sointu.Patch }{v.d.Song.Patch[from : to+1]})
if err != nil {
return nil, fmt.Errorf("InstrumentListView.marshal: %v", err)
}
return ret, nil
}
func (v *Instruments) unmarshal(data []byte) (from, to int, err error) {
var newInstr struct{ Patch sointu.Patch }
if err := yaml.Unmarshal(data, &newInstr); err != nil {
return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: %v", err)
}
if len(newInstr.Patch) == 0 {
return 0, 0, errors.New("InstrumentListView.unmarshal: no instruments")
}
if v.d.Song.Patch.NumVoices()+newInstr.Patch.NumVoices() > vm.MAX_VOICES {
return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: too many voices: %d", v.d.Song.Patch.NumVoices()+newInstr.Patch.NumVoices())
}
patch := append(v.d.Song.Patch, make([]sointu.Instrument, len(newInstr.Patch))...)
sel := v.Selected()
copy(patch[sel+len(newInstr.Patch):], patch[sel:])
copy(patch[sel:sel+len(newInstr.Patch)], newInstr.Patch)
v.d.Song.Patch = patch
from = sel
to = sel + len(newInstr.Patch) - 1
return
}
// Units methods
func (v *Units) List() List {
return List{v}
}
func (m *Units) SelectedType() string {
if m.d.InstrIndex < 0 ||
m.d.InstrIndex >= len(m.d.Song.Patch) ||
m.d.UnitIndex < 0 ||
m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) {
return ""
}
return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Type
}
func (m *Units) SetSelectedType(t string) {
if m.d.InstrIndex < 0 ||
m.d.InstrIndex >= len(m.d.Song.Patch) ||
m.d.UnitIndex < 0 ||
m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) {
return
}
unit, ok := defaultUnits[t]
if !ok { // if the type is invalid, we just set it to empty unit
unit = sointu.Unit{Parameters: make(map[string]int)}
} else {
unit = unit.Copy()
}
oldUnit := m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex]
if oldUnit.Type == unit.Type {
return
}
defer m.change("SetSelectedType", MajorChange)()
m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = unit
m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].ID = oldUnit.ID // keep the ID of the replaced unit
}
func (v *Units) Iterate(yield UnitYieldFunc) {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return
}
stackBefore := 0
for _, unit := range v.d.Song.Patch[v.d.InstrIndex].Units {
stackAfter := stackBefore + unit.StackChange()
if !yield(UnitListItem{
Type: unit.Type,
Disabled: unit.Disabled,
StackNeed: unit.StackNeed(),
StackBefore: stackBefore,
StackAfter: stackAfter,
}) {
break
}
stackBefore = stackAfter
}
}
func (v *Units) Selected() int {
return intMax(intMin(v.d.UnitIndex, v.Count()-1), 0)
}
func (v *Units) Selected2() int {
return intMax(intMin(v.d.UnitIndex2, v.Count()-1), 0)
}
func (v *Units) SetSelected(value int) {
m := (*Model)(v)
m.d.UnitIndex = intMax(intMin(value, v.Count()-1), 0)
m.d.ParamIndex = 0
m.d.UnitSearching = false
m.d.UnitSearchString = ""
}
func (v *Units) SetSelected2(value int) {
(*Model)(v).d.UnitIndex2 = intMax(intMin(value, v.Count()-1), 0)
}
func (v *Units) Count() int {
m := (*Model)(v)
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return 0
}
return len(m.d.Song.Patch[(*Model)(v).d.InstrIndex].Units)
}
func (v *Units) swap(i, j int) (ok bool) {
m := (*Model)(v)
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return false
}
units := m.d.Song.Patch[m.d.InstrIndex].Units
if i < 0 || j < 0 || i >= len(units) || j >= len(units) || i == j {
return false
}
units[i], units[j] = units[j], units[i]
return true
}
func (v *Units) delete(i int) (ok bool) {
m := (*Model)(v)
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return false
}
units := m.d.Song.Patch[m.d.InstrIndex].Units
if i < 0 || i >= len(units) {
return false
}
units = append(units[:i], units[i+1:]...)
m.d.Song.Patch[m.d.InstrIndex].Units = units
return true
}
func (v *Units) change(n string, severity ChangeSeverity) func() {
return (*Model)(v).change("UnitListView."+n, PatchChange, severity)
}
func (v *Units) cancel() {
(*Model)(v).changeCancel = true
}
func (v *Units) marshal(from, to int) ([]byte, error) {
m := (*Model)(v)
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return nil, errors.New("UnitListView.marshal: no instruments")
}
if from < 0 || to >= len(m.d.Song.Patch[m.d.InstrIndex].Units) || from > to {
return nil, fmt.Errorf("UnitListView.marshal: index out of range: %d, %d", from, to)
}
ret, err := yaml.Marshal(struct{ Units []sointu.Unit }{m.d.Song.Patch[m.d.InstrIndex].Units[from : to+1]})
if err != nil {
return nil, fmt.Errorf("UnitListView.marshal: %v", err)
}
return ret, nil
}
func (v *Units) unmarshal(data []byte) (from, to int, err error) {
m := (*Model)(v)
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
return 0, 0, errors.New("UnitListView.unmarshal: no instruments")
}
var pastedUnits struct{ Units []sointu.Unit }
if err := yaml.Unmarshal(data, &pastedUnits); err != nil {
return 0, 0, fmt.Errorf("UnitListView.unmarshal: %v", err)
}
if len(pastedUnits.Units) == 0 {
return 0, 0, errors.New("UnitListView.unmarshal: no units")
}
m.assignUnitIDs(pastedUnits.Units)
sel := v.Selected()
units := append(m.d.Song.Patch[m.d.InstrIndex].Units, make([]sointu.Unit, len(pastedUnits.Units))...)
copy(units[sel+len(pastedUnits.Units):], units[sel:])
copy(units[sel:], pastedUnits.Units)
m.d.Song.Patch[m.d.InstrIndex].Units = units
from = sel
to = sel + len(pastedUnits.Units) - 1
return
}
// Tracks methods
func (v *Tracks) List() List {
return List{v}
}
func (v *Tracks) Selected() int {
return intMax(intMin(v.d.Cursor.Track, v.Count()-1), 0)
}
func (v *Tracks) Selected2() int {
return intMax(intMin(v.d.Cursor2.Track, v.Count()-1), 0)
}
func (v *Tracks) SetSelected(value int) {
v.d.Cursor.Track = intMax(intMin(value, v.Count()-1), 0)
}
func (v *Tracks) SetSelected2(value int) {
v.d.Cursor2.Track = intMax(intMin(value, v.Count()-1), 0)
}
func (v *Tracks) swap(i, j int) (ok bool) {
m := (*Model)(v)
if i < 0 || j < 0 || i >= len(m.d.Song.Score.Tracks) || j >= len(m.d.Song.Score.Tracks) || i == j {
return false
}
tracks := m.d.Song.Score.Tracks
tracks[i], tracks[j] = tracks[j], tracks[i]
return true
}
func (v *Tracks) delete(i int) (ok bool) {
m := (*Model)(v)
if i < 0 || i >= len(m.d.Song.Score.Tracks) {
return false
}
m.d.Song.Score.Tracks = append(m.d.Song.Score.Tracks[:i], m.d.Song.Score.Tracks[i+1:]...)
return true
}
func (v *Tracks) change(n string, severity ChangeSeverity) func() {
return (*Model)(v).change("TrackList."+n, ScoreChange, severity)
}
func (v *Tracks) cancel() {
v.changeCancel = true
}
func (v *Tracks) Count() int {
return len((*Model)(v).d.Song.Score.Tracks)
}
func (v *Tracks) marshal(from, to int) ([]byte, error) {
m := (*Model)(v)
if from < 0 || to >= len(m.d.Song.Score.Tracks) || from > to {
return nil, fmt.Errorf("TrackListView.marshal: index out of range: %d, %d", from, to)
}
ret, err := yaml.Marshal(struct{ Score sointu.Score }{sointu.Score{Tracks: m.d.Song.Score.Tracks[from : to+1]}})
if err != nil {
return nil, fmt.Errorf("TrackListView.marshal: %v", err)
}
return ret, nil
}
func (v *Tracks) unmarshal(data []byte) (from, to int, err error) {
m := (*Model)(v)
var newTracks struct{ Score sointu.Score }
if err := yaml.Unmarshal(data, &newTracks); err != nil {
return 0, 0, fmt.Errorf("TrackListView.unmarshal: %v", err)
}
if len(newTracks.Score.Tracks) == 0 {
return 0, 0, errors.New("TrackListView.unmarshal: no tracks")
}
if v.d.Song.Score.NumVoices()+newTracks.Score.NumVoices() > vm.MAX_VOICES {
return 0, 0, fmt.Errorf("InstrumentListView.unmarshal: too many voices: %d", v.d.Song.Patch.NumVoices()+newTracks.Score.NumVoices())
}
from = m.d.Cursor.Track
to = m.d.Cursor.Track + len(newTracks.Score.Tracks) - 1
tracks := m.d.Song.Score.Tracks
newTracks.Score.Tracks = append(newTracks.Score.Tracks, tracks[m.d.Cursor.Track:]...)
tracks = append(tracks[:m.d.Cursor.Track], newTracks.Score.Tracks...)
m.d.Song.Score.Tracks = tracks
return
}
// OrderRows methods
func (v *OrderRows) List() List {
return List{v}
}
func (v *OrderRows) Selected() int {
p := v.d.Cursor.OrderRow
p = intMax(intMin(p, v.Count()-1), 0)
return p
}
func (v *OrderRows) Selected2() int {
p := v.d.Cursor2.OrderRow
p = intMax(intMin(p, v.Count()-1), 0)
return p
}
func (v *OrderRows) SetSelected(value int) {
y := intMax(intMin(value, v.Count()-1), 0)
if y != v.d.Cursor.OrderRow {
v.noteTracking = false
}
v.d.Cursor.OrderRow = y
}
func (v *OrderRows) SetSelected2(value int) {
v.d.Cursor2.OrderRow = intMax(intMin(value, v.Count()-1), 0)
}
func (v *OrderRows) swap(x, y int) (ok bool) {
for i := range v.d.Song.Score.Tracks {
track := &v.d.Song.Score.Tracks[i]
a, b := track.Order.Get(x), track.Order.Get(y)
track.Order.Set(x, b)
track.Order.Set(y, a)
}
return true
}
func (v *OrderRows) delete(i int) (ok bool) {
for _, track := range v.d.Song.Score.Tracks {
if i < len(track.Order) {
track.Order = append(track.Order[:i], track.Order[i+1:]...)
}
}
return true
}
func (v *OrderRows) change(n string, severity ChangeSeverity) func() {
return (*Model)(v).change("OrderRowList."+n, ScoreChange, severity)
}
func (v *OrderRows) cancel() {
v.changeCancel = true
}
func (v *OrderRows) Count() int {
return v.d.Song.Score.Length
}
type marshalOrderRows struct {
Columns [][]int `yaml:",flow"`
}
func (v *OrderRows) marshal(from, to int) ([]byte, error) {
var table marshalOrderRows
for i := range v.d.Song.Score.Tracks {
table.Columns = append(table.Columns, make([]int, to-from+1))
for j := 0; j < to-from+1; j++ {
table.Columns[i][j] = v.d.Song.Score.Tracks[i].Order.Get(from + j)
}
}
return yaml.Marshal(table)
}
func (v *OrderRows) unmarshal(data []byte) (from, to int, err error) {
var table marshalOrderRows
err = yaml.Unmarshal(data, &table)
if err != nil {
return
}
if len(table.Columns) == 0 {
err = errors.New("OrderRowList.unmarshal: no rows")
return
}
from = v.d.Cursor.OrderRow
to = v.d.Cursor.OrderRow + len(table.Columns[0]) - 1
for i := range v.d.Song.Score.Tracks {
if i >= len(table.Columns) {
break
}
order := &v.d.Song.Score.Tracks[i].Order
for j := 0; j < from-len(*order); j++ {
*order = append(*order, -1)
}
if len(*order) > from {
table.Columns[i] = append(table.Columns[i], (*order)[from:]...)
*order = (*order)[:from]
}
*order = append(*order, table.Columns[i]...)
}
return
}
// NoteRows methods
func (v *NoteRows) List() List {
return List{v}
}
func (v *NoteRows) Selected() int {
return v.d.Song.Score.SongRow(v.d.Song.Score.Clamp(v.d.Cursor.SongPos))
}
func (v *NoteRows) Selected2() int {
return v.d.Song.Score.SongRow(v.d.Song.Score.Clamp(v.d.Cursor2.SongPos))
}
func (v *NoteRows) SetSelected(value int) {
if value != v.d.Song.Score.SongRow(v.d.Cursor.SongPos) {
v.noteTracking = false
}
v.d.Cursor.SongPos = v.d.Song.Score.Clamp(v.d.Song.Score.SongPos(value))
}
func (v *NoteRows) SetSelected2(value int) {
v.d.Cursor2.SongPos = v.d.Song.Score.Clamp(v.d.Song.Score.SongPos(value))
}
func (v *NoteRows) swap(i, j int) (ok bool) {
ipos := v.d.Song.Score.SongPos(i)
jpos := v.d.Song.Score.SongPos(j)
for _, track := range v.d.Song.Score.Tracks {
n1 := track.Note(ipos)
n2 := track.Note(jpos)
track.SetNote(ipos, n2)
track.SetNote(jpos, n1)
}
return true
}
func (v *NoteRows) delete(i int) (ok bool) {
if i < 0 || i >= v.Count() {
return
}
pos := v.d.Song.Score.SongPos(i)
for _, track := range v.d.Song.Score.Tracks {
track.SetNote(pos, 1)
}
return true
}
func (v *NoteRows) change(n string, severity ChangeSeverity) func() {
return (*Model)(v).change("NoteRowList."+n, ScoreChange, severity)
}
func (v *NoteRows) cancel() {
(*Model)(v).changeCancel = true
}
func (v *NoteRows) Count() int {
return (*Model)(v).d.Song.Score.Length * v.d.Song.Score.RowsPerPattern
}
type marshalNoteRows struct {
NoteRows [][]byte `yaml:",flow"`
}
func (v *NoteRows) marshal(from, to int) ([]byte, error) {
var table marshalNoteRows
for i, track := range v.d.Song.Score.Tracks {
table.NoteRows = append(table.NoteRows, make([]byte, to-from+1))
for j := 0; j < to-from+1; j++ {
row := from + j
pos := v.d.Song.Score.SongPos(row)
table.NoteRows[i][j] = track.Note(pos)
}
}
return yaml.Marshal(table)
}
func (v *NoteRows) unmarshal(data []byte) (from, to int, err error) {
var table marshalNoteRows
if err := yaml.Unmarshal(data, &table); err != nil {
return 0, 0, fmt.Errorf("NoteRowList.unmarshal: %v", err)
}
if len(table.NoteRows) < 1 {
return 0, 0, errors.New("NoteRowList.unmarshal: no tracks")
}
from = v.d.Song.Score.SongRow(v.d.Cursor.SongPos)
for i, arr := range table.NoteRows {
if i >= len(v.d.Song.Score.Tracks) {
continue
}
to = from + len(arr) - 1
for j, note := range arr {
y := j + from
pos := v.d.Song.Score.SongPos(y)
v.d.Song.Score.Tracks[i].SetNote(pos, note)
}
}
return
}
// SearchResults
func (v *SearchResults) List() List {
return List{v}
}
func (l *SearchResults) Iterate(yield UnitSearchYieldFunc) {
for _, name := range sointu.UnitNames {
if !strings.HasPrefix(name, l.d.UnitSearchString) {
continue
}
if !yield(name) {
break
}
}
}
func (l *SearchResults) Selected() int {
return intMax(intMin(l.d.UnitSearchIndex, l.Count()-1), 0)
}
func (l *SearchResults) Selected2() int {
return intMax(intMin(l.d.UnitSearchIndex, l.Count()-1), 0)
}
func (l *SearchResults) SetSelected(value int) {
l.d.UnitSearchIndex = intMax(intMin(value, l.Count()-1), 0)
}
func (l *SearchResults) SetSelected2(value int) {
}
func (l *SearchResults) Count() (count int) {
for _, n := range sointu.UnitNames {
if strings.HasPrefix(n, l.d.UnitSearchString) {
count++
}
}
return
}

File diff suppressed because it is too large Load Diff

252
tracker/model_test.go Normal file
View File

@ -0,0 +1,252 @@
package tracker_test
import (
"bytes"
"encoding/binary"
"fmt"
"testing"
"github.com/vsariola/sointu/tracker"
"github.com/vsariola/sointu/vm"
)
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
}
type modelFuzzState struct {
model *tracker.Model
clipboard []byte
}
func (s *modelFuzzState) Iterate(yield func(string, func(p string, t *testing.T)) bool, seed int) {
// Ints
s.IterateInt("InstrumentVoices", s.model.InstrumentVoices().Int(), yield, seed)
s.IterateInt("TrackVoices", s.model.TrackVoices().Int(), yield, seed)
s.IterateInt("SongLength", s.model.SongLength().Int(), yield, seed)
s.IterateInt("BPM", s.model.BPM().Int(), yield, seed)
s.IterateInt("RowsPerPattern", s.model.RowsPerPattern().Int(), yield, seed)
s.IterateInt("RowsPerBeat", s.model.RowsPerBeat().Int(), yield, seed)
s.IterateInt("Step", s.model.Step().Int(), yield, seed)
s.IterateInt("Octave", s.model.Octave().Int(), yield, seed)
// Lists
s.IterateList("Instruments", s.model.Instruments().List(), yield, seed)
s.IterateList("Units", s.model.Units().List(), yield, seed)
s.IterateList("Tracks", s.model.Tracks().List(), yield, seed)
s.IterateList("OrderRows", s.model.OrderRows().List(), yield, seed)
s.IterateList("NoteRows", s.model.NoteRows().List(), yield, seed)
s.IterateList("UnitSearchResults", s.model.SearchResults().List(), yield, seed)
s.IterateBool("Panic", s.model.Panic().Bool(), yield, seed)
s.IterateBool("Recording", s.model.IsRecording().Bool(), yield, seed)
s.IterateBool("Playing", s.model.Playing().Bool(), yield, seed)
s.IterateBool("InstrEnlarged", s.model.InstrEnlarged().Bool(), yield, seed)
s.IterateBool("Effect", s.model.Effect().Bool(), yield, seed)
s.IterateBool("CommentExpanded", s.model.CommentExpanded().Bool(), yield, seed)
s.IterateBool("NoteTracking", s.model.NoteTracking().Bool(), yield, seed)
// Strings
s.IterateString("FilePath", s.model.FilePath().String(), yield, seed)
s.IterateString("InstrumentName", s.model.InstrumentName().String(), yield, seed)
s.IterateString("InstrumentComment", s.model.InstrumentComment().String(), yield, seed)
s.IterateString("UnitSearchText", s.model.UnitSearch().String(), yield, seed)
// Actions
s.IterateAction("AddTrack", s.model.AddTrack(), yield, seed)
s.IterateAction("DeleteTrack", s.model.DeleteTrack(), yield, seed)
s.IterateAction("AddInstrument", s.model.AddInstrument(), yield, seed)
s.IterateAction("DeleteInstrument", s.model.DeleteInstrument(), yield, seed)
s.IterateAction("AddUnitAfter", s.model.AddUnit(false), yield, seed)
s.IterateAction("AddUnitBefore", s.model.AddUnit(true), yield, seed)
s.IterateAction("DeleteUnit", s.model.DeleteUnit(), yield, seed)
s.IterateAction("ClearUnit", s.model.ClearUnit(), yield, seed)
s.IterateAction("Undo", s.model.Undo(), yield, seed)
s.IterateAction("Redo", s.model.Redo(), yield, seed)
s.IterateAction("RemoveUnused", s.model.RemoveUnused(), yield, seed)
s.IterateAction("AddSemitone", s.model.AddSemitone(), yield, seed)
s.IterateAction("SubtractSemitone", s.model.SubtractSemitone(), yield, seed)
s.IterateAction("AddOctave", s.model.AddOctave(), yield, seed)
s.IterateAction("SubtractOctave", s.model.SubtractOctave(), yield, seed)
s.IterateAction("EditNoteOff", s.model.EditNoteOff(), yield, seed)
s.IterateAction("Rewind", s.model.Rewind(), yield, seed)
s.IterateAction("AddOrderRowAfter", s.model.AddOrderRow(false), yield, seed)
s.IterateAction("AddOrderRowBefore", s.model.AddOrderRow(true), yield, seed)
s.IterateAction("DeleteOrderRowForward", s.model.DeleteOrderRow(false), yield, seed)
s.IterateAction("DeleteOrderRowBackward", s.model.DeleteOrderRow(true), yield, seed)
// Tables
s.IterateTable("Order", s.model.Order().Table(), yield, seed)
s.IterateTable("Notes", s.model.Notes().Table(), yield, seed)
}
func (s *modelFuzzState) IterateInt(name string, i tracker.Int, yield func(string, func(p string, t *testing.T)) bool, seed int) {
r := i.Range()
yield(name+".Set", func(p string, t *testing.T) {
i.Set(seed%(r.Max-r.Min+10) - 5 + r.Min)
})
yield(name+".Value", func(p string, t *testing.T) {
if v := i.Value(); v < r.Min || v > r.Max {
r := i.Range()
t.Errorf("Path: %s %s value out of range [%d,%d]: %d", p, name, r.Min, r.Max, v)
}
})
}
func (s *modelFuzzState) IterateAction(name string, a tracker.Action, yield func(string, func(p string, t *testing.T)) bool, seed int) {
yield(name+".Do", func(p string, t *testing.T) {
a.Do()
})
}
func (s *modelFuzzState) IterateBool(name string, b tracker.Bool, yield func(string, func(p string, t *testing.T)) bool, seed int) {
yield(name+".Set", func(p string, t *testing.T) {
b.Set(seed%2 == 0)
})
yield(name+".Toggle", func(p string, t *testing.T) {
b.Toggle()
})
}
func (s *modelFuzzState) IterateString(name string, str tracker.String, yield func(string, func(p string, t *testing.T)) bool, seed int) {
yield(name+".Set", func(p string, t *testing.T) {
str.Set(fmt.Sprintf("%d", seed))
})
}
func (s *modelFuzzState) IterateList(name string, l tracker.List, yield func(string, func(p string, t *testing.T)) bool, seed int) {
yield(name+".SetSelected", func(p string, t *testing.T) {
l.SetSelected(seed%50 - 16)
})
yield(name+".Count", func(p string, t *testing.T) {
if c := l.Count(); c > 0 {
if l.Selected() < 0 || l.Selected() >= c {
t.Errorf("Path: %s %s selected out of range: %d", p, name, l.Selected())
}
} else {
if l.Selected() != 0 {
t.Errorf("Path: %s %s selected out of range: %d", p, name, l.Selected())
}
}
})
yield(name+".SetSelected2", func(p string, t *testing.T) {
l.SetSelected2(seed%50 - 16)
})
yield(name+".Count2", func(p string, t *testing.T) {
if c := l.Count(); c > 0 {
if l.Selected2() < 0 || l.Selected2() >= c {
t.Errorf("Path: %s List selected2 out of range: %d", p, l.Selected2())
}
} else {
if l.Selected2() != 0 {
t.Errorf("Path: %s List selected2 out of range: %d", p, l.Selected2())
}
}
})
yield(name+".MoveElements", func(p string, t *testing.T) {
l.MoveElements(seed%2*2 - 1)
})
yield(name+".DeleteElementsForward", func(p string, t *testing.T) {
l.DeleteElements(false)
})
yield(name+".DeleteElementsBackward", func(p string, t *testing.T) {
l.DeleteElements(true)
})
yield(name+".CopyElements", func(p string, t *testing.T) {
s.clipboard, _ = l.CopyElements()
})
yield(name+".PasteElements", func(p string, t *testing.T) {
l.PasteElements(s.clipboard)
})
}
func (s *modelFuzzState) IterateTable(name string, table tracker.Table, yield func(string, func(p string, t *testing.T)) bool, seed int) {
yield(name+".SetCursor", func(p string, t *testing.T) {
table.SetCursor(tracker.Point{seed % 16, seed * 1337 % 16})
})
yield(name+".SetCursor2", func(p string, t *testing.T) {
table.SetCursor2(tracker.Point{seed % 16, seed * 1337 % 16})
})
yield(name+".Cursor", func(p string, t *testing.T) {
if c := table.Cursor(); c.X < 0 || (c.X >= table.Width() && table.Width() > 0) || c.Y < 0 || (c.Y >= table.Height() && table.Height() > 0) {
t.Errorf("Path: %s Table cursor out of range: %v", p, c)
}
})
yield(name+".Cursor2", func(p string, t *testing.T) {
if c := table.Cursor2(); c.X < 0 || (c.X >= table.Width() && table.Width() > 0) || c.Y < 0 || (c.Y >= table.Height() && table.Height() > 0) {
t.Errorf("Path: %s Table cursor2 out of range: %v", p, c)
}
})
yield(name+".SetCursorX", func(p string, t *testing.T) {
table.SetCursorX(seed % 16)
})
yield(name+".SetCursorY", func(p string, t *testing.T) {
table.SetCursorY(seed % 16)
})
yield(name+".MoveCursor", func(p string, t *testing.T) {
table.MoveCursor(seed%2*2-1, seed%2*2-1)
})
yield(name+".Copy", func(p string, t *testing.T) {
s.clipboard, _ = table.Copy()
})
yield(name+".Paste", func(p string, t *testing.T) {
table.Paste(s.clipboard)
})
yield(name+".Clear", func(p string, t *testing.T) {
table.Clear()
})
yield(name+".Fill", func(p string, t *testing.T) {
table.Fill(seed % 16)
})
yield(name+".Add", func(p string, t *testing.T) {
table.Add(seed % 16)
})
}
func FuzzModel(f *testing.F) {
seed := make([]byte, 1)
for i := range seed {
seed[i] = byte(i)
}
f.Add(seed)
f.Fuzz(func(t *testing.T, slice []byte) {
reader := bytes.NewReader(slice)
synther := vm.GoSynther{}
model, player := tracker.NewModelPlayer(synther, "")
buf := make([][2]float32, 2048)
closeChan := make(chan struct{})
go func() {
loop:
for {
select {
case <-closeChan:
break loop
default:
ctx := NullContext{}
player.Process(buf, ctx)
}
}
}()
state := modelFuzzState{model: model}
count := 0
state.Iterate(func(n string, f func(p string, t *testing.T)) bool {
count++
return true
}, 0)
totalPath := ""
for m, err := binary.ReadVarint(reader); err == nil; m, err = binary.ReadVarint(reader) {
seed := int(m)
index := seed % count
state.Iterate(func(n string, f func(p string, t *testing.T)) bool {
if index == 0 {
totalPath += n + ". "
f(totalPath, t)
}
index--
return index > 0
}, seed)
}
closeChan <- struct{}{}
})
}

345
tracker/params.go Normal file
View File

@ -0,0 +1,345 @@
package tracker
import (
"fmt"
"math"
"slices"
"strconv"
"github.com/vsariola/sointu"
"github.com/vsariola/sointu/vm"
)
type (
Parameter interface {
IntData
Type() ParameterType
Name() string
Hint() string
LargeStep() int
Reset()
}
parameter struct {
m *Model
unit *sointu.Unit
}
NamedParameter struct {
parameter
up *sointu.UnitParameter
}
DelayTimeParameter struct {
parameter
index int
}
DelayLinesParameter struct{ parameter }
GmDlsEntryParameter struct{ parameter }
ReverbParameter struct{ parameter }
Params Model
ParamYieldFunc func(Parameter)
ParameterType int
)
const (
IntegerParameter ParameterType = iota
BoolParameter
IDParameter
)
// Model methods
func (m *Model) Params() *Params { return (*Params)(m) }
// parameter methods
func (p parameter) change(kind string) func() {
return p.m.change("Parameter."+kind, PatchChange, MinorChange)
}
// ParamList
func (pl *Params) List() List { return List{pl} }
func (pl *Params) Selected() int { return pl.d.ParamIndex }
func (pl *Params) Selected2() int { return pl.Selected() }
func (pl *Params) SetSelected(value int) { pl.d.ParamIndex = intMax(intMin(value, pl.Count()-1), 0) }
func (pl *Params) SetSelected2(value int) {}
func (pl *Params) cancel() { (*Model)(pl).changeCancel = true }
func (pl *Params) change(n string, severity ChangeSeverity) func() {
return (*Model)(pl).change("ParamList."+n, PatchChange, severity)
}
func (pl *Params) Count() int {
count := 0
pl.Iterate(func(p Parameter) {
count++
})
return count
}
func (pl *Params) SelectedItem() (ret Parameter) {
index := pl.Selected()
pl.Iterate(func(param Parameter) {
if index == 0 {
ret = param
}
index--
})
return
}
func (pl *Params) Iterate(yield ParamYieldFunc) {
if pl.d.InstrIndex < 0 || pl.d.InstrIndex >= len(pl.d.Song.Patch) {
return
}
if pl.d.UnitIndex < 0 || pl.d.UnitIndex >= len(pl.d.Song.Patch[pl.d.InstrIndex].Units) {
return
}
unit := &pl.d.Song.Patch[pl.d.InstrIndex].Units[pl.d.UnitIndex]
unitType, ok := sointu.UnitTypes[unit.Type]
if !ok {
return
}
for i := range unitType {
if !unitType[i].CanSet {
continue
}
if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && i >= 11 {
break // don't show the sample related params unless necessary
}
yield(NamedParameter{
parameter: parameter{m: (*Model)(pl), unit: unit},
up: &unitType[i],
})
}
if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample {
yield(GmDlsEntryParameter{parameter: parameter{m: (*Model)(pl), unit: unit}})
}
switch {
case unit.Type == "delay":
if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 {
unit.VarArgs = append(unit.VarArgs, 1)
}
yield(ReverbParameter{parameter: parameter{m: (*Model)(pl), unit: unit}})
yield(DelayLinesParameter{parameter: parameter{m: (*Model)(pl), unit: unit}})
for i := range unit.VarArgs {
yield(DelayTimeParameter{parameter: parameter{m: (*Model)(pl), unit: unit}, index: i})
}
}
}
// NamedParameter
func (p NamedParameter) Name() string { return p.up.Name }
func (p NamedParameter) Range() intRange { return intRange{Min: p.up.MinValue, Max: p.up.MaxValue} }
func (p NamedParameter) Value() int { return p.unit.Parameters[p.up.Name] }
func (p NamedParameter) setValue(value int) { p.unit.Parameters[p.up.Name] = value }
func (p NamedParameter) Reset() {
v, ok := defaultUnits[p.unit.Type].Parameters[p.up.Name]
if !ok || p.unit.Parameters[p.up.Name] == v {
return
}
defer p.parameter.change("Reset")()
p.unit.Parameters[p.up.Name] = v
}
func (p NamedParameter) Type() ParameterType {
if p.unit.Type == "send" && p.up.Name == "target" {
return IDParameter
}
if p.up.MinValue == 0 && p.up.MaxValue == 1 {
return BoolParameter
}
return IntegerParameter
}
func (p NamedParameter) Hint() string {
val := p.Value()
text := p.m.d.Song.Patch.ParamHintString(p.m.d.InstrIndex, p.m.d.UnitIndex, p.up.Name)
if text != "" {
text = fmt.Sprintf("%v / %v", val, text)
} else {
text = strconv.Itoa(val)
}
return text
}
func (p NamedParameter) LargeStep() int {
if p.up.Name == "transpose" {
return 12
}
return 16
}
// GmDlsEntryParameter
func (p GmDlsEntryParameter) Name() string { return "sample" }
func (p GmDlsEntryParameter) Type() ParameterType { return IntegerParameter }
func (p GmDlsEntryParameter) Range() intRange { return intRange{Min: 0, Max: len(GmDlsEntries)} }
func (p GmDlsEntryParameter) LargeStep() int { return 16 }
func (p GmDlsEntryParameter) Reset() { return }
func (p GmDlsEntryParameter) Value() int {
key := vm.SampleOffset{Start: uint32(p.unit.Parameters["samplestart"]), LoopStart: uint16(p.unit.Parameters["loopstart"]), LoopLength: uint16(p.unit.Parameters["looplength"])}
if v, ok := gmDlsEntryMap[key]; ok {
return v + 1
}
return 0
}
func (p GmDlsEntryParameter) setValue(v int) {
if v < 1 || v > len(GmDlsEntries) {
return
}
e := GmDlsEntries[v-1]
p.unit.Parameters["samplestart"] = e.Start
p.unit.Parameters["loopstart"] = e.LoopStart
p.unit.Parameters["looplength"] = e.LoopLength
p.unit.Parameters["transpose"] = 64 + e.SuggestedTranspose
}
func (p GmDlsEntryParameter) Hint() string {
if v := p.Value(); v > 0 {
return fmt.Sprintf("%v / %v", v, GmDlsEntries[v-1].Name)
}
return "0 / custom"
}
// DelayTimeParameter
func (p DelayTimeParameter) Name() string { return "delaytime" }
func (p DelayTimeParameter) Type() ParameterType { return IntegerParameter }
func (p DelayTimeParameter) LargeStep() int { return 16 }
func (p DelayTimeParameter) Reset() { return }
func (p DelayTimeParameter) Value() int {
if p.index < 0 || p.index >= len(p.unit.VarArgs) {
return 1
}
return p.unit.VarArgs[p.index]
}
func (p DelayTimeParameter) setValue(v int) {
p.unit.VarArgs[p.index] = v
}
func (p DelayTimeParameter) Range() intRange {
if p.unit.Parameters["notetracking"] == 2 {
return intRange{Min: 1, Max: 576}
}
return intRange{Min: 1, Max: 65535}
}
func (p DelayTimeParameter) Hint() string {
val := p.Value()
var text string
switch p.unit.Parameters["notetracking"] {
default:
case 0:
text = fmt.Sprintf("%v / %.3f rows", val, float32(val)/float32(p.m.d.Song.SamplesPerRow()))
case 1:
relPitch := float64(val) / 10787
semitones := -math.Log2(relPitch) * 12
text = fmt.Sprintf("%v / %.3f st", val, semitones)
case 2:
k := 0
v := val
for v&1 == 0 { // divide val by 2 until it is odd
v >>= 1
k++
}
switch v {
case 1:
if k <= 7 {
text = fmt.Sprintf(" (1/%d triplet)", 1<<(7-k))
}
case 3:
if k <= 6 {
text = fmt.Sprintf(" (1/%d)", 1<<(6-k))
}
break
case 9:
if k <= 5 {
text = fmt.Sprintf(" (1/%d dotted)", 1<<(5-k))
}
}
text = fmt.Sprintf("%v / %.3f beats%s", val, float32(val)/48.0, text)
}
if p.unit.Parameters["stereo"] == 1 {
if p.index < len(p.unit.VarArgs)/2 {
text += " R"
} else {
text += " L"
}
}
return text
}
// DelayLinesParameter
func (p DelayLinesParameter) Name() string { return "delaylines" }
func (p DelayLinesParameter) Type() ParameterType { return IntegerParameter }
func (p DelayLinesParameter) Range() intRange { return intRange{Min: 1, Max: 32} }
func (p DelayLinesParameter) LargeStep() int { return 4 }
func (p DelayLinesParameter) Reset() { return }
func (p DelayLinesParameter) Hint() string { return strconv.Itoa(p.Value()) }
func (p DelayLinesParameter) Value() int {
val := len(p.unit.VarArgs)
if p.unit.Parameters["stereo"] == 1 {
val /= 2
}
return val
}
func (p DelayLinesParameter) setValue(v int) {
targetLines := v
if p.unit.Parameters["stereo"] == 1 {
targetLines *= 2
}
for len(p.unit.VarArgs) < targetLines {
p.unit.VarArgs = append(p.unit.VarArgs, 1)
}
p.unit.VarArgs = p.unit.VarArgs[:targetLines]
}
// ReverbParameter
func (p ReverbParameter) Name() string { return "reverb" }
func (p ReverbParameter) Type() ParameterType { return IntegerParameter }
func (p ReverbParameter) Range() intRange { return intRange{Min: 0, Max: len(reverbs)} }
func (p ReverbParameter) LargeStep() int { return 1 }
func (p ReverbParameter) Reset() { return }
func (p ReverbParameter) Value() int {
i := slices.IndexFunc(reverbs, func(d delayPreset) bool {
return d.stereo == p.unit.Parameters["stereo"] && p.unit.Parameters["notetracking"] == 0 && slices.Equal(d.varArgs, p.unit.VarArgs)
})
return i + 1
}
func (p ReverbParameter) setValue(v int) {
if v < 1 || v > len(reverbs) {
return
}
entry := reverbs[v-1]
p.unit.Parameters["stereo"] = entry.stereo
p.unit.Parameters["notetracking"] = 0
p.unit.VarArgs = make([]int, len(entry.varArgs))
copy(p.unit.VarArgs, entry.varArgs)
}
func (p ReverbParameter) Hint() string {
i := p.Value()
if i > 0 {
return fmt.Sprintf("%v / %v", i, reverbs[i-1].name)
}
return "0 / custom"
}

View File

@ -19,18 +19,19 @@ type (
song sointu.Song // the song being played
playing bool // is the player playing the score or not
rowtime int // how many samples have been played in the current row
position ScoreRow // the current position in the score
songPos sointu.SongPos // the current position in the score
avgVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the average volume
peakVolumeMeter VolumeAnalyzer // the volume analyzer used to calculate the peak volume
voiceLevels [vm.MAX_VOICES]float32 // a level that can be used to visualize the volume of each voice
voices [vm.MAX_VOICES]voice
loop Loop
recState recState // is the recording off; are we waiting for a note; or are we recording
recording Recording // the recorded MIDI events and BPM
synther sointu.Synther // the synther used to create new synths
playerMessages chan<- PlayerMessage
modelMessages <-chan interface{}
synther sointu.Synther // the synther used to create new synths
playerMsgs chan<- PlayerMsg
modelMsgs <-chan interface{}
}
// PlayerProcessContext is the context given to the player when processing
@ -50,29 +51,19 @@ type (
Note byte
}
// PlayerMessage is a message sent from the player to the model. The Inner
// PlayerMsg is a message sent from the player to the model. The Inner
// field can contain any message. Panic, AverageVolume, PeakVolume, SongRow
// and VoiceStates transmitted frequently, with every message, so they are
// treated specially, to avoid boxing. All the rest messages can be boxed to
// Inner interface{}
PlayerMessage struct {
PlayerMsg struct {
Panic bool
AverageVolume Volume
PeakVolume Volume
SongRow ScoreRow
SongPosition sointu.SongPos
VoiceLevels [vm.MAX_VOICES]float32
Inner interface{}
}
// PlayerCrashMessage is sent to the model when the player crashes.
PlayerCrashMessage struct {
error
}
// PlayerVolumeErrorMessage is sent to the model there is an error in the volume analyzer. The error is not fatal.
PlayerVolumeErrorMessage struct {
error
}
)
type (
@ -93,20 +84,6 @@ const (
const numRenderTries = 10000
// NewPlayer creates a new player. The playerMessages channel is used to send
// messages to the model. The modelMessages channel is used to receive messages
// from the model. The synther is used to create new synths.
func NewPlayer(synther sointu.Synther, playerMessages chan<- PlayerMessage, modelMessages <-chan interface{}) *Player {
p := &Player{
playerMessages: playerMessages,
modelMessages: modelMessages,
synther: synther,
avgVolumeMeter: VolumeAnalyzer{Attack: 0.3, Release: 0.3, Min: -100, Max: 20},
peakVolumeMeter: VolumeAnalyzer{Attack: 1e-4, Release: 1, Min: -100, Max: 20},
}
return p
}
// Process renders audio to the given buffer, trying to fill it completely. If
// the buffer is not filled, the synth is destroyed and an error is sent to the
// model. context tells the player which MIDI events happen during the current
@ -152,6 +129,9 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
if p.playing {
timeUntilRowAdvance = p.song.SamplesPerRow() - p.rowtime
}
if timeUntilRowAdvance < 0 {
timeUntilRowAdvance = 0
}
var rendered, timeAdvanced int
var err error
if p.synth != nil {
@ -169,7 +149,7 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
}
if err != nil {
p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synth.Render: %w", err)})
p.send(Alert{Message: fmt.Sprintf("synth.Render: %s", err.Error()), Priority: Error, Name: "PlayerCrash"})
}
buffer = buffer[rendered:]
frame += rendered
@ -189,47 +169,41 @@ func (p *Player) Process(buffer sointu.AudioBuffer, context PlayerProcessContext
if len(buffer) == 0 {
err := p.avgVolumeMeter.Update(oldBuffer)
err2 := p.peakVolumeMeter.Update(oldBuffer)
var msg interface{}
if err != nil {
msg = PlayerCrashMessage{err}
p.synth = nil
p.sendAlert("PlayerVolume", err.Error(), Warning)
return
}
if err2 != nil {
msg = PlayerCrashMessage{err}
p.synth = nil
p.sendAlert("PlayerVolume", err2.Error(), Warning)
return
}
p.send(msg)
p.send(nil)
return
}
}
// we were not able to fill the buffer with NUM_RENDER_TRIES attempts, destroy synth and throw an error
p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synth did not fill the audio buffer even with %d render calls", numRenderTries)})
p.sendAlert("PlayerCrash", fmt.Sprintf("synth did not fill the audio buffer even with %d render calls", numRenderTries), Error)
}
func (p *Player) advanceRow() {
if p.song.Score.Length == 0 || p.song.Score.RowsPerPattern == 0 {
return
}
p.position.Row++ // advance row (this is why we subtracted one in Play())
p.position = p.position.Wrap(p.song.Score)
p.songPos.PatternRow++ // advance row (this is why we subtracted one in Play())
if p.loop.Length > 0 && p.songPos.PatternRow >= p.song.Score.RowsPerPattern && p.songPos.OrderRow == p.loop.Start+p.loop.Length-1 {
p.songPos.PatternRow = 0
p.songPos.OrderRow = p.loop.Start
}
p.songPos = p.song.Score.Wrap(p.songPos)
p.send(nil) // just send volume and song row information
lastVoice := 0
for i, t := range p.song.Score.Tracks {
start := lastVoice
lastVoice = start + t.NumVoices
if p.position.Pattern < 0 || p.position.Pattern >= len(t.Order) {
continue
}
o := t.Order[p.position.Pattern]
if o < 0 || o >= len(t.Patterns) {
continue
}
pat := t.Patterns[o]
if p.position.Row < 0 || p.position.Row >= len(pat) {
continue
}
n := pat[p.position.Row]
n := t.Note(p.songPos)
switch {
case n == 0:
p.releaseTrack(i)
@ -245,9 +219,9 @@ func (p *Player) processMessages(context PlayerProcessContext) {
loop:
for { // process new message
select {
case msg := <-p.modelMessages:
case msg := <-p.modelMsgs:
switch m := msg.(type) {
case ModelPanicMessage:
case PanicMsg:
if m.bool {
p.synth = nil
} else {
@ -261,23 +235,25 @@ loop:
p.compileOrUpdateSynth()
case sointu.Score:
p.song.Score = m
case ModelPlayingChangedMessage:
case Loop:
p.loop = m
case IsPlayingMsg:
p.playing = bool(m.bool)
if !p.playing {
for i := range p.song.Score.Tracks {
p.releaseTrack(i)
}
}
case ModelBPMChangedMessage:
case BPMMsg:
p.song.BPM = m.int
p.compileOrUpdateSynth()
case ModelRowsPerBeatChangedMessage:
case RowsPerBeatMsg:
p.song.RowsPerBeat = m.int
p.compileOrUpdateSynth()
case ModelPlayFromPositionMessage:
case StartPlayMsg:
p.playing = true
p.position = m.ScoreRow
p.position.Row--
p.songPos = m.SongPos
p.songPos.PatternRow--
p.rowtime = math.MaxInt
for i, t := range p.song.Score.Tracks {
if !t.Effect {
@ -285,19 +261,19 @@ loop:
p.releaseTrack(i)
}
}
case ModelNoteOnMessage:
case NoteOnMsg:
if m.IsInstr {
p.triggerInstrument(m.Instr, m.Note)
} else {
p.triggerTrack(m.Track, m.Note)
}
case ModelNoteOffMessage:
case NoteOffMsg:
if m.IsInstr {
p.releaseInstrument(m.Instr, m.Note)
} else {
p.releaseTrack(m.Track)
}
case ModelRecordingMessage:
case RecordingMsg:
if m.bool {
p.recState = recStateWaitingForNote
p.recording = Recording{}
@ -317,6 +293,15 @@ loop:
}
}
func (p *Player) sendAlert(name, message string, priority AlertPriority) {
p.send(Alert{
Name: name,
Priority: priority,
Message: message,
Duration: defaultAlertDuration,
})
}
func (p *Player) compileOrUpdateSynth() {
if p.song.BPM <= 0 {
return // bpm not set yet
@ -325,7 +310,7 @@ func (p *Player) compileOrUpdateSynth() {
err := p.synth.Update(p.song.Patch, p.song.BPM)
if err != nil {
p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synth.Update: %w", err)})
p.sendAlert("PlayerCrash", fmt.Sprintf("synth.Update: %v", err), Error)
return
}
} else {
@ -333,7 +318,7 @@ func (p *Player) compileOrUpdateSynth() {
p.synth, err = p.synther.Synth(p.song.Patch, p.song.BPM)
if err != nil {
p.synth = nil
p.send(PlayerCrashMessage{fmt.Errorf("synther.Synth: %w", err)})
p.sendAlert("PlayerCrash", fmt.Sprintf("synther.Synth: %v", err), Error)
return
}
}
@ -342,7 +327,7 @@ func (p *Player) compileOrUpdateSynth() {
// all sends from player are always non-blocking, to ensure that the player thread cannot end up in a dead-lock
func (p *Player) send(message interface{}) {
select {
case p.playerMessages <- PlayerMessage{Panic: p.synth == nil, AverageVolume: p.avgVolumeMeter.Level, PeakVolume: p.peakVolumeMeter.Level, SongRow: p.position, VoiceLevels: p.voiceLevels, Inner: message}:
case p.playerMsgs <- PlayerMsg{Panic: p.synth == nil, AverageVolume: p.avgVolumeMeter.Level, PeakVolume: p.peakVolumeMeter.Level, SongPosition: p.songPos, VoiceLevels: p.voiceLevels, Inner: message}:
default:
}
}

View File

@ -3,6 +3,8 @@ package tracker
import (
"embed"
"io/fs"
"os"
"path/filepath"
"sort"
"github.com/vsariola/sointu"
@ -12,23 +14,31 @@ import (
//go:generate go run generate/main.go
// GmDlsEntry is a single sample entry from the gm.dls file
type GmDlsEntry struct {
Start int // sample start offset in words
LoopStart int // loop start offset in words
LoopLength int // loop length in words
SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch
Name string // sample name
}
type (
// GmDlsEntry is a single sample entry from the gm.dls file
GmDlsEntry struct {
Start int // sample start offset in words
LoopStart int // loop start offset in words
LoopLength int // loop length in words
SuggestedTranspose int // suggested transpose in semitones, so that all samples play at same pitch
Name string // sample name
}
// GmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the
InstrumentPresetYieldFunc func(index int, item string) (ok bool)
LoadPreset struct {
Index int
*Model
}
)
// gmDlsEntryMap is a reverse map, to find the index of the GmDlsEntry in the
// GmDlsEntries list based on the sample offset. Do not modify during runtime.
var GmDlsEntryMap = make(map[vm.SampleOffset]int)
var gmDlsEntryMap = make(map[vm.SampleOffset]int)
func init() {
for i, e := range GmDlsEntries {
key := vm.SampleOffset{Start: uint32(e.Start), LoopStart: uint16(e.LoopStart), LoopLength: uint16(e.LoopLength)}
GmDlsEntryMap[key] = i
gmDlsEntryMap[key] = i
}
}
@ -49,6 +59,7 @@ var defaultUnits = map[string]sointu.Unit{
"pan": {Type: "pan", Parameters: map[string]int{"stereo": 0, "panning": 64}},
"gain": {Type: "gain", Parameters: map[string]int{"stereo": 0, "gain": 64}},
"invgain": {Type: "invgain", Parameters: map[string]int{"stereo": 0, "invgain": 64}},
"dbgain": {Type: "dbgain", Parameters: map[string]int{"stereo": 0, "decibels": 64}},
"crush": {Type: "crush", Parameters: map[string]int{"stereo": 0, "resolution": 64}},
"clip": {Type: "clip", Parameters: map[string]int{"stereo": 0}},
"hold": {Type: "hold", Parameters: map[string]int{"stereo": 0, "holdfreq": 64}},
@ -102,12 +113,6 @@ var defaultSong = sointu.Song{
}}},
}
type delayPreset struct {
name string
stereo int
varArgs []int
}
var reverbs = []delayPreset{
{"stereo", 1, []int{1116, 1188, 1276, 1356, 1422, 1492, 1556, 1618,
1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642,
@ -116,11 +121,43 @@ var reverbs = []delayPreset{
{"right", 0, []int{1140, 1212, 1300, 1380, 1446, 1516, 1580, 1642}},
}
type instrumentPresets []sointu.Instrument
type delayPreset struct {
name string
stereo int
varArgs []int
}
func (m *Model) IterateInstrumentPresets(yield InstrumentPresetYieldFunc) {
for index, instr := range instrumentPresets {
if !yield(index, instr.Name) {
return
}
}
}
func (m *Model) LoadPreset(index int) Action {
return Action{do: func() {
defer m.change("LoadPreset", PatchChange, MajorChange)()
if m.d.InstrIndex < 0 {
m.d.InstrIndex = 0
}
m.d.InstrIndex2 = m.d.InstrIndex
for m.d.InstrIndex >= len(m.d.Song.Patch) {
m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy())
}
newInstr := instrumentPresets[index].Copy()
(*Model)(m).assignUnitIDs(newInstr.Units)
m.d.Song.Patch[m.d.InstrIndex] = newInstr
}, allowed: func() bool {
return true
}}
}
type instrumentPresetsSlice []sointu.Instrument
//go:embed presets/*
var instrumentPresetFS embed.FS
var InstrumentPresets instrumentPresets
var instrumentPresets instrumentPresetsSlice
func init() {
fs.WalkDir(instrumentPresetFS, ".", func(path string, d fs.DirEntry, err error) error {
@ -135,23 +172,34 @@ func init() {
return nil
}
var instr sointu.Instrument
if yaml.Unmarshal(data, &instr) != nil {
return nil
if yaml.Unmarshal(data, &instr) == nil {
instrumentPresets = append(instrumentPresets, instr)
}
InstrumentPresets = append(InstrumentPresets, instr)
return nil
})
sort.Sort(InstrumentPresets)
if configDir, err := os.UserConfigDir(); err == nil {
userPresets := filepath.Join(configDir, "sointu", "presets")
filepath.WalkDir(userPresets, 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 {
instrumentPresets = append(instrumentPresets, instr)
}
return nil
})
}
sort.Sort(instrumentPresets)
}
func (p instrumentPresets) Len() int {
return len(p)
}
func (p instrumentPresets) Less(i, j int) bool {
return p[i].Name < p[j].Name
}
func (p instrumentPresets) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
func (p instrumentPresetsSlice) Len() int { return len(p) }
func (p instrumentPresetsSlice) Less(i, j int) bool { return p[i].Name < p[j].Name }
func (p instrumentPresetsSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

View File

@ -22,9 +22,9 @@ type recordingNote struct {
var ErrInvalidRows = errors.New("rows per beat and rows per pattern must be greater than 1")
func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Song, error) {
func (recording *Recording) Score(patch sointu.Patch, rowsPerBeat, rowsPerPattern int) (sointu.Score, error) {
if rowsPerBeat <= 1 || rowsPerPattern <= 1 {
return sointu.Song{}, ErrInvalidRows
return sointu.Score{}, ErrInvalidRows
}
channelNotes := make([][]recordingNote, 0)
// find the length of each note and assign it to its respective channel
@ -77,6 +77,12 @@ func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern
tracks[i][oldestIndex] = append(tracks[i][oldestIndex], n)
}
}
// if there was tracks that had no notes, create empty tracks for them
for i := range channelNotes {
if l := len(tracks[i]); l == 0 && l < patch[i].NumVoices {
tracks[i] = append(tracks[i], []recordingNote{})
}
}
songLengthPatterns := (frameToRow(recording.BPM, rowsPerBeat, recording.TotalFrames) + rowsPerPattern - 1) / rowsPerPattern
songLengthRows := songLengthPatterns * rowsPerPattern
songTracks := make([]sointu.Track, 0)
@ -88,15 +94,18 @@ func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern
flatPattern[k] = 1 // set all notes as holds at first
}
for _, n := range t {
flatPattern.Set(n.startRow, n.note)
if n.startRow >= songLengthRows {
continue
}
flatPattern[n.startRow] = n.note
if n.endRow < songLengthRows {
for l := n.startRow + 1; l < n.endRow; l++ {
flatPattern.Set(l, 1)
flatPattern[l] = 1
}
flatPattern.Set(n.endRow, 0)
flatPattern[n.endRow] = 0
} else {
for l := n.startRow + 1; l < songLengthRows; l++ {
flatPattern.Set(l, 1)
flatPattern[l] = 1
}
}
}
@ -136,7 +145,7 @@ func (recording *Recording) Song(patch sointu.Patch, rowsPerBeat, rowsPerPattern
}
}
score := sointu.Score{Length: songLengthPatterns, RowsPerPattern: rowsPerPattern, Tracks: songTracks}
return sointu.Song{BPM: int(recording.BPM + 0.5), RowsPerBeat: rowsPerBeat, Score: score, Patch: patch.Copy()}, nil
return score, nil
}
func frameToRow(BPM float64, rowsPerBeat, frame int) int {

View File

@ -1,106 +0,0 @@
package tracker
import "github.com/vsariola/sointu"
type (
// ScoreRow identifies a row of the song score.
ScoreRow struct {
Pattern int
Row int
}
// ScorePoint identifies a row and a track in a song score.
ScorePoint struct {
Track int
ScoreRow
}
// ScoreRect identifies a rectangular area in a song score.
ScoreRect struct {
Corner1 ScorePoint
Corner2 ScorePoint
}
)
func (r ScoreRow) AddRows(rows int) ScoreRow {
return ScoreRow{Row: r.Row + rows, Pattern: r.Pattern}
}
func (r ScoreRow) AddPatterns(patterns int) ScoreRow {
return ScoreRow{Row: r.Row, Pattern: r.Pattern + patterns}
}
func (r ScoreRow) Wrap(score sointu.Score) ScoreRow {
totalRow := r.Pattern*score.RowsPerPattern + r.Row
r.Row = mod(totalRow, score.RowsPerPattern)
r.Pattern = mod((totalRow-r.Row)/score.RowsPerPattern, score.Length)
return r
}
func (r ScoreRow) Clamp(score sointu.Score) ScoreRow {
totalRow := r.Pattern*score.RowsPerPattern + r.Row
if totalRow < 0 {
totalRow = 0
}
if totalRow >= score.LengthInRows() {
totalRow = score.LengthInRows() - 1
}
r.Row = totalRow % score.RowsPerPattern
r.Pattern = ((totalRow - r.Row) / score.RowsPerPattern) % score.Length
return r
}
func (r ScorePoint) AddRows(rows int) ScorePoint {
return ScorePoint{Track: r.Track, ScoreRow: r.ScoreRow.AddRows(rows)}
}
func (r ScorePoint) AddPatterns(patterns int) ScorePoint {
return ScorePoint{Track: r.Track, ScoreRow: r.ScoreRow.AddPatterns(patterns)}
}
func (p ScorePoint) Wrap(score sointu.Score) ScorePoint {
p.Track = mod(p.Track, len(score.Tracks))
p.ScoreRow = p.ScoreRow.Wrap(score)
return p
}
func (p ScorePoint) Clamp(score sointu.Score) ScorePoint {
if p.Track < 0 {
p.Track = 0
} else if l := len(score.Tracks); p.Track >= l {
p.Track = l - 1
}
p.ScoreRow = p.ScoreRow.Clamp(score)
return p
}
func (r *ScoreRect) Contains(p ScorePoint) bool {
track1, track2 := r.Corner1.Track, r.Corner2.Track
if track2 < track1 {
track1, track2 = track2, track1
}
if p.Track < track1 || p.Track > track2 {
return false
}
pattern1, row1, pattern2, row2 := r.Corner1.Pattern, r.Corner1.Row, r.Corner2.Pattern, r.Corner2.Row
if pattern2 < pattern1 || (pattern1 == pattern2 && row2 < row1) {
pattern1, row1, pattern2, row2 = pattern2, row2, pattern1, row1
}
if p.Pattern < pattern1 || p.Pattern > pattern2 {
return false
}
if p.Pattern == pattern1 && p.Row < row1 {
return false
}
if p.Pattern == pattern2 && p.Row > row2 {
return false
}
return true
}
func mod(a, b int) int {
if a < 0 {
return b - 1 - mod(-a-1, b)
}
return a % b
}

97
tracker/string.go Normal file
View File

@ -0,0 +1,97 @@
package tracker
type (
String struct {
StringData
}
StringData interface {
Value() string
setValue(string)
change(kind string) func()
}
FilePath Model
InstrumentName Model
InstrumentComment Model
UnitSearch Model
)
func (v String) Set(value string) {
if v.Value() != value {
defer v.change("Set")()
v.setValue(value)
}
}
// Model methods
func (m *Model) FilePath() *FilePath { return (*FilePath)(m) }
func (m *Model) InstrumentName() *InstrumentName { return (*InstrumentName)(m) }
func (m *Model) InstrumentComment() *InstrumentComment { return (*InstrumentComment)(m) }
func (m *Model) UnitSearch() *UnitSearch { return (*UnitSearch)(m) }
// FilePathString
func (v *FilePath) String() String { return String{v} }
func (v *FilePath) Value() string { return v.d.FilePath }
func (v *FilePath) setValue(value string) { v.d.FilePath = value }
func (v *FilePath) change(kind string) func() { return func() {} }
// UnitSearchString
func (v *UnitSearch) String() String { return String{v} }
func (v *UnitSearch) Value() string { return v.d.UnitSearchString }
func (v *UnitSearch) setValue(value string) {
v.d.UnitSearchString = value
v.d.UnitSearching = true
}
func (v *UnitSearch) change(kind string) func() { return func() {} }
// InstrumentNameString
func (v *InstrumentName) String() String {
return String{v}
}
func (v *InstrumentName) Value() string {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return ""
}
return v.d.Song.Patch[v.d.InstrIndex].Name
}
func (v *InstrumentName) setValue(value string) {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return
}
v.d.Song.Patch[v.d.InstrIndex].Name = value
}
func (v *InstrumentName) change(kind string) func() {
return (*Model)(v).change("InstrumentNameString."+kind, PatchChange, MinorChange)
}
// InstrumentComment
func (v *InstrumentComment) String() String {
return String{v}
}
func (v *InstrumentComment) Value() string {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return ""
}
return v.d.Song.Patch[v.d.InstrIndex].Comment
}
func (v *InstrumentComment) setValue(value string) {
if v.d.InstrIndex < 0 || v.d.InstrIndex >= len(v.d.Song.Patch) {
return
}
v.d.Song.Patch[v.d.InstrIndex].Comment = value
}
func (v *InstrumentComment) change(kind string) func() {
return (*Model)(v).change("InstrumentComment."+kind, PatchChange, MinorChange)
}

632
tracker/table.go Normal file
View File

@ -0,0 +1,632 @@
package tracker
import (
"github.com/vsariola/sointu"
"gopkg.in/yaml.v3"
)
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) (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
}
Order Model
Notes Model
)
// Model methods
func (m *Model) Order() *Order { return (*Order)(m) }
func (m *Model) Notes() *Notes { return (*Notes)(m) }
// 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
}
}
// Table methods
func (v Table) Range() (rect Rect) {
rect.TopLeft.X = intMin(v.Cursor().X, v.Cursor2().X)
rect.TopLeft.Y = intMin(v.Cursor().Y, v.Cursor2().Y)
rect.BottomRight.X = intMax(v.Cursor().X, v.Cursor2().X)
rect.BottomRight.Y = intMax(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) 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) {
defer v.change("Add", MinorChange)()
if !v.add(v.Range(), delta) {
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)
}
// Order methods
func (v *Order) Table() Table {
return Table{v}
}
func (m *Order) Cursor() Point {
t := intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
p := intMax(intMin(m.d.Cursor.OrderRow, m.d.Song.Score.Length-1), 0)
return Point{t, p}
}
func (m *Order) Cursor2() Point {
t := intMax(intMin(m.d.Cursor2.Track, len(m.d.Song.Score.Tracks)-1), 0)
p := intMax(intMin(m.d.Cursor2.OrderRow, m.d.Song.Score.Length-1), 0)
return Point{t, p}
}
func (m *Order) SetCursor(p Point) {
m.d.Cursor.Track = intMax(intMin(p.X, len(m.d.Song.Score.Tracks)-1), 0)
y := intMax(intMin(p.Y, m.d.Song.Score.Length-1), 0)
if y != m.d.Cursor.OrderRow {
m.noteTracking = false
}
m.d.Cursor.OrderRow = y
m.updateCursorRows()
}
func (m *Order) SetCursor2(p Point) {
m.d.Cursor2.Track = intMax(intMin(p.X, len(m.d.Song.Score.Tracks)-1), 0)
m.d.Cursor2.OrderRow = intMax(intMin(p.Y, m.d.Song.Score.Length-1), 0)
m.updateCursorRows()
}
func (v *Order) updateCursorRows() {
if v.Cursor() == v.Cursor2() {
v.d.Cursor.PatternRow = 0
v.d.Cursor2.PatternRow = 0
return
}
if v.d.Cursor.OrderRow > v.d.Cursor2.OrderRow {
v.d.Cursor.PatternRow = v.d.Song.Score.RowsPerPattern - 1
v.d.Cursor2.PatternRow = 0
} else {
v.d.Cursor.PatternRow = 0
v.d.Cursor2.PatternRow = v.d.Song.Score.RowsPerPattern - 1
}
}
func (v *Order) Width() int {
return len((*Model)(v).d.Song.Score.Tracks)
}
func (v *Order) Height() int {
return (*Model)(v).d.Song.Score.Length
}
func (v *Order) MoveCursor(dx, dy int) (ok bool) {
p := v.Cursor()
p.X += dx
p.Y += dy
v.SetCursor(p)
return p == v.Cursor()
}
func (m *Order) clear(p Point) {
m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, -1)
}
func (m *Order) set(p Point, value int) {
m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, value)
}
func (v *Order) add(rect Rect, delta int) (ok bool) {
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
if !v.add1(Point{x, y}, delta) {
return false
}
}
}
return true
}
func (v *Order) add1(p Point, delta int) (ok bool) {
if p.X < 0 || p.X >= len(v.d.Song.Score.Tracks) {
return true
}
val := v.d.Song.Score.Tracks[p.X].Order.Get(p.Y)
if val < 0 {
return true
}
val += delta
if val < 0 || val > 36 {
return false
}
v.d.Song.Score.Tracks[p.X].Order.Set(p.Y, val)
return true
}
type marshalOrder struct {
Order []int `yaml:",flow"`
}
type marshalTracks struct {
Tracks []marshalOrder
}
func (m *Order) marshal(rect Rect) (data []byte, ok bool) {
width := rect.BottomRight.X - rect.TopLeft.X + 1
height := rect.BottomRight.Y - rect.TopLeft.Y + 1
var table = marshalTracks{Tracks: make([]marshalOrder, 0, width)}
for x := 0; x < width; x++ {
ax := x + rect.TopLeft.X
if ax < 0 || ax >= len(m.d.Song.Score.Tracks) {
continue
}
table.Tracks = append(table.Tracks, marshalOrder{Order: make([]int, 0, rect.BottomRight.Y-rect.TopLeft.Y+1)})
for y := 0; y < height; y++ {
table.Tracks[x].Order = append(table.Tracks[x].Order, m.d.Song.Score.Tracks[ax].Order.Get(y+rect.TopLeft.Y))
}
}
ret, err := yaml.Marshal(table)
if err != nil {
return nil, false
}
return ret, true
}
func (m *Order) unmarshal(data []byte) (marshalTracks, bool) {
var table marshalTracks
yaml.Unmarshal(data, &table)
if len(table.Tracks) == 0 {
return marshalTracks{}, false
}
for i := 0; i < len(table.Tracks); i++ {
if len(table.Tracks[i].Order) > 0 {
return table, true
}
}
return marshalTracks{}, false
}
func (v *Order) unmarshalAtCursor(data []byte) bool {
table, ok := v.unmarshal(data)
if !ok {
return false
}
for i := 0; i < len(table.Tracks); i++ {
for j, q := range table.Tracks[i].Order {
if table.Tracks[i].Order[j] < -1 || table.Tracks[i].Order[j] > 36 {
continue
}
x := i + v.Cursor().X
y := j + v.Cursor().Y
if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.Length {
continue
}
v.d.Song.Score.Tracks[x].Order.Set(y, q)
}
}
return true
}
func (v *Order) unmarshalRange(rect Rect, data []byte) bool {
table, ok := v.unmarshal(data)
if !ok {
return false
}
for i := 0; i < rect.Width(); i++ {
for j := 0; j < rect.Height(); j++ {
k := i % len(table.Tracks)
l := j % len(table.Tracks[k].Order)
a := table.Tracks[k].Order[l]
if a < -1 || a > 36 {
continue
}
x := i + rect.TopLeft.X
y := j + rect.TopLeft.Y
if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.Length {
continue
}
v.d.Song.Score.Tracks[x].Order.Set(y, a)
}
}
return true
}
func (v *Order) change(kind string, severity ChangeSeverity) func() {
return (*Model)(v).change("OrderTableView."+kind, ScoreChange, severity)
}
func (v *Order) cancel() {
v.changeCancel = true
}
func (m *Order) Value(p Point) int {
if p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) {
return -1
}
return m.d.Song.Score.Tracks[p.X].Order.Get(p.Y)
}
func (m *Order) SetValue(p Point, val int) {
defer (*Model)(m).change("OrderElement.SetValue", ScoreChange, MinorChange)()
m.d.Song.Score.Tracks[p.X].Order.Set(p.Y, val)
}
func (e *Order) Title(x int) (title string) {
title = "?"
if x < 0 || x >= len(e.d.Song.Score.Tracks) {
return
}
t := e.d.Song.Score.Tracks[x]
firstVoice := e.d.Song.Score.FirstVoiceForTrack(x)
lastVoice := firstVoice + t.NumVoices - 1
firstIndex, err := e.d.Song.Patch.InstrumentForVoice(firstVoice)
lastIndex, err2 := e.d.Song.Patch.InstrumentForVoice(lastVoice)
if err != nil || err2 != nil {
return
}
switch diff := lastIndex - firstIndex; diff {
case 0:
title = e.d.Song.Patch[firstIndex].Name
default:
n1 := e.d.Song.Patch[firstIndex].Name
n2 := e.d.Song.Patch[firstIndex+1].Name
if len(n1) > 0 {
n1 = string(n1[0])
} else {
n1 = "?"
}
if len(n2) > 0 {
n2 = string(n2[0])
} else {
n2 = "?"
}
if diff > 1 {
title = n1 + "/" + n2 + "..."
} else {
title = n1 + "/" + n2
}
}
return
}
// NoteTable
func (v *Notes) Table() Table {
return Table{v}
}
func (m *Notes) Cursor() Point {
t := intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
p := intMax(intMin(m.d.Song.Score.SongRow(m.d.Cursor.SongPos), m.d.Song.Score.LengthInRows()-1), 0)
return Point{t, p}
}
func (m *Notes) Cursor2() Point {
t := intMax(intMin(m.d.Cursor2.Track, len(m.d.Song.Score.Tracks)-1), 0)
p := intMax(intMin(m.d.Song.Score.SongRow(m.d.Cursor2.SongPos), m.d.Song.Score.LengthInRows()-1), 0)
return Point{t, p}
}
func (v *Notes) SetCursor(p Point) {
v.d.Cursor.Track = intMax(intMin(p.X, len(v.d.Song.Score.Tracks)-1), 0)
newPos := v.d.Song.Score.Wrap(sointu.SongPos{PatternRow: p.Y})
if newPos != v.d.Cursor.SongPos {
v.noteTracking = false
}
v.d.Cursor.SongPos = newPos
}
func (v *Notes) SetCursor2(p Point) {
v.d.Cursor2.Track = intMax(intMin(p.X, len(v.d.Song.Score.Tracks)-1), 0)
v.d.Cursor2.SongPos = v.d.Song.Score.Wrap(sointu.SongPos{PatternRow: p.Y})
}
func (v *Notes) Width() int {
return len((*Model)(v).d.Song.Score.Tracks)
}
func (v *Notes) Height() int {
return (*Model)(v).d.Song.Score.Length * (*Model)(v).d.Song.Score.RowsPerPattern
}
func (v *Notes) MoveCursor(dx, dy int) (ok bool) {
p := v.Cursor()
for dx < 0 {
if v.Effect(p.X) && v.d.LowNibble {
v.d.LowNibble = false
} else {
p.X--
v.d.LowNibble = true
}
dx++
}
for dx > 0 {
if v.Effect(p.X) && !v.d.LowNibble {
v.d.LowNibble = true
} else {
p.X++
v.d.LowNibble = false
}
dx--
}
p.Y += dy
v.SetCursor(p)
return p == v.Cursor()
}
func (v *Notes) clear(p Point) {
v.SetValue(p, 1)
}
func (v *Notes) set(p Point, value int) {
v.SetValue(p, byte(value))
}
func (v *Notes) add(rect Rect, delta int) (ok bool) {
for x := rect.BottomRight.X; x >= rect.TopLeft.X; x-- {
for y := rect.BottomRight.Y; y >= rect.TopLeft.Y; y-- {
if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() {
continue
}
pos := v.d.Song.Score.SongPos(y)
note := v.d.Song.Score.Tracks[x].Note(pos)
if note <= 1 {
continue
}
newVal := int(note) + delta
if newVal < 2 {
newVal = 2
} else if newVal > 255 {
newVal = 255
}
// only do all sets after all gets, so we don't accidentally adjust single note multiple times
defer v.d.Song.Score.Tracks[x].SetNote(pos, byte(newVal))
}
}
return true
}
type noteTable struct {
Notes [][]byte `yaml:",flow"`
}
func (m *Notes) marshal(rect Rect) (data []byte, ok bool) {
width := rect.BottomRight.X - rect.TopLeft.X + 1
height := rect.BottomRight.Y - rect.TopLeft.Y + 1
var table = noteTable{Notes: make([][]byte, 0, width)}
for x := 0; x < width; x++ {
table.Notes = append(table.Notes, make([]byte, 0, rect.BottomRight.Y-rect.TopLeft.Y+1))
for y := 0; y < height; y++ {
pos := m.d.Song.Score.SongPos(y + rect.TopLeft.Y)
ax := x + rect.TopLeft.X
if ax < 0 || ax >= len(m.d.Song.Score.Tracks) {
continue
}
table.Notes[x] = append(table.Notes[x], m.d.Song.Score.Tracks[ax].Note(pos))
}
}
ret, err := yaml.Marshal(table)
if err != nil {
return nil, false
}
return ret, true
}
func (v *Notes) unmarshal(data []byte) (noteTable, bool) {
var table noteTable
yaml.Unmarshal(data, &table)
if len(table.Notes) == 0 {
return noteTable{}, false
}
for i := 0; i < len(table.Notes); i++ {
if len(table.Notes[i]) > 0 {
return table, true
}
}
return noteTable{}, false
}
func (v *Notes) unmarshalAtCursor(data []byte) bool {
table, ok := v.unmarshal(data)
if !ok {
return false
}
for i := 0; i < len(table.Notes); i++ {
for j, q := range table.Notes[i] {
x := i + v.Cursor().X
y := j + v.Cursor().Y
if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() {
continue
}
pos := v.d.Song.Score.SongPos(y)
v.d.Song.Score.Tracks[x].SetNote(pos, q)
}
}
return true
}
func (v *Notes) unmarshalRange(rect Rect, data []byte) bool {
table, ok := v.unmarshal(data)
if !ok {
return false
}
for i := 0; i < rect.Width(); i++ {
for j := 0; j < rect.Height(); j++ {
k := i % len(table.Notes)
l := j % len(table.Notes[k])
a := table.Notes[k][l]
x := i + rect.TopLeft.X
y := j + rect.TopLeft.Y
if x < 0 || x >= len(v.d.Song.Score.Tracks) || y < 0 || y >= v.d.Song.Score.LengthInRows() {
continue
}
pos := v.d.Song.Score.SongPos(y)
v.d.Song.Score.Tracks[x].SetNote(pos, a)
}
}
return true
}
func (v *Notes) change(kind string, severity ChangeSeverity) func() {
return (*Model)(v).change("OrderTableView."+kind, ScoreChange, severity)
}
func (v *Notes) cancel() {
v.changeCancel = true
}
func (m *Notes) Value(p Point) byte {
if p.Y < 0 || p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) {
return 1
}
pos := m.d.Song.Score.SongPos(p.Y)
return m.d.Song.Score.Tracks[p.X].Note(pos)
}
func (m *Notes) Effect(x int) bool {
if x < 0 || x >= len(m.d.Song.Score.Tracks) {
return false
}
return m.d.Song.Score.Tracks[x].Effect
}
func (m *Notes) LowNibble() bool {
return m.d.LowNibble
}
func (m *Notes) Unique(t, p int) bool {
if t < 0 || t >= len(m.cachePatternUseCount) || p < 0 || p >= len(m.cachePatternUseCount[t]) {
return false
}
return m.cachePatternUseCount[t][p] == 1
}
func (m *Notes) SetValue(p Point, val byte) {
defer m.change("SetValue", MinorChange)()
if p.Y < 0 || p.X < 0 || p.X >= len(m.d.Song.Score.Tracks) {
return
}
track := &(m.d.Song.Score.Tracks[p.X])
pos := m.d.Song.Score.SongPos(p.Y)
(*track).SetNote(pos, val)
}
func (v *Notes) FillNibble(value byte, lowNibble bool) {
defer v.change("FillNibble", MajorChange)()
rect := Table{v}.Range()
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
val := v.Value(Point{x, y})
if val == 1 {
val = 0 // treat hold also as 0
}
if lowNibble {
val = (val & 0xf0) | byte(value&15)
} else {
val = (val & 0x0f) | byte((value&15)<<4)
}
v.SetValue(Point{x, y}, val)
}
}
}

View File

@ -78,7 +78,7 @@ func NewBytecode(patch sointu.Patch, featureSet FeatureSet, bpm int) (*Bytecode,
return nil, errors.New("Each instrument must have at least 1 voice")
}
for unitIndex, unit := range instr.Units {
if unit.Type == "" { // empty units are just ignored & skipped
if unit.Type == "" || unit.Disabled { // empty units are just ignored & skipped
continue
}
opcode, ok := featureSet.Opcode(unit.Type)

View File

@ -40,7 +40,7 @@ func TestOscillatSine(t *testing.T) {
}}}
tracks := []sointu.Track{{NumVoices: 1, Order: []int{0}, Patterns: []sointu.Pattern{{64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0}}}}
song := sointu.Song{BPM: 100, RowsPerBeat: 4, Score: sointu.Score{RowsPerPattern: 16, Length: 1, Tracks: tracks}, Patch: patch}
buffer, err := sointu.Play(bridge.NativeSynther{}, song)
buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil)
if err != nil {
t.Fatalf("Render failed: %v", err)
}
@ -95,7 +95,7 @@ func TestAllRegressionTests(t *testing.T) {
if err != nil {
t.Fatalf("could not parse the .yml file: %v", err)
}
buffer, err := sointu.Play(bridge.NativeSynther{}, song)
buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil)
buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always.
if err != nil {
t.Fatalf("Play failed: %v", err)

View File

@ -14,13 +14,8 @@ func flattenSequence(t sointu.Track, songLength int, rowsPerPattern int, release
notes := make([]int, sumLen)
k := 0
for i := 0; i < songLength; i++ {
patIndex := t.Order.Get(i)
var pattern sointu.Pattern = nil
if patIndex >= 0 && patIndex < len(t.Patterns) {
pattern = t.Patterns[patIndex]
}
for j := 0; j < rowsPerPattern; j++ {
note := int(pattern.Get(j))
note := int(t.Note(sointu.SongPos{OrderRow: i, PatternRow: j}))
if releaseFirst && i == 0 && j == 0 && note == 1 {
note = 0
}

View File

@ -98,6 +98,41 @@ su_op_invgain_mono:
{{- end}}
{{end}}
{{- if .HasOp "dbgain"}}
;-------------------------------------------------------------------------------
; DBGAIN opcode: apply gain on the signal, with gain given in decibels
;-------------------------------------------------------------------------------
; Mono: x -> x*g, where g = 2**((2*d-1)*6.643856189774724) i.e. -40dB to 40dB, d=[0..1]
; Stereo: l r -> l*g r*g
;-------------------------------------------------------------------------------
{{.Func "su_op_dbgain" "Opcode"}}
{{- if .Stereo "dbgain"}}
fld dword [{{.Input "dbgain" "decibels"}}] ; d l r
{{- .Prepare (.Float 0.5)}}
fsub dword [{{.Use (.Float 0.5)}}] ; d-.5
fadd st0, st0 ; 2*d-1
{{- .Prepare (.Float 6.643856189774724)}}
fmul dword [{{.Use (.Float 6.643856189774724)}}] ; (2*d-1)*6.643856189774724
{{.Call "su_power"}}
{{- if .Mono "dbgain"}}
jnc su_op_dbgain_mono
{{- end}}
fmul st2, st0 ; g l r/g
su_op_dbgain_mono:
fmulp st1, st0 ; l/g (r/)
ret
{{- else}}
fld dword [{{.Input "dbgain" "decibels"}}] ; d l
{{- .Prepare (.Float 0.5)}}
fsub dword [{{.Use (.Float 0.5)}}] ; d-.5
fadd st0, st0 ; 2*d-1
{{- .Prepare (.Float 6.643856189774724)}}
fmul dword [{{.Use (.Float 6.643856189774724)}}] ; (2*d-1)*6.643856189774724
{{.Call "su_power"}}
fmulp st1, st0
ret
{{- end}}
{{end}}
{{- if .HasOp "filter"}}
;-------------------------------------------------------------------------------
@ -121,6 +156,9 @@ su_op_invgain_mono:
fsubp st2, st0 ; r x-l'
fmul dword [{{.WRK}}+8] ; r*b x-l'
fsubp st1, st0 ; x-l'-r*b
{{- .Float 0.5 | .Prepare | indent 4}}
fadd dword [{{.Float 0.5 | .Use}}] ; add and sub small offset to prevent denormalization
fsub dword [{{.Float 0.5 | .Use}}] ; See for example: https://stackoverflow.com/questions/36781881/why-denormalized-floats-are-so-much-slower-than-other-floats-from-hardware-arch
fst dword [{{.WRK}}+4] ; h'=x-l'-r*b
fmul dword [{{.WRK}}+12] ; f2*h'
fadd dword [{{.WRK}}+8] ; f2*h'+b

View File

@ -23,15 +23,17 @@
add rsp, 40 ; shadow space, as required by Win64 ABI
ret
{{else}}
mov ebx, su_sample_table
push 0 ; OF_READ
push ebx ; &ofstruct, blatantly reuse the sample table
push su_gmdls_path1 ; path
call dword [__imp__OpenFile@12]; eax = OpenFile(path,&ofstruct,OF_READ) // should not touch ebx according to calling convention
mov eax, su_sample_table
; these are the arguments for ReadFile
push 0 ; NULL
push ebx ; &bytes_read, reusing sample table again; it does not matter that the first four bytes are trashed
push eax ; &bytes_read, reusing sample table again; it does not matter that the first four bytes are trashed
push 3440660 ; number of bytes to read
push ebx ; here we actually pass the sample table to readfile
push eax ; here we actually pass the sample table to readfile
; these are for OpenFile
push 0 ; OF_READ
push eax ; &ofstruct, blatantly reuse the sample table
push su_gmdls_path1 ; path
call dword [__imp__OpenFile@12]; eax = OpenFile(path,&ofstruct,OF_READ)
push eax ; handle to file
call dword [__imp__ReadFile@20] ; Readfile(handle,&su_sample_table,SAMPLE_TABLE_SIZE,&bytes_read,NULL)
ret

View File

@ -56,7 +56,7 @@ su_render_samples_loop:
push {{.DI}}
fnstsw [{{.SP}}] ; store the FPU status flag to stack top
pop {{.DI}} ; {{.DI}} = FPU status flag
and {{.DI}}, 0b0011100001000101 ; mask TOP pointer, stack error, zero divide and in{{.VAL}}id operation
and {{.DI}}, 0011100001000101b ; mask TOP pointer, stack error, zero divide and in{{.VAL}}id operation
test {{.DI}},{{.DI}} ; all the aforementioned bits should be 0!
jne su_render_samples_time_finish ; otherwise, we exit due to error
cmp eax, [{{.Stack "RowLength"}}] ; if rowtick >= maxtime

View File

@ -114,6 +114,7 @@ su_op_advance_finish:
; Output: st0 : 2^x
;-------------------------------------------------------------------------------
{{- if not (.HasCall "su_nonlinear_map")}}{{.SectText "su_power"}}{{end}}
{{.Export "su_pow" 0}}
su_power:
fld1 ; 1 x
fld st1 ; x 1 x

View File

@ -120,7 +120,7 @@ su_update_voices_trackloop:
movzx eax, byte [{{.SI}}] ; eax = current pattern
imul eax, {{.PatternLength}} ; eax = offset to current pattern data
{{- .Prepare "su_patterns" .AX | indent 4}}
movzx eax,byte [{{.Use "su_patterns" .AX}},{{.DX}}] ; eax = note
movzx eax,byte [{{.Use "su_patterns" .AX}} + {{.DX}}] ; eax = note
push {{.DX}} ; Stack: ptrnrow
xor edx, edx ; edx=0
mov ecx, ebx ; ecx=first voice of the track to be done

View File

@ -98,6 +98,26 @@
)
{{end}}
{{- if .HasOp "dbgain"}}
;;-------------------------------------------------------------------------------
;; DBGAIN opcode: apply gain on the signal, with gain given in decibels
;;-------------------------------------------------------------------------------
;; Mono: x -> x*g, where g = 2**((2*d-1)*6.643856189774724) i.e. -40dB to 40dB, d=[0..1]
;; Stereo: l r -> l*g r*g
;;-------------------------------------------------------------------------------
(func $su_op_dbgain (param $stereo i32)
{{- if .Stereo "dbgain"}}
(call $stereoHelper (local.get $stereo) (i32.const {{div (.GetOp "dbgain") 2}}))
{{- end}}
(call $input (i32.const {{.InputNumber "dbgain" "decibels"}}))
(f32.sub (f32.const 0.5))
(f32.mul (f32.const 13.287712379549449))
(call $pow2)
(f32.mul (call $pop))
(call $push)
)
{{end}}
{{- if .HasOp "filter"}}
;;-------------------------------------------------------------------------------
@ -293,7 +313,7 @@
))
{{- end}}
{{- if .SupportsModulation "delay" "delaytime"}}
(i32.trunc_f32_u (f32.add
(i32.trunc_f32_s (f32.add
(f32.add
(local.get $delayTime)
(f32.mul
@ -304,7 +324,7 @@
(f32.const 0.5)
))
{{- else}}
(i32.trunc_f32_u (f32.add (local.get $delayTime) (f32.const 0.5)))
(i32.trunc_f32_s (f32.add (local.get $delayTime) (f32.const 0.5)))
{{- end}}
{{- else}}
(i32.load16_u

View File

@ -353,6 +353,16 @@ func (p *X86Macros) FmtStack() string {
return b.String()
}
func (p *X86Macros) Export(name string, numParams int) string {
if !p.Amd64 && p.OS == "windows" {
return fmt.Sprintf("global _%[1]v@%[2]v\n_%[1]v@%[2]v:", name, numParams*4)
}
if p.OS == "darwin" {
return fmt.Sprintf("global _%[1]v\n_%[1]v:", name)
}
return fmt.Sprintf("global %[1]v\n%[1]v:", name)
}
func (p *X86Macros) ExportFunc(name string, params ...string) string {
numRegisters := 0 // in 32-bit systems, we use stdcall: everything in stack
switch {
@ -371,13 +381,7 @@ func (p *X86Macros) ExportFunc(name string, params ...string) string {
reverseParams[len(params)-1-i] = param
}
p.Stacklocs = append(reverseParams, "retaddr_"+name) // in 32-bit, we use stdcall and parameters are in the stack
if !p.Amd64 && p.OS == "windows" {
return fmt.Sprintf("%[1]v\nglobal _%[2]v@%[3]v\n_%[2]v@%[3]v:", p.SectText(name), name, len(params)*4)
}
if p.OS == "darwin" {
return fmt.Sprintf("%[1]v\nglobal _%[2]v\n_%[2]v:", p.SectText(name), name)
}
return fmt.Sprintf("%[1]v\nglobal %[2]v\n%[2]v:", p.SectText(name), name)
return fmt.Sprintf("%[1]v\n%[2]v", p.SectText(name), p.Export(name, len(params)))
}
func (p *X86Macros) Input(unit string, port string) (string, error) {

View File

@ -118,7 +118,7 @@ func NecessaryFeaturesFor(patch sointu.Patch) NecessaryFeatures {
features := NecessaryFeatures{opcodes: map[string]int{}, supportsParamValue: map[paramKey](map[int]bool){}, supportsModulation: map[paramKey]bool{}}
for instrIndex, instrument := range patch {
for _, unit := range instrument.Units {
if unit.Type == "" {
if unit.Type == "" || unit.Disabled {
continue
}
if _, ok := features.opcodes[unit.Type]; !ok {

View File

@ -346,6 +346,12 @@ func (s *GoSynth) Render(buffer sointu.AudioBuffer, maxtime int) (samples int, t
stack[l-2] /= params[0]
}
stack[l-1] /= params[0]
case opDbgain:
gain := float32(math.Pow(2, float64(params[0]*2-1)*6.643856189774724))
if stereo {
stack[l-2] *= gain
}
stack[l-1] *= gain
case opClip:
if stereo {
stack[l-2] = clip(stack[l-2])

View File

@ -43,7 +43,7 @@ func TestAllRegressionTests(t *testing.T) {
if err != nil {
t.Fatalf("could not parse the .yml file: %v", err)
}
buffer, err := sointu.Play(vm.GoSynther{}, song)
buffer, err := sointu.Play(vm.GoSynther{}, song, nil)
buffer = buffer[:song.Score.LengthInRows()*song.SamplesPerRow()] // extend to the nominal length always.
if err != nil {
t.Fatalf("Play failed: %v", err)

View File

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