mirror of
https://github.com/vsariola/sointu.git
synced 2026-04-12 17:14:43 -04:00
Compare commits
272 Commits
v0.4.0
...
feat/enums
| Author | SHA1 | Date | |
|---|---|---|---|
| 02a9d9747f | |||
| f88986dc64 | |||
| ca4b87d43d | |||
| 86ca3fb300 | |||
| b93304adab | |||
| 173648fbdb | |||
| 651ceb3cbb | |||
| 1693d7ed5e | |||
| 74beb6760c | |||
| 6629a9fdfa | |||
| 60222dded4 | |||
| 810998d95b | |||
| 3a7010f897 | |||
| 4d09e04a49 | |||
| 33ee80a908 | |||
| 9b87589f7b | |||
| 16c652b2ba | |||
| dafd45fd81 | |||
| 05b64dadc8 | |||
| 6978dd4afe | |||
| fa9654d311 | |||
| 3495d91a4a | |||
| 628365c486 | |||
| 9db6b669c9 | |||
| a37990a7fa | |||
| 4a46d601f2 | |||
| da6226d3ff | |||
| 1dbe351beb | |||
| 91c9701f14 | |||
| 48dc4a35bb | |||
| 9b9dc3548f | |||
| c583156d1b | |||
| c1ea47a509 | |||
| 60ae8645b6 | |||
| 362bc3029f | |||
| 213202a7e0 | |||
| e08af03fb2 | |||
| 8e99c93d14 | |||
| f4bb2bc754 | |||
| de366316d4 | |||
| 7a43aec50e | |||
| 82cf34a28f | |||
| 2336a135c6 | |||
| 3f365707c2 | |||
| 34c0045652 | |||
| c64422767e | |||
| 1dcd3fe3c6 | |||
| c0488226d2 | |||
| f894e2ee86 | |||
| 54a8358522 | |||
| bdfe2d37bf | |||
| 167f541a52 | |||
| be48f5824f | |||
| 989b6e605b | |||
| 7459437822 | |||
| 55f9c36bd5 | |||
| a09b52a912 | |||
| 74fea4138f | |||
| f13a5cd2df | |||
| 7f3010a4a6 | |||
| 5839471bcc | |||
| fe0106bb60 | |||
| 3163f46447 | |||
| 13102aa7d6 | |||
| 399bac481c | |||
| 072e4ee208 | |||
| edc0782f5f | |||
| 697fb05b5c | |||
| cf86f3f1c8 | |||
| 8e5f3098a4 | |||
| 452a4cf04f | |||
| 5841848813 | |||
| 0ce79978d5 | |||
| 4138c34574 | |||
| 172fbaeb2a | |||
| 666af9433e | |||
| c3caa8de11 | |||
| 18d7848367 | |||
| 192909328c | |||
| cb4c020061 | |||
| d78ef98e73 | |||
| 08c36ed462 | |||
| d276f52942 | |||
| b8cf70e8e9 | |||
| e59fbb50cf | |||
| ba281ca7c0 | |||
| b4ec136ab1 | |||
| 18d198d764 | |||
| 355ccefb6f | |||
| 7a030683c6 | |||
| 17ca15b205 | |||
| 58f6cceb9a | |||
| b79de95f91 | |||
| f6bc5fffcd | |||
| 33f7b5fb6a | |||
| 5f43bc3067 | |||
| fb0fa4af92 | |||
| 6f1db6b392 | |||
| 31007515b5 | |||
| db2ccf977d | |||
| 0ea20ea5bf | |||
| beef8fe1e0 | |||
| 289bfb0605 | |||
| a601b98b74 | |||
| 602b3b05cc | |||
| 3881b8eb22 | |||
| 4fa0e04788 | |||
| b291959a97 | |||
| 840fe3ef0e | |||
| 430b01d143 | |||
| 28a0006b6a | |||
| 8eb5f17f73 | |||
| f47bee37b0 | |||
| 4f2c73d0db | |||
| c77d541dc6 | |||
| 340620ed49 | |||
| 1a13fadd75 | |||
| b6e8ab5c25 | |||
| c6b70560f6 | |||
| 1eea263dc9 | |||
| c023dc08b8 | |||
| 0e32608872 | |||
| 283fbc1171 | |||
| 7ef868a434 | |||
| 4f779edb88 | |||
| d20a23d57b | |||
| de2e64533d | |||
| 74f37318d6 | |||
| fb3a0da3ed | |||
| 036cb1f34d | |||
| d6badb97be | |||
| d342c9961d | |||
| 32f1e1baea | |||
| 5b260d19f5 | |||
| ddbaf6a4bb | |||
| 27bf8220c0 | |||
| 448bc9f236 | |||
| afb1fee4ed | |||
| 8245fbda24 | |||
| 0f42a993dc | |||
| 554a840982 | |||
| 9f89c37956 | |||
| 0199658025 | |||
| afc6b1f4a9 | |||
| 3623bdf5b2 | |||
| fe9daf7988 | |||
| bf0d697b80 | |||
| f72f29188b | |||
| 5fd78d8362 | |||
| 805b98524c | |||
| 54176cc2b3 | |||
| 845f0119c8 | |||
| 5a3c859a51 | |||
| 5c0b86a0f0 | |||
| e0392323c0 | |||
| bb605ffa0b | |||
| 40be82de46 | |||
| 42c95ab8ee | |||
| d0413e0a13 | |||
| bdf9e2ba0c | |||
| 95af8da939 | |||
| 78fc6302a0 | |||
| ea4dee9285 | |||
| ae217665bf | |||
| 46a9c7dab3 | |||
| 5ee7e44ed7 | |||
| dd7b5ddc84 | |||
| ee229d8d94 | |||
| 6ba595e7ff | |||
| 7ff3c942cb | |||
| 4169356845 | |||
| 8d71cf3ca7 | |||
| b255a68ebc | |||
| d517576a65 | |||
| 4d7c998fc2 | |||
| 55c062a390 | |||
| b423d04c17 | |||
| 639b2266e3 | |||
| 8d7d896375 | |||
| 04deac5722 | |||
| 6337101985 | |||
| 8074fd71d3 | |||
| 37769fcc9c | |||
| 76322bb541 | |||
| 1c601858ae | |||
| 65a7f060ec | |||
| b08f5d4b1e | |||
| 2a2934b4e4 | |||
| 9d59cfb3b6 | |||
| 19661f90ea | |||
| 94058c2603 | |||
| 943073d0cc | |||
| b73fc0b95b | |||
| ee3ab3bf86 | |||
| 2aa0aaee0c | |||
| 3eb4d86d52 | |||
| ec222bd67d | |||
| 86c65939bb | |||
| 7417170a8b | |||
| daf7fb1519 | |||
| eb9413b9a0 | |||
| 8dfadacafe | |||
| 216cde2365 | |||
| 025f8832d9 | |||
| 1c42a51cc6 | |||
| 0ba6557f65 | |||
| 3306c431c3 | |||
| 9bce1cb3d5 | |||
| 63c08d53fe | |||
| 063b2c29c5 | |||
| 7b213bd8b0 | |||
| 27b6bc57d2 | |||
| 00b8e1872a | |||
| 04ca0a3f6e | |||
| 08386323ed | |||
| 7470413ad8 | |||
| 5099c61705 | |||
| b494a69a76 | |||
| 3986bbede7 | |||
| 97e59c5650 | |||
| 2b7ce39069 | |||
| 03c994e4da | |||
| cd88ea0680 | |||
| f8f0e11b76 | |||
| 2809526de6 | |||
| f427eca1f4 | |||
| c07d8000c6 | |||
| 577265b250 | |||
| 9779beee99 | |||
| 160eb8eea9 | |||
| 3fb7f07c2c | |||
| 10f021a497 | |||
| 3a7ab0416a | |||
| 4c096a3fac | |||
| 59c04ed4a1 | |||
| a6bb5c2afc | |||
| 5c51932f60 | |||
| 773655ef9c | |||
| 91b7850bf7 | |||
| b4a63ce362 | |||
| a94703deea | |||
| ad5f7628a5 | |||
| b538737643 | |||
| 47d7568552 | |||
| 81a6d1acea | |||
| 890ebe3294 | |||
| bf5579a2d2 | |||
| 8fd2df19a1 | |||
| ce673578fd | |||
| 0e10cd2ae8 | |||
| 4ee355bb45 | |||
| 7d6daba3d2 | |||
| 2b38e11643 | |||
| f8c522873c | |||
| e49f699f62 | |||
| 6924b63e02 | |||
| 6fc9277113 | |||
| 877556b428 | |||
| 5e65410d27 | |||
| 4e1fdf57d9 | |||
| 1daaf1829c | |||
| 74972b5ff4 | |||
| 9da6c2216c | |||
| 61e7da5dab | |||
| 59fb39d9b3 | |||
| 9cb573d965 | |||
| d46605c638 | |||
| 569958547e | |||
| 012ed10851 | |||
| 5bc6dc6015 | |||
| 350402f8f3 | |||
| 75bd9c591e |
102
.github/workflows/binaries.yml
vendored
102
.github/workflows/binaries.yml
vendored
@ -39,31 +39,19 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
output: sointu-track.exe
|
||||
params: -ldflags -H=windowsgui cmd/sointu-track/main.go
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
output: sointu-compile.exe
|
||||
params: cmd/sointu-compile/main.go
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
output: sointu-track-native.exe
|
||||
params: -ldflags -H=windowsgui -tags=native cmd/sointu-track/main.go
|
||||
output: sointu-track.exe
|
||||
params: -tags=native cmd/sointu-track/main.go
|
||||
ldflags: -H=windowsgui
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
output: sointu-vsti.dll
|
||||
params: -buildmode=c-shared -tags=plugin ./cmd/sointu-vsti/
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
output: sointu-vsti-native.dll
|
||||
params: -buildmode=c-shared -tags="plugin,native" ./cmd/sointu-vsti/
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
output: sointu-track
|
||||
params: cmd/sointu-track/main.go
|
||||
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
output: sointu-compile
|
||||
@ -71,31 +59,28 @@ jobs:
|
||||
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
output: sointu-track-native
|
||||
output: sointu-track
|
||||
params: -tags=native cmd/sointu-track/main.go
|
||||
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
output: sointu-vsti.so
|
||||
params: -buildmode=c-shared -tags=plugin ./cmd/sointu-vsti/
|
||||
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
output: sointu-vsti-native.so
|
||||
params: -buildmode=c-shared -tags="plugin,native" ./cmd/sointu-vsti/
|
||||
packages: libegl-dev libvulkan-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libx11-xcb-dev libxcursor-dev libxfixes-dev
|
||||
- os: macos-latest
|
||||
asmnasm: /Users/runner/nasm/nasm
|
||||
output: sointu-compile
|
||||
params: cmd/sointu-compile/main.go
|
||||
- os: macos-latest
|
||||
asmnasm: /Users/runner/nasm/nasm
|
||||
output: sointu-track
|
||||
params: cmd/sointu-track/main.go
|
||||
- os: macos-latest
|
||||
asmnasm: /Users/runner/nasm/nasm
|
||||
output: sointu-compile
|
||||
params: cmd/sointu-compile/main.go
|
||||
- os: macos-12 # this is intel still
|
||||
asmnasm: /Users/runner/nasm/nasm
|
||||
output: sointu-track-native
|
||||
params: -tags=native cmd/sointu-track/main.go
|
||||
output: sointu-vsti.a
|
||||
bundleoutput: sointu-vsti
|
||||
params: -buildmode=c-archive -tags="plugin" ./cmd/sointu-vsti/
|
||||
bundle: true
|
||||
steps:
|
||||
- uses: benjlevesque/short-sha@v3.0
|
||||
id: short-sha
|
||||
@ -103,9 +88,11 @@ jobs:
|
||||
length: 7
|
||||
- uses: lukka/get-cmake@latest
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5 # has to be after checkout, see https://medium.com/@s0k0mata/github-actions-and-go-the-new-cache-feature-in-actions-setup-go-v4-and-what-to-watch-out-for-aeea373ed07d
|
||||
with:
|
||||
go-version: '>=1.21.0'
|
||||
go-version: '>=1.23.8 <1.23.9'
|
||||
- uses: ilammy/setup-nasm@v1.5.1
|
||||
- uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
@ -114,20 +101,73 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
- name: Build library
|
||||
env:
|
||||
ASM_NASM: ${{ matrix.config.asmnasm }}
|
||||
ASM_NASM: ${{ matrix.config.asmnasm }}
|
||||
run: |
|
||||
mkdir build
|
||||
cd build
|
||||
cmake -GNinja ..
|
||||
ninja sointu
|
||||
- name: Build binary
|
||||
- name: Build binary
|
||||
run: |
|
||||
go build -o ${{ matrix.config.output }} ${{ matrix.config.params }}
|
||||
go build -ldflags "-X github.com/vsariola/sointu/version.Version=$(git describe) ${{ matrix.config.ldflags}}" -o ${{ matrix.config.output }} ${{ matrix.config.params }}
|
||||
- name: Upload binary
|
||||
if: matrix.config.bundle != true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ runner.os }}-${{ steps.short-sha.outputs.sha }}-${{ matrix.config.output }}
|
||||
path: ${{ matrix.config.output }}
|
||||
- name: Bundle VST
|
||||
if: matrix.config.bundle
|
||||
run: | # following https://github.com/RustAudio/vst-rs/blob/master/osx_vst_bundler.sh
|
||||
mkdir -p "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/MacOS"
|
||||
clang++ -D__MACOSX_CORE__ -framework CoreServices -framework CoreAudio -framework CoreMIDI -framework CoreFoundation -L./build/ -lsointu -bundle -o bundle/${{ matrix.config.bundleoutput }} -all_load ${{ matrix.config.output }}
|
||||
echo "BNDL????" > "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/PkgInfo"
|
||||
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
|
||||
<plist version=\"1.0\">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${{ matrix.config.bundleoutput }}</string>
|
||||
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>vst</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.vsariola.${{ matrix.config.bundleoutput }}</string>
|
||||
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
|
||||
<key>CFBundleName</key>
|
||||
<string>${{ matrix.config.bundleoutput }}</string>
|
||||
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
|
||||
<key>CFBundleSignature</key>
|
||||
<string>$((RANDOM % 9999))</string>
|
||||
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<string></string>
|
||||
|
||||
</dict>
|
||||
</plist>" > "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/Info.plist"
|
||||
mv "bundle/${{ matrix.config.bundleoutput }}" "bundle/${{ matrix.config.bundleoutput }}.vst/Contents/MacOS/${{ matrix.config.bundleoutput }}"
|
||||
- name: Upload bundle
|
||||
if: matrix.config.bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ runner.os }}-${{ steps.short-sha.outputs.sha }}-${{ matrix.config.bundleoutput }}
|
||||
path: bundle
|
||||
upload_release_asset:
|
||||
needs: [create_release, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@ -20,14 +20,17 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
asmnasm: /home/runner/nasm/nasm
|
||||
gotests: yes
|
||||
gotestcases: ./vm ./vm/compiler/bridge ./vm/compiler
|
||||
cgo_ldflags:
|
||||
- os: windows-latest
|
||||
asmnasm: C:\Users\runneradmin\nasm\nasm
|
||||
gotests: yes
|
||||
gotestcases: ./vm ./vm/compiler/bridge ./vm/compiler
|
||||
cgo_ldflags:
|
||||
- os: macos-12 # this is intel still
|
||||
- os: macos-latest
|
||||
asmnasm: /Users/runner/nasm/nasm
|
||||
gotests: yes
|
||||
gotestcases: ./vm ./vm/compiler
|
||||
cgo_ldflags: # -Wl,-no_pie
|
||||
# ld on mac is complaining about position dependent code so this would take the errors away, BUT
|
||||
# suddenly this causes an error, even though worked last week. Let's accept the warnings rather
|
||||
@ -44,7 +47,7 @@ jobs:
|
||||
go-version: '>=1.21.0'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '15'
|
||||
node-version: '22'
|
||||
- uses: ilammy/setup-nasm@v1.5.1
|
||||
- name: Run ctest
|
||||
env:
|
||||
@ -60,4 +63,4 @@ jobs:
|
||||
env:
|
||||
CGO_LDFLAGS: ${{ matrix.config.cgo_ldflags }}
|
||||
run: |
|
||||
go test ./vm ./vm/compiler/bridge ./vm/compiler
|
||||
go test ${{ matrix.config.gotestcases }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,8 +17,9 @@ build/
|
||||
# Project specific
|
||||
old/
|
||||
|
||||
# VS Code
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# project specific
|
||||
# this is autogenerated from bridge.go.in
|
||||
|
||||
40
4klang.go
40
4klang.go
@ -245,7 +245,7 @@ func read4klangUnit(r io.Reader, version int) ([]Unit, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func read4klangENV(vals [15]byte, version int) []Unit {
|
||||
func read4klangENV(vals [15]byte, _ int) []Unit {
|
||||
return []Unit{{
|
||||
Type: "envelope",
|
||||
Parameters: map[string]int{
|
||||
@ -273,7 +273,7 @@ func read4klangVCO(vals [15]byte, version int) []Unit {
|
||||
color, v = int(v[0]), v[1:]
|
||||
shape, v = int(v[0]), v[1:]
|
||||
gain, v = int(v[0]), v[1:]
|
||||
flags, v = int(v[0]), v[1:]
|
||||
flags, _ = int(v[0]), v[1:]
|
||||
if flags&0x10 == 0x10 {
|
||||
lfo = 1
|
||||
}
|
||||
@ -318,9 +318,9 @@ func read4klangVCO(vals [15]byte, version int) []Unit {
|
||||
}}
|
||||
}
|
||||
|
||||
func read4klangVCF(vals [15]byte, version int) []Unit {
|
||||
func read4klangVCF(vals [15]byte, _ int) []Unit {
|
||||
flags := vals[2]
|
||||
var stereo, lowpass, bandpass, highpass, neghighpass int
|
||||
var stereo, lowpass, bandpass, highpass int
|
||||
if flags&0x01 == 0x01 {
|
||||
lowpass = 1
|
||||
}
|
||||
@ -332,7 +332,7 @@ func read4klangVCF(vals [15]byte, version int) []Unit {
|
||||
}
|
||||
if flags&0x08 == 0x08 {
|
||||
lowpass = 1
|
||||
neghighpass = 1
|
||||
highpass = -1
|
||||
}
|
||||
if flags&0x10 == 0x10 {
|
||||
stereo = 1
|
||||
@ -340,26 +340,24 @@ func read4klangVCF(vals [15]byte, version int) []Unit {
|
||||
return []Unit{{
|
||||
Type: "filter",
|
||||
Parameters: map[string]int{
|
||||
"stereo": stereo,
|
||||
"frequency": int(vals[0]),
|
||||
"resonance": int(vals[1]),
|
||||
"lowpass": lowpass,
|
||||
"bandpass": bandpass,
|
||||
"highpass": highpass,
|
||||
"negbandpass": 0,
|
||||
"neghighpass": neghighpass,
|
||||
"stereo": stereo,
|
||||
"frequency": int(vals[0]),
|
||||
"resonance": int(vals[1]),
|
||||
"lowpass": lowpass,
|
||||
"bandpass": bandpass,
|
||||
"highpass": highpass,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func read4klangDST(vals [15]byte, version int) []Unit {
|
||||
func read4klangDST(vals [15]byte, _ int) []Unit {
|
||||
return []Unit{
|
||||
{Type: "distort", Parameters: map[string]int{"drive": int(vals[0]), "stereo": int(vals[2])}},
|
||||
{Type: "hold", Parameters: map[string]int{"holdfreq": int(vals[1]), "stereo": int(vals[2])}},
|
||||
}
|
||||
}
|
||||
|
||||
func read4klangDLL(vals [15]byte, version int) []Unit {
|
||||
func read4klangDLL(vals [15]byte, _ int) []Unit {
|
||||
var delaytimes []int
|
||||
var notetracking int
|
||||
if vals[11] > 0 {
|
||||
@ -400,7 +398,7 @@ func read4klangDLL(vals [15]byte, version int) []Unit {
|
||||
}}
|
||||
}
|
||||
|
||||
func read4klangFOP(vals [15]byte, version int) []Unit {
|
||||
func read4klangFOP(vals [15]byte, _ int) []Unit {
|
||||
var t string
|
||||
var stereo int
|
||||
switch vals[0] {
|
||||
@ -434,7 +432,7 @@ func read4klangFOP(vals [15]byte, version int) []Unit {
|
||||
}}
|
||||
}
|
||||
|
||||
func read4klangFST(vals [15]byte, version int) []Unit {
|
||||
func read4klangFST(vals [15]byte, _ int) []Unit {
|
||||
sendpop := 0
|
||||
if vals[1]&0x40 == 0x40 {
|
||||
sendpop = 1
|
||||
@ -484,7 +482,7 @@ func fix4klangTargets(instrIndex int, instr Instrument, m _4klangTargetMap) {
|
||||
}
|
||||
}
|
||||
|
||||
func read4klangPAN(vals [15]byte, version int) []Unit {
|
||||
func read4klangPAN(vals [15]byte, _ int) []Unit {
|
||||
return []Unit{{
|
||||
Type: "pan",
|
||||
Parameters: map[string]int{
|
||||
@ -493,7 +491,7 @@ func read4klangPAN(vals [15]byte, version int) []Unit {
|
||||
}}}
|
||||
}
|
||||
|
||||
func read4klangOUT(vals [15]byte, version int) []Unit {
|
||||
func read4klangOUT(vals [15]byte, _ int) []Unit {
|
||||
return []Unit{{
|
||||
Type: "outaux",
|
||||
Parameters: map[string]int{
|
||||
@ -503,7 +501,7 @@ func read4klangOUT(vals [15]byte, version int) []Unit {
|
||||
}}
|
||||
}
|
||||
|
||||
func read4klangACC(vals [15]byte, version int) []Unit {
|
||||
func read4klangACC(vals [15]byte, _ int) []Unit {
|
||||
c := 0
|
||||
if vals[0] != 0 {
|
||||
c = 2
|
||||
@ -514,7 +512,7 @@ func read4klangACC(vals [15]byte, version int) []Unit {
|
||||
}}
|
||||
}
|
||||
|
||||
func read4klangFLD(vals [15]byte, version int) []Unit {
|
||||
func read4klangFLD(vals [15]byte, _ int) []Unit {
|
||||
return []Unit{{
|
||||
Type: "loadval",
|
||||
Parameters: map[string]int{"stereo": 0, "value": int(vals[0])},
|
||||
|
||||
248
CHANGELOG.md
248
CHANGELOG.md
@ -3,7 +3,203 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## v0.4.0
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Spectrum analyzer showing the spectrum. When the user has a filter or belleq
|
||||
unit selected, it's frequency response is plotted on top. ([#67][i67])
|
||||
- belleq unit: a bell-shaped second-order filter for equalization. Belleq unit
|
||||
takes the center frequency, bandwidth (inverse of Q-factor) and gain (+-40
|
||||
dB). Useful for boosting or reducing specific frequency ranges. Kudos to Reaby
|
||||
for the initial implementation!
|
||||
- Multithreaded synths: the user can split the patch up to four threads.
|
||||
Selecting the thread can be done on the instrument properties pane.
|
||||
Multithreading works only on the multithreaded synths, selectable from the CPU
|
||||
panel. Currently the multithreaded rendering has not yet been implemented in
|
||||
the compiled player and the thread information is disregarded while compiling
|
||||
the song. ([#199][i199])
|
||||
- Preset explorer, whichs allows 1) searching the presets by name; 2) filtering
|
||||
them by category (directory); 3) filtering them by being builtin vs. user;
|
||||
4) filtering them if they need gm.dls (for Linux/Mac users, who don't have
|
||||
it); and 5) saving and deleting user presets. ([#91][i91])
|
||||
- Panic the synth if it outputs NaN or Inf, and handle these more gracefully in
|
||||
the loudness and peak detector. ([#210][i210])
|
||||
- More presets from Reaby, and all new and existing presets were normalized
|
||||
roughly to -12 dBFS true peak. ([#211][i211])
|
||||
|
||||
### Fixed
|
||||
- Occasional NaNs in the Trisaw oscillator when the color was 0 in the Go VM.
|
||||
- The tracker thought that "sync" unit pops the value from stack, even if the VM
|
||||
did not, resulting it claiming errors in patches that worked once compiled.
|
||||
|
||||
### Changed
|
||||
- The song panel can scroll if all the widgets don't fit into it
|
||||
- The provided MacOS executables are now arm64, which means the x86 native
|
||||
synths are not compiled in.
|
||||
|
||||
## [0.5.0]
|
||||
### BREAKING CHANGES
|
||||
- BREAKING CHANGE: always first modulate delay time, then apply notetracking. In
|
||||
a delay unit, modulation adds to the delay time, while note tracking
|
||||
multiplies it with a multiplier dependent on the note. The order of these
|
||||
operations was different in the Go VM vs. x86 VM & WebAssembly VM. In the Go
|
||||
VM, it first modulated, and then applied the note tracking multiplication. In
|
||||
the two assembly VMs, it first applied the note tracking and then modulated.
|
||||
Of these two behaviours, the Go VM behaviour made more sense: if you make a
|
||||
vibrato of +-50 cents for C4, you probably want a vibrato of +-50 cents for C6
|
||||
also. Thus, first modulating and then applying the note tracking
|
||||
multiplication is now the behaviour accross all VMs.
|
||||
- BREAKING CHANGE: the negbandpass and neghighpass parameters of the filter unit
|
||||
were removed. Setting bandpass or highpass to -1 achieves now the same end
|
||||
result. Setting both negbandpass and bandpass to 1 was previously a no-op. Old
|
||||
patch and instrument files are converted to the new format when loaded, but
|
||||
newer Sointu files should not be compiled with an old version of
|
||||
sointu-compile.
|
||||
|
||||
### Added
|
||||
- Signal rail that visualizes what happens in the stack, shown on the left side
|
||||
of each unit in the rack.
|
||||
- The parameters are now displayed in a grid as knobs, with units of the
|
||||
instrument going from the top to the bottom. Bezier lines are used to indicate
|
||||
which sends modulate which ports. ([#173][i173])
|
||||
- Tabbing works more consistently, with widgets placed in a "tree", and plain
|
||||
Tab moves to the next widget on the same level or more shallow in the tree,
|
||||
while ctrl-Tab moves to next widget, regardless of its depth. This allows the
|
||||
user to quickly move between different panels, but also tabbing into every
|
||||
tiny widget if needed. Shift-* tab backwards.
|
||||
- Help menu, with a menu item to show the license in a dialog, and also menu
|
||||
items to open manual, Github Discussions & Github Issues in a browser
|
||||
([#196][i196])
|
||||
- Show CPU load percentage in the song panel ([#192][i192])
|
||||
- Theme can be user configured, in theme.yml. This theme.yml should be placed in
|
||||
the usual sointu config directory (i.e.
|
||||
`os.UserConfigDir()/sointu/theme.yml`). See
|
||||
[theme.yml](tracker/gioui/theme.yml) for the default theme, and
|
||||
[theme.go](tracker/gioui/theme.go) for what can be changed.
|
||||
- Ctrl + scroll wheel adjusts the global scaling of the GUI ([#153][i153])
|
||||
- The loudness detection supports LUFS, A-weighting, C-weighting or
|
||||
RMS-weighting, and peak detection supports true peak or sample peak detection.
|
||||
The loudness and peak values are displayed in the song panel ([#186][i186])
|
||||
- Oscilloscope to visualize the outputted waveform ([#61][i61])
|
||||
- Toggle button to keep instruments and tracks linked, and buttons to split
|
||||
instruments and tracks with more than 1 voice into parallel ones
|
||||
([#163][i163], [#157][i157])
|
||||
- Mute and solo toggles for instruments ([#168][i168])
|
||||
- Many units (e.g. envelopes, oscillators and compressors) display values dB
|
||||
- Dragging mouse to select rectangles in the tables
|
||||
- The standalone tracker can open a MIDI port for receiving MIDI notes
|
||||
([#166][i166])
|
||||
- The note editor has a button to allow entering notes by MIDI. ([#170][i170])
|
||||
- Units can have comments, to make it easier to distinguish between units of
|
||||
same type within an instrument and to use these as subsection titles.
|
||||
([#114][i114])
|
||||
- A toggle button for copying non-unique patterns before editing. When enabled
|
||||
and if the pattern is used in multiple places, the pattern is copied first.
|
||||
([#77][i77])
|
||||
- User can define own keybindings in `os.UserConfigDir()/sointu/keybindings.yml`
|
||||
([#94][i94], [#151][i151])
|
||||
- User can define preferred window size in
|
||||
`os.UserConfigDir()/sointu/preferences.yml` ([#184][i184])
|
||||
- A small number above the instrument name identifies the MIDI channel /
|
||||
instrument number, with numbering starting from 1 ([#154][i154])
|
||||
- The filter unit frequency parameter is displayed in Hz, corresponding roughly
|
||||
to the resonant frequency of the filter ([#158][i158])
|
||||
- Include version info in the binaries, as given be `git describe`. This version
|
||||
info is shown as a label in the tracker and can be checked with `-v` flag in
|
||||
the command line tools.
|
||||
- Performance improvement: values needed by the UI that are derived from the
|
||||
score or patch are cached when score or patch changes, so they don't have to
|
||||
be computed every draw. ([#176][i176])
|
||||
|
||||
### Fixed
|
||||
- Tooltips will be hidden after certain amount of time has passed, to ensure
|
||||
that the tooltips don't stay around ([#141][i141])
|
||||
- Loading instrument forgot to close the file (in model.ReadInstrument)
|
||||
- We try to honor the MIDI event time stamps, so that the timing between MIDI
|
||||
events (as reported to us by RTMIDI) will be correct.
|
||||
- When unmarshaling the recovery file, the unit parameter maps were "merged"
|
||||
with the existing parameter maps, instead of overwriting. This created units
|
||||
with unnecessary parameters, which was harmless, but would cause a warning to
|
||||
the user.
|
||||
- When changing a nibble of a hexadecimal note, the note played was the note
|
||||
before changing the nibble
|
||||
- Clicking on low nibble or high nibble of a hex track selects that nibble
|
||||
([#160][i160])
|
||||
- If units have useless parameters in their parameter maps, from bugs or from a
|
||||
malformed yaml file, they are removed and user is warned about it
|
||||
- Pressing `a` or `1` when editing note values in hex mode created a note off
|
||||
line ([#162][i162])
|
||||
- Warn about plugin sample rate being different from 44100 only after
|
||||
ProcessFloatFunc has been called, so that host has time to set the sample rate
|
||||
after initialization.
|
||||
- Crashes with sample-based oscillators in the 32-bit library, as the pointer to
|
||||
sample-table (edi) got accidentally overwritten by detune
|
||||
- Sample-based oscillators could hard crash if a x87 stack overflow happened
|
||||
when calculating the current position in the sample ([#149][i149])
|
||||
- Numeric updown widget calculated dp-to-px conversion incorrectly, resulting in
|
||||
wrong scaling ([#150][i150])
|
||||
- Empty patch should not crash the native synth ([#148][i148])
|
||||
- sointu-play allows choosing between the synths, assuming it was compiled with
|
||||
`-tags=native`
|
||||
- Most buttons never gain focus, so that clicking a button does not stop
|
||||
whatever the user was currently doing and so that the user does not
|
||||
accidentally trigger the buttons by having them focused and e.g. hitting space
|
||||
([#156][i156])
|
||||
|
||||
### Changed
|
||||
- When saving instrument to a file, the instrument name is not saved to the name
|
||||
field, as Sointu will anyway use the filename as the instrument's name when it
|
||||
is loaded.
|
||||
- Native version of the tracker/VSTi was removed. Instead, you can change
|
||||
between the two versions of the synth on the fly, by clicking on the "Synth"
|
||||
option under the CPU group in the song panel ([#200][i200])
|
||||
- Send amount defaults to 64 = 0.0 ([#178][i178])
|
||||
- The maximum number of delaylines in the native synth was increased to 128,
|
||||
with slight increase in memory usage ([#155][i155])
|
||||
- The numeric updown widget has a new appearance.
|
||||
- The draggable UI splitters snap more controllably to the window edges.
|
||||
- New & better presets, organized by their type to subfolders (thanks Reaby!)
|
||||
([#136][i136])
|
||||
- Presets get their name by concatenating their subdirectory path (with path
|
||||
separators replaced with spaces) to their filename
|
||||
- The keyboard shortcuts are now again closer to what they were old trackers
|
||||
([#151][i151])
|
||||
- The stand-alone apps now output floating point sound, as made possible by
|
||||
upgrading oto-library to latest version. This way the tracker sound output
|
||||
matches the compiled output better, as usually compiled intros output sound in
|
||||
floating point. This might be important if OS sound drivers apply some audio
|
||||
enhancemenets e.g. compressors to the audio.
|
||||
|
||||
## [0.4.1]
|
||||
### Added
|
||||
- Clicking the parameter slider also selects that parameter ([#112][i112])
|
||||
- The vertical and horizontal split bars indicate with a cursor that they can be
|
||||
resized ([#145][i145])
|
||||
|
||||
### Fixed
|
||||
- When adding a unit on the last row of the unit list, the editor for entering
|
||||
the type of the unit by text did gain focus.
|
||||
- When inputting a note to the note editor, advance the cursor by step
|
||||
([#144][i144])
|
||||
- When loading an instrument, make sure the total number of voices does not go
|
||||
over the maximum number allowed by vm, and make sure a loaded instrument has
|
||||
at least 1 voice
|
||||
- Potential ID collisions when clearing unit or pasteing instruments
|
||||
- Assign new IDs to loaded instruments, and fix ID collisions in case they
|
||||
somehow still appear ([#146][i146])
|
||||
- In x86 templates, do not optimize away phase modulations when unisons are used
|
||||
even if all phase inputs are zeros, as unisons use the phase modulation
|
||||
mechanism to offset the different oscillators
|
||||
- Do not include delay times in the delay time table if the delay unit is
|
||||
disabled ([#139][i139])
|
||||
- Moved the error and warning popups slightly up so they don't block the unit
|
||||
control buttons ([#142][i142])
|
||||
|
||||
### Changed
|
||||
- Do not automatically wrap around the song when playing as it was usually
|
||||
unwanted behaviour. There is already the looping mechanism if the user really
|
||||
wants to loop the song forever.
|
||||
|
||||
## [0.4.0]
|
||||
### Added
|
||||
- User can drop preset instruments into `os.UserConfigDir()/sointu/presets/` and
|
||||
they appear in the list of presets next time sointu is started.
|
||||
@ -50,7 +246,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
up now increases the value, while scrolling down decreases the value. It was
|
||||
vice versa. ([#112][i112])
|
||||
|
||||
## v0.3.0
|
||||
## [0.3.0]
|
||||
### Added
|
||||
- Scroll bars to menus, shown when a menu is too long to fit.
|
||||
- Save the GUI state periodically to a recovery file and load it on
|
||||
@ -85,7 +281,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
stays the same, but the label was changed to "self", to highlight that
|
||||
this means the voice modulates only itself and not other voices.
|
||||
|
||||
## v0.2.0
|
||||
## [0.2.0]
|
||||
### Added
|
||||
- Saving and loading instruments
|
||||
- Comment field to instruments
|
||||
@ -123,7 +319,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
breaking change and changes the meaning of the resolution values. But
|
||||
now there are more usable values in the resolution.
|
||||
|
||||
## v0.1.0
|
||||
## [0.1.0]
|
||||
### Added
|
||||
- An instrument (set of opcodes & accompanying values) can have any
|
||||
number of voices.
|
||||
@ -151,14 +347,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- a command line utility to convert .yml songs to .asm
|
||||
- a command line utility to play the songs on command line
|
||||
|
||||
[Unreleased]: https://github.com/vsariola/sointu/compare/v0.4.0...HEAD
|
||||
[Unreleased]: https://github.com/vsariola/sointu/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://github.com/vsariola/sointu/compare/v0.4.1...v0.5.0
|
||||
[0.4.1]: https://github.com/vsariola/sointu/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/vsariola/sointu/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/vsariola/sointu/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://github.com/vsariola/sointu/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/vsariola/sointu/compare/4klang-3.11...v0.1.0
|
||||
[i61]: https://github.com/vsariola/sointu/issues/61
|
||||
[i65]: https://github.com/vsariola/sointu/issues/65
|
||||
[i67]: https://github.com/vsariola/sointu/issues/67
|
||||
[i68]: https://github.com/vsariola/sointu/issues/68
|
||||
[i77]: https://github.com/vsariola/sointu/issues/77
|
||||
[i91]: https://github.com/vsariola/sointu/issues/91
|
||||
[i94]: https://github.com/vsariola/sointu/issues/94
|
||||
[i112]: https://github.com/vsariola/sointu/issues/112
|
||||
[i114]: https://github.com/vsariola/sointu/issues/114
|
||||
[i116]: https://github.com/vsariola/sointu/issues/116
|
||||
[i120]: https://github.com/vsariola/sointu/issues/120
|
||||
[i121]: https://github.com/vsariola/sointu/issues/121
|
||||
@ -167,3 +371,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
[i128]: https://github.com/vsariola/sointu/issues/128
|
||||
[i129]: https://github.com/vsariola/sointu/issues/129
|
||||
[i130]: https://github.com/vsariola/sointu/issues/130
|
||||
[i136]: https://github.com/vsariola/sointu/issues/136
|
||||
[i139]: https://github.com/vsariola/sointu/issues/139
|
||||
[i141]: https://github.com/vsariola/sointu/issues/141
|
||||
[i142]: https://github.com/vsariola/sointu/issues/142
|
||||
[i144]: https://github.com/vsariola/sointu/issues/144
|
||||
[i145]: https://github.com/vsariola/sointu/issues/145
|
||||
[i146]: https://github.com/vsariola/sointu/issues/146
|
||||
[i148]: https://github.com/vsariola/sointu/issues/148
|
||||
[i149]: https://github.com/vsariola/sointu/issues/149
|
||||
[i150]: https://github.com/vsariola/sointu/issues/150
|
||||
[i151]: https://github.com/vsariola/sointu/issues/151
|
||||
[i153]: https://github.com/vsariola/sointu/issues/153
|
||||
[i154]: https://github.com/vsariola/sointu/issues/154
|
||||
[i155]: https://github.com/vsariola/sointu/issues/155
|
||||
[i156]: https://github.com/vsariola/sointu/issues/156
|
||||
[i157]: https://github.com/vsariola/sointu/issues/157
|
||||
[i158]: https://github.com/vsariola/sointu/issues/158
|
||||
[i160]: https://github.com/vsariola/sointu/issues/160
|
||||
[i162]: https://github.com/vsariola/sointu/issues/162
|
||||
[i163]: https://github.com/vsariola/sointu/issues/163
|
||||
[i166]: https://github.com/vsariola/sointu/issues/166
|
||||
[i168]: https://github.com/vsariola/sointu/issues/168
|
||||
[i170]: https://github.com/vsariola/sointu/issues/170
|
||||
[i173]: https://github.com/vsariola/sointu/issues/173
|
||||
[i176]: https://github.com/vsariola/sointu/issues/176
|
||||
[i178]: https://github.com/vsariola/sointu/issues/178
|
||||
[i184]: https://github.com/vsariola/sointu/issues/184
|
||||
[i186]: https://github.com/vsariola/sointu/issues/186
|
||||
[i192]: https://github.com/vsariola/sointu/issues/192
|
||||
[i196]: https://github.com/vsariola/sointu/issues/196
|
||||
[i199]: https://github.com/vsariola/sointu/issues/199
|
||||
[i200]: https://github.com/vsariola/sointu/issues/200
|
||||
[i210]: https://github.com/vsariola/sointu/issues/210
|
||||
[i211]: https://github.com/vsariola/sointu/issues/211
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -1,7 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Dominik Ries
|
||||
(c) 2020 Veikko Sariola
|
||||
(c) 2020-2025 Veikko Sariola, moitias, qm210, LeStahl, petersalomonsen, anticore, reaby
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
128
README.md
128
README.md
@ -7,18 +7,27 @@ intros, forked from [4klang](https://github.com/hzdgopher/4klang). Targetable
|
||||
architectures include 386, amd64, and WebAssembly; targetable platforms include
|
||||
Windows, Mac, Linux (and related) + browser.
|
||||
|
||||
User manual will be in the [Wiki](https://github.com/vsariola/sointu/wiki).
|
||||
- [User manual](https://github.com/vsariola/sointu/wiki) is in the Wiki
|
||||
- [Discussions](https://github.com/vsariola/sointu/discussions) is for asking
|
||||
help, sharing patches/instruments and brainstorming ideas
|
||||
- [Issues](https://github.com/vsariola/sointu/issues) is for reporting bugs
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
You can either 1) download the prebuilt release binaries from the
|
||||
[releases](https://github.com/vsariola/sointu/releases); or 2) download the
|
||||
latest build from the master branch from the
|
||||
[actions](https://github.com/vsariola/sointu/actions) (find workflow "Binaries"
|
||||
and scroll down for .zip files containing the artifacts). Then just run one of
|
||||
the executables or, in the case of the VST plugins library files, copy them
|
||||
wherever you keep you VST2 plugins.
|
||||
You can either:
|
||||
|
||||
1) Download the latest build from the master branch from the
|
||||
[actions](https://github.com/vsariola/sointu/actions) (find workflow
|
||||
"Binaries" and scroll down for .zip files containing the artifacts.
|
||||
**Note:** You have to be logged into Github to download artifacts!
|
||||
|
||||
or
|
||||
|
||||
2) Download the prebuilt release binaries from the
|
||||
[releases](https://github.com/vsariola/sointu/releases). Then just run one
|
||||
of the executables or, in the case of the VST plugins library files, copy
|
||||
them wherever you keep you VST2 plugins.
|
||||
|
||||
The pre 1.0 version tags are mostly for reference: no backwards
|
||||
compatibility will be guaranteed while upgrading to a newer version.
|
||||
@ -73,7 +82,8 @@ for the audio, so the portability is currently limited by these.
|
||||
#### Prerequisites
|
||||
|
||||
- [go](https://golang.org/)
|
||||
- If you want to use the faster x86 assembly written synthesizer:
|
||||
- If you want to also use the x86 assembly written synthesizer, to test that the
|
||||
patch also works once compiled:
|
||||
- Follow the instructions to build the [x86 native virtual machine](#native-virtual-machine)
|
||||
before building the tracker.
|
||||
- cgo compatible compiler e.g. [gcc](https://gcc.gnu.org/). On
|
||||
@ -100,7 +110,7 @@ go build -o sointu-track.exe cmd/sointu-track/main.go
|
||||
On other platforms than Windows, replace `-o sointu-track.exe` with
|
||||
`-o sointu-track`.
|
||||
|
||||
If you want to use the [x86 native virtual machine](#native-virtual-machine),
|
||||
If you want to include the [x86 native virtual machine](#native-virtual-machine),
|
||||
add `-tags=native` to all the commands e.g.
|
||||
|
||||
```
|
||||
@ -123,7 +133,7 @@ a dynamically linked library and ran inside a VST host.
|
||||
if it is not set and go fails to find the compiler, go just excludes
|
||||
all files with `import "C"` from the build, resulting in lots of
|
||||
errors about missing types.
|
||||
- If you want to use the faster x86 assembly written synthesizer:
|
||||
- If you want to build the VSTI with the native x86 assembly written synthesizer:
|
||||
- Follow the instructions to build the [x86 native virtual machine](#native-virtual-machine)
|
||||
before building the plugin itself
|
||||
|
||||
@ -171,15 +181,15 @@ The compiler can then be used to compile a .yml song into .asm and .h files. For
|
||||
example:
|
||||
|
||||
```
|
||||
sointu-compile -o . -arch=386 tests/test_chords.yml
|
||||
sointu-compile -arch=386 tests/test_chords.yml
|
||||
nasm -f win32 test_chords.asm
|
||||
```
|
||||
|
||||
WebAssembly example:
|
||||
|
||||
```
|
||||
sointu-compile -o . -arch=wasm tests/test_chords.yml
|
||||
wat2wasm --enable-bulk-memory test_chords.wat
|
||||
sointu-compile -arch=wasm tests/test_chords.yml
|
||||
wat2wasm test_chords.wat
|
||||
```
|
||||
|
||||
If you are looking for an easy way to compile an executable from a Sointu song
|
||||
@ -203,11 +213,17 @@ there by default.
|
||||
|
||||
### Native virtual machine
|
||||
|
||||
The native bridge allows Go to call the sointu compiled x86 native virtual
|
||||
machine, through cgo, instead of using the Go written bytecode interpreter. It's
|
||||
likely slightly faster than the interpreter. Before you can actually run it, you
|
||||
need to build the bridge using CMake (thus, ***this will not work with go
|
||||
get***).
|
||||
The native bridge allows Go to call the Sointu compiled x86 native virtual
|
||||
machine, through cgo, instead of using the Go written bytecode interpreter. With
|
||||
the latest Go compiler, the native virtual machine is actually slower than the
|
||||
Go-written one, but importantly, the native virtual machine allows you to test
|
||||
that the patch also works within the stack limits of the x87 virtual machine,
|
||||
which is the VM used in the compiled intros. In the tracker/VSTi, you can switch
|
||||
between the native synth and the Go synth under the CPU panel in the Song
|
||||
settings.
|
||||
|
||||
Before you can actually run it, you need to build the bridge using CMake (thus,
|
||||
***this will not work with go get***).
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
@ -323,7 +339,7 @@ New features since fork
|
||||
`text/template` package, effectively working as a preprocessor. This allows
|
||||
quite powerful combination: we can handcraft the assembly code to keep the
|
||||
entropy as low as possible, yet we can call arbitrary go functions as
|
||||
"macros". The templates are [here](templates/) and the compiler lives
|
||||
"macros". The templates are [here](vm/compiler/templates/) and the compiler lives
|
||||
[here](vm/compiler/).
|
||||
- **Tracker**. Written in go. Can run either as a stand-alone app or a vsti
|
||||
plugin.
|
||||
@ -340,9 +356,9 @@ New features since fork
|
||||
opcodes. So, you can have a single instrument with three voices, and three
|
||||
tracks that use this instrument, to make chords. See
|
||||
[here](tests/test_chords.yml) for an example and
|
||||
[here](templates/amd64-386/patch.asm) for the implementation. The maximum
|
||||
total number of voices is 32: you can have 32 monophonic instruments or any
|
||||
combination of polyphonic instruments adding up to 32.
|
||||
[here](vm/compiler/templates/amd64-386/patch.asm) for the implementation.
|
||||
The maximum total number of voices is 32: you can have 32 monophonic
|
||||
instruments or any combination of polyphonic instruments adding up to 32.
|
||||
- **Any number of voices per track**. A single track can trigger more than one
|
||||
voice. At every note, a new voice from the assigned voices is triggered and
|
||||
the previous released. Combined with the previous, you can have a single
|
||||
@ -351,13 +367,13 @@ New features since fork
|
||||
Not only that, a track can even trigger voices of different instruments,
|
||||
alternating between these two; maybe useful for example as an easy way to
|
||||
alternate between an open and a closed hihat.
|
||||
- **Easily extensible**. Instead of %ifdef hell, the primary extension
|
||||
mechanism is through new opcodes for the virtual machine. Only the opcodes
|
||||
actually used in a song are compiled into the virtual machine. The goal is
|
||||
to try to write the code so that if two similar opcodes are used, the common
|
||||
code in both is reused by moving it to a function. Macro and linker magic
|
||||
ensure that also helper functions are only compiled in if they are actually
|
||||
used.
|
||||
- **Reasonably easily extensible**. Instead of %ifdef hell, the primary
|
||||
extension mechanism is through new opcodes for the virtual machine. Only the
|
||||
opcodes actually used in a song are compiled into the virtual machine. The
|
||||
goal is to try to write the code so that if two similar opcodes are used,
|
||||
the common code in both is reused by moving it to a function. Macro and
|
||||
linker magic ensure that also helper functions are only compiled in if they
|
||||
are actually used.
|
||||
- **Songs are YAML files**. These markup files are simple data files,
|
||||
describing the tracks, patterns and patch structure (see
|
||||
[here](tests/test_oscillat_trisaw.yml) for an example). The sointu-compile
|
||||
@ -412,36 +428,9 @@ New features since fork
|
||||
releasing voices etc.)
|
||||
- **Calling Sointu as a library from Go language**. The Go API is slighty more
|
||||
sane than the low-level library API, offering more Go-like experience.
|
||||
- **A bytecode interpreter written in pure go**. It's slightly slower than the
|
||||
hand-written assembly code by sointu compiler, but with this, the tracker is
|
||||
ultraportable and does not need cgo calls.
|
||||
|
||||
Future goals
|
||||
------------
|
||||
|
||||
- **Find a more general solution for skipping opcodes / early outs**. It might
|
||||
be a new opcode "skip" that skips from the opcode to the next out in case
|
||||
the signal entering skip and the signal leaving out are both close to zero.
|
||||
Need to investigate the best way to implement this.
|
||||
- **Even more opcodes**. Some potentially useful additions could be:
|
||||
- Equalizer / more flexible filters
|
||||
- Very slow filters (~ DC-offset removal). Can be implemented using a single
|
||||
bit flag in the existing filter
|
||||
- Arbitrary envelopes; for easier automation.
|
||||
- **MIDI support for the tracker**.
|
||||
- **Find a solution for denormalized signals**. Denormalized floating point
|
||||
numbers (floating point numbers that are very very small) can result in 100x
|
||||
CPU slow down. We got hit by this already: the damp filters in delay units
|
||||
were denormalizing, resulting in the synth being unusable in real time. Need
|
||||
to investigate a) where denormalization can happen; b) how to prevent it:
|
||||
add & substract value; c) make this optional to the user. For quick
|
||||
explanation about the potential massive CPU hit, see
|
||||
https://stackoverflow.com/questions/36781881/why-denormalized-floats-are-so-much-slower-than-other-floats-from-hardware-arch
|
||||
|
||||
Long-shot ideas
|
||||
-----------
|
||||
- **Hack deeper into audio sources from the OS**. Speech synthesis, I'm eyeing
|
||||
at you.
|
||||
- **A bytecode interpreter written in pure go**. With the latest Go compiler,
|
||||
it's slightly faster hand-written one using x87 opcodes. With this, the
|
||||
tracker is ultraportable and does not need cgo calls.
|
||||
|
||||
Design philosophy
|
||||
-----------------
|
||||
@ -463,8 +452,8 @@ Design philosophy
|
||||
- Benchmark optimizations. Compression results are sometimes slightly
|
||||
nonintuitive so alternative implementations should always be benchmarked
|
||||
e.g. by compiling and linking a real-world song with
|
||||
[Leviathan](https://github.com/armak/Leviathan-2.0) and observing how the
|
||||
optimizations affect the byte size.
|
||||
[one of the examples](examples/code/C) and observing how the optimizations
|
||||
affect the byte size.
|
||||
|
||||
Background and history
|
||||
----------------------
|
||||
@ -510,6 +499,15 @@ Prods using Sointu
|
||||
- [Phosphorescent Purple Pixel Peaks](https://www.pouet.net/prod.php?which=96198) by mrange & Virgill
|
||||
- [21](https://demozoo.org/music/338597/) by NR4 / Team210
|
||||
- [Tausendeins](https://www.pouet.net/prod.php?which=96192) by epoqe & Team210
|
||||
- [Radiant](https://www.pouet.net/prod.php?which=97200) by Team210
|
||||
- [Aurora Florae](https://www.pouet.net/prod.php?which=97516) by Team210 and
|
||||
epoqe
|
||||
- [Night Ride](https://www.pouet.net/prod.php?which=98212) by Ctrl-Alt-Test &
|
||||
Alcatraz
|
||||
- [Bicolor Challenge](https://demozoo.org/competitions/19410/) with [Sointu
|
||||
song](https://files.scene.org/view/parties/2024/deadline24/bicolor_challenge/wayfinder_-_bicolor_soundtrack.zip)
|
||||
provided by wayfinder
|
||||
- [napolnitel](https://www.pouet.net/prod.php?which=104336) by jetlag
|
||||
|
||||
Contributing
|
||||
------------
|
||||
@ -536,5 +534,7 @@ The original 4klang: Dominik Ries ([gopher/Alcatraz](https://github.com/hzdgophe
|
||||
& Paul Kraus (pOWL/Alcatraz) :heart:
|
||||
|
||||
Sointu: Veikko Sariola (pestis/bC!), [Apollo/bC!](https://github.com/moitias),
|
||||
[NR4/Team210](https://github.com/LeStahL/), [PoroCYon](https://github.com/PoroCYon/4klang),
|
||||
[kendfss](https://github.com/kendfss), [anticore](https://github.com/anticore)
|
||||
[NR4/Team210](https://github.com/LeStahL/),
|
||||
[PoroCYon](https://github.com/PoroCYon/4klang),
|
||||
[kendfss](https://github.com/kendfss), [anticore](https://github.com/anticore),
|
||||
[qm210](https://github.com/qm210), [reaby](https://github.com/reaby)
|
||||
|
||||
75
audio.go
75
audio.go
@ -5,7 +5,9 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
@ -13,22 +15,28 @@ type (
|
||||
// sample represented by [2]float32. [0] is left channel, [1] is right
|
||||
AudioBuffer [][2]float32
|
||||
|
||||
// AudioOutput represents something where we can send audio e.g. audio output.
|
||||
// WriteAudio should block if not ready to accept audio e.g. buffer full.
|
||||
AudioOutput interface {
|
||||
WriteAudio(buffer AudioBuffer) error
|
||||
Close() error
|
||||
CloserWaiter interface {
|
||||
io.Closer
|
||||
Wait()
|
||||
}
|
||||
|
||||
// AudioContext represents the low-level audio drivers. There should be at most
|
||||
// one AudioContext at a time. The interface is implemented at least by
|
||||
// AudioContext represents the low-level audio drivers. There should be at
|
||||
// most one AudioContext at a time. The interface is implemented at least by
|
||||
// oto.OtoContext, but in future we could also mock it.
|
||||
//
|
||||
// AudioContext is used to create one or more AudioOutputs with Output(); each
|
||||
// can be used to output separate sound & closed when done.
|
||||
// AudioContext is used to play one or more AudioSources. Playing can be
|
||||
// stopped by closing the returned io.Closer.
|
||||
AudioContext interface {
|
||||
Output() AudioOutput
|
||||
Close() error
|
||||
Play(r AudioSource) CloserWaiter
|
||||
}
|
||||
|
||||
// AudioSource is an function for reading audio samples into an AudioBuffer.
|
||||
// Returns error if the buffer is not filled.
|
||||
AudioSource func(buf AudioBuffer) error
|
||||
|
||||
BufferSource struct {
|
||||
buffer AudioBuffer
|
||||
pos int
|
||||
}
|
||||
|
||||
// Synth represents a state of a synthesizer, compiled from a Patch.
|
||||
@ -55,13 +63,24 @@ type (
|
||||
// Release releases the currently playing note for a given voice. Called
|
||||
// between synth.Renders.
|
||||
Release(voice int)
|
||||
|
||||
// Close disposes the synth, freeing any resources. No other functions should be called after Close.
|
||||
Close()
|
||||
|
||||
// Populates the given array with the current CPU load of each thread,
|
||||
// returning the number of threads / elements populated
|
||||
CPULoad([]CPULoad) int
|
||||
}
|
||||
|
||||
// Synther compiles a given Patch into a Synth, throwing errors if the
|
||||
// Patch is malformed.
|
||||
Synther interface {
|
||||
Name() string // Name of the synther, e.g. "Go" or "Native"
|
||||
Synth(patch Patch, bpm int) (Synth, error)
|
||||
SupportsMultithreading() bool
|
||||
}
|
||||
|
||||
CPULoad float32
|
||||
)
|
||||
|
||||
// Play plays the Song by first compiling the patch with the given Synther,
|
||||
@ -75,6 +94,7 @@ func Play(synther Synther, song Song, progress func(float32)) (AudioBuffer, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sointu.Play failed: %v", err)
|
||||
}
|
||||
defer synth.Close()
|
||||
curVoices := make([]int, len(song.Score.Tracks))
|
||||
for i := range curVoices {
|
||||
curVoices[i] = song.Score.FirstVoiceForTrack(i)
|
||||
@ -145,6 +165,28 @@ func (buffer AudioBuffer) Fill(synth Synth) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b AudioBuffer) Source() AudioSource {
|
||||
return func(buf AudioBuffer) error {
|
||||
n := copy(buf, b)
|
||||
b = b[n:]
|
||||
if n < len(buf) {
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAudio reads audio samples from an AudioSource into an AudioBuffer.
|
||||
// Returns an error when the buffer is fully consumed.
|
||||
func (a *BufferSource) ReadAudio(buf AudioBuffer) error {
|
||||
n := copy(buf, a.buffer[a.pos:])
|
||||
a.pos += n
|
||||
if a.pos >= len(a.buffer) {
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wav converts an AudioBuffer into a valid WAV-file, returned as a []byte
|
||||
// array.
|
||||
//
|
||||
@ -174,6 +216,17 @@ func (buffer AudioBuffer) Raw(pcm16 bool) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (p *CPULoad) Update(duration time.Duration, frames int64) {
|
||||
if frames <= 0 {
|
||||
return // no frames rendered, so cannot compute CPU load
|
||||
}
|
||||
realtime := float64(duration) / 1e9
|
||||
songtime := float64(frames) / 44100
|
||||
newload := realtime / songtime
|
||||
alpha := math.Exp(-songtime) // smoothing factor, time constant of 1 second
|
||||
*p = CPULoad(float64(*p)*alpha + newload*(1-alpha))
|
||||
}
|
||||
|
||||
func (data AudioBuffer) rawToBuffer(pcm16 bool, buf *bytes.Buffer) error {
|
||||
var err error
|
||||
if pcm16 {
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
//go:build native
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/vsariola/sointu/vm/compiler/bridge"
|
||||
|
||||
var MainSynther = bridge.NativeSynther{}
|
||||
@ -1,7 +0,0 @@
|
||||
//go:build !native
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/vsariola/sointu/vm"
|
||||
|
||||
var MainSynther = vm.GoSynther{}
|
||||
12
cmd/midi_cgo.go
Normal file
12
cmd/midi_cgo.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build cgo
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"github.com/vsariola/sointu/tracker/gomidi"
|
||||
)
|
||||
|
||||
func NewMidiContext(broker *tracker.Broker) tracker.MIDIContext {
|
||||
return gomidi.NewContext(broker)
|
||||
}
|
||||
12
cmd/midi_not_cgo.go
Normal file
12
cmd/midi_not_cgo.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build !cgo
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
func NewMidiContext(broker *tracker.Broker) tracker.MIDIContext {
|
||||
// with no cgo, we cannot use MIDI, so return a null context
|
||||
return tracker.NullMIDIContext{}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/version"
|
||||
"github.com/vsariola/sointu/vm/compiler"
|
||||
)
|
||||
|
||||
@ -43,8 +44,13 @@ func main() {
|
||||
targetArch := flag.String("arch", runtime.GOARCH, "Target architecture. Defaults to OS architecture. Possible values: 386, amd64, wasm")
|
||||
output16bit := flag.Bool("i", false, "Compiled song should output 16-bit integers, instead of floats.")
|
||||
targetOs := flag.String("os", runtime.GOOS, "Target OS. Defaults to current OS. Possible values: windows, darwin, linux. Anything else is assumed linuxy. Ignored when targeting wasm.")
|
||||
versionFlag := flag.Bool("v", false, "Print version.")
|
||||
flag.Usage = printUsage
|
||||
flag.Parse()
|
||||
if *versionFlag {
|
||||
fmt.Println(version.VersionOrHash)
|
||||
os.Exit(0)
|
||||
}
|
||||
if (flag.NArg() == 0 && !*library) || *help {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
|
||||
@ -9,11 +9,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/vsariola/sointu/cmd"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/oto"
|
||||
"github.com/vsariola/sointu/vm/compiler/bridge"
|
||||
"github.com/vsariola/sointu/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -27,8 +29,21 @@ func main() {
|
||||
rawOut := flag.Bool("r", false, "Output the rendered song as .raw file. By default, saves stereo float32 buffer to disk.")
|
||||
wavOut := flag.Bool("w", false, "Output the rendered song as .wav file. By default, saves stereo float32 buffer to disk.")
|
||||
pcm := flag.Bool("c", false, "Convert audio to 16-bit signed PCM when outputting.")
|
||||
versionFlag := flag.Bool("v", false, "Print version.")
|
||||
syntherInt := flag.Int("synth", 0, "Select the synther to use. By default, uses the first one in the list of available synthers.")
|
||||
flag.Usage = printUsage
|
||||
flag.Parse()
|
||||
if *versionFlag {
|
||||
fmt.Println(version.VersionOrHash)
|
||||
os.Exit(0)
|
||||
}
|
||||
if *syntherInt < 0 || *syntherInt >= len(cmd.Synthers) {
|
||||
fmt.Fprintf(os.Stderr, "synth index %d is out of range; available synthers:\n", *syntherInt)
|
||||
for i, s := range cmd.Synthers {
|
||||
fmt.Fprintf(os.Stderr, " %d: %s\n", i, s.Name())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
if flag.NArg() == 0 || *help {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
@ -37,6 +52,7 @@ func main() {
|
||||
*play = true // if the user gives nothing to output, then the default behaviour is just to play the file
|
||||
}
|
||||
var audioContext sointu.AudioContext
|
||||
var playWaiter sointu.CloserWaiter
|
||||
if *play {
|
||||
var err error
|
||||
audioContext, err = oto.NewContext()
|
||||
@ -44,7 +60,6 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "could not acquire oto AudioContext: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer audioContext.Close()
|
||||
}
|
||||
process := func(filename string) error {
|
||||
output := func(extension string, contents []byte) error {
|
||||
@ -87,16 +102,12 @@ func main() {
|
||||
return fmt.Errorf("the song could not be parsed as .json (%v) or .yml (%v)", errJSON, errYaml)
|
||||
}
|
||||
}
|
||||
buffer, err := sointu.Play(bridge.NativeSynther{}, song, nil) // render the song to calculate its length
|
||||
buffer, err := sointu.Play(cmd.Synthers[*syntherInt], song, nil) // render the song to calculate its length
|
||||
if err != nil {
|
||||
return fmt.Errorf("sointu.Play failed: %v", err)
|
||||
}
|
||||
if *play {
|
||||
output := audioContext.Output()
|
||||
defer output.Close()
|
||||
if err := output.WriteAudio(buffer); err != nil {
|
||||
return fmt.Errorf("error playing: %v", err)
|
||||
}
|
||||
playWaiter = audioContext.Play(buffer.Source())
|
||||
}
|
||||
if *rawOut {
|
||||
raw, err := buffer.Raw(*pcm)
|
||||
@ -116,6 +127,9 @@ func main() {
|
||||
return fmt.Errorf("error outputting .wav file: %v", err)
|
||||
}
|
||||
}
|
||||
if *play {
|
||||
playWaiter.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
retval := 0
|
||||
|
||||
@ -17,19 +17,9 @@ import (
|
||||
"github.com/vsariola/sointu/tracker/gioui"
|
||||
)
|
||||
|
||||
type NullContext struct {
|
||||
}
|
||||
|
||||
func (NullContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
|
||||
return tracker.MIDINoteEvent{}, false
|
||||
}
|
||||
|
||||
func (NullContext) BPM() (bpm float64, ok bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
|
||||
var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
|
||||
var defaultMidiInput = flag.String("midi-input", "", "connect MIDI input to matching device name prefix")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
@ -49,32 +39,45 @@ func main() {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer audioContext.Close()
|
||||
recoveryFile := ""
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
recoveryFile = filepath.Join(configDir, "Sointu", "sointu-track-recovery")
|
||||
}
|
||||
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
|
||||
broker := tracker.NewBroker()
|
||||
midiContext := cmd.NewMidiContext(broker)
|
||||
defer midiContext.Close()
|
||||
if isFlagPassed("midi-input") {
|
||||
input, ok := tracker.FindMIDIDeviceByPrefix(midiContext, *defaultMidiInput)
|
||||
if ok {
|
||||
err := midiContext.Open(input)
|
||||
if err != nil {
|
||||
log.Printf("failed to open MIDI input '%s': %v", input, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("no MIDI input device found with prefix '%s'", *defaultMidiInput)
|
||||
}
|
||||
}
|
||||
model := tracker.NewModel(broker, cmd.Synthers, midiContext, recoveryFile)
|
||||
player := tracker.NewPlayer(broker, cmd.Synthers[0])
|
||||
|
||||
if a := flag.Args(); len(a) > 0 {
|
||||
f, err := os.Open(a[0])
|
||||
if err == nil {
|
||||
model.ReadSong(f)
|
||||
model.Song().Read(f)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
tracker := gioui.NewTracker(model)
|
||||
output := audioContext.Output()
|
||||
defer output.Close()
|
||||
|
||||
trackerUi := gioui.NewTracker(model)
|
||||
audioCloser := audioContext.Play(func(buf sointu.AudioBuffer) error {
|
||||
player.Process(buf, tracker.NullPlayerProcessContext{})
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
buf := make(sointu.AudioBuffer, 1024)
|
||||
ctx := NullContext{}
|
||||
for {
|
||||
player.Process(buf, ctx)
|
||||
output.WriteAudio(buf)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
tracker.Main()
|
||||
trackerUi.Main()
|
||||
audioCloser.Close()
|
||||
model.Close()
|
||||
if *cpuprofile != "" {
|
||||
pprof.StopCPUProfile()
|
||||
f.Close()
|
||||
@ -94,3 +97,13 @@ func main() {
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func isFlagPassed(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
@ -18,31 +18,13 @@ import (
|
||||
"pipelined.dev/audio/vst2"
|
||||
)
|
||||
|
||||
type VSTIProcessContext struct {
|
||||
events []vst2.MIDIEvent
|
||||
eventIndex int
|
||||
host vst2.Host
|
||||
}
|
||||
|
||||
func (c *VSTIProcessContext) NextEvent() (event tracker.MIDINoteEvent, ok bool) {
|
||||
for c.eventIndex < len(c.events) {
|
||||
ev := c.events[c.eventIndex]
|
||||
c.eventIndex++
|
||||
switch {
|
||||
case ev.Data[0] >= 0x80 && ev.Data[0] < 0x90:
|
||||
channel := ev.Data[0] - 0x80
|
||||
note := ev.Data[1]
|
||||
return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: false, Channel: int(channel), Note: note}, true
|
||||
case ev.Data[0] >= 0x90 && ev.Data[0] < 0xA0:
|
||||
channel := ev.Data[0] - 0x90
|
||||
note := ev.Data[1]
|
||||
return tracker.MIDINoteEvent{Frame: int(ev.DeltaFrames), On: true, Channel: int(channel), Note: note}, true
|
||||
default:
|
||||
// ignore all other MIDI messages
|
||||
}
|
||||
type (
|
||||
VSTIProcessContext struct {
|
||||
events []vst2.MIDIEvent
|
||||
eventIndex int
|
||||
host vst2.Host
|
||||
}
|
||||
return tracker.MIDINoteEvent{}, false
|
||||
}
|
||||
)
|
||||
|
||||
func (c *VSTIProcessContext) BPM() (bpm float64, ok bool) {
|
||||
timeInfo := c.host.GetTimeInfo(vst2.TempoValid)
|
||||
@ -63,41 +45,45 @@ func init() {
|
||||
rand.Read(randBytes)
|
||||
recoveryFile = filepath.Join(configDir, "sointu", "sointu-vsti-recovery-"+hex.EncodeToString(randBytes))
|
||||
}
|
||||
model, player := tracker.NewModelPlayer(cmd.MainSynther, recoveryFile)
|
||||
broker := tracker.NewBroker()
|
||||
model := tracker.NewModel(broker, cmd.Synthers, cmd.NewMidiContext(broker), recoveryFile)
|
||||
player := tracker.NewPlayer(broker, cmd.Synthers[0])
|
||||
|
||||
t := gioui.NewTracker(model)
|
||||
tracker.Bool{BoolData: (*tracker.InstrEnlarged)(model)}.Set(true)
|
||||
if s := h.GetSampleRate(); math.Abs(float64(h.GetSampleRate()-44100.0)) > 1e-6 {
|
||||
model.Alerts().AddAlert(tracker.Alert{
|
||||
Message: fmt.Sprintf("VSTi host sample rate is %.0f Hz; sointu supports 44100 Hz only", s),
|
||||
Priority: tracker.Error,
|
||||
Duration: 10 * time.Second,
|
||||
})
|
||||
}
|
||||
model.Play().TrackerHidden().SetValue(true)
|
||||
// since the VST is usually working without any regard for the tracks
|
||||
// until recording, disable the Instrument-Track linking by default
|
||||
// because it might just confuse the user why instrument cannot be
|
||||
// swapped/added etc.
|
||||
model.Track().LinkInstrument().SetValue(false)
|
||||
go t.Main()
|
||||
context := VSTIProcessContext{host: h}
|
||||
context := &VSTIProcessContext{host: h}
|
||||
buf := make(sointu.AudioBuffer, 1024)
|
||||
var totalFrames int64 = 0
|
||||
return vst2.Plugin{
|
||||
UniqueID: PLUGIN_ID,
|
||||
UniqueID: [4]byte{'S', 'n', 't', 'u'},
|
||||
Version: version,
|
||||
InputChannels: 0,
|
||||
OutputChannels: 2,
|
||||
Name: PLUGIN_NAME,
|
||||
Name: "Sointu",
|
||||
Vendor: "vsariola/sointu",
|
||||
Category: vst2.PluginCategorySynth,
|
||||
Flags: vst2.PluginIsSynth,
|
||||
ProcessFloatFunc: func(in, out vst2.FloatBuffer) {
|
||||
if s := h.GetSampleRate(); math.Abs(float64(h.GetSampleRate()-44100.0)) > 1e-6 {
|
||||
player.SendAlert("WrongSampleRate", fmt.Sprintf("VSTi host sample rate is %.0f Hz; sointu supports 44100 Hz only", s), tracker.Error)
|
||||
}
|
||||
left := out.Channel(0)
|
||||
right := out.Channel(1)
|
||||
if len(buf) < out.Frames {
|
||||
buf = append(buf, make(sointu.AudioBuffer, out.Frames-len(buf))...)
|
||||
}
|
||||
buf = buf[:out.Frames]
|
||||
player.Process(buf, &context)
|
||||
player.Process(buf, context)
|
||||
for i := 0; i < out.Frames; i++ {
|
||||
left[i], right[i] = buf[i][0], buf[i][1]
|
||||
}
|
||||
context.events = context.events[:0] // reset buffer, but keep the allocated memory
|
||||
context.eventIndex = 0
|
||||
totalFrames += int64(out.Frames)
|
||||
},
|
||||
}, vst2.Dispatcher{
|
||||
CanDoFunc: func(pcds vst2.PluginCanDoString) vst2.CanDoResponse {
|
||||
@ -107,26 +93,36 @@ func init() {
|
||||
}
|
||||
return vst2.NoCanDo
|
||||
},
|
||||
ProcessEventsFunc: func(ev *vst2.EventsPtr) {
|
||||
for i := 0; i < ev.NumEvents(); i++ {
|
||||
a := ev.Event(i)
|
||||
switch v := a.(type) {
|
||||
ProcessEventsFunc: func(events *vst2.EventsPtr) {
|
||||
for i := 0; i < events.NumEvents(); i++ {
|
||||
switch ev := events.Event(i).(type) {
|
||||
case *vst2.MIDIEvent:
|
||||
context.events = append(context.events, *v)
|
||||
if ev.Data[0] >= 0x80 && ev.Data[0] <= 0x9F {
|
||||
channel := ev.Data[0] & 0x0F
|
||||
note := ev.Data[1]
|
||||
on := ev.Data[0] >= 0x90
|
||||
trackerEvent := tracker.NoteEvent{Timestamp: int64(ev.DeltaFrames) + totalFrames, On: on, Channel: int(channel), Note: note, Source: &context}
|
||||
tracker.TrySend(broker.MIDIChannel(), any(trackerEvent))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
CloseFunc: func() {
|
||||
t.Exec() <- func() { t.ForceQuit().Do() }
|
||||
t.WaitQuitted()
|
||||
tracker.TrySend(broker.CloseGUI, struct{}{})
|
||||
model.Close()
|
||||
tracker.TimeoutReceive(broker.FinishedGUI, 3*time.Second)
|
||||
},
|
||||
GetChunkFunc: func(isPreset bool) []byte {
|
||||
retChn := make(chan []byte)
|
||||
t.Exec() <- func() { retChn <- t.MarshalRecovery() }
|
||||
return <-retChn
|
||||
|
||||
if !tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { retChn <- t.History().MarshalRecovery() }}) {
|
||||
return nil
|
||||
}
|
||||
ret, _ := tracker.TimeoutReceive(retChn, 5*time.Second) // ret will be nil if timeout or channel closed
|
||||
return ret
|
||||
},
|
||||
SetChunkFunc: func(data []byte, isPreset bool) {
|
||||
t.Exec() <- func() { t.UnmarshalRecovery(data) }
|
||||
tracker.TrySend(broker.ToModel, tracker.MsgToModel{Data: func() { t.History().UnmarshalRecovery(data) }})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
//go:build native
|
||||
|
||||
package main
|
||||
|
||||
var PLUGIN_ID = [4]byte{'S', 'n', 't', 'N'}
|
||||
var PLUGIN_NAME = "Sointu Native"
|
||||
@ -1,6 +0,0 @@
|
||||
//go:build !native
|
||||
|
||||
package main
|
||||
|
||||
var PLUGIN_ID = [4]byte{'S', 'n', 't', 'u'}
|
||||
var PLUGIN_NAME = "Sointu"
|
||||
11
cmd/synthers.go
Normal file
11
cmd/synthers.go
Normal file
@ -0,0 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/vm"
|
||||
)
|
||||
|
||||
var Synthers = []sointu.Synther{
|
||||
vm.GoSynther{},
|
||||
vm.MakeMultithreadSynther(vm.GoSynther{}),
|
||||
}
|
||||
13
cmd/synthers_native.go
Normal file
13
cmd/synthers_native.go
Normal file
@ -0,0 +1,13 @@
|
||||
//go:build native
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/vsariola/sointu/vm"
|
||||
"github.com/vsariola/sointu/vm/compiler/bridge"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Synthers = append(Synthers, bridge.NativeSynther{})
|
||||
Synthers = append(Synthers, vm.MakeMultithreadSynther(bridge.NativeSynther{}))
|
||||
}
|
||||
@ -43,7 +43,7 @@ if(WIN32)
|
||||
add_dependencies(examples cplay-directsound)
|
||||
elseif(UNIX)
|
||||
add_executable(cplay
|
||||
cplay.unix.c
|
||||
cplay.linux.c
|
||||
physics_girl_st.h
|
||||
)
|
||||
target_link_libraries(cplay PRIVATE asound pthread)
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
#include "physics_girl_st.h"
|
||||
|
||||
static SUsample sound_buffer[SU_LENGTH_IN_SAMPLES * SU_CHANNEL_COUNT];
|
||||
static snd_pcm_t *pcm_handle;
|
||||
static pthread_t render_thread;
|
||||
static uint32_t render_thread_handle;
|
||||
static pthread_t render_thread, playback_thread;
|
||||
static uint32_t render_thread_handle, playback_thread_handle;
|
||||
|
||||
void play() {
|
||||
snd_pcm_writei(pcm_handle, sound_buffer, SU_LENGTH_IN_SAMPLES);
|
||||
}
|
||||
|
||||
int main(int argc, char **args) {
|
||||
// Unix does not have gm.dls, no need to ifdef and setup here.
|
||||
@ -33,7 +38,21 @@ int main(int argc, char **args) {
|
||||
0,
|
||||
SU_LENGTH_IN_SAMPLES
|
||||
);
|
||||
snd_pcm_writei(pcm_handle, sound_buffer, SU_LENGTH_IN_SAMPLES);
|
||||
playback_thread_handle = pthread_create(&playback_thread, 0, (void *(*)(void *))play, 0);
|
||||
|
||||
// This is for obtaining the playback time.
|
||||
snd_pcm_status_t *status;
|
||||
snd_pcm_status_malloc(&status);
|
||||
snd_htimestamp_t htime, htstart;
|
||||
snd_pcm_status(pcm_handle, status);
|
||||
snd_pcm_status_get_htstamp(status, &htstart);
|
||||
for(int sample; sample < SU_LENGTH_IN_SAMPLES; sample = (int)(((float)htime.tv_sec + (float)htime.tv_nsec * 1.e-9 - (float)htstart.tv_sec - (float)htstart.tv_nsec * 1.e-9) * SU_SAMPLE_RATE)) {
|
||||
snd_pcm_status(pcm_handle, status);
|
||||
snd_pcm_status_get_htstamp(status, &htime);
|
||||
printf("Sample: %d\n", sample);
|
||||
usleep(1000000 / 30);
|
||||
}
|
||||
snd_pcm_status_free(status);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@ -22,6 +22,11 @@ WAVEFORMATEX wave_format = {
|
||||
SU_SAMPLE_SIZE*8,
|
||||
0
|
||||
};
|
||||
// If you want to loop the song:
|
||||
// 1) Change WHDR_PREPARED -> WHDR_BEGINLOOP | WHDR_ENDLOOP | WHDR_PREPARED
|
||||
// 2) The next field should then contain the number of loops (for example, 4)
|
||||
// 3) Remember also change the exit condition for main, e.g. if you plan to loop 4 times:
|
||||
// mmtime.u.sample != SU_LENGTH_IN_SAMPLES -> mmtime.u.sample != 4 * SU_LENGTH_IN_SAMPLES
|
||||
WAVEHDR wave_header = {
|
||||
(LPSTR)sound_buffer,
|
||||
SU_LENGTH_IN_SAMPLES * SU_SAMPLE_SIZE * SU_CHANNEL_COUNT,
|
||||
|
||||
@ -49,7 +49,7 @@ patch:
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
id: 10
|
||||
parameters: {bandpass: 0, frequency: 0, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 58, stereo: 0}
|
||||
parameters: {bandpass: 0, frequency: 0, highpass: 0, lowpass: 1, resonance: 58, stereo: 0}
|
||||
- type: delay
|
||||
id: 4
|
||||
parameters: {damp: 0, dry: 128, feedback: 96, notetracking: 2, pregain: 40, stereo: 0}
|
||||
@ -87,7 +87,7 @@ patch:
|
||||
parameters: {color: 59, detune: 73, gain: 35, phase: 26, shape: 70, stereo: 0, transpose: 57, type: 2, unison: 3}
|
||||
- type: filter
|
||||
id: 31
|
||||
parameters: {bandpass: 0, frequency: 37, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 60, stereo: 0}
|
||||
parameters: {bandpass: 0, frequency: 37, highpass: 0, lowpass: 1, resonance: 60, stereo: 0}
|
||||
- type: mulp
|
||||
id: 24
|
||||
parameters: {stereo: 0}
|
||||
@ -198,7 +198,7 @@ patch:
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: filter
|
||||
id: 76
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 1}
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, resonance: 64, stereo: 1}
|
||||
- type: outaux
|
||||
id: 73
|
||||
parameters: {auxgain: 64, outgain: 64, stereo: 1}
|
||||
|
||||
33
go.mod
33
go.mod
@ -1,38 +1,41 @@
|
||||
module github.com/vsariola/sointu
|
||||
|
||||
go 1.21
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
gioui.org v0.5.0
|
||||
gioui.org/x v0.5.0
|
||||
gioui.org v0.9.0
|
||||
gioui.org/x v0.8.1
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible
|
||||
github.com/hajimehoshi/oto v0.6.6
|
||||
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
|
||||
golang.org/x/text v0.9.0
|
||||
gopkg.in/yaml.v2 v2.3.0
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
|
||||
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43
|
||||
github.com/viterin/vek v0.4.2
|
||||
gitlab.com/gomidi/midi/v2 v2.2.10
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
golang.org/x/text v0.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2
|
||||
)
|
||||
|
||||
require (
|
||||
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.0 // indirect
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect
|
||||
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 // indirect
|
||||
github.com/chewxy/math32 v1.11.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/go-text/typesetting v0.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.0.6 // indirect
|
||||
github.com/google/uuid v1.1.2 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/imdario/mergo v0.3.11 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/copystructure v1.0.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.6.1 // indirect
|
||||
github.com/viterin/partial v1.1.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
pipelined.dev/pipe v0.11.0 // indirect
|
||||
pipelined.dev/signal v0.10.0 // indirect
|
||||
)
|
||||
|
||||
139
go.sum
139
go.sum
@ -1,117 +1,92 @@
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
gioui.org v0.5.0 h1:07g7/LY1MFuTncfO4A5DIKMMsQV6PkPHyx0MhDqgmYY=
|
||||
gioui.org v0.5.0/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q=
|
||||
gioui.org v0.8.1-0.20250526181049-1a17e9ea3725 h1:8dzkqzvWLIwW6HEQv5CinK53vMeANmUEETzpcbtPRp0=
|
||||
gioui.org v0.8.1-0.20250526181049-1a17e9ea3725/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||
gioui.org v0.8.1-0.20250624114559-c3ce484b5e80 h1:cnimNlq1PEHY4z1Cy32n6In86VUF5/VLi7cWHAM1XcY=
|
||||
gioui.org v0.8.1-0.20250624114559-c3ce484b5e80/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
|
||||
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
|
||||
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
gioui.org/x v0.5.0 h1:NVKTn5AZuYhkAnF7MYcy1dIes36+U1N4gUTsgBhfr4A=
|
||||
gioui.org/x v0.5.0/go.mod h1:X4UBhvanAN+8S16L3K6jDMrVo7Dii7NptgBpOLBD7E4=
|
||||
gioui.org/x v0.7.1 h1:7bnQHsV7qB36tIUit2WDcUx4Cnmo+6T9I38B9brLQ7o=
|
||||
gioui.org/x v0.7.1/go.mod h1:5CzZ64oFpOaqb2kaMvj+QEr5T3nVuLKD0LizLH32ii0=
|
||||
gioui.org/x v0.8.1 h1:Q2wumEOfjz3XfRa3TEi6w7dq8+cxV8zsYK8xXQkrCRk=
|
||||
gioui.org/x v0.8.1/go.mod h1:v2g60aiZtIVR7lNFXZ123+U0kijJeOChODSuqr7MFSI=
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
|
||||
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg=
|
||||
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo=
|
||||
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0 h1:uF5Q/hWnDU1XZeT6CsrRSxHLroUSEYYO3kgES+yd+So=
|
||||
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg=
|
||||
github.com/chewxy/math32 v1.11.1 h1:b7PGHlp8KjylDoU8RrcEsRuGZhJuz8haxnKfuMMRqy8=
|
||||
github.com/chewxy/math32 v1.11.1/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/oto/v3 v3.3.0 h1:34lJpJLqda0Iee9g9p8RWtVVwBcOOO2YSIS2x4yD1OQ=
|
||||
github.com/ebitengine/oto/v3 v3.3.0/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
|
||||
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43 h1:2sTZTp/Nc8srRyDdari4gS+clwfnuNmpLiLvmwxqPVE=
|
||||
github.com/ebitengine/oto/v3 v3.5.0-alpha.0.20260119133252-bae718d5ff43/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
|
||||
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
||||
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
|
||||
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
|
||||
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hajimehoshi/oto v0.6.6 h1:HYSZ8cYZqOL4iHugvbcfhNN2smiSOsBMaoSBi4nnWcw=
|
||||
github.com/hajimehoshi/oto v0.6.6/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
|
||||
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
|
||||
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/viterin/partial v1.1.0 h1:iH1l1xqBlapXsYzADS1dcbizg3iQUKTU1rbwkHv/80E=
|
||||
github.com/viterin/partial v1.1.0/go.mod h1:oKGAo7/wylWkJTLrWX8n+f4aDPtQMQ6VG4dd2qur5QA=
|
||||
github.com/viterin/vek v0.4.2 h1:Vyv04UjQT6gcjEFX82AS9ocgNbAJqsHviheIBdPlv5U=
|
||||
github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc=
|
||||
gitlab.com/gomidi/midi/v2 v2.2.10 h1:u9D+5TM0vkFWF5DcO6xGKG99ERYqksh6wPj2X2Rx5A8=
|
||||
gitlab.com/gomidi/midi/v2 v2.2.10/go.mod h1:ENtYaJPOwb2N+y7ihv/L7R4GtWjbknouhIIkMrJ5C0g=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
|
||||
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f h1:kgfVkAEEQXXQ0qc6dH7n6y37NAYmTFmz0YRwrRjgxKw=
|
||||
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2 h1:qrI7YY5ZH4pJflMfzum2TKvA1NaX+H4feaA6jweX2R8=
|
||||
pipelined.dev/audio/vst2 v0.10.1-0.20240223162706-41e9b65fb5c2/go.mod h1:wETLxsbBPftj6t4iVBCXvH/Xgd27ZgIC4hNnHDYNuz8=
|
||||
pipelined.dev/pipe v0.10.0/go.mod h1:aIt+NPlW0QLYByqYniG77lTxSvl7OtCNLws/m+Xz5ww=
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
package oto
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
// FloatBufferTo16BitLE is a naive helper method to convert []float32 buffers to
|
||||
// 16-bit little-endian, but encoded in byte buffer
|
||||
//
|
||||
// Appends the encoded bytes into "to" slice, allowing you to preallocate the
|
||||
// capacity or just use nil
|
||||
func FloatBufferTo16BitLE(from sointu.AudioBuffer, to []byte) []byte {
|
||||
for _, v := range from {
|
||||
left := to16BitSample(v[0])
|
||||
right := to16BitSample(v[1])
|
||||
to = append(to, byte(left&255), byte(left>>8), byte(right&255), byte(right>>8))
|
||||
}
|
||||
return to
|
||||
}
|
||||
|
||||
// convert float32 to int16, clamping to min and max
|
||||
func to16BitSample(v float32) int16 {
|
||||
if v < -1.0 {
|
||||
return -math.MaxInt16
|
||||
}
|
||||
if v > 1.0 {
|
||||
return math.MaxInt16
|
||||
}
|
||||
return int16(v * math.MaxInt16)
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package oto_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/oto"
|
||||
)
|
||||
|
||||
func TestFloatBufferToBytes(t *testing.T) {
|
||||
floats := sointu.AudioBuffer{{0, 0.000489128}, {0, 0.0019555532}, {0, 0.0043964}, {0, 0.007806882}, {0, 0.012180306}, {0, 0.017508084}, {0, 0.023779746}, {0, 0.030982954}, {0, 0.039103523}, {0, 0.04812544}, {0, 0.05803088}, {0, 0.068800256}, {0, 0.08041221}, {0, 0.09284368}, {0, 0.10606992}, {0, 0.120064534}, {0, 0.13479951}, {0, 0.1502453}, {0, 0.16637078}, {0, 0.18314338}, {0, 0.20052913}, {0, 0.21849263}, {0, 0.23699719}, {0, 0.2560048}, {0, 0.27547634}, {0, 0.29537144}, {0, 0.31564865}, {0, 0.33626547}, {0, 0.35717854}, {0, 0.37834346}, {0, 0.39971504}, {0, 0.4212474}, {0, 0.4428938}, {0, 0.46460703}, {0, 0.48633927}, {0, 0.50804216}, {0, 0.52966696}, {0, 0.5511646}, {0, 0.57248586}, {0, 0.5935812}, {0, 0.6144009}, {0, 0.63489544}, {0, 0.6550152}, {0, 0.67471063}, {0, 0.6939326}, {0, 0.712632}, {0, 0.7307603}, {0, 0.7482692}, {0, 0.7651111}, {0, 0.7812389}}
|
||||
bytes := []byte{0x0, 0x0, 0x10, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, 0x90, 0x0, 0x0, 0x0, 0xff, 0x0, 0x0, 0x0, 0x8f, 0x1, 0x0, 0x0, 0x3d, 0x2, 0x0, 0x0, 0xb, 0x3, 0x0, 0x0, 0xf7, 0x3, 0x0, 0x0, 0x1, 0x5, 0x0, 0x0, 0x28, 0x6, 0x0, 0x0, 0x6d, 0x7, 0x0, 0x0, 0xce, 0x8, 0x0, 0x0, 0x4a, 0xa, 0x0, 0x0, 0xe2, 0xb, 0x0, 0x0, 0x93, 0xd, 0x0, 0x0, 0x5e, 0xf, 0x0, 0x0, 0x40, 0x11, 0x0, 0x0, 0x3b, 0x13, 0x0, 0x0, 0x4b, 0x15, 0x0, 0x0, 0x71, 0x17, 0x0, 0x0, 0xaa, 0x19, 0x0, 0x0, 0xf7, 0x1b, 0x0, 0x0, 0x55, 0x1e, 0x0, 0x0, 0xc4, 0x20, 0x0, 0x0, 0x42, 0x23, 0x0, 0x0, 0xce, 0x25, 0x0, 0x0, 0x66, 0x28, 0x0, 0x0, 0xa, 0x2b, 0x0, 0x0, 0xb7, 0x2d, 0x0, 0x0, 0x6d, 0x30, 0x0, 0x0, 0x29, 0x33, 0x0, 0x0, 0xeb, 0x35, 0x0, 0x0, 0xb0, 0x38, 0x0, 0x0, 0x77, 0x3b, 0x0, 0x0, 0x3f, 0x3e, 0x0, 0x0, 0x7, 0x41, 0x0, 0x0, 0xcb, 0x43, 0x0, 0x0, 0x8c, 0x46, 0x0, 0x0, 0x46, 0x49, 0x0, 0x0, 0xf9, 0x4b, 0x0, 0x0, 0xa4, 0x4e, 0x0, 0x0, 0x43, 0x51, 0x0, 0x0, 0xd6, 0x53, 0x0, 0x0, 0x5c, 0x56, 0x0, 0x0, 0xd2, 0x58, 0x0, 0x0, 0x36, 0x5b, 0x0, 0x0, 0x88, 0x5d, 0x0, 0x0, 0xc6, 0x5f, 0x0, 0x0, 0xee, 0x61, 0x0, 0x0, 0xfe, 0x63}
|
||||
converted := oto.FloatBufferTo16BitLE(floats, nil)
|
||||
for i, v := range converted {
|
||||
if bytes[i] != v {
|
||||
t.Fail()
|
||||
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(converted, bytes) {
|
||||
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloatBufferToBytesLimits(t *testing.T) {
|
||||
floats := sointu.AudioBuffer{{0, 1}, {-1, 0.999}, {-0.999, 0}}
|
||||
bytes := []byte{
|
||||
0x0, 0x0,
|
||||
0xFF, 0x7F, // float 1 = 0x7FFF = 0111111111111111
|
||||
0x01, 0x80, // float -1 = 0x8001 = 1000000000000001
|
||||
0xDE, 0x7F, // float 0.999 = 0x7FDE = 0111111111011110
|
||||
0x22, 0x80, // float -0.999 = 0x8022 = 1000000000100010
|
||||
0x0, 0x0,
|
||||
}
|
||||
converted := oto.FloatBufferTo16BitLE(floats, nil)
|
||||
for i, v := range converted {
|
||||
if bytes[i] != v {
|
||||
t.Fail()
|
||||
t.Errorf("Unexpected conversion output byte %x (expected %x) at position %v", v, bytes[i], i)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(converted, bytes) {
|
||||
t.Fatalf("Unexpected conversion output from FloatBufferTo16BitLE")
|
||||
}
|
||||
}
|
||||
108
oto/oto.go
108
oto/oto.go
@ -1,55 +1,99 @@
|
||||
package oto
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/hajimehoshi/oto"
|
||||
"github.com/ebitengine/oto/v3"
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
type OtoContext oto.Context
|
||||
type OtoOutput struct {
|
||||
player *oto.Player
|
||||
tmpBuffer []byte
|
||||
}
|
||||
const latency = 2048 // in samples at 44100 Hz = ~46 ms
|
||||
|
||||
func (c *OtoContext) Output() sointu.AudioOutput {
|
||||
return &OtoOutput{player: (*oto.Context)(c).NewPlayer(), tmpBuffer: make([]byte, 0)}
|
||||
}
|
||||
type (
|
||||
OtoContext oto.Context
|
||||
|
||||
const otoBufferSize = 8192
|
||||
OtoPlayer struct {
|
||||
player *oto.Player
|
||||
reader *OtoReader
|
||||
}
|
||||
|
||||
OtoReader struct {
|
||||
audioSource sointu.AudioSource
|
||||
tmpBuffer sointu.AudioBuffer
|
||||
waitGroup sync.WaitGroup
|
||||
err error
|
||||
errMutex sync.RWMutex
|
||||
}
|
||||
)
|
||||
|
||||
// NewPlayer creates and initializes a new OtoPlayer
|
||||
func NewContext() (*OtoContext, error) {
|
||||
context, err := oto.NewContext(44100, 2, 2, otoBufferSize)
|
||||
op := oto.NewContextOptions{}
|
||||
op.SampleRate = 44100
|
||||
op.ChannelCount = 2
|
||||
op.Format = oto.FormatFloat32LE
|
||||
context, readyChan, err := oto.NewContext(&op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create oto context: %w", err)
|
||||
}
|
||||
<-readyChan
|
||||
return (*OtoContext)(context), nil
|
||||
}
|
||||
|
||||
func (c *OtoContext) Close() error {
|
||||
if err := (*oto.Context)(c).Close(); err != nil {
|
||||
return fmt.Errorf("cannot close oto context: %w", err)
|
||||
}
|
||||
return nil
|
||||
func (c *OtoContext) Play(r sointu.AudioSource) sointu.CloserWaiter {
|
||||
reader := &OtoReader{audioSource: r}
|
||||
reader.waitGroup.Add(1)
|
||||
player := (*oto.Context)(c).NewPlayer(reader)
|
||||
player.SetBufferSize(latency * 8)
|
||||
player.Play()
|
||||
return OtoPlayer{player: player, reader: reader}
|
||||
}
|
||||
|
||||
// Play implements the audio.Player interface for OtoPlayer
|
||||
func (o *OtoOutput) WriteAudio(floatBuffer sointu.AudioBuffer) (err error) {
|
||||
// we reuse the old capacity tmpBuffer by setting its length to zero. then,
|
||||
// we save the tmpBuffer so we can reuse it next time
|
||||
o.tmpBuffer = FloatBufferTo16BitLE(floatBuffer, o.tmpBuffer[:0])
|
||||
if _, err := o.player.Write(o.tmpBuffer); err != nil {
|
||||
return fmt.Errorf("cannot write to player: %w", err)
|
||||
}
|
||||
return nil
|
||||
func (o OtoPlayer) Wait() {
|
||||
o.reader.waitGroup.Wait()
|
||||
}
|
||||
|
||||
// Close disposes of resources
|
||||
func (o *OtoOutput) Close() error {
|
||||
if err := o.player.Close(); err != nil {
|
||||
return fmt.Errorf("cannot close oto player: %w", err)
|
||||
}
|
||||
return nil
|
||||
func (o OtoPlayer) Close() error {
|
||||
o.reader.closeWithError(errors.New("OtoPlayer was closed"))
|
||||
return o.player.Close()
|
||||
}
|
||||
|
||||
func (o *OtoReader) Read(b []byte) (n int, err error) {
|
||||
o.errMutex.RLock()
|
||||
if o.err != nil {
|
||||
o.errMutex.RUnlock()
|
||||
return 0, o.err
|
||||
}
|
||||
o.errMutex.RUnlock()
|
||||
if len(b)%8 != 0 {
|
||||
return o.closeWithError(fmt.Errorf("oto: Read buffer length must be a multiple of 8"))
|
||||
}
|
||||
samples := len(b) / 8
|
||||
if samples > len(o.tmpBuffer) {
|
||||
o.tmpBuffer = append(o.tmpBuffer, make(sointu.AudioBuffer, samples-len(o.tmpBuffer))...)
|
||||
} else if samples < len(o.tmpBuffer) {
|
||||
o.tmpBuffer = o.tmpBuffer[:samples]
|
||||
}
|
||||
err = o.audioSource(o.tmpBuffer)
|
||||
if err != nil {
|
||||
return o.closeWithError(err)
|
||||
}
|
||||
for i := range o.tmpBuffer {
|
||||
binary.LittleEndian.PutUint32(b[i*8:], math.Float32bits(o.tmpBuffer[i][0]))
|
||||
binary.LittleEndian.PutUint32(b[i*8+4:], math.Float32bits(o.tmpBuffer[i][1]))
|
||||
}
|
||||
return samples * 8, nil
|
||||
}
|
||||
|
||||
func (o *OtoReader) closeWithError(err error) (int, error) {
|
||||
o.errMutex.Lock()
|
||||
defer o.errMutex.Unlock()
|
||||
if o.err == nil {
|
||||
o.err = err
|
||||
o.waitGroup.Done()
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
567
patch.go
567
patch.go
@ -4,7 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/bits"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
@ -16,7 +20,12 @@ type (
|
||||
Name string `yaml:",omitempty"`
|
||||
Comment string `yaml:",omitempty"`
|
||||
NumVoices int
|
||||
Units []Unit
|
||||
Mute bool `yaml:",omitempty"` // Mute is only used in the tracker for soloing/muting instruments; the compiled player ignores this field
|
||||
// ThreadMaskM1 is a bit mask of which threads are used, minus 1. Minus
|
||||
// 1 is done so that the default value 0 means bit mask 0b0001 i.e. only
|
||||
// thread 1 is rendering the instrument.
|
||||
ThreadMaskM1 int `yaml:",omitempty"`
|
||||
Units []Unit
|
||||
}
|
||||
|
||||
// Unit is e.g. a filter, oscillator, envelope and its parameters
|
||||
@ -37,7 +46,7 @@ type (
|
||||
// an oscillator, unit.Type == "oscillator" and unit.Parameters["attack"]
|
||||
// could be 64. Most parameters are either limites to 0 and 1 (e.g. stereo
|
||||
// parameters) or between 0 and 128, inclusive.
|
||||
Parameters map[string]int `yaml:",flow"`
|
||||
Parameters ParamMap `yaml:",flow"`
|
||||
|
||||
// VarArgs is a list containing the variable number arguments that some
|
||||
// units require, most notably the DELAY units. For example, for a DELAY
|
||||
@ -48,16 +57,34 @@ type (
|
||||
// Disabled is a flag that can be set to true to disable the unit.
|
||||
// Disabled units are considered to be not present in the patch.
|
||||
Disabled bool `yaml:",omitempty"`
|
||||
|
||||
// Comment is a free-form comment about the unit that can be displayed
|
||||
// instead of/besides the type of the unit in the GUI, to make it easier
|
||||
// to track what the unit is doing & to make it easier to target sends.
|
||||
Comment string `yaml:",omitempty"`
|
||||
}
|
||||
|
||||
ParamMap map[string]int
|
||||
|
||||
// UnitParameter documents one parameter that an unit takes
|
||||
UnitParameter struct {
|
||||
Name string // thould be found with this name in the Unit.Parameters map
|
||||
MinValue int // minimum value of the parameter, inclusive
|
||||
MaxValue int // maximum value of the parameter, inclusive
|
||||
Neutral int // neutral value of the parameter
|
||||
CanSet bool // if this parameter can be set before hand i.e. through the gui
|
||||
CanModulate bool // if this parameter can be modulated i.e. has a port number in "send" unit
|
||||
DisplayFunc UnitParameterDisplayFunc
|
||||
}
|
||||
|
||||
// StackUse documents how a unit will affect the signal stack.
|
||||
StackUse struct {
|
||||
Inputs [][]int // Inputs documents which inputs contribute to which outputs. len(Inputs) is the number of inputs. Each input can contribute to multiple outputs, so its a slice.
|
||||
Modifies []bool // Modifies documents which of the (mixed) inputs are actually modified by the unit
|
||||
NumOutputs int // NumOutputs is the number of outputs produced by the unit. This is used to determine how many outputs are needed for the unit.
|
||||
}
|
||||
|
||||
UnitParameterDisplayFunc func(int) (value string, unit string)
|
||||
)
|
||||
|
||||
// UnitTypes documents all the available unit types and if they support stereo variant
|
||||
@ -73,90 +100,96 @@ var UnitTypes = map[string]([]UnitParameter){
|
||||
"xch": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
|
||||
"distort": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "drive", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "drive", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
"hold": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "holdfreq", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
"crush": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "resolution", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "resolution", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(24 * float64(v) / 128), "bits" }}},
|
||||
"gain": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
|
||||
"invgain": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB" }}},
|
||||
"dbgain": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "decibels", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "decibels", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(40 * (float64(v)/64 - 1)), "dB" }}},
|
||||
"filter": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "resonance", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: filterFrequencyDispFunc},
|
||||
{Name: "resonance", MinValue: 0, Neutral: 128, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
|
||||
return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "Q dB"
|
||||
}},
|
||||
{Name: "lowpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "bandpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "highpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "negbandpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "neghighpass", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
|
||||
{Name: "bandpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "highpass", MinValue: -1, MaxValue: 1, CanSet: true, CanModulate: false}},
|
||||
"clip": []UnitParameter{{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
|
||||
"pan": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "panning", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "panning", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
"delay": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "pregain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "dry", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "feedback", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "damp", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "notetracking", MinValue: 0, MaxValue: 2, CanSet: true, CanModulate: false},
|
||||
{Name: "notetracking", MinValue: 0, MaxValue: 2, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(noteTrackingNames[:])},
|
||||
{Name: "delaytime", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
|
||||
"compressor": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "threshold", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "ratio", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
|
||||
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: compressorTimeDispFunc},
|
||||
{Name: "invgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
|
||||
return strconv.FormatFloat(toDecibel(128/float64(v)), 'g', 3, 64), "dB"
|
||||
}},
|
||||
{Name: "threshold", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
|
||||
return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB"
|
||||
}},
|
||||
{Name: "ratio", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(1 - float64(v)/128), "" }}},
|
||||
"speed": []UnitParameter{},
|
||||
"out": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
|
||||
"outaux": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "outgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "auxgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "outgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
|
||||
{Name: "auxgain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
|
||||
"aux": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false}},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
|
||||
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
|
||||
"send": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "amount", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "voice", MinValue: 0, MaxValue: 32, CanSet: true, CanModulate: false},
|
||||
{Name: "amount", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }},
|
||||
{Name: "voice", MinValue: 0, MaxValue: 32, CanSet: true, CanModulate: false, DisplayFunc: sendVoiceDispFunc},
|
||||
{Name: "target", MinValue: 0, MaxValue: math.MaxInt32, CanSet: true, CanModulate: false},
|
||||
{Name: "port", MinValue: 0, MaxValue: 7, CanSet: true, CanModulate: false},
|
||||
{Name: "sendpop", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false}},
|
||||
"envelope": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "attack", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
|
||||
{Name: "decay", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
|
||||
{Name: "sustain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
|
||||
{Name: "release", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return engineeringTime(math.Pow(2, 24*float64(v)/128) / 44100) }},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
|
||||
"noise": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "shape", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }}},
|
||||
"oscillator": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "transpose", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "detune", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "phase", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "transpose", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: oscillatorTransposeDispFunc},
|
||||
{Name: "detune", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v-64) / 64), "st" }},
|
||||
{Name: "phase", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) {
|
||||
return strconv.FormatFloat(float64(v)/128*360, 'f', 1, 64), "°"
|
||||
}},
|
||||
{Name: "color", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "shape", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "shape", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true},
|
||||
{Name: "gain", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return strconv.FormatFloat(toDecibel(float64(v)/128), 'g', 3, 64), "dB" }},
|
||||
{Name: "frequency", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
|
||||
{Name: "type", MinValue: int(Sine), MaxValue: int(Sample), CanSet: true, CanModulate: false},
|
||||
{Name: "type", MinValue: int(Sine), MaxValue: int(Sample), CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(oscTypes[:])},
|
||||
{Name: "lfo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "unison", MinValue: 0, MaxValue: 3, CanSet: true, CanModulate: false},
|
||||
{Name: "samplestart", MinValue: 0, MaxValue: 1720329, CanSet: true, CanModulate: false},
|
||||
@ -164,15 +197,127 @@ var UnitTypes = map[string]([]UnitParameter){
|
||||
{Name: "looplength", MinValue: 0, MaxValue: 65535, CanSet: true, CanModulate: false}},
|
||||
"loadval": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "value", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true}},
|
||||
{Name: "value", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return formatFloat(float64(v)/64 - 1), "" }}},
|
||||
"receive": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "left", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true},
|
||||
{Name: "right", MinValue: 0, MaxValue: -1, CanSet: false, CanModulate: true}},
|
||||
"in": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false}},
|
||||
{Name: "channel", MinValue: 0, MaxValue: 6, CanSet: true, CanModulate: false, DisplayFunc: arrDispFunc(channelNames[:])}},
|
||||
"sync": []UnitParameter{},
|
||||
"belleq": []UnitParameter{
|
||||
{Name: "stereo", MinValue: 0, MaxValue: 1, CanSet: true, CanModulate: false},
|
||||
{Name: "frequency", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqFrequencyDisplay(v) }},
|
||||
{Name: "bandwidth", MinValue: 0, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqBandwidthDisplay(v) }},
|
||||
{Name: "gain", MinValue: 0, Neutral: 64, MaxValue: 128, CanSet: true, CanModulate: true, DisplayFunc: func(v int) (string, string) { return belleqGainDisplay(v) }}},
|
||||
}
|
||||
|
||||
// compile errors if interface is not implemented.
|
||||
var _ yaml.Unmarshaler = &ParamMap{}
|
||||
|
||||
func (a *ParamMap) UnmarshalYAML(value *yaml.Node) error {
|
||||
var m map[string]int
|
||||
if err := value.Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Backwards compatibility hack: if the patch was saved with an older
|
||||
// version of Sointu, it might have used the negbandpass and neghighpass
|
||||
// parameters, which now correspond to having bandpass as value -1 and
|
||||
// highpass as value -1.
|
||||
if n, ok := m["negbandpass"]; ok {
|
||||
m["bandpass"] = m["bandpass"] - n
|
||||
delete(m, "negbandpass")
|
||||
}
|
||||
if n, ok := m["neghighpass"]; ok {
|
||||
m["highpass"] = m["highpass"] - n
|
||||
delete(m, "neghighpass")
|
||||
}
|
||||
*a = m
|
||||
return nil
|
||||
}
|
||||
|
||||
var channelNames = [...]string{"left", "right", "aux1 left", "aux1 right", "aux2 left", "aux2 right", "aux3 left", "aux3 right"}
|
||||
var noteTrackingNames = [...]string{"fixed", "pitch", "BPM"}
|
||||
var oscTypes = [...]string{"sine", "trisaw", "pulse", "gate", "sample"}
|
||||
|
||||
func arrDispFunc(arr []string) UnitParameterDisplayFunc {
|
||||
return func(v int) (string, string) {
|
||||
if v < 0 || v >= len(arr) {
|
||||
return "???", ""
|
||||
}
|
||||
return arr[v], ""
|
||||
}
|
||||
}
|
||||
|
||||
func filterFrequencyDispFunc(v int) (string, string) {
|
||||
// In https://www.musicdsp.org/en/latest/Filters/23-state-variable.html,
|
||||
// they call it "cutoff" but it's actually the location of the resonance
|
||||
// peak
|
||||
freq := float64(v) / 128
|
||||
p := freq * freq
|
||||
f := math.Asin(p/2) / math.Pi * 44100
|
||||
return strconv.FormatFloat(f, 'f', 0, 64), "Hz"
|
||||
}
|
||||
|
||||
func belleqFrequencyDisplay(v int) (string, string) {
|
||||
freq := float64(v) / 128
|
||||
p := 2 * freq * freq
|
||||
f := 44100 * p / math.Pi / 2
|
||||
return strconv.FormatFloat(f, 'f', 0, 64), "Hz"
|
||||
}
|
||||
|
||||
func belleqBandwidthDisplay(v int) (string, string) {
|
||||
p := float64(v) / 128
|
||||
Q := 1 / (4 * p)
|
||||
return strconv.FormatFloat(Q, 'f', 2, 64), "Q"
|
||||
}
|
||||
|
||||
func belleqGainDisplay(v int) (string, string) {
|
||||
return strconv.FormatFloat(40*(float64(v)/64-1), 'f', 2, 64), "dB"
|
||||
}
|
||||
|
||||
func compressorTimeDispFunc(v int) (string, string) {
|
||||
alpha := math.Pow(2, -24*float64(v)/128) // alpha is the "smoothing factor" of first order low pass iir
|
||||
sec := -1 / (44100 * math.Log(1-alpha)) // from smoothing factor to time constant, https://en.wikipedia.org/wiki/Exponential_smoothing
|
||||
return engineeringTime(sec)
|
||||
}
|
||||
|
||||
func oscillatorTransposeDispFunc(v int) (string, string) {
|
||||
relvalue := v - 64
|
||||
if relvalue%12 == 0 {
|
||||
return strconv.Itoa(relvalue / 12), "oct"
|
||||
}
|
||||
return strconv.Itoa(relvalue), "st"
|
||||
}
|
||||
|
||||
func sendVoiceDispFunc(v int) (string, string) {
|
||||
if v == 0 {
|
||||
return "default", ""
|
||||
}
|
||||
return strconv.Itoa(v), ""
|
||||
}
|
||||
|
||||
func engineeringTime(sec float64) (string, string) {
|
||||
if sec < 1e-3 {
|
||||
return fmt.Sprintf("%.2f", sec*1e6), "us"
|
||||
} else if sec < 1 {
|
||||
return fmt.Sprintf("%.2f", sec*1e3), "ms"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", sec), "s"
|
||||
}
|
||||
|
||||
func formatFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func toDecibel(amplitude float64) float64 {
|
||||
if amplitude <= 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
// Decibels are defined as 20 * log10(amplitude)
|
||||
// https://en.wikipedia.org/wiki/Decibel#Sound_pressure
|
||||
return 20 * math.Log10(amplitude)
|
||||
}
|
||||
|
||||
// When unit.Type = "oscillator", its unit.Parameter["Type"] tells the type of
|
||||
@ -218,13 +363,112 @@ func init() {
|
||||
|
||||
// Copy makes a deep copy of a unit.
|
||||
func (u *Unit) Copy() Unit {
|
||||
parameters := make(map[string]int)
|
||||
ret := *u
|
||||
ret.Parameters = make(map[string]int, len(u.Parameters))
|
||||
for k, v := range u.Parameters {
|
||||
parameters[k] = v
|
||||
ret.Parameters[k] = v
|
||||
}
|
||||
varArgs := make([]int, len(u.VarArgs))
|
||||
copy(varArgs, u.VarArgs)
|
||||
return Unit{Type: u.Type, Parameters: parameters, VarArgs: varArgs, ID: u.ID, Disabled: u.Disabled}
|
||||
ret.VarArgs = make([]int, len(u.VarArgs))
|
||||
copy(ret.VarArgs, u.VarArgs)
|
||||
return ret
|
||||
}
|
||||
|
||||
var stackUseSource = [2]StackUse{
|
||||
{Inputs: [][]int{}, Modifies: []bool{true}, NumOutputs: 1}, // mono
|
||||
{Inputs: [][]int{}, Modifies: []bool{true, true}, NumOutputs: 2}, // stereo
|
||||
}
|
||||
var stackUseSink = [2]StackUse{
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0}, // mono
|
||||
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 0}, // stereo
|
||||
}
|
||||
var stackUseEffect = [2]StackUse{
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 1}, // mono
|
||||
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // stereo
|
||||
}
|
||||
var stackUseMonoStereo = map[string][2]StackUse{
|
||||
"add": {
|
||||
{Inputs: [][]int{{0, 1}, {1}}, Modifies: []bool{false, true}, NumOutputs: 2},
|
||||
{Inputs: [][]int{{0, 2}, {1, 3}, {2}, {3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4},
|
||||
},
|
||||
"mul": {
|
||||
{Inputs: [][]int{{0, 1}, {1}}, Modifies: []bool{false, true}, NumOutputs: 2},
|
||||
{Inputs: [][]int{{0, 2}, {1, 3}, {2}, {3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4},
|
||||
},
|
||||
"addp": {
|
||||
{Inputs: [][]int{{0}, {0}}, Modifies: []bool{true}, NumOutputs: 1},
|
||||
{Inputs: [][]int{{0}, {1}, {0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
|
||||
},
|
||||
"mulp": {
|
||||
{Inputs: [][]int{{0}, {0}}, Modifies: []bool{true}, NumOutputs: 1},
|
||||
{Inputs: [][]int{{0}, {1}, {0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
|
||||
},
|
||||
"xch": {
|
||||
{Inputs: [][]int{{1}, {0}}, Modifies: []bool{false, false}, NumOutputs: 2},
|
||||
{Inputs: [][]int{{2}, {3}, {0}, {1}}, Modifies: []bool{false, false, false, false}, NumOutputs: 4},
|
||||
},
|
||||
"push": {
|
||||
{Inputs: [][]int{{0, 1}}, Modifies: []bool{false, false}, NumOutputs: 2},
|
||||
{Inputs: [][]int{{0, 2}, {1, 3}}, Modifies: []bool{false, false, false, false}, NumOutputs: 4},
|
||||
},
|
||||
"pop": stackUseSink,
|
||||
"envelope": stackUseSource,
|
||||
"oscillator": stackUseSource,
|
||||
"noise": stackUseSource,
|
||||
"loadnote": stackUseSource,
|
||||
"loadval": stackUseSource,
|
||||
"receive": stackUseSource,
|
||||
"in": stackUseSource,
|
||||
"out": stackUseSink,
|
||||
"outaux": stackUseSink,
|
||||
"aux": stackUseSink,
|
||||
"distort": stackUseEffect,
|
||||
"hold": stackUseEffect,
|
||||
"crush": stackUseEffect,
|
||||
"gain": stackUseEffect,
|
||||
"invgain": stackUseEffect,
|
||||
"dbgain": stackUseEffect,
|
||||
"filter": stackUseEffect,
|
||||
"clip": stackUseEffect,
|
||||
"delay": stackUseEffect,
|
||||
"compressor": {
|
||||
{Inputs: [][]int{{0, 1}}, Modifies: []bool{false, true}, NumOutputs: 2}, // mono
|
||||
{Inputs: [][]int{{0, 2, 3}, {1, 2, 3}}, Modifies: []bool{false, false, true, true}, NumOutputs: 4}, // stereo
|
||||
},
|
||||
"pan": {
|
||||
{Inputs: [][]int{{0, 1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // mono
|
||||
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2}, // mono
|
||||
},
|
||||
"speed": {
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0},
|
||||
{},
|
||||
},
|
||||
"sync": {
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{false}, NumOutputs: 1},
|
||||
{},
|
||||
},
|
||||
"belleq": stackUseEffect,
|
||||
}
|
||||
var stackUseSendNoPop = [2]StackUse{
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 1},
|
||||
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 2},
|
||||
}
|
||||
var stackUseSendPop = [2]StackUse{
|
||||
{Inputs: [][]int{{0}}, Modifies: []bool{true}, NumOutputs: 0}, // mono
|
||||
{Inputs: [][]int{{0}, {1}}, Modifies: []bool{true, true}, NumOutputs: 0}, // stereo
|
||||
}
|
||||
|
||||
func (u *Unit) StackUse() StackUse {
|
||||
if u.Disabled {
|
||||
return StackUse{}
|
||||
}
|
||||
if u.Type == "send" {
|
||||
// "send" unit is special, it has a different stack use depending on sendpop
|
||||
if u.Parameters["sendpop"] == 0 {
|
||||
return stackUseSendNoPop[u.Parameters["stereo"]]
|
||||
}
|
||||
return stackUseSendPop[u.Parameters["stereo"]]
|
||||
}
|
||||
return stackUseMonoStereo[u.Type][u.Parameters["stereo"]]
|
||||
}
|
||||
|
||||
// StackChange returns how this unit will affect the signal stack. "pop" and
|
||||
@ -234,49 +478,34 @@ func (u *Unit) Copy() Unit {
|
||||
// unit). Effects that just change the topmost signal and will not change the
|
||||
// number of signals on the stack and thus return 0.
|
||||
func (u *Unit) StackChange() int {
|
||||
if u.Disabled {
|
||||
return 0
|
||||
}
|
||||
switch u.Type {
|
||||
case "addp", "mulp", "pop", "out", "outaux", "aux":
|
||||
return -1 - u.Parameters["stereo"]
|
||||
case "envelope", "oscillator", "push", "noise", "receive", "loadnote", "loadval", "in", "compressor":
|
||||
return 1 + u.Parameters["stereo"]
|
||||
case "pan":
|
||||
return 1 - u.Parameters["stereo"]
|
||||
case "speed":
|
||||
return -1
|
||||
case "send":
|
||||
return (-1 - u.Parameters["stereo"]) * u.Parameters["sendpop"]
|
||||
}
|
||||
return 0
|
||||
s := u.StackUse()
|
||||
return s.NumOutputs - len(s.Inputs)
|
||||
}
|
||||
|
||||
// StackNeed returns the number of signals that should be on the stack before
|
||||
// this unit is executed. Used to prevent stack underflow. Units producing
|
||||
// signals do not care what is on the stack before and will return 0.
|
||||
func (u *Unit) StackNeed() int {
|
||||
if u.Disabled {
|
||||
return 0
|
||||
}
|
||||
switch u.Type {
|
||||
case "", "envelope", "oscillator", "noise", "receive", "loadnote", "loadval", "in":
|
||||
return 0
|
||||
case "mulp", "mul", "add", "addp", "xch":
|
||||
return 2 * (1 + u.Parameters["stereo"])
|
||||
case "speed":
|
||||
return 1
|
||||
}
|
||||
return 1 + u.Parameters["stereo"]
|
||||
return len(u.StackUse().Inputs)
|
||||
}
|
||||
|
||||
// Copy makes a deep copy of an Instrument
|
||||
func (instr *Instrument) Copy() Instrument {
|
||||
units := make([]Unit, len(instr.Units))
|
||||
ret := *instr
|
||||
ret.Units = make([]Unit, len(instr.Units))
|
||||
for i, u := range instr.Units {
|
||||
units[i] = u.Copy()
|
||||
ret.Units[i] = u.Copy()
|
||||
}
|
||||
return Instrument{Name: instr.Name, Comment: instr.Comment, NumVoices: instr.NumVoices, Units: units}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Implement the counter interface
|
||||
func (i *Instrument) GetNumVoices() int {
|
||||
return i.NumVoices
|
||||
}
|
||||
|
||||
func (i *Instrument) SetNumVoices(count int) {
|
||||
i.NumVoices = count
|
||||
}
|
||||
|
||||
// Copy makes a deep copy of a Patch.
|
||||
@ -326,15 +555,29 @@ func (p Patch) NumSyncs() int {
|
||||
return total
|
||||
}
|
||||
|
||||
func (p Patch) NumThreads() int {
|
||||
numThreads := 1
|
||||
for _, instr := range p {
|
||||
if l := bits.Len((uint)(instr.ThreadMaskM1 + 1)); l > numThreads {
|
||||
numThreads = l
|
||||
}
|
||||
}
|
||||
return numThreads
|
||||
}
|
||||
|
||||
// FirstVoiceForInstrument returns the index of the first voice of given
|
||||
// instrument. For example, if the Patch has three instruments (0, 1 and 2),
|
||||
// with 1, 3, 2 voices, respectively, then FirstVoiceForInstrument(0) returns 0,
|
||||
// FirstVoiceForInstrument(1) returns 1 and FirstVoiceForInstrument(2) returns
|
||||
// 4. Essentially computes just the cumulative sum.
|
||||
func (p Patch) FirstVoiceForInstrument(instrIndex int) int {
|
||||
if instrIndex < 0 {
|
||||
return 0
|
||||
}
|
||||
instrIndex = min(instrIndex, len(p))
|
||||
ret := 0
|
||||
for _, t := range p[:instrIndex] {
|
||||
ret += t.NumVoices
|
||||
for i := 0; i < instrIndex; i++ {
|
||||
ret += p[i].NumVoices
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@ -375,157 +618,19 @@ func (p Patch) FindUnit(id int) (instrIndex int, unitIndex int, err error) {
|
||||
return 0, 0, fmt.Errorf("could not find a unit with id %v", id)
|
||||
}
|
||||
|
||||
// ParamHintString returns a human readable string representing the current
|
||||
// value of a given unit parameter.
|
||||
func (p Patch) ParamHintString(instrIndex, unitIndex int, param string) string {
|
||||
if instrIndex < 0 || instrIndex >= len(p) {
|
||||
return ""
|
||||
func FindParamForModulationPort(unitName string, index int) (up UnitParameter, upIndex int, ok bool) {
|
||||
unitType, ok := UnitTypes[unitName]
|
||||
if !ok {
|
||||
return UnitParameter{}, 0, false
|
||||
}
|
||||
instr := p[instrIndex]
|
||||
if unitIndex < 0 || unitIndex >= len(instr.Units) {
|
||||
return ""
|
||||
for i, param := range unitType {
|
||||
if !param.CanModulate {
|
||||
continue
|
||||
}
|
||||
if index == 0 {
|
||||
return param, i, true
|
||||
}
|
||||
index--
|
||||
}
|
||||
unit := instr.Units[unitIndex]
|
||||
value := unit.Parameters[param]
|
||||
switch unit.Type {
|
||||
case "envelope":
|
||||
switch param {
|
||||
case "attack":
|
||||
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100)
|
||||
case "decay":
|
||||
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100 * (1 - float64(unit.Parameters["sustain"])/128))
|
||||
case "release":
|
||||
return engineeringTime(math.Pow(2, 24*float64(value)/128) / 44100 * float64(unit.Parameters["sustain"]) / 128)
|
||||
}
|
||||
case "oscillator":
|
||||
switch param {
|
||||
case "type":
|
||||
switch value {
|
||||
case Sine:
|
||||
return "Sine"
|
||||
case Trisaw:
|
||||
return "Trisaw"
|
||||
case Pulse:
|
||||
return "Pulse"
|
||||
case Gate:
|
||||
return "Gate"
|
||||
case Sample:
|
||||
return "Sample"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
case "transpose":
|
||||
relvalue := value - 64
|
||||
octaves := relvalue / 12
|
||||
semitones := relvalue % 12
|
||||
if octaves != 0 {
|
||||
return fmt.Sprintf("%v oct, %v st", octaves, semitones)
|
||||
}
|
||||
return fmt.Sprintf("%v st", semitones)
|
||||
case "detune":
|
||||
return fmt.Sprintf("%v st", float32(value-64)/64.0)
|
||||
}
|
||||
case "compressor":
|
||||
switch param {
|
||||
case "attack":
|
||||
fallthrough
|
||||
case "release":
|
||||
alpha := math.Pow(2, -24*float64(value)/128) // alpha is the "smoothing factor" of first order low pass iir
|
||||
sec := -1 / (44100 * math.Log(1-alpha)) // from smoothing factor to time constant, https://en.wikipedia.org/wiki/Exponential_smoothing
|
||||
return engineeringTime(sec)
|
||||
case "ratio":
|
||||
return fmt.Sprintf("1 : %.3f", 1-float64(value)/128)
|
||||
}
|
||||
case "loadval":
|
||||
switch param {
|
||||
case "value":
|
||||
return fmt.Sprintf("%.2f", float32(value)/64-1)
|
||||
}
|
||||
case "send":
|
||||
switch param {
|
||||
case "amount":
|
||||
return fmt.Sprintf("%.2f", float32(value)/64-1)
|
||||
case "voice":
|
||||
if value == 0 {
|
||||
targetIndex, _, err := p.FindUnit(unit.Parameters["target"])
|
||||
if err == nil && targetIndex != instrIndex {
|
||||
return "all"
|
||||
}
|
||||
return "self"
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
case "target":
|
||||
instrIndex, unitIndex, err := p.FindUnit(unit.Parameters["target"])
|
||||
if err != nil {
|
||||
return "invalid target"
|
||||
}
|
||||
instr := p[instrIndex]
|
||||
unit := instr.Units[unitIndex]
|
||||
return fmt.Sprintf("%v / %v%v", instr.Name, unit.Type, unitIndex)
|
||||
case "port":
|
||||
instrIndex, unitIndex, err := p.FindUnit(unit.Parameters["target"])
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v ???", value)
|
||||
}
|
||||
portList := Ports[p[instrIndex].Units[unitIndex].Type]
|
||||
if value < 0 || value >= len(portList) {
|
||||
return fmt.Sprintf("%v ???", value)
|
||||
}
|
||||
return fmt.Sprintf(portList[value])
|
||||
}
|
||||
case "delay":
|
||||
switch param {
|
||||
case "notetracking":
|
||||
switch value {
|
||||
case 0:
|
||||
return "fixed"
|
||||
case 1:
|
||||
return "tracks pitch"
|
||||
case 2:
|
||||
return "tracks BPM"
|
||||
}
|
||||
}
|
||||
case "in", "aux":
|
||||
switch param {
|
||||
case "channel":
|
||||
switch value {
|
||||
case 0:
|
||||
return "left"
|
||||
case 1:
|
||||
return "right"
|
||||
case 2:
|
||||
return "aux1 left"
|
||||
case 3:
|
||||
return "aux1 right"
|
||||
case 4:
|
||||
return "aux2 left"
|
||||
case 5:
|
||||
return "aux2 right"
|
||||
case 6:
|
||||
return "aux3 left"
|
||||
case 7:
|
||||
return "aux3 right"
|
||||
}
|
||||
}
|
||||
case "dbgain":
|
||||
switch param {
|
||||
case "decibels":
|
||||
return fmt.Sprintf("%.2f dB", 40*(float32(value)/64-1))
|
||||
}
|
||||
case "crush":
|
||||
switch param {
|
||||
case "resolution":
|
||||
return fmt.Sprintf("%v bits", 24*float32(value)/128)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func engineeringTime(sec float64) string {
|
||||
if sec < 1e-3 {
|
||||
return fmt.Sprintf("%.2f us", sec*1e6)
|
||||
} else if sec < 1 {
|
||||
return fmt.Sprintf("%.2f ms", sec*1e3)
|
||||
}
|
||||
return fmt.Sprintf("%.2f s", sec)
|
||||
return UnitParameter{}, 0, false
|
||||
}
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 166 KiB |
95
song.go
95
song.go
@ -1,6 +1,7 @@
|
||||
package sointu
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
)
|
||||
|
||||
@ -75,14 +76,32 @@ type (
|
||||
OrderRow int
|
||||
PatternRow int
|
||||
}
|
||||
|
||||
// NumVoicer is used for slices where elements have NumVoices, of which
|
||||
// there are two: Tracks and Instruments.
|
||||
NumVoicer interface {
|
||||
GetNumVoices() int
|
||||
SetNumVoices(count int)
|
||||
}
|
||||
|
||||
// NumVoicerPointer is a helper interface for type constraints, as
|
||||
// SetNumVoices needs to be defined with a pointer receiver to be able to
|
||||
// actually modify the value.
|
||||
NumVoicerPointer[M any] interface {
|
||||
*M
|
||||
NumVoicer
|
||||
}
|
||||
)
|
||||
|
||||
//go:embed LICENSE
|
||||
var License string
|
||||
|
||||
func (s *Score) SongPos(songRow int) SongPos {
|
||||
if s.RowsPerPattern == 0 {
|
||||
return SongPos{OrderRow: 0, PatternRow: 0}
|
||||
}
|
||||
orderRow := songRow / s.RowsPerPattern
|
||||
patternRow := songRow % s.RowsPerPattern
|
||||
patternRow := (songRow%s.RowsPerPattern + s.RowsPerPattern) % s.RowsPerPattern
|
||||
orderRow := ((songRow - patternRow) / s.RowsPerPattern)
|
||||
return SongPos{OrderRow: orderRow, PatternRow: patternRow}
|
||||
}
|
||||
|
||||
@ -92,7 +111,7 @@ func (s *Score) SongRow(songPos SongPos) int {
|
||||
|
||||
func (s *Score) Wrap(songPos SongPos) SongPos {
|
||||
ret := s.SongPos(s.SongRow(songPos))
|
||||
ret.OrderRow %= s.Length
|
||||
ret.OrderRow = (ret.OrderRow%s.Length + s.Length) % s.Length
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -137,7 +156,10 @@ func (s Track) Note(pos SongPos) byte {
|
||||
return s.Patterns[pat][pos.PatternRow]
|
||||
}
|
||||
|
||||
func (s *Track) SetNote(pos SongPos, note byte) {
|
||||
// SetNote sets the note at the given position. If uniquePatterns is true, the
|
||||
// pattern is copied to a new pattern if the pattern is used by more than one
|
||||
// order row.
|
||||
func (s *Track) SetNote(pos SongPos, note byte, uniquePatterns bool) {
|
||||
if pos.OrderRow < 0 || pos.PatternRow < 0 {
|
||||
return
|
||||
}
|
||||
@ -163,13 +185,31 @@ func (s *Track) SetNote(pos SongPos, note byte) {
|
||||
for pat >= len(s.Patterns) {
|
||||
s.Patterns = append(s.Patterns, Pattern{})
|
||||
}
|
||||
if pos.PatternRow >= len(s.Patterns[pat]) && note == 1 {
|
||||
return
|
||||
if uniquePatterns {
|
||||
uses := 0
|
||||
maxPat := 0
|
||||
for _, p := range s.Order {
|
||||
if p == pat {
|
||||
uses++
|
||||
}
|
||||
if p > maxPat {
|
||||
maxPat = p
|
||||
}
|
||||
}
|
||||
if uses > 1 {
|
||||
newPattern := append(Pattern{}, s.Patterns[pat]...)
|
||||
pat = maxPat + 1
|
||||
if pat >= 36 {
|
||||
return
|
||||
}
|
||||
for pat >= len(s.Patterns) {
|
||||
s.Patterns = append(s.Patterns, Pattern{})
|
||||
}
|
||||
s.Patterns[pat] = newPattern
|
||||
s.Order.Set(pos.OrderRow, pat)
|
||||
}
|
||||
}
|
||||
for pos.PatternRow >= len(s.Patterns[pat]) {
|
||||
s.Patterns[pat] = append(s.Patterns[pat], 1)
|
||||
}
|
||||
s.Patterns[pat][pos.PatternRow] = note
|
||||
s.Patterns[pat].Set(pos.PatternRow, note)
|
||||
}
|
||||
|
||||
// Get returns the value at index; or 1 is the index is out of range
|
||||
@ -182,6 +222,9 @@ func (s Pattern) Get(index int) byte {
|
||||
|
||||
// Set sets the value at index; appending 1s until the slice is long enough.
|
||||
func (s *Pattern) Set(index int, value byte) {
|
||||
if value == 1 && index >= len(*s) {
|
||||
return
|
||||
}
|
||||
for len(*s) <= index {
|
||||
*s = append(*s, 1)
|
||||
}
|
||||
@ -231,9 +274,13 @@ func (l Score) NumVoices() int {
|
||||
// returns 1 and FirstVoiceForTrack(2) returns 4. Essentially computes just the
|
||||
// cumulative sum.
|
||||
func (l Score) FirstVoiceForTrack(track int) int {
|
||||
if track < 0 {
|
||||
return 0
|
||||
}
|
||||
track = min(track, len(l.Tracks))
|
||||
ret := 0
|
||||
for _, t := range l.Tracks[:track] {
|
||||
ret += t.NumVoices
|
||||
for i := 0; i < track; i++ {
|
||||
ret += l.Tracks[i].NumVoices
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@ -246,7 +293,10 @@ func (l Score) LengthInRows() int {
|
||||
|
||||
// Copy makes a deep copy of a Score.
|
||||
func (s *Song) Copy() Song {
|
||||
return Song{BPM: s.BPM, RowsPerBeat: s.RowsPerBeat, Score: s.Score.Copy(), Patch: s.Patch.Copy()}
|
||||
ret := *s
|
||||
ret.Score = s.Score.Copy()
|
||||
ret.Patch = s.Patch.Copy()
|
||||
return ret
|
||||
}
|
||||
|
||||
// Assuming 44100 Hz playback speed, return the number of samples of each row of
|
||||
@ -273,3 +323,22 @@ func (s *Song) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// *Track implements NumVoicer interface
|
||||
|
||||
func (t *Track) GetNumVoices() int {
|
||||
return t.NumVoices
|
||||
}
|
||||
|
||||
func (t *Track) SetNumVoices(c int) {
|
||||
t.NumVoices = c
|
||||
}
|
||||
|
||||
// TotalVoices returns the total number of voices used in the slice; summing the
|
||||
// GetNumVoices of every element
|
||||
func TotalVoices[T any, S ~[]T, P NumVoicerPointer[T]](slice S) (ret int) {
|
||||
for _, e := range slice {
|
||||
ret += (P)(&e).GetNumVoices()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -117,6 +117,7 @@ if(WIN32) # The samples are currently only GMDLs based, and thus require Windows
|
||||
regression_test(test_oscillat_sample_stereo ENVELOPE)
|
||||
endif()
|
||||
regression_test(test_oscillat_unison ENVELOPE)
|
||||
regression_test(test_oscillat_unison_phase ENVELOPE)
|
||||
regression_test(test_oscillat_unison_stereo ENVELOPE)
|
||||
regression_test(test_oscillat_lfo "ENVELOPE;VCO_SINE;VCO_PULSE;FOP_MULP2")
|
||||
regression_test(test_oscillat_transposemod "VCO_SINE;ENVELOPE;FOP_MULP;FOP_PUSH;SEND")
|
||||
@ -154,9 +155,13 @@ regression_test(test_filter_stereo "VCO_SINE;ENVELOPE;FOP_MULP")
|
||||
regression_test(test_filter_freqmod "VCO_SINE;ENVELOPE;FOP_MULP;SEND")
|
||||
regression_test(test_filter_resmod "VCO_SINE;ENVELOPE;FOP_MULP;SEND")
|
||||
|
||||
regression_test(test_belleq "VCO_SINE;ENVELOPE;FOP_MULP")
|
||||
regression_test(test_belleq_stereo "VCO_SINE;ENVELOPE;FOP_MULP")
|
||||
|
||||
regression_test(test_delay "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
|
||||
regression_test(test_delay_stereo "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
|
||||
regression_test(test_delay_notetracking "ENVELOPE;FOP_MULP;PANNING;NOISE")
|
||||
regression_test(test_delay_notetracking_modulation "ENVELOPE;FOP_MULP;PANNING;NOISE")
|
||||
regression_test(test_delay_reverb "ENVELOPE;FOP_MULP;PANNING;VCO_SINE")
|
||||
regression_test(test_delay_feedbackmod "ENVELOPE;FOP_MULP;PANNING;VCO_SINE;SEND")
|
||||
regression_test(test_delay_pregainmod "ENVELOPE;FOP_MULP;PANNING;VCO_SINE;SEND")
|
||||
@ -180,3 +185,8 @@ target_compile_definitions(test_render_samples PUBLIC TEST_HEADER="test_render_s
|
||||
add_executable(test_render_samples_api test_render_samples_api.c)
|
||||
target_link_libraries(test_render_samples_api ${STATICLIB})
|
||||
add_test(test_render_samples_api test_render_samples_api)
|
||||
|
||||
add_executable(test_oscillator_crash test_oscillator_crash.c)
|
||||
target_link_libraries(test_oscillator_crash ${STATICLIB})
|
||||
add_test(test_oscillator_crash test_oscillator_crash)
|
||||
|
||||
|
||||
BIN
tests/expected_output/test_belleq.raw
Normal file
BIN
tests/expected_output/test_belleq.raw
Normal file
Binary file not shown.
BIN
tests/expected_output/test_belleq_stereo.raw
Normal file
BIN
tests/expected_output/test_belleq_stereo.raw
Normal file
Binary file not shown.
BIN
tests/expected_output/test_delay_notetracking_modulation.raw
Normal file
BIN
tests/expected_output/test_delay_notetracking_modulation.raw
Normal file
Binary file not shown.
BIN
tests/expected_output/test_oscillat_unison_phase.raw
Normal file
BIN
tests/expected_output/test_oscillat_unison_phase.raw
Normal file
Binary file not shown.
24
tests/test_belleq.yml
Normal file
24
tests/test_belleq.yml
Normal file
@ -0,0 +1,24 @@
|
||||
bpm: 100
|
||||
rowsperbeat: 4
|
||||
score:
|
||||
rowsperpattern: 16
|
||||
length: 1
|
||||
tracks:
|
||||
- numvoices: 1
|
||||
order: [0]
|
||||
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
|
||||
patch:
|
||||
- numvoices: 1
|
||||
units:
|
||||
- type: envelope
|
||||
parameters: {attack: 64, decay: 64, gain: 128, release: 72, stereo: 0, sustain: 64}
|
||||
- type: oscillator
|
||||
parameters: {color: 128, detune: 64, gain: 128, lfo: 0, phase: 0, shape: 64, stereo: 0, transpose: 64, type: 1, unison: 0}
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: belleq
|
||||
parameters: {frequency: 64, bandwidth: 64, gain: 96, stereo: 0}
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
parameters: {gain: 128, stereo: 1}
|
||||
22
tests/test_belleq_stereo.yml
Normal file
22
tests/test_belleq_stereo.yml
Normal file
@ -0,0 +1,22 @@
|
||||
bpm: 100
|
||||
rowsperbeat: 4
|
||||
score:
|
||||
rowsperpattern: 16
|
||||
length: 1
|
||||
tracks:
|
||||
- numvoices: 1
|
||||
order: [0]
|
||||
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
|
||||
patch:
|
||||
- numvoices: 1
|
||||
units:
|
||||
- type: envelope
|
||||
parameters: {attack: 64, decay: 64, gain: 128, release: 72, stereo: 1, sustain: 64}
|
||||
- type: oscillator
|
||||
parameters: {color: 128, detune: 64, gain: 128, lfo: 0, phase: 0, shape: 64, stereo: 1, transpose: 64, type: 1, unison: 0}
|
||||
- type: mulp
|
||||
parameters: {stereo: 1}
|
||||
- type: belleq
|
||||
parameters: {frequency: 64, bandwidth: 64, gain: 96, stereo: 1}
|
||||
- type: out
|
||||
parameters: {gain: 64, stereo: 1}
|
||||
@ -19,12 +19,12 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 1, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 128, stereo: 0}
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 1, lowpass: 1, resonance: 128, stereo: 0}
|
||||
- type: delay
|
||||
parameters: {damp: 16, dry: 128, feedback: 128, notetracking: 1, pregain: 128, stereo: 0}
|
||||
varargs: [10787]
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 24, highpass: 1, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 128, stereo: 0}
|
||||
parameters: {bandpass: 1, frequency: 24, highpass: 1, lowpass: 1, resonance: 128, stereo: 0}
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: pan
|
||||
|
||||
43
tests/test_delay_notetracking_modulation.yml
Normal file
43
tests/test_delay_notetracking_modulation.yml
Normal file
@ -0,0 +1,43 @@
|
||||
bpm: 100
|
||||
rowsperbeat: 4
|
||||
score:
|
||||
tracks:
|
||||
- numvoices: 1
|
||||
order: [0]
|
||||
patterns: [[73, 1, 1, 1, 0, 1, 1, 1, 77, 1, 1, 1, 0]]
|
||||
rowsperpattern: 16
|
||||
length: 1
|
||||
patch:
|
||||
- name: Instr
|
||||
numvoices: 1
|
||||
units:
|
||||
- type: envelope
|
||||
id: 1
|
||||
parameters: {attack: 64, decay: 64, gain: 64, release: 64, stereo: 0, sustain: 64}
|
||||
- type: noise
|
||||
id: 10
|
||||
parameters: {gain: 64, shape: 64, stereo: 0}
|
||||
- type: filter
|
||||
id: 12
|
||||
parameters: {bandpass: 0, frequency: 39, highpass: 0, lowpass: 1, resonance: 128, stereo: 0}
|
||||
- type: delay
|
||||
id: 11
|
||||
parameters: {damp: 0, dry: 71, feedback: 114, notetracking: 1, pregain: 128, stereo: 0}
|
||||
varargs: [21574]
|
||||
- type: mulp
|
||||
id: 3
|
||||
parameters: {stereo: 0}
|
||||
- type: pan
|
||||
id: 5
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
id: 16
|
||||
parameters: {gain: 128, stereo: 1}
|
||||
- id: 13
|
||||
parameters: {}
|
||||
- type: oscillator
|
||||
id: 14
|
||||
parameters: {color: 128, detune: 64, gain: 5, lfo: 1, phase: 0, shape: 64, stereo: 0, transpose: 76, type: 0}
|
||||
- type: send
|
||||
id: 15
|
||||
parameters: {amount: 96, port: 4, sendpop: 1, stereo: 0, target: 11, voice: 0}
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
|
||||
id: 1
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 1, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 1, lowpass: 0, resonance: 64, stereo: 0}
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, resonance: 64, stereo: 0}
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: 0, lowpass: 1, negbandpass: 0, neghighpass: 1, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 0, frequency: 32, highpass: -1, lowpass: 1, resonance: 64, stereo: 0}
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: out
|
||||
|
||||
@ -17,7 +17,7 @@ patch:
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 0}
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 0}
|
||||
id: 1
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
|
||||
@ -19,6 +19,6 @@ patch:
|
||||
- type: pan
|
||||
parameters: {panning: 64, stereo: 0}
|
||||
- type: filter
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, negbandpass: 0, neghighpass: 0, resonance: 64, stereo: 1}
|
||||
parameters: {bandpass: 1, frequency: 32, highpass: 0, lowpass: 0, resonance: 64, stereo: 1}
|
||||
- type: out
|
||||
parameters: {gain: 128, stereo: 1}
|
||||
|
||||
22
tests/test_oscillat_unison_phase.yml
Normal file
22
tests/test_oscillat_unison_phase.yml
Normal file
@ -0,0 +1,22 @@
|
||||
bpm: 100
|
||||
rowsperbeat: 4
|
||||
score:
|
||||
rowsperpattern: 16
|
||||
length: 1
|
||||
tracks:
|
||||
- numvoices: 1
|
||||
order: [0]
|
||||
patterns: [[64, 0, 68, 0, 32, 0, 0, 0, 75, 0, 78, 0, 0, 0, 0, 0]]
|
||||
patch:
|
||||
- numvoices: 1
|
||||
units:
|
||||
- type: envelope
|
||||
parameters: {attack: 32, decay: 32, gain: 128, release: 64, stereo: 0, sustain: 64}
|
||||
- type: oscillator
|
||||
parameters: {color: 128, detune: 0, gain: 32, lfo: 0, phase: 0, shape: 64, stereo: 0, transpose: 64, type: 1, unison: 3}
|
||||
- type: mulp
|
||||
parameters: {stereo: 0}
|
||||
- type: push
|
||||
parameters: {stereo: 0}
|
||||
- type: out
|
||||
parameters: {gain: 128, stereo: 1}
|
||||
55
tests/test_oscillator_crash.c
Normal file
55
tests/test_oscillator_crash.c
Normal file
@ -0,0 +1,55 @@
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <sointu.h>
|
||||
|
||||
#define BPM 100
|
||||
#define SAMPLE_RATE 44100
|
||||
#define LENGTH_IN_ROWS 16
|
||||
#define SAMPLES_PER_ROW SAMPLE_RATE * 4 * 60 / (BPM * 16)
|
||||
const int su_max_samples = SAMPLES_PER_ROW * LENGTH_IN_ROWS;
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
Synth* synth;
|
||||
float* buffer;
|
||||
// The patch is invalid and overflows the stack. This should still exit cleanly, but used to hard crash.
|
||||
// See: https://github.com/vsariola/sointu/issues/149
|
||||
const unsigned char opcodes[] = { SU_OSCILLATOR_ID + 1, // STEREO
|
||||
SU_ADVANCE_ID };
|
||||
const unsigned char operands[] = { 69, 74, 0, 0, 82, 128, 128 };
|
||||
int errcode;
|
||||
int time;
|
||||
int samples;
|
||||
int totalrendered;
|
||||
int retval;
|
||||
// initialize Synth
|
||||
synth = (Synth*)malloc(sizeof(Synth));
|
||||
memset(synth, 0, sizeof(Synth));
|
||||
memcpy(synth->Opcodes, opcodes, sizeof(opcodes));
|
||||
memcpy(synth->Operands, operands, sizeof(operands));
|
||||
synth->NumVoices = 3;
|
||||
synth->Polyphony = 6;
|
||||
synth->RandSeed = 1;
|
||||
synth->SampleOffsets[0].Start = 91507;
|
||||
synth->SampleOffsets[0].LoopStart = 5448;
|
||||
synth->SampleOffsets[0].LoopLength = 563;
|
||||
// initialize Buffer
|
||||
buffer = (float*)malloc(2 * sizeof(float) * su_max_samples);
|
||||
// triger first voice
|
||||
synth->SynthWrk.Voices[0].Note = 64;
|
||||
synth->SynthWrk.Voices[0].Sustain = 1;
|
||||
totalrendered = 0;
|
||||
samples = su_max_samples;
|
||||
time = INT32_MAX;
|
||||
retval = 0;
|
||||
errcode = su_render(synth, buffer, &samples, &time);
|
||||
if (errcode != 0x1041) {
|
||||
retval = 1;
|
||||
printf("su_render should have return errcode 0x1401, got 0x%08x\n", errcode);
|
||||
}
|
||||
free(synth);
|
||||
free(buffer);
|
||||
return retval;
|
||||
}
|
||||
@ -1,409 +0,0 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/vm"
|
||||
)
|
||||
|
||||
type (
|
||||
// Action describes a user action that can be performed on the model. It is
|
||||
// usually a button press or a menu item. Action advertises whether it is
|
||||
// allowed to be performed or not.
|
||||
Action struct {
|
||||
do func()
|
||||
allowed func() bool
|
||||
}
|
||||
)
|
||||
|
||||
// Action methods
|
||||
|
||||
func (e Action) Do() {
|
||||
if e.allowed != nil && e.allowed() {
|
||||
e.do()
|
||||
}
|
||||
}
|
||||
|
||||
func (e Action) Allowed() bool {
|
||||
return e.allowed != nil && e.allowed()
|
||||
}
|
||||
|
||||
func Allow(do func()) Action {
|
||||
return Action{do: do, allowed: func() bool { return true }}
|
||||
}
|
||||
|
||||
func Check(do func(), allowed func() bool) Action {
|
||||
return Action{do: do, allowed: allowed}
|
||||
}
|
||||
|
||||
// Model methods
|
||||
|
||||
func (m *Model) AddTrack() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return m.d.Song.Score.NumVoices() < vm.MAX_VOICES },
|
||||
do: func() {
|
||||
defer (*Model)(m).change("AddTrackAction", ScoreChange, MajorChange)()
|
||||
if len(m.d.Song.Score.Tracks) == 0 { // no instruments, add one
|
||||
m.d.Cursor.Track = 0
|
||||
} else {
|
||||
m.d.Cursor.Track++
|
||||
}
|
||||
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)), 0)
|
||||
newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)+1)
|
||||
copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track])
|
||||
copy(newTracks[m.d.Cursor.Track+1:], m.d.Song.Score.Tracks[m.d.Cursor.Track:])
|
||||
newTracks[m.d.Cursor.Track] = sointu.Track{
|
||||
NumVoices: 1,
|
||||
Patterns: []sointu.Pattern{},
|
||||
}
|
||||
m.d.Song.Score.Tracks = newTracks
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) DeleteTrack() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return len(m.d.Song.Score.Tracks) > 0 },
|
||||
do: func() {
|
||||
defer (*Model)(m).change("DeleteTrackAction", ScoreChange, MajorChange)()
|
||||
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
|
||||
newTracks := make([]sointu.Track, len(m.d.Song.Score.Tracks)-1)
|
||||
copy(newTracks, m.d.Song.Score.Tracks[:m.d.Cursor.Track])
|
||||
copy(newTracks[m.d.Cursor.Track:], m.d.Song.Score.Tracks[m.d.Cursor.Track+1:])
|
||||
m.d.Cursor.Track = intMax(intMin(m.d.Cursor.Track, len(m.d.Song.Score.Tracks)-1), 0)
|
||||
m.d.Song.Score.Tracks = newTracks
|
||||
m.d.Cursor2 = m.d.Cursor
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) AddInstrument() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return (*Model)(m).d.Song.Patch.NumVoices() < vm.MAX_VOICES },
|
||||
do: func() {
|
||||
defer (*Model)(m).change("AddInstrumentAction", PatchChange, MajorChange)()
|
||||
if len(m.d.Song.Patch) == 0 { // no instruments, add one
|
||||
m.d.InstrIndex = 0
|
||||
} else {
|
||||
m.d.InstrIndex++
|
||||
}
|
||||
m.d.Song.Patch = append(m.d.Song.Patch, sointu.Instrument{})
|
||||
copy(m.d.Song.Patch[m.d.InstrIndex+1:], m.d.Song.Patch[m.d.InstrIndex:])
|
||||
newInstr := defaultInstrument.Copy()
|
||||
(*Model)(m).assignUnitIDs(newInstr.Units)
|
||||
m.d.Song.Patch[m.d.InstrIndex] = newInstr
|
||||
m.d.InstrIndex2 = m.d.InstrIndex
|
||||
m.d.UnitIndex = 0
|
||||
m.d.ParamIndex = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) DeleteInstrument() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return len((*Model)(m).d.Song.Patch) > 0 },
|
||||
do: func() {
|
||||
defer (*Model)(m).change("DeleteInstrumentAction", PatchChange, MajorChange)()
|
||||
m.d.Song.Patch = append(m.d.Song.Patch[:m.d.InstrIndex], m.d.Song.Patch[m.d.InstrIndex+1:]...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) AddUnit(before bool) Action {
|
||||
return Allow(func() {
|
||||
defer (*Model)(m).change("AddUnitAction", PatchChange, MajorChange)()
|
||||
if len(m.d.Song.Patch) == 0 { // no instruments, add one
|
||||
instr := sointu.Instrument{NumVoices: 1}
|
||||
instr.Units = make([]sointu.Unit, 0, 1)
|
||||
m.d.Song.Patch = append(m.d.Song.Patch, instr)
|
||||
m.d.UnitIndex = 0
|
||||
} else {
|
||||
if !before {
|
||||
m.d.UnitIndex++
|
||||
}
|
||||
}
|
||||
m.d.InstrIndex = intMax(intMin(m.d.InstrIndex, len(m.d.Song.Patch)-1), 0)
|
||||
instr := m.d.Song.Patch[m.d.InstrIndex]
|
||||
newUnits := make([]sointu.Unit, len(instr.Units)+1)
|
||||
m.d.UnitIndex = clamp(m.d.UnitIndex, 0, len(newUnits)-1)
|
||||
m.d.UnitIndex2 = m.d.UnitIndex
|
||||
copy(newUnits, instr.Units[:m.d.UnitIndex])
|
||||
copy(newUnits[m.d.UnitIndex+1:], instr.Units[m.d.UnitIndex:])
|
||||
(*Model)(m).assignUnitIDs(newUnits[m.d.UnitIndex : m.d.UnitIndex+1])
|
||||
m.d.Song.Patch[m.d.InstrIndex].Units = newUnits
|
||||
m.d.ParamIndex = 0
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) DeleteUnit() Action {
|
||||
return Action{
|
||||
allowed: func() bool {
|
||||
return len((*Model)(m).d.Song.Patch) > 0 && len((*Model)(m).d.Song.Patch[(*Model)(m).d.InstrIndex].Units) > 1
|
||||
},
|
||||
do: func() {
|
||||
defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)()
|
||||
m.Units().List().DeleteElements(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) ClearUnit() Action {
|
||||
return Action{
|
||||
do: func() {
|
||||
defer (*Model)(m).change("DeleteUnitAction", PatchChange, MajorChange)()
|
||||
m.d.UnitIndex = intMax(intMin(m.d.UnitIndex, len(m.d.Song.Patch[m.d.InstrIndex].Units)-1), 0)
|
||||
m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex] = sointu.Unit{}
|
||||
},
|
||||
allowed: func() bool {
|
||||
return m.d.InstrIndex >= 0 &&
|
||||
m.d.InstrIndex < len(m.d.Song.Patch) &&
|
||||
len(m.d.Song.Patch[m.d.InstrIndex].Units) > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
func (m *Model) Undo() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return len((*Model)(m).undoStack) > 0 },
|
||||
do: func() {
|
||||
m.redoStack = append(m.redoStack, m.d.Copy())
|
||||
if len(m.redoStack) >= maxUndo {
|
||||
copy(m.redoStack, m.redoStack[len(m.redoStack)-maxUndo:])
|
||||
m.redoStack = m.redoStack[:maxUndo]
|
||||
}
|
||||
m.d = m.undoStack[len(m.undoStack)-1]
|
||||
m.undoStack = m.undoStack[:len(m.undoStack)-1]
|
||||
m.prevUndoKind = ""
|
||||
(*Model)(m).send(m.d.Song.Copy())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Redo() Action {
|
||||
return Action{
|
||||
allowed: func() bool { return len((*Model)(m).redoStack) > 0 },
|
||||
do: func() {
|
||||
m.undoStack = append(m.undoStack, m.d.Copy())
|
||||
if len(m.undoStack) >= maxUndo {
|
||||
copy(m.undoStack, m.undoStack[len(m.undoStack)-maxUndo:])
|
||||
m.undoStack = m.undoStack[:maxUndo]
|
||||
}
|
||||
m.d = m.redoStack[len(m.redoStack)-1]
|
||||
m.redoStack = m.redoStack[:len(m.redoStack)-1]
|
||||
m.prevUndoKind = ""
|
||||
(*Model)(m).send(m.d.Song.Copy())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) AddSemitone() Action {
|
||||
return Allow(func() { Table{(*Notes)(m)}.Add(1) })
|
||||
}
|
||||
|
||||
func (m *Model) SubtractSemitone() Action {
|
||||
return Allow(func() { Table{(*Notes)(m)}.Add(-1) })
|
||||
}
|
||||
|
||||
func (m *Model) AddOctave() Action {
|
||||
return Allow(func() { Table{(*Notes)(m)}.Add(12) })
|
||||
}
|
||||
|
||||
func (m *Model) SubtractOctave() Action {
|
||||
return Allow(func() { Table{(*Notes)(m)}.Add(-12) })
|
||||
}
|
||||
|
||||
func (m *Model) EditNoteOff() Action {
|
||||
return Allow(func() { Table{(*Notes)(m)}.Fill(0) })
|
||||
}
|
||||
|
||||
func (m *Model) RemoveUnused() Action {
|
||||
return Allow(func() {
|
||||
defer m.change("RemoveUnusedAction", ScoreChange, MajorChange)()
|
||||
for trkIndex, trk := range m.d.Song.Score.Tracks {
|
||||
// assign new indices to patterns
|
||||
newIndex := map[int]int{}
|
||||
runningIndex := 0
|
||||
length := 0
|
||||
if len(trk.Order) > m.d.Song.Score.Length {
|
||||
trk.Order = trk.Order[:m.d.Song.Score.Length]
|
||||
}
|
||||
for i, p := range trk.Order {
|
||||
// if the pattern hasn't been considered and is within limits
|
||||
if _, ok := newIndex[p]; !ok && p >= 0 && p < len(trk.Patterns) {
|
||||
pat := trk.Patterns[p]
|
||||
useful := false
|
||||
for _, n := range pat { // patterns that have anything else than all holds are useful and to be kept
|
||||
if n != 1 {
|
||||
useful = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if useful {
|
||||
newIndex[p] = runningIndex
|
||||
runningIndex++
|
||||
} else {
|
||||
newIndex[p] = -1
|
||||
}
|
||||
}
|
||||
if ind, ok := newIndex[p]; ok && ind > -1 {
|
||||
length = i + 1
|
||||
trk.Order[i] = ind
|
||||
} else {
|
||||
trk.Order[i] = -1
|
||||
}
|
||||
}
|
||||
trk.Order = trk.Order[:length]
|
||||
newPatterns := make([]sointu.Pattern, runningIndex)
|
||||
for i, pat := range trk.Patterns {
|
||||
if ind, ok := newIndex[i]; ok && ind > -1 {
|
||||
patLength := 0
|
||||
for j, note := range pat { // find last note that is something else that hold
|
||||
if note != 1 {
|
||||
patLength = j + 1
|
||||
}
|
||||
}
|
||||
if patLength > m.d.Song.Score.RowsPerPattern {
|
||||
patLength = m.d.Song.Score.RowsPerPattern
|
||||
}
|
||||
newPatterns[ind] = pat[:patLength] // crop to either RowsPerPattern or last row having something else than hold
|
||||
}
|
||||
}
|
||||
trk.Patterns = newPatterns
|
||||
m.d.Song.Score.Tracks[trkIndex] = trk
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) Rewind() Action {
|
||||
return Action{
|
||||
allowed: func() bool {
|
||||
return m.playing || !m.instrEnlarged
|
||||
},
|
||||
do: func() {
|
||||
m.playing = true
|
||||
m.send(StartPlayMsg{})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) AddOrderRow(before bool) Action {
|
||||
return Allow(func() {
|
||||
defer m.change("AddOrderRowAction", ScoreChange, MinorChange)()
|
||||
if before {
|
||||
m.d.Cursor.OrderRow++
|
||||
}
|
||||
m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow
|
||||
from := m.d.Cursor.OrderRow
|
||||
m.d.Song.Score.Length++
|
||||
for i := range m.d.Song.Score.Tracks {
|
||||
order := &m.d.Song.Score.Tracks[i].Order
|
||||
if len(*order) > from {
|
||||
*order = append(*order, -1)
|
||||
copy((*order)[from+1:], (*order)[from:])
|
||||
(*order)[from] = -1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) DeleteOrderRow(backwards bool) Action {
|
||||
return Allow(func() {
|
||||
defer m.change("AddOrderRowAction", ScoreChange, MinorChange)()
|
||||
from := m.d.Cursor.OrderRow
|
||||
m.d.Song.Score.Length--
|
||||
for i := range m.d.Song.Score.Tracks {
|
||||
order := &m.d.Song.Score.Tracks[i].Order
|
||||
if len(*order) > from {
|
||||
copy((*order)[from:], (*order)[from+1:])
|
||||
*order = (*order)[:len(*order)-1]
|
||||
}
|
||||
}
|
||||
if backwards {
|
||||
if m.d.Cursor.OrderRow > 0 {
|
||||
m.d.Cursor.OrderRow--
|
||||
}
|
||||
}
|
||||
m.d.Cursor2.OrderRow = m.d.Cursor.OrderRow
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) NewSong() Action {
|
||||
return Allow(func() {
|
||||
m.dialog = NewSongChanges
|
||||
m.completeAction(true)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) OpenSong() Action {
|
||||
return Allow(func() {
|
||||
m.dialog = OpenSongChanges
|
||||
m.completeAction(true)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) Quit() Action {
|
||||
return Allow(func() {
|
||||
m.dialog = QuitChanges
|
||||
m.completeAction(true)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) ForceQuit() Action {
|
||||
return Allow(func() {
|
||||
m.quitted = true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) SaveSong() Action {
|
||||
return Allow(func() {
|
||||
if m.d.FilePath == "" {
|
||||
switch m.dialog {
|
||||
case NoDialog:
|
||||
m.dialog = SaveAsExplorer
|
||||
case NewSongChanges:
|
||||
m.dialog = NewSongSaveExplorer
|
||||
case OpenSongChanges:
|
||||
m.dialog = OpenSongSaveExplorer
|
||||
case QuitChanges:
|
||||
m.dialog = QuitSaveExplorer
|
||||
}
|
||||
return
|
||||
}
|
||||
f, err := os.Create(m.d.FilePath)
|
||||
if err != nil {
|
||||
m.Alerts().Add("Error creating file: "+err.Error(), Error)
|
||||
return
|
||||
}
|
||||
m.WriteSong(f)
|
||||
m.d.ChangedSinceSave = false
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) DiscardSong() Action { return Allow(func() { m.completeAction(false) }) }
|
||||
func (m *Model) SaveSongAs() Action { return Allow(func() { m.dialog = SaveAsExplorer }) }
|
||||
func (m *Model) Cancel() Action { return Allow(func() { m.dialog = NoDialog }) }
|
||||
func (m *Model) Export() Action { return Allow(func() { m.dialog = Export }) }
|
||||
func (m *Model) ExportFloat() Action { return Allow(func() { m.dialog = ExportFloatExplorer }) }
|
||||
func (m *Model) ExportInt16() Action { return Allow(func() { m.dialog = ExportInt16Explorer }) }
|
||||
|
||||
func (m *Model) completeAction(checkSave bool) {
|
||||
if checkSave && m.d.ChangedSinceSave {
|
||||
return
|
||||
}
|
||||
switch m.dialog {
|
||||
case NewSongChanges, NewSongSaveExplorer:
|
||||
c := m.change("NewSong", SongChange|LoopChange, MajorChange)
|
||||
m.resetSong()
|
||||
c()
|
||||
m.d.ChangedSinceSave = false
|
||||
m.dialog = NoDialog
|
||||
case OpenSongChanges, OpenSongSaveExplorer:
|
||||
m.dialog = OpenSongOpenExplorer
|
||||
case QuitChanges, QuitSaveExplorer:
|
||||
m.quitted = true
|
||||
m.dialog = NoDialog
|
||||
default:
|
||||
m.dialog = NoDialog
|
||||
}
|
||||
}
|
||||
@ -17,9 +17,7 @@ type (
|
||||
FadeLevel float64
|
||||
}
|
||||
|
||||
AlertPriority int
|
||||
AlertYieldFunc func(alert Alert)
|
||||
Alerts Model
|
||||
AlertPriority int
|
||||
)
|
||||
|
||||
const (
|
||||
@ -29,18 +27,22 @@ const (
|
||||
Error
|
||||
)
|
||||
|
||||
// Model methods
|
||||
|
||||
// Alerts returns the Alerts model from the main Model, used to manage alerts.
|
||||
func (m *Model) Alerts() *Alerts { return (*Alerts)(m) }
|
||||
|
||||
// Alerts methods
|
||||
type Alerts Model
|
||||
|
||||
func (m *Alerts) Iterate(yield AlertYieldFunc) {
|
||||
for _, a := range m.alerts {
|
||||
yield(a)
|
||||
// Iterate through the alerts.
|
||||
func (m *Alerts) Iterate(yield func(index int, alert Alert) bool) {
|
||||
for i, a := range m.alerts {
|
||||
if !yield(i, a) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the alerts, reducing their duration and updating their fade levels,
|
||||
// given the elapsed time d.
|
||||
func (m *Alerts) Update(d time.Duration) (animating bool) {
|
||||
for i := len(m.alerts) - 1; i >= 0; i-- {
|
||||
if m.alerts[i].Duration >= d {
|
||||
@ -64,6 +66,7 @@ func (m *Alerts) Update(d time.Duration) (animating bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add a new alert with the given message and priority.
|
||||
func (m *Alerts) Add(message string, priority AlertPriority) {
|
||||
m.AddAlert(Alert{
|
||||
Priority: priority,
|
||||
@ -72,6 +75,7 @@ func (m *Alerts) Add(message string, priority AlertPriority) {
|
||||
})
|
||||
}
|
||||
|
||||
// AddNamed adds a new alert with the given name, message, and priority.
|
||||
func (m *Alerts) AddNamed(name, message string, priority AlertPriority) {
|
||||
m.AddAlert(Alert{
|
||||
Name: name,
|
||||
@ -81,6 +85,17 @@ func (m *Alerts) AddNamed(name, message string, priority AlertPriority) {
|
||||
})
|
||||
}
|
||||
|
||||
// ClearNamed clears the alert with the given name.
|
||||
func (m *Alerts) ClearNamed(name string) {
|
||||
for i := range m.alerts {
|
||||
if n := m.alerts[i].Name; n != "" && n == name {
|
||||
m.alerts[i].Duration = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddAlert adds or updates an alert.
|
||||
func (m *Alerts) AddAlert(a Alert) {
|
||||
for i := range m.alerts {
|
||||
if n := m.alerts[i].Name; n != "" && n == a.Name {
|
||||
@ -94,15 +109,17 @@ func (m *Alerts) AddAlert(a Alert) {
|
||||
}
|
||||
|
||||
func (m *Alerts) Push(x any) {
|
||||
if _, ok := x.(Alert); !ok {
|
||||
panic("invalid type for Alerts.Push, expected Alert")
|
||||
}
|
||||
m.alerts = append(m.alerts, x.(Alert))
|
||||
}
|
||||
|
||||
func (m *Alerts) Pop() any {
|
||||
old := m.alerts
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
m.alerts = old[0 : n-1]
|
||||
return x
|
||||
n := len(m.alerts)
|
||||
last := m.alerts[n-1]
|
||||
m.alerts = m.alerts[:n-1]
|
||||
return last
|
||||
}
|
||||
|
||||
func (m Alerts) Len() int { return len(m.alerts) }
|
||||
|
||||
568
tracker/basic_types.go
Normal file
568
tracker/basic_types.go
Normal file
@ -0,0 +1,568 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"math"
|
||||
"math/bits"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Enabler is an interface that defines a single Enabled() method, which is used
|
||||
// by the UI to check if UI Action/Bool/Int etc. is enabled or not.
|
||||
type Enabler interface {
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// Action
|
||||
|
||||
type (
|
||||
// Action describes a user action that can be performed on the model, which
|
||||
// can be initiated by calling the Do() method. It is usually initiated by a
|
||||
// button press or a menu item. Action advertises whether it is enabled, so
|
||||
// UI can e.g. gray out buttons when the underlying action is not allowed.
|
||||
// The underlying Doer can optionally implement the Enabler interface to
|
||||
// decide if the action is enabled or not; if it does not implement the
|
||||
// Enabler interface, the action is always allowed.
|
||||
Action struct {
|
||||
doer Doer
|
||||
}
|
||||
|
||||
// Doer is an interface that defines a single Do() method, which is called
|
||||
// when an action is performed.
|
||||
Doer interface {
|
||||
Do()
|
||||
}
|
||||
)
|
||||
|
||||
func MakeAction(doer Doer) Action { return Action{doer: doer} }
|
||||
|
||||
func (a Action) Do() {
|
||||
e, ok := a.doer.(Enabler)
|
||||
if ok && !e.Enabled() {
|
||||
return
|
||||
}
|
||||
if a.doer != nil {
|
||||
a.doer.Do()
|
||||
}
|
||||
}
|
||||
|
||||
func (a Action) Enabled() bool {
|
||||
if a.doer == nil {
|
||||
return false // no doer, not allowed
|
||||
}
|
||||
e, ok := a.doer.(Enabler)
|
||||
if !ok {
|
||||
return true // not enabler, always allowed
|
||||
}
|
||||
return e.Enabled()
|
||||
}
|
||||
|
||||
// Bool
|
||||
|
||||
type (
|
||||
Bool struct {
|
||||
value BoolValue
|
||||
}
|
||||
|
||||
BoolValue interface {
|
||||
Value() bool
|
||||
SetValue(bool)
|
||||
}
|
||||
|
||||
simpleBool bool
|
||||
)
|
||||
|
||||
func MakeBool(value BoolValue) Bool { return Bool{value: value} }
|
||||
func MakeBoolFromPtr(value *bool) Bool { return Bool{value: (*simpleBool)(value)} }
|
||||
func (v Bool) Toggle() { v.SetValue(!v.Value()) }
|
||||
|
||||
func (v Bool) SetValue(value bool) (changed bool) {
|
||||
if !v.Enabled() || v.Value() == value {
|
||||
return false
|
||||
}
|
||||
v.value.SetValue(value)
|
||||
return true
|
||||
}
|
||||
|
||||
func (v Bool) Value() bool {
|
||||
if v.value == nil {
|
||||
return false
|
||||
}
|
||||
return v.value.Value()
|
||||
}
|
||||
|
||||
func (v Bool) Enabled() bool {
|
||||
if v.value == nil {
|
||||
return false
|
||||
}
|
||||
e, ok := v.value.(Enabler)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return e.Enabled()
|
||||
}
|
||||
|
||||
func (v *simpleBool) Value() bool { return bool(*v) }
|
||||
func (v *simpleBool) SetValue(value bool) { *v = simpleBool(value) }
|
||||
|
||||
// Int
|
||||
|
||||
type (
|
||||
// Int represents an integer value in the tracker model e.g. BPM, song
|
||||
// length, etc. It is a wrapper around an IntValue interface that provides
|
||||
// methods to manipulate the value, but Int guard that all changes are
|
||||
// within the range of the underlying IntValue implementation and that
|
||||
// SetValue is not called when the value is unchanged. The IntValue can
|
||||
// optionally implement the StringOfer interface to provide custom string
|
||||
// representations of the integer values.
|
||||
Int struct {
|
||||
value IntValue
|
||||
}
|
||||
|
||||
IntValue interface {
|
||||
Value() int
|
||||
SetValue(int) (changed bool)
|
||||
Range() RangeInclusive
|
||||
}
|
||||
|
||||
StringOfer interface {
|
||||
StringOf(value int) string
|
||||
}
|
||||
)
|
||||
|
||||
func MakeInt(value IntValue) Int { return Int{value} }
|
||||
|
||||
func (v Int) Add(delta int) (changed bool) {
|
||||
return v.SetValue(v.Value() + delta)
|
||||
}
|
||||
|
||||
func (v Int) SetValue(value int) (changed bool) {
|
||||
r := v.Range()
|
||||
value = r.Clamp(value)
|
||||
if value == v.Value() || value < r.Min || value > r.Max {
|
||||
return false
|
||||
}
|
||||
return v.value.SetValue(value)
|
||||
}
|
||||
|
||||
func (v Int) Range() RangeInclusive {
|
||||
if v.value == nil {
|
||||
return RangeInclusive{0, 0}
|
||||
}
|
||||
return v.value.Range()
|
||||
}
|
||||
|
||||
func (v Int) Value() int {
|
||||
if v.value == nil {
|
||||
return 0
|
||||
}
|
||||
return v.value.Value()
|
||||
}
|
||||
|
||||
func (v Int) String() string {
|
||||
return v.StringOf(v.Value())
|
||||
}
|
||||
|
||||
func (v Int) StringOf(value int) string {
|
||||
if s, ok := v.value.(StringOfer); ok {
|
||||
return s.StringOf(value)
|
||||
}
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
// String
|
||||
|
||||
type (
|
||||
String struct {
|
||||
value StringValue
|
||||
}
|
||||
|
||||
StringValue interface {
|
||||
Value() string
|
||||
SetValue(string) (changed bool)
|
||||
}
|
||||
)
|
||||
|
||||
func MakeString(value StringValue) String { return String{value: value} }
|
||||
|
||||
func (v String) SetValue(value string) (changed bool) {
|
||||
if v.value == nil || v.value.Value() == value {
|
||||
return false
|
||||
}
|
||||
return v.value.SetValue(value)
|
||||
}
|
||||
|
||||
func (v String) Value() string {
|
||||
if v.value == nil {
|
||||
return ""
|
||||
}
|
||||
return v.value.Value()
|
||||
}
|
||||
|
||||
// List
|
||||
|
||||
type (
|
||||
List struct {
|
||||
data ListData
|
||||
}
|
||||
|
||||
ListData interface {
|
||||
Selected() int
|
||||
Selected2() int
|
||||
SetSelected(int)
|
||||
SetSelected2(int)
|
||||
Count() int
|
||||
}
|
||||
|
||||
MutableListData interface {
|
||||
Change(kind string, severity ChangeSeverity) func()
|
||||
Cancel()
|
||||
Move(r Range, delta int) (ok bool)
|
||||
Delete(r Range) (ok bool)
|
||||
Marshal(r Range) ([]byte, error)
|
||||
Unmarshal([]byte) (r Range, err error)
|
||||
}
|
||||
)
|
||||
|
||||
func MakeList(data ListData) List { return List{data} }
|
||||
|
||||
func (l List) Selected() int { return max(min(l.data.Selected(), l.data.Count()-1), 0) }
|
||||
func (l List) Selected2() int { return max(min(l.data.Selected2(), l.data.Count()-1), 0) }
|
||||
func (l List) SetSelected(value int) { l.data.SetSelected(max(min(value, l.data.Count()-1), 0)) }
|
||||
func (l List) SetSelected2(value int) { l.data.SetSelected2(max(min(value, l.data.Count()-1), 0)) }
|
||||
func (l List) Count() int { return l.data.Count() }
|
||||
|
||||
// MoveElements moves the selected elements in a list by delta. The list must
|
||||
// implement the MutableListData interface.
|
||||
func (v List) MoveElements(delta int) bool {
|
||||
s, ok := v.data.(MutableListData)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
r := v.listRange()
|
||||
if delta == 0 || r.Start+delta < 0 || r.End+delta > v.Count() {
|
||||
return false
|
||||
}
|
||||
defer s.Change("MoveElements", MajorChange)()
|
||||
if !s.Move(r, delta) {
|
||||
s.Cancel()
|
||||
return false
|
||||
}
|
||||
v.SetSelected(v.Selected() + delta)
|
||||
v.SetSelected2(v.Selected2() + delta)
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteElements deletes the selected elements in a list. The list must
|
||||
// implement the MutableListData interface.
|
||||
func (v List) DeleteElements(backwards bool) bool {
|
||||
d, ok := v.data.(MutableListData)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
r := v.listRange()
|
||||
if r.Len() == 0 {
|
||||
return false
|
||||
}
|
||||
defer d.Change("DeleteElements", MajorChange)()
|
||||
if !d.Delete(r) {
|
||||
d.Cancel()
|
||||
return false
|
||||
}
|
||||
if backwards && r.Start > 0 {
|
||||
r.Start--
|
||||
}
|
||||
v.SetSelected(r.Start)
|
||||
v.SetSelected2(r.Start)
|
||||
return true
|
||||
}
|
||||
|
||||
// CopyElements copies the selected elements in a list. The list must implement
|
||||
// the MutableListData interface. Returns the copied data, marshaled into byte
|
||||
// slice, and true if successful.
|
||||
func (v List) CopyElements() ([]byte, bool) {
|
||||
m, ok := v.data.(MutableListData)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
r := v.listRange()
|
||||
if r.Len() == 0 {
|
||||
return nil, false
|
||||
}
|
||||
ret, err := m.Marshal(r)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return ret, true
|
||||
}
|
||||
|
||||
// PasteElements pastes the data into the list. The data is unmarshaled from the
|
||||
// byte slice. The list must implement the MutableListData interface. Returns
|
||||
// true if successful.
|
||||
func (v List) PasteElements(data []byte) (ok bool) {
|
||||
m, ok := v.data.(MutableListData)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer m.Change("PasteElements", MajorChange)()
|
||||
r, err := m.Unmarshal(data)
|
||||
if err != nil {
|
||||
m.Cancel()
|
||||
return false
|
||||
}
|
||||
v.SetSelected(r.Start)
|
||||
v.SetSelected2(r.End - 1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (v List) Mutable() bool {
|
||||
_, ok := v.data.(MutableListData)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (v *List) listRange() (r Range) {
|
||||
r.Start = max(min(v.Selected(), v.Selected2()), 0)
|
||||
r.End = min(max(v.Selected(), v.Selected2())+1, v.Count())
|
||||
return
|
||||
}
|
||||
|
||||
// RangeInclusive
|
||||
|
||||
// RangeInclusive represents a range of integers [Min, Max], inclusive.
|
||||
type RangeInclusive struct{ Min, Max int }
|
||||
|
||||
func (r RangeInclusive) Clamp(value int) int { return max(min(value, r.Max), r.Min) }
|
||||
|
||||
// Range is used to represent a range [Start,End) of integers, excluding End
|
||||
type Range struct{ Start, End int }
|
||||
|
||||
func (r Range) Len() int { return r.End - r.Start }
|
||||
|
||||
func (r Range) Swaps(delta int) iter.Seq2[int, int] {
|
||||
if delta > 0 {
|
||||
return func(yield func(int, int) bool) {
|
||||
for i := r.End - 1; i >= r.Start; i-- {
|
||||
if !yield(i, i+delta) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return func(yield func(int, int) bool) {
|
||||
for i := r.Start; i < r.End; i++ {
|
||||
if !yield(i, i+delta) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r Range) Intersect(s Range) (ret Range) {
|
||||
ret.Start = max(r.Start, s.Start)
|
||||
ret.End = max(min(r.End, s.End), ret.Start)
|
||||
if ret.Len() == 0 {
|
||||
return Range{}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func MakeMoveRanges(a Range, delta int) [4]Range {
|
||||
if delta < 0 {
|
||||
return [4]Range{
|
||||
{math.MinInt, a.Start + delta},
|
||||
{a.Start, a.End},
|
||||
{a.Start + delta, a.Start},
|
||||
{a.End, math.MaxInt},
|
||||
}
|
||||
}
|
||||
return [4]Range{
|
||||
{math.MinInt, a.Start},
|
||||
{a.End, a.End + delta},
|
||||
{a.Start, a.End},
|
||||
{a.End + delta, math.MaxInt},
|
||||
}
|
||||
}
|
||||
|
||||
// MakeSetLength takes a range and a length, and returns a slice of ranges that
|
||||
// can be used with VoiceSlice to expand or shrink the range to the given
|
||||
// length, by either duplicating or removing elements. The function tries to
|
||||
// duplicate elements so all elements are equally spaced, and tries to remove
|
||||
// elements from the middle of the range.
|
||||
func MakeSetLength(a Range, length int) []Range {
|
||||
if length <= 0 || a.Len() <= 0 {
|
||||
return []Range{{a.Start, a.Start}}
|
||||
}
|
||||
ret := make([]Range, a.Len(), max(a.Len(), length)+2)
|
||||
for i := 0; i < a.Len(); i++ {
|
||||
ret[i] = Range{a.Start + i, a.Start + i + 1}
|
||||
}
|
||||
for x := len(ret); x < length; x++ {
|
||||
e := (x << 1) ^ (1 << bits.Len((uint)(x)))
|
||||
ret = append(ret[0:e+1], ret[e:]...)
|
||||
}
|
||||
for x := len(ret); x > length; x-- {
|
||||
e := (((x << 1) ^ (1 << bits.Len((uint)(x)))) + x - 1) % x
|
||||
ret = append(ret[0:e], ret[e+1:]...)
|
||||
}
|
||||
ret = append([]Range{{math.MinInt, a.Start}}, ret...)
|
||||
ret = append(ret, Range{a.End, math.MaxInt})
|
||||
return ret
|
||||
}
|
||||
|
||||
func Complement(a Range) [2]Range {
|
||||
return [2]Range{
|
||||
{math.MinInt, a.Start},
|
||||
{a.End, math.MaxInt},
|
||||
}
|
||||
}
|
||||
|
||||
// Insert inserts elements into a slice at the given index. If the index is out
|
||||
// of bounds, the function returns false.
|
||||
func Insert[T any, S ~[]T](slice S, index int, inserted ...T) (ret S, ok bool) {
|
||||
if index < 0 || index > len(slice) {
|
||||
return nil, false
|
||||
}
|
||||
ret = make(S, 0, len(slice)+len(inserted))
|
||||
ret = append(ret, slice[:index]...)
|
||||
ret = append(ret, inserted...)
|
||||
ret = append(ret, slice[index:]...)
|
||||
return ret, true
|
||||
}
|
||||
|
||||
// Table
|
||||
|
||||
type (
|
||||
Table struct {
|
||||
TableData
|
||||
}
|
||||
|
||||
TableData interface {
|
||||
Cursor() Point
|
||||
Cursor2() Point
|
||||
SetCursor(Point)
|
||||
SetCursor2(Point)
|
||||
Width() int
|
||||
Height() int
|
||||
MoveCursor(dx, dy int) (ok bool)
|
||||
|
||||
clear(p Point)
|
||||
set(p Point, value int)
|
||||
add(rect Rect, delta int, largestep bool) (ok bool)
|
||||
marshal(rect Rect) (data []byte, ok bool)
|
||||
unmarshalAtCursor(data []byte) (ok bool)
|
||||
unmarshalRange(rect Rect, data []byte) (ok bool)
|
||||
change(kind string, severity ChangeSeverity) func()
|
||||
cancel()
|
||||
}
|
||||
|
||||
Point struct {
|
||||
X, Y int
|
||||
}
|
||||
|
||||
Rect struct {
|
||||
TopLeft, BottomRight Point
|
||||
}
|
||||
)
|
||||
|
||||
// Rect methods
|
||||
|
||||
func (r *Rect) Contains(p Point) bool {
|
||||
return r.TopLeft.X <= p.X && p.X <= r.BottomRight.X &&
|
||||
r.TopLeft.Y <= p.Y && p.Y <= r.BottomRight.Y
|
||||
}
|
||||
|
||||
func (r *Rect) Width() int {
|
||||
return r.BottomRight.X - r.TopLeft.X + 1
|
||||
}
|
||||
|
||||
func (r *Rect) Height() int {
|
||||
return r.BottomRight.Y - r.TopLeft.Y + 1
|
||||
}
|
||||
|
||||
func (r *Rect) Limit(width, height int) {
|
||||
if r.TopLeft.X < 0 {
|
||||
r.TopLeft.X = 0
|
||||
}
|
||||
if r.TopLeft.Y < 0 {
|
||||
r.TopLeft.Y = 0
|
||||
}
|
||||
if r.BottomRight.X >= width {
|
||||
r.BottomRight.X = width - 1
|
||||
}
|
||||
if r.BottomRight.Y >= height {
|
||||
r.BottomRight.Y = height - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (v Table) Range() (rect Rect) {
|
||||
rect.TopLeft.X = min(v.Cursor().X, v.Cursor2().X)
|
||||
rect.TopLeft.Y = min(v.Cursor().Y, v.Cursor2().Y)
|
||||
rect.BottomRight.X = max(v.Cursor().X, v.Cursor2().X)
|
||||
rect.BottomRight.Y = max(v.Cursor().Y, v.Cursor2().Y)
|
||||
return
|
||||
}
|
||||
|
||||
func (v Table) Copy() ([]byte, bool) {
|
||||
ret, ok := v.marshal(v.Range())
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return ret, true
|
||||
}
|
||||
|
||||
func (v Table) Paste(data []byte) bool {
|
||||
defer v.change("Paste", MajorChange)()
|
||||
if v.Cursor() == v.Cursor2() {
|
||||
return v.unmarshalAtCursor(data)
|
||||
} else {
|
||||
return v.unmarshalRange(v.Range(), data)
|
||||
}
|
||||
}
|
||||
|
||||
func (v Table) Clear() {
|
||||
defer v.change("Clear", MajorChange)()
|
||||
rect := v.Range()
|
||||
rect.Limit(v.Width(), v.Height())
|
||||
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
|
||||
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
|
||||
v.clear(Point{x, y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v Table) Set(value byte) {
|
||||
defer v.change("Set", MajorChange)()
|
||||
cursor := v.Cursor()
|
||||
// TODO: might check for visibility
|
||||
v.set(cursor, int(value))
|
||||
}
|
||||
|
||||
func (v Table) Fill(value int) {
|
||||
defer v.change("Fill", MajorChange)()
|
||||
rect := v.Range()
|
||||
rect.Limit(v.Width(), v.Height())
|
||||
for y := rect.TopLeft.Y; y <= rect.BottomRight.Y; y++ {
|
||||
for x := rect.TopLeft.X; x <= rect.BottomRight.X; x++ {
|
||||
v.set(Point{x, y}, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v Table) Add(delta int, largeStep bool) {
|
||||
defer v.change("Add", MinorChange)()
|
||||
if !v.add(v.Range(), delta, largeStep) {
|
||||
v.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (v Table) SetCursorX(x int) {
|
||||
p := v.Cursor()
|
||||
p.X = x
|
||||
v.SetCursor(p)
|
||||
}
|
||||
|
||||
func (v Table) SetCursorY(y int) {
|
||||
p := v.Cursor()
|
||||
p.Y = y
|
||||
v.SetCursor(p)
|
||||
}
|
||||
182
tracker/bool.go
182
tracker/bool.go
@ -1,182 +0,0 @@
|
||||
package tracker
|
||||
|
||||
type (
|
||||
Bool struct {
|
||||
BoolData
|
||||
}
|
||||
|
||||
BoolData interface {
|
||||
Value() bool
|
||||
Enabled() bool
|
||||
setValue(bool)
|
||||
}
|
||||
|
||||
Panic Model
|
||||
IsRecording Model
|
||||
Playing Model
|
||||
InstrEnlarged Model
|
||||
Effect Model
|
||||
CommentExpanded Model
|
||||
NoteTracking Model
|
||||
UnitSearching Model
|
||||
UnitDisabled Model
|
||||
LoopToggle Model
|
||||
)
|
||||
|
||||
func (v Bool) Toggle() {
|
||||
v.Set(!v.Value())
|
||||
}
|
||||
|
||||
func (v Bool) Set(value bool) {
|
||||
if v.Enabled() && v.Value() != value {
|
||||
v.setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Model methods
|
||||
|
||||
func (m *Model) Panic() *Panic { return (*Panic)(m) }
|
||||
func (m *Model) IsRecording() *IsRecording { return (*IsRecording)(m) }
|
||||
func (m *Model) Playing() *Playing { return (*Playing)(m) }
|
||||
func (m *Model) InstrEnlarged() *InstrEnlarged { return (*InstrEnlarged)(m) }
|
||||
func (m *Model) Effect() *Effect { return (*Effect)(m) }
|
||||
func (m *Model) CommentExpanded() *CommentExpanded { return (*CommentExpanded)(m) }
|
||||
func (m *Model) NoteTracking() *NoteTracking { return (*NoteTracking)(m) }
|
||||
func (m *Model) UnitSearching() *UnitSearching { return (*UnitSearching)(m) }
|
||||
func (m *Model) UnitDisabled() *UnitDisabled { return (*UnitDisabled)(m) }
|
||||
func (m *Model) LoopToggle() *LoopToggle { return (*LoopToggle)(m) }
|
||||
|
||||
// Panic methods
|
||||
|
||||
func (m *Panic) Bool() Bool { return Bool{m} }
|
||||
func (m *Panic) Value() bool { return m.panic }
|
||||
func (m *Panic) setValue(val bool) {
|
||||
m.panic = val
|
||||
(*Model)(m).send(PanicMsg{val})
|
||||
}
|
||||
func (m *Panic) Enabled() bool { return true }
|
||||
|
||||
// IsRecording methods
|
||||
|
||||
func (m *IsRecording) Bool() Bool { return Bool{m} }
|
||||
func (m *IsRecording) Value() bool { return (*Model)(m).recording }
|
||||
func (m *IsRecording) setValue(val bool) {
|
||||
m.recording = val
|
||||
m.instrEnlarged = val
|
||||
(*Model)(m).send(RecordingMsg{val})
|
||||
}
|
||||
func (m *IsRecording) Enabled() bool { return true }
|
||||
|
||||
// Playing methods
|
||||
|
||||
func (m *Playing) Bool() Bool { return Bool{m} }
|
||||
func (m *Playing) Value() bool { return m.playing }
|
||||
func (m *Playing) setValue(val bool) {
|
||||
m.playing = val
|
||||
if m.playing {
|
||||
(*Model)(m).send(StartPlayMsg{m.d.Cursor.SongPos})
|
||||
} else {
|
||||
(*Model)(m).send(IsPlayingMsg{val})
|
||||
}
|
||||
}
|
||||
func (m *Playing) Enabled() bool { return m.playing || !m.instrEnlarged }
|
||||
|
||||
// InstrEnlarged methods
|
||||
|
||||
func (m *InstrEnlarged) Bool() Bool { return Bool{m} }
|
||||
func (m *InstrEnlarged) Value() bool { return m.instrEnlarged }
|
||||
func (m *InstrEnlarged) setValue(val bool) { m.instrEnlarged = val }
|
||||
func (m *InstrEnlarged) Enabled() bool { return true }
|
||||
|
||||
// CommentExpanded methods
|
||||
|
||||
func (m *CommentExpanded) Bool() Bool { return Bool{m} }
|
||||
func (m *CommentExpanded) Value() bool { return m.commentExpanded }
|
||||
func (m *CommentExpanded) setValue(val bool) { m.commentExpanded = val }
|
||||
func (m *CommentExpanded) Enabled() bool { return true }
|
||||
|
||||
// NoteTracking methods
|
||||
|
||||
func (m *NoteTracking) Bool() Bool { return Bool{m} }
|
||||
func (m *NoteTracking) Value() bool { return m.playing && m.noteTracking }
|
||||
func (m *NoteTracking) setValue(val bool) { m.noteTracking = val }
|
||||
func (m *NoteTracking) Enabled() bool { return m.playing }
|
||||
|
||||
// Effect methods
|
||||
|
||||
func (m *Effect) Bool() Bool { return Bool{m} }
|
||||
func (m *Effect) Value() bool {
|
||||
if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) {
|
||||
return false
|
||||
}
|
||||
return m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect
|
||||
}
|
||||
func (m *Effect) setValue(val bool) {
|
||||
if m.d.Cursor.Track < 0 || m.d.Cursor.Track >= len(m.d.Song.Score.Tracks) {
|
||||
return
|
||||
}
|
||||
m.d.Song.Score.Tracks[m.d.Cursor.Track].Effect = val
|
||||
}
|
||||
func (m *Effect) Enabled() bool { return true }
|
||||
|
||||
// UnitSearching methods
|
||||
|
||||
func (m *UnitSearching) Bool() Bool { return Bool{m} }
|
||||
func (m *UnitSearching) Value() bool { return m.d.UnitSearching }
|
||||
func (m *UnitSearching) setValue(val bool) {
|
||||
m.d.UnitSearching = val
|
||||
if !val {
|
||||
m.d.UnitSearchString = ""
|
||||
}
|
||||
}
|
||||
func (m *UnitSearching) Enabled() bool { return true }
|
||||
|
||||
// UnitDisabled methods
|
||||
|
||||
func (m *UnitDisabled) Bool() Bool { return Bool{m} }
|
||||
func (m *UnitDisabled) Value() bool {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
return false
|
||||
}
|
||||
if m.d.UnitIndex < 0 || m.d.UnitIndex >= len(m.d.Song.Patch[m.d.InstrIndex].Units) {
|
||||
return false
|
||||
}
|
||||
return m.d.Song.Patch[m.d.InstrIndex].Units[m.d.UnitIndex].Disabled
|
||||
}
|
||||
func (m *UnitDisabled) setValue(val bool) {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
return
|
||||
}
|
||||
l := ((*Model)(m)).Units().List()
|
||||
a, b := l.listRange()
|
||||
defer (*Model)(m).change("UnitDisabledSet", PatchChange, MajorChange)()
|
||||
for i := a; i <= b; i++ {
|
||||
m.d.Song.Patch[m.d.InstrIndex].Units[i].Disabled = val
|
||||
}
|
||||
}
|
||||
func (m *UnitDisabled) Enabled() bool {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
return false
|
||||
}
|
||||
if len(m.d.Song.Patch[m.d.InstrIndex].Units) == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LoopToggle methods
|
||||
|
||||
func (m *LoopToggle) Bool() Bool { return Bool{m} }
|
||||
func (m *LoopToggle) Value() bool { return m.d.Loop.Length > 0 }
|
||||
func (t *LoopToggle) setValue(val bool) {
|
||||
m := (*Model)(t)
|
||||
defer m.change("SetLoopAction", LoopChange, MinorChange)()
|
||||
if !val {
|
||||
m.d.Loop = Loop{}
|
||||
return
|
||||
}
|
||||
l := m.OrderRows().List()
|
||||
a, b := l.listRange()
|
||||
m.d.Loop = Loop{a, b - a + 1}
|
||||
}
|
||||
func (m *LoopToggle) Enabled() bool { return true }
|
||||
193
tracker/broker.go
Normal file
193
tracker/broker.go
Normal file
@ -0,0 +1,193 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
type (
|
||||
// Broker is the centralized message broker for the tracker. It is used to
|
||||
// communicate between the player, the model, and the loudness detector. At
|
||||
// the moment, the broker is just many-to-one communication, implemented
|
||||
// with one channel for each recipient. Additionally, the broker has a
|
||||
// sync.pool for *sointu.AudioBuffers, from which the player can get and
|
||||
// return buffers to pass buffers around without allocating new memory every
|
||||
// time. We can later consider making many-to-many types of communication
|
||||
// and more complex routing logic to the Broker if needed.
|
||||
//
|
||||
// For closing goroutines, the broker has two channels for each goroutine:
|
||||
// CloseXXX and FinishedXXX. The CloseXXX channel has a capacity of 1, so
|
||||
// you can always send a empty message (struct{}{}) to it without blocking.
|
||||
// If the channel is already full, that means someone else has already
|
||||
// requested its closure and the goroutine is already closing, so dropping
|
||||
// the message is fine. Then, FinishedXXX is used to signal that a goroutine
|
||||
// has succesfully closed and cleaned up. Nothing is ever sent to the
|
||||
// channel, it is only closed. You can wait until the goroutines is done
|
||||
// closing with "<- FinishedXXX", which for avoiding deadlocks can be
|
||||
// combined with a timeout:
|
||||
// select {
|
||||
// case <-FinishedXXX:
|
||||
// case <-time.After(3 * time.Second):
|
||||
// }
|
||||
Broker struct {
|
||||
ToModel chan MsgToModel
|
||||
ToPlayer chan any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
|
||||
ToDetector chan MsgToDetector
|
||||
ToGUI chan any
|
||||
ToSpecAn chan MsgToSpecAn
|
||||
|
||||
CloseDetector chan struct{}
|
||||
CloseGUI chan struct{}
|
||||
CloseSpecAn chan struct{}
|
||||
|
||||
FinishedGUI chan struct{}
|
||||
FinishedDetector chan struct{}
|
||||
FinishedSpecAn chan struct{}
|
||||
|
||||
// mIDIEventsToGUI is true if all MIDI events should be sent to the GUI,
|
||||
// for inputting notes to tracks. If false, they should be sent to the
|
||||
// player instead.
|
||||
mIDIEventsToGUI atomic.Bool
|
||||
|
||||
bufferPool sync.Pool
|
||||
spectrumPool sync.Pool
|
||||
}
|
||||
|
||||
// MsgToModel is a message sent to the model. The most often sent data
|
||||
// (Panic, SongPosition, VoiceLevels and DetectorResult) are not boxed to
|
||||
// avoid allocations. All the infrequently passed messages can be boxed &
|
||||
// cast to any; casting pointer types to any is cheap (does not allocate).
|
||||
MsgToModel struct {
|
||||
HasPanicPlayerStatus bool
|
||||
Panic bool
|
||||
PlayerStatus PlayerStatus
|
||||
|
||||
HasDetectorResult bool
|
||||
DetectorResult DetectorResult
|
||||
|
||||
TriggerChannel int // note: 0 = no trigger, 1 = first channel, etc.
|
||||
Reset bool // true: playing started, so should reset the detector and the scope cursor
|
||||
|
||||
Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
|
||||
}
|
||||
|
||||
// MsgToDetector is a message sent to the detector. It contains a reset flag
|
||||
// and a data field. The data field can contain many different messages,
|
||||
// including *sointu.AudioBuffer for the detector to analyze and func()
|
||||
// which gets executed in the detector goroutine.
|
||||
MsgToDetector struct {
|
||||
Reset bool
|
||||
Data any // TODO: consider using a sum type here, for a bit more type safety. See: https://www.jerf.org/iri/post/2917/
|
||||
|
||||
WeightingType WeightingType
|
||||
HasWeightingType bool
|
||||
Oversampling bool
|
||||
HasOversampling bool
|
||||
}
|
||||
|
||||
// MsgToGUI is a message sent to the GUI, as GUI stores part of the state.
|
||||
// In particular, GUI knows about where lists / tables are centered, so the
|
||||
// kind of messages we send to the GUI are about centering the view on a
|
||||
// specific row, or ensuring that the cursor is visible.
|
||||
MsgToGUI struct {
|
||||
Kind GUIMessageKind
|
||||
Param int
|
||||
}
|
||||
|
||||
MsgToSpecAn struct {
|
||||
SpecSettings specAnSettings
|
||||
HasSettings bool
|
||||
Data any
|
||||
}
|
||||
|
||||
GUIMessageKind int
|
||||
)
|
||||
|
||||
const (
|
||||
GUIMessageKindNone GUIMessageKind = iota
|
||||
GUIMessageCenterOnRow
|
||||
GUIMessageEnsureCursorVisible
|
||||
)
|
||||
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{
|
||||
ToPlayer: make(chan any, 1024),
|
||||
ToModel: make(chan MsgToModel, 1024),
|
||||
ToDetector: make(chan MsgToDetector, 1024),
|
||||
ToGUI: make(chan any, 1024),
|
||||
ToSpecAn: make(chan MsgToSpecAn, 1024),
|
||||
CloseDetector: make(chan struct{}, 1),
|
||||
CloseGUI: make(chan struct{}, 1),
|
||||
CloseSpecAn: make(chan struct{}, 1),
|
||||
FinishedGUI: make(chan struct{}),
|
||||
FinishedDetector: make(chan struct{}),
|
||||
FinishedSpecAn: make(chan struct{}),
|
||||
bufferPool: sync.Pool{New: func() any { return &sointu.AudioBuffer{} }},
|
||||
spectrumPool: sync.Pool{New: func() any { return &Spectrum{} }},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) MIDIChannel() chan<- any {
|
||||
if b.mIDIEventsToGUI.Load() {
|
||||
return b.ToGUI
|
||||
}
|
||||
return b.ToPlayer
|
||||
}
|
||||
|
||||
// GetAudioBuffer returns an audio buffer from the buffer pool. The buffer is
|
||||
// guaranteed to be empty. After using the buffer, it should be returned to the
|
||||
// pool with PutAudioBuffer.
|
||||
func (b *Broker) GetAudioBuffer() *sointu.AudioBuffer {
|
||||
return b.bufferPool.Get().(*sointu.AudioBuffer)
|
||||
}
|
||||
|
||||
// PutAudioBuffer returns an audio buffer to the buffer pool. If the buffer is
|
||||
// not empty, its length is resetted (but capacity kept) before returning it to
|
||||
// the pool.
|
||||
func (b *Broker) PutAudioBuffer(buf *sointu.AudioBuffer) {
|
||||
if len(*buf) > 0 {
|
||||
*buf = (*buf)[:0]
|
||||
}
|
||||
b.bufferPool.Put(buf)
|
||||
}
|
||||
|
||||
func (b *Broker) GetSpectrum() *Spectrum {
|
||||
return b.spectrumPool.Get().(*Spectrum)
|
||||
}
|
||||
|
||||
func (b *Broker) PutSpectrum(s *Spectrum) {
|
||||
if len((*s)[0]) > 0 {
|
||||
(*s)[0] = (*s)[0][:0]
|
||||
}
|
||||
if len((*s)[1]) > 0 {
|
||||
(*s)[1] = (*s)[1][:0]
|
||||
}
|
||||
b.spectrumPool.Put(s)
|
||||
}
|
||||
|
||||
// TrySend is a helper function to send a value to a channel if it is not full.
|
||||
// It is guaranteed to be non-blocking. Return true if the value was sent, false
|
||||
// otherwise.
|
||||
func TrySend[T any](c chan<- T, v T) bool {
|
||||
select {
|
||||
case c <- v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TimeoutReceive is a helper function to block until a value is received from a
|
||||
// channel, or timing out after t. ok will be false if the timeout occurred or
|
||||
// if the channel is closed.
|
||||
func TimeoutReceive[T any](c <-chan T, t time.Duration) (v T, ok bool) {
|
||||
select {
|
||||
case v, ok = <-c:
|
||||
return v, ok
|
||||
case <-time.After(t):
|
||||
return v, false
|
||||
}
|
||||
}
|
||||
319
tracker/derived.go
Normal file
319
tracker/derived.go
Normal file
@ -0,0 +1,319 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
type (
|
||||
Rail struct {
|
||||
PassThrough int
|
||||
Send bool
|
||||
StackUse sointu.StackUse
|
||||
}
|
||||
|
||||
Wire struct {
|
||||
From int
|
||||
FromSet bool
|
||||
To Point
|
||||
ToSet bool
|
||||
Hint string
|
||||
Highlight bool
|
||||
}
|
||||
|
||||
RailError struct {
|
||||
InstrIndex, UnitIndex int
|
||||
Err error
|
||||
}
|
||||
|
||||
// derivedModelData contains useful information derived from the modelData,
|
||||
// cached for performance and/or easy access. This needs to be updated when
|
||||
// corresponding part of the model changes.
|
||||
derivedModelData struct {
|
||||
// map Unit by ID, other entities by their respective index
|
||||
patch []derivedInstrument
|
||||
tracks []derivedTrack
|
||||
railError RailError
|
||||
searchResults []string
|
||||
}
|
||||
|
||||
derivedInstrument struct {
|
||||
wires []Wire
|
||||
rails []Rail
|
||||
railWidth int
|
||||
params [][]Parameter
|
||||
paramsWidth int
|
||||
}
|
||||
|
||||
derivedTrack struct {
|
||||
title string
|
||||
patternUseCounts []int
|
||||
}
|
||||
)
|
||||
|
||||
// init / update methods
|
||||
|
||||
func (m *Model) updateDeriveData(changeType ChangeType) {
|
||||
setSliceLength(&m.derived.tracks, len(m.d.Song.Score.Tracks))
|
||||
if changeType&ScoreChange != 0 {
|
||||
for index, track := range m.d.Song.Score.Tracks {
|
||||
m.derived.tracks[index].patternUseCounts = m.buildPatternUseCounts(track)
|
||||
}
|
||||
}
|
||||
if changeType&ScoreChange != 0 || changeType&PatchChange != 0 {
|
||||
for index := range m.d.Song.Score.Tracks {
|
||||
m.derived.tracks[index].title = m.buildTrackTitle(index)
|
||||
}
|
||||
}
|
||||
setSliceLength(&m.derived.patch, len(m.d.Song.Patch))
|
||||
if changeType&PatchChange != 0 {
|
||||
m.updateParams()
|
||||
m.updateRails()
|
||||
m.updateWires()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateParams() {
|
||||
for i, instr := range m.d.Song.Patch {
|
||||
setSliceLength(&m.derived.patch[i].params, len(instr.Units))
|
||||
paramsWidth := 0
|
||||
for u := range instr.Units {
|
||||
p := m.deriveParams(&instr.Units[u], m.derived.patch[i].params[u])
|
||||
m.derived.patch[i].params[u] = p
|
||||
paramsWidth = max(paramsWidth, len(p))
|
||||
}
|
||||
m.derived.patch[i].paramsWidth = paramsWidth
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) deriveParams(unit *sointu.Unit, ret []Parameter) []Parameter {
|
||||
ret = ret[:0] // reset the slice
|
||||
unitType, ok := sointu.UnitTypes[unit.Type]
|
||||
if !ok {
|
||||
return ret
|
||||
}
|
||||
portIndex := 0
|
||||
for i, up := range unitType {
|
||||
if !up.CanSet && !up.CanModulate {
|
||||
continue // skip parameters that cannot be set or modulated
|
||||
}
|
||||
if unit.Type == "oscillator" && unit.Parameters["type"] != sointu.Sample && (up.Name == "samplestart" || up.Name == "loopstart" || up.Name == "looplength") {
|
||||
continue // don't show the sample related params unless necessary
|
||||
}
|
||||
if unit.Type == "send" && up.Name == "port" {
|
||||
continue
|
||||
}
|
||||
q := 0
|
||||
if up.CanModulate {
|
||||
portIndex++
|
||||
q = portIndex
|
||||
}
|
||||
ret = append(ret, Parameter{m: m, unit: unit, up: &unitType[i], vtable: &namedParameter{}, port: q})
|
||||
}
|
||||
if unit.Type == "oscillator" && unit.Parameters["type"] == sointu.Sample {
|
||||
ret = append(ret, Parameter{m: m, unit: unit, vtable: &gmDlsEntryParameter{}})
|
||||
}
|
||||
if unit.Type == "delay" {
|
||||
if unit.Parameters["stereo"] == 1 && len(unit.VarArgs)%2 == 1 {
|
||||
unit.VarArgs = append(unit.VarArgs, 1)
|
||||
}
|
||||
ret = append(ret,
|
||||
Parameter{m: m, unit: unit, vtable: &reverbParameter{}},
|
||||
Parameter{m: m, unit: unit, vtable: &delayLinesParameter{}})
|
||||
for i := range unit.VarArgs {
|
||||
ret = append(ret, Parameter{m: m, unit: unit, index: i, vtable: &delayTimeParameter{}})
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m *Model) instrumentRangeFor(trackIndex int) (int, int, error) {
|
||||
track := m.d.Song.Score.Tracks[trackIndex]
|
||||
if track.NumVoices <= 0 {
|
||||
return 0, 0, fmt.Errorf("track %d has no voices", trackIndex)
|
||||
}
|
||||
firstVoice := m.d.Song.Score.FirstVoiceForTrack(trackIndex)
|
||||
lastVoice := firstVoice + track.NumVoices - 1
|
||||
firstIndex, err1 := m.d.Song.Patch.InstrumentForVoice(firstVoice)
|
||||
if err1 != nil {
|
||||
return trackIndex, trackIndex, err1
|
||||
}
|
||||
lastIndex, err2 := m.d.Song.Patch.InstrumentForVoice(lastVoice)
|
||||
if err2 != nil {
|
||||
return trackIndex, trackIndex, err2
|
||||
}
|
||||
return firstIndex, lastIndex, nil
|
||||
}
|
||||
|
||||
func (m *Model) buildTrackTitle(track int) string {
|
||||
if track < 0 || track >= len(m.d.Song.Score.Tracks) {
|
||||
return "?"
|
||||
}
|
||||
firstIndex, lastIndex, err := m.instrumentRangeFor(track)
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
switch diff := lastIndex - firstIndex; diff {
|
||||
case 0:
|
||||
return nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name)
|
||||
case 1:
|
||||
return fmt.Sprintf("%s/%s",
|
||||
nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name),
|
||||
nilIsQuestionMark(m.d.Song.Patch[firstIndex+1].Name))
|
||||
default:
|
||||
return fmt.Sprintf("%s/%s/...",
|
||||
nilIsQuestionMark(m.d.Song.Patch[firstIndex].Name),
|
||||
nilIsQuestionMark(m.d.Song.Patch[firstIndex+1].Name))
|
||||
}
|
||||
}
|
||||
|
||||
func nilIsQuestionMark(s string) string {
|
||||
if len(s) == 0 {
|
||||
return "?"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Model) buildPatternUseCounts(track sointu.Track) []int {
|
||||
result := make([]int, 0, len(track.Patterns))
|
||||
for j := range min(len(track.Order), m.d.Song.Score.Length) {
|
||||
if p := track.Order[j]; p >= 0 {
|
||||
for len(result) <= p {
|
||||
result = append(result, 0)
|
||||
}
|
||||
result[p]++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Model) updateRails() {
|
||||
type stackElem struct{ instr, unit int }
|
||||
scratchArray := [32]stackElem{}
|
||||
scratch := scratchArray[:0]
|
||||
m.derived.railError = RailError{}
|
||||
for i, instr := range m.d.Song.Patch {
|
||||
setSliceLength(&m.derived.patch[i].rails, len(instr.Units))
|
||||
start := len(scratch)
|
||||
maxWidth := 0
|
||||
for u, unit := range instr.Units {
|
||||
stackUse := unit.StackUse()
|
||||
numInputs := len(stackUse.Inputs)
|
||||
if len(scratch) < numInputs {
|
||||
if m.derived.railError == (RailError{}) {
|
||||
m.derived.railError = RailError{
|
||||
InstrIndex: i,
|
||||
UnitIndex: u,
|
||||
Err: fmt.Errorf("%s unit in instrument %d / %s needs %d inputs, but got only %d", unit.Type, i, instr.Name, numInputs, len(scratch)),
|
||||
}
|
||||
}
|
||||
scratch = scratch[:0]
|
||||
} else {
|
||||
scratch = scratch[:len(scratch)-numInputs]
|
||||
}
|
||||
m.derived.patch[i].rails[u] = Rail{
|
||||
PassThrough: len(scratch),
|
||||
StackUse: stackUse,
|
||||
Send: !unit.Disabled && unit.Type == "send",
|
||||
}
|
||||
maxWidth = max(maxWidth, len(scratch)+max(len(stackUse.Inputs), stackUse.NumOutputs))
|
||||
for range stackUse.NumOutputs {
|
||||
scratch = append(scratch, stackElem{instr: i, unit: u})
|
||||
}
|
||||
}
|
||||
m.derived.patch[i].railWidth = maxWidth
|
||||
diff := len(scratch) - start
|
||||
if instr.NumVoices > 1 && diff != 0 {
|
||||
if diff < 0 {
|
||||
morepop := (instr.NumVoices - 1) * diff
|
||||
if morepop > len(scratch) {
|
||||
if m.derived.railError == (RailError{}) {
|
||||
m.derived.railError = RailError{
|
||||
InstrIndex: i,
|
||||
UnitIndex: -1,
|
||||
Err: fmt.Errorf("each voice of instrument %d / %s consumes %d signals, but there was not enough signals available", i, instr.Name, -diff),
|
||||
}
|
||||
}
|
||||
scratch = scratch[:0]
|
||||
} else {
|
||||
scratch = scratch[:len(scratch)-morepop]
|
||||
}
|
||||
} else {
|
||||
for range (instr.NumVoices - 1) * diff {
|
||||
scratch = append(scratch, scratch[len(scratch)-diff])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(scratch) > 0 && m.derived.railError == (RailError{}) {
|
||||
patch := m.d.Song.Patch
|
||||
m.derived.railError = RailError{
|
||||
InstrIndex: scratch[0].instr,
|
||||
UnitIndex: scratch[0].unit,
|
||||
Err: fmt.Errorf("instrument %d / %s unit %d / %s leaves a signal on stack", scratch[0].instr, patch[scratch[0].instr].Name, scratch[0].unit, patch[scratch[0].instr].Units[scratch[0].unit].Type),
|
||||
}
|
||||
}
|
||||
if m.derived.railError.Err != nil {
|
||||
m.Alerts().AddAlert(Alert{
|
||||
Name: "RailError",
|
||||
Message: m.derived.railError.Error(),
|
||||
Priority: Error,
|
||||
Duration: time.Second * 10,
|
||||
})
|
||||
} else { // clear the alert if it was set
|
||||
m.Alerts().ClearNamed("RailError")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateWires() {
|
||||
for i := range m.d.Song.Patch {
|
||||
m.derived.patch[i].wires = m.derived.patch[i].wires[:0] // reset the wires
|
||||
}
|
||||
for i, instr := range m.d.Song.Patch {
|
||||
for u, unit := range instr.Units {
|
||||
if unit.Disabled || unit.Type != "send" {
|
||||
continue
|
||||
}
|
||||
tI, tU, err := m.d.Song.Patch.FindUnit(unit.Parameters["target"])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
up, tX, ok := sointu.FindParamForModulationPort(m.d.Song.Patch[tI].Units[tU].Type, unit.Parameters["port"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tI == i {
|
||||
// local send
|
||||
m.derived.patch[i].wires = append(m.derived.patch[i].wires, Wire{
|
||||
From: u,
|
||||
FromSet: true,
|
||||
To: Point{X: tX, Y: tU},
|
||||
ToSet: true,
|
||||
})
|
||||
} else {
|
||||
// remote send
|
||||
m.derived.patch[i].wires = append(m.derived.patch[i].wires, Wire{
|
||||
From: u,
|
||||
FromSet: true,
|
||||
Hint: fmt.Sprintf("To instrument #%d (%s), unit #%d (%s), port %s", tI, m.d.Song.Patch[tI].Name, tU, m.d.Song.Patch[tI].Units[tU].Type, up.Name),
|
||||
})
|
||||
toPt := Point{X: tX, Y: tU}
|
||||
hint := fmt.Sprintf("From instrument #%d (%s), send #%d", i, m.d.Song.Patch[i].Name, u)
|
||||
for i, w := range m.derived.patch[tI].wires {
|
||||
if !w.FromSet && w.ToSet && w.To == toPt {
|
||||
m.derived.patch[tI].wires[i].Hint += "; " + hint
|
||||
goto skipAppend
|
||||
}
|
||||
}
|
||||
m.derived.patch[tI].wires = append(m.derived.patch[tI].wires, Wire{
|
||||
To: toPt,
|
||||
ToSet: true,
|
||||
Hint: hint,
|
||||
})
|
||||
skipAppend:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
485
tracker/detector.go
Normal file
485
tracker/detector.go
Normal file
@ -0,0 +1,485 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/viterin/vek/vek32"
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
const MAX_INTEGRATED_DATA = 10 * 60 * 60 // 1 hour of samples at 10 Hz (100 ms per sample)
|
||||
// In the detector, we clamp the signal levels to +-MAX_SIGNAL_AMPLITUDE to
|
||||
// avoid Inf results. This is 240 dBFS. max float32 is about 3.4e38, so squaring
|
||||
// the amplitude values gives 1e24, and adding 4410 of those together (when
|
||||
// taking the mean) gives a value < 1e37, which is still < max float32.
|
||||
const MAX_SIGNAL_AMPLITUDE = 1e12
|
||||
|
||||
// Detector returns a DetectorModel which provides access to the detector
|
||||
// settings and results.
|
||||
func (m *Model) Detector() *DetectorModel { return (*DetectorModel)(m) }
|
||||
|
||||
type DetectorModel Model
|
||||
|
||||
// Result returns the latest DetectorResult from the detector.
|
||||
func (m *DetectorModel) Result() DetectorResult { return m.detectorResult }
|
||||
|
||||
type (
|
||||
DetectorResult struct {
|
||||
Loudness LoudnessResult
|
||||
Peaks PeakResult
|
||||
}
|
||||
LoudnessResult [NumLoudnessTypes]Decibel
|
||||
PeakResult [NumPeakTypes][2]Decibel
|
||||
Decibel float32
|
||||
LoudnessType int
|
||||
PeakType int
|
||||
)
|
||||
|
||||
const (
|
||||
LoudnessMomentary LoudnessType = iota
|
||||
LoudnessShortTerm
|
||||
LoudnessMaxMomentary
|
||||
LoudnessMaxShortTerm
|
||||
LoudnessIntegrated
|
||||
NumLoudnessTypes
|
||||
)
|
||||
|
||||
const (
|
||||
PeakMomentary PeakType = iota
|
||||
PeakShortTerm
|
||||
PeakIntegrated
|
||||
NumPeakTypes
|
||||
)
|
||||
|
||||
// Weighting returns an Int property for setting the detector weighting type.
|
||||
func (m *DetectorModel) Weighting() Int { return MakeInt((*detectorWeighting)(m)) }
|
||||
|
||||
type detectorWeighting Model
|
||||
|
||||
func (v *detectorWeighting) Value() int { return int(v.weightingType) }
|
||||
func (v *detectorWeighting) SetValue(value int) bool {
|
||||
v.weightingType = WeightingType(value)
|
||||
TrySend(v.broker.ToDetector, MsgToDetector{HasWeightingType: true, WeightingType: WeightingType(value)})
|
||||
return true
|
||||
}
|
||||
func (v *detectorWeighting) Range() RangeInclusive {
|
||||
return RangeInclusive{0, int(NumWeightingTypes) - 1}
|
||||
}
|
||||
func (v *detectorWeighting) StringOf(value int) string {
|
||||
switch WeightingType(value) {
|
||||
case KWeighting:
|
||||
return "K-weighting"
|
||||
case AWeighting:
|
||||
return "A-weighting"
|
||||
case CWeighting:
|
||||
return "C-weighting (LUFS)"
|
||||
case NoWeighting:
|
||||
return "No weighting (RMS)"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type WeightingType int
|
||||
|
||||
const (
|
||||
KWeighting WeightingType = iota
|
||||
AWeighting
|
||||
CWeighting
|
||||
NoWeighting
|
||||
NumWeightingTypes
|
||||
)
|
||||
|
||||
// Oversampling returns a Bool property for setting whether the peak detector
|
||||
// uses oversampling to calculate true peaks, or just sample peaks if not.
|
||||
func (m *DetectorModel) Oversampling() Bool { return MakeBool((*detectorOversampling)(m)) }
|
||||
|
||||
type detectorOversampling Model
|
||||
|
||||
func (m *detectorOversampling) Value() bool { return m.oversampling }
|
||||
func (m *detectorOversampling) SetValue(val bool) {
|
||||
m.oversampling = val
|
||||
TrySend(m.broker.ToDetector, MsgToDetector{HasOversampling: true, Oversampling: val})
|
||||
}
|
||||
|
||||
type (
|
||||
detector struct {
|
||||
broker *Broker
|
||||
loudnessDetector loudnessDetector
|
||||
peakDetector peakDetector
|
||||
chunker chunker
|
||||
}
|
||||
|
||||
loudnessDetector struct {
|
||||
weighting weighting
|
||||
states [2][3]biquadState
|
||||
powers [2]RingBuffer[float32] // 0 = momentary, 1 = short-term
|
||||
averagedPowers [2][]float32
|
||||
maxPowers [2]float32
|
||||
integratedPower float32
|
||||
tmp, tmp2 []float32
|
||||
tmpbool []bool
|
||||
}
|
||||
|
||||
biquadState struct {
|
||||
x1, x2, y1, y2 float32
|
||||
}
|
||||
|
||||
biquadCoeff struct {
|
||||
b0, b1, b2, a1, a2 float32
|
||||
}
|
||||
|
||||
weighting []biquadCoeff
|
||||
|
||||
peakDetector struct {
|
||||
oversampling bool
|
||||
states [2]oversamplerState
|
||||
windows [2][2]RingBuffer[float32]
|
||||
maxPower [2]float32
|
||||
tmp, tmp2 []float32
|
||||
}
|
||||
|
||||
oversamplerState struct {
|
||||
history [11]float32
|
||||
tmp, tmp2 []float32
|
||||
}
|
||||
)
|
||||
|
||||
func runDetector(b *Broker) {
|
||||
s := &detector{
|
||||
broker: b,
|
||||
loudnessDetector: makeLoudnessDetector(KWeighting),
|
||||
peakDetector: makePeakDetector(true),
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-s.broker.CloseDetector:
|
||||
close(s.broker.FinishedDetector)
|
||||
return
|
||||
case msg := <-s.broker.ToDetector:
|
||||
s.handleMsg(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *detector) handleMsg(msg MsgToDetector) {
|
||||
if msg.Reset {
|
||||
s.loudnessDetector.reset()
|
||||
s.peakDetector.reset()
|
||||
}
|
||||
if msg.HasWeightingType {
|
||||
s.loudnessDetector.weighting = weightings[WeightingType(msg.WeightingType)]
|
||||
s.loudnessDetector.reset()
|
||||
}
|
||||
if msg.HasOversampling {
|
||||
s.peakDetector.oversampling = msg.Oversampling
|
||||
s.peakDetector.reset()
|
||||
}
|
||||
|
||||
switch data := msg.Data.(type) {
|
||||
case *sointu.AudioBuffer:
|
||||
buf := *data
|
||||
s.chunker.Process(buf, 4410, 0, func(chunk sointu.AudioBuffer) {
|
||||
TrySend(s.broker.ToModel, MsgToModel{
|
||||
HasDetectorResult: true,
|
||||
DetectorResult: DetectorResult{
|
||||
Loudness: s.loudnessDetector.update(chunk),
|
||||
Peaks: s.peakDetector.update(chunk),
|
||||
},
|
||||
})
|
||||
})
|
||||
s.broker.PutAudioBuffer(data)
|
||||
}
|
||||
}
|
||||
|
||||
func makeLoudnessDetector(weighting WeightingType) loudnessDetector {
|
||||
return loudnessDetector{
|
||||
weighting: weightings[weighting],
|
||||
powers: [2]RingBuffer[float32]{
|
||||
{Buffer: make([]float32, 4)}, // momentary loudness
|
||||
{Buffer: make([]float32, 30)}, // short-term loudness
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makePeakDetector(oversampling bool) peakDetector {
|
||||
return peakDetector{
|
||||
oversampling: oversampling,
|
||||
windows: [2][2]RingBuffer[float32]{
|
||||
{{Buffer: make([]float32, 4)}, {Buffer: make([]float32, 4)}}, // momentary peaks
|
||||
{{Buffer: make([]float32, 30)}, {Buffer: make([]float32, 30)}}, // short-term peaks
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
From matlab: (we bake in the scale values to the numerator coefficients)
|
||||
weightings = {'A-weighting','C-weighting','k-weighting'}
|
||||
for j = 1:3
|
||||
disp(weightings{j})
|
||||
f = getFilter(weightingFilter(weightings{j},'SampleRate',44100)); f.Numerator, f.Denominator, f.ScaleValues
|
||||
if j == 3 % k-weighting has non-zero gain at 1 kHz, so normalize it to 0 dB by scaling the first filter
|
||||
[h,w] = freqz(f,[1000,1000],44100);
|
||||
g = abs(h(1));
|
||||
fprintf("Gain %f dB\n", 20*log10(abs(h(1))));
|
||||
f.Numerator(1,:) = f.Numerator(1,:)/g;
|
||||
end
|
||||
for i = 1:size(f.Numerator,1); fprintf("b0: %.16f, b1: %.16f, b2: %.16f, a1: %.16f, a2: %.16f\n",f.Numerator(i,:)*f.ScaleValues(i),f.Denominator(i,2:end)); end
|
||||
end
|
||||
*/
|
||||
var weightings = map[WeightingType]weighting{
|
||||
AWeighting: {
|
||||
{b0: 0.2556115104436430, b1: 0.5112230208872860, b2: 0.2556115104436430, a1: -0.1405360824207108, a2: 0.0049375976155402},
|
||||
{b0: 1, b1: -2, b2: 1, a1: -1.8849012174287920, a2: 0.8864214718161675},
|
||||
{b0: 1, b1: -2, b2: 1, a1: -1.9941388812663283, a2: 0.9941474694445309},
|
||||
},
|
||||
CWeighting: {
|
||||
{b0: 0.2170124955461332, b1: 0.4340249910922664, b2: 0.2170124955461332, a1: -0.1405360824207108, a2: 0.0049375976155402},
|
||||
{b0: 1, b1: -2, b2: 1, a1: -1.9941388812663283, a2: 0.9941474694445309},
|
||||
},
|
||||
KWeighting: {
|
||||
{b0: 1.4128568659906546, b1: -2.4466647580657646, b2: 1.0789762991286349, a1: -1.6636551132560204, a2: 0.7125954280732254},
|
||||
{b0: 0.9995600645425144, b1: -1.9991201290850289, b2: 0.9995600645425144, a1: -1.9891696736297957, a2: 0.9891990357870394},
|
||||
},
|
||||
NoWeighting: {},
|
||||
}
|
||||
|
||||
// according to https://tech.ebu.ch/docs/tech/tech3341.pdf
|
||||
// we have two sliding windows: momentary loudness = last 400 ms, short-term loudness = last 3 s
|
||||
// display:
|
||||
//
|
||||
// momentary loudness = last analyzed 400 ms blcok
|
||||
// short-term loudness = last analyzed 3 s block
|
||||
//
|
||||
// every 100 ms, we collect one data point of the momentary loudness (starting to play song again resets the data blocks)
|
||||
// then:
|
||||
//
|
||||
// integrated loudness = the blocks are gated, and the average loudness of the gated blocks is calculated
|
||||
// maximum momentary loudness = maximum of all the momentary blocks
|
||||
// maximum short-term loudness = maximum of all the short-term blocks
|
||||
func (d *loudnessDetector) update(chunk sointu.AudioBuffer) LoudnessResult {
|
||||
l := max(len(chunk), MAX_INTEGRATED_DATA)
|
||||
setSliceLength(&d.tmp, l)
|
||||
setSliceLength(&d.tmp2, l)
|
||||
setSliceLength(&d.tmpbool, l)
|
||||
var total float32
|
||||
for chn := range 2 {
|
||||
// deinterleave the channels
|
||||
for i := range chunk {
|
||||
d.tmp[i] = removeNaNsAndClamp(chunk[i][chn])
|
||||
}
|
||||
// filter the signal with the weighting filter
|
||||
for k := range d.weighting {
|
||||
d.states[chn][k].Filter(d.tmp[:len(chunk)], d.weighting[k])
|
||||
}
|
||||
// square the samples
|
||||
res := vek32.Mul_Into(d.tmp2, d.tmp[:len(chunk)], d.tmp[:len(chunk)])
|
||||
// calculate the mean and add it to the total
|
||||
total += vek32.Mean(res)
|
||||
}
|
||||
var ret [NumLoudnessTypes]Decibel
|
||||
for i := range d.powers {
|
||||
d.powers[i].WriteWrapSingle(total) // these are sliding windows of 4 and 30 power measurements (400 ms and 3 s aka momentary and short-term windows)
|
||||
mean := vek32.Mean(d.powers[i].Buffer)
|
||||
if len(d.averagedPowers[i]) < MAX_INTEGRATED_DATA { // we need to have some limit on how much data we keep
|
||||
d.averagedPowers[i] = append(d.averagedPowers[i], mean)
|
||||
}
|
||||
if d.maxPowers[i] < mean {
|
||||
d.maxPowers[i] = mean
|
||||
}
|
||||
ret[i+int(LoudnessMomentary)] = powerToDecibel(mean) // we assume the LoudnessMomentary is followed by LoudnessShortTerm
|
||||
ret[i+int(LoudnessMaxMomentary)] = powerToDecibel(d.maxPowers[i])
|
||||
}
|
||||
if len(d.averagedPowers[0])%10 == 0 { // every 10 samples of 100 ms i.e. every 1 s, we recalculate the integrated power
|
||||
absThreshold := decibelToPower(-70) // -70 dB is the first threshold
|
||||
b := vek32.GtNumber_Into(d.tmpbool, d.averagedPowers[0], absThreshold)
|
||||
m2 := vek32.Select_Into(d.tmp, d.averagedPowers[0], b)
|
||||
if len(m2) > 0 {
|
||||
relThreshold := vek32.Mean(m2) / 10 // the relative threshold is 10 dB below the mean of the values above the absolute threshold
|
||||
b2 := vek32.GtNumber_Into(d.tmpbool, m2, relThreshold)
|
||||
m3 := vek32.Select_Into(d.tmp2, m2, b2)
|
||||
if len(m3) > 0 {
|
||||
d.integratedPower = vek32.Mean(m3)
|
||||
}
|
||||
}
|
||||
}
|
||||
ret[LoudnessIntegrated] = powerToDecibel(d.integratedPower)
|
||||
return ret
|
||||
}
|
||||
|
||||
func (d *loudnessDetector) reset() {
|
||||
for i := range d.powers {
|
||||
d.powers[i].Cursor = 0
|
||||
l := len(d.powers[i].Buffer)
|
||||
d.powers[i].Buffer = d.powers[i].Buffer[:0]
|
||||
d.powers[i].Buffer = append(d.powers[i].Buffer, make([]float32, l)...)
|
||||
d.averagedPowers[i] = d.averagedPowers[i][:0]
|
||||
d.maxPowers[i] = 0
|
||||
}
|
||||
// reset the biquad states
|
||||
d.states = [2][3]biquadState{}
|
||||
d.integratedPower = 0
|
||||
}
|
||||
|
||||
func removeNaNsAndClamp(s float32) float32 {
|
||||
if s != s { // NaN
|
||||
return 0
|
||||
}
|
||||
return min(max(s, -MAX_SIGNAL_AMPLITUDE), MAX_SIGNAL_AMPLITUDE)
|
||||
}
|
||||
|
||||
func powerToDecibel(power float32) Decibel {
|
||||
return Decibel(float32(10 * math.Log10(float64(power))))
|
||||
}
|
||||
|
||||
func amplitudeToDecibel(amplitude float32) Decibel {
|
||||
return Decibel(float32(20 * math.Log10(float64(amplitude))))
|
||||
}
|
||||
|
||||
func decibelToPower(loudness Decibel) float32 {
|
||||
return (float32)(math.Pow(10, (float64(loudness))/10))
|
||||
}
|
||||
|
||||
func (state *biquadState) Filter(buffer []float32, coeff biquadCoeff) {
|
||||
s := *state
|
||||
for i := range buffer {
|
||||
x := buffer[i]
|
||||
y := coeff.b0*x + coeff.b1*s.x1 + coeff.b2*s.x2 - coeff.a1*s.y1 - coeff.a2*s.y2
|
||||
s.x2, s.x1 = s.x1, x
|
||||
s.y2, s.y1 = s.y1, y
|
||||
buffer[i] = y
|
||||
}
|
||||
*state = s
|
||||
}
|
||||
|
||||
func setSliceLength[T any](slice *[]T, length int) {
|
||||
if len(*slice) < length {
|
||||
*slice = append(*slice, make([]T, length-len(*slice))...)
|
||||
}
|
||||
*slice = (*slice)[:length]
|
||||
}
|
||||
|
||||
// ref: https://www.itu.int/dms_pubrec/itu-r/rec/bs/R-REC-BS.1770-5-202311-I!!PDF-E.pdf
|
||||
var oversamplingCoeffs = [4][12]float32{
|
||||
{0.0017089843750, 0.0109863281250, -0.0196533203125, 0.0332031250000, -0.0594482421875, 0.1373291015625, 0.9721679687500, -0.1022949218750, 0.0476074218750, -0.0266113281250, 0.0148925781250, -0.0083007812500},
|
||||
{-0.0291748046875, 0.0292968750000, -0.0517578125000, 0.0891113281250, -0.1665039062500, 0.4650878906250, 0.7797851562500, -0.2003173828125, 0.1015625000000, -0.0582275390625, 0.0330810546875, -0.0189208984375},
|
||||
{-0.0189208984375, 0.0330810546875, -0.058227539062, 0.1015625000000, -0.200317382812, 0.7797851562500, 0.4650878906250, -0.166503906250, 0.0891113281250, -0.051757812500, 0.0292968750000, -0.0291748046875},
|
||||
{-0.0083007812500, 0.0148925781250, -0.0266113281250, 0.0476074218750, -0.1022949218750, 0.9721679687500, 0.1373291015625, -0.0594482421875, 0.0332031250000, -0.0196533203125, 0.0109863281250, 0.0017089843750},
|
||||
}
|
||||
|
||||
// u[k] = x[k/4] if k%4 == 0, 0 otherwise
|
||||
// y[k] = sum_{i=0}^{47} h[i] * u[k-i]
|
||||
// h[i] = o[i%4][i/4]
|
||||
// k = p*4+q, q=0..3
|
||||
// y[p*4+q] = sum_{j=0}^{11} sum_{i=0}^{3} h[j*4+i] * u[p*4+q-j*4-i] = ...
|
||||
// (q-i)%4 == 0 ==> i = q
|
||||
// ... = sum_{j=0}^{11} o[q][j] * x[p-j]
|
||||
// y should be at least 4 times the length of x
|
||||
func (s *oversamplerState) Oversample(x []float32, y []float32) []float32 {
|
||||
if len(s.tmp) < len(x) {
|
||||
s.tmp = append(s.tmp, make([]float32, len(x)-len(s.tmp))...)
|
||||
}
|
||||
if len(s.tmp2) < len(x) {
|
||||
s.tmp2 = append(s.tmp2, make([]float32, len(x)-len(s.tmp2))...)
|
||||
}
|
||||
for q, coeffs := range oversamplingCoeffs {
|
||||
// tmp2 will be conv(o[q],x)
|
||||
r := vek32.Zeros_Into(s.tmp2, len(x))
|
||||
for j, c := range coeffs {
|
||||
vek32.MulNumber_Into(s.tmp[:j], s.history[11-j:11], c) // convolution might pull values before x[0], so we need to use history for that
|
||||
vek32.MulNumber_Into(s.tmp[j:], x[:len(x)-j], c)
|
||||
vek32.Add_Inplace(r, s.tmp[:len(x)])
|
||||
}
|
||||
// interleave the phases
|
||||
for p, v := range r {
|
||||
y[p*4+q] = v
|
||||
}
|
||||
}
|
||||
z := min(len(x), 11)
|
||||
copy(s.history[:11-z], s.history[z:11])
|
||||
copy(s.history[11-z:], x[len(x)-z:])
|
||||
return y[:len(x)*4]
|
||||
}
|
||||
|
||||
// we should perform the peak detection also momentary (last 400 ms), short term
|
||||
// (last 3 s), and integrated (whole song) for display purposes, we can use
|
||||
// always last arrived data for the integrated peak, we can use the maximum of
|
||||
// all the peaks so far (there is no need show "maximum short term true peak" or
|
||||
// "maximum momentary true peak" because they are same as the maximum for entire song)
|
||||
//
|
||||
// display:
|
||||
//
|
||||
// momentary true peak
|
||||
// short-term true peak
|
||||
// integrated true peak
|
||||
func (d *peakDetector) update(buf sointu.AudioBuffer) (ret PeakResult) {
|
||||
if len(d.tmp) < len(buf) {
|
||||
d.tmp = append(d.tmp, make([]float32, len(buf)-len(d.tmp))...)
|
||||
}
|
||||
len4 := 4 * len(buf)
|
||||
if len(d.tmp2) < len4 {
|
||||
d.tmp2 = append(d.tmp2, make([]float32, len4-len(d.tmp2))...)
|
||||
}
|
||||
for chn := range 2 {
|
||||
// deinterleave the channels
|
||||
for i := range buf {
|
||||
d.tmp[i] = removeNaNsAndClamp(buf[i][chn])
|
||||
}
|
||||
// 4x oversample the signal
|
||||
var o []float32
|
||||
if d.oversampling {
|
||||
o = d.states[chn].Oversample(d.tmp[:len(buf)], d.tmp2)
|
||||
} else {
|
||||
o = d.tmp[:len(buf)]
|
||||
}
|
||||
// take absolute value of the oversampled signal
|
||||
vek32.Abs_Inplace(o)
|
||||
p := vek32.Max(o)
|
||||
// find the maximum value in the window
|
||||
for i := range d.windows {
|
||||
d.windows[i][chn].WriteWrapSingle(p)
|
||||
windowPeak := vek32.Max(d.windows[i][chn].Buffer)
|
||||
ret[i+int(PeakMomentary)][chn] = amplitudeToDecibel(windowPeak)
|
||||
}
|
||||
if d.maxPower[chn] < p {
|
||||
d.maxPower[chn] = p
|
||||
}
|
||||
ret[int(PeakIntegrated)][chn] = amplitudeToDecibel(d.maxPower[chn])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (d *peakDetector) reset() {
|
||||
for chn := range 2 {
|
||||
d.states[chn].history = [11]float32{}
|
||||
for i := range d.windows[chn] {
|
||||
d.windows[i][chn].Cursor = 0
|
||||
l := len(d.windows[i][chn].Buffer)
|
||||
d.windows[i][chn].Buffer = d.windows[i][chn].Buffer[:0]
|
||||
d.windows[i][chn].Buffer = append(d.windows[i][chn].Buffer, make([]float32, l)...)
|
||||
}
|
||||
d.maxPower[chn] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// chunker maintains a buffer of audio data. Its Process method appends an input
|
||||
// buffer to the buffer and calls a callback function with chunks of specified
|
||||
// length and overlap. The remaining data is kept in the buffer for the next
|
||||
// call.
|
||||
type chunker struct {
|
||||
buffer sointu.AudioBuffer
|
||||
}
|
||||
|
||||
// Process appends input to the internal buffer and calls cb with chunks of
|
||||
// windowLen length and overlap overlap. The remaining data is kept in the
|
||||
// internal buffer.
|
||||
func (c *chunker) Process(input sointu.AudioBuffer, windowLen, overlap int, cb func(sointu.AudioBuffer)) {
|
||||
c.buffer = append(c.buffer, input...)
|
||||
b := c.buffer
|
||||
for len(b) >= windowLen {
|
||||
cb(b[:windowLen])
|
||||
b = b[windowLen-overlap:]
|
||||
}
|
||||
copy(c.buffer, b)
|
||||
c.buffer = c.buffer[:len(b)]
|
||||
}
|
||||
@ -1,4 +1,23 @@
|
||||
/*
|
||||
Package tracker contains the data model for the Sointu tracker GUI.
|
||||
|
||||
The tracker package defines the Model struct, which holds the entire application
|
||||
state, including the song data, instruments, effects, and large part of the UI
|
||||
state.
|
||||
|
||||
The GUI does not modify the Model data directly, rather, there are types Action,
|
||||
Bool, Int, String, List and Table which can be used to manipulate the model data
|
||||
in a controlled way. For example, model.ShowLicense() returns an Action to show
|
||||
the license to the user, which can be executed with model.ShowLicense().Do().
|
||||
|
||||
The various Actions and other data manipulation methods are grouped based on
|
||||
their functionalities. For example, model.Instrument() groups all the ways to
|
||||
manipulate the instrument(s). Similarly, model.Play() groups all the ways to
|
||||
start and stop playback.
|
||||
|
||||
The method naming aims at API fluency. For example, model.Play().FromBeginning()
|
||||
returns an Action to start playing the song from the beginning. Similarly,
|
||||
model.Instrument().Add() returns an Action to add a new instrument to the song
|
||||
and model.Instrument().List() returns a List of all the instruments.
|
||||
*/
|
||||
package tracker
|
||||
|
||||
179
tracker/files.go
179
tracker/files.go
@ -1,179 +0,0 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
)
|
||||
|
||||
func (m *Model) ReadSong(r io.ReadCloser) {
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var song sointu.Song
|
||||
if errJSON := json.Unmarshal(b, &song); errJSON != nil {
|
||||
if errYaml := yaml.Unmarshal(b, &song); errYaml != nil {
|
||||
m.Alerts().Add(fmt.Sprintf("Error unmarshaling a song file: %v / %v", errYaml, errJSON), Error)
|
||||
return
|
||||
}
|
||||
}
|
||||
f := m.change("LoadSong", SongChange, MajorChange)
|
||||
m.d.Song = song
|
||||
if f, ok := r.(*os.File); ok {
|
||||
m.d.FilePath = f.Name()
|
||||
// when the song is loaded from a file, we are quite confident that the file is persisted and thus
|
||||
// we can close sointu without worrying about losing changes
|
||||
m.d.ChangedSinceSave = false
|
||||
}
|
||||
f()
|
||||
m.completeAction(false)
|
||||
}
|
||||
|
||||
func (m *Model) WriteSong(w io.WriteCloser) {
|
||||
path := ""
|
||||
var extension = filepath.Ext(path)
|
||||
var contents []byte
|
||||
var err error
|
||||
if extension == ".json" {
|
||||
contents, err = json.Marshal(m.d.Song)
|
||||
} else {
|
||||
contents, err = yaml.Marshal(m.d.Song)
|
||||
}
|
||||
if err != nil {
|
||||
m.Alerts().Add(fmt.Sprintf("Error marshaling a song file: %v", err), Error)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(contents); err != nil {
|
||||
m.Alerts().Add(fmt.Sprintf("Error writing to file: %v", err), Error)
|
||||
return
|
||||
}
|
||||
if f, ok := w.(*os.File); ok {
|
||||
path = f.Name()
|
||||
// when the song is saved to a file, we are quite confident that the file is persisted and thus
|
||||
// we can close sointu without worrying about losing changes
|
||||
m.d.ChangedSinceSave = false
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error)
|
||||
return
|
||||
}
|
||||
m.d.FilePath = path
|
||||
m.completeAction(false)
|
||||
}
|
||||
|
||||
func (m *Model) WriteWav(w io.WriteCloser, pcm16 bool, execChan chan<- func()) {
|
||||
m.dialog = NoDialog
|
||||
song := m.d.Song.Copy()
|
||||
go func() {
|
||||
b := make([]byte, 32+2)
|
||||
rand.Read(b)
|
||||
name := fmt.Sprintf("%x", b)[2 : 32+2]
|
||||
data, err := sointu.Play(m.synther, song, func(p float32) {
|
||||
execChan <- func() {
|
||||
m.Alerts().AddNamed(name, fmt.Sprintf("Exporting song: %.0f%%", p*100), Info)
|
||||
}
|
||||
}) // render the song to calculate its length
|
||||
if err != nil {
|
||||
execChan <- func() {
|
||||
m.Alerts().Add(fmt.Sprintf("Error rendering the song during export: %v", err), Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
buffer, err := data.Wav(pcm16)
|
||||
if err != nil {
|
||||
execChan <- func() {
|
||||
m.Alerts().Add(fmt.Sprintf("Error converting to .wav: %v", err), Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Write(buffer)
|
||||
w.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Model) SaveInstrument(w io.WriteCloser) bool {
|
||||
if m.d.InstrIndex < 0 || m.d.InstrIndex >= len(m.d.Song.Patch) {
|
||||
m.Alerts().Add("No instrument selected", Error)
|
||||
return false
|
||||
}
|
||||
path := ""
|
||||
if f, ok := w.(*os.File); ok {
|
||||
path = f.Name()
|
||||
}
|
||||
var extension = filepath.Ext(path)
|
||||
var contents []byte
|
||||
var err error
|
||||
if extension == ".json" {
|
||||
contents, err = json.Marshal(m.d.Song.Patch[m.d.InstrIndex])
|
||||
} else {
|
||||
contents, err = yaml.Marshal(m.d.Song.Patch[m.d.InstrIndex])
|
||||
}
|
||||
if err != nil {
|
||||
m.Alerts().Add(fmt.Sprintf("Error marshaling a ínstrument file: %v", err), Error)
|
||||
return false
|
||||
}
|
||||
w.Write(contents)
|
||||
w.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Model) LoadInstrument(r io.ReadCloser) bool {
|
||||
if m.d.InstrIndex < 0 {
|
||||
return false
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var instrument sointu.Instrument
|
||||
var errJSON, errYaml, err4ki, err4kp error
|
||||
var patch sointu.Patch
|
||||
errJSON = json.Unmarshal(b, &instrument)
|
||||
if errJSON == nil {
|
||||
goto success
|
||||
}
|
||||
errYaml = yaml.Unmarshal(b, &instrument)
|
||||
if errYaml == nil {
|
||||
goto success
|
||||
}
|
||||
patch, err4kp = sointu.Read4klangPatch(bytes.NewReader(b))
|
||||
if err4kp == nil {
|
||||
defer m.change("LoadInstrument", PatchChange, MajorChange)()
|
||||
m.d.Song.Patch = patch
|
||||
return true
|
||||
}
|
||||
instrument, err4ki = sointu.Read4klangInstrument(bytes.NewReader(b))
|
||||
if err4ki == nil {
|
||||
goto success
|
||||
}
|
||||
m.Alerts().Add(fmt.Sprintf("Error unmarshaling an instrument file: %v / %v / %v / %v", errYaml, errJSON, err4ki, err4kp), Error)
|
||||
return false
|
||||
success:
|
||||
if f, ok := r.(*os.File); ok {
|
||||
filename := f.Name()
|
||||
// the 4klang instrument names are junk, replace them with the filename without extension
|
||||
instrument.Name = filepath.Base(filename[:len(filename)-len(filepath.Ext(filename))])
|
||||
}
|
||||
defer m.change("LoadInstrument", PatchChange, MajorChange)()
|
||||
for len(m.d.Song.Patch) <= m.d.InstrIndex {
|
||||
m.d.Song.Patch = append(m.d.Song.Patch, defaultInstrument.Copy())
|
||||
}
|
||||
m.d.Song.Patch[m.d.InstrIndex] = instrument
|
||||
if m.d.Song.Patch[m.d.InstrIndex].Comment != "" {
|
||||
m.commentExpanded = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
48
tracker/generate/clean_presets.go
Normal file
48
tracker/generate/clean_presets.go
Normal file
@ -0,0 +1,48 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
filepath.WalkDir("presets", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var instr sointu.Instrument
|
||||
if yaml.Unmarshal(data, &instr) != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not unmarshal the preset file %v: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
tracker.RemoveUnusedUnitParameters(&instr) // remove invalid parameters
|
||||
instr.Name = "" // we don't need the names in the preset files as they are derived from the file path
|
||||
instr.NumVoices = 1
|
||||
outData, err := yaml.Marshal(instr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not marshal the preset file %v: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, outData, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not write the preset file %v: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
98
tracker/gioui/alerts.go
Normal file
98
tracker/gioui/alerts.go
Normal file
@ -0,0 +1,98 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
AlertsState struct {
|
||||
prevUpdate time.Time
|
||||
}
|
||||
|
||||
AlertStyle struct {
|
||||
Bg color.NRGBA
|
||||
Text LabelStyle
|
||||
}
|
||||
|
||||
AlertStyles struct {
|
||||
Info AlertStyle
|
||||
Warning AlertStyle
|
||||
Error AlertStyle
|
||||
Margin layout.Inset
|
||||
Inset layout.Inset
|
||||
}
|
||||
|
||||
AlertsWidget struct {
|
||||
Theme *Theme
|
||||
Model *tracker.Alerts
|
||||
State *AlertsState
|
||||
}
|
||||
)
|
||||
|
||||
func NewAlertsState() *AlertsState {
|
||||
return &AlertsState{prevUpdate: time.Now()}
|
||||
}
|
||||
|
||||
func Alerts(m *tracker.Alerts, th *Theme, st *AlertsState) AlertsWidget {
|
||||
return AlertsWidget{
|
||||
Theme: th,
|
||||
Model: m,
|
||||
State: st,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AlertsWidget) Layout(gtx C) D {
|
||||
now := time.Now()
|
||||
if a.Model.Update(now.Sub(a.State.prevUpdate)) {
|
||||
gtx.Execute(op.InvalidateCmd{At: now.Add(50 * time.Millisecond)})
|
||||
}
|
||||
a.State.prevUpdate = now
|
||||
|
||||
var totalY float64 = float64(gtx.Dp(38))
|
||||
for _, alert := range a.Model.Iterate {
|
||||
var alertStyle *AlertStyle
|
||||
switch alert.Priority {
|
||||
case tracker.Warning:
|
||||
alertStyle = &a.Theme.Alert.Warning
|
||||
case tracker.Error:
|
||||
alertStyle = &a.Theme.Alert.Error
|
||||
default:
|
||||
alertStyle = &a.Theme.Alert.Info
|
||||
}
|
||||
bgWidget := func(gtx C) D {
|
||||
paint.FillShape(gtx.Ops, alertStyle.Bg, clip.Rect{
|
||||
Max: gtx.Constraints.Min,
|
||||
}.Op())
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}
|
||||
labelStyle := Label(a.Theme, &alertStyle.Text, alert.Message)
|
||||
a.Theme.Alert.Margin.Layout(gtx, func(gtx C) D {
|
||||
return layout.S.Layout(gtx, func(gtx C) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
recording := op.Record(gtx.Ops)
|
||||
dims := layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(bgWidget),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
return a.Theme.Alert.Inset.Layout(gtx, labelStyle.Layout)
|
||||
}),
|
||||
)
|
||||
macro := recording.Stop()
|
||||
delta := float64(dims.Size.Y + gtx.Dp(a.Theme.Alert.Margin.Bottom))
|
||||
op.Offset(image.Point{0, int(-totalY*alert.FadeLevel + delta*(1-alert.FadeLevel))}).Add((gtx.Ops))
|
||||
totalY += delta
|
||||
macro.Add(gtx.Ops)
|
||||
return dims
|
||||
})
|
||||
})
|
||||
}
|
||||
return D{}
|
||||
}
|
||||
@ -1,150 +1,559 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"gioui.org/x/component"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
TipClickable struct {
|
||||
Clickable widget.Clickable
|
||||
TipArea component.TipArea
|
||||
Clickable struct {
|
||||
click gesture.Click
|
||||
history []widget.Press
|
||||
|
||||
requestClicks int
|
||||
TipArea TipArea // since almost all buttons have tooltips, we include the state for a tooltip here for convenience
|
||||
}
|
||||
|
||||
ActionClickable struct {
|
||||
Action tracker.Action
|
||||
TipClickable
|
||||
ButtonStyle struct {
|
||||
// Color is the text color.
|
||||
Color color.NRGBA
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
Background color.NRGBA
|
||||
CornerRadius unit.Dp
|
||||
Height unit.Dp
|
||||
Inset layout.Inset
|
||||
}
|
||||
|
||||
TipIconButtonStyle struct {
|
||||
TipArea *component.TipArea
|
||||
IconButtonStyle material.IconButtonStyle
|
||||
Tooltip component.Tooltip
|
||||
IconButtonStyle struct {
|
||||
Background color.NRGBA
|
||||
// Color is the icon color.
|
||||
Color color.NRGBA
|
||||
// Size is the icon size.
|
||||
Size unit.Dp
|
||||
Inset layout.Inset
|
||||
}
|
||||
|
||||
BoolClickable struct {
|
||||
Clickable widget.Clickable
|
||||
TipArea component.TipArea
|
||||
Bool tracker.Bool
|
||||
// Button is a text button
|
||||
Button struct {
|
||||
Theme *Theme
|
||||
Style *ButtonStyle
|
||||
Text string
|
||||
Tip string
|
||||
Clickable *Clickable
|
||||
}
|
||||
|
||||
// ActionButton is a text button that executes an action when clicked.
|
||||
ActionButton struct {
|
||||
Action tracker.Action
|
||||
DisabledStyle *ButtonStyle
|
||||
Button
|
||||
}
|
||||
|
||||
// ToggleButton is a text button that toggles a boolean value when clicked.
|
||||
ToggleButton struct {
|
||||
Bool tracker.Bool
|
||||
DisabledStyle *ButtonStyle
|
||||
OffStyle *ButtonStyle
|
||||
Button
|
||||
}
|
||||
|
||||
// TabButton is a button used in a tab bar.
|
||||
TabButton struct {
|
||||
IndicatorHeight unit.Dp
|
||||
IndicatorColor color.NRGBA
|
||||
ToggleButton
|
||||
}
|
||||
|
||||
// IconButton is a button with an icon.
|
||||
IconButton struct {
|
||||
Theme *Theme
|
||||
Style *IconButtonStyle
|
||||
Icon *widget.Icon
|
||||
Tip string
|
||||
Clickable *Clickable
|
||||
}
|
||||
|
||||
// ActionIconButton is an icon button that executes an action when clicked.
|
||||
ActionIconButton struct {
|
||||
Action tracker.Action
|
||||
DisabledStyle *IconButtonStyle
|
||||
IconButton
|
||||
}
|
||||
|
||||
// ToggleIconButton is an icon button that toggles a boolean value when clicked.
|
||||
ToggleIconButton struct {
|
||||
Bool tracker.Bool
|
||||
DisabledStyle *IconButtonStyle
|
||||
OffIcon *widget.Icon
|
||||
OffTip string
|
||||
IconButton
|
||||
}
|
||||
)
|
||||
|
||||
func NewActionClickable(a tracker.Action) *ActionClickable {
|
||||
return &ActionClickable{
|
||||
Action: a,
|
||||
func Btn(th *Theme, st *ButtonStyle, c *Clickable, txt string, tip string) Button {
|
||||
return Button{
|
||||
Theme: th,
|
||||
Style: st,
|
||||
Clickable: c,
|
||||
Text: txt,
|
||||
Tip: tip,
|
||||
}
|
||||
}
|
||||
|
||||
func NewBoolClickable(b tracker.Bool) *BoolClickable {
|
||||
return &BoolClickable{
|
||||
Bool: b,
|
||||
func ActionBtn(act tracker.Action, th *Theme, c *Clickable, txt string, tip string) ActionButton {
|
||||
return ActionButton{
|
||||
Action: act,
|
||||
DisabledStyle: &th.Button.Disabled,
|
||||
Button: Btn(th, &th.Button.Text, c, txt, tip),
|
||||
}
|
||||
}
|
||||
|
||||
func Tooltip(th *material.Theme, tip string) component.Tooltip {
|
||||
tooltip := component.PlatformTooltip(th, tip)
|
||||
tooltip.Bg = black
|
||||
return tooltip
|
||||
}
|
||||
|
||||
func ActionIcon(gtx C, th *material.Theme, w *ActionClickable, icon []byte, tip string) TipIconButtonStyle {
|
||||
ret := TipIcon(th, &w.TipClickable, icon, tip)
|
||||
for w.Clickable.Clicked(gtx) {
|
||||
w.Action.Do()
|
||||
}
|
||||
if !w.Action.Allowed() {
|
||||
ret.IconButtonStyle.Color = disabledTextColor
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func TipIcon(th *material.Theme, w *TipClickable, icon []byte, tip string) TipIconButtonStyle {
|
||||
iconButtonStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "")
|
||||
iconButtonStyle.Color = primaryColor
|
||||
iconButtonStyle.Background = transparent
|
||||
iconButtonStyle.Inset = layout.UniformInset(unit.Dp(6))
|
||||
return TipIconButtonStyle{
|
||||
TipArea: &w.TipArea,
|
||||
IconButtonStyle: iconButtonStyle,
|
||||
Tooltip: Tooltip(th, tip),
|
||||
func ToggleBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string) ToggleButton {
|
||||
return ToggleButton{
|
||||
Bool: b,
|
||||
DisabledStyle: &th.Button.Disabled,
|
||||
OffStyle: &th.Button.Text,
|
||||
Button: Btn(th, &th.Button.Filled, c, text, tip),
|
||||
}
|
||||
}
|
||||
|
||||
func ToggleIcon(gtx C, th *material.Theme, w *BoolClickable, offIcon, onIcon []byte, offTip, onTip string) TipIconButtonStyle {
|
||||
icon := offIcon
|
||||
tip := offTip
|
||||
if w.Bool.Value() {
|
||||
icon = onIcon
|
||||
tip = onTip
|
||||
}
|
||||
for w.Clickable.Clicked(gtx) {
|
||||
w.Bool.Toggle()
|
||||
}
|
||||
ibStyle := material.IconButton(th, &w.Clickable, widgetForIcon(icon), "")
|
||||
ibStyle.Background = transparent
|
||||
ibStyle.Inset = layout.UniformInset(unit.Dp(6))
|
||||
ibStyle.Color = primaryColor
|
||||
if !w.Bool.Enabled() {
|
||||
ibStyle.Color = disabledTextColor
|
||||
}
|
||||
return TipIconButtonStyle{
|
||||
TipArea: &w.TipArea,
|
||||
IconButtonStyle: ibStyle,
|
||||
Tooltip: Tooltip(th, tip),
|
||||
func TabBtn(b tracker.Bool, th *Theme, c *Clickable, text string, tip string) TabButton {
|
||||
return TabButton{
|
||||
IndicatorHeight: th.Button.Tab.IndicatorHeight,
|
||||
IndicatorColor: th.Button.Tab.IndicatorColor,
|
||||
ToggleButton: ToggleButton{
|
||||
Bool: b,
|
||||
DisabledStyle: &th.Button.Disabled,
|
||||
OffStyle: &th.Button.Tab.Inactive,
|
||||
Button: Btn(th, &th.Button.Tab.Active, c, text, tip),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TipIconButtonStyle) Layout(gtx C) D {
|
||||
return t.TipArea.Layout(gtx, t.Tooltip, t.IconButtonStyle.Layout)
|
||||
func IconBtn(th *Theme, st *IconButtonStyle, c *Clickable, icon []byte, tip string) IconButton {
|
||||
return IconButton{
|
||||
Theme: th,
|
||||
Style: st,
|
||||
Clickable: c,
|
||||
Icon: th.Icon(icon),
|
||||
Tip: tip,
|
||||
}
|
||||
}
|
||||
|
||||
func ActionButton(gtx C, th *material.Theme, w *ActionClickable, text string) material.ButtonStyle {
|
||||
for w.Clickable.Clicked(gtx) {
|
||||
w.Action.Do()
|
||||
func ActionIconBtn(act tracker.Action, th *Theme, c *Clickable, icon []byte, tip string) ActionIconButton {
|
||||
return ActionIconButton{
|
||||
Action: act,
|
||||
DisabledStyle: &th.IconButton.Disabled,
|
||||
IconButton: IconBtn(th, &th.IconButton.Enabled, c, icon, tip),
|
||||
}
|
||||
ret := material.Button(th, &w.Clickable, text)
|
||||
ret.Color = th.Palette.Fg
|
||||
if !w.Action.Allowed() {
|
||||
ret.Color = disabledTextColor
|
||||
}
|
||||
ret.Background = transparent
|
||||
ret.Inset = layout.UniformInset(unit.Dp(6))
|
||||
return ret
|
||||
}
|
||||
|
||||
func ToggleButton(gtx C, th *material.Theme, b *BoolClickable, text string) material.ButtonStyle {
|
||||
func ToggleIconBtn(b tracker.Bool, th *Theme, c *Clickable, offIcon, onIcon []byte, offTip, onTip string) ToggleIconButton {
|
||||
return ToggleIconButton{
|
||||
Bool: b,
|
||||
DisabledStyle: &th.IconButton.Disabled,
|
||||
OffIcon: th.Icon(offIcon),
|
||||
OffTip: offTip,
|
||||
IconButton: IconBtn(th, &th.IconButton.Enabled, c, onIcon, onTip),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Button) Layout(gtx C) D {
|
||||
if b.Tip != "" {
|
||||
return b.Clickable.TipArea.Layout(gtx, Tooltip(b.Theme, b.Tip), b.actualLayout)
|
||||
}
|
||||
return b.actualLayout(gtx)
|
||||
}
|
||||
|
||||
func (b *Button) actualLayout(gtx C) D {
|
||||
min := gtx.Constraints.Min
|
||||
min.Y = gtx.Dp(b.Style.Height)
|
||||
return b.Clickable.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rr := gtx.Dp(b.Style.CornerRadius)
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
|
||||
background := b.Style.Background
|
||||
switch {
|
||||
case b.Clickable.Hovered():
|
||||
background = hoveredColor(background)
|
||||
}
|
||||
paint.Fill(gtx.Ops, background)
|
||||
for _, c := range b.Clickable.History() {
|
||||
drawInk(gtx, (widget.Press)(c))
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min = min
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return b.Style.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
colMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: b.Style.Color}.Add(gtx.Ops)
|
||||
return widget.Label{Alignment: text.Middle}.Layout(gtx, b.Theme.Material.Shaper, b.Style.Font, b.Style.TextSize, b.Text, colMacro.Stop())
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *ActionButton) Layout(gtx C) D {
|
||||
for b.Clickable.Clicked(gtx) {
|
||||
b.Action.Do()
|
||||
}
|
||||
if !b.Action.Enabled() {
|
||||
b.Style = b.DisabledStyle
|
||||
}
|
||||
return b.Button.Layout(gtx)
|
||||
}
|
||||
|
||||
func (b *ToggleButton) Layout(gtx C) D {
|
||||
for b.Clickable.Clicked(gtx) {
|
||||
b.Bool.Toggle()
|
||||
}
|
||||
ret := material.Button(th, &b.Clickable, text)
|
||||
ret.Background = transparent
|
||||
ret.Inset = layout.UniformInset(unit.Dp(6))
|
||||
if b.Bool.Value() {
|
||||
ret.Color = th.Palette.ContrastFg
|
||||
ret.Background = th.Palette.Fg
|
||||
} else {
|
||||
ret.Color = th.Palette.Fg
|
||||
ret.Background = transparent
|
||||
if !b.Bool.Enabled() {
|
||||
b.Style = b.DisabledStyle
|
||||
} else if !b.Bool.Value() {
|
||||
b.Style = b.OffStyle
|
||||
}
|
||||
return ret
|
||||
return b.Button.Layout(gtx)
|
||||
}
|
||||
|
||||
func LowEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {
|
||||
ret := material.Button(th, w, text)
|
||||
ret.Color = th.Palette.Fg
|
||||
ret.Background = transparent
|
||||
ret.Inset = layout.UniformInset(unit.Dp(6))
|
||||
return ret
|
||||
func (b *IconButton) Layout(gtx C) D {
|
||||
if b.Tip != "" {
|
||||
return b.Clickable.TipArea.Layout(gtx, Tooltip(b.Theme, b.Tip), b.actualLayout)
|
||||
}
|
||||
return b.actualLayout(gtx)
|
||||
}
|
||||
|
||||
func HighEmphasisButton(th *material.Theme, w *widget.Clickable, text string) material.ButtonStyle {
|
||||
ret := material.Button(th, w, text)
|
||||
ret.Color = th.Palette.ContrastFg
|
||||
ret.Background = th.Palette.Fg
|
||||
ret.Inset = layout.UniformInset(unit.Dp(6))
|
||||
return ret
|
||||
func (b *IconButton) actualLayout(gtx C) D {
|
||||
m := op.Record(gtx.Ops)
|
||||
dims := b.Clickable.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rr := (gtx.Constraints.Min.X + gtx.Constraints.Min.Y) / 4
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
|
||||
background := b.Style.Background
|
||||
switch {
|
||||
case b.Clickable.Hovered():
|
||||
background = hoveredColor(background)
|
||||
}
|
||||
paint.Fill(gtx.Ops, background)
|
||||
for _, c := range b.Clickable.History() {
|
||||
drawInk(gtx, (widget.Press)(c))
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return b.Style.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(b.Style.Size)
|
||||
if b.Icon != nil {
|
||||
gtx.Constraints.Min = image.Point{X: size}
|
||||
b.Icon.Layout(gtx, b.Style.Color)
|
||||
}
|
||||
return layout.Dimensions{
|
||||
Size: image.Point{X: size, Y: size},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
c := m.Stop()
|
||||
bounds := image.Rectangle{Max: dims.Size}
|
||||
defer clip.Ellipse(bounds).Push(gtx.Ops).Pop()
|
||||
c.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
func (b *ActionIconButton) Layout(gtx C) D {
|
||||
for b.Clickable.Clicked(gtx) {
|
||||
b.Action.Do()
|
||||
}
|
||||
if !b.Action.Enabled() {
|
||||
b.Style = b.DisabledStyle
|
||||
}
|
||||
return b.IconButton.Layout(gtx)
|
||||
}
|
||||
|
||||
func (b *ToggleIconButton) Layout(gtx C) D {
|
||||
for b.Clickable.Clicked(gtx) {
|
||||
b.Bool.Toggle()
|
||||
}
|
||||
if !b.Bool.Enabled() {
|
||||
b.Style = b.DisabledStyle
|
||||
}
|
||||
if !b.Bool.Value() {
|
||||
b.Icon = b.OffIcon
|
||||
b.Tip = b.OffTip
|
||||
}
|
||||
return b.IconButton.Layout(gtx)
|
||||
}
|
||||
|
||||
func (b *TabButton) Layout(gtx C) D {
|
||||
return layout.Stack{Alignment: layout.S}.Layout(gtx,
|
||||
layout.Stacked(b.ToggleButton.Layout),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
if !b.ToggleButton.Bool.Value() {
|
||||
return D{}
|
||||
}
|
||||
w := gtx.Constraints.Min.X
|
||||
h := gtx.Dp(b.IndicatorHeight)
|
||||
r := clip.RRect{
|
||||
Rect: image.Rect(0, 0, w, h),
|
||||
NE: h, NW: h, SE: 0, SW: 0,
|
||||
}
|
||||
defer r.Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, b.IndicatorColor)
|
||||
return layout.Dimensions{Size: image.Pt(w, h)}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Click executes a simple programmatic click.
|
||||
func (b *Clickable) Click() {
|
||||
b.requestClicks++
|
||||
}
|
||||
|
||||
// Clicked calls Update and reports whether a click was registered.
|
||||
func (b *Clickable) Clicked(gtx layout.Context) bool {
|
||||
return b.clicked(b, gtx)
|
||||
}
|
||||
|
||||
func (b *Clickable) clicked(t event.Tag, gtx layout.Context) bool {
|
||||
_, clicked := b.update(t, gtx)
|
||||
return clicked
|
||||
}
|
||||
|
||||
// Hovered reports whether a pointer is over the element.
|
||||
func (b *Clickable) Hovered() bool {
|
||||
return b.click.Hovered()
|
||||
}
|
||||
|
||||
// Pressed reports whether a pointer is pressing the element.
|
||||
func (b *Clickable) Pressed() bool {
|
||||
return b.click.Pressed()
|
||||
}
|
||||
|
||||
// History is the past pointer presses useful for drawing markers.
|
||||
// History is retained for a short duration (about a second).
|
||||
func (b *Clickable) History() []widget.Press {
|
||||
return b.history
|
||||
}
|
||||
|
||||
// Layout and update the button state.
|
||||
func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
return b.layout(b, gtx, w)
|
||||
}
|
||||
|
||||
func (b *Clickable) layout(t event.Tag, gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
for {
|
||||
_, ok := b.update(t, gtx)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
m := op.Record(gtx.Ops)
|
||||
dims := w(gtx)
|
||||
c := m.Stop()
|
||||
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
|
||||
semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops)
|
||||
b.click.Add(gtx.Ops)
|
||||
event.Op(gtx.Ops, t)
|
||||
c.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
// Update the button state by processing events, and return the next
|
||||
// click, if any.
|
||||
func (b *Clickable) Update(gtx layout.Context) (widget.Click, bool) {
|
||||
return b.update(b, gtx)
|
||||
}
|
||||
|
||||
func (b *Clickable) update(_ event.Tag, gtx layout.Context) (widget.Click, bool) {
|
||||
for len(b.history) > 0 {
|
||||
c := b.history[0]
|
||||
if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second {
|
||||
break
|
||||
}
|
||||
n := copy(b.history, b.history[1:])
|
||||
b.history = b.history[:n]
|
||||
}
|
||||
if c := b.requestClicks; c > 0 {
|
||||
b.requestClicks = 0
|
||||
return widget.Click{
|
||||
NumClicks: c,
|
||||
}, true
|
||||
}
|
||||
for {
|
||||
e, ok := b.click.Update(gtx.Source)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e.Kind {
|
||||
case gesture.KindClick:
|
||||
if l := len(b.history); l > 0 {
|
||||
b.history[l-1].End = gtx.Now
|
||||
}
|
||||
return widget.Click{
|
||||
Modifiers: e.Modifiers,
|
||||
NumClicks: e.NumClicks,
|
||||
}, true
|
||||
case gesture.KindCancel:
|
||||
for i := range b.history {
|
||||
b.history[i].Cancelled = true
|
||||
if b.history[i].End.IsZero() {
|
||||
b.history[i].End = gtx.Now
|
||||
}
|
||||
}
|
||||
case gesture.KindPress:
|
||||
b.history = append(b.history, widget.Press{
|
||||
Position: e.Position,
|
||||
Start: gtx.Now,
|
||||
})
|
||||
}
|
||||
}
|
||||
return widget.Click{}, false
|
||||
}
|
||||
|
||||
func drawInk(gtx layout.Context, c widget.Press) {
|
||||
// duration is the number of seconds for the
|
||||
// completed animation: expand while fading in, then
|
||||
// out.
|
||||
const (
|
||||
expandDuration = float32(0.5)
|
||||
fadeDuration = float32(0.9)
|
||||
)
|
||||
|
||||
now := gtx.Now
|
||||
|
||||
t := float32(now.Sub(c.Start).Seconds())
|
||||
|
||||
end := c.End
|
||||
if end.IsZero() {
|
||||
// If the press hasn't ended, don't fade-out.
|
||||
end = now
|
||||
}
|
||||
|
||||
endt := float32(end.Sub(c.Start).Seconds())
|
||||
|
||||
// Compute the fade-in/out position in [0;1].
|
||||
var alphat float32
|
||||
{
|
||||
var haste float32
|
||||
if c.Cancelled {
|
||||
// If the press was cancelled before the inkwell
|
||||
// was fully faded in, fast forward the animation
|
||||
// to match the fade-out.
|
||||
if h := 0.5 - endt/fadeDuration; h > 0 {
|
||||
haste = h
|
||||
}
|
||||
}
|
||||
// Fade in.
|
||||
half1 := t/fadeDuration + haste
|
||||
if half1 > 0.5 {
|
||||
half1 = 0.5
|
||||
}
|
||||
|
||||
// Fade out.
|
||||
half2 := float32(now.Sub(end).Seconds())
|
||||
half2 /= fadeDuration
|
||||
half2 += haste
|
||||
if half2 > 0.5 {
|
||||
// Too old.
|
||||
return
|
||||
}
|
||||
|
||||
alphat = half1 + half2
|
||||
}
|
||||
|
||||
// Compute the expand position in [0;1].
|
||||
sizet := t
|
||||
if c.Cancelled {
|
||||
// Freeze expansion of cancelled presses.
|
||||
sizet = endt
|
||||
}
|
||||
sizet /= expandDuration
|
||||
|
||||
// Animate only ended presses, and presses that are fading in.
|
||||
if !c.End.IsZero() || sizet <= 1.0 {
|
||||
gtx.Execute(op.InvalidateCmd{})
|
||||
}
|
||||
|
||||
if sizet > 1.0 {
|
||||
sizet = 1.0
|
||||
}
|
||||
|
||||
if alphat > .5 {
|
||||
// Start fadeout after half the animation.
|
||||
alphat = 1.0 - alphat
|
||||
}
|
||||
// Twice the speed to attain fully faded in at 0.5.
|
||||
t2 := alphat * 2
|
||||
// Beziér ease-in curve.
|
||||
alphaBezier := t2 * t2 * (3.0 - 2.0*t2)
|
||||
sizeBezier := sizet * sizet * (3.0 - 2.0*sizet)
|
||||
size := gtx.Constraints.Min.X
|
||||
if h := gtx.Constraints.Min.Y; h > size {
|
||||
size = h
|
||||
}
|
||||
// Cover the entire constraints min rectangle and
|
||||
// apply curve values to size and color.
|
||||
size = int(float32(size) * 2 * float32(math.Sqrt(2)) * sizeBezier)
|
||||
alpha := 0.7 * alphaBezier
|
||||
const col = 0.8
|
||||
ba, bc := byte(alpha*0xff), byte(col*0xff)
|
||||
rgba := color.NRGBA{A: 0xff, R: bc, G: bc, B: bc}
|
||||
rgba.A = uint8(uint32(rgba.A) * uint32(ba) / 0xFF)
|
||||
ink := paint.ColorOp{Color: rgba}
|
||||
ink.Add(gtx.Ops)
|
||||
rr := size / 2
|
||||
defer op.Offset(c.Position.Add(image.Point{
|
||||
X: -rr,
|
||||
Y: -rr,
|
||||
})).Push(gtx.Ops).Pop()
|
||||
defer clip.UniformRRect(image.Rectangle{Max: image.Pt(size, size)}, rr).Push(gtx.Ops).Pop()
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
|
||||
func hoveredColor(c color.NRGBA) (h color.NRGBA) {
|
||||
if c.A == 0 {
|
||||
// Provide a reasonable default for transparent widgets.
|
||||
return color.NRGBA{A: 0x44, R: 0x88, G: 0x88, B: 0x88}
|
||||
}
|
||||
const ratio = 0x20
|
||||
m := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: c.A}
|
||||
if int(c.R)+int(c.G)+int(c.B) > 384 {
|
||||
m = color.NRGBA{A: c.A}
|
||||
}
|
||||
return mix(m, c, ratio)
|
||||
}
|
||||
|
||||
// mix mixes c1 and c2 weighted by (1 - a/256) and a/256 respectively.
|
||||
func mix(c1, c2 color.NRGBA, a uint8) color.NRGBA {
|
||||
ai := int(a)
|
||||
return color.NRGBA{
|
||||
R: byte((int(c1.R)*ai + int(c2.R)*(256-ai)) / 256),
|
||||
G: byte((int(c1.G)*ai + int(c2.G)*(256-ai)) / 256),
|
||||
B: byte((int(c1.B)*ai + int(c2.B)*(256-ai)) / 256),
|
||||
A: byte((int(c1.A)*ai + int(c2.A)*(256-ai)) / 256),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,68 +1,147 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"gioui.org/io/event"
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type Dialog struct {
|
||||
BtnAlt *ActionClickable
|
||||
BtnOk *ActionClickable
|
||||
BtnCancel *ActionClickable
|
||||
tag bool
|
||||
keyFilters []event.Filter
|
||||
}
|
||||
const DIALOG_MAX_BTNS = 3
|
||||
|
||||
type DialogStyle struct {
|
||||
dialog *Dialog
|
||||
Title string
|
||||
Text string
|
||||
Inset layout.Inset
|
||||
TextInset layout.Inset
|
||||
AltStyle material.ButtonStyle
|
||||
OkStyle material.ButtonStyle
|
||||
CancelStyle material.ButtonStyle
|
||||
Shaper *text.Shaper
|
||||
}
|
||||
type (
|
||||
// DialogState is the state that needs to be retained between frames
|
||||
DialogState struct {
|
||||
Clickables [DIALOG_MAX_BTNS]widget.Clickable
|
||||
|
||||
func NewDialog(ok, alt, cancel tracker.Action) *Dialog {
|
||||
ret := &Dialog{
|
||||
BtnOk: NewActionClickable(ok),
|
||||
BtnAlt: NewActionClickable(alt),
|
||||
BtnCancel: NewActionClickable(cancel),
|
||||
visible bool // this is used to control the visibility of the dialog
|
||||
}
|
||||
|
||||
// DialogStyle is the style for a dialog that is store in the theme.yml
|
||||
DialogStyle struct {
|
||||
TitleInset layout.Inset
|
||||
TextInset layout.Inset
|
||||
ButtonStyle ButtonStyle
|
||||
Title LabelStyle
|
||||
Text LabelStyle
|
||||
Bg color.NRGBA
|
||||
Buttons ButtonStyle
|
||||
}
|
||||
|
||||
// Dialog is the widget with a Layout method that can be used to display a dialog.
|
||||
Dialog struct {
|
||||
Theme *Theme
|
||||
State *DialogState
|
||||
Style *DialogStyle
|
||||
Btns [DIALOG_MAX_BTNS]DialogButton
|
||||
NumBtns int
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
DialogButton struct {
|
||||
Text string
|
||||
Action tracker.Action
|
||||
}
|
||||
)
|
||||
|
||||
func MakeDialog(th *Theme, d *DialogState, title, text string, btns ...DialogButton) Dialog {
|
||||
ret := Dialog{
|
||||
Theme: th,
|
||||
Style: &th.Dialog,
|
||||
State: d,
|
||||
Title: title,
|
||||
Text: text,
|
||||
}
|
||||
if len(btns) > DIALOG_MAX_BTNS {
|
||||
panic(fmt.Sprintf("too many buttons for dialog: %d, max is %d", len(btns), DIALOG_MAX_BTNS))
|
||||
}
|
||||
copy(ret.Btns[:], btns)
|
||||
ret.NumBtns = len(btns)
|
||||
d.visible = true
|
||||
return ret
|
||||
}
|
||||
|
||||
func ConfirmDialog(gtx C, th *material.Theme, dialog *Dialog, title, text string) DialogStyle {
|
||||
ret := DialogStyle{
|
||||
dialog: dialog,
|
||||
Title: title,
|
||||
Text: text,
|
||||
Inset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12), Left: unit.Dp(20), Right: unit.Dp(20)},
|
||||
TextInset: layout.Inset{Top: unit.Dp(12), Bottom: unit.Dp(12)},
|
||||
AltStyle: ActionButton(gtx, th, dialog.BtnAlt, "Alt"),
|
||||
OkStyle: ActionButton(gtx, th, dialog.BtnOk, "Ok"),
|
||||
CancelStyle: ActionButton(gtx, th, dialog.BtnCancel, "Cancel"),
|
||||
Shaper: th.Shaper,
|
||||
}
|
||||
return ret
|
||||
func DialogBtn(text string, action tracker.Action) DialogButton {
|
||||
return DialogButton{Text: text, Action: action}
|
||||
}
|
||||
|
||||
func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *ActionClickable) {
|
||||
func (d *Dialog) Layout(gtx C) D {
|
||||
anyFocused := false
|
||||
for i := 0; i < d.NumBtns; i++ {
|
||||
anyFocused = anyFocused || gtx.Focused(&d.State.Clickables[i])
|
||||
}
|
||||
if !anyFocused {
|
||||
gtx.Execute(key.FocusCmd{Tag: &d.State.Clickables[d.NumBtns-1]})
|
||||
}
|
||||
d.handleKeys(gtx)
|
||||
paint.Fill(gtx.Ops, d.Style.Bg)
|
||||
dims := layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return Popup(d.Theme, &d.State.visible).Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return d.Style.TitleInset.Layout(gtx, Label(d.Theme, &d.Style.Title, d.Title).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return d.Style.TextInset.Layout(gtx, Label(d.Theme, &d.Style.Text, d.Text).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.E.Layout(gtx, func(gtx C) D {
|
||||
var fcs [DIALOG_MAX_BTNS]layout.FlexChild
|
||||
var actBtns [DIALOG_MAX_BTNS]material.ButtonStyle
|
||||
for i := 0; i < d.NumBtns; i++ {
|
||||
actBtns[i] = material.Button(&d.Theme.Material, &d.State.Clickables[i], d.Btns[i].Text)
|
||||
actBtns[i].Background = d.Style.Buttons.Background
|
||||
actBtns[i].Color = d.Style.Buttons.Color
|
||||
actBtns[i].TextSize = d.Style.Buttons.TextSize
|
||||
actBtns[i].Font = d.Style.Buttons.Font
|
||||
actBtns[i].Inset = d.Style.Buttons.Inset
|
||||
actBtns[i].CornerRadius = d.Style.Buttons.CornerRadius
|
||||
}
|
||||
// putting this inside these inside the for loop
|
||||
// cause heap escapes, so that's why this ugliness;
|
||||
// remember to update if you change the
|
||||
// DIAOLG_MAX_BTNS constant
|
||||
fcs[0] = layout.Rigid(actBtns[0].Layout)
|
||||
fcs[1] = layout.Rigid(actBtns[1].Layout)
|
||||
fcs[2] = layout.Rigid(actBtns[2].Layout)
|
||||
gtx.Constraints.Min.Y = gtx.Dp(d.Style.Buttons.Height)
|
||||
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx, fcs[:d.NumBtns]...)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
if !d.State.visible {
|
||||
d.Btns[d.NumBtns-1].Action.Do()
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func (d *Dialog) handleKeys(gtx C) {
|
||||
for i := 0; i < d.NumBtns; i++ {
|
||||
for d.State.Clickables[i].Clicked(gtx) {
|
||||
d.Btns[i].Action.Do()
|
||||
}
|
||||
d.handleKeysForButton(gtx, (i+d.NumBtns-1)%d.NumBtns, i, (i+1)%d.NumBtns)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dialog) handleKeysForButton(gtx C, prev, cur, next int) {
|
||||
cPrev := &d.State.Clickables[prev]
|
||||
cCur := &d.State.Clickables[cur]
|
||||
cNext := &d.State.Clickables[next]
|
||||
for {
|
||||
e, ok := gtx.Event(
|
||||
key.Filter{Focus: &btn.Clickable, Name: key.NameLeftArrow},
|
||||
key.Filter{Focus: &btn.Clickable, Name: key.NameRightArrow},
|
||||
key.Filter{Focus: &btn.Clickable, Name: key.NameEscape},
|
||||
key.Filter{Focus: &btn.Clickable, Name: key.NameTab, Optional: key.ModShift},
|
||||
key.Filter{Focus: cCur, Name: key.NameLeftArrow},
|
||||
key.Filter{Focus: cCur, Name: key.NameRightArrow},
|
||||
key.Filter{Focus: cCur, Name: key.NameEscape},
|
||||
key.Filter{Focus: cCur, Name: key.NameTab, Optional: key.ModShift},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
@ -70,61 +149,12 @@ func (d *Dialog) handleKeysForButton(gtx C, btn, next, prev *ActionClickable) {
|
||||
if e, ok := e.(key.Event); ok && e.State == key.Press {
|
||||
switch {
|
||||
case e.Name == key.NameLeftArrow || (e.Name == key.NameTab && e.Modifiers.Contain(key.ModShift)):
|
||||
gtx.Execute(key.FocusCmd{Tag: &prev.Clickable})
|
||||
gtx.Execute(key.FocusCmd{Tag: cPrev})
|
||||
case e.Name == key.NameRightArrow || (e.Name == key.NameTab && !e.Modifiers.Contain(key.ModShift)):
|
||||
gtx.Execute(key.FocusCmd{Tag: &next.Clickable})
|
||||
gtx.Execute(key.FocusCmd{Tag: cNext})
|
||||
case e.Name == key.NameEscape:
|
||||
d.BtnCancel.Action.Do()
|
||||
d.Btns[d.NumBtns-1].Action.Do() // last button is always the cancel button
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dialog) handleKeys(gtx C) {
|
||||
if d.BtnAlt.Action.Allowed() {
|
||||
d.handleKeysForButton(gtx, d.BtnAlt, d.BtnCancel, d.BtnOk)
|
||||
d.handleKeysForButton(gtx, d.BtnCancel, d.BtnOk, d.BtnAlt)
|
||||
d.handleKeysForButton(gtx, d.BtnOk, d.BtnAlt, d.BtnCancel)
|
||||
} else {
|
||||
d.handleKeysForButton(gtx, d.BtnOk, d.BtnCancel, d.BtnCancel)
|
||||
d.handleKeysForButton(gtx, d.BtnCancel, d.BtnOk, d.BtnOk)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DialogStyle) Layout(gtx C) D {
|
||||
if !gtx.Source.Focused(&d.dialog.BtnOk.Clickable) && !gtx.Source.Focused(&d.dialog.BtnCancel.Clickable) && !gtx.Source.Focused(&d.dialog.BtnAlt.Clickable) {
|
||||
gtx.Execute(key.FocusCmd{Tag: &d.dialog.BtnCancel.Clickable})
|
||||
}
|
||||
d.dialog.handleKeys(gtx)
|
||||
paint.Fill(gtx.Ops, dialogBgColor)
|
||||
text := func(gtx C) D {
|
||||
return d.TextInset.Layout(gtx, LabelStyle{Text: d.Text, Color: highEmphasisTextColor, Font: labelDefaultFont, FontSize: unit.Sp(14), Shaper: d.Shaper}.Layout)
|
||||
}
|
||||
visible := true
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return Popup(&visible).Layout(gtx, func(gtx C) D {
|
||||
return d.Inset.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(Label(d.Title, highEmphasisTextColor, d.Shaper)),
|
||||
layout.Rigid(text),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.E.Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(120))
|
||||
if d.dialog.BtnAlt.Action.Allowed() {
|
||||
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
|
||||
layout.Rigid(d.OkStyle.Layout),
|
||||
layout.Rigid(d.AltStyle.Layout),
|
||||
layout.Rigid(d.CancelStyle.Layout),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
|
||||
layout.Rigid(d.OkStyle.Layout),
|
||||
layout.Rigid(d.CancelStyle.Layout),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -15,8 +15,6 @@ import (
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
@ -29,32 +27,28 @@ type DragList struct {
|
||||
dragID pointer.ID
|
||||
tags []bool
|
||||
swapped bool
|
||||
focused bool
|
||||
requestFocus bool
|
||||
}
|
||||
|
||||
type FilledDragListStyle struct {
|
||||
dragList *DragList
|
||||
HoverColor color.NRGBA
|
||||
SelectedColor color.NRGBA
|
||||
CursorColor color.NRGBA
|
||||
ScrollBarWidth unit.Dp
|
||||
element, bg func(gtx C, i int) D
|
||||
dragList *DragList
|
||||
HoverColor color.NRGBA
|
||||
Cursor CursorStyle
|
||||
Selection CursorStyle
|
||||
ScrollBar ScrollBarStyle
|
||||
}
|
||||
|
||||
func NewDragList(model tracker.List, axis layout.Axis) *DragList {
|
||||
return &DragList{TrackerList: model, List: &layout.List{Axis: axis}, HoverItem: -1, ScrollBar: &ScrollBar{Axis: axis}}
|
||||
}
|
||||
|
||||
func FilledDragList(th *material.Theme, dragList *DragList, element, bg func(gtx C, i int) D) FilledDragListStyle {
|
||||
func FilledDragList(th *Theme, dragList *DragList) FilledDragListStyle {
|
||||
return FilledDragListStyle{
|
||||
dragList: dragList,
|
||||
element: element,
|
||||
bg: bg,
|
||||
HoverColor: dragListHoverColor,
|
||||
SelectedColor: dragListSelectedColor,
|
||||
CursorColor: cursorColor,
|
||||
ScrollBarWidth: unit.Dp(10),
|
||||
dragList: dragList,
|
||||
HoverColor: hoveredColor(th.Selection.Active),
|
||||
Cursor: th.Cursor,
|
||||
Selection: th.Selection,
|
||||
ScrollBar: th.ScrollBar,
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,15 +56,11 @@ func (d *DragList) Focus() {
|
||||
d.requestFocus = true
|
||||
}
|
||||
|
||||
func (d *DragList) Focused() bool {
|
||||
return d.focused
|
||||
}
|
||||
|
||||
func (s FilledDragListStyle) LayoutScrollBar(gtx C) D {
|
||||
return s.dragList.ScrollBar.Layout(gtx, s.ScrollBarWidth, s.dragList.TrackerList.Count(), &s.dragList.List.Position)
|
||||
return s.dragList.ScrollBar.Layout(gtx, &s.ScrollBar, s.dragList.TrackerList.Count(), &s.dragList.List.Position)
|
||||
}
|
||||
|
||||
func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
func (s FilledDragListStyle) Layout(gtx C, element, bg func(gtx C, i int) D) D {
|
||||
swap := 0
|
||||
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
@ -119,12 +109,13 @@ func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
}
|
||||
switch ke := event.(type) {
|
||||
case key.FocusEvent:
|
||||
s.dragList.focused = ke.Focus
|
||||
if !s.dragList.focused {
|
||||
if !ke.Focus {
|
||||
s.dragList.TrackerList.SetSelected2(s.dragList.TrackerList.Selected())
|
||||
} else {
|
||||
s.dragList.EnsureVisible(s.dragList.TrackerList.Selected())
|
||||
}
|
||||
case key.Event:
|
||||
if !s.dragList.focused || ke.State != key.Press {
|
||||
if ke.State != key.Press {
|
||||
break
|
||||
}
|
||||
s.dragList.command(gtx, ke)
|
||||
@ -137,7 +128,7 @@ func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
gtx.Execute(op.InvalidateCmd{})
|
||||
}
|
||||
|
||||
_, isMutable := s.dragList.TrackerList.ListData.(tracker.MutableListData)
|
||||
isMutable := s.dragList.TrackerList.Mutable()
|
||||
|
||||
listElem := func(gtx C, index int) D {
|
||||
for len(s.dragList.tags) <= index {
|
||||
@ -146,13 +137,17 @@ func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
cursorBg := func(gtx C) D {
|
||||
var color color.NRGBA
|
||||
if s.dragList.TrackerList.Selected() == index {
|
||||
if s.dragList.focused {
|
||||
color = s.CursorColor
|
||||
if gtx.Focused(s.dragList) {
|
||||
color = s.Cursor.Active
|
||||
} else {
|
||||
color = s.SelectedColor
|
||||
color = s.Cursor.Inactive
|
||||
}
|
||||
} else if between(s.dragList.TrackerList.Selected(), index, s.dragList.TrackerList.Selected2()) {
|
||||
color = s.SelectedColor
|
||||
if gtx.Focused(s.dragList) {
|
||||
color = s.Selection.Active
|
||||
} else {
|
||||
color = s.Selection.Inactive
|
||||
}
|
||||
} else if s.dragList.HoverItem == index {
|
||||
color = s.HoverColor
|
||||
}
|
||||
@ -194,7 +189,7 @@ func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
area.Pop()
|
||||
if index == s.dragList.TrackerList.Selected() && isMutable {
|
||||
for {
|
||||
target := &s.dragList.focused
|
||||
target := &s.dragList.drag
|
||||
if s.dragList.drag {
|
||||
target = nil
|
||||
}
|
||||
@ -234,18 +229,18 @@ func (s FilledDragListStyle) Layout(gtx C) D {
|
||||
}
|
||||
}
|
||||
area := clip.Rect(rect).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, &s.dragList.focused)
|
||||
event.Op(gtx.Ops, &s.dragList.drag)
|
||||
pointer.CursorGrab.Add(gtx.Ops)
|
||||
area.Pop()
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := s.element(gtx, index)
|
||||
dims := element(gtx, index)
|
||||
call := macro.Stop()
|
||||
gtx.Constraints.Min = dims.Size
|
||||
if s.bg != nil {
|
||||
s.bg(gtx, index)
|
||||
if bg != nil {
|
||||
bg(gtx, index)
|
||||
}
|
||||
cursorBg(gtx)
|
||||
call.Add(gtx.Ops)
|
||||
@ -357,17 +352,3 @@ func (l *DragList) CenterOn(item int) {
|
||||
func between(a, b, c int) bool {
|
||||
return (a <= b && b <= c) || (c <= b && b <= a)
|
||||
}
|
||||
|
||||
func intMax(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func intMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
114
tracker/gioui/editor.go
Normal file
114
tracker/gioui/editor.go
Normal file
@ -0,0 +1,114 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
// Editor wraps a widget.Editor and adds some additional key event filters,
|
||||
// to prevent key presses from flowing through to the rest of the
|
||||
// application while editing (particularly: to prevent triggering notes
|
||||
// while editing).
|
||||
Editor struct {
|
||||
widgetEditor widget.Editor
|
||||
filters []event.Filter
|
||||
requestFocus bool
|
||||
}
|
||||
|
||||
EditorStyle struct {
|
||||
Color color.NRGBA
|
||||
HintColor color.NRGBA
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
}
|
||||
|
||||
EditorEvent int
|
||||
)
|
||||
|
||||
const (
|
||||
EditorEventNone EditorEvent = iota
|
||||
EditorEventSubmit
|
||||
EditorEventCancel
|
||||
)
|
||||
|
||||
func NewEditor(singleLine, submit bool, alignment text.Alignment) *Editor {
|
||||
ret := &Editor{widgetEditor: widget.Editor{SingleLine: singleLine, Submit: submit, Alignment: alignment}}
|
||||
for c := 'A'; c <= 'Z'; c++ {
|
||||
ret.filters = append(ret.filters, key.Filter{Name: key.Name(c), Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
|
||||
}
|
||||
for c := '0'; c <= '9'; c++ {
|
||||
ret.filters = append(ret.filters, key.Filter{Name: key.Name(c), Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
|
||||
}
|
||||
ret.filters = append(ret.filters, key.Filter{Name: key.NameSpace, Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
|
||||
ret.filters = append(ret.filters, key.Filter{Name: key.NameEscape, Focus: &ret.widgetEditor, Optional: key.ModAlt | key.ModShift | key.ModShortcut})
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *EditorStyle) AsLabelStyle() LabelStyle {
|
||||
return LabelStyle{
|
||||
Color: s.Color,
|
||||
Font: s.Font,
|
||||
TextSize: s.TextSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Editor) Layout(gtx C, str tracker.String, th *Theme, style *EditorStyle, hint string) D {
|
||||
for e.Update(gtx, str) != EditorEventNone {
|
||||
// just consume all events if the user did not consume them
|
||||
}
|
||||
if e.widgetEditor.Text() != str.Value() {
|
||||
e.widgetEditor.SetText(str.Value())
|
||||
l := len(e.widgetEditor.Text())
|
||||
e.widgetEditor.SetCaret(l, l)
|
||||
}
|
||||
me := material.Editor(&th.Material, &e.widgetEditor, hint)
|
||||
me.Font = style.Font
|
||||
me.TextSize = style.TextSize
|
||||
me.Color = style.Color
|
||||
me.HintColor = style.HintColor
|
||||
return me.Layout(gtx)
|
||||
}
|
||||
|
||||
func (e *Editor) Update(gtx C, str tracker.String) EditorEvent {
|
||||
if e.requestFocus {
|
||||
e.requestFocus = false
|
||||
gtx.Execute(key.FocusCmd{Tag: &e.widgetEditor})
|
||||
l := len(e.widgetEditor.Text())
|
||||
e.widgetEditor.SetCaret(l, l)
|
||||
}
|
||||
for {
|
||||
ev, ok := e.widgetEditor.Update(gtx)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if _, ok := ev.(widget.ChangeEvent); ok {
|
||||
str.SetValue(e.widgetEditor.Text())
|
||||
}
|
||||
if _, ok := ev.(widget.SubmitEvent); ok {
|
||||
return EditorEventSubmit
|
||||
}
|
||||
}
|
||||
for {
|
||||
event, ok := gtx.Event(e.filters...)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := event.(key.Event); ok && e.State == key.Press && e.Name == key.NameEscape {
|
||||
return EditorEventCancel
|
||||
}
|
||||
}
|
||||
return EditorEventNone
|
||||
}
|
||||
|
||||
func (e *Editor) Focus() {
|
||||
e.requestFocus = true
|
||||
}
|
||||
91
tracker/gioui/focus.go
Normal file
91
tracker/gioui/focus.go
Normal file
@ -0,0 +1,91 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
)
|
||||
|
||||
type TagYieldFunc func(level int, tag event.Tag) bool
|
||||
type Tagged interface {
|
||||
Tags(level int, yield TagYieldFunc) bool
|
||||
}
|
||||
|
||||
// FocusNext navigates to the next focusable tag in the tracker. If stepInto is
|
||||
// true, it will focus the next tag regardless of its depth; otherwise it will
|
||||
// focus the next tag at the current level or shallower.
|
||||
func (t *Tracker) FocusNext(gtx C, stepInto bool) {
|
||||
_, next := t.findPrevNext(gtx, stepInto)
|
||||
if next != nil {
|
||||
gtx.Execute(key.FocusCmd{Tag: next})
|
||||
}
|
||||
}
|
||||
|
||||
// FocusPrev navigates to the previous focusable tag in the tracker. If stepInto
|
||||
// is true, it will focus the previous tag regardless of its depth; otherwise it
|
||||
// will focus the previous tag at the current level or shallower.
|
||||
func (t *Tracker) FocusPrev(gtx C, stepInto bool) {
|
||||
prev, _ := t.findPrevNext(gtx, stepInto)
|
||||
if prev != nil {
|
||||
gtx.Execute(key.FocusCmd{Tag: prev})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) findPrevNext(gtx C, stepInto bool) (prev, next event.Tag) {
|
||||
var first, last event.Tag
|
||||
found := false
|
||||
maxLevel := math.MaxInt
|
||||
if !stepInto {
|
||||
if level, ok := t.findFocusedLevel(gtx); ok {
|
||||
maxLevel = level // limit to the current focused tag's level
|
||||
}
|
||||
}
|
||||
t.Tags(0, func(l int, t event.Tag) bool {
|
||||
if l > maxLevel || t == nil {
|
||||
return true // skip tags that are too deep or nils
|
||||
}
|
||||
if first == nil {
|
||||
first = t
|
||||
}
|
||||
if found && next == nil {
|
||||
next = t
|
||||
}
|
||||
if gtx.Focused(t) {
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
prev = t
|
||||
}
|
||||
last = t
|
||||
return true
|
||||
})
|
||||
if next == nil {
|
||||
next = first
|
||||
}
|
||||
if prev == nil {
|
||||
prev = last
|
||||
}
|
||||
return prev, next
|
||||
}
|
||||
|
||||
func (t *Tracker) findFocusedLevel(gtx C) (level int, ok bool) {
|
||||
t.Tags(0, func(l int, t event.Tag) bool {
|
||||
if gtx.Focused(t) {
|
||||
level = l
|
||||
ok = true
|
||||
return false // stop when we find the focused tag
|
||||
}
|
||||
return true // continue searching
|
||||
})
|
||||
return level, ok
|
||||
}
|
||||
|
||||
func firstTag(t Tagged) (tag event.Tag, ok bool) {
|
||||
t.Tags(0, func(level int, t event.Tag) bool {
|
||||
tag = t
|
||||
ok = true
|
||||
return false
|
||||
})
|
||||
return tag, ok
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
var iconCache = map[*byte]*widget.Icon{}
|
||||
|
||||
// widgetForIcon returns a widget for IconVG data, but caching the results
|
||||
func widgetForIcon(icon []byte) *widget.Icon {
|
||||
if widget, ok := iconCache[&icon[0]]; ok {
|
||||
return widget
|
||||
}
|
||||
widget, err := widget.NewIcon(icon)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
iconCache[&icon[0]] = widget
|
||||
return widget
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
211
tracker/gioui/instrument_presets.go
Normal file
211
tracker/gioui/instrument_presets.go
Normal file
@ -0,0 +1,211 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type (
|
||||
InstrumentPresets struct {
|
||||
searchEditor *Editor
|
||||
gmDlsBtn *Clickable
|
||||
userPresetsBtn *Clickable
|
||||
builtinPresetsBtn *Clickable
|
||||
clearSearchBtn *Clickable
|
||||
saveUserPreset *Clickable
|
||||
deleteUserPreset *Clickable
|
||||
dirList *DragList
|
||||
resultList *DragList
|
||||
}
|
||||
)
|
||||
|
||||
func NewInstrumentPresets(m *tracker.Model) *InstrumentPresets {
|
||||
return &InstrumentPresets{
|
||||
searchEditor: NewEditor(true, true, text.Start),
|
||||
gmDlsBtn: new(Clickable),
|
||||
clearSearchBtn: new(Clickable),
|
||||
userPresetsBtn: new(Clickable),
|
||||
builtinPresetsBtn: new(Clickable),
|
||||
saveUserPreset: new(Clickable),
|
||||
deleteUserPreset: new(Clickable),
|
||||
dirList: NewDragList(m.Preset().DirList(), layout.Vertical),
|
||||
resultList: NewDragList(m.Preset().SearchResultList(), layout.Vertical),
|
||||
}
|
||||
}
|
||||
|
||||
func (ip *InstrumentPresets) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level, &ip.searchEditor.widgetEditor) &&
|
||||
yield(level+1, ip.clearSearchBtn) &&
|
||||
yield(level+1, ip.builtinPresetsBtn) &&
|
||||
yield(level+1, ip.userPresetsBtn) &&
|
||||
yield(level+1, ip.gmDlsBtn) &&
|
||||
yield(level, ip.dirList) &&
|
||||
yield(level, ip.resultList) &&
|
||||
yield(level+1, ip.saveUserPreset) &&
|
||||
yield(level+1, ip.deleteUserPreset)
|
||||
}
|
||||
|
||||
func (ip *InstrumentPresets) update(gtx C) {
|
||||
for {
|
||||
event, ok := gtx.Event(
|
||||
key.Filter{Focus: ip.resultList, Name: key.NameLeftArrow},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := event.(key.Event); ok && e.State == key.Press {
|
||||
switch e.Name {
|
||||
case key.NameLeftArrow:
|
||||
ip.dirList.Focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
event, ok := gtx.Event(
|
||||
key.Filter{Focus: ip.dirList, Name: key.NameRightArrow},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := event.(key.Event); ok && e.State == key.Press {
|
||||
switch e.Name {
|
||||
case key.NameRightArrow:
|
||||
ip.resultList.Focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ip *InstrumentPresets) layout(gtx C) D {
|
||||
ip.update(gtx)
|
||||
// get tracker from values
|
||||
tr := TrackerFromContext(gtx)
|
||||
gmDlsBtn := ToggleBtn(tr.Preset().NoGmDls(), tr.Theme, ip.gmDlsBtn, "No gm.dls", "Exclude presets using gm.dls")
|
||||
userPresetsFilterBtn := ToggleBtn(tr.Preset().UserFilter(), tr.Theme, ip.userPresetsBtn, "User", "Show only user presets")
|
||||
builtinPresetsFilterBtn := ToggleBtn(tr.Preset().BuiltinFilter(), tr.Theme, ip.builtinPresetsBtn, "Builtin", "Show only builtin presets")
|
||||
saveUserPresetBtn := ActionIconBtn(tr.Preset().Save(), tr.Theme, ip.saveUserPreset, icons.ContentSave, "Save instrument as user preset")
|
||||
deleteUserPresetBtn := ActionIconBtn(tr.Preset().Delete(), tr.Theme, ip.deleteUserPreset, icons.ActionDelete, "Delete user preset")
|
||||
dirElem := func(gtx C, i int) D {
|
||||
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Directory, tr.Model.Preset().Dir(i)).Layout(gtx)
|
||||
}
|
||||
dirs := func(gtx C) D {
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(140), gtx.Constraints.Max.Y))
|
||||
fdl := FilledDragList(tr.Theme, ip.dirList)
|
||||
dims := fdl.Layout(gtx, dirElem, nil)
|
||||
fdl.LayoutScrollBar(gtx)
|
||||
return dims
|
||||
}
|
||||
dirSurface := func(gtx C) D {
|
||||
return Surface{Height: 5, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, dirs)
|
||||
}
|
||||
resultElem := func(gtx C, i int) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
n, d, u := tr.Model.Preset().SearchResult(i)
|
||||
if u {
|
||||
ln := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.User, n)
|
||||
ld := Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.UserDir, d)
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(ln.Layout),
|
||||
layout.Rigid(layout.Spacer{Width: 6}.Layout),
|
||||
layout.Rigid(ld.Layout),
|
||||
)
|
||||
}
|
||||
return Label(tr.Theme, &tr.Theme.InstrumentEditor.Presets.Results.Builtin, n).Layout(gtx)
|
||||
}
|
||||
floatButtons := func(gtx C) D {
|
||||
if tr.Model.Preset().Delete().Enabled() {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(deleteUserPresetBtn.Layout),
|
||||
layout.Rigid(saveUserPresetBtn.Layout),
|
||||
layout.Rigid(layout.Spacer{Width: 10}.Layout),
|
||||
)
|
||||
}
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(saveUserPresetBtn.Layout),
|
||||
layout.Rigid(layout.Spacer{Width: 10}.Layout),
|
||||
)
|
||||
}
|
||||
results := func(gtx C) D {
|
||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
||||
fdl := FilledDragList(tr.Theme, ip.resultList)
|
||||
dims := fdl.Layout(gtx, resultElem, nil)
|
||||
layout.SE.Layout(gtx, floatButtons)
|
||||
fdl.LayoutScrollBar(gtx)
|
||||
return dims
|
||||
}
|
||||
resultSurface := func(gtx C) D {
|
||||
return Surface{Height: 4, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, results)
|
||||
}
|
||||
bottom := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(dirSurface),
|
||||
layout.Flexed(1, resultSurface),
|
||||
)
|
||||
}
|
||||
// layout
|
||||
f := func(gtx C) D {
|
||||
m := gtx.Constraints.Max
|
||||
gtx.Constraints.Max.X = min(gtx.Dp(360), gtx.Constraints.Max.X)
|
||||
layout.Flex{Axis: layout.Vertical, Alignment: layout.Start}.Layout(gtx,
|
||||
layout.Rigid(ip.layoutSearch),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(userPresetsFilterBtn.Layout),
|
||||
layout.Rigid(builtinPresetsFilterBtn.Layout),
|
||||
layout.Rigid(gmDlsBtn.Layout),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(bottom),
|
||||
)
|
||||
return D{Size: m}
|
||||
}
|
||||
return Surface{Height: 3, Focus: tr.PatchPanel.TreeFocused(gtx)}.Layout(gtx, f)
|
||||
}
|
||||
|
||||
func (ip *InstrumentPresets) layoutSearch(gtx C) D {
|
||||
// draw search icon on left and clear button on right
|
||||
// return ip.searchEditor.Layout(gtx, tr.Model.PresetSearchString(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Search presets")
|
||||
tr := TrackerFromContext(gtx)
|
||||
bg := func(gtx C) D {
|
||||
rr := gtx.Dp(18)
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, tr.Theme.InstrumentEditor.Presets.SearchBg)
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}
|
||||
// icon, search editor, clear button
|
||||
icon := func(gtx C) D {
|
||||
return tr.Theme.IconButton.Enabled.Inset.Layout(gtx, func(gtx C) D {
|
||||
return tr.Theme.Icon(icons.ActionSearch).Layout(gtx, tr.Theme.Material.Fg)
|
||||
})
|
||||
}
|
||||
ed := func(gtx C) D {
|
||||
return ip.searchEditor.Layout(gtx, tr.Preset().SearchTerm(), tr.Theme, &tr.Theme.InstrumentEditor.UnitComment, "Search presets")
|
||||
}
|
||||
clr := func(gtx C) D {
|
||||
btn := ActionIconBtn(tr.Preset().ClearSearch(), tr.Theme, ip.clearSearchBtn, icons.ContentClear, "Clear search")
|
||||
return btn.Layout(gtx)
|
||||
}
|
||||
w := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(icon),
|
||||
layout.Flexed(1, ed),
|
||||
layout.Rigid(clr),
|
||||
)
|
||||
}
|
||||
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(bg),
|
||||
layout.Stacked(w),
|
||||
)
|
||||
})
|
||||
}
|
||||
123
tracker/gioui/instrument_properties.go
Normal file
123
tracker/gioui/instrument_properties.go
Normal file
@ -0,0 +1,123 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type (
|
||||
InstrumentProperties struct {
|
||||
nameEditor *Editor
|
||||
commentEditor *Editor
|
||||
list *layout.List
|
||||
soloBtn *Clickable
|
||||
muteBtn *Clickable
|
||||
threadBtns [4]*Clickable
|
||||
soloHint string
|
||||
unsoloHint string
|
||||
muteHint string
|
||||
unmuteHint string
|
||||
voices *NumericUpDownState
|
||||
splitInstrumentBtn *Clickable
|
||||
splitInstrumentHint string
|
||||
}
|
||||
)
|
||||
|
||||
func NewInstrumentProperties() *InstrumentProperties {
|
||||
ret := &InstrumentProperties{
|
||||
list: &layout.List{Axis: layout.Vertical},
|
||||
nameEditor: NewEditor(true, true, text.Start),
|
||||
commentEditor: NewEditor(false, false, text.Start),
|
||||
soloBtn: new(Clickable),
|
||||
muteBtn: new(Clickable),
|
||||
voices: NewNumericUpDownState(),
|
||||
splitInstrumentBtn: new(Clickable),
|
||||
threadBtns: [4]*Clickable{new(Clickable), new(Clickable), new(Clickable), new(Clickable)},
|
||||
}
|
||||
ret.soloHint = makeHint("Solo", " (%s)", "SoloToggle")
|
||||
ret.unsoloHint = makeHint("Unsolo", " (%s)", "SoloToggle")
|
||||
ret.muteHint = makeHint("Mute", " (%s)", "MuteToggle")
|
||||
ret.unmuteHint = makeHint("Unmute", " (%s)", "MuteToggle")
|
||||
ret.splitInstrumentHint = makeHint("Split instrument", " (%s)", "SplitInstrument")
|
||||
return ret
|
||||
}
|
||||
|
||||
func (ip *InstrumentProperties) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level, &ip.commentEditor.widgetEditor)
|
||||
}
|
||||
|
||||
// layout
|
||||
func (ip *InstrumentProperties) layout(gtx C) D {
|
||||
// get tracker from values
|
||||
tr := TrackerFromContext(gtx)
|
||||
voiceLine := func(gtx C) D {
|
||||
splitInstrumentBtn := ActionIconBtn(tr.Instrument().Split(), tr.Theme, ip.splitInstrumentBtn, icons.CommunicationCallSplit, ip.splitInstrumentHint)
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
instrumentVoices := NumUpDown(tr.Model.Instrument().Voices(), tr.Theme, ip.voices, "Number of voices for this instrument")
|
||||
return instrumentVoices.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(splitInstrumentBtn.Layout),
|
||||
)
|
||||
}
|
||||
|
||||
thread1btn := ToggleIconBtn(tr.Instrument().Thread1(), tr.Theme, ip.threadBtns[0], icons.ImageCropSquare, icons.ImageFilter1, "Do not render instrument on thread 1", "Render instrument on thread 1")
|
||||
thread2btn := ToggleIconBtn(tr.Instrument().Thread2(), tr.Theme, ip.threadBtns[1], icons.ImageCropSquare, icons.ImageFilter2, "Do not render instrument on thread 2", "Render instrument on thread 2")
|
||||
thread3btn := ToggleIconBtn(tr.Instrument().Thread3(), tr.Theme, ip.threadBtns[2], icons.ImageCropSquare, icons.ImageFilter3, "Do not render instrument on thread 3", "Render instrument on thread 3")
|
||||
thread4btn := ToggleIconBtn(tr.Instrument().Thread4(), tr.Theme, ip.threadBtns[3], icons.ImageCropSquare, icons.ImageFilter4, "Do not render instrument on thread 4", "Render instrument on thread 4")
|
||||
|
||||
threadbtnline := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(thread1btn.Layout),
|
||||
layout.Rigid(thread2btn.Layout),
|
||||
layout.Rigid(thread3btn.Layout),
|
||||
layout.Rigid(thread4btn.Layout),
|
||||
)
|
||||
}
|
||||
|
||||
return ip.list.Layout(gtx, 11, func(gtx C, index int) D {
|
||||
switch index {
|
||||
case 0:
|
||||
return layoutInstrumentPropertyLine(gtx, "Name", func(gtx C) D {
|
||||
return ip.nameEditor.Layout(gtx, tr.Instrument().Name(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Instr")
|
||||
})
|
||||
case 2:
|
||||
return layoutInstrumentPropertyLine(gtx, "Voices", voiceLine)
|
||||
case 4:
|
||||
muteBtn := ToggleIconBtn(tr.Instrument().Mute(), tr.Theme, ip.muteBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.muteHint, ip.unmuteHint)
|
||||
return layoutInstrumentPropertyLine(gtx, "Mute", muteBtn.Layout)
|
||||
case 6:
|
||||
soloBtn := ToggleIconBtn(tr.Instrument().Solo(), tr.Theme, ip.soloBtn, icons.ToggleCheckBoxOutlineBlank, icons.ToggleCheckBox, ip.soloHint, ip.unsoloHint)
|
||||
return layoutInstrumentPropertyLine(gtx, "Solo", soloBtn.Layout)
|
||||
case 8:
|
||||
return layoutInstrumentPropertyLine(gtx, "Thread", threadbtnline)
|
||||
case 10:
|
||||
return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D {
|
||||
return ip.commentEditor.Layout(gtx, tr.Instrument().Comment(), tr.Theme, &tr.Theme.InstrumentEditor.InstrumentComment, "Comment")
|
||||
})
|
||||
default: // odd valued list items are dividers
|
||||
px := max(gtx.Dp(unit.Dp(1)), 1)
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{255, 255, 255, 3}, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, px)).Op())
|
||||
return D{Size: image.Pt(gtx.Constraints.Max.X, px)}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func layoutInstrumentPropertyLine(gtx C, text string, content layout.Widget) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
gtx.Constraints.Max.X = min(gtx.Dp(300), gtx.Constraints.Max.X)
|
||||
label := Label(tr.Theme, &tr.Theme.InstrumentEditor.Properties.Label, text)
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Width: 6, Height: 36}.Layout),
|
||||
layout.Rigid(label.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(content),
|
||||
)
|
||||
}
|
||||
297
tracker/gioui/keybindings.go
Normal file
297
tracker/gioui/keybindings.go
Normal file
@ -0,0 +1,297 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
KeyAction string
|
||||
|
||||
KeyBinding struct {
|
||||
Key string
|
||||
Shortcut, Ctrl, Command, Shift, Alt, Super bool
|
||||
Action string
|
||||
}
|
||||
)
|
||||
|
||||
var keyBindingMap = map[key.Event]string{}
|
||||
var keyActionMap = map[KeyAction]string{} // holds an informative string of the first key bound to an action
|
||||
|
||||
//go:embed keybindings.yml
|
||||
var defaultKeyBindings []byte
|
||||
|
||||
func init() {
|
||||
var keyBindings, userKeybindings []KeyBinding
|
||||
dec := yaml.NewDecoder(bytes.NewReader(defaultKeyBindings))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&keyBindings); err != nil {
|
||||
panic(fmt.Errorf("failed to unmarshal default keybindings: %w", err))
|
||||
}
|
||||
if err := ReadCustomConfig("keybindings.yml", &userKeybindings); err == nil {
|
||||
keyBindings = append(keyBindings, userKeybindings...)
|
||||
}
|
||||
|
||||
for _, kb := range keyBindings {
|
||||
var mods key.Modifiers
|
||||
if kb.Shortcut {
|
||||
mods |= key.ModShortcut
|
||||
}
|
||||
if kb.Ctrl {
|
||||
mods |= key.ModCtrl
|
||||
}
|
||||
if kb.Command {
|
||||
mods |= key.ModCommand
|
||||
}
|
||||
if kb.Shift {
|
||||
mods |= key.ModShift
|
||||
}
|
||||
if kb.Alt {
|
||||
mods |= key.ModAlt
|
||||
}
|
||||
if kb.Super {
|
||||
mods |= key.ModSuper
|
||||
}
|
||||
|
||||
keyEvent := key.Event{Name: key.Name(kb.Key), Modifiers: mods, State: key.Press}
|
||||
action, ok := keyBindingMap[keyEvent] // if this key has been previously bound, remove it from the hint map
|
||||
if ok {
|
||||
delete(keyActionMap, KeyAction(action))
|
||||
}
|
||||
if kb.Action == "" { // unbind
|
||||
delete(keyBindingMap, keyEvent)
|
||||
} else { // bind
|
||||
keyBindingMap[keyEvent] = kb.Action
|
||||
// last binding of the some action wins for displaying the hint
|
||||
modString := strings.Replace(mods.String(), "-", "+", -1)
|
||||
text := kb.Key
|
||||
if modString != "" {
|
||||
text = modString + "+" + text
|
||||
}
|
||||
keyActionMap[KeyAction(kb.Action)] = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeHint(hint, format, action string) string {
|
||||
if keyActionMap[KeyAction(action)] != "" {
|
||||
return hint + fmt.Sprintf(format, keyActionMap[KeyAction(action)])
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
// KeyEvent handles incoming key events and returns true if repaint is needed.
|
||||
func (t *Tracker) KeyEvent(e key.Event, gtx C) {
|
||||
if e.State == key.Release {
|
||||
t.KeyNoteMap.Release(e.Name)
|
||||
return
|
||||
}
|
||||
action, ok := keyBindingMap[e]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
// Actions
|
||||
case "AddTrack":
|
||||
t.Track().Add().Do()
|
||||
case "DeleteTrack":
|
||||
t.Track().Delete().Do()
|
||||
case "AddInstrument":
|
||||
t.Instrument().Add().Do()
|
||||
case "DeleteInstrument":
|
||||
t.Instrument().Delete().Do()
|
||||
case "AddUnitAfter":
|
||||
t.Unit().Add(false).Do()
|
||||
case "AddUnitBefore":
|
||||
t.Unit().Add(true).Do()
|
||||
case "DeleteUnit":
|
||||
t.Unit().Delete().Do()
|
||||
case "ClearUnit":
|
||||
t.Unit().Clear().Do()
|
||||
case "Undo":
|
||||
t.History().Undo().Do()
|
||||
case "Redo":
|
||||
t.History().Redo().Do()
|
||||
case "AddSemitone":
|
||||
t.Note().AddSemitone().Do()
|
||||
case "SubtractSemitone":
|
||||
t.Note().SubtractSemitone().Do()
|
||||
case "AddOctave":
|
||||
t.Note().AddOctave().Do()
|
||||
case "SubtractOctave":
|
||||
t.Note().SubtractOctave().Do()
|
||||
case "EditNoteOff":
|
||||
t.Note().NoteOff().Do()
|
||||
case "RemoveUnused":
|
||||
t.Order().RemoveUnusedPatterns().Do()
|
||||
case "PlayCurrentPosFollow":
|
||||
t.Play().IsFollowing().SetValue(true)
|
||||
t.Play().FromCurrentPos().Do()
|
||||
case "PlayCurrentPosUnfollow":
|
||||
t.Play().IsFollowing().SetValue(false)
|
||||
t.Play().FromCurrentPos().Do()
|
||||
case "PlaySongStartFollow":
|
||||
t.Play().IsFollowing().SetValue(true)
|
||||
t.Play().FromBeginning().Do()
|
||||
case "PlaySongStartUnfollow":
|
||||
t.Play().IsFollowing().SetValue(false)
|
||||
t.Play().FromBeginning().Do()
|
||||
case "PlaySelectedFollow":
|
||||
t.Play().IsFollowing().SetValue(true)
|
||||
t.Play().FromSelected().Do()
|
||||
case "PlaySelectedUnfollow":
|
||||
t.Play().IsFollowing().SetValue(false)
|
||||
t.Play().FromSelected().Do()
|
||||
case "PlayLoopFollow":
|
||||
t.Play().IsFollowing().SetValue(true)
|
||||
t.Play().FromLoopBeginning().Do()
|
||||
case "PlayLoopUnfollow":
|
||||
t.Play().IsFollowing().SetValue(false)
|
||||
t.Play().FromLoopBeginning().Do()
|
||||
case "StopPlaying":
|
||||
t.Play().Stop().Do()
|
||||
case "AddOrderRowBefore":
|
||||
t.Order().AddRow(true).Do()
|
||||
case "AddOrderRowAfter":
|
||||
t.Order().AddRow(false).Do()
|
||||
case "DeleteOrderRowBackwards":
|
||||
t.Order().DeleteRow(true).Do()
|
||||
case "DeleteOrderRowForwards":
|
||||
t.Order().DeleteRow(false).Do()
|
||||
case "NewSong":
|
||||
t.Song().New().Do()
|
||||
case "OpenSong":
|
||||
t.Song().Open().Do()
|
||||
case "Quit":
|
||||
if canQuit {
|
||||
t.RequestQuit().Do()
|
||||
}
|
||||
case "SaveSong":
|
||||
t.Song().Save().Do()
|
||||
case "SaveSongAs":
|
||||
t.Song().SaveAs().Do()
|
||||
case "ExportWav":
|
||||
t.Song().Export().Do()
|
||||
case "ExportFloat":
|
||||
t.Song().ExportFloat().Do()
|
||||
case "ExportInt16":
|
||||
t.Song().ExportInt16().Do()
|
||||
case "SplitTrack":
|
||||
t.Track().Split().Do()
|
||||
case "SplitInstrument":
|
||||
t.Instrument().Split().Do()
|
||||
case "ShowManual":
|
||||
t.ShowManual().Do()
|
||||
case "AskHelp":
|
||||
t.AskHelp().Do()
|
||||
case "ReportBug":
|
||||
t.ReportBug().Do()
|
||||
case "ShowLicense":
|
||||
t.ShowLicense().Do()
|
||||
// Booleans
|
||||
case "PanicToggle":
|
||||
t.Play().Panicked().Toggle()
|
||||
case "RecordingToggle":
|
||||
t.Play().IsRecording().Toggle()
|
||||
case "PlayingToggleFollow":
|
||||
t.Play().IsFollowing().SetValue(true)
|
||||
t.Play().Started().Toggle()
|
||||
case "PlayingToggleUnfollow":
|
||||
t.Play().IsFollowing().SetValue(false)
|
||||
t.Play().Started().Toggle()
|
||||
case "InstrEnlargedToggle":
|
||||
t.Play().TrackerHidden().Toggle()
|
||||
case "LinkInstrTrackToggle":
|
||||
t.Track().LinkInstrument().Toggle()
|
||||
case "FollowToggle":
|
||||
t.Play().IsFollowing().Toggle()
|
||||
case "UnitDisabledToggle":
|
||||
t.Unit().Disabled().Toggle()
|
||||
case "LoopToggle":
|
||||
t.Play().IsLooping().Toggle()
|
||||
case "UniquePatternsToggle":
|
||||
t.Note().UniquePatterns().Toggle()
|
||||
case "MuteToggle":
|
||||
t.Instrument().Mute().Toggle()
|
||||
case "SoloToggle":
|
||||
t.Instrument().Solo().Toggle()
|
||||
// Integers
|
||||
case "InstrumentVoicesAdd":
|
||||
t.Instrument().Voices().Add(1)
|
||||
case "InstrumentVoicesSubtract":
|
||||
t.Instrument().Voices().Add(-1)
|
||||
case "TrackVoicesAdd":
|
||||
t.Track().Voices().Add(1)
|
||||
case "TrackVoicesSubtract":
|
||||
t.Track().Voices().Add(-1)
|
||||
case "SongLengthAdd":
|
||||
t.Song().Length().Add(1)
|
||||
case "SongLengthSubtract":
|
||||
t.Song().Length().Add(-1)
|
||||
case "BPMAdd":
|
||||
t.Song().BPM().Add(1)
|
||||
case "BPMSubtract":
|
||||
t.Song().BPM().Add(-1)
|
||||
case "RowsPerPatternAdd":
|
||||
t.Song().RowsPerPattern().Add(1)
|
||||
case "RowsPerPatternSubtract":
|
||||
t.Song().RowsPerPattern().Add(-1)
|
||||
case "RowsPerBeatAdd":
|
||||
t.Song().RowsPerBeat().Add(1)
|
||||
case "RowsPerBeatSubtract":
|
||||
t.Song().RowsPerBeat().Add(-1)
|
||||
case "StepAdd":
|
||||
t.Note().Step().Add(1)
|
||||
case "StepSubtract":
|
||||
t.Note().Step().Add(-1)
|
||||
case "OctaveAdd":
|
||||
t.Note().Octave().Add(1)
|
||||
case "OctaveSubtract":
|
||||
t.Note().Octave().Add(-1)
|
||||
// Other miscellaneous
|
||||
case "Paste":
|
||||
gtx.Execute(clipboard.ReadCmd{Tag: t})
|
||||
case "OrderEditorFocus":
|
||||
t.Play().TrackerHidden().SetValue(false)
|
||||
gtx.Execute(key.FocusCmd{Tag: t.OrderEditor.scrollTable})
|
||||
case "TrackEditorFocus":
|
||||
t.Play().TrackerHidden().SetValue(false)
|
||||
gtx.Execute(key.FocusCmd{Tag: t.TrackEditor.scrollTable})
|
||||
case "InstrumentListFocus":
|
||||
gtx.Execute(key.FocusCmd{Tag: t.PatchPanel.instrList.instrumentDragList})
|
||||
case "UnitListFocus":
|
||||
var tag event.Tag
|
||||
t.PatchPanel.BottomTags(0, func(level int, t event.Tag) bool {
|
||||
tag = t
|
||||
return false
|
||||
})
|
||||
gtx.Execute(key.FocusCmd{Tag: tag})
|
||||
case "FocusPrev":
|
||||
t.FocusPrev(gtx, false)
|
||||
case "FocusPrevInto":
|
||||
t.FocusPrev(gtx, true)
|
||||
case "FocusNext":
|
||||
t.FocusNext(gtx, false)
|
||||
case "FocusNextInto":
|
||||
t.FocusNext(gtx, true)
|
||||
default:
|
||||
if len(action) > 4 && action[:4] == "Note" {
|
||||
val, err := strconv.Atoi(string(action[4:]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
instr := t.Model.Instrument().List().Selected()
|
||||
n := noteAsValue(t.Model.Note().Octave().Value(), val-12)
|
||||
t.KeyNoteMap.Press(e.Name, tracker.NoteEvent{Channel: instr, Note: n})
|
||||
}
|
||||
}
|
||||
}
|
||||
98
tracker/gioui/keybindings.yml
Normal file
98
tracker/gioui/keybindings.yml
Normal file
@ -0,0 +1,98 @@
|
||||
# You can place your custom keybindings.yml in the Sointu config directory e.g.
|
||||
# AppData\Roaming\sointu\keybindings.yml on Windows. There, you can override the
|
||||
# default keybindings with your own. The format is the same as below. A
|
||||
# keybinding without any action means unbinding the key. For example, the line
|
||||
#
|
||||
# - {key: "A"}
|
||||
#
|
||||
# will stop the A key from sending NoteOff events.
|
||||
- { key: "C", shortcut: true, action: "Copy" }
|
||||
- { key: "V", shortcut: true, action: "Paste" }
|
||||
- { key: "A", shortcut: true, action: "SelectAll" }
|
||||
- { key: "X", shortcut: true, action: "Cut" }
|
||||
- { key: "Z", shortcut: true, action: "Undo" }
|
||||
- { key: "Y", shortcut: true, action: "Redo" }
|
||||
- { key: "D", shortcut: true, action: "UnitDisabledToggle" }
|
||||
- { key: "L", shortcut: true, action: "LoopToggle" }
|
||||
- { key: "N", shortcut: true, action: "NewSong" }
|
||||
- { key: "S", shortcut: true, action: "SaveSong" }
|
||||
- { key: "M", shortcut: true, action: "MuteToggle" }
|
||||
- { key: ",", shortcut: true, action: "SoloToggle" }
|
||||
- { key: "O", shortcut: true, action: "OpenSong" }
|
||||
- { key: "I", shortcut: true, shift: true, action: "DeleteInstrument" }
|
||||
- { key: "I", shortcut: true, action: "AddInstrument" }
|
||||
- { key: "I", shortcut: true, alt: true, action: "SplitInstrument" }
|
||||
- { key: "T", shortcut: true, shift: true, action: "DeleteTrack" }
|
||||
- { key: "T", shortcut: true, alt: true, action: "SplitTrack" }
|
||||
- { key: "T", shortcut: true, action: "AddTrack" }
|
||||
- { key: "E", shortcut: true, action: "InstrEnlargedToggle" }
|
||||
- { key: "K", shortcut: true, action: "LinkInstrTrackToggle" }
|
||||
- { key: "W", shortcut: true, action: "Quit" }
|
||||
- { key: "Space", action: "PlayingToggleUnfollow" }
|
||||
- { key: "Space", shift: true, action: "PlayingToggleFollow" }
|
||||
- { key: "F1", action: "OrderEditorFocus" }
|
||||
- { key: "F2", action: "TrackEditorFocus" }
|
||||
- { key: "F3", action: "InstrumentListFocus" }
|
||||
- { key: "F4", action: "UnitListFocus" }
|
||||
- { key: "F5", action: "PlayCurrentPosUnfollow" }
|
||||
- { key: "F5", shift: true, action: "PlayCurrentPosFollow" }
|
||||
- { key: "F5", shortcut: true, action: "PlaySongStartUnfollow" }
|
||||
- { key: "F5", shortcut: true, shift: true, action: "PlaySongStartFollow" }
|
||||
- { key: "F6", action: "PlaySelectedUnfollow" }
|
||||
- { key: "F6", shift: true, action: "PlaySelectedFollow" }
|
||||
- { key: "F6", shortcut: true, action: "PlayLoopUnfollow" }
|
||||
- { key: "F6", shortcut: true, shift: true, action: "PlayLoopFollow" }
|
||||
- { key: "F7", action: "RecordingToggle" }
|
||||
- { key: "F8", action: "StopPlaying" }
|
||||
- { key: "F9", action: "FollowToggle" }
|
||||
- { key: "F12", action: "PanicToggle" }
|
||||
- { key: "\\", shift: true, action: "OctaveAdd" }
|
||||
- { key: "\\", action: "OctaveSubtract" }
|
||||
- { key: ">", shift: true, action: "OctaveAdd" }
|
||||
- { key: ">", action: "OctaveSubtract" }
|
||||
- { key: "<", shift: true, action: "OctaveAdd" }
|
||||
- { key: "<", action: "OctaveSubtract" }
|
||||
- { key: "⎋", action: "FocusPrev" } # Esc key
|
||||
- { key: "Tab", shift: true, action: "FocusPrev" }
|
||||
- { key: "Tab", shift: true, shortcut: true, action: "FocusPrevInto" }
|
||||
- { key: "Tab", action: "FocusNext" }
|
||||
- { key: "Tab", shortcut: true, action: "FocusNextInto" }
|
||||
- { key: "A", action: "NoteOff" }
|
||||
- { key: "1", action: "NoteOff" }
|
||||
- { key: "Z", action: "Note0" }
|
||||
- { key: "S", action: "Note1" }
|
||||
- { key: "X", action: "Note2" }
|
||||
- { key: "D", action: "Note3" }
|
||||
- { key: "C", action: "Note4" }
|
||||
- { key: "V", action: "Note5" }
|
||||
- { key: "G", action: "Note6" }
|
||||
- { key: "B", action: "Note7" }
|
||||
- { key: "H", action: "Note8" }
|
||||
- { key: "N", action: "Note9" }
|
||||
- { key: "J", action: "Note10" }
|
||||
- { key: "M", action: "Note11" }
|
||||
- { key: ",", action: "Note12" }
|
||||
- { key: "L", action: "Note13" }
|
||||
- { key: ".", action: "Note14" }
|
||||
- { key: "Q", action: "Note12" }
|
||||
- { key: "2", action: "Note13" }
|
||||
- { key: "W", action: "Note14" }
|
||||
- { key: "3", action: "Note15" }
|
||||
- { key: "E", action: "Note16" }
|
||||
- { key: "R", action: "Note17" }
|
||||
- { key: "5", action: "Note18" }
|
||||
- { key: "T", action: "Note19" }
|
||||
- { key: "6", action: "Note20" }
|
||||
- { key: "Y", action: "Note21" }
|
||||
- { key: "7", action: "Note22" }
|
||||
- { key: "U", action: "Note23" }
|
||||
- { key: "I", action: "Note24" }
|
||||
- { key: "9", action: "Note25" }
|
||||
- { key: "O", action: "Note26" }
|
||||
- { key: "0", action: "Note27" }
|
||||
- { key: "P", action: "Note28" }
|
||||
- { key: "+", action: "Increase" }
|
||||
- { key: "-", action: "Decrease" }
|
||||
- { key: "+", shortcut: true, action: "IncreaseMore" } # increase a large step
|
||||
- { key: "-", shortcut: true, action: "DecreaseMore" } # decrease a large step
|
||||
|
||||
58
tracker/gioui/keyboard.go
Normal file
58
tracker/gioui/keyboard.go
Normal file
@ -0,0 +1,58 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
// Keyboard is used to associate the keys of a keyboard (e.g. computer or a
|
||||
// MIDI keyboard) to currently playing notes. You can use any type T to
|
||||
// identify each key; T should be a comparable type.
|
||||
Keyboard[T comparable] struct {
|
||||
broker *tracker.Broker
|
||||
pressed map[T]tracker.NoteEvent
|
||||
}
|
||||
)
|
||||
|
||||
func MakeKeyboard[T comparable](broker *tracker.Broker) Keyboard[T] {
|
||||
return Keyboard[T]{
|
||||
broker: broker,
|
||||
pressed: make(map[T]tracker.NoteEvent),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Keyboard[T]) Press(key T, ev tracker.NoteEvent) {
|
||||
if _, ok := t.pressed[key]; ok {
|
||||
return // already playing a note with this key, do not send a new event
|
||||
}
|
||||
t.Release(key) // unset any previous note
|
||||
if ev.Note > 1 {
|
||||
ev.Source = t // set the source to this keyboard
|
||||
ev.On = true
|
||||
ev.Timestamp = t.now()
|
||||
if tracker.TrySend(t.broker.ToPlayer, any(ev)) {
|
||||
t.pressed[key] = ev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Keyboard[T]) Release(key T) {
|
||||
if ev, ok := t.pressed[key]; ok {
|
||||
ev.Timestamp = t.now()
|
||||
ev.On = false // the pressed contains the event we need to send to release the note
|
||||
tracker.TrySend(t.broker.ToPlayer, any(ev))
|
||||
delete(t.pressed, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Keyboard[T]) ReleaseAll() {
|
||||
for key := range t.pressed {
|
||||
t.Release(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Keyboard[T]) now() int64 {
|
||||
return time.Now().UnixMilli() * 441 / 10 // convert to 44100Hz frames
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/key"
|
||||
)
|
||||
|
||||
var noteMap = map[key.Name]int{
|
||||
"Z": -12,
|
||||
"S": -11,
|
||||
"X": -10,
|
||||
"D": -9,
|
||||
"C": -8,
|
||||
"V": -7,
|
||||
"G": -6,
|
||||
"B": -5,
|
||||
"H": -4,
|
||||
"N": -3,
|
||||
"J": -2,
|
||||
"M": -1,
|
||||
",": 0,
|
||||
"L": 1,
|
||||
".": 2,
|
||||
"Q": 0,
|
||||
"2": 1,
|
||||
"W": 2,
|
||||
"3": 3,
|
||||
"E": 4,
|
||||
"R": 5,
|
||||
"5": 6,
|
||||
"T": 7,
|
||||
"6": 8,
|
||||
"Y": 9,
|
||||
"7": 10,
|
||||
"U": 11,
|
||||
"I": 12,
|
||||
"9": 13,
|
||||
"O": 14,
|
||||
"0": 15,
|
||||
"P": 16,
|
||||
}
|
||||
|
||||
// KeyEvent handles incoming key events and returns true if repaint is needed.
|
||||
func (t *Tracker) KeyEvent(e key.Event, gtx C) {
|
||||
if e.State == key.Press {
|
||||
switch e.Name {
|
||||
case "V":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
gtx.Execute(clipboard.ReadCmd{Tag: t})
|
||||
return
|
||||
}
|
||||
case "Z":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.Undo().Do()
|
||||
return
|
||||
}
|
||||
case "Y":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.Redo().Do()
|
||||
return
|
||||
}
|
||||
case "D":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.UnitDisabled().Bool().Toggle()
|
||||
return
|
||||
}
|
||||
case "L":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.LoopToggle().Bool().Toggle()
|
||||
return
|
||||
}
|
||||
case "N":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.NewSong().Do()
|
||||
return
|
||||
}
|
||||
case "S":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.SaveSong().Do()
|
||||
return
|
||||
}
|
||||
case "O":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.OpenSong().Do()
|
||||
return
|
||||
}
|
||||
case "I":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
if e.Modifiers.Contain(key.ModShift) {
|
||||
t.DeleteInstrument().Do()
|
||||
} else {
|
||||
t.AddInstrument().Do()
|
||||
}
|
||||
return
|
||||
}
|
||||
case "T":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
if e.Modifiers.Contain(key.ModShift) {
|
||||
t.DeleteTrack().Do()
|
||||
} else {
|
||||
t.AddTrack().Do()
|
||||
}
|
||||
return
|
||||
}
|
||||
case "E":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.InstrEnlarged().Bool().Toggle()
|
||||
return
|
||||
}
|
||||
case "W":
|
||||
if e.Modifiers.Contain(key.ModShortcut) && canQuit {
|
||||
t.Quit().Do()
|
||||
return
|
||||
}
|
||||
case "F1":
|
||||
t.OrderEditor.scrollTable.Focus()
|
||||
return
|
||||
case "F2":
|
||||
t.TrackEditor.scrollTable.Focus()
|
||||
return
|
||||
case "F3":
|
||||
t.InstrumentEditor.Focus()
|
||||
return
|
||||
case "F5":
|
||||
t.SongPanel.RewindBtn.Action.Do()
|
||||
t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
|
||||
return
|
||||
case "F6", "Space":
|
||||
t.SongPanel.PlayingBtn.Bool.Toggle()
|
||||
t.SongPanel.NoteTracking.Bool.Set(!e.Modifiers.Contain(key.ModCtrl))
|
||||
return
|
||||
case "F7":
|
||||
t.SongPanel.RecordBtn.Bool.Toggle()
|
||||
return
|
||||
case "F8":
|
||||
t.SongPanel.NoteTracking.Bool.Toggle()
|
||||
return
|
||||
case "F12":
|
||||
t.Panic().Bool().Toggle()
|
||||
return
|
||||
case `\`, `<`, `>`:
|
||||
if e.Modifiers.Contain(key.ModShift) {
|
||||
t.OctaveNumberInput.Int.Add(1)
|
||||
} else {
|
||||
t.OctaveNumberInput.Int.Add(-1)
|
||||
}
|
||||
case key.NameTab:
|
||||
if e.Modifiers.Contain(key.ModShift) {
|
||||
switch {
|
||||
case t.OrderEditor.scrollTable.Focused():
|
||||
t.InstrumentEditor.unitEditor.sliderList.Focus()
|
||||
case t.TrackEditor.scrollTable.Focused():
|
||||
t.OrderEditor.scrollTable.Focus()
|
||||
case t.InstrumentEditor.Focused():
|
||||
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
|
||||
t.InstrumentEditor.unitEditor.sliderList.Focus()
|
||||
} else {
|
||||
t.TrackEditor.scrollTable.Focus()
|
||||
}
|
||||
default:
|
||||
t.InstrumentEditor.Focus()
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case t.OrderEditor.scrollTable.Focused():
|
||||
t.TrackEditor.scrollTable.Focus()
|
||||
case t.TrackEditor.scrollTable.Focused():
|
||||
t.InstrumentEditor.Focus()
|
||||
case t.InstrumentEditor.Focused():
|
||||
t.InstrumentEditor.unitEditor.sliderList.Focus()
|
||||
default:
|
||||
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
|
||||
t.InstrumentEditor.Focus()
|
||||
} else {
|
||||
t.OrderEditor.scrollTable.Focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.JammingPressed(e)
|
||||
} else { // e.State == key.Release
|
||||
t.JammingReleased(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) JammingPressed(e key.Event) byte {
|
||||
if val, ok := noteMap[e.Name]; ok {
|
||||
if _, ok := t.KeyPlaying[e.Name]; !ok {
|
||||
n := noteAsValue(t.OctaveNumberInput.Int.Value(), val)
|
||||
instr := t.InstrumentEditor.instrumentDragList.TrackerList.Selected()
|
||||
t.KeyPlaying[e.Name] = t.InstrNoteOn(instr, n)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *Tracker) JammingReleased(e key.Event) bool {
|
||||
if noteID, ok := t.KeyPlaying[e.Name]; ok {
|
||||
noteID.NoteOff()
|
||||
delete(t.KeyPlaying, e.Name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
@ -14,37 +13,39 @@ import (
|
||||
)
|
||||
|
||||
type LabelStyle struct {
|
||||
Text string
|
||||
Color color.NRGBA
|
||||
ShadeColor color.NRGBA
|
||||
Alignment layout.Direction
|
||||
Font font.Font
|
||||
FontSize unit.Sp
|
||||
Shaper *text.Shaper
|
||||
Color color.NRGBA
|
||||
ShadowColor color.NRGBA
|
||||
Alignment text.Alignment
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
MaxLines int
|
||||
}
|
||||
|
||||
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
return l.Alignment.Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min = image.Point{}
|
||||
paint.ColorOp{Color: l.ShadeColor}.Add(gtx.Ops)
|
||||
type LabelWidget struct {
|
||||
Text string
|
||||
Shaper *text.Shaper
|
||||
LabelStyle
|
||||
}
|
||||
|
||||
func (l LabelWidget) Layout(gtx C) D {
|
||||
textColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
|
||||
textColor := textColorMacro.Stop()
|
||||
t := widget.Label{
|
||||
Alignment: l.Alignment,
|
||||
MaxLines: l.MaxLines,
|
||||
}
|
||||
if l.ShadowColor.A > 0 {
|
||||
shadowColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: l.ShadowColor}.Add(gtx.Ops)
|
||||
shadowColor := shadowColorMacro.Stop()
|
||||
offs := op.Offset(image.Pt(2, 2)).Push(gtx.Ops)
|
||||
widget.Label{
|
||||
Alignment: text.Start,
|
||||
MaxLines: 1,
|
||||
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
|
||||
t.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, shadowColor)
|
||||
offs.Pop()
|
||||
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
|
||||
dims := widget.Label{
|
||||
Alignment: text.Start,
|
||||
MaxLines: 1,
|
||||
}.Layout(gtx, l.Shaper, l.Font, l.FontSize, l.Text, op.CallOp{})
|
||||
return layout.Dimensions{
|
||||
Size: dims.Size,
|
||||
Baseline: dims.Baseline,
|
||||
}
|
||||
})
|
||||
}
|
||||
return t.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, textColor)
|
||||
}
|
||||
|
||||
func Label(str string, color color.NRGBA, shaper *text.Shaper) layout.Widget {
|
||||
return LabelStyle{Text: str, Color: color, ShadeColor: black, Font: labelDefaultFont, FontSize: labelDefaultFontSize, Alignment: layout.W, Shaper: shaper}.Layout
|
||||
func Label(th *Theme, style *LabelStyle, txt string) LabelWidget {
|
||||
return LabelWidget{Text: txt, Shaper: th.Material.Shaper, LabelStyle: *style}
|
||||
}
|
||||
|
||||
@ -5,186 +5,371 @@ import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type Menu struct {
|
||||
Visible bool
|
||||
clickable widget.Clickable
|
||||
tags []bool
|
||||
clicks []int
|
||||
hover int
|
||||
list layout.List
|
||||
scrollBar ScrollBar
|
||||
}
|
||||
type (
|
||||
// MenuState is the part of the menu that needs to be retained between frames.
|
||||
MenuState struct {
|
||||
tags []bool
|
||||
hover int
|
||||
hoverOk bool
|
||||
list layout.List
|
||||
scrollBar ScrollBar
|
||||
|
||||
type MenuStyle struct {
|
||||
Menu *Menu
|
||||
Title string
|
||||
IconColor color.NRGBA
|
||||
TextColor color.NRGBA
|
||||
ShortCutColor color.NRGBA
|
||||
FontSize unit.Sp
|
||||
IconSize unit.Dp
|
||||
HoverColor color.NRGBA
|
||||
Shaper *text.Shaper
|
||||
}
|
||||
tag bool
|
||||
visible bool
|
||||
|
||||
type MenuItem struct {
|
||||
IconBytes []byte
|
||||
Text string
|
||||
ShortcutText string
|
||||
Doer tracker.Action
|
||||
}
|
||||
|
||||
func (m *Menu) Clicked() (int, bool) {
|
||||
if len(m.clicks) == 0 {
|
||||
return 0, false
|
||||
itemTmp []menuItem
|
||||
}
|
||||
first := m.clicks[0]
|
||||
for i := 1; i < len(m.clicks); i++ {
|
||||
m.clicks[i-1] = m.clicks[i]
|
||||
|
||||
// MenuStyle is the style for a menu that is stored in the theme.yml.
|
||||
MenuStyle struct {
|
||||
Text LabelStyle
|
||||
Shortcut LabelStyle
|
||||
Disabled color.NRGBA
|
||||
Hover color.NRGBA
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
}
|
||||
m.clicks = m.clicks[:len(m.clicks)-1]
|
||||
return first, true
|
||||
|
||||
// MenuWidget has a Layout method to display a menu
|
||||
MenuWidget struct {
|
||||
State *MenuState
|
||||
Style *MenuStyle
|
||||
}
|
||||
)
|
||||
|
||||
func Menu(state *MenuState) MenuWidget { return MenuWidget{State: state} }
|
||||
func (w MenuWidget) WithStyle(style *MenuStyle) MenuWidget { w.Style = style; return w }
|
||||
|
||||
func (ms *MenuState) Tags(level int, yield TagYieldFunc) bool {
|
||||
if ms.visible {
|
||||
return yield(level, &ms.tag)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MenuStyle) Layout(gtx C, items ...MenuItem) D {
|
||||
contents := func(gtx C) D {
|
||||
for i, item := range items {
|
||||
// make sure we have a tag for every item
|
||||
for len(m.Menu.tags) <= i {
|
||||
m.Menu.tags = append(m.Menu.tags, false)
|
||||
// MenuChild describes one or more menu items; if MenuChild is an Action or
|
||||
// Bool, it's one item per child, but Ints are treated as enumerations and
|
||||
// create one item per different possible values of the int.
|
||||
type MenuChild struct {
|
||||
Icon []byte
|
||||
Text string
|
||||
Shortcut string
|
||||
|
||||
kind menuChildKind
|
||||
action tracker.Action
|
||||
bool tracker.Bool
|
||||
int tracker.Int
|
||||
widget layout.Widget // these should be passive separators and such
|
||||
}
|
||||
|
||||
type menuChildKind int
|
||||
|
||||
const (
|
||||
menuChildAction menuChildKind = iota
|
||||
menuChildBool
|
||||
menuChildInt
|
||||
menuChildList
|
||||
menuChildWidget
|
||||
)
|
||||
|
||||
func ActionMenuChild(act tracker.Action, text, shortcut string, icon []byte) MenuChild {
|
||||
return MenuChild{
|
||||
Icon: icon,
|
||||
Text: text,
|
||||
Shortcut: shortcut,
|
||||
|
||||
kind: menuChildAction,
|
||||
action: act,
|
||||
}
|
||||
}
|
||||
|
||||
func BoolMenuChild(b tracker.Bool, text, shortcut string, icon []byte) MenuChild {
|
||||
return MenuChild{
|
||||
Icon: icon,
|
||||
Text: text,
|
||||
Shortcut: shortcut,
|
||||
|
||||
kind: menuChildBool,
|
||||
bool: b,
|
||||
}
|
||||
}
|
||||
|
||||
func IntMenuChild(i tracker.Int, text, shortcut string, icon []byte) MenuChild {
|
||||
return MenuChild{
|
||||
Icon: icon,
|
||||
Text: text,
|
||||
Shortcut: shortcut,
|
||||
kind: menuChildInt,
|
||||
int: i,
|
||||
}
|
||||
}
|
||||
|
||||
// Layout the menu with the given items
|
||||
func (m MenuWidget) Layout(gtx C, children ...MenuChild) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
if m.Style == nil {
|
||||
m.Style = &t.Theme.Menu.Main
|
||||
}
|
||||
// unfortunately, there was no way to include items into the MenuWidget
|
||||
// without causing heap escapes, so they are passed as a parameter to the Layout
|
||||
m.State.itemTmp = m.State.itemTmp[:0]
|
||||
for i, c := range children {
|
||||
switch c.kind {
|
||||
case menuChildAction:
|
||||
m.State.itemTmp = append(m.State.itemTmp, menuItem{childIndex: i, icon: c.Icon, text: c.Text, shortcut: c.Shortcut, enabled: c.enabled()})
|
||||
case menuChildBool:
|
||||
mi := menuItem{childIndex: i, text: c.Text, shortcut: c.Shortcut, enabled: c.enabled()}
|
||||
if c.bool.Value() {
|
||||
mi.icon = c.Icon
|
||||
}
|
||||
// handle pointer events for this item
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: &m.Menu.tags[i],
|
||||
Kinds: pointer.Press | pointer.Enter | pointer.Leave,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
m.State.itemTmp = append(m.State.itemTmp, mi)
|
||||
case menuChildInt:
|
||||
for i := c.int.Range().Min; i <= c.int.Range().Max; i++ {
|
||||
mi := menuItem{childIndex: i, text: c.int.StringOf(i), value: i, enabled: c.enabled()}
|
||||
if c.int.Value() == i {
|
||||
mi.icon = c.Icon
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
item.Doer.Do()
|
||||
m.Menu.Visible = false
|
||||
case pointer.Enter:
|
||||
m.Menu.hover = i + 1
|
||||
case pointer.Leave:
|
||||
if m.Menu.hover == i+1 {
|
||||
m.Menu.hover = 0
|
||||
}
|
||||
if i == c.int.Range().Min {
|
||||
mi.shortcut = c.Shortcut
|
||||
}
|
||||
m.State.itemTmp = append(m.State.itemTmp, mi)
|
||||
}
|
||||
}
|
||||
m.Menu.list.Axis = layout.Vertical
|
||||
m.Menu.scrollBar.Axis = layout.Vertical
|
||||
}
|
||||
m.update(gtx, children, m.State.itemTmp)
|
||||
listItem := func(gtx C, i int) D {
|
||||
item := m.State.itemTmp[i]
|
||||
icon := t.Theme.Icon(item.icon)
|
||||
iconColor := m.Style.Text.Color
|
||||
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)}
|
||||
textLabel := Label(t.Theme, &m.Style.Text, item.text)
|
||||
shortcutLabel := Label(t.Theme, &m.Style.Shortcut, item.shortcut)
|
||||
if !item.enabled {
|
||||
iconColor = m.Style.Disabled
|
||||
textLabel.Color = m.Style.Disabled
|
||||
shortcutLabel.Color = m.Style.Disabled
|
||||
}
|
||||
shortcutInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(12), Bottom: unit.Dp(2), Top: unit.Dp(2)}
|
||||
fg := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return iconInset.Layout(gtx, func(gtx C) D {
|
||||
p := gtx.Dp(unit.Dp(m.Style.Text.TextSize))
|
||||
gtx.Constraints.Min = image.Pt(p, p)
|
||||
return icon.Layout(gtx, iconColor)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(textLabel.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return shortcutInset.Layout(gtx, shortcutLabel.Layout)
|
||||
}),
|
||||
)
|
||||
}
|
||||
bg := func(gtx C) D {
|
||||
rect := clip.Rect{Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}
|
||||
if item.enabled && m.State.hoverOk && m.State.hover == i {
|
||||
paint.FillShape(gtx.Ops, m.Style.Hover, rect.Op())
|
||||
}
|
||||
if item.enabled {
|
||||
area := rect.Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, &m.State.tags[i])
|
||||
area.Pop()
|
||||
}
|
||||
return D{Size: rect.Max}
|
||||
}
|
||||
return layout.Background{}.Layout(gtx, bg, fg)
|
||||
}
|
||||
menuList := func(gtx C) D {
|
||||
gtx.Constraints.Max.X = gtx.Dp(m.Style.Width)
|
||||
gtx.Constraints.Max.Y = gtx.Dp(m.Style.Height)
|
||||
r := clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, &m.State.tag)
|
||||
r.Pop()
|
||||
m.State.list.Axis = layout.Vertical
|
||||
m.State.scrollBar.Axis = layout.Vertical
|
||||
return layout.Stack{Alignment: layout.SE}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D { return m.State.list.Layout(gtx, len(m.State.itemTmp), listItem) }),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
return m.Menu.list.Layout(gtx, len(items), func(gtx C, i int) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
var macro op.MacroOp
|
||||
item := &items[i]
|
||||
if i == m.Menu.hover-1 && item.Doer.Allowed() {
|
||||
macro = op.Record(gtx.Ops)
|
||||
}
|
||||
icon := widgetForIcon(item.IconBytes)
|
||||
iconColor := m.IconColor
|
||||
if !item.Doer.Allowed() {
|
||||
iconColor = mediumEmphasisTextColor
|
||||
}
|
||||
iconInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(6)}
|
||||
textLabel := LabelStyle{Text: item.Text, FontSize: m.FontSize, Color: m.TextColor, Shaper: m.Shaper}
|
||||
if !item.Doer.Allowed() {
|
||||
textLabel.Color = mediumEmphasisTextColor
|
||||
}
|
||||
shortcutLabel := LabelStyle{Text: item.ShortcutText, FontSize: m.FontSize, Color: m.ShortCutColor, Shaper: m.Shaper}
|
||||
shortcutInset := layout.Inset{Left: unit.Dp(12), Right: unit.Dp(12), Bottom: unit.Dp(2), Top: unit.Dp(2)}
|
||||
dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return iconInset.Layout(gtx, func(gtx C) D {
|
||||
p := gtx.Dp(unit.Dp(m.IconSize))
|
||||
gtx.Constraints.Min = image.Pt(p, p)
|
||||
return icon.Layout(gtx, iconColor)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(textLabel.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Max.X, 1)} }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return shortcutInset.Layout(gtx, shortcutLabel.Layout)
|
||||
}),
|
||||
)
|
||||
if i == m.Menu.hover-1 && item.Doer.Allowed() {
|
||||
recording := macro.Stop()
|
||||
paint.FillShape(gtx.Ops, m.HoverColor, clip.Rect{
|
||||
Max: image.Pt(dims.Size.X, dims.Size.Y),
|
||||
}.Op())
|
||||
recording.Add(gtx.Ops)
|
||||
}
|
||||
if item.Doer.Allowed() {
|
||||
rect := image.Rect(0, 0, dims.Size.X, dims.Size.Y)
|
||||
area := clip.Rect(rect).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, &m.Menu.tags[i])
|
||||
area.Pop()
|
||||
}
|
||||
return dims
|
||||
})
|
||||
}),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
return m.Menu.scrollBar.Layout(gtx, unit.Dp(10), len(items), &m.Menu.list.Position)
|
||||
return m.State.scrollBar.Layout(gtx, &t.Theme.ScrollBar, len(m.State.itemTmp), &m.State.list.Position)
|
||||
}),
|
||||
)
|
||||
}
|
||||
popup := Popup(&m.Menu.Visible)
|
||||
popup.NE = unit.Dp(0)
|
||||
popup.ShadowN = unit.Dp(0)
|
||||
popup.NW = unit.Dp(0)
|
||||
return popup.Layout(gtx, contents)
|
||||
popup := Popup(t.Theme, &m.State.visible)
|
||||
popup.Style = &t.Theme.Popup.Menu
|
||||
return popup.Layout(gtx, menuList)
|
||||
}
|
||||
|
||||
func PopupMenu(menu *Menu, shaper *text.Shaper) MenuStyle {
|
||||
return MenuStyle{
|
||||
Menu: menu,
|
||||
IconColor: white,
|
||||
TextColor: white,
|
||||
ShortCutColor: mediumEmphasisTextColor,
|
||||
FontSize: unit.Sp(16),
|
||||
IconSize: unit.Dp(16),
|
||||
HoverColor: menuHoverColor,
|
||||
Shaper: shaper,
|
||||
type menuItem struct {
|
||||
childIndex int
|
||||
value int
|
||||
icon []byte
|
||||
text, shortcut string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func (m *MenuWidget) update(gtx C, children []MenuChild, items []menuItem) {
|
||||
// handle keyboard events for the menu
|
||||
for {
|
||||
ev, ok := gtx.Event(
|
||||
key.FocusFilter{Target: &m.State.tag},
|
||||
key.Filter{Focus: &m.State.tag, Name: key.NameUpArrow},
|
||||
key.Filter{Focus: &m.State.tag, Name: key.NameDownArrow},
|
||||
key.Filter{Focus: &m.State.tag, Name: key.NameEnter},
|
||||
key.Filter{Focus: &m.State.tag, Name: key.NameReturn},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e := ev.(type) {
|
||||
case key.Event:
|
||||
if e.State != key.Press {
|
||||
continue
|
||||
}
|
||||
switch e.Name {
|
||||
case key.NameUpArrow:
|
||||
if !m.State.hoverOk {
|
||||
m.State.hover = 0 // if nothing is selected, select the first item before starting to move backwards
|
||||
}
|
||||
for i := 1; i < len(items); i++ {
|
||||
idx := (m.State.hover - i + len(items)) % len(items)
|
||||
child := &children[items[idx].childIndex]
|
||||
if child.enabled() {
|
||||
m.State.hover = idx
|
||||
m.State.hoverOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case key.NameDownArrow:
|
||||
if !m.State.hoverOk {
|
||||
m.State.hover = len(items) - 1 // if nothing is selected, select the last item before starting to move backwards
|
||||
}
|
||||
for i := 1; i < len(items); i++ {
|
||||
idx := (m.State.hover + i) % len(items)
|
||||
child := &children[items[idx].childIndex]
|
||||
if child.enabled() {
|
||||
m.State.hover = idx
|
||||
m.State.hoverOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case key.NameEnter, key.NameReturn:
|
||||
if m.State.hoverOk && m.State.hover >= 0 && m.State.hover < len(items) {
|
||||
m.activateItem(items[m.State.hover], children)
|
||||
}
|
||||
}
|
||||
case key.FocusEvent:
|
||||
if !m.State.hoverOk {
|
||||
m.State.hover = 0
|
||||
}
|
||||
m.State.hoverOk = e.Focus
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
// make sure we have a tag for every item
|
||||
for len(m.State.tags) <= i {
|
||||
m.State.tags = append(m.State.tags, false)
|
||||
}
|
||||
// handle pointer events for this item
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{Target: &m.State.tags[i], Kinds: pointer.Press | pointer.Enter | pointer.Leave})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
m.activateItem(items[i], children)
|
||||
case pointer.Enter:
|
||||
m.State.hover = i
|
||||
m.State.hoverOk = true
|
||||
if !gtx.Focused(&m.State.tag) {
|
||||
gtx.Execute(key.FocusCmd{Tag: &m.State.tag})
|
||||
}
|
||||
case pointer.Leave:
|
||||
if m.State.hover == i {
|
||||
m.State.hoverOk = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *Tracker) layoutMenu(gtx C, title string, clickable *widget.Clickable, menu *Menu, width unit.Dp, items ...MenuItem) layout.Widget {
|
||||
for clickable.Clicked(gtx) {
|
||||
menu.Visible = true
|
||||
func (m *MenuWidget) activateItem(item menuItem, children []MenuChild) {
|
||||
if item.childIndex < 0 || item.childIndex >= len(children) {
|
||||
return
|
||||
}
|
||||
m := PopupMenu(menu, tr.Theme.Shaper)
|
||||
return func(gtx C) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
titleBtn := material.Button(tr.Theme, clickable, title)
|
||||
titleBtn.Color = white
|
||||
titleBtn.Background = transparent
|
||||
titleBtn.CornerRadius = unit.Dp(0)
|
||||
dims := titleBtn.Layout(gtx)
|
||||
op.Offset(image.Pt(0, dims.Size.Y)).Add(gtx.Ops)
|
||||
gtx.Constraints.Max.X = gtx.Dp(width)
|
||||
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(300))
|
||||
m.Layout(gtx, items...)
|
||||
return dims
|
||||
child := &children[item.childIndex]
|
||||
if !child.enabled() {
|
||||
return
|
||||
}
|
||||
switch child.kind {
|
||||
case menuChildAction:
|
||||
child.action.Do()
|
||||
case menuChildBool:
|
||||
child.bool.Toggle()
|
||||
case menuChildInt:
|
||||
child.int.SetValue(item.value)
|
||||
}
|
||||
m.State.visible = false
|
||||
}
|
||||
|
||||
func (c *MenuChild) enabled() bool {
|
||||
switch c.kind {
|
||||
case menuChildAction:
|
||||
return c.action.Enabled()
|
||||
case menuChildBool:
|
||||
return c.bool.Enabled()
|
||||
case menuChildWidget:
|
||||
return false // the widget are passive separators and such
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MenuButton displays a button with text that opens a menu when clicked.
|
||||
type MenuButton struct {
|
||||
Title string
|
||||
Style *ButtonStyle
|
||||
Clickable *Clickable
|
||||
MenuState *MenuState
|
||||
Width unit.Dp
|
||||
}
|
||||
|
||||
func MenuBtn(ms *MenuState, cl *Clickable, title string) MenuButton {
|
||||
return MenuButton{MenuState: ms, Clickable: cl, Title: title}
|
||||
}
|
||||
|
||||
func (mb MenuButton) WithStyle(style *ButtonStyle) MenuButton { mb.Style = style; return mb }
|
||||
|
||||
func (mb MenuButton) Layout(gtx C, children ...MenuChild) D {
|
||||
for mb.Clickable.Clicked(gtx) {
|
||||
mb.MenuState.visible = true
|
||||
gtx.Execute(key.FocusCmd{Tag: &mb.MenuState.tag})
|
||||
}
|
||||
t := TrackerFromContext(gtx)
|
||||
if mb.Style == nil {
|
||||
mb.Style = &t.Theme.Button.Menu
|
||||
}
|
||||
btn := Btn(t.Theme, mb.Style, mb.Clickable, mb.Title, "")
|
||||
dims := btn.Layout(gtx)
|
||||
if mb.MenuState.visible {
|
||||
defer op.Offset(image.Pt(0, dims.Size.Y)).Push(gtx.Ops).Pop()
|
||||
m := Menu(mb.MenuState)
|
||||
m.Layout(gtx, children...)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ package gioui
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
@ -24,126 +25,131 @@ const trackColTitleHeight = unit.Dp(16)
|
||||
const trackPatMarkWidth = unit.Dp(25)
|
||||
const trackRowMarkWidth = unit.Dp(25)
|
||||
|
||||
var noteStr [256]string
|
||||
var noteName [256]string
|
||||
var noteHex [256]string
|
||||
var hexStr [256]string
|
||||
|
||||
func init() {
|
||||
// initialize these strings once, so we don't have to do it every time we draw the note editor
|
||||
hexStr[0] = "--"
|
||||
hexStr[1] = ".."
|
||||
noteStr[0] = "---"
|
||||
noteStr[1] = "..."
|
||||
for i := range 256 {
|
||||
hexStr[i] = fmt.Sprintf("%02X", i)
|
||||
}
|
||||
noteHex[0] = "--"
|
||||
noteHex[1] = ".."
|
||||
noteName[0] = "---"
|
||||
noteName[1] = "..."
|
||||
for i := 2; i < 256; i++ {
|
||||
hexStr[i] = fmt.Sprintf("%02x", i)
|
||||
noteHex[i] = fmt.Sprintf("%02x", i)
|
||||
oNote := mod(i-baseNote, 12)
|
||||
octave := (i - oNote - baseNote) / 12
|
||||
switch {
|
||||
case octave < 0:
|
||||
noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave)))
|
||||
noteName[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('Z'+1+octave)))
|
||||
case octave >= 10:
|
||||
noteStr[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
|
||||
noteName[i] = fmt.Sprintf("%s%s", notes[oNote], string(byte('A'+octave-10)))
|
||||
default:
|
||||
noteStr[i] = fmt.Sprintf("%s%d", notes[oNote], octave)
|
||||
noteName[i] = fmt.Sprintf("%s%d", notes[oNote], octave)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type NoteEditor struct {
|
||||
TrackVoices *NumberInput
|
||||
NewTrackBtn *ActionClickable
|
||||
DeleteTrackBtn *ActionClickable
|
||||
AddSemitoneBtn *ActionClickable
|
||||
SubtractSemitoneBtn *ActionClickable
|
||||
AddOctaveBtn *ActionClickable
|
||||
SubtractOctaveBtn *ActionClickable
|
||||
NoteOffBtn *ActionClickable
|
||||
EffectBtn *BoolClickable
|
||||
TrackVoices *NumericUpDownState
|
||||
NewTrackBtn *Clickable
|
||||
DeleteTrackBtn *Clickable
|
||||
SplitTrackBtn *Clickable
|
||||
|
||||
scrollTable *ScrollTable
|
||||
tag struct{}
|
||||
AddSemitoneBtn *Clickable
|
||||
SubtractSemitoneBtn *Clickable
|
||||
AddOctaveBtn *Clickable
|
||||
SubtractOctaveBtn *Clickable
|
||||
NoteOffBtn *Clickable
|
||||
EffectBtn *Clickable
|
||||
UniqueBtn *Clickable
|
||||
TrackMidiInBtn *Clickable
|
||||
|
||||
scrollTable *ScrollTable
|
||||
eventFilters []event.Filter
|
||||
|
||||
deleteTrackHint string
|
||||
addTrackHint string
|
||||
uniqueOffTip, uniqueOnTip string
|
||||
splitTrackHint string
|
||||
}
|
||||
|
||||
func NewNoteEditor(model *tracker.Model) *NoteEditor {
|
||||
return &NoteEditor{
|
||||
TrackVoices: NewNumberInput(model.TrackVoices().Int()),
|
||||
NewTrackBtn: NewActionClickable(model.AddTrack()),
|
||||
DeleteTrackBtn: NewActionClickable(model.DeleteTrack()),
|
||||
AddSemitoneBtn: NewActionClickable(model.AddSemitone()),
|
||||
SubtractSemitoneBtn: NewActionClickable(model.SubtractSemitone()),
|
||||
AddOctaveBtn: NewActionClickable(model.AddOctave()),
|
||||
SubtractOctaveBtn: NewActionClickable(model.SubtractOctave()),
|
||||
NoteOffBtn: NewActionClickable(model.EditNoteOff()),
|
||||
EffectBtn: NewBoolClickable(model.Effect().Bool()),
|
||||
ret := &NoteEditor{
|
||||
TrackVoices: NewNumericUpDownState(),
|
||||
NewTrackBtn: new(Clickable),
|
||||
DeleteTrackBtn: new(Clickable),
|
||||
SplitTrackBtn: new(Clickable),
|
||||
AddSemitoneBtn: new(Clickable),
|
||||
SubtractSemitoneBtn: new(Clickable),
|
||||
AddOctaveBtn: new(Clickable),
|
||||
SubtractOctaveBtn: new(Clickable),
|
||||
NoteOffBtn: new(Clickable),
|
||||
EffectBtn: new(Clickable),
|
||||
UniqueBtn: new(Clickable),
|
||||
TrackMidiInBtn: new(Clickable),
|
||||
scrollTable: NewScrollTable(
|
||||
model.Notes().Table(),
|
||||
model.Tracks().List(),
|
||||
model.NoteRows().List(),
|
||||
model.Note().Table(),
|
||||
model.Track().List(),
|
||||
model.Note().RowList(),
|
||||
),
|
||||
}
|
||||
for k, a := range keyBindingMap {
|
||||
if len(a) < 4 || a[:4] != "Note" {
|
||||
continue
|
||||
}
|
||||
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Required: k.Modifiers, Name: k.Name})
|
||||
}
|
||||
for c := 'A'; c <= 'F'; c++ {
|
||||
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)})
|
||||
}
|
||||
for c := '0'; c <= '9'; c++ {
|
||||
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret.scrollTable, Name: key.Name(c)})
|
||||
}
|
||||
ret.deleteTrackHint = makeHint("Delete\ntrack", "\n(%s)", "DeleteTrack")
|
||||
ret.addTrackHint = makeHint("Add\ntrack", "\n(%s)", "AddTrack")
|
||||
ret.uniqueOnTip = makeHint("Duplicate non-unique patterns", " (%s)", "UniquePatternsToggle")
|
||||
ret.uniqueOffTip = makeHint("Allow editing non-unique patterns", " (%s)", "UniquePatternsToggle")
|
||||
ret.splitTrackHint = makeHint("Split track", " (%s)", "SplitTrack")
|
||||
return ret
|
||||
}
|
||||
|
||||
func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
|
||||
func (te *NoteEditor) Layout(gtx layout.Context) layout.Dimensions {
|
||||
t := TrackerFromContext(gtx)
|
||||
for {
|
||||
e, ok := gtx.Event(
|
||||
key.Filter{Focus: te.scrollTable, Name: "A"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "B"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "C"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "D"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "E"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "F"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "G"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "H"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "I"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "J"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "K"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "L"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "M"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "N"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "O"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "P"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "Q"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "R"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "S"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "T"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "U"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "V"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "W"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "X"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "Y"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "Z"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "0"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "1"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "2"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "3"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "4"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "5"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "6"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "7"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "8"},
|
||||
key.Filter{Focus: te.scrollTable, Name: "9"},
|
||||
key.Filter{Focus: te.scrollTable, Name: ","},
|
||||
key.Filter{Focus: te.scrollTable, Name: "."},
|
||||
)
|
||||
e, ok := gtx.Event(te.eventFilters...)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case key.Event:
|
||||
if e.State == key.Release {
|
||||
if noteID, ok := t.KeyPlaying[e.Name]; ok {
|
||||
noteID.NoteOff()
|
||||
delete(t.KeyPlaying, e.Name)
|
||||
}
|
||||
t.KeyNoteMap.Release(e.Name)
|
||||
continue
|
||||
}
|
||||
te.command(gtx, t, e)
|
||||
te.command(t, e)
|
||||
}
|
||||
}
|
||||
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
for gtx.Focused(te.scrollTable) && len(t.noteEvents) > 0 {
|
||||
ev := t.noteEvents[0]
|
||||
ev.IsTrack = true
|
||||
ev.Channel = t.Model.Note().Cursor().X
|
||||
ev.Source = te
|
||||
if ev.On {
|
||||
t.Model.Note().Input(ev.Note)
|
||||
}
|
||||
copy(t.noteEvents, t.noteEvents[1:])
|
||||
t.noteEvents = t.noteEvents[:len(t.noteEvents)-1]
|
||||
tracker.TrySend(t.Broker().ToPlayer, any(ev))
|
||||
}
|
||||
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
|
||||
|
||||
return Surface{Gray: 24, Focus: te.scrollTable.Focused()}.Layout(gtx, func(gtx C) D {
|
||||
return Surface{Height: 3, Focus: te.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return te.layoutButtons(gtx, t)
|
||||
@ -156,33 +162,41 @@ func (te *NoteEditor) Layout(gtx layout.Context, t *Tracker) layout.Dimensions {
|
||||
}
|
||||
|
||||
func (te *NoteEditor) layoutButtons(gtx C, t *Tracker) D {
|
||||
return Surface{Gray: 37, Focus: te.scrollTable.Focused() || te.scrollTable.ChildFocused(), FitSize: true}.Layout(gtx, func(gtx C) D {
|
||||
addSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.AddSemitoneBtn, "+1")
|
||||
subtractSemitoneBtnStyle := ActionButton(gtx, t.Theme, te.SubtractSemitoneBtn, "-1")
|
||||
addOctaveBtnStyle := ActionButton(gtx, t.Theme, te.AddOctaveBtn, "+12")
|
||||
subtractOctaveBtnStyle := ActionButton(gtx, t.Theme, te.SubtractOctaveBtn, "-12")
|
||||
noteOffBtnStyle := ActionButton(gtx, t.Theme, te.NoteOffBtn, "Note Off")
|
||||
deleteTrackBtnStyle := ActionIcon(gtx, t.Theme, te.DeleteTrackBtn, icons.ActionDelete, "Delete track\n(Ctrl+Shift+T)")
|
||||
newTrackBtnStyle := ActionIcon(gtx, t.Theme, te.NewTrackBtn, icons.ContentAdd, "Add track\n(Ctrl+T)")
|
||||
return Surface{Height: 4, Focus: te.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
|
||||
addSemitoneBtn := ActionBtn(t.Note().AddSemitone(), t.Theme, te.AddSemitoneBtn, "+1", "Add semitone")
|
||||
subtractSemitoneBtn := ActionBtn(t.Note().SubtractSemitone(), t.Theme, te.SubtractSemitoneBtn, "-1", "Subtract semitone")
|
||||
addOctaveBtn := ActionBtn(t.Note().AddOctave(), t.Theme, te.AddOctaveBtn, "+12", "Add octave")
|
||||
subtractOctaveBtn := ActionBtn(t.Note().SubtractOctave(), t.Theme, te.SubtractOctaveBtn, "-12", "Subtract octave")
|
||||
noteOffBtn := ActionBtn(t.Note().NoteOff(), t.Theme, te.NoteOffBtn, "Note Off", "")
|
||||
deleteTrackBtn := ActionIconBtn(t.Track().Delete(), t.Theme, te.DeleteTrackBtn, icons.ActionDelete, te.deleteTrackHint)
|
||||
splitTrackBtn := ActionIconBtn(t.Track().Split(), t.Theme, te.SplitTrackBtn, icons.CommunicationCallSplit, te.splitTrackHint)
|
||||
newTrackBtn := ActionIconBtn(t.Track().Add(), t.Theme, te.NewTrackBtn, icons.ContentAdd, te.addTrackHint)
|
||||
trackVoices := NumUpDown(t.Model.Track().Voices(), t.Theme, te.TrackVoices, "Track voices")
|
||||
in := layout.UniformInset(unit.Dp(1))
|
||||
voiceUpDown := func(gtx C) D {
|
||||
numStyle := NumericUpDown(t.Theme, te.TrackVoices, "Number of voices for this track")
|
||||
return in.Layout(gtx, numStyle.Layout)
|
||||
trackVoicesInsetted := func(gtx C) D {
|
||||
return in.Layout(gtx, trackVoices.Layout)
|
||||
}
|
||||
effectBtnStyle := ToggleButton(gtx, t.Theme, te.EffectBtn, "Hex")
|
||||
effectBtn := ToggleBtn(t.Track().Effect(), t.Theme, te.EffectBtn, "Hex", "Input notes as hex values")
|
||||
uniqueBtn := ToggleIconBtn(t.Note().UniquePatterns(), t.Theme, te.UniqueBtn, icons.ToggleStarBorder, icons.ToggleStar, te.uniqueOffTip, te.uniqueOnTip)
|
||||
midiInBtn := ToggleBtn(t.MIDI().InputtingNotes(), t.Theme, te.TrackMidiInBtn, "MIDI", "Input notes from MIDI keyboard")
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return layout.Dimensions{Size: image.Pt(gtx.Dp(unit.Dp(12)), 0)} }),
|
||||
layout.Rigid(addSemitoneBtnStyle.Layout),
|
||||
layout.Rigid(subtractSemitoneBtnStyle.Layout),
|
||||
layout.Rigid(addOctaveBtnStyle.Layout),
|
||||
layout.Rigid(subtractOctaveBtnStyle.Layout),
|
||||
layout.Rigid(noteOffBtnStyle.Layout),
|
||||
layout.Rigid(effectBtnStyle.Layout),
|
||||
layout.Rigid(Label(" Voices:", white, t.Theme.Shaper)),
|
||||
layout.Rigid(voiceUpDown),
|
||||
layout.Rigid(addSemitoneBtn.Layout),
|
||||
layout.Rigid(subtractSemitoneBtn.Layout),
|
||||
layout.Rigid(addOctaveBtn.Layout),
|
||||
layout.Rigid(subtractOctaveBtn.Layout),
|
||||
layout.Rigid(noteOffBtn.Layout),
|
||||
layout.Rigid(effectBtn.Layout),
|
||||
layout.Rigid(uniqueBtn.Layout),
|
||||
layout.Rigid(layout.Spacer{Width: 10}.Layout),
|
||||
layout.Rigid(Label(t.Theme, &t.Theme.NoteEditor.Header, "Voices").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: 4}.Layout),
|
||||
layout.Rigid(trackVoicesInsetted),
|
||||
layout.Rigid(splitTrackBtn.Layout),
|
||||
layout.Rigid(midiInBtn.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(deleteTrackBtnStyle.Layout),
|
||||
layout.Rigid(newTrackBtnStyle.Layout))
|
||||
layout.Rigid(deleteTrackBtn.Layout),
|
||||
layout.Rigid(newTrackBtn.Layout))
|
||||
})
|
||||
}
|
||||
|
||||
@ -204,123 +218,142 @@ var notes = []string{
|
||||
}
|
||||
|
||||
func (te *NoteEditor) layoutTracks(gtx C, t *Tracker) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
|
||||
|
||||
beatMarkerDensity := t.RowsPerBeat().Value()
|
||||
beatMarkerDensity := t.Song().RowsPerBeat().Value()
|
||||
switch beatMarkerDensity {
|
||||
case 0, 1, 2:
|
||||
beatMarkerDensity = 4
|
||||
}
|
||||
|
||||
playSongRow := t.PlaySongRow()
|
||||
playSongRow := t.Play().SongRow()
|
||||
pxWidth := gtx.Dp(trackColWidth)
|
||||
pxHeight := gtx.Dp(trackRowHeight)
|
||||
pxPatMarkWidth := gtx.Dp(trackPatMarkWidth)
|
||||
pxRowMarkWidth := gtx.Dp(trackRowMarkWidth)
|
||||
|
||||
colTitle := func(gtx C, i int) D {
|
||||
h := gtx.Dp(unit.Dp(trackColTitleHeight))
|
||||
title := ((*tracker.Order)(t.Model)).Title(i)
|
||||
h := gtx.Dp(trackColTitleHeight)
|
||||
gtx.Constraints = layout.Exact(image.Pt(pxWidth, h))
|
||||
LabelStyle{Alignment: layout.N, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx)
|
||||
Label(t.Theme, &t.Theme.NoteEditor.TrackTitle, t.Model.Track().Item(i).Title).Layout(gtx)
|
||||
return D{Size: image.Pt(pxWidth, h)}
|
||||
}
|
||||
|
||||
rowTitleBg := func(gtx C, j int) D {
|
||||
if mod(j, beatMarkerDensity*2) == 0 {
|
||||
paint.FillShape(gtx.Ops, twoBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.TwoBeat, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
} else if mod(j, beatMarkerDensity) == 0 {
|
||||
paint.FillShape(gtx.Ops, oneBeatHighlight, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.OneBeat, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
}
|
||||
if t.SongPanel.PlayingBtn.Bool.Value() && j == playSongRow {
|
||||
paint.FillShape(gtx.Ops, trackerPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
if t.Model.Play().Started().Value() && j == playSongRow {
|
||||
paint.FillShape(gtx.Ops, t.Theme.NoteEditor.Play, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, pxHeight)}.Op())
|
||||
}
|
||||
return D{}
|
||||
}
|
||||
|
||||
orderRowOp := colorOp(gtx, t.Theme.NoteEditor.OrderRow.Color)
|
||||
loopColorOp := colorOp(gtx, t.Theme.OrderEditor.Loop)
|
||||
patternRowOp := colorOp(gtx, t.Theme.NoteEditor.PatternRow.Color)
|
||||
|
||||
rowTitle := func(gtx C, j int) D {
|
||||
rpp := intMax(t.RowsPerPattern().Value(), 1)
|
||||
rpp := max(t.Song().RowsPerPattern().Value(), 1)
|
||||
pat := j / rpp
|
||||
row := j % rpp
|
||||
w := pxPatMarkWidth + pxRowMarkWidth
|
||||
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
|
||||
if row == 0 {
|
||||
color := rowMarkerPatternTextColor
|
||||
if l := t.Loop(); pat >= l.Start && pat < l.Start+l.Length {
|
||||
color = loopMarkerColor
|
||||
op := orderRowOp
|
||||
if l := t.Play().Loop(); pat >= l.Start && pat < l.Start+l.Length {
|
||||
op = loopColorOp
|
||||
}
|
||||
paint.ColorOp{Color: color}.Add(gtx.Ops)
|
||||
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", pat)), op.CallOp{})
|
||||
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.OrderRow.Font, t.Theme.NoteEditor.OrderRow.TextSize, hexStr[pat&255], op)
|
||||
}
|
||||
defer op.Offset(image.Pt(pxPatMarkWidth, 0)).Push(gtx.Ops).Pop()
|
||||
paint.ColorOp{Color: rowMarkerRowTextColor}.Add(gtx.Ops)
|
||||
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", row)), op.CallOp{})
|
||||
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.PatternRow.Font, t.Theme.NoteEditor.PatternRow.TextSize, hexStr[row&255], patternRowOp)
|
||||
return D{Size: image.Pt(w, pxHeight)}
|
||||
}
|
||||
|
||||
drawSelection := te.scrollTable.Table.Cursor() != te.scrollTable.Table.Cursor2()
|
||||
cursor := te.scrollTable.Table.Cursor()
|
||||
drawSelection := cursor != te.scrollTable.Table.Cursor2()
|
||||
selection := te.scrollTable.Table.Range()
|
||||
hasTrackMidiIn := t.MIDI().InputtingNotes().Value()
|
||||
|
||||
patternNoOp := colorOp(gtx, t.Theme.NoteEditor.PatternNo.Color)
|
||||
uniqueOp := colorOp(gtx, t.Theme.NoteEditor.Unique.Color)
|
||||
noteOp := colorOp(gtx, t.Theme.NoteEditor.Note.Color)
|
||||
|
||||
cell := func(gtx C, x, y int) D {
|
||||
// draw the background, to indicate selection
|
||||
color := transparent
|
||||
point := tracker.Point{X: x, Y: y}
|
||||
if drawSelection && selection.Contains(point) {
|
||||
color = inactiveSelectionColor
|
||||
if te.scrollTable.Focused() {
|
||||
color = selectionColor
|
||||
color := t.Theme.Selection.Inactive
|
||||
if gtx.Focused(te.scrollTable) {
|
||||
color = t.Theme.Selection.Active
|
||||
}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
|
||||
}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(0, 0), Max: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}.Op())
|
||||
// draw the cursor
|
||||
if point == te.scrollTable.Table.Cursor() {
|
||||
cw := gtx.Constraints.Min.X
|
||||
cx := 0
|
||||
if t.Model.Notes().Effect(x) {
|
||||
cw /= 2
|
||||
if t.Model.Notes().LowNibble() {
|
||||
cx += cw
|
||||
}
|
||||
if point == cursor {
|
||||
c := t.Theme.Cursor.Inactive
|
||||
if gtx.Focused(te.scrollTable) {
|
||||
c = t.Theme.Cursor.Active
|
||||
}
|
||||
c := inactiveSelectionColor
|
||||
if te.scrollTable.Focused() {
|
||||
c = cursorColor
|
||||
if hasTrackMidiIn {
|
||||
c = t.Theme.Cursor.ActiveAlt
|
||||
}
|
||||
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
|
||||
te.paintColumnCell(gtx, x, t, c)
|
||||
}
|
||||
|
||||
// draw the pattern marker
|
||||
rpp := intMax(t.RowsPerPattern().Value(), 1)
|
||||
rpp := max(t.Song().RowsPerPattern().Value(), 1)
|
||||
pat := y / rpp
|
||||
row := y % rpp
|
||||
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
|
||||
s := t.Model.Order().Value(tracker.Point{X: x, Y: pat})
|
||||
if row == 0 { // draw the pattern marker
|
||||
paint.ColorOp{Color: trackerPatMarker}.Add(gtx.Ops)
|
||||
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, patternIndexToString(s), op.CallOp{})
|
||||
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.PatternNo.Font, t.Theme.NoteEditor.PatternNo.TextSize, patternIndexToString(s), patternNoOp)
|
||||
}
|
||||
if row == 1 && t.Model.Notes().Unique(x, s) { // draw a * if the pattern is unique
|
||||
paint.ColorOp{Color: mediumEmphasisTextColor}.Add(gtx.Ops)
|
||||
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, "*", op.CallOp{})
|
||||
if row == 1 && t.Order().PatternUnique(x, s) { // draw a * if the pattern is unique
|
||||
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.Unique.Font, t.Theme.NoteEditor.Unique.TextSize, "*", uniqueOp)
|
||||
}
|
||||
if te.scrollTable.Table.Cursor() == point && te.scrollTable.Focused() {
|
||||
paint.ColorOp{Color: trackerActiveTextColor}.Add(gtx.Ops)
|
||||
} else {
|
||||
paint.ColorOp{Color: trackerInactiveTextColor}.Add(gtx.Ops)
|
||||
op := noteOp
|
||||
val := noteName[byte(t.Model.Note().At(tracker.Point{X: x, Y: y}))]
|
||||
if t.Model.Track().Item(x).Effect {
|
||||
val = noteHex[byte(t.Model.Note().At(tracker.Point{X: x, Y: y}))]
|
||||
}
|
||||
val := noteStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))]
|
||||
if t.Model.Notes().Effect(x) {
|
||||
val = hexStr[byte(t.Model.Notes().Value(tracker.Point{X: x, Y: y}))]
|
||||
}
|
||||
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{})
|
||||
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.NoteEditor.Note.Font, t.Theme.NoteEditor.Note.TextSize, val, op)
|
||||
return D{Size: image.Pt(pxWidth, pxHeight)}
|
||||
}
|
||||
table := FilledScrollTable(t.Theme, te.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
|
||||
table := FilledScrollTable(t.Theme, te.scrollTable)
|
||||
table.RowTitleWidth = trackPatMarkWidth + trackRowMarkWidth
|
||||
table.ColumnTitleHeight = trackColTitleHeight
|
||||
table.CellWidth = trackColWidth
|
||||
table.CellHeight = trackRowHeight
|
||||
return table.Layout(gtx)
|
||||
return table.Layout(gtx, cell, colTitle, rowTitle, nil, rowTitleBg)
|
||||
}
|
||||
|
||||
func (t *NoteEditor) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level+1, t.scrollTable.RowTitleList) &&
|
||||
yield(level+1, t.scrollTable.ColTitleList) &&
|
||||
yield(level, t.scrollTable)
|
||||
}
|
||||
|
||||
func colorOp(gtx C, c color.NRGBA) op.CallOp {
|
||||
macro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: c}.Add(gtx.Ops)
|
||||
return macro.Stop()
|
||||
}
|
||||
|
||||
func (te *NoteEditor) paintColumnCell(gtx C, x int, t *Tracker, c color.NRGBA) {
|
||||
cw := gtx.Constraints.Min.X
|
||||
cx := 0
|
||||
if t.Model.Track().Item(x).Effect {
|
||||
cw /= 2
|
||||
if t.Model.Note().LowNibble() {
|
||||
cx += cw
|
||||
}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, c, clip.Rect{Min: image.Pt(cx, 0), Max: image.Pt(cx+cw, gtx.Constraints.Min.Y)}.Op())
|
||||
}
|
||||
|
||||
func mod(x, d int) int {
|
||||
@ -338,47 +371,31 @@ func noteAsValue(octave, note int) byte {
|
||||
return byte(baseNote + (octave * 12) + note)
|
||||
}
|
||||
|
||||
func (te *NoteEditor) command(gtx C, t *Tracker, e key.Event) {
|
||||
if e.Name == "A" || e.Name == "1" {
|
||||
t.Model.Notes().Table().Fill(0)
|
||||
te.scrollTable.EnsureCursorVisible()
|
||||
return
|
||||
}
|
||||
func (te *NoteEditor) command(t *Tracker, e key.Event) {
|
||||
var n byte
|
||||
if t.Model.Notes().Effect(te.scrollTable.Table.Cursor().X) {
|
||||
if t.Model.Track().Item(te.scrollTable.Table.Cursor().X).Effect {
|
||||
if nibbleValue, err := strconv.ParseInt(string(e.Name), 16, 8); err == nil {
|
||||
n = t.Model.Notes().Value(te.scrollTable.Table.Cursor())
|
||||
t.Model.Notes().FillNibble(byte(nibbleValue), t.Model.Notes().LowNibble())
|
||||
goto validNote
|
||||
ev := t.Model.Note().InputNibble(byte(nibbleValue))
|
||||
t.KeyNoteMap.Press(e.Name, ev)
|
||||
}
|
||||
} else {
|
||||
if val, ok := noteMap[e.Name]; ok {
|
||||
n = noteAsValue(t.OctaveNumberInput.Int.Value(), val)
|
||||
t.Model.Notes().Table().Fill(int(n))
|
||||
goto validNote
|
||||
action, ok := keyBindingMap[e]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if action == "NoteOff" {
|
||||
ev := t.Model.Note().Input(0)
|
||||
t.KeyNoteMap.Press(e.Name, ev)
|
||||
return
|
||||
}
|
||||
if action[:4] == "Note" {
|
||||
val, err := strconv.Atoi(string(action[4:]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n = noteAsValue(t.Note().Octave().Value(), val-12)
|
||||
ev := t.Model.Note().Input(n)
|
||||
t.KeyNoteMap.Press(e.Name, ev)
|
||||
}
|
||||
}
|
||||
return
|
||||
validNote:
|
||||
te.scrollTable.EnsureCursorVisible()
|
||||
if _, ok := t.KeyPlaying[e.Name]; !ok {
|
||||
trk := te.scrollTable.Table.Cursor().X
|
||||
t.KeyPlaying[e.Name] = t.TrackNoteOn(trk, n)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
case "+":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
te.AddOctaveBtn.Action.Do()
|
||||
} else {
|
||||
te.AddSemitoneBtn.Action.Do()
|
||||
}
|
||||
case "-":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
te.SubtractSemitoneBtn.Action.Do()
|
||||
} else {
|
||||
te.SubtractOctaveBtn.Action.Do()
|
||||
}
|
||||
}*/
|
||||
|
||||
@ -1,220 +1,158 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strconv"
|
||||
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/x/component"
|
||||
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
type NumberInput struct {
|
||||
Int tracker.Int
|
||||
dragStartValue int
|
||||
dragStartXY float32
|
||||
clickDecrease gesture.Click
|
||||
clickIncrease gesture.Click
|
||||
tipArea component.TipArea
|
||||
type (
|
||||
NumericUpDownState struct {
|
||||
DpPerStep unit.Dp
|
||||
|
||||
dragStartValue int
|
||||
dragStartXY float32
|
||||
clickDecrease gesture.Click
|
||||
clickIncrease gesture.Click
|
||||
tipArea TipArea
|
||||
}
|
||||
|
||||
NumericUpDownStyle struct {
|
||||
TextColor color.NRGBA `yaml:",flow"`
|
||||
IconColor color.NRGBA `yaml:",flow"`
|
||||
BgColor color.NRGBA `yaml:",flow"`
|
||||
CornerRadius unit.Dp
|
||||
ButtonWidth unit.Dp
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
TextSize unit.Sp
|
||||
Font font.Font
|
||||
}
|
||||
|
||||
NumericUpDown struct {
|
||||
Int tracker.Int
|
||||
Theme *Theme
|
||||
State *NumericUpDownState
|
||||
Style *NumericUpDownStyle
|
||||
Tip string
|
||||
}
|
||||
)
|
||||
|
||||
func NewNumericUpDownState() *NumericUpDownState {
|
||||
return &NumericUpDownState{DpPerStep: unit.Dp(8)}
|
||||
}
|
||||
|
||||
type NumericUpDownStyle struct {
|
||||
NumberInput *NumberInput
|
||||
Color color.NRGBA
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
BorderColor color.NRGBA
|
||||
IconColor color.NRGBA
|
||||
BackgroundColor color.NRGBA
|
||||
CornerRadius unit.Dp
|
||||
Border unit.Dp
|
||||
ButtonWidth unit.Dp
|
||||
UnitsPerStep unit.Dp
|
||||
Tooltip component.Tooltip
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
shaper text.Shaper
|
||||
}
|
||||
|
||||
func NewNumberInput(v tracker.Int) *NumberInput {
|
||||
return &NumberInput{Int: v}
|
||||
}
|
||||
|
||||
func NumericUpDown(th *material.Theme, number *NumberInput, tooltip string) NumericUpDownStyle {
|
||||
bgColor := th.Palette.Fg
|
||||
bgColor.R /= 4
|
||||
bgColor.G /= 4
|
||||
bgColor.B /= 4
|
||||
return NumericUpDownStyle{
|
||||
NumberInput: number,
|
||||
Color: white,
|
||||
BorderColor: th.Palette.Fg,
|
||||
IconColor: th.Palette.ContrastFg,
|
||||
BackgroundColor: bgColor,
|
||||
CornerRadius: unit.Dp(4),
|
||||
ButtonWidth: unit.Dp(16),
|
||||
Border: unit.Dp(1),
|
||||
UnitsPerStep: unit.Dp(8),
|
||||
TextSize: th.TextSize * 14 / 16,
|
||||
Tooltip: Tooltip(th, tooltip),
|
||||
Width: unit.Dp(70),
|
||||
Height: unit.Dp(20),
|
||||
shaper: *th.Shaper,
|
||||
func NumUpDown(v tracker.Int, th *Theme, n *NumericUpDownState, tip string) NumericUpDown {
|
||||
return NumericUpDown{
|
||||
Int: v,
|
||||
Theme: th,
|
||||
State: n,
|
||||
Style: &th.NumericUpDown,
|
||||
Tip: tip,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) Layout(gtx C) D {
|
||||
if s.Tooltip.Text.Text != "" {
|
||||
return s.NumberInput.tipArea.Layout(gtx, s.Tooltip, s.actualLayout)
|
||||
}
|
||||
return s.actualLayout(gtx)
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) actualLayout(gtx C) D {
|
||||
size := image.Pt(gtx.Dp(s.Width), gtx.Dp(s.Height))
|
||||
gtx.Constraints.Min = size
|
||||
rr := gtx.Dp(s.CornerRadius)
|
||||
border := gtx.Dp(s.Border)
|
||||
c := clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops)
|
||||
paint.Fill(gtx.Ops, s.BorderColor)
|
||||
c.Pop()
|
||||
off := op.Offset(image.Pt(border, border)).Push(gtx.Ops)
|
||||
c2 := clip.UniformRRect(image.Rectangle{Max: image.Pt(
|
||||
gtx.Constraints.Min.X-border*2,
|
||||
gtx.Constraints.Min.Y-border*2,
|
||||
)}, rr-border).Push(gtx.Ops)
|
||||
gtx.Constraints.Min.X -= int(border * 2)
|
||||
gtx.Constraints.Min.Y -= int(border * 2)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowBack), -1, &s.NumberInput.clickDecrease)),
|
||||
layout.Flexed(1, s.layoutText),
|
||||
layout.Rigid(s.button(gtx.Constraints.Max.Y, widgetForIcon(icons.NavigationArrowForward), 1, &s.NumberInput.clickIncrease)),
|
||||
)
|
||||
off.Pop()
|
||||
c2.Pop()
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) button(height int, icon *widget.Icon, delta int, click *gesture.Click) layout.Widget {
|
||||
return func(gtx C) D {
|
||||
btnWidth := gtx.Dp(s.ButtonWidth)
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
//paint.FillShape(gtx.Ops, black, clip.Rect(image.Rect(0, 0, btnWidth, height)).Op())
|
||||
return layout.Dimensions{Size: image.Point{X: btnWidth, Y: height}}
|
||||
}),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
size := btnWidth
|
||||
if height < size {
|
||||
size = height
|
||||
}
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
if icon != nil {
|
||||
p := gtx.Dp(unit.Dp(size))
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
gtx.Constraints = layout.Exact(image.Pt(p, p))
|
||||
return icon.Layout(gtx, s.IconColor)
|
||||
}
|
||||
return layout.Dimensions{}
|
||||
}),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
return s.layoutClick(gtx, delta, click)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) layoutText(gtx C) D {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Stacked(func(gtx C) D {
|
||||
paint.FillShape(gtx.Ops, s.BackgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}),
|
||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||
paint.ColorOp{Color: s.Color}.Add(gtx.Ops)
|
||||
return widget.Label{Alignment: text.Middle}.Layout(gtx, &s.shaper, s.Font, s.TextSize, fmt.Sprintf("%v", s.NumberInput.Int.Value()), op.CallOp{})
|
||||
}),
|
||||
layout.Expanded(s.layoutDrag),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) layoutDrag(gtx layout.Context) layout.Dimensions {
|
||||
{ // handle dragging
|
||||
pxPerStep := float32(gtx.Dp(s.UnitsPerStep))
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: s.NumberInput,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := ev.(pointer.Event); ok {
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
s.NumberInput.dragStartValue = s.NumberInput.Int.Value()
|
||||
s.NumberInput.dragStartXY = e.Position.X - e.Position.Y
|
||||
|
||||
case pointer.Drag:
|
||||
var deltaCoord float32
|
||||
deltaCoord = e.Position.X - e.Position.Y - s.NumberInput.dragStartXY
|
||||
s.NumberInput.Int.Set(s.NumberInput.dragStartValue + int(deltaCoord/pxPerStep+0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid affecting the input tree with pointer events.
|
||||
stack := op.Offset(image.Point{}).Push(gtx.Ops)
|
||||
// register for input
|
||||
dragRect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
|
||||
area := clip.Rect(dragRect).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, s.NumberInput)
|
||||
area.Pop()
|
||||
stack.Pop()
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}
|
||||
|
||||
func (s *NumericUpDownStyle) layoutClick(gtx layout.Context, delta int, click *gesture.Click) layout.Dimensions {
|
||||
// handle clicking
|
||||
func (s *NumericUpDownState) Update(gtx layout.Context, v tracker.Int) {
|
||||
// handle dragging
|
||||
pxPerStep := float32(gtx.Dp(s.DpPerStep))
|
||||
for {
|
||||
ev, ok := click.Update(gtx.Source)
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: s,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch ev.Kind {
|
||||
case gesture.KindClick:
|
||||
s.NumberInput.Int.Add(delta)
|
||||
if e, ok := ev.(pointer.Event); ok {
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
s.dragStartValue = v.Value()
|
||||
s.dragStartXY = e.Position.X - e.Position.Y
|
||||
case pointer.Drag:
|
||||
var deltaCoord float32
|
||||
deltaCoord = e.Position.X - e.Position.Y - s.dragStartXY
|
||||
v.SetValue(s.dragStartValue + int(deltaCoord/pxPerStep+0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
// handle decrease clicks
|
||||
for ev, ok := s.clickDecrease.Update(gtx.Source); ok; ev, ok = s.clickDecrease.Update(gtx.Source) {
|
||||
if ev.Kind == gesture.KindClick {
|
||||
v.Add(-1)
|
||||
}
|
||||
}
|
||||
// handle increase clicks
|
||||
for ev, ok := s.clickIncrease.Update(gtx.Source); ok; ev, ok = s.clickIncrease.Update(gtx.Source) {
|
||||
if ev.Kind == gesture.KindClick {
|
||||
v.Add(1)
|
||||
}
|
||||
}
|
||||
// Avoid affecting the input tree with pointer events.
|
||||
stack := op.Offset(image.Point{}).Push(gtx.Ops)
|
||||
|
||||
// register for input
|
||||
clickRect := image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
|
||||
area := clip.Rect(clickRect).Push(gtx.Ops)
|
||||
click.Add(gtx.Ops)
|
||||
area.Pop()
|
||||
stack.Pop()
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}
|
||||
|
||||
func (n *NumericUpDown) Layout(gtx C) D {
|
||||
n.State.Update(gtx, n.Int)
|
||||
if n.Tip != "" {
|
||||
return n.State.tipArea.Layout(gtx, Tooltip(n.Theme, n.Tip), n.actualLayout)
|
||||
}
|
||||
return n.actualLayout(gtx)
|
||||
}
|
||||
|
||||
func (n *NumericUpDown) actualLayout(gtx C) D {
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(n.Style.Width), gtx.Dp(n.Style.Height)))
|
||||
width := gtx.Dp(n.Style.ButtonWidth)
|
||||
height := gtx.Dp(n.Style.Height)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, gtx.Dp(n.Style.CornerRadius)).Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, n.Style.BgColor)
|
||||
event.Op(gtx.Ops, n.State) // register drag inputs, if not hitting the clicks
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
gtx.Constraints = layout.Exact(image.Pt(width, height))
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
|
||||
n.State.clickDecrease.Add(gtx.Ops)
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx C) D { return n.Theme.Icon(icons.ContentRemove).Layout(gtx, n.Style.IconColor) },
|
||||
)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
paint.ColorOp{Color: n.Style.TextColor}.Add(gtx.Ops)
|
||||
return widget.Label{Alignment: text.Middle}.Layout(gtx, n.Theme.Material.Shaper, n.Style.Font, n.Style.TextSize, strconv.Itoa(n.Int.Value()), op.CallOp{})
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
gtx.Constraints = layout.Exact(image.Pt(width, height))
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
|
||||
n.State.clickIncrease.Add(gtx.Ops)
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx C) D { return n.Theme.Icon(icons.ContentAdd).Layout(gtx, n.Style.IconColor) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
@ -22,7 +20,6 @@ import (
|
||||
|
||||
const patternCellHeight = unit.Dp(16)
|
||||
const patternCellWidth = unit.Dp(16)
|
||||
const patternRowMarkerWidth = unit.Dp(30)
|
||||
const orderTitleHeight = unit.Dp(52)
|
||||
|
||||
type OrderEditor struct {
|
||||
@ -45,13 +42,14 @@ func NewOrderEditor(m *tracker.Model) *OrderEditor {
|
||||
return &OrderEditor{
|
||||
scrollTable: NewScrollTable(
|
||||
m.Order().Table(),
|
||||
m.Tracks().List(),
|
||||
m.OrderRows().List(),
|
||||
m.Track().List(),
|
||||
m.Order().RowList(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
|
||||
func (oe *OrderEditor) Layout(gtx C) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
if oe.scrollTable.CursorMoved() {
|
||||
cursor := t.TrackEditor.scrollTable.Table.Cursor()
|
||||
t.TrackEditor.scrollTable.ColTitleList.CenterOn(cursor.X)
|
||||
@ -69,56 +67,62 @@ func (oe *OrderEditor) Layout(gtx C, t *Tracker) D {
|
||||
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
|
||||
defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(0, 0), -90*math.Pi/180).Offset(f32.Point{X: 0, Y: float32(h)})).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints = layout.Exact(image.Pt(1e6, 1e6))
|
||||
title := t.Model.Order().Title(i)
|
||||
LabelStyle{Alignment: layout.NW, Text: title, FontSize: unit.Sp(12), Color: mediumEmphasisTextColor, Shaper: t.Theme.Shaper}.Layout(gtx)
|
||||
Label(t.Theme, &t.Theme.OrderEditor.TrackTitle, t.Model.Track().Item(i).Title).Layout(gtx)
|
||||
return D{Size: image.Pt(gtx.Dp(patternCellWidth), h)}
|
||||
}
|
||||
|
||||
rowTitleBg := func(gtx C, j int) D {
|
||||
if t.SongPanel.PlayingBtn.Bool.Value() && j == t.PlayPosition().OrderRow {
|
||||
paint.FillShape(gtx.Ops, patternPlayColor, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, gtx.Dp(patternCellHeight))}.Op())
|
||||
if t.Model.Play().Started().Value() && j == t.Play().Position().OrderRow {
|
||||
paint.FillShape(gtx.Ops, t.Theme.OrderEditor.Play, clip.Rect{Max: image.Pt(gtx.Constraints.Max.X, gtx.Dp(patternCellHeight))}.Op())
|
||||
}
|
||||
return D{}
|
||||
}
|
||||
|
||||
rowMarkerPatternTextColorOp := colorOp(gtx, t.Theme.OrderEditor.RowTitle.Color)
|
||||
loopMarkerColorOp := colorOp(gtx, t.Theme.OrderEditor.Loop)
|
||||
|
||||
rowTitle := func(gtx C, j int) D {
|
||||
w := gtx.Dp(unit.Dp(30))
|
||||
color := rowMarkerPatternTextColor
|
||||
if l := t.Loop(); j >= l.Start && j < l.Start+l.Length {
|
||||
color = loopMarkerColor
|
||||
callOp := rowMarkerPatternTextColorOp
|
||||
if l := t.Play().Loop(); j >= l.Start && j < l.Start+l.Length {
|
||||
callOp = loopMarkerColorOp
|
||||
}
|
||||
paint.ColorOp{Color: color}.Add(gtx.Ops)
|
||||
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
|
||||
widget.Label{}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, strings.ToUpper(fmt.Sprintf("%02x", j)), op.CallOp{})
|
||||
widget.Label{}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.OrderEditor.RowTitle.Font, t.Theme.OrderEditor.RowTitle.TextSize, hexStr[j&255], callOp)
|
||||
return D{Size: image.Pt(w, gtx.Dp(patternCellHeight))}
|
||||
}
|
||||
|
||||
selection := oe.scrollTable.Table.Range()
|
||||
cellColorOp := colorOp(gtx, t.Theme.OrderEditor.Cell.Color)
|
||||
|
||||
cell := func(gtx C, x, y int) D {
|
||||
val := patternIndexToString(t.Model.Order().Value(tracker.Point{X: x, Y: y}))
|
||||
color := patternCellColor
|
||||
color := t.Theme.OrderEditor.CellBg
|
||||
point := tracker.Point{X: x, Y: y}
|
||||
if selection.Contains(point) {
|
||||
color = inactiveSelectionColor
|
||||
if oe.scrollTable.Focused() {
|
||||
color = selectionColor
|
||||
if point == oe.scrollTable.Table.Cursor() {
|
||||
color = cursorColor
|
||||
color = t.Theme.Selection.Inactive
|
||||
if gtx.Focused(oe.scrollTable) {
|
||||
color = t.Theme.Selection.Active
|
||||
}
|
||||
if point == oe.scrollTable.Table.Cursor() {
|
||||
color = t.Theme.Cursor.Inactive
|
||||
if gtx.Focused(oe.scrollTable) {
|
||||
color = t.Theme.Cursor.Active
|
||||
}
|
||||
}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{Min: image.Pt(1, 1), Max: image.Pt(gtx.Constraints.Min.X-1, gtx.Constraints.Min.X-1)}.Op())
|
||||
paint.ColorOp{Color: patternTextColor}.Add(gtx.Ops)
|
||||
defer op.Offset(image.Pt(0, -2)).Push(gtx.Ops).Pop()
|
||||
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Shaper, trackerFont, trackerFontSize, val, op.CallOp{})
|
||||
widget.Label{Alignment: text.Middle}.Layout(gtx, t.Theme.Material.Shaper, t.Theme.OrderEditor.Cell.Font, t.Theme.OrderEditor.Cell.TextSize, val, cellColorOp)
|
||||
return D{Size: image.Pt(gtx.Dp(patternCellWidth), gtx.Dp(patternCellHeight))}
|
||||
}
|
||||
|
||||
table := FilledScrollTable(t.Theme, oe.scrollTable, cell, colTitle, rowTitle, nil, rowTitleBg)
|
||||
table := FilledScrollTable(t.Theme, oe.scrollTable)
|
||||
table.ColumnTitleHeight = orderTitleHeight
|
||||
|
||||
return table.Layout(gtx)
|
||||
return Surface{Height: 3, Focus: oe.scrollTable.TreeFocused(gtx)}.Layout(gtx, func(gtx C) D {
|
||||
return table.Layout(gtx, cell, colTitle, rowTitle, nil, rowTitleBg)
|
||||
})
|
||||
}
|
||||
|
||||
func (oe *OrderEditor) handleEvents(gtx C, t *Tracker) {
|
||||
@ -171,27 +175,23 @@ func (oe *OrderEditor) handleEvents(gtx C, t *Tracker) {
|
||||
if e.State != key.Press {
|
||||
continue
|
||||
}
|
||||
oe.command(gtx, t, e)
|
||||
oe.command(t, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) {
|
||||
func (oe *OrderEditor) command(t *Tracker, e key.Event) {
|
||||
switch e.Name {
|
||||
case key.NameDeleteBackward:
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.DeleteOrderRow(true).Do()
|
||||
t.Model.Order().DeleteRow(true).Do()
|
||||
}
|
||||
case key.NameDeleteForward:
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Model.DeleteOrderRow(false).Do()
|
||||
t.Model.Order().DeleteRow(false).Do()
|
||||
}
|
||||
case key.NameReturn:
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
oe.scrollTable.Table.MoveCursor(0, -1)
|
||||
oe.scrollTable.Table.SetCursor2(oe.scrollTable.Table.Cursor())
|
||||
}
|
||||
t.Model.AddOrderRow(!e.Modifiers.Contain(key.ModShortcut)).Do()
|
||||
t.Model.Order().AddRow(e.Modifiers.Contain(key.ModShortcut)).Do()
|
||||
}
|
||||
if iv, err := strconv.Atoi(string(e.Name)); err == nil {
|
||||
t.Model.Order().SetValue(oe.scrollTable.Table.Cursor(), iv)
|
||||
@ -203,6 +203,10 @@ func (oe *OrderEditor) command(gtx C, t *Tracker, e key.Event) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *OrderEditor) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level+1, t.scrollTable.RowTitleList) && yield(level+1, t.scrollTable.ColTitleList) && yield(level, t.scrollTable)
|
||||
}
|
||||
|
||||
func patternIndexToString(index int) string {
|
||||
if index < 0 {
|
||||
return ""
|
||||
|
||||
125
tracker/gioui/oscilloscope.go
Normal file
125
tracker/gioui/oscilloscope.go
Normal file
@ -0,0 +1,125 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type (
|
||||
OscilloscopeState struct {
|
||||
onceBtn *Clickable
|
||||
wrapBtn *Clickable
|
||||
lengthInBeatsNumber *NumericUpDownState
|
||||
triggerChannelNumber *NumericUpDownState
|
||||
plot *Plot
|
||||
}
|
||||
|
||||
Oscilloscope struct {
|
||||
Theme *Theme
|
||||
State *OscilloscopeState
|
||||
}
|
||||
)
|
||||
|
||||
func NewOscilloscope() *OscilloscopeState {
|
||||
return &OscilloscopeState{
|
||||
plot: NewPlot(plotRange{0, 1}, plotRange{-1, 1}, 0),
|
||||
onceBtn: new(Clickable),
|
||||
wrapBtn: new(Clickable),
|
||||
lengthInBeatsNumber: NewNumericUpDownState(),
|
||||
triggerChannelNumber: NewNumericUpDownState(),
|
||||
}
|
||||
}
|
||||
|
||||
func Scope(th *Theme, st *OscilloscopeState) Oscilloscope {
|
||||
return Oscilloscope{
|
||||
Theme: th,
|
||||
State: st,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Oscilloscope) Layout(gtx C) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
|
||||
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
|
||||
|
||||
triggerChannel := NumUpDown(t.Scope().TriggerChannel(), s.Theme, s.State.triggerChannelNumber, "Trigger channel")
|
||||
lengthInBeats := NumUpDown(t.Scope().LengthInBeats(), s.Theme, s.State.lengthInBeatsNumber, "Buffer length in beats")
|
||||
|
||||
onceBtn := ToggleBtn(t.Scope().Once(), s.Theme, s.State.onceBtn, "Once", "Trigger once on next event")
|
||||
wrapBtn := ToggleBtn(t.Scope().Wrap(), s.Theme, s.State.wrapBtn, "Wrap", "Wrap buffer when full")
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
w := t.Scope().Waveform()
|
||||
cx := float32(w.Cursor) / float32(len(w.Buffer))
|
||||
|
||||
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
|
||||
x1 := max(int(xr.a*float32(len(w.Buffer))), 0)
|
||||
x2 := min(int(xr.b*float32(len(w.Buffer))), len(w.Buffer)-1)
|
||||
if x1 > x2 {
|
||||
return plotRange{}, false
|
||||
}
|
||||
step := max((x2-x1)/1000, 1) // if the range is too large, sample only ~ 1000 points
|
||||
y1 := float32(math.Inf(-1))
|
||||
y2 := float32(math.Inf(+1))
|
||||
for i := x1; i <= x2; i += step {
|
||||
sample := w.Buffer[i][chn]
|
||||
y1 = max(y1, sample)
|
||||
y2 = min(y2, sample)
|
||||
}
|
||||
return plotRange{-y1, -y2}, true
|
||||
}
|
||||
|
||||
rpb := max(t.Song().RowsPerBeat().Value(), 1)
|
||||
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
|
||||
l := t.Scope().LengthInBeats().Value() * rpb
|
||||
a := max(int(math.Ceil(float64(r.a*float32(l)))), 0)
|
||||
b := min(int(math.Floor(float64(r.b*float32(l)))), l)
|
||||
step := 1
|
||||
n := rpb
|
||||
for (b-a+1)/step > count {
|
||||
step *= n
|
||||
n = 2
|
||||
}
|
||||
a = (a / step) * step
|
||||
for i := a; i <= b; i += step {
|
||||
if i%rpb == 0 {
|
||||
beat := i / rpb
|
||||
yield(float32(i)/float32(l), strconv.Itoa(beat))
|
||||
} else {
|
||||
yield(float32(i)/float32(l), "")
|
||||
}
|
||||
}
|
||||
}
|
||||
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
|
||||
yield(-1, "")
|
||||
yield(1, "")
|
||||
}
|
||||
|
||||
return s.State.plot.Layout(gtx, data, xticks, yticks, cx, 2)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(s.Theme, &s.Theme.SongPanel.RowHeader, "Trigger").Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(onceBtn.Layout),
|
||||
layout.Rigid(triggerChannel.Layout),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(s.Theme, &s.Theme.SongPanel.RowHeader, "Buffer").Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(wrapBtn.Layout),
|
||||
layout.Rigid(lengthInBeats.Layout),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
467
tracker/gioui/param.go
Normal file
467
tracker/gioui/param.go
Normal file
@ -0,0 +1,467 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/x/stroke"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type (
|
||||
ParamState struct {
|
||||
drag gesture.Drag
|
||||
dragStartPt f32.Point // used to calculate the drag amount
|
||||
dragStartVal int
|
||||
tipArea TipArea
|
||||
clickable Clickable
|
||||
}
|
||||
|
||||
ParamWidget struct {
|
||||
Parameter tracker.Parameter
|
||||
State *ParamState
|
||||
Theme *Theme
|
||||
Focus bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
PortStyle struct {
|
||||
Diameter unit.Dp
|
||||
StrokeWidth unit.Dp
|
||||
Color color.NRGBA
|
||||
}
|
||||
|
||||
PortWidget struct {
|
||||
Theme *Theme
|
||||
Style *PortStyle
|
||||
State *ParamState
|
||||
}
|
||||
|
||||
KnobStyle struct {
|
||||
Diameter unit.Dp
|
||||
StrokeWidth unit.Dp
|
||||
Bg color.NRGBA
|
||||
Pos struct {
|
||||
Color color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Neg struct {
|
||||
Color color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Indicator struct {
|
||||
Color color.NRGBA
|
||||
Width unit.Dp
|
||||
InnerDiam unit.Dp
|
||||
OuterDiam unit.Dp
|
||||
}
|
||||
Value LabelStyle
|
||||
Title LabelStyle
|
||||
}
|
||||
|
||||
KnobWidget struct {
|
||||
Theme *Theme
|
||||
Value tracker.Parameter
|
||||
State *ParamState
|
||||
Style *KnobStyle
|
||||
Hint string
|
||||
Scroll bool
|
||||
}
|
||||
|
||||
SwitchStyle struct {
|
||||
Neutral struct {
|
||||
Fg color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Pos struct {
|
||||
Fg color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Neg struct {
|
||||
Fg color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
Outline unit.Dp
|
||||
Handle unit.Dp
|
||||
Icon unit.Dp
|
||||
}
|
||||
|
||||
SwitchWidget struct {
|
||||
Theme *Theme
|
||||
Value tracker.Parameter
|
||||
State *ParamState
|
||||
Style *SwitchStyle
|
||||
Hint string
|
||||
Scroll bool
|
||||
Disabled bool
|
||||
}
|
||||
)
|
||||
|
||||
// ParamState
|
||||
|
||||
func Param(Parameter tracker.Parameter, th *Theme, paramWidget *ParamState, focus, disabled bool) ParamWidget {
|
||||
return ParamWidget{
|
||||
Theme: th,
|
||||
State: paramWidget,
|
||||
Parameter: Parameter,
|
||||
Focus: focus,
|
||||
Disabled: disabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (p ParamWidget) Layout(gtx C) D {
|
||||
title := Label(p.Theme, &p.Theme.UnitEditor.Name, p.Parameter.Name())
|
||||
t := TrackerFromContext(gtx)
|
||||
widget := func(gtx C) D {
|
||||
if port, ok := p.Parameter.Port(); t.Params().IsChoosingSendTarget() && ok {
|
||||
for p.State.clickable.Clicked(gtx) {
|
||||
t.Params().ChooseSendTarget(p.Parameter.UnitID(), port).Do()
|
||||
}
|
||||
k := Port(p.Theme, p.State)
|
||||
return k.Layout(gtx)
|
||||
}
|
||||
switch p.Parameter.Type() {
|
||||
case tracker.IntegerParameter:
|
||||
k := Knob(p.Parameter, p.Theme, p.State, p.Parameter.Hint().Label, p.Focus, p.Disabled)
|
||||
return k.Layout(gtx)
|
||||
case tracker.BoolParameter:
|
||||
s := Switch(p.Parameter, p.Theme, p.State, p.Parameter.Hint().Label, p.Focus, p.Disabled)
|
||||
return s.Layout(gtx)
|
||||
case tracker.IDParameter:
|
||||
for p.State.clickable.Clicked(gtx) {
|
||||
t.Params().ChooseSendSource(p.Parameter.UnitID()).Do()
|
||||
}
|
||||
btn := Btn(t.Theme, &t.Theme.Button.Text, &p.State.clickable, "Set", p.Parameter.Hint().Label)
|
||||
if p.Disabled {
|
||||
btn.Style = &t.Theme.Button.Disabled
|
||||
}
|
||||
return layout.Center.Layout(gtx, btn.Layout)
|
||||
}
|
||||
if _, ok := p.Parameter.Port(); ok {
|
||||
k := Port(p.Theme, p.State)
|
||||
return k.Layout(gtx)
|
||||
}
|
||||
return D{}
|
||||
}
|
||||
title.Layout(gtx)
|
||||
widget(gtx)
|
||||
return D{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
|
||||
}
|
||||
|
||||
func (s *ParamState) update(gtx C, param tracker.Parameter, scroll bool) {
|
||||
for scroll {
|
||||
e, ok := gtx.Event(pointer.Filter{
|
||||
Target: s,
|
||||
Kinds: pointer.Scroll,
|
||||
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev, ok := e.(pointer.Event); ok && ev.Kind == pointer.Scroll {
|
||||
delta := -int(math.Min(math.Max(float64(ev.Scroll.Y), -1), 1))
|
||||
param.Add(delta, ev.Modifiers.Contain(key.ModShortcut))
|
||||
s.tipArea.Appear(gtx.Now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KnobWidget
|
||||
|
||||
func Knob(v tracker.Parameter, th *Theme, state *ParamState, hint string, scroll, disabled bool) KnobWidget {
|
||||
ret := KnobWidget{
|
||||
Theme: th,
|
||||
Value: v,
|
||||
State: state,
|
||||
Style: &th.Knob,
|
||||
Hint: hint,
|
||||
Scroll: scroll,
|
||||
}
|
||||
if disabled {
|
||||
ret.Style = &th.DisabledKnob
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (k *KnobWidget) Layout(gtx C) D {
|
||||
k.State.update(gtx, k.Value, k.Scroll)
|
||||
for {
|
||||
p, ok := k.State.drag.Update(gtx.Metric, gtx.Source, gesture.Both)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch p.Kind {
|
||||
case pointer.Press:
|
||||
k.State.dragStartPt = p.Position
|
||||
k.State.dragStartVal = k.Value.Value()
|
||||
case pointer.Drag:
|
||||
// update the value based on the drag amount
|
||||
m := k.Value.Range()
|
||||
d := p.Position.Sub(k.State.dragStartPt)
|
||||
speed := gtx.Dp(512)
|
||||
if p.Modifiers.Contain(key.ModCtrl) {
|
||||
speed = gtx.Dp(128)
|
||||
}
|
||||
amount := float32(d.X-d.Y) / float32(speed)
|
||||
newValue := int(float32(k.State.dragStartVal) + amount*float32(m.Max-m.Min))
|
||||
k.Value.SetValue(newValue)
|
||||
k.State.tipArea.Appear(gtx.Now)
|
||||
}
|
||||
}
|
||||
for k.Scroll {
|
||||
ev, ok := gtx.Event(pointer.Filter{Target: k.State, Kinds: pointer.Press})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if pe, ok := ev.(pointer.Event); ok && pe.Kind == pointer.Press && pe.Buttons == pointer.ButtonSecondary {
|
||||
k.Value.Reset()
|
||||
k.State.tipArea.Appear(gtx.Now)
|
||||
}
|
||||
}
|
||||
d := gtx.Dp(k.Style.Diameter)
|
||||
knob := func(gtx C) D {
|
||||
m := k.Value.Range()
|
||||
amount := float32(k.Value.Value()-m.Min) / float32(m.Max-m.Min)
|
||||
sw := gtx.Dp(k.Style.StrokeWidth)
|
||||
middle := float32(k.Value.Neutral()-m.Min) / float32(m.Max-m.Min)
|
||||
pos := max(amount, middle)
|
||||
neg := min(amount, middle)
|
||||
if middle > 0 {
|
||||
k.strokeKnobArc(gtx, k.Style.Neg.Bg, sw, d, 0, neg)
|
||||
}
|
||||
if middle < 1 {
|
||||
k.strokeKnobArc(gtx, k.Style.Pos.Bg, sw, d, pos, 1)
|
||||
}
|
||||
if pos > middle {
|
||||
k.strokeKnobArc(gtx, k.Style.Pos.Color, sw, d, middle, pos)
|
||||
}
|
||||
if neg < middle {
|
||||
k.strokeKnobArc(gtx, k.Style.Neg.Color, sw, d, neg, middle)
|
||||
}
|
||||
k.strokeIndicator(gtx, amount)
|
||||
return D{Size: image.Pt(d, d)}
|
||||
}
|
||||
label := Label(k.Theme, &k.Style.Value, strconv.Itoa(k.Value.Value()))
|
||||
w := func(gtx C) D {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Stacked(knob),
|
||||
layout.Stacked(label.Layout))
|
||||
}
|
||||
if !k.Scroll {
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
}
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
|
||||
if k.Scroll {
|
||||
event.Op(gtx.Ops, k.State)
|
||||
}
|
||||
k.State.drag.Add(gtx.Ops)
|
||||
if k.Hint != "" {
|
||||
c := gtx.Constraints
|
||||
gtx.Constraints.Max = image.Pt(1e6, 1e6)
|
||||
return k.State.tipArea.Layout(gtx, Tooltip(k.Theme, k.Hint), func(gtx C) D {
|
||||
gtx.Constraints = c
|
||||
return layout.Center.Layout(gtx, w)
|
||||
})
|
||||
}
|
||||
return layout.Center.Layout(gtx, w)
|
||||
}
|
||||
|
||||
func (k *KnobWidget) strokeKnobArc(gtx C, color color.NRGBA, strokeWidth, diameter int, start, end float32) {
|
||||
rad := float32(diameter) / 2
|
||||
end = min(max(end, 0), 1)
|
||||
if end <= 0 {
|
||||
return
|
||||
}
|
||||
startAngle := float64((start*8 + 1) / 10 * 2 * math.Pi)
|
||||
deltaAngle := (end - start) * 8 * math.Pi / 5
|
||||
center := f32.Point{X: rad, Y: rad}
|
||||
r2 := rad - float32(strokeWidth)/2
|
||||
startPt := f32.Point{X: rad - r2*float32(math.Sin(startAngle)), Y: rad + r2*float32(math.Cos(startAngle))}
|
||||
segments := [...]stroke.Segment{
|
||||
stroke.MoveTo(startPt),
|
||||
stroke.ArcTo(center, deltaAngle),
|
||||
}
|
||||
s := stroke.Stroke{
|
||||
Path: stroke.Path{Segments: segments[:]},
|
||||
Width: float32(strokeWidth),
|
||||
Cap: stroke.FlatCap,
|
||||
}
|
||||
paint.FillShape(gtx.Ops, color, s.Op(gtx.Ops))
|
||||
}
|
||||
|
||||
func (k *KnobWidget) strokeIndicator(gtx C, amount float32) {
|
||||
innerRad := float32(gtx.Dp(k.Style.Indicator.InnerDiam)) / 2
|
||||
outerRad := float32(gtx.Dp(k.Style.Indicator.OuterDiam)) / 2
|
||||
center := float32(gtx.Dp(k.Style.Diameter)) / 2
|
||||
angle := (float64(amount)*8 + 1) / 10 * 2 * math.Pi
|
||||
start := f32.Point{
|
||||
X: center - innerRad*float32(math.Sin(angle)),
|
||||
Y: center + innerRad*float32(math.Cos(angle)),
|
||||
}
|
||||
end := f32.Point{
|
||||
X: center - outerRad*float32(math.Sin(angle)),
|
||||
Y: center + outerRad*float32(math.Cos(angle)),
|
||||
}
|
||||
segments := [...]stroke.Segment{
|
||||
stroke.MoveTo(start),
|
||||
stroke.LineTo(end),
|
||||
}
|
||||
s := stroke.Stroke{
|
||||
Path: stroke.Path{Segments: segments[:]},
|
||||
Width: float32(k.Style.Indicator.Width),
|
||||
Cap: stroke.FlatCap,
|
||||
}
|
||||
paint.FillShape(gtx.Ops, k.Style.Indicator.Color, s.Op(gtx.Ops))
|
||||
}
|
||||
|
||||
// SwitchWidget
|
||||
|
||||
func Switch(v tracker.Parameter, th *Theme, state *ParamState, hint string, scroll, disabled bool) SwitchWidget {
|
||||
return SwitchWidget{
|
||||
Theme: th,
|
||||
Value: v,
|
||||
State: state,
|
||||
Style: &th.Switch,
|
||||
Hint: hint,
|
||||
Scroll: scroll,
|
||||
Disabled: disabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SwitchWidget) Layout(gtx C) D {
|
||||
s.State.update(gtx, s.Value, s.Scroll)
|
||||
for s.Scroll {
|
||||
ev, ok := gtx.Event(pointer.Filter{Target: s.State, Kinds: pointer.Press})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if pe, ok := ev.(pointer.Event); ok && pe.Kind == pointer.Press {
|
||||
delta := 0
|
||||
if pe.Buttons == pointer.ButtonPrimary {
|
||||
delta = 1
|
||||
}
|
||||
if pe.Buttons == pointer.ButtonSecondary {
|
||||
delta = -1
|
||||
}
|
||||
r := s.Value.Range()
|
||||
if r.Max < r.Min {
|
||||
continue
|
||||
}
|
||||
newVal := mod(s.Value.Value()+delta-r.Min, r.Max-r.Min+1) + r.Min
|
||||
s.Value.SetValue(newVal)
|
||||
s.State.tipArea.Appear(gtx.Now)
|
||||
}
|
||||
}
|
||||
if s.Scroll {
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, s.State)
|
||||
}
|
||||
return layout.Center.Layout(gtx, s.layoutSwitch)
|
||||
}
|
||||
|
||||
func (s *SwitchWidget) layoutSwitch(gtx C) D {
|
||||
width := gtx.Dp(s.Style.Width)
|
||||
height := gtx.Dp(s.Style.Height)
|
||||
var fg, bg color.NRGBA
|
||||
o := 0
|
||||
switch {
|
||||
case s.Disabled || s.Value.Value() == 0:
|
||||
fg = s.Style.Neutral.Fg
|
||||
bg = s.Style.Neutral.Bg
|
||||
o = gtx.Dp(s.Style.Outline)
|
||||
case s.Value.Value() < 0:
|
||||
fg = s.Style.Neg.Fg
|
||||
bg = s.Style.Neg.Bg
|
||||
case s.Value.Value() > 0:
|
||||
fg = s.Style.Pos.Fg
|
||||
bg = s.Style.Pos.Bg
|
||||
}
|
||||
r := min(width, height) / 2
|
||||
fillRoundRect := func(ops *op.Ops, rect image.Rectangle, r int, c color.NRGBA) {
|
||||
defer clip.UniformRRect(rect, r).Push(ops).Pop()
|
||||
paint.ColorOp{Color: c}.Add(ops)
|
||||
paint.PaintOp{}.Add(ops)
|
||||
}
|
||||
if o > 0 {
|
||||
fillRoundRect(gtx.Ops, image.Rect(0, 0, width, height), r, fg)
|
||||
}
|
||||
fillRoundRect(gtx.Ops, image.Rect(o, o, width-o, height-o), r-o, bg)
|
||||
a := r
|
||||
b := width - r
|
||||
p := a + (b-a)*(s.Value.Value()-s.Value.Range().Min)/(s.Value.Range().Max-s.Value.Range().Min)
|
||||
circle := func(x, y, r int) clip.Op {
|
||||
b := image.Rectangle{
|
||||
Min: image.Pt(x-r, y-r),
|
||||
Max: image.Pt(x+r, y+r),
|
||||
}
|
||||
return clip.Ellipse(b).Op(gtx.Ops)
|
||||
}
|
||||
paint.FillShape(gtx.Ops, fg, circle(p, height/2, gtx.Dp(s.Style.Handle)/2))
|
||||
icon := icons.NavigationClose
|
||||
if s.Value.Range().Min < 0 {
|
||||
if s.Value.Value() < 0 {
|
||||
icon = icons.ImageExposureNeg1
|
||||
} else if s.Value.Value() > 0 {
|
||||
icon = icons.ImageExposurePlus1
|
||||
}
|
||||
} else if s.Value.Value() > 0 {
|
||||
icon = icons.NavigationCheck
|
||||
}
|
||||
w := s.Theme.Icon(icon)
|
||||
i := gtx.Dp(s.Style.Icon)
|
||||
defer op.Offset(image.Pt(p-i/2, (height-i)/2)).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints = layout.Exact(image.Pt(i, i))
|
||||
w.Layout(gtx, bg)
|
||||
return D{Size: image.Pt(width, height)}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func Port(t *Theme, p *ParamState) PortWidget {
|
||||
return PortWidget{Theme: t, Style: &t.Port, State: p}
|
||||
}
|
||||
|
||||
func (p *PortWidget) Layout(gtx C) D {
|
||||
w := func(gtx C) D {
|
||||
d := gtx.Dp(p.Style.Diameter)
|
||||
defer clip.Rect(image.Rectangle{Max: image.Pt(d, d)}).Push(gtx.Ops).Pop()
|
||||
p.strokeCircle(gtx)
|
||||
return D{Size: image.Pt(d, d)}
|
||||
}
|
||||
return p.State.clickable.layout(p.State, gtx, func(gtx C) D {
|
||||
layout.Center.Layout(gtx, w)
|
||||
return D{Size: gtx.Constraints.Max}
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PortWidget) strokeCircle(gtx C) {
|
||||
sw := float32(gtx.Dp(p.Style.StrokeWidth))
|
||||
d := float32(gtx.Dp(p.Style.Diameter))
|
||||
rad := d / 2
|
||||
center := f32.Point{X: rad, Y: rad}
|
||||
var path clip.Path
|
||||
path.Begin(gtx.Ops)
|
||||
path.MoveTo(f32.Pt(sw/2, rad))
|
||||
path.ArcTo(center, center, float32(math.Pi*2))
|
||||
paint.FillShape(gtx.Ops, p.Style.Color,
|
||||
clip.Stroke{
|
||||
Path: path.End(),
|
||||
Width: sw,
|
||||
}.Op())
|
||||
}
|
||||
334
tracker/gioui/patch_panel.go
Normal file
334
tracker/gioui/patch_panel.go
Normal file
@ -0,0 +1,334 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type (
|
||||
PatchPanel struct {
|
||||
instrList InstrumentList
|
||||
tools InstrumentTools
|
||||
instrProps InstrumentProperties
|
||||
instrPresets InstrumentPresets
|
||||
instrEditor InstrumentEditor
|
||||
*tracker.Model
|
||||
}
|
||||
|
||||
InstrumentList struct {
|
||||
instrumentDragList *DragList
|
||||
nameEditor *Editor
|
||||
}
|
||||
|
||||
InstrumentTools struct {
|
||||
EditorTab *Clickable
|
||||
PresetsTab *Clickable
|
||||
CommentTab *Clickable
|
||||
|
||||
saveInstrumentBtn *Clickable
|
||||
loadInstrumentBtn *Clickable
|
||||
copyInstrumentBtn *Clickable
|
||||
deleteInstrumentBtn *Clickable
|
||||
|
||||
octave *NumericUpDownState
|
||||
enlargeBtn *Clickable
|
||||
linkInstrTrackBtn *Clickable
|
||||
newInstrumentBtn *Clickable
|
||||
|
||||
octaveHint string
|
||||
linkDisabledHint string
|
||||
linkEnabledHint string
|
||||
enlargeHint, shrinkHint string
|
||||
addInstrumentHint string
|
||||
|
||||
deleteInstrumentHint string
|
||||
}
|
||||
)
|
||||
|
||||
// PatchPanel methods
|
||||
|
||||
func NewPatchPanel(model *tracker.Model) *PatchPanel {
|
||||
return &PatchPanel{
|
||||
instrEditor: *NewInstrumentEditor(model),
|
||||
instrList: MakeInstrList(model),
|
||||
tools: MakeInstrumentTools(model),
|
||||
instrProps: *NewInstrumentProperties(),
|
||||
instrPresets: *NewInstrumentPresets(model),
|
||||
Model: model,
|
||||
}
|
||||
}
|
||||
|
||||
func (pp *PatchPanel) Layout(gtx C) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
bottom := func(gtx C) D {
|
||||
switch {
|
||||
case tr.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
|
||||
return pp.instrProps.layout(gtx)
|
||||
case tr.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
|
||||
return pp.instrPresets.layout(gtx)
|
||||
default: // editor
|
||||
return pp.instrEditor.layout(gtx)
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(pp.instrList.Layout),
|
||||
layout.Rigid(pp.tools.Layout),
|
||||
layout.Flexed(1, bottom),
|
||||
)
|
||||
}
|
||||
|
||||
func (pp *PatchPanel) BottomTags(level int, yield TagYieldFunc) bool {
|
||||
switch {
|
||||
case pp.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
|
||||
return pp.instrProps.Tags(level, yield)
|
||||
case pp.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
|
||||
return pp.instrPresets.Tags(level, yield)
|
||||
default: // editor
|
||||
return pp.instrEditor.Tags(level, yield)
|
||||
}
|
||||
}
|
||||
|
||||
func (pp *PatchPanel) Tags(level int, yield TagYieldFunc) bool {
|
||||
return pp.instrList.Tags(level, yield) &&
|
||||
pp.tools.Tags(level, yield) &&
|
||||
pp.BottomTags(level, yield)
|
||||
}
|
||||
|
||||
// TreeFocused returns true if any of the tags in the patch panel is focused
|
||||
func (pp *PatchPanel) TreeFocused(gtx C) bool {
|
||||
return !pp.Tags(0, func(_ int, tag event.Tag) bool {
|
||||
return !gtx.Focused(tag)
|
||||
})
|
||||
}
|
||||
|
||||
// InstrumentTools methods
|
||||
|
||||
func MakeInstrumentTools(m *tracker.Model) InstrumentTools {
|
||||
ret := InstrumentTools{
|
||||
EditorTab: new(Clickable),
|
||||
PresetsTab: new(Clickable),
|
||||
CommentTab: new(Clickable),
|
||||
deleteInstrumentBtn: new(Clickable),
|
||||
copyInstrumentBtn: new(Clickable),
|
||||
saveInstrumentBtn: new(Clickable),
|
||||
loadInstrumentBtn: new(Clickable),
|
||||
deleteInstrumentHint: makeHint("Delete\ninstrument", "\n(%s)", "DeleteInstrument"),
|
||||
octave: NewNumericUpDownState(),
|
||||
enlargeBtn: new(Clickable),
|
||||
linkInstrTrackBtn: new(Clickable),
|
||||
newInstrumentBtn: new(Clickable),
|
||||
octaveHint: makeHint("Octave down", " (%s)", "OctaveNumberInputSubtract") + makeHint(" or up", " (%s)", "OctaveNumberInputAdd"),
|
||||
linkDisabledHint: makeHint("Instrument-Track\nlinking disabled", "\n(%s)", "LinkInstrTrackToggle"),
|
||||
linkEnabledHint: makeHint("Instrument-Track\nlinking enabled", "\n(%s)", "LinkInstrTrackToggle"),
|
||||
enlargeHint: makeHint("Enlarge", " (%s)", "InstrEnlargedToggle"),
|
||||
shrinkHint: makeHint("Shrink", " (%s)", "InstrEnlargedToggle"),
|
||||
addInstrumentHint: makeHint("Add\ninstrument", "\n(%s)", "AddInstrument"),
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (it *InstrumentTools) Layout(gtx C) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
it.update(gtx, t)
|
||||
editorBtn := TabBtn(tracker.MakeBool((*editorTab)(t.Model)), t.Theme, it.EditorTab, "Editor", "")
|
||||
presetsBtn := TabBtn(tracker.MakeBool((*presetsTab)(t.Model)), t.Theme, it.PresetsTab, "Presets", "")
|
||||
commentBtn := TabBtn(tracker.MakeBool((*commentTab)(t.Model)), t.Theme, it.CommentTab, "Properties", "")
|
||||
octave := NumUpDown(t.Note().Octave(), t.Theme, t.OctaveNumberInput, "Octave")
|
||||
linkInstrTrackBtn := ToggleIconBtn(t.Track().LinkInstrument(), t.Theme, it.linkInstrTrackBtn, icons.NotificationSyncDisabled, icons.NotificationSync, it.linkDisabledHint, it.linkEnabledHint)
|
||||
instrEnlargedBtn := ToggleIconBtn(t.Play().TrackerHidden(), t.Theme, it.enlargeBtn, icons.NavigationFullscreen, icons.NavigationFullscreenExit, it.enlargeHint, it.shrinkHint)
|
||||
addInstrumentBtn := ActionIconBtn(t.Model.Instrument().Add(), t.Theme, it.newInstrumentBtn, icons.ContentAdd, it.addInstrumentHint)
|
||||
|
||||
saveInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.saveInstrumentBtn, icons.ContentSave, "Save instrument")
|
||||
loadInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.loadInstrumentBtn, icons.FileFolderOpen, "Load instrument")
|
||||
copyInstrumentBtn := IconBtn(t.Theme, &t.Theme.IconButton.Enabled, it.copyInstrumentBtn, icons.ContentContentCopy, "Copy instrument")
|
||||
deleteInstrumentBtn := ActionIconBtn(t.Instrument().Delete(), t.Theme, it.deleteInstrumentBtn, icons.ActionDelete, it.deleteInstrumentHint)
|
||||
btns := func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(layout.Spacer{Width: 6}.Layout),
|
||||
layout.Rigid(editorBtn.Layout),
|
||||
layout.Rigid(presetsBtn.Layout),
|
||||
layout.Rigid(commentBtn.Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return layout.Dimensions{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(layout.Spacer{Width: 4}.Layout),
|
||||
layout.Rigid(Label(t.Theme, &t.Theme.InstrumentEditor.Octave, "Octave").Layout),
|
||||
layout.Rigid(octave.Layout),
|
||||
layout.Rigid(linkInstrTrackBtn.Layout),
|
||||
layout.Rigid(instrEnlargedBtn.Layout),
|
||||
layout.Rigid(copyInstrumentBtn.Layout),
|
||||
layout.Rigid(saveInstrumentBtn.Layout),
|
||||
layout.Rigid(loadInstrumentBtn.Layout),
|
||||
layout.Rigid(deleteInstrumentBtn.Layout),
|
||||
layout.Rigid(addInstrumentBtn.Layout),
|
||||
)
|
||||
}
|
||||
return Surface{Height: 4, Focus: t.PatchPanel.TreeFocused(gtx)}.Layout(gtx, btns)
|
||||
}
|
||||
|
||||
type (
|
||||
editorTab tracker.Model
|
||||
presetsTab tracker.Model
|
||||
commentTab tracker.Model
|
||||
)
|
||||
|
||||
func (e *editorTab) Value() bool {
|
||||
return (*tracker.Model)(e).Instrument().Tab().Value() == int(tracker.InstrumentEditorTab)
|
||||
}
|
||||
func (e *editorTab) SetValue(val bool) {
|
||||
if val {
|
||||
(*tracker.Model)(e).Instrument().Tab().SetValue(int(tracker.InstrumentEditorTab))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *presetsTab) Value() bool {
|
||||
return (*tracker.Model)(p).Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab)
|
||||
}
|
||||
func (p *presetsTab) SetValue(val bool) {
|
||||
if val {
|
||||
(*tracker.Model)(p).Instrument().Tab().SetValue(int(tracker.InstrumentPresetsTab))
|
||||
}
|
||||
}
|
||||
func (c *commentTab) Value() bool {
|
||||
return (*tracker.Model)(c).Instrument().Tab().Value() == int(tracker.InstrumentCommentTab)
|
||||
}
|
||||
func (c *commentTab) SetValue(val bool) {
|
||||
if val {
|
||||
(*tracker.Model)(c).Instrument().Tab().SetValue(int(tracker.InstrumentCommentTab))
|
||||
}
|
||||
}
|
||||
|
||||
func (it *InstrumentTools) update(gtx C, tr *Tracker) {
|
||||
for it.copyInstrumentBtn.Clicked(gtx) {
|
||||
if contents, ok := tr.Instrument().List().CopyElements(); ok {
|
||||
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
|
||||
tr.Alerts().Add("Instrument copied to clipboard", tracker.Info)
|
||||
}
|
||||
}
|
||||
for it.saveInstrumentBtn.Clicked(gtx) {
|
||||
writer, err := tr.Explorer.CreateFile(tr.Instrument().Name().Value() + ".yml")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tr.Instrument().Write(writer)
|
||||
}
|
||||
for it.loadInstrumentBtn.Clicked(gtx) {
|
||||
reader, err := tr.Explorer.ChooseFile(".yml", ".json", ".4ki", ".4kp")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tr.Instrument().Read(reader)
|
||||
}
|
||||
}
|
||||
|
||||
func (it *InstrumentTools) Tags(level int, yield TagYieldFunc) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InstrumentList methods
|
||||
|
||||
func MakeInstrList(model *tracker.Model) InstrumentList {
|
||||
return InstrumentList{
|
||||
instrumentDragList: NewDragList(model.Instrument().List(), layout.Horizontal),
|
||||
nameEditor: NewEditor(true, true, text.Middle),
|
||||
}
|
||||
}
|
||||
|
||||
func (il *InstrumentList) Layout(gtx C) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
il.update(gtx, t)
|
||||
gtx.Constraints.Max.Y = gtx.Dp(36)
|
||||
gtx.Constraints.Min.Y = gtx.Dp(36)
|
||||
element := func(gtx C, i int) D {
|
||||
grabhandle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, strconv.Itoa(i+1))
|
||||
label := func(gtx C) D {
|
||||
name, level, mute, ok := t.Instrument().Item(i)
|
||||
if !ok {
|
||||
labelStyle := Label(t.Theme, &t.Theme.InstrumentEditor.InstrumentList.Number, "")
|
||||
return layout.Center.Layout(gtx, labelStyle.Layout)
|
||||
}
|
||||
s := t.Theme.InstrumentEditor.InstrumentList.NameMuted
|
||||
if !mute {
|
||||
s = t.Theme.InstrumentEditor.InstrumentList.Name
|
||||
k := byte(255 - level*127)
|
||||
s.Color = color.NRGBA{R: 255, G: k, B: 255, A: 255}
|
||||
}
|
||||
if i == il.instrumentDragList.TrackerList.Selected() {
|
||||
for il.nameEditor.Update(gtx, t.Instrument().Name()) != EditorEventNone {
|
||||
il.instrumentDragList.Focus()
|
||||
}
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
|
||||
return il.nameEditor.Layout(gtx, t.Instrument().Name(), t.Theme, &s, "Instr")
|
||||
})
|
||||
}
|
||||
if name == "" {
|
||||
name = "Instr"
|
||||
}
|
||||
l := s.AsLabelStyle()
|
||||
return layout.Center.Layout(gtx, Label(t.Theme, &l, name).Layout)
|
||||
}
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return layout.Inset{Left: unit.Dp(6), Right: unit.Dp(6)}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(grabhandle.Layout),
|
||||
layout.Rigid(label),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
instrumentList := FilledDragList(t.Theme, il.instrumentDragList)
|
||||
instrumentList.ScrollBar = t.Theme.InstrumentEditor.InstrumentList.ScrollBar
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
|
||||
dims := instrumentList.Layout(gtx, element, nil)
|
||||
gtx.Constraints = layout.Exact(dims.Size)
|
||||
instrumentList.LayoutScrollBar(gtx)
|
||||
return dims
|
||||
}
|
||||
|
||||
func (il *InstrumentList) update(gtx C, t *Tracker) {
|
||||
for {
|
||||
event, ok := gtx.Event(
|
||||
key.Filter{Focus: il.instrumentDragList, Name: key.NameDownArrow},
|
||||
key.Filter{Focus: il.instrumentDragList, Name: key.NameReturn},
|
||||
key.Filter{Focus: il.instrumentDragList, Name: key.NameEnter},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := event.(key.Event); ok && e.State == key.Press {
|
||||
switch e.Name {
|
||||
case key.NameDownArrow:
|
||||
var tagged Tagged
|
||||
switch {
|
||||
case t.Instrument().Tab().Value() == int(tracker.InstrumentCommentTab):
|
||||
tagged = &t.PatchPanel.instrProps
|
||||
case t.Instrument().Tab().Value() == int(tracker.InstrumentPresetsTab):
|
||||
tagged = &t.PatchPanel.instrPresets
|
||||
default: // editor
|
||||
tagged = &t.PatchPanel.instrEditor
|
||||
}
|
||||
if tag, ok := firstTag(tagged); ok {
|
||||
gtx.Execute(key.FocusCmd{Tag: tag})
|
||||
}
|
||||
case key.NameReturn, key.NameEnter:
|
||||
il.nameEditor.Focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (il *InstrumentList) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level, il.instrumentDragList)
|
||||
}
|
||||
186
tracker/gioui/plot.go
Normal file
186
tracker/gioui/plot.go
Normal file
@ -0,0 +1,186 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type (
|
||||
Plot struct {
|
||||
origXlim, origYlim plotRange
|
||||
fixedYLevel float32
|
||||
|
||||
xScale, yScale float32
|
||||
xOffset float32
|
||||
dragging bool
|
||||
dragId pointer.ID
|
||||
dragStartPoint f32.Point
|
||||
}
|
||||
|
||||
PlotStyle struct {
|
||||
CurveColors [3]color.NRGBA `yaml:",flow"`
|
||||
LimitColor color.NRGBA `yaml:",flow"`
|
||||
CursorColor color.NRGBA `yaml:",flow"`
|
||||
Ticks LabelStyle
|
||||
DpPerTick unit.Dp
|
||||
}
|
||||
|
||||
PlotDataFunc func(chn int, xr plotRange) (yr plotRange, ok bool)
|
||||
PlotTickFunc func(r plotRange, num int, yield func(pos float32, label string))
|
||||
plotRange struct{ a, b float32 }
|
||||
plotRel float32
|
||||
plotPx int
|
||||
plotLogScale float32
|
||||
)
|
||||
|
||||
func NewPlot(xlim, ylim plotRange, fixedYLevel float32) *Plot {
|
||||
return &Plot{
|
||||
origXlim: xlim,
|
||||
origYlim: ylim,
|
||||
fixedYLevel: fixedYLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plot) Layout(gtx C, data PlotDataFunc, xticks, yticks PlotTickFunc, cursornx float32, numchns int) D {
|
||||
p.update(gtx)
|
||||
t := TrackerFromContext(gtx)
|
||||
style := t.Theme.Plot
|
||||
s := gtx.Constraints.Max
|
||||
if s.X <= 1 || s.Y <= 1 {
|
||||
return D{}
|
||||
}
|
||||
defer clip.Rect(image.Rectangle{Max: s}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, p)
|
||||
|
||||
xlim := p.xlim()
|
||||
ylim := p.ylim()
|
||||
|
||||
// draw tick marks
|
||||
numxticks := s.X / gtx.Dp(style.DpPerTick)
|
||||
xticks(xlim, numxticks, func(x float32, txt string) {
|
||||
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
|
||||
sx := plotPx(s.X).toScreen(xlim.toRelative(x))
|
||||
fillRect(gtx, clip.Rect{Min: image.Pt(sx, 0), Max: image.Pt(sx+1, s.Y)})
|
||||
defer op.Offset(image.Pt(sx, gtx.Dp(2))).Push(gtx.Ops).Pop()
|
||||
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
|
||||
})
|
||||
|
||||
numyticks := s.Y / gtx.Dp(style.DpPerTick)
|
||||
yticks(ylim, numyticks, func(y float32, txt string) {
|
||||
paint.ColorOp{Color: style.LimitColor}.Add(gtx.Ops)
|
||||
sy := plotPx(s.Y).toScreen(ylim.toRelative(y))
|
||||
fillRect(gtx, clip.Rect{Min: image.Pt(0, sy), Max: image.Pt(s.X, sy+1)})
|
||||
defer op.Offset(image.Pt(gtx.Dp(2), sy)).Push(gtx.Ops).Pop()
|
||||
Label(t.Theme, &t.Theme.Plot.Ticks, txt).Layout(gtx)
|
||||
})
|
||||
|
||||
// draw cursor
|
||||
if cursornx == cursornx { // check for NaN
|
||||
paint.ColorOp{Color: style.CursorColor}.Add(gtx.Ops)
|
||||
csx := plotPx(s.X).toScreen(xlim.toRelative(cursornx))
|
||||
fillRect(gtx, clip.Rect{Min: image.Pt(csx, 0), Max: image.Pt(csx+1, s.Y)})
|
||||
}
|
||||
|
||||
// draw curves
|
||||
for chn := range numchns {
|
||||
paint.ColorOp{Color: style.CurveColors[chn]}.Add(gtx.Ops)
|
||||
right := xlim.fromRelative(plotPx(s.X).fromScreen(0))
|
||||
for sx := range s.X {
|
||||
// left and right is the sample range covered by the pixel
|
||||
left := right
|
||||
right = xlim.fromRelative(plotPx(s.X).fromScreen(sx + 1))
|
||||
yr, ok := data(chn, plotRange{left, right})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
y1 := plotPx(s.Y).toScreen(ylim.toRelative(yr.a))
|
||||
y2 := plotPx(s.Y).toScreen(ylim.toRelative(yr.b))
|
||||
fillRect(gtx, clip.Rect{Min: image.Pt(sx, min(y1, y2)), Max: image.Pt(sx+1, max(y1, y2)+1)})
|
||||
}
|
||||
}
|
||||
return D{Size: s}
|
||||
}
|
||||
|
||||
func (r plotRange) toRelative(f float32) plotRel { return plotRel((f - r.a) / (r.b - r.a)) }
|
||||
func (r plotRange) fromRelative(pr plotRel) float32 { return float32(pr)*(r.b-r.a) + r.a }
|
||||
func (r plotRange) offset(o float32) plotRange { return plotRange{r.a + o, r.b + o} }
|
||||
func (r plotRange) scale(logScale float32) plotRange {
|
||||
s := float32(math.Exp(float64(logScale)))
|
||||
return plotRange{r.a * s, r.b * s}
|
||||
}
|
||||
|
||||
func (s plotPx) toScreen(pr plotRel) int { return int(float32(pr)*float32(s-1) + 0.5) }
|
||||
func (s plotPx) fromScreen(px int) plotRel { return plotRel(float32(px) / float32(s-1)) }
|
||||
func (s plotPx) fromScreenF32(px float32) plotRel { return plotRel(px / float32(s-1)) }
|
||||
|
||||
func (o *Plot) xlim() plotRange { return o.origXlim.scale(o.xScale).offset(o.xOffset) }
|
||||
func (o *Plot) ylim() plotRange {
|
||||
return o.origYlim.offset(-o.fixedYLevel).scale(o.yScale).offset(o.fixedYLevel)
|
||||
}
|
||||
|
||||
func fillRect(gtx C, rect clip.Rect) {
|
||||
stack := rect.Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
stack.Pop()
|
||||
}
|
||||
|
||||
func (o *Plot) update(gtx C) {
|
||||
s := gtx.Constraints.Max
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: o,
|
||||
Kinds: pointer.Scroll | pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
|
||||
ScrollY: pointer.ScrollRange{Min: -1e6, Max: 1e6},
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := ev.(pointer.Event); ok {
|
||||
switch e.Kind {
|
||||
case pointer.Scroll:
|
||||
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
|
||||
o.xScale += float32(min(max(-1, int(e.Scroll.Y)), 1)) * 0.1
|
||||
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
|
||||
o.xOffset += x1 - x2
|
||||
case pointer.Press:
|
||||
if e.Buttons&pointer.ButtonSecondary != 0 {
|
||||
o.xOffset = 0
|
||||
o.xScale = 0
|
||||
o.yScale = 0
|
||||
}
|
||||
if e.Buttons&pointer.ButtonPrimary != 0 {
|
||||
o.dragging = true
|
||||
o.dragId = e.PointerID
|
||||
o.dragStartPoint = e.Position
|
||||
}
|
||||
case pointer.Drag:
|
||||
if e.Buttons&pointer.ButtonPrimary != 0 && o.dragging && e.PointerID == o.dragId {
|
||||
x1 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(o.dragStartPoint.X))
|
||||
x2 := o.xlim().fromRelative(plotPx(s.X).fromScreenF32(e.Position.X))
|
||||
o.xOffset += x1 - x2
|
||||
|
||||
num := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(e.Position.Y))
|
||||
den := o.ylim().fromRelative(plotPx(s.Y).fromScreenF32(o.dragStartPoint.Y))
|
||||
num -= o.fixedYLevel
|
||||
den -= o.fixedYLevel
|
||||
if l := math.Abs(float64(num / den)); l > 1e-3 && l < 1e3 {
|
||||
o.yScale -= float32(math.Log(l))
|
||||
o.yScale = min(max(o.yScale, -1e3), 1e3)
|
||||
}
|
||||
o.dragStartPoint = e.Position
|
||||
}
|
||||
case pointer.Release | pointer.Cancel:
|
||||
o.dragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,69 +13,51 @@ import (
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type PopupStyle struct {
|
||||
Visible *bool
|
||||
SurfaceColor color.NRGBA
|
||||
ShadowColor color.NRGBA
|
||||
ShadowN unit.Dp
|
||||
ShadowE unit.Dp
|
||||
ShadowW unit.Dp
|
||||
ShadowS unit.Dp
|
||||
SE, SW, NW, NE unit.Dp
|
||||
}
|
||||
type (
|
||||
PopupStyle struct {
|
||||
Color color.NRGBA
|
||||
CornerRadii struct {
|
||||
SE, SW, NW, NE unit.Dp
|
||||
}
|
||||
Shadow struct {
|
||||
Color color.NRGBA
|
||||
N, E, W, S unit.Dp
|
||||
}
|
||||
}
|
||||
|
||||
func Popup(visible *bool) PopupStyle {
|
||||
return PopupStyle{
|
||||
Visible: visible,
|
||||
SurfaceColor: popupSurfaceColor,
|
||||
ShadowColor: popupShadowColor,
|
||||
ShadowN: unit.Dp(2),
|
||||
ShadowE: unit.Dp(2),
|
||||
ShadowS: unit.Dp(2),
|
||||
ShadowW: unit.Dp(2),
|
||||
SE: unit.Dp(6),
|
||||
SW: unit.Dp(6),
|
||||
NW: unit.Dp(6),
|
||||
NE: unit.Dp(6),
|
||||
PopupWidget struct {
|
||||
Style *PopupStyle
|
||||
Visible *bool
|
||||
}
|
||||
)
|
||||
|
||||
func Popup(th *Theme, visible *bool) PopupWidget {
|
||||
return PopupWidget{
|
||||
Style: &th.Popup.Dialog,
|
||||
Visible: visible,
|
||||
}
|
||||
}
|
||||
|
||||
func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
|
||||
func (s PopupWidget) Layout(gtx C, contents layout.Widget) D {
|
||||
s.update(gtx)
|
||||
|
||||
if !*s.Visible {
|
||||
return D{}
|
||||
}
|
||||
|
||||
for {
|
||||
event, ok := gtx.Event(pointer.Filter{
|
||||
Target: s.Visible,
|
||||
Kinds: pointer.Press,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := event.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
*s.Visible = false
|
||||
}
|
||||
}
|
||||
|
||||
bg := func(gtx C) D {
|
||||
rrect := clip.RRect{
|
||||
Rect: image.Rectangle{Max: gtx.Constraints.Min},
|
||||
SE: gtx.Dp(s.SE),
|
||||
SW: gtx.Dp(s.SW),
|
||||
NW: gtx.Dp(s.NW),
|
||||
NE: gtx.Dp(s.NE),
|
||||
SE: gtx.Dp(s.Style.CornerRadii.SE),
|
||||
SW: gtx.Dp(s.Style.CornerRadii.SW),
|
||||
NW: gtx.Dp(s.Style.CornerRadii.NW),
|
||||
NE: gtx.Dp(s.Style.CornerRadii.NE),
|
||||
}
|
||||
rrect2 := rrect
|
||||
rrect2.Rect.Min = rrect2.Rect.Min.Sub(image.Pt(gtx.Dp(s.ShadowW), gtx.Dp(s.ShadowN)))
|
||||
rrect2.Rect.Max = rrect2.Rect.Max.Add(image.Pt(gtx.Dp(s.ShadowE), gtx.Dp(s.ShadowS)))
|
||||
paint.FillShape(gtx.Ops, s.ShadowColor, rrect2.Op(gtx.Ops))
|
||||
paint.FillShape(gtx.Ops, s.SurfaceColor, rrect.Op(gtx.Ops))
|
||||
rrect2.Rect.Min = rrect2.Rect.Min.Sub(image.Pt(gtx.Dp(s.Style.Shadow.W), gtx.Dp(s.Style.Shadow.N)))
|
||||
rrect2.Rect.Max = rrect2.Rect.Max.Add(image.Pt(gtx.Dp(s.Style.Shadow.E), gtx.Dp(s.Style.Shadow.S)))
|
||||
paint.FillShape(gtx.Ops, s.Style.Shadow.Color, rrect2.Op(gtx.Ops))
|
||||
paint.FillShape(gtx.Ops, s.Style.Color, rrect.Op(gtx.Ops))
|
||||
area := clip.Rect(image.Rect(-1e6, -1e6, 1e6, 1e6)).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, s.Visible)
|
||||
area.Pop()
|
||||
@ -94,4 +76,24 @@ func (s PopupStyle) Layout(gtx C, contents layout.Widget) D {
|
||||
return dims
|
||||
}
|
||||
|
||||
func (s *PopupWidget) update(gtx C) {
|
||||
for {
|
||||
event, ok := gtx.Event(pointer.Filter{
|
||||
Target: s.Visible,
|
||||
Kinds: pointer.Press,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := event.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
*s.Visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dummyTag bool
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type PopupAlert struct {
|
||||
alerts *tracker.Alerts
|
||||
prevUpdate time.Time
|
||||
shaper *text.Shaper
|
||||
}
|
||||
|
||||
var alertSpeed = 150 * time.Millisecond
|
||||
var alertMargin = layout.UniformInset(unit.Dp(6))
|
||||
var alertInset = layout.UniformInset(unit.Dp(6))
|
||||
|
||||
func NewPopupAlert(alerts *tracker.Alerts, shaper *text.Shaper) *PopupAlert {
|
||||
return &PopupAlert{alerts: alerts, shaper: shaper, prevUpdate: time.Now()}
|
||||
}
|
||||
|
||||
func (a *PopupAlert) Layout(gtx C) D {
|
||||
now := time.Now()
|
||||
if a.alerts.Update(now.Sub(a.prevUpdate)) {
|
||||
gtx.Execute(op.InvalidateCmd{At: now.Add(50 * time.Millisecond)})
|
||||
}
|
||||
a.prevUpdate = now
|
||||
|
||||
var totalY float64
|
||||
a.alerts.Iterate(func(alert tracker.Alert) {
|
||||
var color, textColor, shadeColor color.NRGBA
|
||||
switch alert.Priority {
|
||||
case tracker.Warning:
|
||||
color = warningColor
|
||||
textColor = black
|
||||
case tracker.Error:
|
||||
color = errorColor
|
||||
textColor = black
|
||||
default:
|
||||
color = popupSurfaceColor
|
||||
textColor = white
|
||||
shadeColor = black
|
||||
}
|
||||
bgWidget := func(gtx C) D {
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{
|
||||
Max: gtx.Constraints.Min,
|
||||
}.Op())
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}
|
||||
labelStyle := LabelStyle{Text: alert.Message, Color: textColor, ShadeColor: shadeColor, Font: labelDefaultFont, Alignment: layout.Center, FontSize: unit.Sp(16), Shaper: a.shaper}
|
||||
alertMargin.Layout(gtx, func(gtx C) D {
|
||||
return layout.S.Layout(gtx, func(gtx C) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
recording := op.Record(gtx.Ops)
|
||||
dims := layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(bgWidget),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
return alertInset.Layout(gtx, labelStyle.Layout)
|
||||
}),
|
||||
)
|
||||
macro := recording.Stop()
|
||||
delta := float64(dims.Size.Y + gtx.Dp(alertMargin.Bottom))
|
||||
op.Offset(image.Point{0, int(-totalY*alert.FadeLevel + delta*(1-alert.FadeLevel))}).Add((gtx.Ops))
|
||||
totalY += delta
|
||||
macro.Add(gtx.Ops)
|
||||
return dims
|
||||
})
|
||||
})
|
||||
})
|
||||
return D{}
|
||||
}
|
||||
67
tracker/gioui/preferences.go
Normal file
67
tracker/gioui/preferences.go
Normal file
@ -0,0 +1,67 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type (
|
||||
Preferences struct {
|
||||
Window WindowPreferences
|
||||
}
|
||||
|
||||
WindowPreferences struct {
|
||||
Width int
|
||||
Height int
|
||||
Maximized bool `yaml:",omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
//go:embed preferences.yml
|
||||
var defaultPreferences []byte
|
||||
|
||||
// ReadCustomConfig modifies the target argument, i.e. needs a pointer. Just
|
||||
// fails silently if the file cannot be found/read, but will warn about
|
||||
// malformed files.
|
||||
func ReadCustomConfig(filename string, target any) error {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(configDir, "sointu", filename)
|
||||
bytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := yaml.Unmarshal(bytes, target); err != nil {
|
||||
return fmt.Errorf("ReadCustomConfig %v: %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadConfig first unmarshals the defaultConfig which should be the embedded
|
||||
// default config, and then tries to read the custom config with
|
||||
// ReadCustomConfig. It panics right away if the embedded defaultConfig could
|
||||
// not be parsed as yaml as this should never happen except during development.
|
||||
// The returned error should be treated as a warning: this function will always
|
||||
// return at least the default config, and the warning will just tell if there
|
||||
// was a problem parsing the custom config.
|
||||
func ReadConfig(defaultConfig []byte, path string, target any) (warn error) {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(defaultConfig))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(target); err != nil {
|
||||
panic(fmt.Errorf("ReadConfig %v failed to unmarshal the embedded default config: %w", path, err))
|
||||
}
|
||||
return ReadCustomConfig(path, target)
|
||||
}
|
||||
|
||||
func (p Preferences) WindowSize() (unit.Dp, unit.Dp) {
|
||||
return unit.Dp(p.Window.Width), unit.Dp(p.Window.Height)
|
||||
}
|
||||
4
tracker/gioui/preferences.yml
Normal file
4
tracker/gioui/preferences.yml
Normal file
@ -0,0 +1,4 @@
|
||||
window:
|
||||
width: 800
|
||||
height: 600
|
||||
maximized: false
|
||||
@ -14,7 +14,6 @@ import (
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
@ -22,9 +21,11 @@ type ScrollTable struct {
|
||||
ColTitleList *DragList
|
||||
RowTitleList *DragList
|
||||
Table tracker.Table
|
||||
focused bool
|
||||
requestFocus bool
|
||||
cursorMoved bool
|
||||
eventFilters []event.Filter
|
||||
drag bool
|
||||
dragID pointer.ID
|
||||
}
|
||||
|
||||
type ScrollTableStyle struct {
|
||||
@ -36,23 +37,43 @@ type ScrollTableStyle struct {
|
||||
ColumnTitleHeight unit.Dp
|
||||
CellWidth unit.Dp
|
||||
CellHeight unit.Dp
|
||||
element func(gtx C, x, y int) D
|
||||
}
|
||||
|
||||
func NewScrollTable(table tracker.Table, vertList, horizList tracker.List) *ScrollTable {
|
||||
return &ScrollTable{
|
||||
ret := &ScrollTable{
|
||||
Table: table,
|
||||
ColTitleList: NewDragList(vertList, layout.Horizontal),
|
||||
RowTitleList: NewDragList(horizList, layout.Vertical),
|
||||
}
|
||||
ret.eventFilters = []event.Filter{
|
||||
key.FocusFilter{Target: ret},
|
||||
transfer.TargetFilter{Target: ret, Type: "application/text"},
|
||||
pointer.Filter{Target: ret, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel},
|
||||
key.Filter{Focus: ret, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: ret, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: ret, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: ret, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: ret, Name: key.NamePageUp, Optional: key.ModShift},
|
||||
key.Filter{Focus: ret, Name: key.NamePageDown, Optional: key.ModShift},
|
||||
key.Filter{Focus: ret, Name: key.NameHome, Optional: key.ModShift},
|
||||
key.Filter{Focus: ret, Name: key.NameEnd, Optional: key.ModShift},
|
||||
key.Filter{Focus: ret, Name: key.NameDeleteBackward},
|
||||
key.Filter{Focus: ret, Name: key.NameDeleteForward},
|
||||
}
|
||||
for k, a := range keyBindingMap {
|
||||
switch a {
|
||||
case "Copy", "Paste", "Cut", "Increase", "Decrease", "IncreaseMore", "DecreaseMore":
|
||||
ret.eventFilters = append(ret.eventFilters, key.Filter{Focus: ret, Name: k.Name, Required: k.Modifiers})
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FilledScrollTable(th *material.Theme, scrollTable *ScrollTable, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) ScrollTableStyle {
|
||||
func FilledScrollTable(th *Theme, scrollTable *ScrollTable) ScrollTableStyle {
|
||||
return ScrollTableStyle{
|
||||
RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList, rowTitle, rowTitleBg),
|
||||
ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList, colTitle, colTitleBg),
|
||||
RowTitleStyle: FilledDragList(th, scrollTable.RowTitleList),
|
||||
ColTitleStyle: FilledDragList(th, scrollTable.ColTitleList),
|
||||
ScrollTable: scrollTable,
|
||||
element: element,
|
||||
ScrollBarWidth: unit.Dp(14),
|
||||
RowTitleWidth: unit.Dp(30),
|
||||
ColumnTitleHeight: unit.Dp(16),
|
||||
@ -71,8 +92,17 @@ func (st *ScrollTable) Focus() {
|
||||
st.requestFocus = true
|
||||
}
|
||||
|
||||
func (st *ScrollTable) Focused() bool {
|
||||
return st.focused
|
||||
func (st *ScrollTable) Tags(level int, yield TagYieldFunc) bool {
|
||||
return yield(level+1, st.RowTitleList) &&
|
||||
yield(level+1, st.ColTitleList) &&
|
||||
yield(level, st)
|
||||
}
|
||||
|
||||
// TreeFocused return true if any of the tags in the scroll table has focus.
|
||||
func (st *ScrollTable) TreeFocused(gtx C) bool {
|
||||
return !st.Tags(0, func(_ int, tag event.Tag) bool {
|
||||
return !gtx.Focused(tag)
|
||||
})
|
||||
}
|
||||
|
||||
func (st *ScrollTable) EnsureCursorVisible() {
|
||||
@ -80,87 +110,82 @@ func (st *ScrollTable) EnsureCursorVisible() {
|
||||
st.RowTitleList.EnsureVisible(st.Table.Cursor().Y)
|
||||
}
|
||||
|
||||
func (st *ScrollTable) ChildFocused() bool {
|
||||
return st.ColTitleList.Focused() || st.RowTitleList.Focused()
|
||||
}
|
||||
|
||||
func (s ScrollTableStyle) Layout(gtx C) D {
|
||||
func (s ScrollTableStyle) Layout(gtx C, element func(gtx C, x, y int) D, colTitle, rowTitle, colTitleBg, rowTitleBg func(gtx C, i int) D) D {
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, s.ScrollTable)
|
||||
|
||||
p := image.Pt(gtx.Dp(s.RowTitleWidth), gtx.Dp(s.ColumnTitleHeight))
|
||||
s.handleEvents(gtx, p)
|
||||
|
||||
return Surface{Gray: 24, Focus: s.ScrollTable.Focused() || s.ScrollTable.ChildFocused()}.Layout(gtx, func(gtx C) D {
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
|
||||
dims := gtx.Constraints.Max
|
||||
s.layoutColTitles(gtx, p)
|
||||
s.layoutRowTitles(gtx, p)
|
||||
defer op.Offset(p).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X-p.X, gtx.Constraints.Max.Y-p.Y))
|
||||
s.layoutTable(gtx, p)
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Push(gtx.Ops).Pop()
|
||||
s.layoutOffset(gtx, image.Pt(p.X, 0), func(gtx C) D { return s.ColTitleStyle.Layout(gtx, colTitle, colTitleBg) })
|
||||
s.layoutOffset(gtx, image.Pt(0, p.Y), func(gtx C) D { return s.RowTitleStyle.Layout(gtx, rowTitle, rowTitleBg) })
|
||||
s.layoutOffset(gtx, p, func(gtx C) D {
|
||||
s.layoutTable(gtx, element)
|
||||
s.RowTitleStyle.LayoutScrollBar(gtx)
|
||||
s.ColTitleStyle.LayoutScrollBar(gtx)
|
||||
return D{Size: dims}
|
||||
return D{Size: gtx.Constraints.Max}
|
||||
})
|
||||
return D{Size: gtx.Constraints.Max}
|
||||
|
||||
}
|
||||
|
||||
func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) {
|
||||
for {
|
||||
e, ok := gtx.Event(
|
||||
key.FocusFilter{Target: s.ScrollTable},
|
||||
transfer.TargetFilter{Target: s.ScrollTable, Type: "application/text"},
|
||||
pointer.Filter{Target: s.ScrollTable, Kinds: pointer.Press},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameLeftArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameUpArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameRightArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameDownArrow, Optional: key.ModShift | key.ModCtrl | key.ModAlt},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NamePageUp, Optional: key.ModShift},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NamePageDown, Optional: key.ModShift},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameHome, Optional: key.ModShift},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameEnd, Optional: key.ModShift},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteBackward},
|
||||
key.Filter{Focus: s.ScrollTable, Name: key.NameDeleteForward},
|
||||
key.Filter{Focus: s.ScrollTable, Name: "C", Required: key.ModShortcut},
|
||||
key.Filter{Focus: s.ScrollTable, Name: "V", Required: key.ModShortcut},
|
||||
key.Filter{Focus: s.ScrollTable, Name: "X", Required: key.ModShortcut},
|
||||
key.Filter{Focus: s.ScrollTable, Name: "+"},
|
||||
key.Filter{Focus: s.ScrollTable, Name: "-"},
|
||||
)
|
||||
e, ok := gtx.Event(s.ScrollTable.eventFilters...)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case key.FocusEvent:
|
||||
s.ScrollTable.focused = e.Focus
|
||||
case pointer.Event:
|
||||
if int(e.Position.X) < p.X || int(e.Position.Y) < p.Y {
|
||||
break
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
if s.ScrollTable.drag {
|
||||
break
|
||||
}
|
||||
s.ScrollTable.dragID = e.PointerID
|
||||
s.ScrollTable.drag = true
|
||||
fallthrough
|
||||
case pointer.Drag:
|
||||
if s.ScrollTable.dragID != e.PointerID {
|
||||
break
|
||||
}
|
||||
if int(e.Position.X) < p.X || int(e.Position.Y) < p.Y {
|
||||
break
|
||||
}
|
||||
e.Position.X -= float32(p.X)
|
||||
e.Position.Y -= float32(p.Y)
|
||||
if e.Kind == pointer.Press {
|
||||
gtx.Execute(key.FocusCmd{Tag: s.ScrollTable})
|
||||
}
|
||||
dx := (e.Position.X + float32(s.ScrollTable.ColTitleList.List.Position.Offset)) / float32(gtx.Dp(s.CellWidth))
|
||||
dy := (e.Position.Y + float32(s.ScrollTable.RowTitleList.List.Position.Offset)) / float32(gtx.Dp(s.CellHeight))
|
||||
x := dx + float32(s.ScrollTable.ColTitleList.List.Position.First)
|
||||
y := dy + float32(s.ScrollTable.RowTitleList.List.Position.First)
|
||||
cursorPoint := tracker.Point{X: int(x), Y: int(y)}
|
||||
s.ScrollTable.Table.SetCursor2(cursorPoint)
|
||||
if e.Kind == pointer.Press && !e.Modifiers.Contain(key.ModShift) {
|
||||
s.ScrollTable.Table.SetCursor(cursorPoint)
|
||||
}
|
||||
s.ScrollTable.cursorMoved = true
|
||||
case pointer.Release:
|
||||
fallthrough
|
||||
case pointer.Cancel:
|
||||
s.ScrollTable.drag = false
|
||||
}
|
||||
e.Position.X -= float32(p.X)
|
||||
e.Position.Y -= float32(p.Y)
|
||||
if e.Kind == pointer.Press {
|
||||
gtx.Execute(key.FocusCmd{Tag: s.ScrollTable})
|
||||
}
|
||||
dx := (int(e.Position.X) + s.ScrollTable.ColTitleList.List.Position.Offset) / gtx.Dp(s.CellWidth)
|
||||
dy := (int(e.Position.Y) + s.ScrollTable.RowTitleList.List.Position.Offset) / gtx.Dp(s.CellHeight)
|
||||
x := dx + s.ScrollTable.ColTitleList.List.Position.First
|
||||
y := dy + s.ScrollTable.RowTitleList.List.Position.First
|
||||
s.ScrollTable.Table.SetCursor(
|
||||
tracker.Point{X: x, Y: y},
|
||||
)
|
||||
if !e.Modifiers.Contain(key.ModShift) {
|
||||
s.ScrollTable.Table.SetCursor2(s.ScrollTable.Table.Cursor())
|
||||
}
|
||||
s.ScrollTable.cursorMoved = true
|
||||
case key.Event:
|
||||
if e.State == key.Press {
|
||||
s.ScrollTable.command(gtx, e)
|
||||
s.ScrollTable.command(gtx, e, p)
|
||||
}
|
||||
case transfer.DataEvent:
|
||||
if b, err := io.ReadAll(e.Open()); err == nil {
|
||||
s.ScrollTable.Table.Paste(b)
|
||||
}
|
||||
case key.FocusEvent:
|
||||
if e.Focus {
|
||||
s.ScrollTable.ColTitleList.EnsureVisible(s.ScrollTable.Table.Cursor().X)
|
||||
s.ScrollTable.RowTitleList.EnsureVisible(s.ScrollTable.Table.Cursor().Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +214,7 @@ func (s *ScrollTableStyle) handleEvents(gtx layout.Context, p image.Point) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) {
|
||||
func (s ScrollTableStyle) layoutTable(gtx C, element func(gtx C, x, y int) D) {
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
|
||||
|
||||
if s.ScrollTable.requestFocus {
|
||||
@ -207,85 +232,75 @@ func (s ScrollTableStyle) layoutTable(gtx C, p image.Point) {
|
||||
for x := 0; x < colP.Count; x++ {
|
||||
for y := 0; y < rowP.Count; y++ {
|
||||
o := op.Offset(image.Pt(cellWidth*x, cellHeight*y)).Push(gtx.Ops)
|
||||
s.element(gtx, x+colP.First, y+rowP.First)
|
||||
element(gtx, x+colP.First, y+rowP.First)
|
||||
o.Pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScrollTableStyle) layoutRowTitles(gtx C, p image.Point) {
|
||||
defer op.Offset(image.Pt(0, p.Y)).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints.Min.X = p.X
|
||||
gtx.Constraints.Max.Y -= p.Y
|
||||
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
|
||||
s.RowTitleStyle.Layout(gtx)
|
||||
func (s ScrollTableStyle) layoutOffset(gtx C, offset image.Point, element func(gtx C) D) {
|
||||
gtx.Constraints = layout.Exact(gtx.Constraints.Max.Sub(offset))
|
||||
defer op.Offset(offset).Push(gtx.Ops).Pop()
|
||||
element(gtx)
|
||||
}
|
||||
|
||||
func (s *ScrollTableStyle) layoutColTitles(gtx C, p image.Point) {
|
||||
defer op.Offset(image.Pt(p.X, 0)).Push(gtx.Ops).Pop()
|
||||
gtx.Constraints.Min.Y = p.Y
|
||||
gtx.Constraints.Max.X -= p.X
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
s.ColTitleStyle.Layout(gtx)
|
||||
}
|
||||
|
||||
func (s *ScrollTable) command(gtx C, e key.Event) {
|
||||
func (s *ScrollTable) command(gtx C, e key.Event, p image.Point) {
|
||||
stepX := 1
|
||||
stepY := 1
|
||||
if e.Modifiers.Contain(key.ModAlt) {
|
||||
stepX = intMax(s.ColTitleList.List.Position.Count-3, 8)
|
||||
stepY = intMax(s.RowTitleList.List.Position.Count-3, 8)
|
||||
stepX = max(s.ColTitleList.List.Position.Count-3, 8)
|
||||
stepY = max(s.RowTitleList.List.Position.Count-3, 8)
|
||||
} else if e.Modifiers.Contain(key.ModCtrl) {
|
||||
stepX = 1e6
|
||||
stepY = 1e6
|
||||
}
|
||||
switch e.Name {
|
||||
case "X", "C":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
contents, ok := s.Table.Copy()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
|
||||
if e.Name == "X" {
|
||||
s.Table.Clear()
|
||||
}
|
||||
return
|
||||
}
|
||||
case "V":
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
gtx.Execute(clipboard.ReadCmd{Tag: s})
|
||||
}
|
||||
return
|
||||
case key.NameDeleteBackward, key.NameDeleteForward:
|
||||
s.Table.Clear()
|
||||
return
|
||||
case key.NameUpArrow:
|
||||
if !s.Table.MoveCursor(0, -stepY) && stepY == 1 {
|
||||
if !s.Table.MoveCursor(0, -stepY) && stepY == 1 && p.Y > 0 {
|
||||
s.ColTitleList.Focus()
|
||||
}
|
||||
case key.NameDownArrow:
|
||||
s.Table.MoveCursor(0, stepY)
|
||||
case key.NameLeftArrow:
|
||||
if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 {
|
||||
if !s.Table.MoveCursor(-stepX, 0) && stepX == 1 && p.X > 0 {
|
||||
s.RowTitleList.Focus()
|
||||
}
|
||||
case key.NameRightArrow:
|
||||
s.Table.MoveCursor(stepX, 0)
|
||||
case key.NamePageUp:
|
||||
s.Table.MoveCursor(0, -intMax(s.RowTitleList.List.Position.Count-3, 8))
|
||||
s.Table.MoveCursor(0, -max(s.RowTitleList.List.Position.Count-3, 8))
|
||||
case key.NamePageDown:
|
||||
s.Table.MoveCursor(0, intMax(s.RowTitleList.List.Position.Count-3, 8))
|
||||
s.Table.MoveCursor(0, max(s.RowTitleList.List.Position.Count-3, 8))
|
||||
case key.NameHome:
|
||||
s.Table.SetCursorX(0)
|
||||
case key.NameEnd:
|
||||
s.Table.SetCursorX(s.Table.Width() - 1)
|
||||
case "+":
|
||||
s.Table.Add(1)
|
||||
return
|
||||
case "-":
|
||||
s.Table.Add(-1)
|
||||
return
|
||||
default:
|
||||
a := keyBindingMap[e]
|
||||
switch a {
|
||||
case "Copy", "Cut":
|
||||
contents, ok := s.Table.Copy()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(bytes.NewReader(contents))})
|
||||
if a == "Cut" {
|
||||
s.Table.Clear()
|
||||
}
|
||||
return
|
||||
case "Paste":
|
||||
gtx.Execute(clipboard.ReadCmd{Tag: s})
|
||||
return
|
||||
case "Increase", "IncreaseMore":
|
||||
s.Table.Add(1, a == "IncreaseMore")
|
||||
return
|
||||
case "Decrease", "DecreaseMore":
|
||||
s.Table.Add(-1, a == "DecreaseMore")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !e.Modifiers.Contain(key.ModShift) {
|
||||
s.Table.SetCursor2(s.Table.Cursor())
|
||||
|
||||
@ -2,6 +2,7 @@ package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/event"
|
||||
@ -21,19 +22,27 @@ type ScrollBar struct {
|
||||
tag bool
|
||||
}
|
||||
|
||||
func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Position) D {
|
||||
type ScrollBarStyle struct {
|
||||
Color color.NRGBA
|
||||
Width unit.Dp
|
||||
Gradient color.NRGBA
|
||||
}
|
||||
|
||||
func (s *ScrollBar) Layout(gtx C, style *ScrollBarStyle, numItems int, pos *layout.Position) D {
|
||||
defer op.Offset(image.Point{}).Push(gtx.Ops).Pop()
|
||||
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
||||
gradientSize := gtx.Dp(unit.Dp(4))
|
||||
var totalPixelsEstimate, scrollBarRelLength float32
|
||||
transparent := style.Gradient
|
||||
transparent.A = 0
|
||||
switch s.Axis {
|
||||
case layout.Vertical:
|
||||
if pos.First > 0 || pos.Offset > 0 {
|
||||
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop2: f32.Pt(0, float32(gradientSize))}.Add(gtx.Ops)
|
||||
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop2: f32.Pt(0, float32(gradientSize))}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
if pos.BeforeEnd {
|
||||
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop1: f32.Pt(0, float32(gtx.Constraints.Min.Y)), Stop2: f32.Pt(0, float32(gtx.Constraints.Min.Y-gradientSize))}.Add(gtx.Ops)
|
||||
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop1: f32.Pt(0, float32(gtx.Constraints.Min.Y)), Stop2: f32.Pt(0, float32(gtx.Constraints.Min.Y-gradientSize))}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
totalPixelsEstimate = float32(gtx.Constraints.Min.Y+pos.Offset-pos.OffsetLast) * float32(numItems) / float32(pos.Count)
|
||||
@ -41,11 +50,11 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
|
||||
|
||||
case layout.Horizontal:
|
||||
if pos.First > 0 || pos.Offset > 0 {
|
||||
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop2: f32.Pt(float32(gradientSize), 0)}.Add(gtx.Ops)
|
||||
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop2: f32.Pt(float32(gradientSize), 0)}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
if pos.BeforeEnd {
|
||||
paint.LinearGradientOp{Color1: black, Color2: transparent, Stop1: f32.Pt(float32(gtx.Constraints.Min.X), 0), Stop2: f32.Pt(float32(gtx.Constraints.Min.X-gradientSize), 0)}.Add(gtx.Ops)
|
||||
paint.LinearGradientOp{Color1: style.Gradient, Color2: transparent, Stop1: f32.Pt(float32(gtx.Constraints.Min.X), 0), Stop2: f32.Pt(float32(gtx.Constraints.Min.X-gradientSize), 0)}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
totalPixelsEstimate = float32(gtx.Constraints.Min.X+pos.Offset-pos.OffsetLast) * float32(numItems) / float32(pos.Count)
|
||||
@ -56,7 +65,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
|
||||
}
|
||||
|
||||
scrollBarRelStart := (float32(pos.First)*totalPixelsEstimate/float32(numItems) + float32(pos.Offset)) / totalPixelsEstimate
|
||||
scrWidth := gtx.Dp(width)
|
||||
scrWidth := gtx.Dp(style.Width)
|
||||
|
||||
stack := op.Offset(image.Point{}).Push(gtx.Ops)
|
||||
var area clip.Stack
|
||||
@ -65,7 +74,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
|
||||
if scrollBarRelLength < 1 && (s.dragging || s.hovering) {
|
||||
y1 := int(scrollBarRelStart * float32(gtx.Constraints.Min.Y))
|
||||
y2 := int((scrollBarRelStart + scrollBarRelLength) * float32(gtx.Constraints.Min.Y))
|
||||
paint.FillShape(gtx.Ops, scrollBarColor, clip.Rect{Min: image.Pt(gtx.Constraints.Min.X-scrWidth, y1), Max: image.Pt(gtx.Constraints.Min.X, y2)}.Op())
|
||||
paint.FillShape(gtx.Ops, style.Color, clip.Rect{Min: image.Pt(gtx.Constraints.Min.X-scrWidth, y1), Max: image.Pt(gtx.Constraints.Min.X, y2)}.Op())
|
||||
}
|
||||
rect := image.Rect(gtx.Constraints.Min.X-scrWidth, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
|
||||
area = clip.Rect(rect).Push(gtx.Ops)
|
||||
@ -73,7 +82,7 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
|
||||
if scrollBarRelLength < 1 && (s.dragging || s.hovering) {
|
||||
x1 := int(scrollBarRelStart * float32(gtx.Constraints.Min.X))
|
||||
x2 := int((scrollBarRelStart + scrollBarRelLength) * float32(gtx.Constraints.Min.X))
|
||||
paint.FillShape(gtx.Ops, scrollBarColor, clip.Rect{Min: image.Pt(x1, gtx.Constraints.Min.Y-scrWidth), Max: image.Pt(x2, gtx.Constraints.Min.Y)}.Op())
|
||||
paint.FillShape(gtx.Ops, style.Color, clip.Rect{Min: image.Pt(x1, gtx.Constraints.Min.Y-scrWidth), Max: image.Pt(x2, gtx.Constraints.Min.Y)}.Op())
|
||||
}
|
||||
rect := image.Rect(0, gtx.Constraints.Min.Y-scrWidth, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)
|
||||
area = clip.Rect(rect).Push(gtx.Ops)
|
||||
@ -143,28 +152,3 @@ func (s *ScrollBar) Layout(gtx C, width unit.Dp, numItems int, pos *layout.Posit
|
||||
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}
|
||||
|
||||
func scrollToView(l *layout.List, index int, length int) {
|
||||
pmin := index + 2 - l.Position.Count
|
||||
pmax := index - 1
|
||||
if pmin < 0 {
|
||||
pmin = 0
|
||||
}
|
||||
if pmax < 0 {
|
||||
pmax = 0
|
||||
}
|
||||
m := length - 1
|
||||
if pmin > m {
|
||||
pmin = m
|
||||
}
|
||||
if pmax > m {
|
||||
pmax = m
|
||||
}
|
||||
if l.Position.First > pmax {
|
||||
l.Position.First = pmax
|
||||
l.Position.Offset = 0
|
||||
}
|
||||
if l.Position.First < pmin {
|
||||
l.Position.First = pmin
|
||||
}
|
||||
}
|
||||
|
||||
106
tracker/gioui/signal_rail.go
Normal file
106
tracker/gioui/signal_rail.go
Normal file
@ -0,0 +1,106 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
const maxSignalsDrawn = 16
|
||||
|
||||
type (
|
||||
RailStyle struct {
|
||||
Color color.NRGBA
|
||||
LineWidth unit.Dp
|
||||
SignalWidth unit.Dp
|
||||
PortDiameter unit.Dp
|
||||
PortColor color.NRGBA
|
||||
}
|
||||
|
||||
RailWidget struct {
|
||||
Style *RailStyle
|
||||
Signal tracker.Rail
|
||||
Height unit.Dp
|
||||
}
|
||||
)
|
||||
|
||||
func Rail(th *Theme, signal tracker.Rail) RailWidget {
|
||||
return RailWidget{
|
||||
Style: &th.SignalRail,
|
||||
Signal: signal,
|
||||
Height: th.UnitEditor.Height,
|
||||
}
|
||||
}
|
||||
|
||||
func (s RailWidget) Layout(gtx C) D {
|
||||
sw := gtx.Dp(s.Style.SignalWidth)
|
||||
h := gtx.Dp(s.Height)
|
||||
if s.Signal.PassThrough == 0 && len(s.Signal.StackUse.Inputs) == 0 && s.Signal.StackUse.NumOutputs == 0 {
|
||||
return D{Size: image.Pt(sw, h)}
|
||||
}
|
||||
lw := gtx.Dp(s.Style.LineWidth)
|
||||
pd := gtx.Dp(s.Style.PortDiameter)
|
||||
center := sw / 2
|
||||
var path clip.Path
|
||||
path.Begin(gtx.Ops)
|
||||
// Draw pass through signals
|
||||
for i := range min(maxSignalsDrawn, s.Signal.PassThrough) {
|
||||
x := float32(i*sw + center)
|
||||
path.MoveTo(f32.Pt(x, 0))
|
||||
path.LineTo(f32.Pt(x, float32(h)))
|
||||
}
|
||||
// Draw the routing of input signals
|
||||
for i := range min(len(s.Signal.StackUse.Inputs), maxSignalsDrawn-s.Signal.PassThrough) {
|
||||
input := s.Signal.StackUse.Inputs[i]
|
||||
x1 := float32((i+s.Signal.PassThrough)*sw + center)
|
||||
for _, link := range input {
|
||||
x2 := float32((link+s.Signal.PassThrough)*sw + center)
|
||||
path.MoveTo(f32.Pt(x1, 0))
|
||||
path.LineTo(f32.Pt(x2, float32(h/2)))
|
||||
}
|
||||
}
|
||||
if s.Signal.Send {
|
||||
for i := range min(len(s.Signal.StackUse.Inputs), maxSignalsDrawn-s.Signal.PassThrough) {
|
||||
d := gtx.Dp(8)
|
||||
from := f32.Pt(float32((i+s.Signal.PassThrough)*sw+center), float32(h/2))
|
||||
to := f32.Pt(float32(gtx.Constraints.Max.X), float32(h)-float32(d))
|
||||
ctrl := f32.Pt(from.X, to.Y)
|
||||
path.MoveTo(from)
|
||||
path.QuadTo(ctrl, to)
|
||||
}
|
||||
}
|
||||
// Draw the routing of output signals
|
||||
for i := range min(s.Signal.StackUse.NumOutputs, maxSignalsDrawn-s.Signal.PassThrough) {
|
||||
x := float32((i+s.Signal.PassThrough)*sw + center)
|
||||
path.MoveTo(f32.Pt(x, float32(h/2)))
|
||||
path.LineTo(f32.Pt(x, float32(h)))
|
||||
}
|
||||
// Signal paths finished
|
||||
paint.FillShape(gtx.Ops, s.Style.Color,
|
||||
clip.Stroke{
|
||||
Path: path.End(),
|
||||
Width: float32(lw),
|
||||
}.Op())
|
||||
// Draw the circles on signals that get modified
|
||||
var circle clip.Path
|
||||
circle.Begin(gtx.Ops)
|
||||
for i := range min(len(s.Signal.StackUse.Modifies), maxSignalsDrawn-s.Signal.PassThrough) {
|
||||
if !s.Signal.StackUse.Modifies[i] {
|
||||
continue
|
||||
}
|
||||
f := f32.Pt(float32((i+s.Signal.PassThrough)*sw+center), float32(h/2))
|
||||
circle.MoveTo(f32.Pt(f.X-float32(pd/2), float32(h/2)))
|
||||
circle.ArcTo(f, f, float32(2*math.Pi))
|
||||
}
|
||||
p := clip.Outline{Path: circle.End()}.Op().Push(gtx.Ops)
|
||||
paint.ColorOp{Color: s.Style.PortColor}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
p.Pop()
|
||||
return D{Size: image.Pt(sw, h)}
|
||||
}
|
||||
601
tracker/gioui/song_panel.go
Normal file
601
tracker/gioui/song_panel.go
Normal file
@ -0,0 +1,601 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"github.com/vsariola/sointu/version"
|
||||
"github.com/vsariola/sointu/vm"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type SongPanel struct {
|
||||
SongSettingsExpander *Expander
|
||||
ScopeExpander *Expander
|
||||
LoudnessExpander *Expander
|
||||
PeakExpander *Expander
|
||||
CPUExpander *Expander
|
||||
SpectrumExpander *Expander
|
||||
|
||||
WeightingTypeBtn *Clickable
|
||||
OversamplingBtn *Clickable
|
||||
SynthBtn *Clickable
|
||||
|
||||
BPM *NumericUpDownState
|
||||
RowsPerPattern *NumericUpDownState
|
||||
RowsPerBeat *NumericUpDownState
|
||||
Step *NumericUpDownState
|
||||
SongLength *NumericUpDownState
|
||||
|
||||
List *layout.List
|
||||
ScrollBar *ScrollBar
|
||||
|
||||
Scope *OscilloscopeState
|
||||
ScopeScaleBar *ScaleBar
|
||||
|
||||
SpectrumState *SpectrumState
|
||||
SpectrumScaleBar *ScaleBar
|
||||
|
||||
MenuBar *MenuBar
|
||||
PlayBar *PlayBar
|
||||
}
|
||||
|
||||
func NewSongPanel(tr *Tracker) *SongPanel {
|
||||
ret := &SongPanel{
|
||||
BPM: NewNumericUpDownState(),
|
||||
RowsPerPattern: NewNumericUpDownState(),
|
||||
RowsPerBeat: NewNumericUpDownState(),
|
||||
Step: NewNumericUpDownState(),
|
||||
SongLength: NewNumericUpDownState(),
|
||||
Scope: NewOscilloscope(),
|
||||
MenuBar: NewMenuBar(tr),
|
||||
PlayBar: NewPlayBar(),
|
||||
|
||||
WeightingTypeBtn: new(Clickable),
|
||||
OversamplingBtn: new(Clickable),
|
||||
SynthBtn: new(Clickable),
|
||||
|
||||
SongSettingsExpander: &Expander{Expanded: true},
|
||||
ScopeExpander: &Expander{},
|
||||
LoudnessExpander: &Expander{},
|
||||
PeakExpander: &Expander{},
|
||||
CPUExpander: &Expander{},
|
||||
SpectrumExpander: &Expander{},
|
||||
|
||||
List: &layout.List{Axis: layout.Vertical},
|
||||
ScrollBar: &ScrollBar{Axis: layout.Vertical},
|
||||
|
||||
SpectrumState: NewSpectrumState(),
|
||||
SpectrumScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
|
||||
ScopeScaleBar: &ScaleBar{Axis: layout.Vertical, BarSize: 10, Size: 300},
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *SongPanel) Update(gtx C, t *Tracker) {
|
||||
for s.WeightingTypeBtn.Clicked(gtx) {
|
||||
t.Model.Detector().Weighting().SetValue((t.Detector().Weighting().Value() + 1) % int(tracker.NumWeightingTypes))
|
||||
}
|
||||
for s.OversamplingBtn.Clicked(gtx) {
|
||||
t.Model.Detector().Oversampling().SetValue(!t.Detector().Oversampling().Value())
|
||||
}
|
||||
for s.SynthBtn.Clicked(gtx) {
|
||||
r := t.Model.Play().SyntherIndex().Range()
|
||||
t.Model.Play().SyntherIndex().SetValue((t.Play().SyntherIndex().Value()+1)%(r.Max-r.Min+1) + r.Min)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongPanel) Layout(gtx C) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
s.Update(gtx, t)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(s.MenuBar.Layout),
|
||||
layout.Rigid(s.PlayBar.Layout),
|
||||
layout.Rigid(s.layoutSongOptions),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SongPanel) layoutSongOptions(gtx C) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
paint.FillShape(gtx.Ops, tr.Theme.SongPanel.Bg, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
|
||||
|
||||
var weightingTxt string
|
||||
switch tracker.WeightingType(tr.Model.Detector().Weighting().Value()) {
|
||||
case tracker.KWeighting:
|
||||
weightingTxt = "K-weight (LUFS)"
|
||||
case tracker.AWeighting:
|
||||
weightingTxt = "A-weight"
|
||||
case tracker.CWeighting:
|
||||
weightingTxt = "C-weight"
|
||||
case tracker.NoWeighting:
|
||||
weightingTxt = "No weight (RMS)"
|
||||
}
|
||||
|
||||
weightingBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.WeightingTypeBtn, weightingTxt, "")
|
||||
|
||||
oversamplingTxt := "Sample peak"
|
||||
if tr.Model.Detector().Oversampling().Value() {
|
||||
oversamplingTxt = "True peak"
|
||||
}
|
||||
oversamplingBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.OversamplingBtn, oversamplingTxt, "")
|
||||
|
||||
cpuSmallLabel := func(gtx C) D {
|
||||
var a [vm.MAX_THREADS]sointu.CPULoad
|
||||
c := tr.Play().CPULoad(a[:])
|
||||
if c < 1 {
|
||||
return D{}
|
||||
}
|
||||
load := slices.Max(a[:c])
|
||||
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, fmt.Sprintf("%d%%", int(load*100+0.5)))
|
||||
if load >= 1 {
|
||||
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
|
||||
}
|
||||
return cpuLabel.Layout(gtx)
|
||||
}
|
||||
|
||||
cpuEnlargedWidget := func(gtx C) D {
|
||||
var sb strings.Builder
|
||||
var a [vm.MAX_THREADS]sointu.CPULoad
|
||||
c := tr.Play().CPULoad(a[:])
|
||||
high := false
|
||||
for i := range c {
|
||||
if i > 0 {
|
||||
fmt.Fprint(&sb, ", ")
|
||||
}
|
||||
cpuLoad := a[i]
|
||||
fmt.Fprintf(&sb, "%d%%", int(cpuLoad*100+0.5))
|
||||
if cpuLoad >= 1 {
|
||||
high = true
|
||||
}
|
||||
}
|
||||
cpuLabel := Label(tr.Theme, &tr.Theme.SongPanel.RowValue, sb.String())
|
||||
if high {
|
||||
cpuLabel.Color = tr.Theme.SongPanel.ErrorColor
|
||||
}
|
||||
return cpuLabel.Layout(gtx)
|
||||
}
|
||||
|
||||
synthBtn := Btn(tr.Theme, &tr.Theme.Button.Text, t.SynthBtn, tr.Model.Play().SyntherName(), "")
|
||||
|
||||
listItem := func(gtx C, index int) D {
|
||||
switch index {
|
||||
case 0:
|
||||
return t.SongSettingsExpander.Layout(gtx, tr.Theme, "Song",
|
||||
func(gtx C) D {
|
||||
return Label(tr.Theme, &tr.Theme.SongPanel.RowHeader, strconv.Itoa(tr.Song().BPM().Value())+" BPM").Layout(gtx)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
bpm := NumUpDown(tr.Song().BPM(), tr.Theme, t.BPM, "BPM")
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "BPM", bpm.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
songLength := NumUpDown(tr.Song().Length(), tr.Theme, t.SongLength, "Song length")
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Song length", songLength.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
rowsPerPattern := NumUpDown(tr.Song().RowsPerPattern(), tr.Theme, t.RowsPerPattern, "Rows per pattern")
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Rows per pat", rowsPerPattern.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
rowsPerBeat := NumUpDown(tr.Song().RowsPerBeat(), tr.Theme, t.RowsPerBeat, "Rows per beat")
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Rows per beat", rowsPerBeat.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
step := NumUpDown(tr.Note().Step(), tr.Theme, t.Step, "Cursor step")
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Cursor step", step.Layout)
|
||||
}),
|
||||
)
|
||||
})
|
||||
case 1:
|
||||
return t.CPUExpander.Layout(gtx, tr.Theme, "CPU", cpuSmallLabel,
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Load", cpuEnlargedWidget) }),
|
||||
layout.Rigid(func(gtx C) D { return layoutSongOptionRow(gtx, tr.Theme, "Synth", synthBtn.Layout) }),
|
||||
)
|
||||
},
|
||||
)
|
||||
case 2:
|
||||
return t.LoudnessExpander.Layout(gtx, tr.Theme, "Loudness",
|
||||
func(gtx C) D {
|
||||
loudness := tr.Model.Detector().Result().Loudness[tracker.LoudnessShortTerm]
|
||||
return dbLabel(tr.Theme, loudness).Layout(gtx)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Momentary", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMomentary]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Short term", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessShortTerm]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Integrated", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessIntegrated]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Max. momentary", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMaxMomentary]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Max. short term", dbLabel(tr.Theme, tr.Model.Detector().Result().Loudness[tracker.LoudnessMaxShortTerm]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = 0
|
||||
return weightingBtn.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
case 3:
|
||||
return t.PeakExpander.Layout(gtx, tr.Theme, "Peaks",
|
||||
func(gtx C) D {
|
||||
maxPeak := max(tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][0], tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][1])
|
||||
return dbLabel(tr.Theme, maxPeak).Layout(gtx)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
|
||||
// no need to show momentary peak, it does not have too much meaning
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Short term L", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][0]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Short term R", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakShortTerm][1]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Integrated L", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakIntegrated][0]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layoutSongOptionRow(gtx, tr.Theme, "Integrated R", dbLabel(tr.Theme, tr.Model.Detector().Result().Peaks[tracker.PeakIntegrated][1]).Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = 0
|
||||
return oversamplingBtn.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
case 4:
|
||||
scope := Scope(tr.Theme, t.Scope)
|
||||
scopeScaleBar := func(gtx C) D {
|
||||
return t.ScopeScaleBar.Layout(gtx, scope.Layout)
|
||||
}
|
||||
return t.ScopeExpander.Layout(gtx, tr.Theme, "Oscilloscope", func(gtx C) D { return D{} }, scopeScaleBar)
|
||||
case 5:
|
||||
spectrumScaleBar := func(gtx C) D {
|
||||
return t.SpectrumScaleBar.Layout(gtx, t.SpectrumState.Layout)
|
||||
}
|
||||
return t.SpectrumExpander.Layout(gtx, tr.Theme, "Spectrum", func(gtx C) D { return D{} }, spectrumScaleBar)
|
||||
case 6:
|
||||
return Label(tr.Theme, &tr.Theme.SongPanel.Version, version.VersionOrHash).Layout(gtx)
|
||||
default:
|
||||
return D{}
|
||||
}
|
||||
}
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
dims := t.List.Layout(gtx, 7, listItem)
|
||||
t.ScrollBar.Layout(gtx, &tr.Theme.SongPanel.ScrollBar, 7, &t.List.Position)
|
||||
tr.Spectrum().Enabled().SetValue(t.SpectrumExpander.Expanded)
|
||||
return dims
|
||||
}
|
||||
|
||||
func dbLabel(th *Theme, value tracker.Decibel) LabelWidget {
|
||||
ret := Label(th, &th.SongPanel.RowValue, fmt.Sprintf("%.1f dB", value))
|
||||
if value >= 0 {
|
||||
ret.Color = th.SongPanel.ErrorColor
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func layoutSongOptionRow(gtx C, th *Theme, label string, widget layout.Widget) D {
|
||||
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
|
||||
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
|
||||
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(th, &th.SongPanel.RowHeader, label).Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(widget),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}
|
||||
|
||||
type ScaleBar struct {
|
||||
Size, BarSize unit.Dp
|
||||
Axis layout.Axis
|
||||
|
||||
drag bool
|
||||
dragID pointer.ID
|
||||
dragStart f32.Point
|
||||
}
|
||||
|
||||
func (s *ScaleBar) Layout(gtx C, w layout.Widget) D {
|
||||
s.Update(gtx)
|
||||
pxBar := gtx.Dp(s.BarSize)
|
||||
pxTot := gtx.Dp(s.Size) + pxBar
|
||||
var rect image.Rectangle
|
||||
var size image.Point
|
||||
if s.Axis == layout.Horizontal {
|
||||
pxTot = min(max(gtx.Constraints.Min.X, pxTot), gtx.Constraints.Max.X)
|
||||
px := pxTot - pxBar
|
||||
rect = image.Rect(px, 0, pxTot, gtx.Constraints.Max.Y)
|
||||
size = image.Pt(pxTot, gtx.Constraints.Max.Y)
|
||||
gtx.Constraints.Max.X = px
|
||||
gtx.Constraints.Min.X = min(gtx.Constraints.Min.X, px)
|
||||
} else {
|
||||
pxTot = min(max(gtx.Constraints.Min.Y, pxTot), gtx.Constraints.Max.Y)
|
||||
px := pxTot - pxBar
|
||||
rect = image.Rect(0, px, gtx.Constraints.Max.X, pxTot)
|
||||
size = image.Pt(gtx.Constraints.Max.X, pxTot)
|
||||
gtx.Constraints.Max.Y = px
|
||||
gtx.Constraints.Min.Y = min(gtx.Constraints.Min.Y, px)
|
||||
}
|
||||
area := clip.Rect(rect).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, s)
|
||||
if s.Axis == layout.Horizontal {
|
||||
pointer.CursorColResize.Add(gtx.Ops)
|
||||
} else {
|
||||
pointer.CursorRowResize.Add(gtx.Ops)
|
||||
}
|
||||
area.Pop()
|
||||
w(gtx)
|
||||
return D{Size: size}
|
||||
}
|
||||
|
||||
func (s *ScaleBar) Update(gtx C) {
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: s,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
if s.drag {
|
||||
break
|
||||
}
|
||||
s.dragID = e.PointerID
|
||||
s.dragStart = e.Position
|
||||
s.drag = true
|
||||
case pointer.Drag:
|
||||
if s.dragID != e.PointerID {
|
||||
break
|
||||
}
|
||||
if s.Axis == layout.Horizontal {
|
||||
s.Size += gtx.Metric.PxToDp(int(e.Position.X - s.dragStart.X))
|
||||
} else {
|
||||
s.Size += gtx.Metric.PxToDp(int(e.Position.Y - s.dragStart.Y))
|
||||
}
|
||||
s.Size = max(s.Size, unit.Dp(50))
|
||||
s.dragStart = e.Position
|
||||
case pointer.Release, pointer.Cancel:
|
||||
s.drag = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Expander struct {
|
||||
Expanded bool
|
||||
click gesture.Click
|
||||
}
|
||||
|
||||
func (e *Expander) Update(gtx C) {
|
||||
for ev, ok := e.click.Update(gtx.Source); ok; ev, ok = e.click.Update(gtx.Source) {
|
||||
switch ev.Kind {
|
||||
case gesture.KindClick:
|
||||
e.Expanded = !e.Expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Expander) Layout(gtx C, th *Theme, title string, smallWidget, largeWidget layout.Widget) D {
|
||||
e.Update(gtx)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return e.layoutHeader(gtx, th, title, smallWidget) }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if e.Expanded {
|
||||
return largeWidget(gtx)
|
||||
}
|
||||
return D{}
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
px := max(gtx.Dp(unit.Dp(1)), 1)
|
||||
paint.FillShape(gtx.Ops, color.NRGBA{255, 255, 255, 3}, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, px)).Op())
|
||||
return D{Size: image.Pt(gtx.Constraints.Max.X, px)}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (e *Expander) layoutHeader(gtx C, th *Theme, title string, smallWidget layout.Widget) D {
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
defer clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops).Pop()
|
||||
// add click op
|
||||
e.click.Add(gtx.Ops)
|
||||
return D{Size: image.Pt(gtx.Constraints.Min.X, gtx.Constraints.Min.Y)}
|
||||
},
|
||||
func(gtx C) D {
|
||||
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(24)}.Layout
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(th, &th.SongPanel.Expander, title).Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if !e.Expanded {
|
||||
return smallWidget(gtx)
|
||||
}
|
||||
return D{}
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
// draw icon
|
||||
icon := icons.NavigationExpandMore
|
||||
if e.Expanded {
|
||||
icon = icons.NavigationExpandLess
|
||||
}
|
||||
gtx.Constraints.Min = image.Pt(gtx.Dp(unit.Dp(24)), gtx.Dp(unit.Dp(24)))
|
||||
return th.Icon(icon).Layout(gtx, th.SongPanel.Expander.Color)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type MenuBar struct {
|
||||
Clickables []Clickable
|
||||
MenuStates []MenuState
|
||||
|
||||
midiMenuItems []MenuChild
|
||||
|
||||
panicHint string
|
||||
PanicBtn *Clickable
|
||||
}
|
||||
|
||||
func NewMenuBar(tr *Tracker) *MenuBar {
|
||||
ret := &MenuBar{
|
||||
Clickables: make([]Clickable, 4),
|
||||
MenuStates: make([]MenuState, 4),
|
||||
PanicBtn: new(Clickable),
|
||||
panicHint: makeHint("Panic", " (%s)", "PanicToggle"),
|
||||
}
|
||||
for input := range tr.MIDI().InputDevices {
|
||||
ret.midiMenuItems = append(ret.midiMenuItems,
|
||||
ActionMenuChild(tr.MIDI().Open(input), input, "", icons.ImageControlPoint),
|
||||
)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (t *MenuBar) Layout(gtx C) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
|
||||
|
||||
flex := layout.Flex{Axis: layout.Horizontal, Alignment: layout.End}
|
||||
fileBtn := MenuBtn(&t.MenuStates[0], &t.Clickables[0], "File")
|
||||
fileFC := layout.Rigid(func(gtx C) D {
|
||||
items := [...]MenuChild{
|
||||
ActionMenuChild(tr.Song().New(), "New Song", keyActionMap["NewSong"], icons.ContentClear),
|
||||
ActionMenuChild(tr.Song().Open(), "Open Song", keyActionMap["OpenSong"], icons.FileFolder),
|
||||
ActionMenuChild(tr.Song().Save(), "Save Song", keyActionMap["SaveSong"], icons.ContentSave),
|
||||
ActionMenuChild(tr.Song().SaveAs(), "Save Song As...", keyActionMap["SaveSongAs"], icons.ContentSave),
|
||||
ActionMenuChild(tr.Song().Export(), "Export Wav...", keyActionMap["ExportWav"], icons.ImageAudiotrack),
|
||||
ActionMenuChild(tr.RequestQuit(), "Quit", keyActionMap["Quit"], icons.ActionExitToApp),
|
||||
}
|
||||
if !canQuit {
|
||||
return fileBtn.Layout(gtx, items[:len(items)-1]...)
|
||||
}
|
||||
return fileBtn.Layout(gtx, items[:]...)
|
||||
})
|
||||
editBtn := MenuBtn(&t.MenuStates[1], &t.Clickables[1], "Edit")
|
||||
editFC := layout.Rigid(func(gtx C) D {
|
||||
return editBtn.Layout(gtx,
|
||||
ActionMenuChild(tr.History().Undo(), "Undo", keyActionMap["Undo"], icons.ContentUndo),
|
||||
ActionMenuChild(tr.History().Redo(), "Redo", keyActionMap["Redo"], icons.ContentRedo),
|
||||
ActionMenuChild(tr.Order().RemoveUnusedPatterns(), "Remove unused data", keyActionMap["RemoveUnused"], icons.ImageCrop),
|
||||
)
|
||||
})
|
||||
midiBtn := MenuBtn(&t.MenuStates[2], &t.Clickables[2], "MIDI")
|
||||
midiFC := layout.Rigid(func(gtx C) D {
|
||||
return midiBtn.Layout(gtx, t.midiMenuItems...)
|
||||
})
|
||||
helpBtn := MenuBtn(&t.MenuStates[3], &t.Clickables[3], "?")
|
||||
helpFC := layout.Rigid(func(gtx C) D {
|
||||
return helpBtn.Layout(gtx,
|
||||
ActionMenuChild(tr.ShowManual(), "Manual", keyActionMap["ShowManual"], icons.AVLibraryBooks),
|
||||
ActionMenuChild(tr.AskHelp(), "Ask help", keyActionMap["AskHelp"], icons.ActionHelp),
|
||||
ActionMenuChild(tr.ReportBug(), "Report bug", keyActionMap["ReportBug"], icons.ActionBugReport),
|
||||
ActionMenuChild(tr.ShowLicense(), "License", keyActionMap["ShowLicense"], icons.ActionCopyright))
|
||||
})
|
||||
panicBtn := ToggleIconBtn(tr.Play().Panicked(), tr.Theme, t.PanicBtn, icons.AlertErrorOutline, icons.AlertError, t.panicHint, t.panicHint)
|
||||
if tr.Play().Panicked().Value() {
|
||||
panicBtn.Style = &tr.Theme.IconButton.Error
|
||||
}
|
||||
panicFC := layout.Flexed(1, func(gtx C) D { return layout.E.Layout(gtx, panicBtn.Layout) })
|
||||
if len(t.midiMenuItems) > 0 {
|
||||
return flex.Layout(gtx, fileFC, editFC, midiFC, helpFC, panicFC)
|
||||
}
|
||||
return flex.Layout(gtx, fileFC, editFC, helpFC, panicFC)
|
||||
}
|
||||
|
||||
func (sp *SongPanel) Tags(level int, yield TagYieldFunc) bool {
|
||||
for i := range sp.MenuBar.MenuStates {
|
||||
if !sp.MenuBar.MenuStates[i].Tags(level, yield) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type PlayBar struct {
|
||||
RewindBtn *Clickable
|
||||
PlayingBtn *Clickable
|
||||
RecordBtn *Clickable
|
||||
FollowBtn *Clickable
|
||||
LoopBtn *Clickable
|
||||
// Hints
|
||||
rewindHint string
|
||||
playHint, stopHint string
|
||||
recordHint, stopRecordHint string
|
||||
followOnHint, followOffHint string
|
||||
loopOffHint, loopOnHint string
|
||||
}
|
||||
|
||||
func NewPlayBar() *PlayBar {
|
||||
ret := &PlayBar{
|
||||
LoopBtn: new(Clickable),
|
||||
RecordBtn: new(Clickable),
|
||||
FollowBtn: new(Clickable),
|
||||
PlayingBtn: new(Clickable),
|
||||
RewindBtn: new(Clickable),
|
||||
}
|
||||
ret.rewindHint = makeHint("Rewind", "\n(%s)", "PlaySongStartUnfollow")
|
||||
ret.playHint = makeHint("Play", " (%s)", "PlayCurrentPosUnfollow")
|
||||
ret.stopHint = makeHint("Stop", " (%s)", "StopPlaying")
|
||||
ret.recordHint = makeHint("Record", " (%s)", "RecordingToggle")
|
||||
ret.stopRecordHint = makeHint("Stop", " (%s)", "RecordingToggle")
|
||||
ret.followOnHint = makeHint("Follow on", " (%s)", "FollowToggle")
|
||||
ret.followOffHint = makeHint("Follow off", " (%s)", "FollowToggle")
|
||||
ret.loopOffHint = makeHint("Loop off", " (%s)", "LoopToggle")
|
||||
ret.loopOnHint = makeHint("Loop on", " (%s)", "LoopToggle")
|
||||
return ret
|
||||
}
|
||||
|
||||
func (pb *PlayBar) Layout(gtx C) D {
|
||||
tr := TrackerFromContext(gtx)
|
||||
playBtn := ToggleIconBtn(tr.Play().Started(), tr.Theme, pb.PlayingBtn, icons.AVPlayArrow, icons.AVStop, pb.playHint, pb.stopHint)
|
||||
rewindBtn := ActionIconBtn(tr.Play().FromBeginning(), tr.Theme, pb.RewindBtn, icons.AVFastRewind, pb.rewindHint)
|
||||
recordBtn := ToggleIconBtn(tr.Play().IsRecording(), tr.Theme, pb.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, pb.recordHint, pb.stopRecordHint)
|
||||
followBtn := ToggleIconBtn(tr.Play().IsFollowing(), tr.Theme, pb.FollowBtn, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, pb.followOffHint, pb.followOnHint)
|
||||
loopBtn := ToggleIconBtn(tr.Play().IsLooping(), tr.Theme, pb.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, pb.loopOffHint, pb.loopOnHint)
|
||||
|
||||
return Surface{Height: 4}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, playBtn.Layout),
|
||||
layout.Rigid(rewindBtn.Layout),
|
||||
layout.Rigid(recordBtn.Layout),
|
||||
layout.Rigid(followBtn.Layout),
|
||||
layout.Rigid(loopBtn.Layout),
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
type SongPanel struct {
|
||||
MenuBar []widget.Clickable
|
||||
Menus []Menu
|
||||
BPM *NumberInput
|
||||
RowsPerPattern *NumberInput
|
||||
RowsPerBeat *NumberInput
|
||||
Step *NumberInput
|
||||
SongLength *NumberInput
|
||||
|
||||
RewindBtn *ActionClickable
|
||||
PlayingBtn *BoolClickable
|
||||
RecordBtn *BoolClickable
|
||||
NoteTracking *BoolClickable
|
||||
PanicBtn *BoolClickable
|
||||
LoopBtn *BoolClickable
|
||||
|
||||
// File menu items
|
||||
fileMenuItems []MenuItem
|
||||
NewSong tracker.Action
|
||||
OpenSongFile tracker.Action
|
||||
SaveSongFile tracker.Action
|
||||
SaveSongAsFile tracker.Action
|
||||
ExportWav tracker.Action
|
||||
Quit tracker.Action
|
||||
|
||||
// Edit menu items
|
||||
editMenuItems []MenuItem
|
||||
}
|
||||
|
||||
func NewSongPanel(model *tracker.Model) *SongPanel {
|
||||
ret := &SongPanel{
|
||||
MenuBar: make([]widget.Clickable, 2),
|
||||
Menus: make([]Menu, 2),
|
||||
BPM: NewNumberInput(model.BPM().Int()),
|
||||
RowsPerPattern: NewNumberInput(model.RowsPerPattern().Int()),
|
||||
RowsPerBeat: NewNumberInput(model.RowsPerBeat().Int()),
|
||||
Step: NewNumberInput(model.Step().Int()),
|
||||
SongLength: NewNumberInput(model.SongLength().Int()),
|
||||
PanicBtn: NewBoolClickable(model.Panic().Bool()),
|
||||
LoopBtn: NewBoolClickable(model.LoopToggle().Bool()),
|
||||
RecordBtn: NewBoolClickable(model.IsRecording().Bool()),
|
||||
NoteTracking: NewBoolClickable(model.NoteTracking().Bool()),
|
||||
PlayingBtn: NewBoolClickable(model.Playing().Bool()),
|
||||
RewindBtn: NewActionClickable(model.Rewind()),
|
||||
}
|
||||
ret.fileMenuItems = []MenuItem{
|
||||
{IconBytes: icons.ContentClear, Text: "New Song", ShortcutText: shortcutKey + "N", Doer: model.NewSong()},
|
||||
{IconBytes: icons.FileFolder, Text: "Open Song", ShortcutText: shortcutKey + "O", Doer: model.OpenSong()},
|
||||
{IconBytes: icons.ContentSave, Text: "Save Song", ShortcutText: shortcutKey + "S", Doer: model.SaveSong()},
|
||||
{IconBytes: icons.ContentSave, Text: "Save Song As...", Doer: model.SaveSongAs()},
|
||||
{IconBytes: icons.ImageAudiotrack, Text: "Export Wav...", Doer: model.Export()},
|
||||
}
|
||||
if canQuit {
|
||||
ret.fileMenuItems = append(ret.fileMenuItems, MenuItem{IconBytes: icons.ActionExitToApp, Text: "Quit", Doer: model.Quit()})
|
||||
}
|
||||
ret.editMenuItems = []MenuItem{
|
||||
{IconBytes: icons.ContentUndo, Text: "Undo", ShortcutText: shortcutKey + "Z", Doer: model.Undo()},
|
||||
{IconBytes: icons.ContentRedo, Text: "Redo", ShortcutText: shortcutKey + "Y", Doer: model.Redo()},
|
||||
{IconBytes: icons.ImageCrop, Text: "Remove unused data", Doer: model.RemoveUnused()},
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
const shortcutKey = "Ctrl+"
|
||||
|
||||
func (s *SongPanel) Layout(gtx C, t *Tracker) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return s.layoutMenuBar(gtx, t)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return s.layoutSongOptions(gtx, t)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SongPanel) layoutMenuBar(gtx C, tr *Tracker) D {
|
||||
gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(36))
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(36))
|
||||
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(tr.layoutMenu(gtx, "File", &t.MenuBar[0], &t.Menus[0], unit.Dp(200), t.fileMenuItems...)),
|
||||
layout.Rigid(tr.layoutMenu(gtx, "Edit", &t.MenuBar[1], &t.Menus[1], unit.Dp(200), t.editMenuItems...)),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SongPanel) layoutSongOptions(gtx C, tr *Tracker) D {
|
||||
paint.FillShape(gtx.Ops, songSurfaceColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
|
||||
|
||||
in := layout.UniformInset(unit.Dp(1))
|
||||
|
||||
panicBtnStyle := ToggleButton(gtx, tr.Theme, t.PanicBtn, "Panic (F12)")
|
||||
rewindBtnStyle := ActionIcon(gtx, tr.Theme, t.RewindBtn, icons.AVFastRewind, "Rewind\n(F5)")
|
||||
playBtnStyle := ToggleIcon(gtx, tr.Theme, t.PlayingBtn, icons.AVPlayArrow, icons.AVStop, "Play (F6 / Space)", "Stop (F6 / Space)")
|
||||
recordBtnStyle := ToggleIcon(gtx, tr.Theme, t.RecordBtn, icons.AVFiberManualRecord, icons.AVFiberSmartRecord, "Record (F7)", "Stop (F7)")
|
||||
noteTrackBtnStyle := ToggleIcon(gtx, tr.Theme, t.NoteTracking, icons.ActionSpeakerNotesOff, icons.ActionSpeakerNotes, "Follow\nOff\n(F8)", "Follow\nOn\n(F8)")
|
||||
loopBtnStyle := ToggleIcon(gtx, tr.Theme, t.LoopBtn, icons.NavigationArrowForward, icons.AVLoop, "Loop\nOff\n(Ctrl+L)", "Loop\nOn\n(Ctrl+L)")
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(Label("LEN:", white, tr.Theme.Shaper)),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
numStyle := NumericUpDown(tr.Theme, t.SongLength, "Song length")
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
|
||||
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
|
||||
dims := in.Layout(gtx, numStyle.Layout)
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(Label("BPM:", white, tr.Theme.Shaper)),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
numStyle := NumericUpDown(tr.Theme, t.BPM, "Beats per minute")
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
|
||||
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
|
||||
dims := in.Layout(gtx, numStyle.Layout)
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(Label("RPP:", white, tr.Theme.Shaper)),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
numStyle := NumericUpDown(tr.Theme, t.RowsPerPattern, "Rows per pattern")
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
|
||||
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
|
||||
dims := in.Layout(gtx, numStyle.Layout)
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(Label("RPB:", white, tr.Theme.Shaper)),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
numStyle := NumericUpDown(tr.Theme, t.RowsPerBeat, "Rows per beat")
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(20))
|
||||
gtx.Constraints.Min.X = gtx.Dp(unit.Dp(70))
|
||||
dims := in.Layout(gtx, numStyle.Layout)
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(Label("STP:", white, tr.Theme.Shaper)),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
numStyle := NumericUpDown(tr.Theme, t.Step, "Cursor step")
|
||||
numStyle.UnitsPerStep = unit.Dp(20)
|
||||
dims := in.Layout(gtx, numStyle.Layout)
|
||||
return dims
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(VuMeter{AverageVolume: tr.Model.AverageVolume(), PeakVolume: tr.Model.PeakVolume(), Range: 100}.Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(rewindBtnStyle.Layout),
|
||||
layout.Rigid(playBtnStyle.Layout),
|
||||
layout.Rigid(recordBtnStyle.Layout),
|
||||
layout.Rigid(noteTrackBtnStyle.Layout),
|
||||
layout.Rigid(loopBtnStyle.Layout),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(panicBtnStyle.Layout),
|
||||
)
|
||||
}
|
||||
217
tracker/gioui/specanalyzer.go
Normal file
217
tracker/gioui/specanalyzer.go
Normal file
@ -0,0 +1,217 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
type (
|
||||
SpectrumState struct {
|
||||
resolutionNumber *NumericUpDownState
|
||||
speed *NumericUpDownState
|
||||
chnModeBtn *Clickable
|
||||
plot *Plot
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
SpectrumDbMin = -60
|
||||
SpectrumDbMax = 12
|
||||
)
|
||||
|
||||
func NewSpectrumState() *SpectrumState {
|
||||
return &SpectrumState{
|
||||
plot: NewPlot(plotRange{-3.8, 0}, plotRange{SpectrumDbMax, SpectrumDbMin}, SpectrumDbMin),
|
||||
resolutionNumber: NewNumericUpDownState(),
|
||||
speed: NewNumericUpDownState(),
|
||||
chnModeBtn: new(Clickable),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SpectrumState) Layout(gtx C) D {
|
||||
s.Update(gtx)
|
||||
t := TrackerFromContext(gtx)
|
||||
leftSpacer := layout.Spacer{Width: unit.Dp(6), Height: unit.Dp(36)}.Layout
|
||||
rightSpacer := layout.Spacer{Width: unit.Dp(6)}.Layout
|
||||
|
||||
var chnModeTxt string = "???"
|
||||
switch tracker.SpecChnMode(t.Model.Spectrum().Channels().Value()) {
|
||||
case tracker.SpecChnModeSum:
|
||||
chnModeTxt = "Sum"
|
||||
case tracker.SpecChnModeSeparate:
|
||||
chnModeTxt = "Separate"
|
||||
}
|
||||
|
||||
resolution := NumUpDown(t.Model.Spectrum().Resolution(), t.Theme, s.resolutionNumber, "Resolution")
|
||||
chnModeBtn := Btn(t.Theme, &t.Theme.Button.Text, s.chnModeBtn, chnModeTxt, "Channel mode")
|
||||
speed := NumUpDown(t.Model.Spectrum().Speed(), t.Theme, s.speed, "Speed")
|
||||
|
||||
numchns := 0
|
||||
speclen := len(t.Model.Spectrum().Result()[0])
|
||||
if speclen > 0 {
|
||||
numchns = 1
|
||||
if len(t.Model.Spectrum().Result()[1]) == speclen {
|
||||
numchns = 2
|
||||
}
|
||||
}
|
||||
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
biquad, biquadok := t.Model.Spectrum().BiquadCoeffs()
|
||||
data := func(chn int, xr plotRange) (yr plotRange, ok bool) {
|
||||
if chn == 2 {
|
||||
if xr.a >= 0 {
|
||||
return plotRange{}, false
|
||||
}
|
||||
ya := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.a)))))) * 20
|
||||
yb := math.Log10(float64(biquad.Gain(float32(math.Pi*math.Pow(10, float64(xr.b)))))) * 20
|
||||
return plotRange{float32(ya), float32(yb)}, true
|
||||
}
|
||||
if chn >= numchns {
|
||||
return plotRange{}, false
|
||||
}
|
||||
xr.a = float32(math.Pow(10, float64(xr.a)))
|
||||
xr.b = float32(math.Pow(10, float64(xr.b)))
|
||||
w1, f1 := math.Modf(float64(xr.a)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
|
||||
w2, f2 := math.Modf(float64(xr.b)*float64(speclen) - 1) // -1 cause we don't have the DC bin there
|
||||
x1 := max(int(w1), 0)
|
||||
x2 := min(int(w2), speclen-1)
|
||||
if x1 > x2 {
|
||||
return plotRange{}, false
|
||||
}
|
||||
y1 := float32(math.Inf(-1))
|
||||
y2 := float32(math.Inf(+1))
|
||||
switch {
|
||||
case x2 <= x1+1 && x2 < speclen-1: // perform smoothstep interpolation when we are overlapping only a few bins
|
||||
l := t.Model.Spectrum().Result()[chn][x1]
|
||||
r := t.Model.Spectrum().Result()[chn][x1+1]
|
||||
y1 = smoothInterpolate(l, r, float32(f1))
|
||||
l = t.Model.Spectrum().Result()[chn][x2]
|
||||
r = t.Model.Spectrum().Result()[chn][x2+1]
|
||||
y2 = smoothInterpolate(l, r, float32(f2))
|
||||
y1, y2 = max(y1, y2), min(y1, y2)
|
||||
default:
|
||||
for i := x1; i <= x2; i++ {
|
||||
sample := t.Model.Spectrum().Result()[chn][i]
|
||||
y1 = max(y1, sample)
|
||||
y2 = min(y2, sample)
|
||||
}
|
||||
}
|
||||
y1 = softplus((y1-SpectrumDbMin)/5)*5 + SpectrumDbMin // we "squash" the low volumes so the -Inf dB becomes -SpectrumDbMin
|
||||
y2 = softplus((y2-SpectrumDbMin)/5)*5 + SpectrumDbMin
|
||||
|
||||
return plotRange{y1, y2}, true
|
||||
}
|
||||
xticks := func(r plotRange, count int, yield func(pos float32, label string)) {
|
||||
type pair struct {
|
||||
freq float64
|
||||
label string
|
||||
}
|
||||
const offset = 0.343408593803857 // log10(22050/10000)
|
||||
const startdiv = 3 * (1 << 8)
|
||||
step := nextPowerOfTwo(int(float64(r.b-r.a)*startdiv/float64(count)) + 1)
|
||||
start := int(math.Floor(float64(r.a+offset) * startdiv / float64(step)))
|
||||
end := int(math.Ceil(float64(r.b+offset) * startdiv / float64(step)))
|
||||
for i := start; i <= end; i++ {
|
||||
lognormfreq := float32(i*step)/startdiv - offset
|
||||
freq := math.Pow(10, float64(lognormfreq)) * 22050
|
||||
df := freq * math.Log(10) * float64(step) / startdiv // this is roughly the difference in Hz between the ticks currently
|
||||
rounding := int(math.Floor(math.Log10(df)))
|
||||
r := math.Pow(10, float64(rounding))
|
||||
freq = math.Round(freq/r) * r
|
||||
tickpos := float32(math.Log10(freq / 22050))
|
||||
if rounding >= 3 {
|
||||
yield(tickpos, fmt.Sprintf("%.0f kHz", freq/1000))
|
||||
} else {
|
||||
yield(tickpos, fmt.Sprintf("%s Hz", strconv.FormatFloat(freq, 'f', -rounding, 64)))
|
||||
}
|
||||
}
|
||||
}
|
||||
yticks := func(r plotRange, count int, yield func(pos float32, label string)) {
|
||||
step := 3
|
||||
var start, end int
|
||||
for {
|
||||
start = int(math.Ceil(float64(r.b) / float64(step)))
|
||||
end = int(math.Floor(float64(r.a) / float64(step)))
|
||||
if end-start+1 <= count*4 { // we use 4x density for the y-lines in the spectrum
|
||||
break
|
||||
}
|
||||
step *= 2
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
yield(float32(i*step), strconv.Itoa(i*step))
|
||||
}
|
||||
}
|
||||
n := numchns
|
||||
if biquadok {
|
||||
n = 3
|
||||
}
|
||||
return s.plot.Layout(gtx, data, xticks, yticks, float32(math.NaN()), n)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Resolution").Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(resolution.Layout),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Speed").Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(speed.Layout),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(leftSpacer),
|
||||
layout.Rigid(Label(t.Theme, &t.Theme.SongPanel.RowHeader, "Channels").Layout),
|
||||
layout.Flexed(1, func(gtx C) D { return D{Size: gtx.Constraints.Min} }),
|
||||
layout.Rigid(chnModeBtn.Layout),
|
||||
layout.Rigid(rightSpacer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func softplus(f float32) float32 {
|
||||
return float32(math.Log(1 + math.Exp(float64(f))))
|
||||
}
|
||||
|
||||
func smoothInterpolate(a, b float32, t float32) float32 {
|
||||
t = t * t * (3 - 2*t)
|
||||
return (1-t)*a + t*b
|
||||
}
|
||||
|
||||
func nextPowerOfTwo(v int) int {
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
v--
|
||||
v |= v >> 1
|
||||
v |= v >> 2
|
||||
v |= v >> 4
|
||||
v |= v >> 8
|
||||
v |= v >> 16
|
||||
v |= v >> 32
|
||||
v++
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *SpectrumState) Update(gtx C) {
|
||||
t := TrackerFromContext(gtx)
|
||||
for s.chnModeBtn.Clicked(gtx) {
|
||||
t.Model.Spectrum().Channels().SetValue((t.Model.Spectrum().Channels().Value() + 1) % int(tracker.NumSpecChnModes))
|
||||
}
|
||||
s.resolutionNumber.Update(gtx, t.Model.Spectrum().Resolution())
|
||||
s.speed.Update(gtx, t.Model.Spectrum().Speed())
|
||||
}
|
||||
@ -11,127 +11,46 @@ import (
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type Split struct {
|
||||
// Ratio keeps the current layout.
|
||||
// 0 is center, -1 completely to the left, 1 completely to the right.
|
||||
Ratio float32
|
||||
// Bar is the width for resizing the layout
|
||||
Bar unit.Dp
|
||||
// Axis is the split direction: layout.Horizontal splits the view in left
|
||||
// and right, layout.Vertical splits the view in top and bottom
|
||||
Axis layout.Axis
|
||||
|
||||
drag bool
|
||||
dragID pointer.ID
|
||||
dragCoord float32
|
||||
}
|
||||
|
||||
var defaultBarWidth = unit.Dp(10)
|
||||
|
||||
func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.Dimensions {
|
||||
bar := gtx.Dp(s.Bar)
|
||||
if bar <= 1 {
|
||||
bar = gtx.Dp(defaultBarWidth)
|
||||
type (
|
||||
SplitState struct {
|
||||
// Ratio keeps the current layout.
|
||||
// 0 is center, -1 completely to the left, 1 completely to the right.
|
||||
Ratio float32
|
||||
// Axis is the split direction: layout.Horizontal splits the view in left
|
||||
// and right, layout.Vertical splits the view in top and bottom
|
||||
Axis layout.Axis
|
||||
drag bool
|
||||
dragID pointer.ID
|
||||
dragCoord float32
|
||||
}
|
||||
|
||||
var coord int
|
||||
if s.Axis == layout.Horizontal {
|
||||
coord = gtx.Constraints.Max.X
|
||||
} else {
|
||||
coord = gtx.Constraints.Max.Y
|
||||
SplitStyle struct {
|
||||
Bar unit.Dp
|
||||
MinSize1, MinSize2 unit.Dp
|
||||
}
|
||||
)
|
||||
|
||||
proportion := (s.Ratio + 1) / 2
|
||||
firstSize := int(proportion*float32(coord) - float32(bar))
|
||||
func (s *SplitState) Layout(gtx layout.Context, st *SplitStyle, first, second layout.Widget) layout.Dimensions {
|
||||
s.update(gtx, st)
|
||||
|
||||
secondOffset := firstSize + bar
|
||||
secondSize := coord - secondOffset
|
||||
|
||||
{ // handle input
|
||||
// Avoid affecting the input tree with pointer events.
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: s,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
// TODO: there should be a grab; there was Grab: s.drag,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
if s.drag {
|
||||
break
|
||||
}
|
||||
|
||||
s.dragID = e.PointerID
|
||||
if s.Axis == layout.Horizontal {
|
||||
s.dragCoord = e.Position.X
|
||||
} else {
|
||||
s.dragCoord = e.Position.Y
|
||||
}
|
||||
s.drag = true
|
||||
|
||||
case pointer.Drag:
|
||||
if s.dragID != e.PointerID {
|
||||
break
|
||||
}
|
||||
|
||||
var deltaCoord, deltaRatio float32
|
||||
if s.Axis == layout.Horizontal {
|
||||
deltaCoord = e.Position.X - s.dragCoord
|
||||
s.dragCoord = e.Position.X
|
||||
deltaRatio = deltaCoord * 2 / float32(gtx.Constraints.Max.X)
|
||||
} else {
|
||||
deltaCoord = e.Position.Y - s.dragCoord
|
||||
s.dragCoord = e.Position.Y
|
||||
deltaRatio = deltaCoord * 2 / float32(gtx.Constraints.Max.Y)
|
||||
}
|
||||
|
||||
s.Ratio += deltaRatio
|
||||
|
||||
case pointer.Release:
|
||||
fallthrough
|
||||
case pointer.Cancel:
|
||||
s.drag = false
|
||||
}
|
||||
}
|
||||
|
||||
low := -1 + float32(bar)/float32(coord)*2
|
||||
const snapMargin = 0.1
|
||||
|
||||
if s.Ratio < low {
|
||||
s.Ratio = low
|
||||
}
|
||||
|
||||
if s.Ratio > 1 {
|
||||
s.Ratio = 1
|
||||
}
|
||||
|
||||
if s.Ratio < low+snapMargin {
|
||||
firstSize = 0
|
||||
secondOffset = bar
|
||||
secondSize = coord - bar
|
||||
} else if s.Ratio > 1-snapMargin {
|
||||
firstSize = coord - bar
|
||||
secondOffset = coord
|
||||
secondSize = 0
|
||||
}
|
||||
size1, size2, bar := s.calculateSplitSizes(gtx, st)
|
||||
secondOffset := size1 + bar
|
||||
|
||||
{
|
||||
// register for input
|
||||
var barRect image.Rectangle
|
||||
if s.Axis == layout.Horizontal {
|
||||
barRect = image.Rect(firstSize, 0, secondOffset, gtx.Constraints.Max.Y)
|
||||
barRect = image.Rect(size1, 0, secondOffset, gtx.Constraints.Max.Y)
|
||||
} else {
|
||||
barRect = image.Rect(0, firstSize, gtx.Constraints.Max.X, secondOffset)
|
||||
barRect = image.Rect(0, size1, gtx.Constraints.Max.X, secondOffset)
|
||||
}
|
||||
area := clip.Rect(barRect).Push(gtx.Ops)
|
||||
event.Op(gtx.Ops, s)
|
||||
if s.Axis == layout.Horizontal {
|
||||
pointer.CursorColResize.Add(gtx.Ops)
|
||||
} else {
|
||||
pointer.CursorRowResize.Add(gtx.Ops)
|
||||
}
|
||||
area.Pop()
|
||||
}
|
||||
|
||||
@ -139,9 +58,9 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
|
||||
gtx := gtx
|
||||
|
||||
if s.Axis == layout.Horizontal {
|
||||
gtx.Constraints = layout.Exact(image.Pt(firstSize, gtx.Constraints.Max.Y))
|
||||
gtx.Constraints = layout.Exact(image.Pt(size1, gtx.Constraints.Max.Y))
|
||||
} else {
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, firstSize))
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, size1))
|
||||
}
|
||||
area := clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops)
|
||||
first(gtx)
|
||||
@ -154,10 +73,10 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
|
||||
var transform op.TransformStack
|
||||
if s.Axis == layout.Horizontal {
|
||||
transform = op.Offset(image.Pt(secondOffset, 0)).Push(gtx.Ops)
|
||||
gtx.Constraints = layout.Exact(image.Pt(secondSize, gtx.Constraints.Max.Y))
|
||||
gtx.Constraints = layout.Exact(image.Pt(size2, gtx.Constraints.Max.Y))
|
||||
} else {
|
||||
transform = op.Offset(image.Pt(0, secondOffset)).Push(gtx.Ops)
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, secondSize))
|
||||
gtx.Constraints = layout.Exact(image.Pt(gtx.Constraints.Max.X, size2))
|
||||
}
|
||||
|
||||
area := clip.Rect(image.Rect(0, 0, gtx.Constraints.Min.X, gtx.Constraints.Min.Y)).Push(gtx.Ops)
|
||||
@ -168,3 +87,107 @@ func (s *Split) Layout(gtx layout.Context, first, second layout.Widget) layout.D
|
||||
|
||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||
}
|
||||
|
||||
func (s *SplitState) update(gtx layout.Context, st *SplitStyle) {
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: s,
|
||||
Kinds: pointer.Press | pointer.Drag | pointer.Release,
|
||||
// TODO: there should be a grab; there was Grab: s.drag,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch e.Kind {
|
||||
case pointer.Press:
|
||||
if s.drag {
|
||||
break
|
||||
}
|
||||
|
||||
s.dragID = e.PointerID
|
||||
if s.Axis == layout.Horizontal {
|
||||
s.dragCoord = e.Position.X
|
||||
} else {
|
||||
s.dragCoord = e.Position.Y
|
||||
}
|
||||
s.drag = true
|
||||
// when the user start dragging, the new display ratio becomes the underlying ratio
|
||||
s.Ratio = s.calculateRatio(gtx, st)
|
||||
|
||||
case pointer.Drag:
|
||||
if s.dragID != e.PointerID {
|
||||
break
|
||||
}
|
||||
|
||||
if s.Axis == layout.Horizontal {
|
||||
s.Ratio += (e.Position.X - s.dragCoord) / float32(gtx.Constraints.Max.X) * 2
|
||||
s.dragCoord = e.Position.X
|
||||
} else {
|
||||
s.Ratio += (e.Position.Y - s.dragCoord) / float32(gtx.Constraints.Max.Y) * 2
|
||||
s.dragCoord = e.Position.Y
|
||||
}
|
||||
|
||||
case pointer.Release, pointer.Cancel:
|
||||
if s.dragID == e.PointerID {
|
||||
// when the user release the grab, the new display ratio becomes the underlying ratio
|
||||
s.Ratio = s.calculateRatio(gtx, st)
|
||||
}
|
||||
s.drag = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SplitState) calculateRatio(gtx layout.Context, st *SplitStyle) float32 {
|
||||
size1, size2, bar := s.calculateSplitSizes(gtx, st)
|
||||
total := size1 + size2 + bar
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 2*float32(size1+bar/2)/float32(total) - 1
|
||||
}
|
||||
|
||||
func (s *SplitState) calculateSplitSizes(gtx layout.Context, st *SplitStyle) (size1, size2, bar int) {
|
||||
bar = gtx.Dp(st.Bar)
|
||||
if bar <= 1 {
|
||||
bar = gtx.Dp(1)
|
||||
}
|
||||
|
||||
total := gtx.Constraints.Max.Y
|
||||
if s.Axis == layout.Horizontal {
|
||||
total = gtx.Constraints.Max.X
|
||||
}
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
if total < bar {
|
||||
return 0, 0, total
|
||||
}
|
||||
totalSize := total - bar
|
||||
size1 = int((s.Ratio+1)/2*float32(total) - float32(bar)/2)
|
||||
minSize1 := gtx.Dp(st.MinSize1)
|
||||
minSize2 := gtx.Dp(st.MinSize2)
|
||||
|
||||
// we always hide the smaller split first
|
||||
if s.Ratio < 0 {
|
||||
size1 = limitSplitSize(size1, totalSize, minSize1, minSize2)
|
||||
} else {
|
||||
size1 = totalSize - limitSplitSize(totalSize-size1, totalSize, minSize2, minSize1)
|
||||
}
|
||||
size2 = totalSize - size1
|
||||
return size1, size2, bar
|
||||
}
|
||||
|
||||
// limitSplitSize hides the first split if it is smaller than minSize1/2 or if
|
||||
// the total size is smaller than minSize1+minSize2. Otherwise, it clamps the
|
||||
// size so that both split get at least minSize1 and minSize2 respectively.
|
||||
func limitSplitSize(size, totalPx, minSize1, minSize2 int) int {
|
||||
if size < minSize1/2 || totalPx < minSize1+minSize2 {
|
||||
return 0 // the first split is completely hidden
|
||||
}
|
||||
return min(max(size, minSize1), totalPx-minSize2)
|
||||
}
|
||||
|
||||
@ -4,52 +4,31 @@ import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
type Surface struct {
|
||||
Gray int
|
||||
Inset layout.Inset
|
||||
FitSize bool
|
||||
Focus bool
|
||||
Height int
|
||||
Inset layout.Inset
|
||||
Focus bool
|
||||
}
|
||||
|
||||
func (s Surface) Layout(gtx C, widget layout.Widget) D {
|
||||
t := TrackerFromContext(gtx)
|
||||
t.surfaceHeight += s.Height
|
||||
bg := func(gtx C) D {
|
||||
grayInt := s.Gray
|
||||
gray := s.Height * 8
|
||||
if s.Focus {
|
||||
grayInt += 8
|
||||
gray += 8
|
||||
}
|
||||
var grayUint8 uint8
|
||||
if grayInt < 0 {
|
||||
grayUint8 = 0
|
||||
} else if grayInt > 255 {
|
||||
grayUint8 = 255
|
||||
} else {
|
||||
grayUint8 = uint8(grayInt)
|
||||
}
|
||||
color := color.NRGBA{R: grayUint8, G: grayUint8, B: grayUint8, A: 255}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{
|
||||
Max: gtx.Constraints.Min,
|
||||
}.Op())
|
||||
gray8 := uint8(min(max(gray, 0), 255))
|
||||
color := color.NRGBA{R: gray8, G: gray8, B: gray8, A: 255}
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect{Max: gtx.Constraints.Min}.Op())
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}
|
||||
fg := func(gtx C) D {
|
||||
return s.Inset.Layout(gtx, widget)
|
||||
}
|
||||
if s.FitSize {
|
||||
macro := op.Record(gtx.Ops)
|
||||
dims := fg(gtx)
|
||||
call := macro.Stop()
|
||||
gtx.Constraints = layout.Exact(dims.Size)
|
||||
bg(gtx)
|
||||
call.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
gtxbg := gtx
|
||||
gtxbg.Constraints.Min = gtxbg.Constraints.Max
|
||||
bg(gtxbg)
|
||||
return fg(gtx)
|
||||
fg := func(gtx C) D { return s.Inset.Layout(gtx, widget) }
|
||||
dims := layout.Background{}.Layout(gtx, bg, fg)
|
||||
t.surfaceHeight -= s.Height
|
||||
return dims
|
||||
}
|
||||
|
||||
@ -1,78 +1,182 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font/gofont"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
)
|
||||
|
||||
var fontCollection []text.FontFace = gofont.Collection()
|
||||
type Theme struct {
|
||||
Define any // this is just needed for yaml.UnmarshalStrict, so we can have "defines" in the yaml
|
||||
Material material.Theme
|
||||
Button struct {
|
||||
Filled ButtonStyle
|
||||
Text ButtonStyle
|
||||
Disabled ButtonStyle
|
||||
Menu ButtonStyle
|
||||
Tab struct {
|
||||
Active ButtonStyle
|
||||
Inactive ButtonStyle
|
||||
IndicatorHeight unit.Dp
|
||||
IndicatorColor color.NRGBA
|
||||
}
|
||||
}
|
||||
IconButton struct {
|
||||
Enabled IconButtonStyle
|
||||
Disabled IconButtonStyle
|
||||
Emphasis IconButtonStyle
|
||||
Error IconButtonStyle
|
||||
}
|
||||
Plot PlotStyle
|
||||
NumericUpDown NumericUpDownStyle
|
||||
SongPanel struct {
|
||||
RowHeader LabelStyle
|
||||
RowValue LabelStyle
|
||||
Expander LabelStyle
|
||||
Version LabelStyle
|
||||
ErrorColor color.NRGBA
|
||||
Bg color.NRGBA
|
||||
ScrollBar ScrollBarStyle
|
||||
}
|
||||
Alert AlertStyles
|
||||
NoteEditor struct {
|
||||
TrackTitle LabelStyle
|
||||
OrderRow LabelStyle
|
||||
PatternRow LabelStyle
|
||||
Note LabelStyle
|
||||
PatternNo LabelStyle
|
||||
Unique LabelStyle
|
||||
Loop color.NRGBA
|
||||
Header LabelStyle
|
||||
Play color.NRGBA
|
||||
OneBeat color.NRGBA
|
||||
TwoBeat color.NRGBA
|
||||
}
|
||||
Dialog DialogStyle
|
||||
OrderEditor struct {
|
||||
TrackTitle LabelStyle
|
||||
RowTitle LabelStyle
|
||||
Cell LabelStyle
|
||||
Loop color.NRGBA
|
||||
CellBg color.NRGBA
|
||||
Play color.NRGBA
|
||||
}
|
||||
Menu struct {
|
||||
Main MenuStyle
|
||||
Preset MenuStyle
|
||||
}
|
||||
InstrumentEditor struct {
|
||||
Octave LabelStyle
|
||||
Properties struct {
|
||||
Label LabelStyle
|
||||
}
|
||||
InstrumentComment EditorStyle
|
||||
UnitComment EditorStyle
|
||||
InstrumentList struct {
|
||||
Number LabelStyle
|
||||
Name EditorStyle
|
||||
NameMuted EditorStyle
|
||||
ScrollBar ScrollBarStyle
|
||||
}
|
||||
UnitList struct {
|
||||
Name EditorStyle
|
||||
NameDisabled EditorStyle
|
||||
Comment LabelStyle
|
||||
Stack LabelStyle
|
||||
Disabled LabelStyle
|
||||
Warning color.NRGBA
|
||||
Error color.NRGBA
|
||||
}
|
||||
Presets struct {
|
||||
SearchBg color.NRGBA
|
||||
Directory LabelStyle
|
||||
Results struct {
|
||||
Builtin LabelStyle
|
||||
User LabelStyle
|
||||
UserDir LabelStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
UnitEditor struct {
|
||||
Name LabelStyle
|
||||
Chooser LabelStyle
|
||||
Hint LabelStyle
|
||||
WireColor color.NRGBA
|
||||
WireHint LabelStyle
|
||||
WireHighlight color.NRGBA
|
||||
Width unit.Dp
|
||||
Height unit.Dp
|
||||
RackComment LabelStyle
|
||||
UnitList struct {
|
||||
LabelWidth unit.Dp
|
||||
Name LabelStyle
|
||||
Disabled LabelStyle
|
||||
Error color.NRGBA
|
||||
}
|
||||
Error color.NRGBA
|
||||
Divider color.NRGBA
|
||||
}
|
||||
Cursor CursorStyle
|
||||
Selection CursorStyle
|
||||
Tooltip struct {
|
||||
Color color.NRGBA
|
||||
Bg color.NRGBA
|
||||
}
|
||||
Popup struct {
|
||||
Menu PopupStyle
|
||||
Dialog PopupStyle
|
||||
}
|
||||
Split SplitStyle
|
||||
ScrollBar ScrollBarStyle
|
||||
Knob KnobStyle
|
||||
DisabledKnob KnobStyle
|
||||
Switch SwitchStyle
|
||||
SignalRail RailStyle
|
||||
Port PortStyle
|
||||
|
||||
var white = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
var black = color.NRGBA{R: 0, G: 0, B: 0, A: 255}
|
||||
var transparent = color.NRGBA{A: 0}
|
||||
// iconCache is used to cache the icons created from iconvg data
|
||||
iconCache map[*byte]*widget.Icon
|
||||
}
|
||||
|
||||
var primaryColor = color.NRGBA{R: 206, G: 147, B: 216, A: 255}
|
||||
var secondaryColor = color.NRGBA{R: 128, G: 222, B: 234, A: 255}
|
||||
type CursorStyle struct {
|
||||
Active color.NRGBA
|
||||
ActiveAlt color.NRGBA // alternative color for the cursor, used e.g. when the midi input is active
|
||||
Inactive color.NRGBA
|
||||
}
|
||||
|
||||
var highEmphasisTextColor = color.NRGBA{R: 222, G: 222, B: 222, A: 222}
|
||||
var mediumEmphasisTextColor = color.NRGBA{R: 153, G: 153, B: 153, A: 153}
|
||||
var disabledTextColor = color.NRGBA{R: 255, G: 255, B: 255, A: 97}
|
||||
//go:embed theme.yml
|
||||
var defaultTheme []byte
|
||||
|
||||
var backgroundColor = color.NRGBA{R: 18, G: 18, B: 18, A: 255}
|
||||
// NewTheme returns a new theme and potentially a warning if the theme file was not found or could not be read
|
||||
func NewTheme() (*Theme, error) {
|
||||
var ret Theme
|
||||
warn := ReadConfig(defaultTheme, "theme.yml", &ret)
|
||||
ret.Material.Shaper = &text.Shaper{}
|
||||
ret.Material.Icon.CheckBoxChecked = must(widget.NewIcon(icons.ToggleCheckBox))
|
||||
ret.Material.Icon.CheckBoxUnchecked = must(widget.NewIcon(icons.ToggleCheckBoxOutlineBlank))
|
||||
ret.Material.Icon.RadioChecked = must(widget.NewIcon(icons.ToggleRadioButtonChecked))
|
||||
ret.Material.Icon.RadioUnchecked = must(widget.NewIcon(icons.ToggleRadioButtonUnchecked))
|
||||
ret.iconCache = make(map[*byte]*widget.Icon)
|
||||
return &ret, warn
|
||||
}
|
||||
|
||||
var labelDefaultColor = highEmphasisTextColor
|
||||
var labelDefaultBgColor = transparent
|
||||
var labelDefaultFont = fontCollection[6].Font
|
||||
var labelDefaultFontSize = unit.Sp(18)
|
||||
func (th *Theme) Icon(data []byte) *widget.Icon {
|
||||
if icon, ok := th.iconCache[&data[0]]; ok {
|
||||
return icon
|
||||
}
|
||||
icon := must(widget.NewIcon(data))
|
||||
th.iconCache[&data[0]] = icon
|
||||
return icon
|
||||
}
|
||||
|
||||
var rowMarkerSurfaceColor = color.NRGBA{R: 0, G: 0, B: 0, A: 0}
|
||||
var rowMarkerPatternTextColor = secondaryColor
|
||||
var rowMarkerRowTextColor = mediumEmphasisTextColor
|
||||
|
||||
var trackerFont = fontCollection[6].Font
|
||||
var trackerFontSize = unit.Sp(16)
|
||||
var trackerInactiveTextColor = highEmphasisTextColor
|
||||
var trackerActiveTextColor = color.NRGBA{R: 255, G: 255, B: 130, A: 255}
|
||||
var trackerPlayColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
|
||||
var trackerPatMarker = primaryColor
|
||||
var oneBeatHighlight = color.NRGBA{R: 31, G: 37, B: 38, A: 255}
|
||||
var twoBeatHighlight = color.NRGBA{R: 31, G: 51, B: 53, A: 255}
|
||||
|
||||
var patternPlayColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
|
||||
var patternTextColor = primaryColor
|
||||
var patternCellColor = color.NRGBA{R: 255, G: 255, B: 255, A: 3}
|
||||
var loopMarkerColor = color.NRGBA{R: 252, G: 186, B: 3, A: 255}
|
||||
|
||||
var instrumentHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255}
|
||||
var instrumentNameColor = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
var instrumentNameHintColor = color.NRGBA{R: 200, G: 200, B: 200, A: 255}
|
||||
|
||||
var songSurfaceColor = color.NRGBA{R: 37, G: 37, B: 38, A: 255}
|
||||
|
||||
var popupSurfaceColor = color.NRGBA{R: 50, G: 50, B: 51, A: 255}
|
||||
var popupShadowColor = color.NRGBA{R: 0, G: 0, B: 0, A: 192}
|
||||
|
||||
var dragListSelectedColor = color.NRGBA{R: 55, G: 55, B: 61, A: 255}
|
||||
var dragListHoverColor = color.NRGBA{R: 42, G: 45, B: 61, A: 255}
|
||||
|
||||
var unitTypeListHighlightColor = color.NRGBA{R: 42, G: 45, B: 61, A: 255}
|
||||
|
||||
var inactiveLightSurfaceColor = color.NRGBA{R: 37, G: 37, B: 38, A: 255}
|
||||
var activeLightSurfaceColor = color.NRGBA{R: 45, G: 45, B: 45, A: 255}
|
||||
|
||||
var cursorColor = color.NRGBA{R: 100, G: 140, B: 255, A: 48}
|
||||
var selectionColor = color.NRGBA{R: 100, G: 140, B: 255, A: 12}
|
||||
var inactiveSelectionColor = color.NRGBA{R: 140, G: 140, B: 140, A: 16}
|
||||
|
||||
var errorColor = color.NRGBA{R: 207, G: 102, B: 121, A: 255}
|
||||
|
||||
var menuHoverColor = color.NRGBA{R: 30, G: 31, B: 38, A: 255}
|
||||
|
||||
var scrollBarColor = color.NRGBA{R: 255, G: 255, B: 255, A: 32}
|
||||
|
||||
var warningColor = color.NRGBA{R: 251, G: 192, B: 45, A: 255}
|
||||
|
||||
var dialogBgColor = color.NRGBA{R: 0, G: 0, B: 0, A: 224}
|
||||
func must[T any](ic T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ic
|
||||
}
|
||||
|
||||
288
tracker/gioui/theme.yml
Normal file
288
tracker/gioui/theme.yml
Normal file
@ -0,0 +1,288 @@
|
||||
# Because we use yaml.UnmarshalStrict, we needed to have "Define any" field for
|
||||
# all the defines; UnmarshalStrict thrwows an error if a field is not defined
|
||||
define:
|
||||
[
|
||||
&primarycolor { r: 206, g: 147, b: 216, a: 255 },
|
||||
&secondarycolor { r: 128, g: 222, b: 234, a: 255 },
|
||||
&transparentcolor { r: 0, g: 0, b: 0, a: 0 },
|
||||
&mediumemphasis { r: 153, g: 153, b: 153, a: 255 },
|
||||
&highemphasis { r: 222, g: 222, b: 222, a: 255 },
|
||||
&disabled { r: 255, g: 255, b: 255, a: 97 },
|
||||
&errorcolor { r: 207, g: 102, b: 121, a: 255 },
|
||||
&warningcolor { r: 251, g: 192, b: 45, a: 255 },
|
||||
&white { r: 255, g: 255, b: 255, a: 255 },
|
||||
&black { r: 0, g: 0, b: 0, a: 255 },
|
||||
&loopcolor { r: 252, g: 186, b: 3, a: 255 },
|
||||
&scrollbarcolor { r: 255, g: 255, b: 255, a: 32 },
|
||||
]
|
||||
|
||||
# from here on starts the structs defined in the theme.go
|
||||
material:
|
||||
textsize: 16
|
||||
fingersize: 38
|
||||
palette:
|
||||
bg: &bg { r: 18, g: 18, b: 18, a: 255 }
|
||||
fg: &fg { r: 255, g: 255, b: 255, a: 255 }
|
||||
contrastbg: *primarycolor
|
||||
contrastfg: &contrastfg { r: 0, g: 0, b: 0, a: 255 }
|
||||
button:
|
||||
filled:
|
||||
background: *primarycolor
|
||||
color: *contrastfg
|
||||
textsize: &buttontextsize 14
|
||||
cornerradius: &buttoncornerradius 18
|
||||
height: &buttonheight 36
|
||||
inset: &buttoninset { top: 0, bottom: 0, left: 6, right: 6 }
|
||||
text: &textbutton
|
||||
background: *transparentcolor
|
||||
color: *primarycolor
|
||||
textsize: *buttontextsize
|
||||
cornerradius: *buttoncornerradius
|
||||
height: *buttonheight
|
||||
inset: *buttoninset
|
||||
disabled:
|
||||
background: { r: 53, g: 51, b: 55, a: 255 }
|
||||
color: { r: 120, g: 116, b: 121, a: 255 }
|
||||
textsize: *buttontextsize
|
||||
cornerradius: *buttoncornerradius
|
||||
height: *buttonheight
|
||||
inset: *buttoninset
|
||||
menu:
|
||||
background: *transparentcolor
|
||||
color: { r: 255, g: 255, b: 255, a: 255 }
|
||||
textsize: *buttontextsize
|
||||
cornerradius: 0
|
||||
height: *buttonheight
|
||||
inset: *buttoninset
|
||||
tab:
|
||||
active: *textbutton
|
||||
inactive:
|
||||
background: *transparentcolor
|
||||
color: *highemphasis
|
||||
textsize: *buttontextsize
|
||||
cornerradius: *buttoncornerradius
|
||||
height: *buttonheight
|
||||
inset: *buttoninset
|
||||
indicatorheight: 2
|
||||
indicatorcolor: *primarycolor
|
||||
iconbutton:
|
||||
enabled:
|
||||
color: *primarycolor
|
||||
size: 24
|
||||
inset: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
disabled:
|
||||
color: *disabled
|
||||
size: 24
|
||||
inset: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
emphasis:
|
||||
color: *contrastfg
|
||||
background: *primarycolor
|
||||
size: 24
|
||||
inset: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
error:
|
||||
color: *errorcolor
|
||||
size: 24
|
||||
inset: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
plot:
|
||||
curvecolors: [*primarycolor, *secondarycolor,*disabled]
|
||||
limitcolor: { r: 255, g: 255, b: 255, a: 8 }
|
||||
cursorcolor: { r: 252, g: 186, b: 3, a: 255 }
|
||||
ticks: { textsize: 12, color: *disabled, maxlines: 1}
|
||||
dppertick: 50
|
||||
numericupdown:
|
||||
bgcolor: { r: 255, g: 255, b: 255, a: 3 }
|
||||
textcolor: *fg
|
||||
iconcolor: *primarycolor
|
||||
cornerradius: 4
|
||||
buttonwidth: 16
|
||||
textsize: 14
|
||||
width: 70
|
||||
height: 20
|
||||
songpanel:
|
||||
bg: { r: 24, g: 24, b: 24, a: 255 }
|
||||
rowheader:
|
||||
textsize: 14
|
||||
color: *mediumemphasis
|
||||
rowvalue:
|
||||
textsize: 14
|
||||
color: *mediumemphasis
|
||||
expander:
|
||||
textsize: 14
|
||||
color: *highemphasis
|
||||
errorcolor: *errorcolor
|
||||
version:
|
||||
textsize: 12
|
||||
color: *mediumemphasis
|
||||
scrollbar: { width: 6, color: *scrollbarcolor }
|
||||
alert:
|
||||
error:
|
||||
bg: *errorcolor
|
||||
text: { textsize: 16, color: *black }
|
||||
warning:
|
||||
bg: *warningcolor
|
||||
text: { textsize: 16, color: *black }
|
||||
info:
|
||||
bg: { r: 50, g: 50, b: 51, a: 255 }
|
||||
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
margin: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
inset: { top: 6, bottom: 6, left: 6, right: 6 }
|
||||
ordereditor:
|
||||
tracktitle: { textsize: 12, color: *mediumemphasis }
|
||||
rowtitle:
|
||||
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
|
||||
cell: { textsize: 16, color: *primarycolor, font: { typeface: "Go Mono" } }
|
||||
loop: *loopcolor
|
||||
cellbg: { r: 255, g: 255, b: 255, a: 3 }
|
||||
play: { r: 55, g: 55, b: 61, a: 255 }
|
||||
noteeditor:
|
||||
tracktitle: { textsize: 12, color: *mediumemphasis, alignment: 2 }
|
||||
orderrow:
|
||||
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
|
||||
patternrow:
|
||||
{ textsize: 16, color: *mediumemphasis, font: { typeface: "Go Mono" } }
|
||||
note: { textsize: 16, color: *highemphasis, font: { typeface: "Go Mono" } }
|
||||
patternno:
|
||||
{ textsize: 16, color: *primarycolor, font: { typeface: "Go Mono" } }
|
||||
unique:
|
||||
{ textsize: 16, color: *secondarycolor, font: { typeface: "Go Mono" } }
|
||||
loop: *loopcolor
|
||||
header: { textsize: 14, color: *disabled }
|
||||
play: { r: 55, g: 55, b: 61, a: 255 }
|
||||
onebeat: { r: 31, g: 37, b: 38, a: 255 }
|
||||
twobeat: { r: 31, g: 51, b: 53, a: 255 }
|
||||
menu:
|
||||
main:
|
||||
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
shortcut: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
|
||||
hover: { r: 100, g: 140, b: 255, a: 48 }
|
||||
disabled: *disabled
|
||||
width: 200
|
||||
height: 300
|
||||
preset:
|
||||
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
shortcut: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
|
||||
hover: { r: 100, g: 140, b: 255, a: 48 }
|
||||
disabled: *disabled
|
||||
width: 180
|
||||
height: 300
|
||||
instrumenteditor:
|
||||
octave: { textsize: 14, color: *disabled }
|
||||
properties:
|
||||
label: { textsize: 14, color: *highemphasis }
|
||||
instrumentcomment:
|
||||
{ textsize: 14, color: *highemphasis, hintcolor: *disabled }
|
||||
unitcomment: { textsize: 14, color: *mediumemphasis, hintcolor: *disabled }
|
||||
instrumentlist:
|
||||
number: { textsize: 10, color: *mediumemphasis }
|
||||
name: { textsize: 12, color: *white, hintcolor: *disabled }
|
||||
namemuted:
|
||||
textsize: 12
|
||||
color: *disabled
|
||||
hintcolor: *disabled
|
||||
font: { style: 1 }
|
||||
scrollbar: { width: 6, color: *scrollbarcolor }
|
||||
unitlist:
|
||||
name: { textsize: 12, color: *white, hintcolor: *disabled }
|
||||
namedisabled:
|
||||
textsize: 12
|
||||
color: *disabled
|
||||
hintcolor: *disabled
|
||||
font: { style: 1 }
|
||||
comment: { textsize: 12, color: *disabled, maxlines: 1}
|
||||
stack: { textsize: 12, color: *mediumemphasis, shadowcolor: *black }
|
||||
disabled: { textsize: 12, color: *disabled }
|
||||
warning: *warningcolor
|
||||
error: *errorcolor
|
||||
presets:
|
||||
searchbg: { r: 255, g: 255, b: 255, a: 6 }
|
||||
directory: { textsize: 12, color: *white, maxlines: 1 }
|
||||
results:
|
||||
builtin: { textsize: 12, color: *white, maxlines: 1 }
|
||||
user: { textsize: 12, color: *secondarycolor, maxlines: 1 }
|
||||
userdir: { textsize: 12, color: *mediumemphasis, maxlines: 1 }
|
||||
cursor:
|
||||
active: { r: 100, g: 140, b: 255, a: 48 }
|
||||
activealt: { r: 255, g: 100, b: 140, a: 48 }
|
||||
inactive: { r: 140, g: 140, b: 140, a: 48 }
|
||||
selection:
|
||||
active: { r: 100, g: 140, b: 255, a: 16 }
|
||||
activealt: { r: 255, g: 100, b: 140, a: 24 }
|
||||
inactive: { r: 140, g: 140, b: 140, a: 16 }
|
||||
scrollbar: { width: 10, color: *scrollbarcolor, gradient: *black }
|
||||
tooltip: { color: *white, bg: *black }
|
||||
popup:
|
||||
dialog:
|
||||
color: { r: 50, g: 50, b: 51, a: 255 }
|
||||
cornerradii: { nw: 6, ne: 6, se: 6, sw: 6 }
|
||||
shadow: { n: 2, s: 2, e: 2, w: 2, color: { r: 0, g: 0, b: 0, a: 192 } }
|
||||
menu:
|
||||
color: { r: 50, g: 50, b: 51, a: 255 }
|
||||
cornerradii: { nw: 0, ne: 0, se: 6, sw: 6 }
|
||||
shadow: { n: 0, s: 2, e: 2, w: 2, color: { r: 0, g: 0, b: 0, a: 192 } }
|
||||
dialog:
|
||||
bg: { r: 0, g: 0, b: 0, a: 224 }
|
||||
title: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
text: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
titleinset: { top: 12, left: 20, right: 20 }
|
||||
textinset: { top: 12, bottom: 12, left: 20, right: 20 }
|
||||
buttons: *textbutton
|
||||
split: { bar: 10, minsize1: 180, minsize2: 180 }
|
||||
uniteditor:
|
||||
hint: { textsize: 16, color: *highemphasis, shadowcolor: *black }
|
||||
chooser: { textsize: 12, color: *white, shadowcolor: *black }
|
||||
name:
|
||||
{ textsize: 12, alignment: 2, color: *highemphasis, shadowcolor: *black }
|
||||
wirecolor: *secondarycolor
|
||||
wirehighlight: *white
|
||||
wirehint: { textsize: 12, color: *disabled, shadowcolor: *black }
|
||||
width: 60
|
||||
height: 70
|
||||
unitlist:
|
||||
labelwidth: 16
|
||||
name: { textsize: 12, color: *white, alignment: 2 }
|
||||
disabled:
|
||||
{ textsize: 12, color: *disabled, font: { style: 1 }, alignment: 2 }
|
||||
error: *errorcolor
|
||||
divider: { r: 255, g: 255, b: 255, a: 5 }
|
||||
rackcomment: { textsize: 16, color: *mediumemphasis, shadowcolor: *black }
|
||||
knob:
|
||||
diameter: 36
|
||||
value: { textsize: 12, color: *highemphasis }
|
||||
strokewidth: 4
|
||||
bg: { r: 40, g: 40, b: 40, a: 255 }
|
||||
pos: { color: *primarycolor, bg: { r: 51, g: 36, b: 54, a: 255 } }
|
||||
neg: { color: *secondarycolor, bg: { r: 32, g: 55, b: 58, a: 255 } }
|
||||
indicator: { color: *white, width: 2, innerdiam: 24, outerdiam: 36 }
|
||||
disabledknob:
|
||||
diameter: 36
|
||||
value: { textsize: 12, color: { r: 147, g: 143, b: 153, a: 255 }}
|
||||
strokewidth: 4
|
||||
bg: { r: 40, g: 40, b: 40, a: 255 }
|
||||
pos: { color: { r: 147, g: 143, b: 153, a: 255 }, bg: { r: 54, g: 52, b: 59, a: 255 } }
|
||||
neg: { color: { r: 147, g: 143, b: 153, a: 255 }, bg: { r: 54, g: 52, b: 59, a: 255 } }
|
||||
indicator: { color: { r: 147, g: 143, b: 153, a: 255 }, width: 2, innerdiam: 24, outerdiam: 36 }
|
||||
signalrail:
|
||||
color: *secondarycolor
|
||||
signalwidth: 10
|
||||
linewidth: 2
|
||||
portdiameter: 8
|
||||
portcolor: *primarycolor
|
||||
port:
|
||||
diameter: 36
|
||||
strokewidth: 4
|
||||
color: { r: 32, g: 55, b: 58, a: 255 }
|
||||
switch:
|
||||
width: 36
|
||||
height: 20
|
||||
handle: 16
|
||||
neutral:
|
||||
fg: { r: 147, g: 143, b: 153, a: 255 }
|
||||
bg: { r: 54, g: 52, b: 59, a: 255 }
|
||||
pos:
|
||||
fg: *white
|
||||
bg: { r: 125, g: 87, b: 128, a: 255 }
|
||||
neg:
|
||||
fg: *white
|
||||
bg: { r: 70, g: 128, b: 131, a: 255 }
|
||||
icon: 10
|
||||
outline: 1
|
||||
151
tracker/gioui/tooltip.go
Normal file
151
tracker/gioui/tooltip.go
Normal file
@ -0,0 +1,151 @@
|
||||
package gioui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/x/component"
|
||||
)
|
||||
|
||||
// TipArea holds the state information for displaying a tooltip. The zero
|
||||
// value will choose sensible defaults for all fields.
|
||||
type TipArea struct {
|
||||
component.VisibilityAnimation
|
||||
Hover component.InvalidateDeadline
|
||||
Press component.InvalidateDeadline
|
||||
LongPress component.InvalidateDeadline
|
||||
Exit component.InvalidateDeadline
|
||||
init bool
|
||||
// HoverDelay is the delay between the cursor entering the tip area
|
||||
// and the tooltip appearing.
|
||||
HoverDelay time.Duration
|
||||
// LongPressDelay is the required duration of a press in the area for
|
||||
// it to count as a long press.
|
||||
LongPressDelay time.Duration
|
||||
// LongPressDuration is the amount of time the tooltip should be displayed
|
||||
// after being triggered by a long press.
|
||||
LongPressDuration time.Duration
|
||||
// FadeDuration is the amount of time it takes the tooltip to fade in
|
||||
// and out.
|
||||
FadeDuration time.Duration
|
||||
// ExitDuration is the amount of time the tooltip will remain visible at
|
||||
// maximum, to avoid tooltips staying visible indefinitely if the user
|
||||
// managed to leave the area without triggering a pointer.Leave event.
|
||||
ExitDuration time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
tipAreaHoverDelay = time.Millisecond * 500
|
||||
tipAreaLongPressDuration = time.Millisecond * 1500
|
||||
tipAreaFadeDuration = time.Millisecond * 250
|
||||
longPressTheshold = time.Millisecond * 500
|
||||
tipAreaExitDelay = time.Millisecond * 5000
|
||||
)
|
||||
|
||||
// Layout renders the provided widget with the provided tooltip. The tooltip
|
||||
// will be summoned if the widget is hovered or long-pressed.
|
||||
func (t *TipArea) Layout(gtx C, tip component.Tooltip, w layout.Widget) D {
|
||||
if !t.init {
|
||||
t.init = true
|
||||
t.VisibilityAnimation.State = component.Invisible
|
||||
if t.HoverDelay == time.Duration(0) {
|
||||
t.HoverDelay = tipAreaHoverDelay
|
||||
}
|
||||
if t.LongPressDelay == time.Duration(0) {
|
||||
t.LongPressDelay = longPressTheshold
|
||||
}
|
||||
if t.LongPressDuration == time.Duration(0) {
|
||||
t.LongPressDuration = tipAreaLongPressDuration
|
||||
}
|
||||
if t.FadeDuration == time.Duration(0) {
|
||||
t.FadeDuration = tipAreaFadeDuration
|
||||
}
|
||||
if t.ExitDuration == time.Duration(0) {
|
||||
t.ExitDuration = tipAreaExitDelay
|
||||
}
|
||||
t.VisibilityAnimation.Duration = t.FadeDuration
|
||||
}
|
||||
for {
|
||||
ev, ok := gtx.Event(pointer.Filter{
|
||||
Target: t,
|
||||
Kinds: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave,
|
||||
})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e, ok := ev.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// regardless of the event, we reset the exit timer to avoid tooltips
|
||||
// staying visible indefinitely
|
||||
t.Exit.SetTarget(gtx.Now.Add(t.ExitDuration))
|
||||
switch e.Kind {
|
||||
case pointer.Enter:
|
||||
t.Hover.SetTarget(gtx.Now.Add(t.HoverDelay))
|
||||
t.Exit.SetTarget(gtx.Now.Add(t.ExitDuration))
|
||||
case pointer.Leave:
|
||||
t.VisibilityAnimation.Disappear(gtx.Now)
|
||||
t.Hover.ClearTarget()
|
||||
case pointer.Press:
|
||||
t.Press.SetTarget(gtx.Now.Add(t.LongPressDelay))
|
||||
case pointer.Release:
|
||||
t.Press.ClearTarget()
|
||||
case pointer.Cancel:
|
||||
t.Hover.ClearTarget()
|
||||
t.Press.ClearTarget()
|
||||
}
|
||||
}
|
||||
if t.Hover.Process(gtx) {
|
||||
t.VisibilityAnimation.Appear(gtx.Now)
|
||||
}
|
||||
if t.Press.Process(gtx) {
|
||||
t.VisibilityAnimation.Appear(gtx.Now)
|
||||
t.LongPress.SetTarget(gtx.Now.Add(t.LongPressDuration))
|
||||
}
|
||||
if t.LongPress.Process(gtx) {
|
||||
t.VisibilityAnimation.Disappear(gtx.Now)
|
||||
}
|
||||
if t.Exit.Process(gtx) {
|
||||
t.VisibilityAnimation.Disappear(gtx.Now)
|
||||
}
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Stacked(w),
|
||||
layout.Expanded(func(gtx C) D {
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
|
||||
event.Op(gtx.Ops, t)
|
||||
|
||||
originalMin := gtx.Constraints.Min
|
||||
gtx.Constraints.Min = image.Point{}
|
||||
|
||||
if t.Visible() {
|
||||
macro := op.Record(gtx.Ops)
|
||||
tip.Bg = component.Interpolate(color.NRGBA{}, tip.Bg, t.VisibilityAnimation.Revealed(gtx))
|
||||
dims := tip.Layout(gtx)
|
||||
call := macro.Stop()
|
||||
xOffset := (originalMin.X / 2) - (dims.Size.X / 2)
|
||||
yOffset := originalMin.Y
|
||||
macro = op.Record(gtx.Ops)
|
||||
op.Offset(image.Pt(xOffset, yOffset)).Add(gtx.Ops)
|
||||
call.Add(gtx.Ops)
|
||||
call = macro.Stop()
|
||||
op.Defer(gtx.Ops, call)
|
||||
}
|
||||
return D{}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func Tooltip(th *Theme, tip string) component.Tooltip {
|
||||
tooltip := component.PlatformTooltip(&th.Material, tip)
|
||||
tooltip.Bg = th.Tooltip.Bg
|
||||
tooltip.Text.Color = th.Tooltip.Color
|
||||
return tooltip
|
||||
}
|
||||
@ -4,13 +4,16 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/font/gofont"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/layout"
|
||||
@ -18,9 +21,8 @@ import (
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
"gioui.org/x/explorer"
|
||||
"github.com/vsariola/sointu"
|
||||
"github.com/vsariola/sointu/tracker"
|
||||
)
|
||||
|
||||
@ -28,34 +30,40 @@ var canQuit = true // set to false in init() if plugin tag is enabled
|
||||
|
||||
type (
|
||||
Tracker struct {
|
||||
Theme *material.Theme
|
||||
OctaveNumberInput *NumberInput
|
||||
InstrumentVoices *NumberInput
|
||||
TopHorizontalSplit *Split
|
||||
BottomHorizontalSplit *Split
|
||||
VerticalSplit *Split
|
||||
KeyPlaying map[key.Name]tracker.NoteID
|
||||
PopupAlert *PopupAlert
|
||||
Theme *Theme
|
||||
OctaveNumberInput *NumericUpDownState
|
||||
InstrumentVoices *NumericUpDownState
|
||||
TopHorizontalSplit *SplitState
|
||||
BottomHorizontalSplit *SplitState
|
||||
VerticalSplit *SplitState
|
||||
KeyNoteMap Keyboard[key.Name]
|
||||
PopupAlert *AlertsState
|
||||
Zoom int
|
||||
|
||||
SaveChangesDialog *Dialog
|
||||
WaveTypeDialog *Dialog
|
||||
DialogState *DialogState
|
||||
|
||||
ModalDialog layout.Widget
|
||||
InstrumentEditor *InstrumentEditor
|
||||
OrderEditor *OrderEditor
|
||||
TrackEditor *NoteEditor
|
||||
Explorer *explorer.Explorer
|
||||
Exploring bool
|
||||
SongPanel *SongPanel
|
||||
ModalDialog layout.Widget
|
||||
PatchPanel *PatchPanel
|
||||
OrderEditor *OrderEditor
|
||||
TrackEditor *NoteEditor
|
||||
Explorer *explorer.Explorer
|
||||
Exploring bool
|
||||
SongPanel *SongPanel
|
||||
|
||||
filePathString tracker.String
|
||||
noteEvents []tracker.NoteEvent
|
||||
|
||||
quitWG sync.WaitGroup
|
||||
execChan chan func()
|
||||
preferences Preferences
|
||||
|
||||
*tracker.Model
|
||||
|
||||
surfaceHeight int
|
||||
}
|
||||
|
||||
ShowManual Tracker
|
||||
AskHelp Tracker
|
||||
ReportBug Tracker
|
||||
|
||||
C = layout.Context
|
||||
D = layout.Dimensions
|
||||
)
|
||||
@ -66,139 +74,168 @@ const (
|
||||
ConfirmNew
|
||||
)
|
||||
|
||||
var ZoomFactors = []float32{.25, 1. / 3, .5, 2. / 3, .75, .8, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5}
|
||||
|
||||
func NewTracker(model *tracker.Model) *Tracker {
|
||||
t := &Tracker{
|
||||
Theme: material.NewTheme(),
|
||||
OctaveNumberInput: NewNumberInput(model.Octave().Int()),
|
||||
InstrumentVoices: NewNumberInput(model.InstrumentVoices().Int()),
|
||||
OctaveNumberInput: NewNumericUpDownState(),
|
||||
InstrumentVoices: NewNumericUpDownState(),
|
||||
|
||||
TopHorizontalSplit: &Split{Ratio: -.5},
|
||||
BottomHorizontalSplit: &Split{Ratio: -.6},
|
||||
VerticalSplit: &Split{Axis: layout.Vertical},
|
||||
TopHorizontalSplit: &SplitState{Ratio: -.5},
|
||||
BottomHorizontalSplit: &SplitState{Ratio: -.6},
|
||||
VerticalSplit: &SplitState{Axis: layout.Vertical},
|
||||
|
||||
KeyPlaying: make(map[key.Name]tracker.NoteID),
|
||||
SaveChangesDialog: NewDialog(model.SaveSong(), model.DiscardSong(), model.Cancel()),
|
||||
WaveTypeDialog: NewDialog(model.ExportInt16(), model.ExportFloat(), model.Cancel()),
|
||||
InstrumentEditor: NewInstrumentEditor(model),
|
||||
OrderEditor: NewOrderEditor(model),
|
||||
TrackEditor: NewNoteEditor(model),
|
||||
SongPanel: NewSongPanel(model),
|
||||
DialogState: new(DialogState),
|
||||
PatchPanel: NewPatchPanel(model),
|
||||
OrderEditor: NewOrderEditor(model),
|
||||
TrackEditor: NewNoteEditor(model),
|
||||
|
||||
Zoom: 6,
|
||||
|
||||
Model: model,
|
||||
|
||||
filePathString: model.FilePath().String(),
|
||||
execChan: make(chan func(), 1024),
|
||||
filePathString: model.Song().FilePath(),
|
||||
}
|
||||
t.SongPanel = NewSongPanel(t)
|
||||
t.KeyNoteMap = MakeKeyboard[key.Name](model.Broker())
|
||||
t.PopupAlert = NewAlertsState()
|
||||
var warn error
|
||||
if t.Theme, warn = NewTheme(); warn != nil {
|
||||
model.Alerts().AddAlert(tracker.Alert{
|
||||
Priority: tracker.Warning,
|
||||
Message: warn.Error(),
|
||||
Duration: 10 * time.Second,
|
||||
})
|
||||
}
|
||||
t.Theme.Material.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
|
||||
if warn := ReadConfig(defaultPreferences, "preferences.yml", &t.preferences); warn != nil {
|
||||
model.Alerts().AddAlert(tracker.Alert{
|
||||
Priority: tracker.Warning,
|
||||
Message: warn.Error(),
|
||||
Duration: 10 * time.Second,
|
||||
})
|
||||
}
|
||||
t.Theme.Shaper = text.NewShaper(text.WithCollection(fontCollection))
|
||||
t.PopupAlert = NewPopupAlert(model.Alerts(), t.Theme.Shaper)
|
||||
t.Theme.Palette.Fg = primaryColor
|
||||
t.Theme.Palette.ContrastFg = black
|
||||
t.TrackEditor.scrollTable.Focus()
|
||||
t.quitWG.Add(1)
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Tracker) Main() {
|
||||
titleFooter := ""
|
||||
w := app.NewWindow(
|
||||
app.Size(unit.Dp(800), unit.Dp(600)),
|
||||
app.Title("Sointu Tracker"),
|
||||
)
|
||||
t.InstrumentEditor.Focus()
|
||||
recoveryTicker := time.NewTicker(time.Second * 30)
|
||||
t.Explorer = explorer.NewExplorer(w)
|
||||
// Make a channel to read window events from.
|
||||
events := make(chan event.Event)
|
||||
// Make a channel to signal the end of processing a window event.
|
||||
acks := make(chan struct{})
|
||||
go eventLoop(w, events, acks)
|
||||
var ops op.Ops
|
||||
for {
|
||||
if titleFooter != t.filePathString.Value() {
|
||||
titleFooter = t.filePathString.Value()
|
||||
if titleFooter != "" {
|
||||
w.Option(app.Title(fmt.Sprintf("Sointu Tracker - %v", titleFooter)))
|
||||
} else {
|
||||
w.Option(app.Title(fmt.Sprintf("Sointu Tracker")))
|
||||
titlePath := ""
|
||||
globals := make(map[string]any, 1)
|
||||
globals["Tracker"] = t
|
||||
for !t.Quitted() {
|
||||
w := t.newWindow()
|
||||
w.Option(app.Title(titleFromPath(titlePath)))
|
||||
t.Explorer = explorer.NewExplorer(w)
|
||||
acks := make(chan struct{})
|
||||
events := make(chan event.Event)
|
||||
go func() {
|
||||
for {
|
||||
ev := w.Event()
|
||||
events <- ev
|
||||
<-acks
|
||||
if _, ok := ev.(app.DestroyEvent); ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case e := <-t.PlayerMessages:
|
||||
t.ProcessPlayerMessage(e)
|
||||
w.Invalidate()
|
||||
case e := <-events:
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
acks <- struct{}{}
|
||||
if canQuit {
|
||||
t.Quit().Do()
|
||||
}()
|
||||
F:
|
||||
for {
|
||||
select {
|
||||
case e := <-t.Broker().ToGUI:
|
||||
switch e := e.(type) {
|
||||
case tracker.NoteEvent:
|
||||
t.noteEvents = append(t.noteEvents, e)
|
||||
case tracker.MsgToGUI:
|
||||
switch e.Kind {
|
||||
case tracker.GUIMessageCenterOnRow:
|
||||
t.TrackEditor.scrollTable.RowTitleList.CenterOn(e.Param)
|
||||
case tracker.GUIMessageEnsureCursorVisible:
|
||||
t.TrackEditor.scrollTable.EnsureCursorVisible()
|
||||
}
|
||||
}
|
||||
if !t.Quitted() {
|
||||
// TODO: uh oh, there's no way of canceling the destroyevent in gioui? so we create a new window just to show the dialog
|
||||
w = app.NewWindow(
|
||||
app.Size(unit.Dp(800), unit.Dp(600)),
|
||||
app.Title("Sointu Tracker"),
|
||||
)
|
||||
t.Explorer = explorer.NewExplorer(w)
|
||||
go eventLoop(w, events, acks)
|
||||
w.Invalidate()
|
||||
case e := <-t.Broker().ToModel:
|
||||
t.ProcessMsg(e)
|
||||
w.Invalidate()
|
||||
case <-t.Broker().CloseGUI:
|
||||
t.ForceQuit().Do()
|
||||
w.Perform(system.ActionClose)
|
||||
case e := <-events:
|
||||
switch e := e.(type) {
|
||||
case app.DestroyEvent:
|
||||
if canQuit {
|
||||
t.RequestQuit().Do()
|
||||
}
|
||||
acks <- struct{}{}
|
||||
break F // this window is done, we need to create a new one
|
||||
case app.FrameEvent:
|
||||
if titlePath != t.filePathString.Value() {
|
||||
titlePath = t.filePathString.Value()
|
||||
w.Option(app.Title(titleFromPath(titlePath)))
|
||||
}
|
||||
gtx := app.NewContext(&ops, e)
|
||||
gtx.Values = globals
|
||||
t.Layout(gtx)
|
||||
e.Frame(gtx.Ops)
|
||||
if t.Quitted() {
|
||||
w.Perform(system.ActionClose)
|
||||
}
|
||||
}
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
if t.SongPanel.PlayingBtn.Bool.Value() && t.SongPanel.NoteTracking.Bool.Value() {
|
||||
t.TrackEditor.scrollTable.RowTitleList.CenterOn(t.PlaySongRow())
|
||||
}
|
||||
t.Layout(gtx, w)
|
||||
e.Frame(gtx.Ops)
|
||||
acks <- struct{}{}
|
||||
default:
|
||||
acks <- struct{}{}
|
||||
case <-recoveryTicker.C:
|
||||
t.History().SaveRecovery()
|
||||
}
|
||||
case <-recoveryTicker.C:
|
||||
t.SaveRecovery()
|
||||
case f := <-t.execChan:
|
||||
f()
|
||||
}
|
||||
if t.Quitted() {
|
||||
break
|
||||
}
|
||||
}
|
||||
recoveryTicker.Stop()
|
||||
w.Perform(system.ActionClose)
|
||||
t.SaveRecovery()
|
||||
t.quitWG.Done()
|
||||
t.History().SaveRecovery()
|
||||
close(t.Broker().FinishedGUI)
|
||||
}
|
||||
|
||||
func eventLoop(w *app.Window, events chan<- event.Event, acks <-chan struct{}) {
|
||||
// Iterate window events, sending each to the old event loop and waiting for
|
||||
// a signal that processing is complete before iterating again.
|
||||
for {
|
||||
ev := w.NextEvent()
|
||||
events <- ev
|
||||
<-acks
|
||||
if _, ok := ev.(app.DestroyEvent); ok {
|
||||
return
|
||||
}
|
||||
func TrackerFromContext(gtx C) *Tracker {
|
||||
t, ok := gtx.Values["Tracker"]
|
||||
if !ok {
|
||||
panic("Tracker not found in context values")
|
||||
}
|
||||
return t.(*Tracker)
|
||||
}
|
||||
|
||||
func (t *Tracker) Exec() chan<- func() {
|
||||
return t.execChan
|
||||
func (t *Tracker) newWindow() *app.Window {
|
||||
w := new(app.Window)
|
||||
w.Option(app.Size(t.preferences.WindowSize()))
|
||||
if t.preferences.Window.Maximized {
|
||||
w.Option(app.Maximized.Option())
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (t *Tracker) WaitQuitted() {
|
||||
t.quitWG.Wait()
|
||||
func titleFromPath(path string) string {
|
||||
if path == "" {
|
||||
return "Sointu Tracker"
|
||||
}
|
||||
return fmt.Sprintf("Sointu Tracker - %s", path)
|
||||
}
|
||||
|
||||
func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
|
||||
paint.FillShape(gtx.Ops, backgroundColor, clip.Rect(image.Rect(0, 0, gtx.Constraints.Max.X, gtx.Constraints.Max.Y)).Op())
|
||||
if t.InstrumentEditor.enlargeBtn.Bool.Value() {
|
||||
func (t *Tracker) Layout(gtx layout.Context) {
|
||||
zoomFactor := ZoomFactors[t.Zoom]
|
||||
gtx.Metric.PxPerDp *= zoomFactor
|
||||
gtx.Metric.PxPerSp *= zoomFactor
|
||||
defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, t.Theme.Material.Bg)
|
||||
event.Op(gtx.Ops, t) // area for capturing scroll events
|
||||
|
||||
if t.Play().TrackerHidden().Value() {
|
||||
t.layoutTop(gtx)
|
||||
} else {
|
||||
t.VerticalSplit.Layout(gtx,
|
||||
&t.Theme.Split,
|
||||
t.layoutTop,
|
||||
t.layoutBottom)
|
||||
}
|
||||
t.PopupAlert.Layout(gtx)
|
||||
alerts := Alerts(t.Alerts(), t.Theme, t.PopupAlert)
|
||||
alerts.Layout(gtx)
|
||||
t.showDialog(gtx)
|
||||
// this is the top level input handler for the whole app
|
||||
// it handles all the global key events and clipboard events
|
||||
@ -207,20 +244,38 @@ func (t *Tracker) Layout(gtx layout.Context, w *app.Window) {
|
||||
for {
|
||||
ev, ok := gtx.Event(
|
||||
key.Filter{Name: "", Optional: key.ModAlt | key.ModCommand | key.ModShift | key.ModShortcut | key.ModSuper},
|
||||
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
||||
key.Filter{Name: key.NameTab, Optional: key.ModShift | key.ModShortcut},
|
||||
transfer.TargetFilter{Target: t, Type: "application/text"},
|
||||
pointer.Filter{Target: t, Kinds: pointer.Scroll, ScrollY: pointer.ScrollRange{Min: -1, Max: 1}},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e := ev.(type) {
|
||||
case pointer.Event:
|
||||
switch e.Kind {
|
||||
case pointer.Scroll:
|
||||
if e.Modifiers.Contain(key.ModShortcut) {
|
||||
t.Zoom = min(max(t.Zoom-int(e.Scroll.Y), 0), len(ZoomFactors)-1)
|
||||
t.Alerts().AddNamed("ZoomFactor", fmt.Sprintf("%.0f%%", ZoomFactors[t.Zoom]*100), tracker.Info)
|
||||
}
|
||||
}
|
||||
case key.Event:
|
||||
t.KeyEvent(e, gtx)
|
||||
case transfer.DataEvent:
|
||||
t.ReadSong(e.Open())
|
||||
t.Song().Read(e.Open())
|
||||
}
|
||||
}
|
||||
|
||||
// if no-one else handled the note events, we handle them here
|
||||
for len(t.noteEvents) > 0 {
|
||||
ev := t.noteEvents[0]
|
||||
ev.IsTrack = false
|
||||
ev.Channel = t.Model.Instrument().List().Selected()
|
||||
ev.Source = t
|
||||
copy(t.noteEvents, t.noteEvents[1:])
|
||||
t.noteEvents = t.noteEvents[:len(t.noteEvents)-1]
|
||||
tracker.TrySend(t.Broker().ToPlayer, any(ev))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) showDialog(gtx C) {
|
||||
@ -229,31 +284,52 @@ func (t *Tracker) showDialog(gtx C) {
|
||||
}
|
||||
switch t.Dialog() {
|
||||
case tracker.NewSongChanges, tracker.OpenSongChanges, tracker.QuitChanges:
|
||||
dstyle := ConfirmDialog(gtx, t.Theme, t.SaveChangesDialog, "Save changes to song?", "Your changes will be lost if you don't save them.")
|
||||
dstyle.OkStyle.Text = "Save"
|
||||
dstyle.AltStyle.Text = "Don't save"
|
||||
dstyle.Layout(gtx)
|
||||
dialog := MakeDialog(t.Theme, t.DialogState, "Save changes to song?", "Your changes will be lost if you don't save them.",
|
||||
DialogBtn("Save", t.Song().Save()),
|
||||
DialogBtn("Don't save", t.Song().Discard()),
|
||||
DialogBtn("Cancel", t.CancelDialog()),
|
||||
)
|
||||
dialog.Layout(gtx)
|
||||
case tracker.Export:
|
||||
dstyle := ConfirmDialog(gtx, t.Theme, t.WaveTypeDialog, "", "Export .wav in int16 or float32 sample format?")
|
||||
dstyle.OkStyle.Text = "Int16"
|
||||
dstyle.AltStyle.Text = "Float32"
|
||||
dstyle.Layout(gtx)
|
||||
dialog := MakeDialog(t.Theme, t.DialogState, "Export format", "Choose the sample format for the exported .wav file.",
|
||||
DialogBtn("Int16", t.Song().ExportInt16()),
|
||||
DialogBtn("Float32", t.Song().ExportFloat()),
|
||||
DialogBtn("Cancel", t.CancelDialog()),
|
||||
)
|
||||
dialog.Layout(gtx)
|
||||
case tracker.OpenSongOpenExplorer:
|
||||
t.explorerChooseFile(t.ReadSong, ".yml", ".json")
|
||||
t.explorerChooseFile(t.Song().Read, ".yml", ".json")
|
||||
case tracker.NewSongSaveExplorer, tracker.OpenSongSaveExplorer, tracker.QuitSaveExplorer, tracker.SaveAsExplorer:
|
||||
filename := t.filePathString.Value()
|
||||
if filename == "" {
|
||||
filename = "song.yml"
|
||||
}
|
||||
t.explorerCreateFile(t.WriteSong, filename)
|
||||
t.explorerCreateFile(t.Song().Write, filename)
|
||||
case tracker.ExportFloatExplorer, tracker.ExportInt16Explorer:
|
||||
filename := "song.wav"
|
||||
if p := t.filePathString.Value(); p != "" {
|
||||
filename = p[:len(p)-len(filepath.Ext(p))] + ".wav"
|
||||
}
|
||||
t.explorerCreateFile(func(wc io.WriteCloser) {
|
||||
t.WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer, t.execChan)
|
||||
t.Song().WriteWav(wc, t.Dialog() == tracker.ExportInt16Explorer)
|
||||
}, filename)
|
||||
case tracker.License:
|
||||
dialog := MakeDialog(t.Theme, t.DialogState, "License", sointu.License,
|
||||
DialogBtn("Close", t.CancelDialog()),
|
||||
)
|
||||
dialog.Layout(gtx)
|
||||
case tracker.DeleteUserPresetDialog:
|
||||
dialog := MakeDialog(t.Theme, t.DialogState, "Delete user preset?", "Are you sure you want to delete the selected user preset?\nThis action cannot be undone.",
|
||||
DialogBtn("Delete", t.Preset().ConfirmDelete()),
|
||||
DialogBtn("Cancel", t.CancelDialog()),
|
||||
)
|
||||
dialog.Layout(gtx)
|
||||
case tracker.OverwriteUserPresetDialog:
|
||||
dialog := MakeDialog(t.Theme, t.DialogState, "Overwrite user preset?", "Are you sure you want to overwrite the existing user preset with the same name?",
|
||||
DialogBtn("Save", t.Preset().Overwrite()),
|
||||
DialogBtn("Cancel", t.CancelDialog()),
|
||||
)
|
||||
dialog.Layout(gtx)
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,14 +337,17 @@ func (t *Tracker) explorerChooseFile(success func(io.ReadCloser), extensions ...
|
||||
t.Exploring = true
|
||||
go func() {
|
||||
file, err := t.Explorer.ChooseFile(extensions...)
|
||||
t.Exec() <- func() {
|
||||
t.Broker().ToModel <- tracker.MsgToModel{Data: func() {
|
||||
t.Exploring = false
|
||||
if err == nil {
|
||||
success(file)
|
||||
} else {
|
||||
t.Cancel().Do()
|
||||
t.CancelDialog().Do()
|
||||
if err != explorer.ErrUserDecline {
|
||||
t.Alerts().Add(err.Error(), tracker.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -276,35 +355,71 @@ func (t *Tracker) explorerCreateFile(success func(io.WriteCloser), filename stri
|
||||
t.Exploring = true
|
||||
go func() {
|
||||
file, err := t.Explorer.CreateFile(filename)
|
||||
t.Exec() <- func() {
|
||||
t.Broker().ToModel <- tracker.MsgToModel{Data: func() {
|
||||
t.Exploring = false
|
||||
if err == nil {
|
||||
success(file)
|
||||
} else {
|
||||
t.Cancel().Do()
|
||||
t.CancelDialog().Do()
|
||||
if err != explorer.ErrUserDecline {
|
||||
t.Alerts().Add(err.Error(), tracker.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *Tracker) layoutBottom(gtx layout.Context) layout.Dimensions {
|
||||
return t.BottomHorizontalSplit.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
return t.OrderEditor.Layout(gtx, t)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return t.TrackEditor.Layout(gtx, t)
|
||||
},
|
||||
&t.Theme.Split,
|
||||
t.OrderEditor.Layout,
|
||||
t.TrackEditor.Layout,
|
||||
)
|
||||
}
|
||||
|
||||
func (t *Tracker) layoutTop(gtx layout.Context) layout.Dimensions {
|
||||
return t.TopHorizontalSplit.Layout(gtx,
|
||||
func(gtx C) D {
|
||||
return t.SongPanel.Layout(gtx, t)
|
||||
},
|
||||
func(gtx C) D {
|
||||
return t.InstrumentEditor.Layout(gtx, t)
|
||||
},
|
||||
&t.Theme.Split,
|
||||
t.SongPanel.Layout,
|
||||
t.PatchPanel.Layout,
|
||||
)
|
||||
}
|
||||
|
||||
func (t *Tracker) ShowManual() tracker.Action { return tracker.MakeAction((*ShowManual)(t)) }
|
||||
func (t *ShowManual) Do() { (*Tracker)(t).openUrl("https://github.com/vsariola/sointu/wiki") }
|
||||
|
||||
func (t *Tracker) AskHelp() tracker.Action { return tracker.MakeAction((*AskHelp)(t)) }
|
||||
func (t *AskHelp) Do() {
|
||||
(*Tracker)(t).openUrl("https://github.com/vsariola/sointu/discussions/categories/help-needed")
|
||||
}
|
||||
|
||||
func (t *Tracker) ReportBug() tracker.Action { return tracker.MakeAction((*ReportBug)(t)) }
|
||||
func (t *ReportBug) Do() { (*Tracker)(t).openUrl("https://github.com/vsariola/sointu/issues") }
|
||||
|
||||
func (t *Tracker) openUrl(url string) {
|
||||
var err error
|
||||
// following https://gist.github.com/hyg/9c4afcd91fe24316cbf0
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported platform for opening urls %s", runtime.GOOS)
|
||||
}
|
||||
if err != nil {
|
||||
t.Alerts().Add(err.Error(), tracker.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) Tags(curLevel int, yield TagYieldFunc) bool {
|
||||
curLevel++
|
||||
ret := t.SongPanel.Tags(curLevel, yield) && t.PatchPanel.Tags(curLevel, yield)
|
||||
if !t.Play().TrackerHidden().Value() {
|
||||
ret = ret && t.OrderEditor.Tags(curLevel, yield) &&
|
||||
t.TrackEditor.Tags(curLevel, yield)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user