Compare commits

...

No commits in common. "gh-pages" and "master" have entirely different histories.

19 changed files with 8011 additions and 40222 deletions

10
.editorconfig Normal file

@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{feature,json,md,yaml,yml}]
indent_size = 2

11
.gitignore vendored

@ -1,2 +1,9 @@
node_modules
out
node_modules/
out/
tmp/
assets/
public/*.js
audio/**/*_data/
*.aup
*.tgz
.vscode/

21
LICENSE Normal file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Matthew T. Kennerly (mtkennerly)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35
README.md Normal file

@ -0,0 +1,35 @@
# Bosca Ceoil JS
This project is an HTML/CSS/JavaScript (TypeScript) rewrite of
[Bosca Ceoil](https://github.com/TerryCavanagh/boscaceoil) using samples
of the preset instruments from [SiON](https://github.com/keim/SiON)
(rather than a port of SiON itself).
It is still a prototype, so significant functionality is missing.
## Sample creation
The SiON samples were created by running
`npm run samples -- <path_to_bosca_ceoil_clone>`.
This requires a few things:
* A clone of Bosca Ceoil with support for
[exporting via CLI](https://github.com/TerryCavanagh/boscaceoil/pull/71).
Since that functionality is not in an official release, the script will run
`adl application.xml` in that clone, so you'll need the Adobe AIR SDK.
* [SoX](http://sox.sourceforge.net) for removing silence from the end of the
recordings and converting from WAV to OGG.
The script should be run with 100% system volume.
## Development
Prerequisites:
* [Node](https://nodejs.org/en)
Initial setup:
* `npm install`
Run:
* `npm run dev`
* Open `http://127.0.0.1:8000/index.html` in your browser

1
audio/main.ceol Normal file

@ -0,0 +1 @@
3,0,0,0,120,16,4,1,0,0,0,128,0,256,2,0,0,0,0,1,0,16,0,0,0,0,0,0,0,0,0,16,0,16,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,

@ -1,15 +0,0 @@
const { app, BrowserWindow } = require('electron')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600
});
win.loadFile('index.html')
app.whenReady().then(() => {
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
}

775
instruments.yaml Normal file

@ -0,0 +1,775 @@
midi.piano1:
index: 0
duration: short
category: [MIDI, Piano, Grand Piano]
# midi.piano2:
# index: 1
# category: [MIDI, Piano, Bright Piano]
# midi.piano3:
# index: 2
# category: [MIDI, Piano, Electric Grand]
# midi.piano4:
# index: 3
# category: [MIDI, Piano, Honky Tonk]
# midi.piano5:
# index: 4
# category: [MIDI, Piano, Electric Piano 1]
# midi.piano6:
# index: 5
# category: [MIDI, Piano, Electric Piano 2]
# midi.piano7:
# index: 6
# category: [MIDI, Piano, Harpsichord]
# midi.piano8:
# index: 7
# category: [MIDI, Piano, Clavi]
midi.chrom1:
index: 8
duration: constant
category: [MIDI, Bells, Celesta]
# midi.chrom2:
# index: 9
# midi.chrom3:
# index: 10
# midi.chrom4:
# index: 11
# midi.chrom5:
# index: 12
# midi.chrom6:
# index: 13
# midi.chrom7:
# index: 14
# midi.chrom8:
# index: 15
midi.organ1:
index: 16
duration: instant
category: [MIDI, Organ, Drawbar Organ]
# midi.organ2:
# index: 17
# midi.organ3:
# index: 18
# midi.organ4:
# index: 19
# midi.organ5:
# index: 20
# midi.organ6:
# index: 21
# midi.organ7:
# index: 22
# midi.organ8:
# index: 23
midi.guitar1:
index: 24
duration: short
category: [MIDI, Guitar, Nylon Guitar]
# midi.guitar2:
# index: 25
# midi.guitar3:
# index: 26
# midi.guitar4:
# index: 27
# midi.guitar5:
# index: 28
# midi.guitar6:
# index: 29
# midi.guitar7:
# index: 30
# midi.guitar8:
# index: 31
midi.bass1:
index: 32
duration: instant
category: [MIDI, Bass, Acoustic Bass]
# midi.bass2:
# index: 33
# midi.bass3:
# index: 34
# midi.bass4:
# index: 35
# midi.bass5:
# index: 36
# midi.bass6:
# index: 37
# midi.bass7:
# index: 38
# midi.bass8:
# index: 39
midi.strings1:
index: 40
duration: mini
category: [MIDI, Strings, Violin]
# midi.strings2:
# index: 41
# midi.strings3:
# index: 42
# midi.strings4:
# index: 43
# midi.strings5:
# index: 44
# midi.strings6:
# index: 45
# midi.strings7:
# index: 46
# midi.strings8:
# index: 47
midi.ensemble1:
index: 48
duration: mid
category: [MIDI, Ensemble, String Ensemble 1]
# midi.ensemble2:
# index: 49
# midi.ensemble3:
# index: 50
# midi.ensemble4:
# index: 51
# midi.ensemble5:
# index: 52
# midi.ensemble6:
# index: 53
# midi.ensemble7:
# index: 54
# midi.ensemble8:
# index: 55
midi.brass1:
index: 56
duration: mini
category: [MIDI, Brass, Trumpet]
# midi.brass2:
# index: 57
# midi.brass3:
# index: 58
# midi.brass4:
# index: 59
# midi.brass5:
# index: 60
# midi.brass6:
# index: 61
# midi.brass7:
# index: 62
# midi.brass8:
# index: 63
midi.reed1:
index: 64
duration: mini
category: [MIDI, Reed, Soprano Sax]
# midi.reed2:
# index: 65
# midi.reed3:
# index: 66
# midi.reed4:
# index: 67
# midi.reed5:
# index: 68
# midi.reed6:
# index: 69
# midi.reed7:
# index: 70
# midi.reed8:
# index: 71
midi.pipe1:
index: 72
duration: mini
category: [MIDI, Pipe, Piccolo]
# midi.pipe2:
# index: 73
# midi.pipe3:
# index: 74
# midi.pipe4:
# index: 75
# midi.pipe5:
# index: 76
# midi.pipe6:
# index: 77
# midi.pipe7:
# index: 78
# midi.pipe8:
# index: 79
midi.lead1:
index: 80
duration: mini
category: [MIDI, Lead, Square Lead]
# midi.lead2:
# index: 81
# midi.lead3:
# index: 82
# midi.lead4:
# index: 83
# midi.lead5:
# index: 84
# midi.lead6:
# index: 85
# midi.lead7:
# index: 86
# midi.lead8:
# index: 87
midi.pad1:
index: 88
duration: long
category: [MIDI, Pads, New Age Pad]
# midi.pad2:
# index: 89
# midi.pad3:
# index: 90
# midi.pad4:
# index: 91
# midi.pad5:
# index: 92
# midi.pad6:
# index: 93
# midi.pad7:
# index: 94
# midi.pad8:
# index: 95
midi.fx1:
index: 96
duration: mega
category: [MIDI, Synth, Rain]
# midi.fx2:
# index: 97
# midi.fx3:
# index: 98
# midi.fx4:
# index: 99
# midi.fx5:
# index: 100
# midi.fx6:
# index: 101
# midi.fx7:
# index: 102
# midi.fx8:
# index: 103
midi.world1:
index: 104
duration: extended
category: [MIDI, World, Sitar]
# midi.world2:
# index: 105
# midi.world3:
# index: 106
# midi.world4:
# index: 107
# midi.world5:
# index: 108
# midi.world6:
# index: 109
# midi.world7:
# index: 110
# midi.world8:
# index: 111
midi.percus1:
index: 112
duration: long
category: [MIDI, Drums, Tinkle Bell]
# midi.percus2:
# index: 113
# midi.percus3:
# index: 114
# midi.percus4:
# index: 115
# midi.percus5:
# index: 116
# midi.percus6:
# index: 117
# midi.percus7:
# index: 118
# midi.percus8:
# index: 119
midi.se1:
index: 120
duration: mini
category: [MIDI, Effects, Fret Noise]
# midi.se2:
# index: 121
# midi.se3:
# index: 122
# midi.se4:
# index: 123
# midi.se5:
# index: 124
# midi.se6:
# index: 125
# midi.se7:
# index: 126
# midi.se8:
# index: 127
square:
index: 128
duration: infinite
category: [Chiptune, Square Wave]
# saw:
# index: 129
# triangle:
# index: 130
# sine:
# index: 131
# noise:
# index: 132
# dualsquare:
# index: 133
# dualsaw:
# index: 134
# triangle8:
# index: 135
# konami:
# index: 136
# ramp:
# index: 137
# beep:
# index: 138
# ma1:
# index: 139
# bassdrumm:
# index: 140
# snare:
# index: 141
# closedhh:
# index: 142
# valsound.bass1:
# index: 143
# valsound.bass2:
# index: 144
# valsound.bass3:
# index: 145
# valsound.bass4:
# index: 146
# valsound.bass5:
# index: 147
# valsound.bass6:
# index: 148
# valsound.bass7:
# index: 149
# valsound.bass8:
# index: 150
# valsound.bass9:
# index: 151
# valsound.bass10:
# index: 152
# valsound.bass11:
# index: 153
# valsound.bass12:
# index: 154
# valsound.bass13:
# index: 155
# valsound.bass14:
# index: 156
# valsound.bass15:
# index: 157
# valsound.bass16:
# index: 158
# valsound.bass17:
# index: 159
# valsound.bass18:
# index: 160
# valsound.bass19:
# index: 161
# valsound.bass20:
# index: 162
# valsound.bass21:
# index: 163
# valsound.bass22:
# index: 164
# valsound.bass23:
# index: 165
# valsound.bass24:
# index: 166
# valsound.bass25:
# index: 167
# valsound.bass26:
# index: 168
# valsound.bass27:
# index: 169
# valsound.bass28:
# index: 170
# valsound.bass29:
# index: 171
# valsound.bass30:
# index: 172
# valsound.bass31:
# index: 173
# valsound.bass32:
# index: 174
# valsound.bass33:
# index: 175
# valsound.bass34:
# index: 176
# valsound.bass35:
# index: 177
# valsound.bass36:
# index: 178
# valsound.bass37:
# index: 179
# valsound.bass38:
# index: 180
# valsound.bass39:
# index: 181
# valsound.bass40:
# index: 182
# valsound.bass41:
# index: 183
# valsound.bass42:
# index: 184
# valsound.bass43:
# index: 185
# valsound.bass44:
# index: 186
# valsound.bass45:
# index: 187
# valsound.bass46:
# index: 188
# valsound.bass47:
# index: 189
# valsound.bass48:
# index: 190
# valsound.bass49:
# index: 191
# valsound.bass50:
# index: 192
# valsound.bass51:
# index: 193
# valsound.bass52:
# index: 194
# valsound.bass53:
# index: 195
# valsound.bass54:
# index: 196
# valsound.brass1:
# index: 197
# valsound.brass2:
# index: 198
# valsound.brass3:
# index: 199
# valsound.brass4:
# index: 200
# valsound.brass5:
# index: 201
# valsound.brass6:
# index: 202
# valsound.brass7:
# index: 203
# valsound.brass8:
# index: 204
# valsound.brass9:
# index: 205
# valsound.brass10:
# index: 206
# valsound.brass11:
# index: 207
# valsound.brass12:
# index: 208
# valsound.brass13:
# index: 209
# valsound.brass14:
# index: 210
# valsound.brass15:
# index: 211
# valsound.brass16:
# index: 212
# valsound.brass17:
# index: 213
# valsound.brass18:
# index: 214
# valsound.brass19:
# index: 215
# valsound.brass20:
# index: 216
# valsound.bell1:
# index: 217
# valsound.bell2:
# index: 218
# valsound.bell3:
# index: 219
# valsound.bell4:
# index: 220
# valsound.bell5:
# index: 221
# valsound.bell6:
# index: 222
# valsound.bell7:
# index: 223
# valsound.bell8:
# index: 224
# valsound.bell9:
# index: 225
# valsound.bell10:
# index: 226
# valsound.bell11:
# index: 227
# valsound.bell12:
# index: 228
# valsound.bell13:
# index: 229
# valsound.bell14:
# index: 230
# valsound.bell15:
# index: 231
# valsound.bell16:
# index: 232
# valsound.bell17:
# index: 233
# valsound.bell18:
# index: 234
# valsound.guitar1:
# index: 235
# valsound.guitar2:
# index: 236
# valsound.guitar3:
# index: 237
# valsound.guitar4:
# index: 238
# valsound.guitar5:
# index: 239
# valsound.guitar6:
# index: 240
# valsound.guitar7:
# index: 241
# valsound.guitar8:
# index: 242
# valsound.guitar9:
# index: 243
# valsound.guitar10:
# index: 244
# valsound.guitar11:
# index: 245
# valsound.guitar12:
# index: 246
# valsound.guitar13:
# index: 247
# valsound.guitar14:
# index: 248
# valsound.guitar15:
# index: 249
# valsound.guitar16:
# index: 250
# valsound.guitar17:
# index: 251
# valsound.guitar18:
# index: 252
# valsound.lead1:
# index: 253
# valsound.lead2:
# index: 254
# valsound.lead3:
# index: 255
# valsound.lead4:
# index: 256
# valsound.lead5:
# index: 257
# valsound.lead6:
# index: 258
# valsound.lead7:
# index: 259
# valsound.lead8:
# index: 260
# valsound.lead9:
# index: 261
# valsound.lead10:
# index: 262
# valsound.lead11:
# index: 263
# valsound.lead12:
# index: 264
# valsound.lead13:
# index: 265
# valsound.lead14:
# index: 266
# valsound.lead15:
# index: 267
# valsound.lead16:
# index: 268
# valsound.lead17:
# index: 269
# valsound.lead18:
# index: 270
# valsound.lead19:
# index: 271
# valsound.lead20:
# index: 272
# valsound.lead21:
# index: 273
# valsound.lead22:
# index: 274
# valsound.lead23:
# index: 275
# valsound.lead24:
# index: 276
# valsound.lead25:
# index: 277
# valsound.lead26:
# index: 278
# valsound.lead27:
# index: 279
# valsound.lead28:
# index: 280
# valsound.lead29:
# index: 281
# valsound.lead30:
# index: 282
# valsound.lead31:
# index: 283
# valsound.lead32:
# index: 284
# valsound.lead33:
# index: 285
# valsound.lead34:
# index: 286
# valsound.lead35:
# index: 287
# valsound.lead36:
# index: 288
# valsound.lead37:
# index: 289
# valsound.lead38:
# index: 290
# valsound.lead39:
# index: 291
# valsound.lead40:
# index: 292
# valsound.lead41:
# index: 293
# valsound.lead42:
# index: 294
# valsound.piano1:
# index: 295
# valsound.piano2:
# index: 296
# valsound.piano3:
# index: 297
# valsound.piano4:
# index: 298
# valsound.piano5:
# index: 299
# valsound.piano6:
# index: 300
# valsound.piano7:
# index: 301
# valsound.piano8:
# index: 302
# valsound.piano9:
# index: 303
# valsound.piano10:
# index: 304
# valsound.piano11:
# index: 305
# valsound.piano12:
# index: 306
# valsound.piano13:
# index: 307
# valsound.piano14:
# index: 308
# valsound.piano15:
# index: 309
# valsound.piano16:
# index: 310
# valsound.piano17:
# index: 311
# valsound.piano18:
# index: 312
# valsound.piano19:
# index: 313
# valsound.piano20:
# index: 314
# valsound.se1:
# index: 315
# valsound.se2:
# index: 316
# valsound.se3:
# index: 317
# valsound.special1:
# index: 318
# valsound.special2:
# index: 319
# valsound.special3:
# index: 320
# valsound.special4:
# index: 321
# valsound.special5:
# index: 322
# valsound.strpad1:
# index: 323
# valsound.strpad2:
# index: 324
# valsound.strpad3:
# index: 325
# valsound.strpad4:
# index: 326
# valsound.strpad5:
# index: 327
# valsound.strpad6:
# index: 328
# valsound.strpad7:
# index: 329
# valsound.strpad8:
# index: 330
# valsound.strpad9:
# index: 331
# valsound.strpad10:
# index: 332
# valsound.strpad11:
# index: 333
# valsound.strpad12:
# index: 334
# valsound.strpad13:
# index: 335
# valsound.strpad14:
# index: 336
# valsound.strpad15:
# index: 337
# valsound.strpad16:
# index: 338
# valsound.strpad17:
# index: 339
# valsound.strpad18:
# index: 340
# valsound.strpad19:
# index: 341
# valsound.strpad20:
# index: 342
# valsound.strpad21:
# index: 343
# valsound.strpad22:
# index: 344
# valsound.strpad23:
# index: 345
# valsound.strpad24:
# index: 346
# valsound.strpad25:
# index: 347
# valsound.wind1:
# index: 348
# valsound.wind2:
# index: 349
# valsound.wind3:
# index: 350
# valsound.wind4:
# index: 351
# valsound.wind5:
# index: 352
# valsound.wind6:
# index: 353
# valsound.wind7:
# index: 354
# valsound.wind8:
# index: 355
# valsound.world1:
# index: 356
# valsound.world2:
# index: 357
# valsound.world3:
# index: 358
# valsound.world4:
# index: 359
# valsound.world5:
# index: 360
# valsound.world6:
# index: 361
# valsound.world7:
# index: 362
# drumkit.1:
# index: 363
# duration: short
# category: [Drumkit, Simple Drumkit]
# drumkit.2:
# index: 364
# drumkit.3:
# index: 365

17893
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,62 +1,64 @@
{
"dependencies": {
"electron-squirrel-startup": "^1.0.0",
"rpmbuild": "^0.0.23"
},
"devDependencies": {
"@electron-forge/cli": "^6.0.0-beta.64",
"@electron-forge/maker-deb": "^6.0.0-beta.64",
"@electron-forge/maker-rpm": "^6.0.0-beta.64",
"@electron-forge/maker-squirrel": "^6.0.0-beta.64",
"@electron-forge/maker-zip": "^6.0.0-beta.64",
"electron": "^19.0.6"
},
"name": "bosca-ceoil-js",
"version": "0.1.0",
"description": "TypeScript port of SiON",
"main": "out/index.js",
"types": "out/index.d.ts",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make"
"compile": "tsc -p ./",
"deploy": "ts-node tasks/deploy.ts",
"dev": "concurrently npm:watch npm:webpack-dev npm:serve",
"samples": "ts-node tasks/samples.ts",
"serve": "ws --rewrite \"/bosca-ceoil-js/(.*) -> /$1\"",
"start": "node out/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "tsc -watch -p ./",
"webpack": "webpack --mode development",
"webpack-dev": "webpack --mode development --watch"
},
"config": {
"forge": {
"packagerConfig": {},
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {
"name": ""
}
},
{
"name": "@electron-forge/maker-zip",
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-deb",
"config": {}
},
{
"name": "@electron-forge/maker-rpm",
"config": {}
}
]
}
},
"name": "bosca-ceoil-electron",
"version": "1.0.0",
"description": "just a port to electron",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://git.brn.systems/BRN_Systems/bosca-ceoil-electron.git"
"url": "git+https://github.com/mtkennerly/bosca-ceoil-js.git"
},
"keywords": [
"bosca",
"ceoil",
"boscaceol",
"electron"
"author": "Matthew T. Kennerly",
"license": "MIT",
"bugs": {
"url": "https://github.com/mtkennerly/bosca-ceoil-js/issues"
},
"homepage": "https://github.com/mtkennerly/bosca-ceoil-js#readme",
"files": [
"/out"
],
"author": "BRN Systems (original:mtkennerly)",
"license": "MIT"
"dependencies": {
"@material/react-button": "^0.14.1",
"@material/react-dialog": "^0.15.0",
"@material/react-menu-surface": "^0.15.0",
"@material/react-select": "^0.14.1",
"@material/react-text-field": "^0.14.1",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-easy-state": "^6.1.3",
"tone": "^13.4.9"
},
"devDependencies": {
"@types/gh-pages": "^2.0.0",
"@types/js-yaml": "^3.12.1",
"@types/node": "^12.6.3",
"@types/react": "^16.8.23",
"@types/react-dom": "^16.8.4",
"@types/tone": "git+https://github.com/Tonejs/TypeScript.git",
"concurrently": "^4.1.1",
"css-loader": "^3.1.0",
"gh-pages": "^2.0.1",
"js-yaml": "^3.13.1",
"js-yaml-loader": "^1.2.2",
"local-web-server": "^3.0.4",
"style-loader": "^0.23.1",
"ts-loader": "^6.0.4",
"ts-node": "^8.3.0",
"tslint": "^5.18.0",
"typescript": "^3.5.2",
"webpack": "^4.35.2",
"webpack-cli": "^3.3.5"
}
}

File diff suppressed because one or more lines are too long

71
src/audio.ts Normal file

@ -0,0 +1,71 @@
import tone from "tone";
import { assertNever, notes } from "./index";
import { instrumentData } from "./data";
function setSamplerRelease(sampler: tone.Sampler, instrument: string) {
const instrumentDuration = instrumentData[instrument]["duration"];
switch (instrumentDuration) {
case "instant":
sampler.release = 0.05;
break;
case "mini":
sampler.release = 0.125;
break;
case "short":
sampler.release = 0.25;
break;
case "mid":
sampler.release = 0.4;
break;
case "long":
sampler.release = 1;
break;
case "extended":
sampler.release = 1.5;
break;
case "mega":
sampler.release = 5;
break;
case "constant":
sampler.release = 30;
break;
case "infinite":
sampler.release = 0.1;
break;
default:
assertNever(instrumentDuration);
}
}
function setSamplerCurve(sampler: tone.Sampler) {
(sampler as any).curve = "linear";
}
export function getSampler(
instrument: string,
extension: string = "ogg",
baseUrl: string = "/bosca-ceoil-js/audio/"
): tone.Sampler {
let samples: { [key: string]: string } = {};
for (const note of notes) {
samples[note] = `${instrument}/${note.toLowerCase()}.${extension}`;
}
let sampler = new tone.Sampler(samples, undefined, baseUrl);
setSamplerCurve(sampler);
setSamplerRelease(sampler, instrument);
return sampler;
}
export function changeSampler(
sampler: tone.Sampler,
instrument: string,
extension: string = "ogg",
): tone.Sampler {
for (const note of notes) {
sampler.add(note, `${instrument}/${note.toLowerCase()}.${extension}`);
}
setSamplerCurve(sampler);
setSamplerRelease(sampler, instrument);
return sampler;
}

3
src/data.ts Normal file

@ -0,0 +1,3 @@
import { InstrumentData } from "./index";
export const instrumentData: InstrumentData = require("../instruments.yaml");

22
src/index.ts Normal file

@ -0,0 +1,22 @@
export function assertNever(x: never): never {
throw new Error(`Unexhaustive condition leading to value: ${x}`);
}
// Durations:
// - Constant means that the sound plays for the same amount of time
// regardless of the length of the note in Bosca Ceoil.
// - Infinite means that the sound plays for however arbitrarily long
// the Bosca Ceoil note is.
// - The others are finite times, meaning that the sound will play up to
// a max time, but after that point, it will go silent regardless of how
// long the note is in Bosca Ceoil.
export interface Instrument {
index: number;
duration: "instant" | "mini" | "short" | "mid" | "long" | "extended" | "mega" | "constant" | "infinite";
release: number;
category: Array<string>;
}
export type InstrumentData = { [key: string]: Instrument };
export const notes = ["C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9"];

675
src/player.tsx Normal file

@ -0,0 +1,675 @@
require("@material/react-button/dist/button.css");
require("@material/react-select/dist/select.css");
require("@material/react-text-field/dist/text-field.css");
require("@material/react-menu-surface/dist/menu-surface.css");
require("@material/react-dialog/dist/dialog.css");
import { store, view } from "react-easy-state";
import tone from "tone";
import React from "react";
import ReactDom from "react-dom";
import Button from "@material/react-button";
import Select, { Option } from "@material/react-select";
import TextField, { Input } from "@material/react-text-field";
import MenuSurface, { Corner } from "@material/react-menu-surface";
import Dialog, { DialogTitle, DialogContent, DialogFooter, DialogButton } from "@material/react-dialog";
import { changeSampler, getSampler } from "./audio";
import { instrumentData } from "./data";
import { assertNever } from "./index";
let playing = false;
const letters = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const chords = [1, 2, 3, 4, 5, 6, 7, 8, 9];
function fromMaybes<T>(maybes: Array<T | null | undefined>, fallback: T) {
for (const value of maybes) {
if (value !== null && value !== undefined) {
return value;
}
}
return fallback;
}
class Song {
meta: SongMeta = new SongMeta();
effects: Effects = new Effects();
patterns: Array<Pattern> = [new Pattern()];
channels: Array<Channel> = [new Channel()];
playing: boolean = false;
toggleAudio(): void {
if (this.playing) {
tone.Transport.stop();
tone.Transport.position = "0";
} else {
resumeAudioContext();
tone.Transport.loopEnd = "1m";
tone.Transport.loop = true;
}
tone.Transport.toggle(0);
this.playing = !this.playing;
}
getEffect(effect: keyof Effects, channel: number, pattern: number): number {
return fromMaybes(
[
this.patterns[pattern].effects[effect],
this.channels[channel].effects[effect],
],
this.effects[effect]
);
}
getEffectBy(effect: keyof Effects, level: "song" | "channel" | "pattern", index: number = 0) {
switch (level) {
case "song":
return this.effects[effect];
case "channel":
return this.channels[index].effects[effect];
case "pattern":
return this.patterns[index].effects[effect];
}
}
setEffectBy(effect: keyof Effects, value: number | undefined, level: "song" | "channel" | "pattern", index: number = 0) {
switch (level) {
case "song":
if (value === undefined) {
throw new Error("Song-level effects cannot be undefined");
}
this.effects[effect] = value;
for (let channel = 0; channel < this.channels.length; channel++) {
for (const pattern of this.channels[channel].patterns) {
if (pattern !== null) {
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, channel, pattern));
}
}
}
break;
case "channel":
this.channels[index].effects[effect] = value;
for (const pattern of this.channels[index].patterns) {
if (pattern !== null) {
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, index, pattern));
}
}
break;
case "pattern":
this.patterns[index].effects[effect] = value;
for (let channel = 0; channel < this.channels.length; channel++) {
for (const pattern of this.channels[channel].patterns) {
if (pattern === index) {
this.patterns[pattern].pipeline.setEffect(effect, this.getEffect(effect, channel, index));
}
}
}
break;
}
}
setInstrument(value: string, pattern: number): void {
this.patterns[pattern].instrument = value;
changeSampler(this.patterns[pattern].pipeline.sampler, value);
}
setBpm(value: number): void {
this.meta.bpm = value;
tone.Transport.bpm.value = song.meta.bpm;
}
setSwing(value: number): void {
this.meta.swing = value;
tone.Transport.swing = song.meta.swing;
tone.Transport.swingSubdivision = "16n";
}
scheduleNote(channel: number, pattern: number, chord: number, letter: string, index: number, length: number, noteCell: NoteCell) {
this.unscheduleNote(channel, pattern, chord, letter, index);
// console.log(`Song.scheduleNote(chord=${chord}, letter=${letter}, index=${index}, length=${length})`);
this.patterns[pattern].notes[chord][letter][index].length = length;
this.patterns[pattern].notes[chord][letter][index].scheduledEvent = tone.Transport.schedule(
time => {
this.patterns[pattern].pipeline.sampler.triggerAttackRelease(
`${letter}${chord}`, tone.Time("16n") * length, time
);
tone.Draw.schedule(() => {
noteCell.setState({ playing: true });
setTimeout(() => {
noteCell.setState({ playing: false });
}, 100 * length);
}, time);
},
`0:0:${index}`
);
}
unscheduleNote(channel: number, pattern: number, chord: number, letter: string, index: number) {
// console.log(`Song.unscheduleNote(chord=${chord}, letter=${letter}, index=${index}, length=${length})`);
this.patterns[pattern].notes[chord][letter][index].length = 0;
const schedulee = this.patterns[pattern].notes[chord][letter][index].scheduledEvent;
if (schedulee !== null) {
tone.Transport.clear(schedulee);
this.patterns[pattern].notes[chord][letter][index].scheduledEvent = null;
}
}
}
class SongMeta {
bpm: number = 120;
swing: number = 0;
}
class Effects {
volume: number = 100;
resonance: number = 0;
dampening: number = 0;
delay: number = 0;
chorus: number = 0;
reverb: number = 0;
distortion: number = 0;
lowBoost: number = 0;
compressor: number = 0;
highPass: number = 0;
}
class Pipeline {
sampler: tone.Sampler;
volume = new tone.Volume(0);
lowPass = new tone.LowpassCombFilter(0, 0);
delayEffect = new tone.FeedbackDelay(0, 0);
chorusEffect = new tone.Chorus();
reverbEffect = new tone.Freeverb(0, 3000);
distortionEffect = new tone.BitCrusher(4);
lowBoostEffect = new tone.Filter(0, "lowshelf");
compressorEffect = new tone.Compressor(0);
highPassEffect = new tone.Filter(0, "highpass");
constructor(instrument: string = "midi.piano1") {
this.sampler = getSampler(instrument);
this.chorusEffect.wet.value = 0;
this.reverbEffect.wet.value = 0;
this.distortionEffect.wet.value = 0;
this.sampler.chain(
this.volume,
this.lowPass,
this.delayEffect,
this.chorusEffect,
this.reverbEffect,
this.distortionEffect,
this.lowBoostEffect,
this.compressorEffect,
this.highPassEffect,
tone.Master,
);
}
setEffect(effect: keyof Effects, value: number): void {
switch (effect) {
case "volume":
if (value === 0) {
this.volume.mute = true;
} else {
this.volume.volume.value = (value - 100) / 5;
this.volume.mute = false;
}
break;
case "resonance":
this.lowPass.resonance.value = value;
break;
case "dampening":
this.lowPass.dampening.value = value;
break;
case "delay":
this.delayEffect.delayTime.value = value === 0 ? 0 : tone.Time("8n") * 2 * value;
this.delayEffect.feedback.value = 0.15 * 2 * value;
break;
case "chorus":
this.chorusEffect.wet.value = value;
break;
case "reverb":
this.reverbEffect.roomSize.value = value * 0.9;
this.reverbEffect.wet.value = value;
break;
case "distortion":
this.distortionEffect.wet.value = value === 0 ? 0 : 1;
this.distortionEffect.bits = value;
break;
case "lowBoost":
this.lowBoostEffect.frequency.value = value;
this.lowBoostEffect.gain.value = value === 0 ? 0 : 20;
break;
case "compressor":
this.compressorEffect.threshold.value = value;
break;
case "highPass":
this.highPassEffect.frequency.value = value;
break;
default:
assertNever(effect);
}
}
}
class Pattern {
effects: Partial<Effects> = {};
notes: { [key: number]: { [key: string]: Array<Note> } } = {};
pipeline: Pipeline;
constructor(public instrument: string = "midi.piano1") {
this.pipeline = new Pipeline(this.instrument);
for (const chord of chords) {
this.notes[chord] = {};
for (const letter of letters) {
this.notes[chord][letter] = Array.from({ length: 16 }, () => ({ length: 0, scheduledEvent: null }));
}
}
}
}
class Channel {
effects: Partial<Effects> = {};
patterns: Array<number | null>;
constructor(patterns: Array<number | null> = [0]) {
this.patterns = patterns;
}
}
const song: Song = store(new Song());
@view
class EffectChoice extends React.Component<{
effect: keyof Effects,
mode: "song" | "channel" | "pattern",
modeIndex: number,
min: number,
max: number,
step: number,
}> {
getLabel(): string {
switch (this.props.effect) {
case "chorus":
return "Chorus";
case "compressor":
return "Compressor";
case "dampening":
return "Dampening";
case "delay":
return "Delay";
case "distortion":
return "Distortion";
case "highPass":
return "High pass";
case "lowBoost":
return "Low boost";
case "resonance":
return "Resonance";
case "reverb":
return "Reverb";
case "volume":
return "Volume";
default:
return assertNever(this.props.effect);
}
}
render(): React.ReactNode {
return <TextField label={this.getLabel()}>
<Input
required={this.props.mode === "song"}
type="number"
min={this.props.min}
max={this.props.max}
step={this.props.step}
value={song.getEffectBy(this.props.effect, this.props.mode, this.props.modeIndex)}
// @ts-ignore
onChange={e => {
if (this.props.mode !== "song" && e.currentTarget.value === "") {
song.setEffectBy(this.props.effect, undefined, this.props.mode, this.props.modeIndex);
return;
}
const newValue = parseFloat(e.currentTarget.value);
if (!isNaN(newValue)) {
song.setEffectBy(this.props.effect, newValue, this.props.mode, this.props.modeIndex);
} else if (e.currentTarget.value === undefined && this.props.mode !== "song") {
song.setEffectBy(this.props.effect, undefined, this.props.mode, this.props.modeIndex);
}
}}
/>
</TextField>;
}
}
@view
class EffectPanel extends React.Component<{
mode: "song" | "channel" | "pattern",
modeIndex: number,
}> {
state = {
open: false,
anchorElement: undefined,
};
getButtonText(): string {
switch (this.props.mode) {
case "song":
return "Song";
case "channel":
return "Channel";
case "pattern":
return "Pattern"
default:
return assertNever(this.props.mode);
}
}
setAnchorElement = (element: HTMLDivElement) => {
if (this.state.anchorElement) {
return;
}
this.setState({ anchorElement: element });
}
render() {
return (
<div
className="mdc-menu-surface--anchor effect-panel"
ref={this.setAnchorElement}
>
<Button raised onClick={() => this.setState({ open: true })}>{this.getButtonText()}</Button>
<MenuSurface
open={this.state.open}
anchorCorner={Corner.BOTTOM_LEFT}
onClose={() => this.setState({ open: false })}
anchorElement={this.state.anchorElement}
>
<div className="effect-panel-popup">
<EffectChoice effect="volume" min={0} max={200} step={5} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="resonance" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="dampening" min={0} max={3000} step={100} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="delay" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="chorus" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="reverb" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="distortion" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="lowBoost" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="compressor" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
<EffectChoice effect="highPass" min={0} max={1} step={0.05} mode={this.props.mode} modeIndex={this.props.modeIndex} />
</div>
</MenuSurface>
</div>
);
}
}
@view
class PlayButton extends React.Component {
onClick() {
song.toggleAudio();
}
render(): React.ReactNode {
return <Button id="playButton" onClick={() => this.onClick()} raised>
{song.playing ? "Stop" : "Play"}
</Button>;
}
}
@view
class Help extends React.Component {
state = {
displayed: false,
};
render(): React.ReactNode {
return <a>
<Button id="helpButton" onClick={() => this.setState({displayed: true})} raised>Help</Button>
<Dialog open={this.state.displayed} onClose={() => this.setState({displayed: false})}>
<DialogTitle>Help</DialogTitle>
<DialogContent>
<div>
<ul>
<li>Click in a box to add or remove a note.</li>
<li>Shift click to extend a note, and ctrl-shift click to shorten it.</li>
<li>Press the space bar to play or stop the song.</li>
<li>After clicking in an entry field, use the arrow keys to quickly change the value.</li>
</ul>
</div>
</DialogContent>
<DialogFooter>
<DialogButton action="close" isDefault>Close</DialogButton>
</DialogFooter>
</Dialog>
</a>;
}
}
@view
class MetaWorkspace extends React.Component<{ activeChannel: number, activePattern: number }> {
render(): React.ReactNode {
const instruments = [];
for (const [instrument, { category }] of Object.entries(instrumentData)) {
instruments.push(
<Option key={instrument} value={instrument}>{category.join(" > ")}</Option>
);
}
return <div id="metaWorkspace">
<PlayButton />
<Select
label="Pattern instrument"
value={song.patterns[this.props.activePattern].instrument}
onChange={e => {
song.setInstrument(e.currentTarget.value, this.props.activeChannel);
}}
>
{instruments}
</Select>
<TextField label="BPM">
<Input
required
type="number"
min={10}
max={220}
step={5}
value={song.meta.bpm}
// @ts-ignore
onChange={e => {
const value = parseInt(e.currentTarget.value);
if (isNaN(value)) {
return;
}
song.setBpm(value);
}}
/>
</TextField>
<TextField label="Swing">
<Input
required
type="number"
min={0}
max={1}
step={0.05}
value={song.meta.swing}
// @ts-ignore
onChange={e => {
const value = parseFloat(e.currentTarget.value);
if (isNaN(value)) {
return;
}
song.setSwing(value);
}}
/>
</TextField>
<EffectPanel mode="song" modeIndex={0} />
<EffectPanel mode="channel" modeIndex={0} />
<EffectPanel mode="pattern" modeIndex={0} />
<Help />
</div>;
}
}
@view
class NoteCell extends React.Component<{ channel: number, pattern: number, chord: number, letter: string, index: number }> {
state = {
chord: this.props.chord,
letter: this.props.letter,
index: this.props.index,
length: 0,
playing: false,
};
onClick(chord: number, letter: string, index: number, withShift: boolean, withCtrl: boolean) {
let length = this.state.length;
if (withShift && withCtrl) {
length = Math.max(length - 1, 0);
} else if (withShift) {
length = Math.max(Math.min(length + 1, 16), 2);
} else if (length > 0) {
length = 0;
} else {
length = 1;
}
if (length > 0 && length !== this.state.length) {
song.scheduleNote(this.props.channel, this.props.pattern, chord, letter, index, length, this);
} else if (length === 0) {
song.unscheduleNote(this.props.channel, this.props.pattern, chord, letter, index);
}
this.setState({ length: length });
}
render(): React.ReactNode {
const classes = [`note-${this.state.letter}`];
if (this.state.length > 0) {
classes.push("active");
}
if (this.state.length > 1) {
classes.push("noteLong");
}
if (this.state.playing) {
if (this.state.length > 1) {
classes.push("playingLong");
} else {
classes.push("playing");
}
}
return <td
id={`${this.state.letter}-${this.state.chord}-${this.state.index}`}
className={classes.join(" ")}
onClick={event => {
this.onClick(this.state.chord, this.state.letter, this.state.index, event.shiftKey, event.ctrlKey);
}}
>
{this.state.length === 0 ? "" : this.state.length}
</td>;
}
}
@view
class PatternWorkspace extends React.Component<{ channel: number, pattern: number }> {
render(): React.ReactNode {
let rows = [];
for (const chord of chords.slice().reverse()) {
let first = true;
for (const letter of letters.slice().reverse()) {
let items = [];
if (first) {
first = false;
items.push(<th key={`chordHeader-${chord}`} rowSpan={letters.length}>{chord}</th>);
}
items.push(<th key={`letterHeader-${chord}-${letter}`}>{letter}</th>);
for (const i of Array(16).keys()) {
items.push(
<NoteCell
key={`cell-${chord}-${letter}-${i}`}
channel={this.props.channel}
pattern={this.props.pattern}
chord={chord}
letter={letter}
index={i}
/>
);
}
rows.push(<tr key={`row-${chord}-${letter}`}>{items}</tr>);
}
}
return <div id="patternWorkspace" className="no-select">
<table id="pattern"><tbody>{rows}</tbody></table>
</div>;
}
}
@view
class App extends React.Component {
state = {
activeChannel: 0,
activePattern: 0,
};
samplers: { [key: number]: tone.Sampler } = {};
render(): React.ReactNode {
return <div id="app">
<MetaWorkspace activeChannel={this.state.activeChannel} activePattern={this.state.activePattern} />
<PatternWorkspace channel={this.state.activeChannel} pattern={this.state.activePattern} />
</div>;
}
}
interface Note {
length: number;
scheduledEvent: number | null;
}
let patterns: { [key: number]: { [key: string]: Array<Note> } } = {};
for (const chord of chords) {
patterns[chord] = {};
for (const letter of letters) {
patterns[chord][letter] = Array.from({ length: 16 }, () => { return { length: 0, scheduledEvent: null }; });
}
}
function toggleAudio() {
if (playing) {
tone.Transport.stop();
tone.Transport.position = "0";
} else {
resumeAudioContext();
tone.Transport.loopEnd = "1m";
tone.Transport.loop = true;
}
tone.Transport.toggle(0);
playing = !playing;
}
function resumeAudioContext() {
let ac = (tone as any).context;
if (ac.state !== "running") {
ac.resume();
}
}
function onLoad() {
ReactDom.render(<App />, document.querySelector("#root"));
document.onkeypress = event => {
if (event.keyCode === 32) {
song.toggleAudio();
event.stopPropagation();
event.preventDefault();
}
};
const centralRow = document.getElementById("F-5-0");
if (centralRow !== null) {
centralRow.scrollIntoView({ "behavior": "smooth", "block": "center" });
}
}
window.onload = onLoad;

21
tasks/deploy.ts Normal file

@ -0,0 +1,21 @@
import ghPages from "gh-pages";
import path from "path";
const root = `${path.dirname(process.argv[1])}/..`;
ghPages.publish(
root,
{
"src": [
"index.html",
"public/*.css",
"public/*.js",
"audio/**/*.ogg",
]
},
err => {
if (err !== undefined) {
console.log(`Failure: ${err}`);
}
}
);

59
tasks/samples.ts Normal file

@ -0,0 +1,59 @@
import fs from "fs";
import path from "path";
import process from "process";
import child_process from "child_process";
import yaml from "js-yaml";
import { InstrumentData, notes } from "../src/index";
if (process.argv.length < 3) {
console.error("Must specify location of Bosca Ceoil clone with CLI export functionality.");
process.exit(1);
}
const root = `${path.dirname(process.argv[1])}/..`;
const tmp = `${root}/tmp`;
const boscaCeoilDir = process.argv[2];
const instrumentData: InstrumentData = yaml.safeLoad(fs.readFileSync(`${root}/instruments.yaml`).toString());
const song = fs.readFileSync(`${root}/audio/main.ceol`).toString().split(",");
if (!fs.existsSync(tmp)) {
fs.mkdirSync(tmp);
}
for (const [instrument, { index }] of Object.entries(instrumentData)) {
const instrumentDir = `${root}/audio/${instrument}`;
if (!fs.existsSync(instrumentDir)) {
fs.mkdirSync(instrumentDir);
}
for (const note of notes) {
if (fs.existsSync(`${instrumentDir}/${note.toLowerCase()}.ogg`)) {
continue;
}
if (!fs.existsSync(`${tmp}/${instrument}-${note.toLowerCase()}.wav`)) {
song[8] = index.toString();
song[20] = (0 + 12 * notes.indexOf(note)).toString();
fs.writeFileSync(`${tmp}/${instrument}.ceol`, song.join(","));
console.log(`${instrument}: ${note}`);
child_process.execSync(
`adl application.xml -- ${tmp}/main.ceol --export ${tmp}/${instrument}-${note.toLowerCase()}.wav`,
{ cwd: boscaCeoilDir },
);
}
child_process.execSync(
`sox ${tmp}/${instrument}-${note.toLowerCase()}.wav -C 5 ${instrumentDir}/${note.toLowerCase()}.ogg silence 0 1 0.1 0.1%`,
{ cwd: root },
);
fs.unlinkSync(`${tmp}/${instrument}-${note.toLowerCase()}.wav`);
}
}
if (fs.existsSync(`${tmp}/main.ceol`)) {
fs.unlinkSync(`${tmp}/main.ceol`);
}

25
tsconfig.json Normal file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es6",
"outDir": "out",
"lib": [
"es6",
"dom"
],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"jsx": "react",
"moduleResolution": "node",
"esModuleInterop": true,
"experimentalDecorators": true,
},
"exclude": [
"node_modules",
"tasks",
"tmp"
],
}

15
tslint.json Normal file

@ -0,0 +1,15 @@
{
"rules": {
"no-string-throw": true,
"no-unused-expression": true,
"no-duplicate-variable": true,
"curly": true,
"class-name": true,
"semicolon": [
true,
"always"
],
"triple-equals": true
},
"defaultSeverity": "warning"
}

32
webpack.config.js Normal file

@ -0,0 +1,32 @@
path = require("path");
module.exports = {
entry: {
index: './src/player.tsx'
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.yaml$/,
use: 'js-yaml-loader',
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
},
devtool: 'inline-source-map',
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'public')
}
};