├── .prettierrc ├── src ├── helpers.janet ├── init.janet ├── globals.janet ├── params.janet ├── euclid.janet ├── cpp │ └── ladder_filter.cpp ├── evaluator.janet ├── runner.janet ├── dsl_helpers.janet ├── instruments.janet ├── dsl.janet ├── harmony.janet └── driver.cpp ├── .nvmrc ├── ui ├── css │ ├── drum_sampler.css │ ├── breakbeat-sampler.css │ ├── synth.css │ ├── fonts │ │ ├── Pixeled.ttf │ │ └── ibm_vga8.woff2 │ ├── fonts.css │ ├── knob.css │ └── main.css ├── generate_params.ts ├── midi_inst.ts ├── effect.ts ├── errors.ts ├── panner.ts ├── gain.ts ├── master_out.ts ├── distortion.ts ├── utils.ts ├── line_in_inst.ts ├── oscillator.ts ├── constant.ts ├── compressor.ts ├── ladder_filter.ts ├── biquad.ts ├── loop_instrument.ts ├── delay.ts ├── lfo.ts ├── envelope.ts ├── crossfader.ts ├── chorus.ts ├── worklets │ ├── loop_worker.js │ └── filter_worklet.js ├── wire.ts ├── scope.ts ├── midi_manager.ts ├── reverb.ts ├── breakbeat_instrument.ts ├── sampler_instrument.ts ├── index.ts ├── loop_manager.ts ├── sineInstrument.ts ├── keyboard.ts ├── editor.ts ├── instruments.ts ├── pitched_sampler.ts ├── tutor.ts ├── audio.ts ├── sampler_generic.ts ├── dark_theme.ts └── knob.ts ├── .prettierignore ├── scripts ├── create-docs.sh ├── build-jimage.sh └── create-parameters.ts ├── test ├── python │ ├── REAME.md │ └── test.py ├── harmony_test.janet ├── pegtest.janet └── dsltest.janet ├── .gitignore ├── eslint.config.mjs ├── project.janet ├── dist ├── index.html └── about.html ├── docs ├── api_template.mustache ├── README.md └── api.md ├── README.md └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/helpers.janet: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.18.1 2 | -------------------------------------------------------------------------------- /ui/css/drum_sampler.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/generate_params.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/css/breakbeat-sampler.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/css/synth.css: -------------------------------------------------------------------------------- 1 | .synth-container { 2 | } 3 | -------------------------------------------------------------------------------- /src/init.janet: -------------------------------------------------------------------------------- 1 | (import ./evaluator :export true) 2 | (import ./runner :export true) 3 | -------------------------------------------------------------------------------- /ui/css/fonts/Pixeled.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gwegash/trane/HEAD/ui/css/fonts/Pixeled.ttf -------------------------------------------------------------------------------- /ui/css/fonts/ibm_vga8.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gwegash/trane/HEAD/ui/css/fonts/ibm_vga8.woff2 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | docs 5 | ui/worklets 6 | dist 7 | test 8 | -------------------------------------------------------------------------------- /ui/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "ibmvga"; 3 | src: url("./fonts/ibm_vga8.woff2"); 4 | } 5 | -------------------------------------------------------------------------------- /scripts/create-docs.sh: -------------------------------------------------------------------------------- 1 | set -euxo pipefail 2 | 3 | ./jpm_tree/bin/documentarian -T docs/api_template.mustache -o docs/api.md -d src/ -L ../ 4 | -------------------------------------------------------------------------------- /test/python/REAME.md: -------------------------------------------------------------------------------- 1 | ``` 2 | python3 -m venv env 3 | 4 | source env/bin/activate 5 | 6 | pip install -r requirements 7 | 8 | pytest test 9 | ``` 10 | -------------------------------------------------------------------------------- /src/globals.janet: -------------------------------------------------------------------------------- 1 | (def *lloops* :lloops) 2 | (def *instruments* :instruments) 3 | (def *instrument_counter* :instruments_counter) 4 | (def *bpm* :bpm) 5 | (def *self* :self) 6 | -------------------------------------------------------------------------------- /ui/css/knob.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | width: max-content; 3 | margin: 0; 4 | } 5 | 6 | .knob-container { 7 | display: flex; 8 | align-items: center; 9 | flex-direction: column; 10 | padding: 2px; 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/samples 2 | dist/impulses 3 | dist/*.js 4 | dist/*.css 5 | dist/tracks 6 | dist/*.map 7 | build/*.jimage 8 | build/*.js 9 | node_modules 10 | test/python/env 11 | test/python/__pycache__ 12 | scripts/create-parameters.js 13 | jpm_tree 14 | 15 | *.tested 16 | *.wasm 17 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals" 2 | import pluginJs from "@eslint/js" 3 | import tseslint from "typescript-eslint" 4 | 5 | export default [ 6 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 7 | { languageOptions: { globals: globals.browser } }, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | ] 11 | -------------------------------------------------------------------------------- /test/harmony_test.janet: -------------------------------------------------------------------------------- 1 | (use judge) 2 | 3 | (use ../src/harmony) 4 | 5 | (test ((chord :c4 :min) [0 1 2]) @[48 51 55]) 6 | (test ((chord :c4 :min) 0) 48) 7 | (test ((chord :c4 :min) [-3 -2 -1]) @[36 39 43]) 8 | 9 | (test ((scale :C3 :minor) [0 1 2 3 4 5 6]) @[36 38 39 41 43 44 46]) 10 | (test ((scale :C3 :minor) 0) 36) 11 | 12 | (test (note 1) 1) 13 | -------------------------------------------------------------------------------- /scripts/build-jimage.sh: -------------------------------------------------------------------------------- 1 | set -euxo pipefail 2 | 3 | janet -c src/init.janet build/trane.jimage 4 | 5 | emcc -O0 -o build/wasm.js -I build/janet build/janet/janet.c src/driver.cpp --embed-file build/trane.jimage@trane.jimage -lembind -s "EXPORTED_FUNCTIONS=['_main']" -s "EXPORTED_RUNTIME_METHODS=['FS', 'UTF8ToString']" -s ALLOW_MEMORY_GROWTH=1 -s AGGRESSIVE_VARIABLE_ELIMINATION=1 -s MODULARIZE -s EXPORT_ES6 -s SINGLE_FILE 6 | -------------------------------------------------------------------------------- /project.janet: -------------------------------------------------------------------------------- 1 | # project.janet 2 | (declare-project 3 | :name "trane" 4 | :description "A musical browser thing" 5 | :dependencies [ 6 | {:url "https://github.com/ianthehenry/judge.git" 7 | :tag "v2.9.0"} 8 | {:url "https://github.com/pyrmont/documentarian"} 9 | ]) 10 | 11 | (task "test" [] (shell "jpm_tree/bin/judge ./test")) 12 | 13 | (declare-source 14 | :source ["src/instruments.janet" "src/dsl.janet" "src/harmony.janet"]) 15 | -------------------------------------------------------------------------------- /ui/midi_inst.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | 3 | class MIDIInst extends Effect { 4 | //TODO this isn't really an effect. 5 | 6 | static friendlyName = "midi" 7 | 8 | constructor(context: AudioContext, parentEl: Element, name: string) { 9 | super(context, parentEl, name) 10 | this.inputNode = undefined //should error if someone tries to wire this to something :) 11 | this.outputNode = undefined //should error if someone tries to wire this to something :) 12 | } 13 | } 14 | 15 | export { MIDIInst } 16 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | [trane] 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

[trane]

17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/api_template.mustache: -------------------------------------------------------------------------------- 1 | # {{project-name}} API 2 | 3 | {{#project-doc}} 4 | {{&project-doc}} 5 | 6 | {{/project-doc}} 7 | {{#modules}} 8 | ## {{ns}} 9 | 10 | {{#items}}{{^first}}, {{/first}}[{{name}}](#{{in-link}}){{/items}} 11 | 12 | {{#doc}} 13 | {{&doc}} 14 | 15 | {{/doc}} 16 | {{#items}} 17 | ### {{name}} 18 | 19 | **{{kind}}** {{#private?}}| **private**{{/private?}} {{#link}}| [source][{{num}}]{{/link}} 20 | 21 | {{#sig}} 22 | ```janet 23 | {{&sig}} 24 | ``` 25 | {{/sig}} 26 | 27 | {{&docstring}} 28 | 29 | {{#link}} 30 | [{{num}}]: {{link}} 31 | {{/link}} 32 | 33 | {{/items}} 34 | {{/modules}} 35 | -------------------------------------------------------------------------------- /ui/effect.ts: -------------------------------------------------------------------------------- 1 | import { GraphNode } from "./instruments" 2 | import { CrossfaderNode } from "./crossfader" 3 | 4 | class Effect extends GraphNode { 5 | inputNode: AudioNode 6 | 7 | createWetDry() { 8 | /* 9 | *Use after the hooking up of inputNode and outputNode, before resolveParams 10 | *this.inputNode must be a gainNode with value one (Ie an untouched signal) 11 | */ 12 | this.webAudioNodes.crossfaderNode = new CrossfaderNode(this.audioContext) 13 | 14 | this.inputNode.connect(this.webAudioNodes.crossfaderNode.getRight()) 15 | this.outputNode.connect(this.webAudioNodes.crossfaderNode.getLeft()) 16 | 17 | this.outputNode = this.webAudioNodes.crossfaderNode.getOutput() 18 | } 19 | } 20 | 21 | export { Effect } 22 | -------------------------------------------------------------------------------- /ui/errors.ts: -------------------------------------------------------------------------------- 1 | class OutputChannel { 2 | private _target: HTMLElement | null = null 3 | 4 | set target(value: HTMLElement | null) { 5 | this._target = value 6 | } 7 | 8 | clearErrors() { 9 | while (this._target?.firstChild) { 10 | this._target.removeChild(this._target.firstChild) 11 | } 12 | } 13 | 14 | print(text: string, isErr: boolean) { 15 | //TODO printing other things 16 | if (this._target == null) { 17 | if (isErr) { 18 | console.error(text) 19 | } else { 20 | console.log(text) 21 | } 22 | } else { 23 | const span = document.createElement("span") 24 | span.classList.toggle("janet-err", isErr) 25 | span.appendChild(document.createTextNode(text)) 26 | span.appendChild(document.createTextNode("\n")) 27 | this._target.prepend(span) 28 | } 29 | } 30 | } 31 | 32 | export { OutputChannel } 33 | -------------------------------------------------------------------------------- /ui/panner.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | 4 | class Panner extends Effect { 5 | static friendlyName = "panner" 6 | 7 | params = [{ name: "pan", path: "pannerNode.pan", min: -1, max: 1 }] 8 | 9 | constructor(context: AudioContext, parentEl: Element, name: string) { 10 | super(context, parentEl, name) 11 | this.webAudioNodes.pannerNode = context.createStereoPanner() 12 | this.inputNode = this.webAudioNodes.pannerNode 13 | this.outputNode = this.webAudioNodes.pannerNode 14 | 15 | this.knobsEl = document.createElement("div") 16 | this.knobsEl.className = "knobs" 17 | this.el.appendChild(this.knobsEl) 18 | 19 | this.resolveParams() //always call me after settings up your webAudioNodes! 20 | this.setupKnobs() 21 | } 22 | 23 | async setup({ pan }) { 24 | this.updateParamIfChanged(0, pan) 25 | return this 26 | } 27 | } 28 | 29 | export { Panner } 30 | -------------------------------------------------------------------------------- /ui/gain.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | 4 | class Gain extends Effect { 5 | static friendlyName = "gain" 6 | 7 | params = [ 8 | { 9 | name: "gain", 10 | path: "gainNode.gain", 11 | min: 0.001, 12 | max: 10.0, 13 | logScale: true, 14 | }, 15 | ] 16 | 17 | constructor(context: AudioContext, parentEl: Element, name: string) { 18 | super(context, parentEl, name) 19 | this.webAudioNodes.gainNode = context.createGain() 20 | this.inputNode = this.webAudioNodes.gainNode 21 | this.outputNode = this.webAudioNodes.gainNode 22 | //this.webAudioNodes.gainNode.gain.setValueAtTime(1.0, context.currentTime); 23 | 24 | this.knobsEl = document.createElement("div") 25 | this.knobsEl.className = "knobs" 26 | this.el.appendChild(this.knobsEl) 27 | 28 | this.resolveParams() //always call me after settings up your webAudioNodes! 29 | this.setupKnobs() 30 | } 31 | 32 | async setup({ gain }) { 33 | this.updateParamIfChanged(0, gain) 34 | return this 35 | } 36 | } 37 | 38 | export { Gain } 39 | -------------------------------------------------------------------------------- /ui/master_out.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | 3 | class Output extends Effect { 4 | //TODO this isn't really an effect. 5 | 6 | static friendlyName = "out" 7 | 8 | params = [ 9 | { 10 | name: "gain", 11 | path: "gainNode.gain", 12 | min: 0.001, 13 | max: 1.0, 14 | logScale: true, 15 | }, 16 | ] 17 | 18 | constructor(context: AudioContext, parentEl: Element, name: string) { 19 | super(context, parentEl, name) 20 | this.webAudioNodes.gainNode = this.audioContext.createGain() 21 | this.webAudioNodes.gainNode.connect(context.destination) 22 | this.webAudioNodes.gainNode.gain.setValueAtTime(0.75, context.currentTime) 23 | 24 | this.inputNode = this.webAudioNodes.gainNode 25 | this.outputNode = undefined //should error if someone tries to wire this to something :) 26 | 27 | this.knobsEl = document.createElement("div") 28 | this.knobsEl.className = "knobs" 29 | this.el.appendChild(this.knobsEl) 30 | 31 | this.resolveParams() //always call me after settings up your webAudioNodes! 32 | this.setupKnobs() 33 | } 34 | } 35 | 36 | export { Output } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Trane](https://lisp.trane.studio/?t=tracks/etude.janet) 2 | 3 | ![trane screenshot](https://lisp.trane.studio/tracks/screenshot.png) 4 | 5 | A musical lisp thing. 6 | 7 | This is still very much a WIP. 8 | 9 | ## [docs](docs/) / [examples](https://github.com/gwegash/tracks) 10 | 11 | ## Installing 12 | 13 | You'll need a copy of [nvm](https://github.com/nvm-sh/nvm) and [emsdk](https://github.com/emscripten-core/emsdk) 14 | 15 | ``` 16 | nvm use 17 | npm install 18 | ./scripts/build-jimage 19 | ``` 20 | 21 | ## Running 22 | 23 | ``` 24 | npm run dev 25 | ``` 26 | 27 | ### Current known issues/bugs 28 | 29 | - The attack/release knobs sometimes freeze up on chrome. 30 | 31 | ## Acknowledgements 32 | 33 | - Ian Henry and his wonderful book [Janet For Mortals](https://janet.guide). The js-janet interop is a modified version to the one running in https://toodle.studio 34 | - Sam Aaron and his work on [Sonic Pi](https://sonic-pi.net/). Quite a few features of trane originate there. 35 | - [Calvin Rose](https://bakpakin.com/) for creating the Janet Language 36 | - Thanks also to all the contributors to [Janet](https://janet-lang.org) 37 | -------------------------------------------------------------------------------- /ui/distortion.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | 3 | const DEG = Math.PI / 180 4 | 5 | function makeDistortionCurve(k = 50) { 6 | const n_samples = 44100 7 | const curve = new Float32Array(n_samples) 8 | curve.forEach((_, i) => { 9 | const x = (i * 2) / n_samples - 1 10 | curve[i] = ((3 + k) * x * 20 * DEG) / (Math.PI + k * Math.abs(x)) 11 | }) 12 | return curve 13 | } 14 | 15 | class Distortion extends Effect { 16 | static friendlyName = "distortion" 17 | distortionCurve: Float32Array 18 | 19 | constructor(context: AudioContext, parentEl: Element, name: string) { 20 | super(context, parentEl, name) 21 | this.webAudioNodes.waveshaper = context.createWaveShaper() 22 | this.inputNode = this.webAudioNodes.waveshaper 23 | this.outputNode = this.webAudioNodes.waveshaper 24 | } 25 | 26 | async setup({ amount }) { 27 | let parsedAmount = parseFloat(amount) 28 | if (!parsedAmount) { 29 | parsedAmount = 10 30 | } 31 | 32 | this.distortionCurve = makeDistortionCurve(parsedAmount) 33 | this.webAudioNodes.waveshaper.curve = this.distortionCurve 34 | } 35 | } 36 | 37 | export { Distortion } 38 | -------------------------------------------------------------------------------- /ui/utils.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage" 2 | 3 | function lerp(start, end, x) { 4 | return (1 - x) * start + x * end 5 | } 6 | 7 | function note_to_frequency(note: number) { 8 | return 440.0 * 2.0 * Math.pow(2, (note - 69.0) / 12.0) 9 | } 10 | 11 | const resolvePath = (obj, pathArr: Array) => { 12 | if (pathArr.length == 0) { 13 | return obj 14 | } else { 15 | const field = pathArr.shift() 16 | return resolvePath(obj[field], pathArr) 17 | } 18 | } 19 | 20 | const loadSample = async (sampleURLRaw) => { 21 | let sampleURL 22 | const localFileSplit = sampleURLRaw.split("local://") //todo cache things already given a URL 23 | if (localFileSplit.length == 2) { 24 | const fileBuffer: ArrayBuffer = await localforage.getItem(localFileSplit[1]) 25 | const sampleBlob = new Blob([fileBuffer]) 26 | sampleURL = URL.createObjectURL(sampleBlob) 27 | } else { 28 | sampleURL = sampleURLRaw 29 | } 30 | 31 | const response = await fetch(sampleURL) 32 | if (response.ok) { 33 | const buffer = await response.arrayBuffer() 34 | 35 | return buffer 36 | } 37 | } 38 | 39 | export { lerp, loadSample, note_to_frequency, resolvePath } 40 | -------------------------------------------------------------------------------- /ui/line_in_inst.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | 3 | class LineIn extends Effect { 4 | //TODO this isn't really an effect. 5 | 6 | static friendlyName = "line_in" 7 | 8 | constructor(context: AudioContext, parentEl: Element, name: string) { 9 | super(context, parentEl, name) 10 | 11 | this.webAudioNodes.gainNode = this.audioContext.createGain() 12 | this.inputNode = undefined //should error if someone tries to wire this to something :) 13 | this.outputNode = this.webAudioNodes.gainNode 14 | } 15 | 16 | async setup() { 17 | if (!this.webAudioNodes.mediaStreamNode) { 18 | const stream = await navigator.mediaDevices.getUserMedia({ 19 | audio: { 20 | autoGainControl: false, 21 | echoCancellation: false, 22 | noiseSuppression: false, 23 | latency: 0.0, 24 | channelCount: 1, 25 | }, 26 | video: false, 27 | }) 28 | this.webAudioNodes.mediaStreamNode = 29 | this.audioContext.createMediaStreamSource(stream) 30 | this.webAudioNodes.mediaStreamNode?.connect(this.webAudioNodes.gainNode) 31 | } 32 | return this 33 | } 34 | } 35 | 36 | export { LineIn } 37 | -------------------------------------------------------------------------------- /ui/oscillator.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | import { resolvePath } from "./utils" 4 | 5 | class Oscillator extends Effect { 6 | static friendlyName = "oscillator" 7 | 8 | params = [ 9 | { 10 | name: "frequency", 11 | path: "oscillatorNode.frequency", 12 | min: 10, 13 | max: 24000, 14 | logScale: true, 15 | }, 16 | ] 17 | 18 | constructor(context: AudioContext, parentEl: Element, name: string) { 19 | super(context, parentEl, name) 20 | this.webAudioNodes.oscillatorNode = context.createOscillator() 21 | this.webAudioNodes.oscillatorNode.start() 22 | this.inputNode = this.webAudioNodes.oscillatorNode 23 | this.outputNode = this.webAudioNodes.oscillatorNode 24 | 25 | this.knobsEl = document.createElement("div") 26 | this.knobsEl.className = "knobs" 27 | this.el.appendChild(this.knobsEl) 28 | 29 | this.resolveParams() //always call me after settings up your webAudioNodes! 30 | this.setupKnobs() 31 | } 32 | 33 | async setup({ wave, frequency }) { 34 | this.webAudioNodes.oscillatorNode.type = wave ? wave : "sine" 35 | this.updateParamIfChanged(0, frequency) 36 | } 37 | } 38 | 39 | export { Oscillator } 40 | -------------------------------------------------------------------------------- /test/pegtest.janet: -------------------------------------------------------------------------------- 1 | (use ../src/dsl) 2 | 3 | (def little-parser 4 | ~{ 5 | :rest (/ "~" :rest) 6 | :tie (/ "-" :tie) 7 | :note (cmt (<- (* (range "ag") (at-most 1 (+ "s" "b")) (range "28"))) ,keyword) 8 | :subdivide (group (* "[" :sequence "]")) 9 | :repetition (cmt (* (+ :subdivide :note :rest :tie) "*" (number :d+)) ,rep) 10 | :item (choice :repetition :note :subdivide :rest :tie) 11 | :sequence (+ (* :item :s :sequence) :item) 12 | :main (* :sequence -1) 13 | } 14 | ) 15 | 16 | (peg/match little-parser "cs2 ~ ~") 17 | (peg/match little-parser "cs2 cs9") 18 | (peg/match little-parser "cs2 cs7 ") 19 | (peg/match little-parser "cs2 cs7 d") 20 | (peg/match little-parser "cs2 cs7") 21 | (peg/match little-parser "[cs2 ~ cs7] ~*5") 22 | (peg/match little-parser "[cs2 cs3] cs7 cs7*5") 23 | (peg/match little-parser "[cs2 cs3 [cs3 cs5]*5] cs7 cs7") 24 | (peg/match little-parser "[cs2 cs3 [cs3 cs5]] cs7 cs7") 25 | 26 | 27 | (def test-patt 28 | ~{ 29 | :string (<- :a+) 30 | :dual (cmt (* :string "*" (number :d+)) ,rep) 31 | :sequence (choice (* :dual :s :sequence) :dual) 32 | :main (* :sequence -1) 33 | } 34 | ) 35 | 36 | (peg/match test-patt "lol*4 eh*5") 37 | (peg/match test-patt "eh*5 eh*4") 38 | -------------------------------------------------------------------------------- /ui/constant.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | import { nullGain } from "./audio" 4 | 5 | class Constant extends Effect { 6 | static friendlyName = "constant" 7 | 8 | params = [ 9 | { 10 | name: "constant", 11 | path: "constantSource.offset", 12 | min: 0.001, 13 | max: 12000.0, 14 | logScale: true, 15 | }, 16 | ] 17 | 18 | constructor(context: AudioContext, parentEl: Element, name: string) { 19 | super(context, parentEl, name) 20 | this.webAudioNodes.constantSource = context.createConstantSource() 21 | this.webAudioNodes.constantSource.connect(nullGain) 22 | this.webAudioNodes.constantSource.start() 23 | 24 | this.knobsEl = document.createElement("div") 25 | this.knobsEl.className = "knobs" 26 | this.el.appendChild(this.knobsEl) 27 | 28 | this.inputNode = this.webAudioNodes.constantSource 29 | this.outputNode = this.webAudioNodes.constantSource 30 | 31 | this.resolveParams() //always call me after settings up your webAudioNodes! 32 | this.setupKnobs() 33 | } 34 | 35 | async setup({ constant }) { 36 | this.updateParamIfChanged(0, constant) 37 | return this 38 | } 39 | } 40 | 41 | export { Constant } 42 | -------------------------------------------------------------------------------- /src/params.janet: -------------------------------------------------------------------------------- 1 | (def *inst_params* @{ 2 | :biquad @{ 3 | :frequency 0 4 | :detune 1 5 | :Q 2 6 | :gain 3 7 | } 8 | :breakbeat_sampler @{ 9 | :gain 0 10 | } 11 | :chorus @{ 12 | :rate 0 13 | :amount 1 14 | :wet-dry 2 15 | } 16 | :compressor @{ 17 | :threshold 0 18 | :knee 1 19 | :ratio 2 20 | :attack 3 21 | :release 4 22 | } 23 | :constant @{ 24 | :constant 0 25 | } 26 | :Dlay @{ 27 | :feedback 0 28 | } 29 | :gain @{ 30 | :gain 0 31 | } 32 | :ladder_filter @{ 33 | :cutoff 0 34 | :Q 1 35 | } 36 | :lfo @{ 37 | :frequency 0 38 | :magnitude 1 39 | } 40 | :looper @{ 41 | :latency 0 42 | } 43 | :out @{ 44 | :gain 0 45 | } 46 | :octaver @{ 47 | :wet-dry 0 48 | } 49 | :oscillator @{ 50 | :frequency 0 51 | } 52 | :panner @{ 53 | :pan 0 54 | } 55 | :pitched_sampler @{ 56 | :gain 0 57 | :attack 1 58 | :release 2 59 | :loop_start 3 60 | :loop_end 4 61 | } 62 | :reverb @{ 63 | :wet-dry 0 64 | } 65 | :synth @{ 66 | :gain 0 67 | :attack 1 68 | :release 2 69 | } 70 | }) -------------------------------------------------------------------------------- /src/euclid.janet: -------------------------------------------------------------------------------- 1 | (defn euclid [pulses steps] 2 | (if (> pulses steps) 3 | (array/new-filled steps 0) 4 | (do 5 | (def pattern @[]) 6 | (def counts @[]) 7 | (def remainders @[]) 8 | (var divisor (- steps pulses)) 9 | (array/concat remainders pulses) 10 | (var level 0) 11 | (while true 12 | (array/push counts (math/floor (/ divisor (get remainders level)))) 13 | (array/push remainders (% divisor (get remainders level))) 14 | (set divisor (get remainders level)) 15 | (set level (+ level 1)) 16 | (if (<= (get remainders level) 1) 17 | (break) 18 | ) 19 | ) 20 | (array/push counts divisor) 21 | 22 | (defn build [level] 23 | (case level 24 | -1 (array/push pattern nil) 25 | -2 (array/push pattern 0) 26 | (do ( 27 | for i 0 (get counts level) 28 | (build (- level 1)) 29 | ) 30 | (if (not (= (get remainders level) 0)) 31 | (build (- level 2)) 32 | ) 33 | ) 34 | ) 35 | ) 36 | 37 | (build level) 38 | (def i (index-of 0 pattern)) 39 | (array/concat (array/slice pattern i) (array/slice pattern 0 i)) 40 | ) 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /ui/compressor.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | 3 | class Compressor extends Effect { 4 | static friendlyName = "compressor" 5 | params = [ 6 | { name: "threshold", path: "compressor.threshold", min: -100, max: 0 }, 7 | { name: "knee", path: "compressor.knee", min: 0, max: 40 }, 8 | { name: "ratio", path: "compressor.ratio", min: 1, max: 20 }, 9 | { name: "attack", path: "compressor.attack", min: 0, max: 1 }, 10 | { name: "release", path: "compressor.release", min: 0, max: 1 }, 11 | ] 12 | 13 | constructor(context: AudioContext, parentEl: Element, name: string) { 14 | super(context, parentEl, name) 15 | this.webAudioNodes.compressor = context.createDynamicsCompressor() 16 | this.inputNode = this.webAudioNodes.compressor 17 | this.outputNode = this.webAudioNodes.compressor 18 | 19 | this.knobsEl = document.createElement("div") 20 | this.knobsEl.className = "knobs" 21 | this.el.appendChild(this.knobsEl) 22 | 23 | this.resolveParams() //always call me after settings up your webAudioNodes! 24 | this.setupKnobs() 25 | } 26 | 27 | async setup({ threshold, knee, ratio, attack, release }) { 28 | this.updateParamIfChanged(0, threshold) 29 | this.updateParamIfChanged(1, knee) 30 | this.updateParamIfChanged(2, ratio) 31 | this.updateParamIfChanged(3, attack) 32 | this.updateParamIfChanged(4, release) 33 | return this 34 | } 35 | } 36 | 37 | export { Compressor } 38 | -------------------------------------------------------------------------------- /ui/ladder_filter.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" // Gets WeAssembly bytcode from file 2 | import filterWasm from "../src/cpp/ladder_filter.wasm" 3 | 4 | class LadderFilter extends Effect { 5 | static friendlyName = "ladder_filter" 6 | 7 | params = [ 8 | { 9 | name: "cutoff", 10 | path: "filterNode.cutoff", 11 | isWorklet: true, 12 | min: 40, 13 | max: 12000, 14 | logScale: true, 15 | }, 16 | { name: "Q", path: "filterNode.Q", isWorklet: true }, 17 | ] 18 | 19 | constructor(context: AudioContext, parentEl: Element, name: string) { 20 | super(context, parentEl, name) 21 | 22 | const filterNode = new AudioWorkletNode( 23 | this.audioContext, 24 | "filter-processor", 25 | ) 26 | 27 | filterNode.port.postMessage({ wasm: filterWasm.buffer }) //load the wasm in the worker 28 | 29 | this.webAudioNodes.filterNode = filterNode 30 | 31 | this.inputNode = this.webAudioNodes.filterNode 32 | this.outputNode = this.webAudioNodes.filterNode 33 | 34 | this.knobsEl = document.createElement("div") 35 | this.knobsEl.className = "knobs" 36 | this.el.appendChild(this.knobsEl) 37 | 38 | this.resolveParams() //always call me after settings up your webAudioNodes! 39 | this.setupKnobs() 40 | } 41 | 42 | async setup({ cutoff, Q }) { 43 | this.updateParamIfChanged(0, cutoff) 44 | this.updateParamIfChanged(1, Q) 45 | return this 46 | } 47 | } 48 | 49 | export { LadderFilter } 50 | -------------------------------------------------------------------------------- /src/cpp/ladder_filter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | // emcc -O3 -s WASM=1 filterKernel.c -o filterKernel.wasm --no-entry 4 | 5 | float _PI = 3.14159265359; 6 | float _SAMPLERATE = 44100; 7 | 8 | static float inputBuffer[128]; 9 | static float outputBuffer[128]; 10 | 11 | double p0, p1, p2, p3; 12 | double p32, p33, p34; 13 | 14 | float cutoff; 15 | inline double fast_tanh(double x) { 16 | double x2 = x * x; 17 | return x * (27.0 + x2) / (27.0 + 9.0 * x2); 18 | } 19 | EMSCRIPTEN_KEEPALIVE 20 | void init() { 21 | p0 = p1 = p2 = p3 = p32 = p33 = p34 = 0.0f; 22 | } 23 | EMSCRIPTEN_KEEPALIVE 24 | float* inputBufferPtr() { 25 | return inputBuffer; 26 | } 27 | EMSCRIPTEN_KEEPALIVE 28 | float* outputBufferPtr() { 29 | return outputBuffer; 30 | } 31 | EMSCRIPTEN_KEEPALIVE 32 | void filter(float cutoff_in, float resonance) { 33 | cutoff = cutoff_in * 2 * _PI / _SAMPLERATE; 34 | cutoff = (cutoff > 1) ? 1 : cutoff; 35 | 36 | for (int i=0 ; i<128 ; i++) { 37 | 38 | double k = resonance * 4; 39 | 40 | double out = p3 * 0.360891 + p32 * 0.417290 + p33 * 0.177896 + p34 * 0.0439725; 41 | 42 | p34 = p33; p33 = p32; p32 = p3; 43 | 44 | p0 += (fast_tanh(inputBuffer[i] - k * out) - fast_tanh(p0)) * cutoff; 45 | p1 += (fast_tanh(p0) - fast_tanh(p1)) * cutoff; 46 | p2 += (fast_tanh(p1) - fast_tanh(p2)) * cutoff; 47 | p3 += (fast_tanh(p2) - fast_tanh(p3)) * cutoff; 48 | 49 | outputBuffer[i] = out; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ui/biquad.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | import { resolvePath } from "./utils" 4 | 5 | class Biquad extends Effect { 6 | static friendlyName = "biquad" 7 | 8 | params = [ 9 | { 10 | name: "frequency", 11 | path: "biquadNode.frequency", 12 | min: 10, 13 | max: 24000, 14 | logScale: true, 15 | }, 16 | { name: "detune", path: "biquadNode.detune", min: -1200, max: 1200 }, 17 | { name: "Q", path: "biquadNode.Q", min: 0.0001, max: 1000, logScale: true }, 18 | { name: "gain", path: "biquadNode.gain", min: -40, max: 40 }, 19 | ] 20 | 21 | constructor(context: AudioContext, parentEl: Element, name: string) { 22 | super(context, parentEl, name) 23 | this.webAudioNodes.biquadNode = context.createBiquadFilter() 24 | this.inputNode = this.webAudioNodes.biquadNode 25 | this.outputNode = this.webAudioNodes.biquadNode 26 | 27 | this.knobsEl = document.createElement("div") 28 | this.knobsEl.className = "knobs" 29 | this.el.appendChild(this.knobsEl) 30 | 31 | this.resolveParams() //always call me after settings up your webAudioNodes! 32 | this.setupKnobs() 33 | } 34 | 35 | async setup({ filter_type, frequency, detune, Q, gain }) { 36 | this.webAudioNodes.biquadNode.type = filter_type ? filter_type : "lowpass" 37 | this.updateParamIfChanged(0, frequency) 38 | this.updateParamIfChanged(1, detune) 39 | this.updateParamIfChanged(2, Q) 40 | this.updateParamIfChanged(3, gain) 41 | 42 | return this 43 | } 44 | } 45 | 46 | export { Biquad } 47 | -------------------------------------------------------------------------------- /ui/loop_instrument.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { bpm } from "./index" 3 | 4 | class LoopInstrument extends Effect { 5 | static friendlyName = "looper" 6 | 7 | params = [ 8 | { name: "latency", path: "loopNode.latency", isWorklet: true }, //min max are correct on the processor 9 | ] 10 | 11 | constructor(context: AudioContext, parentEl: Element, name: string) { 12 | super(context, parentEl, name) 13 | this.webAudioNodes.inputGainNode = context.createGain() 14 | this.webAudioNodes.outputGainNode = context.createGain() //TODO use webAudioNodes?? it's quite a shit thing 15 | this.inputNode = this.webAudioNodes.inputGainNode 16 | this.outputNode = this.webAudioNodes.outputGainNode 17 | 18 | const loopNode = new AudioWorkletNode( 19 | this.audioContext, 20 | "loop-processor", 21 | { processorOptions: { loopTime_s: 1 } }, //start with a decent thing 22 | ) 23 | this.webAudioNodes.loopNode = loopNode 24 | this.inputNode.connect(loopNode) 25 | loopNode.connect(this.outputNode) 26 | 27 | this.knobsEl = document.createElement("div") 28 | this.knobsEl.className = "knobs" 29 | this.el.appendChild(this.knobsEl) 30 | 31 | this.resolveParams() //always call me after settings up your webAudioNodes! 32 | this.setupKnobs() 33 | } 34 | 35 | async setup({ loop_time }) { 36 | const delayTime_beats = parseFloat(loop_time) 37 | const loopTime_s = (delayTime_beats / bpm) * 60 38 | this.webAudioNodes.loopNode.port.postMessage({ loopTime_s }) 39 | return this 40 | } 41 | } 42 | 43 | export { LoopInstrument } 44 | -------------------------------------------------------------------------------- /ui/delay.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { bpm } from "./index" 3 | 4 | class Delay extends Effect { 5 | //TODO we need to unify these names with the DSL, will allow for easier feature development 6 | static friendlyName = "Dlay" 7 | 8 | params = [ 9 | { name: "feedback", path: "gainNode.gain", min: 0, max: 1, lastValue: 0.5 }, 10 | ] 11 | 12 | constructor(context: AudioContext, parentEl: Element, name: string) { 13 | super(context, parentEl, name) 14 | this.webAudioNodes.delay = context.createDelay() 15 | 16 | this.webAudioNodes.gainNode = context.createGain() //feedback gain 17 | this.webAudioNodes.gainNode.connect(this.webAudioNodes.delay) 18 | this.webAudioNodes.gainNode.gain.value = 0.5 19 | this.webAudioNodes.delay.connect(this.webAudioNodes.gainNode) 20 | 21 | this.inputNode = this.webAudioNodes.gainNode 22 | this.outputNode = this.webAudioNodes.gainNode 23 | 24 | this.knobsEl = document.createElement("div") 25 | this.knobsEl.className = "knobs" 26 | this.el.appendChild(this.knobsEl) 27 | 28 | this.resolveParams() //always call me after settings up your webAudioNodes! 29 | this.setupKnobs() 30 | } 31 | 32 | async setup({ delay_time, feedback }) { 33 | let delayTime_beats = parseFloat(delay_time) 34 | if (!delayTime_beats) { 35 | delayTime_beats = 0.75 36 | } 37 | this.webAudioNodes.delay.delayTime.setTargetAtTime( 38 | (delayTime_beats / bpm) * 60, 39 | this.audioContext.currentTime + 0.01, 40 | 0.1, 41 | ) 42 | 43 | this.updateParamIfChanged(0, feedback) 44 | } 45 | } 46 | 47 | export { Delay } 48 | -------------------------------------------------------------------------------- /ui/lfo.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | import { resolvePath } from "./utils" 4 | 5 | class LFO extends Effect { 6 | static friendlyName = "lfo" 7 | params = [ 8 | { 9 | name: "frequency", 10 | path: "oscillatorNode.frequency", 11 | min: 0.001, 12 | max: 200, 13 | logScale: true, 14 | }, 15 | { 16 | name: "magnitude", 17 | path: "magnitudeGain.gain", 18 | min: 0.001, 19 | max: 1000, 20 | logScale: true, 21 | }, 22 | ] 23 | 24 | constructor(context: AudioContext, parentEl: Element, name: string) { 25 | super(context, parentEl, name) 26 | 27 | //osc-->magnitude-->outputGain 28 | 29 | this.webAudioNodes.oscillatorNode = context.createOscillator() 30 | this.webAudioNodes.oscillatorNode.start() 31 | 32 | this.webAudioNodes.magnitudeGain = context.createGain() 33 | this.webAudioNodes.oscillatorNode.connect(this.webAudioNodes.magnitudeGain) 34 | 35 | this.webAudioNodes.outputGain = context.createGain() 36 | 37 | this.webAudioNodes.magnitudeGain.connect(this.webAudioNodes.outputGain) 38 | 39 | this.outputNode = this.webAudioNodes.outputGain 40 | this.knobsEl = document.createElement("div") 41 | this.knobsEl.className = "knobs" 42 | this.el.appendChild(this.knobsEl) 43 | 44 | this.resolveParams() //always call me after settings up your webAudioNodes! 45 | this.setupKnobs() 46 | } 47 | 48 | async setup({ wave, frequency, magnitude }) { 49 | this.webAudioNodes.oscillatorNode.type = wave ? wave : "sine" 50 | this.updateParamIfChanged(0, frequency) 51 | this.updateParamIfChanged(1, magnitude) 52 | return this 53 | } 54 | } 55 | 56 | export { LFO } 57 | -------------------------------------------------------------------------------- /src/evaluator.janet: -------------------------------------------------------------------------------- 1 | # Lots here is taken from https://github.com/ianthehenry/toodle.studio 2 | 3 | (use ./globals) 4 | 5 | (def- template-env (make-env root-env)) 6 | (each module ["./helpers" "./globals" "./dsl" "./instruments" "./euclid" "./dsl_helpers" "./harmony"] 7 | (merge-module template-env (require module))) 8 | 9 | (defn- chunk-string [str] 10 | (var unread true) 11 | (fn [buf _] 12 | (when unread 13 | (set unread false) 14 | (buffer/blit buf str)))) 15 | 16 | (defn evaluate [user-script] 17 | (def env (make-env template-env)) 18 | (def lloops @{}) 19 | (def instruments @{:out @[0 :out] :midi @[1 :midi]}) #start with the master out, midi in 20 | 21 | (put env *lloops* lloops) 22 | (put env *instruments* instruments) 23 | 24 | 25 | (def errors @[]) 26 | (var error-fiber nil) 27 | 28 | (defn parse-error [&opt x y] 29 | (def buf @"") 30 | (with-dyns [*err* buf *err-color* false] 31 | (bad-parse x y)) 32 | (put env :exit true) 33 | (array/push errors (string/slice buf 0 -2))) 34 | (defn compile-error [&opt msg fiber where line col] 35 | (def buf @"") 36 | (with-dyns [*err* buf *err-color* false] 37 | (bad-compile msg nil where line col)) 38 | (array/push errors (string/slice buf 0 -2)) 39 | (put env :exit true) 40 | (set error-fiber fiber)) 41 | 42 | (run-context { 43 | :env env 44 | :chunks (chunk-string user-script) 45 | :source "script" 46 | :on-parse-error parse-error 47 | :on-compile-error compile-error 48 | :on-status (fn [fiber value] 49 | (unless (= (fiber/status fiber) :dead) 50 | (array/push errors value) 51 | (set error-fiber fiber))) 52 | }) 53 | 54 | (if (empty? errors) 55 | env 56 | (if error-fiber 57 | (propagate (first errors) error-fiber) 58 | (error (first errors))))) 59 | -------------------------------------------------------------------------------- /ui/envelope.ts: -------------------------------------------------------------------------------- 1 | import { Instrument } from "./instruments" 2 | import { Knob } from "./knob" 3 | import { note_to_frequency } from "./utils" 4 | import { nullGain } from "./audio" 5 | 6 | class EnvelopeNode { 7 | gain 8 | attackEnd 9 | decayEnd 10 | releaseEnd 11 | 12 | constructor(context : AudioContext){ 13 | this.gain = context.createGain() 14 | this.gain.gain.setValueAtTime(0, context.currentTime) 15 | 16 | this.attackEnd = context.currentTime 17 | this.decayEnd = context.currentTime 18 | this.releaseEnd = context.currentTime 19 | } 20 | 21 | play(startTime, dur, vel, attack, decay, sustain, release) { 22 | this.noteOn(startTime, vel, attack, decay, sustain) 23 | this.noteOff(startTime + dur, release) 24 | 25 | } 26 | 27 | noteOn(startTime, vel, attack, decay, sustain) { 28 | if(startTime < this.releaseEnd || startTime < this.attackEnd || startTime < this.decayEnd){ 29 | this.gain.gain.cancelAndHoldAtTime(startTime) 30 | } 31 | else{ 32 | this.gain.gain.setValueAtTime(0, startTime) 33 | } 34 | 35 | this.attackEnd = startTime + attack 36 | this.gain.gain.linearRampToValueAtTime(vel, this.attackEnd) 37 | this.decayEnd = this.attackEnd + decay 38 | this.gain.gain.linearRampToValueAtTime(sustain * vel, this.decayEnd) 39 | } 40 | 41 | noteOff(endTime, release){ 42 | if(endTime < this.attackEnd || endTime < this.decayEnd ){ 43 | this.gain.gain.cancelAndHoldAtTime(endTime) 44 | } 45 | this.releaseEnd = endTime + release 46 | this.gain.gain.linearRampToValueAtTime(0, this.releaseEnd) 47 | } 48 | 49 | connect(to){ 50 | this.gain.connect(to) 51 | } 52 | 53 | disconnect(){ 54 | this.gain.disconnect() 55 | } 56 | } 57 | 58 | class Envelope extends Instrument { 59 | static friendlyName = "synth" 60 | 61 | } 62 | 63 | export {EnvelopeNode} 64 | -------------------------------------------------------------------------------- /dist/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | [trane] - about 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

trane owes its existance to the following people

16 |

Ian Henry and his wonderful book Janet For Mortals. Ian's amazing work on toodle.studio helped birth this

17 |

Sam Aaron and his work on Sonic Pi. Quite a few features of trane originate there.

18 |

Calvin Rose for creating the Janet Language

19 |

Thanks also to all the contributors to Janet

20 |
21 |

Code, documentation, and examples live on github

22 |
23 |

You can change your keybindings below

24 | 25 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/runner.janet: -------------------------------------------------------------------------------- 1 | # Lots here is taken from https://github.com/ianthehenry/toodle.studio 2 | 3 | (use ./globals) 4 | 5 | (defn- zip-all [t predicates] 6 | (var result true) 7 | (for i 0 (length t) 8 | (unless ((predicates i) (t i)) 9 | (set result false) 10 | (break))) 11 | result) 12 | 13 | (defn- tuple-of? [t predicates] 14 | (and 15 | (tuple? t) 16 | (= (length t) (length predicates)) 17 | (zip-all t predicates))) 18 | 19 | (defn- note? [x] 20 | (tuple-of? x [int? number? number? number? number?])) 21 | 22 | (defn- notes? [xs] 23 | (all note? xs)) 24 | 25 | (defn- rest_length? [x] 26 | (number? x) and (< 0 x)) 27 | 28 | (defn- fiber_return? [x] 29 | (tuple-of? x [rest_length? notes?])) #rest length, array of notes 30 | 31 | #printing the instruments structure 32 | (defn- pwint [x] 33 | (cond 34 | (keyword? x) (describe x) 35 | (array? x) (map pwint x) 36 | x 37 | ) 38 | ) 39 | 40 | (defn print_instruments [instruments] 41 | (map pwint (kvs instruments)) 42 | ) 43 | 44 | (defn print_loops [loops] 45 | (map describe (keys loops)) 46 | ) 47 | 48 | (defn run [env fiber_name start_beat] 49 | (def lloops (env *lloops*)) 50 | 51 | (def current_loop (get lloops fiber_name)) 52 | 53 | # The current fiber env 54 | (def fiber-env (fiber/getenv current_loop)) 55 | 56 | # The dynamic bindings we want to use 57 | (def new-fiber-env @{:current-time start_beat :instruments (env *instruments*)}) 58 | 59 | (fiber/setenv current_loop (table/setproto new-fiber-env fiber-env)) 60 | 61 | (def next-action (resume current_loop)) 62 | 63 | (match (fiber/status current_loop) 64 | :pending 65 | (cond 66 | (nil? next-action) () 67 | (fiber_return? next-action) next-action 68 | (eprintf "illegal yield %q" next-action)) 69 | :error (errorf "doodle error %q \n %q" next-action (debug/stacktrace current_loop)) 70 | :dead () 71 | _ (error "unexpected next-action")) 72 | ) 73 | -------------------------------------------------------------------------------- /ui/crossfader.ts: -------------------------------------------------------------------------------- 1 | //TODO create a custom AudioNode class 2 | class CrossfaderNode { 3 | panner 4 | splitter 5 | lGain 6 | rGain 7 | constantOne 8 | outputGain 9 | 10 | constructor(context : AudioContext){ 11 | this.panner = context.createStereoPanner() 12 | this.splitter = 13 | context.createChannelSplitter(2) 14 | //this.webAudioNodes.wetDryGainToAudio = context.createGain() //TODO setup this don,t know if I need 15 | this.lGain = context.createGain() 16 | this.rGain = context.createGain() 17 | this.outputGain = context.createGain() 18 | this.constantOne = 19 | context.createConstantSource() 20 | 21 | this.constantOne.start() 22 | 23 | this.lGain.gain.setValueAtTime( 24 | 0, 25 | context.currentTime, 26 | ) 27 | this.rGain.gain.setValueAtTime( 28 | 0, 29 | context.currentTime, 30 | ) 31 | 32 | //Wire everything up 33 | this.constantOne.connect( 34 | this.panner, 35 | ) 36 | this.panner.connect( 37 | this.splitter, 38 | ) 39 | 40 | this.panner.channelCount = 1 41 | this.panner.channelCountMode = "explicit" 42 | 43 | this.splitter.connect( 44 | this.lGain.gain, 45 | 0, 46 | ) 47 | this.splitter.connect( 48 | this.rGain.gain, 49 | 1, 50 | ) 51 | 52 | this.lGain.connect( 53 | this.outputGain, 54 | ) 55 | this.rGain.connect( 56 | this.outputGain, 57 | ) 58 | } 59 | 60 | getLeft(){ 61 | return this.lGain 62 | } 63 | 64 | getRight(){ 65 | return this.rGain 66 | } 67 | 68 | getOutput(){ 69 | return this.outputGain 70 | } 71 | 72 | connect(to){ 73 | this.outputGain.connect(to) 74 | } 75 | 76 | disconnect(){ 77 | this.panner.disconnect() 78 | this.splitter.disconnect() 79 | this.lGain.disconnect() 80 | this.rGain.disconnect() 81 | this.constantOne.disconnect() 82 | this.outputGain.disconnect() 83 | } 84 | } 85 | 86 | export {CrossfaderNode} 87 | -------------------------------------------------------------------------------- /ui/chorus.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { bpm } from "./index" 3 | 4 | class Chorus extends Effect { 5 | //TODO we need to unify these names with the DSL, will allow for easier feature development 6 | static friendlyName = "chorus" 7 | 8 | params = [ 9 | { name: "rate", path: "lfo.frequency", min: 0.1, max: 10 }, 10 | { name: "amount", path: "lfoGain.gain", min: 0.001, max: 0.01 }, 11 | { name: "wet-dry", path: "crossfaderNode.panner.pan", min: -1, max: 1 }, 12 | ] 13 | 14 | constructor(context: AudioContext, parentEl: Element, name: string) { 15 | super(context, parentEl, name) 16 | this.webAudioNodes.splitGain = context.createGain() 17 | this.webAudioNodes.delay = context.createDelay() 18 | this.webAudioNodes.lfo = context.createOscillator() 19 | this.webAudioNodes.lfo.start() 20 | this.webAudioNodes.lfoGain = context.createGain() 21 | this.webAudioNodes.mixGain = context.createGain() 22 | 23 | this.webAudioNodes.splitGain.connect(this.webAudioNodes.delay) 24 | 25 | this.webAudioNodes.delay.delayTime.value = 0.014 26 | this.webAudioNodes.lfo.frequency.value = 0.2 27 | this.webAudioNodes.lfoGain.gain.value = 0.004 28 | this.webAudioNodes.lfo.connect(this.webAudioNodes.lfoGain) 29 | this.webAudioNodes.lfoGain.connect(this.webAudioNodes.delay.delayTime) 30 | 31 | //mix the result 32 | this.webAudioNodes.mixGain.gain.value = 0.5 33 | this.webAudioNodes.delay.connect(this.webAudioNodes.mixGain) 34 | this.webAudioNodes.splitGain.connect(this.webAudioNodes.mixGain) 35 | 36 | this.inputNode = this.webAudioNodes.splitGain 37 | this.outputNode = this.webAudioNodes.mixGain 38 | 39 | this.createWetDry() 40 | 41 | this.knobsEl = document.createElement("div") 42 | this.knobsEl.className = "knobs" 43 | this.el.appendChild(this.knobsEl) 44 | 45 | this.resolveParams() //always call me after settings up your webAudioNodes! 46 | this.setupKnobs() 47 | } 48 | 49 | async setup() {} 50 | } 51 | 52 | export { Chorus } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "music-site", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "esbuild ui/index.ts filter_worklet=ui/worklets/filter_worklet.js loop_worker=ui/worklets/loop_worker.js --define:DEBUG=true --external:module --loader:.wasm=binary --sourcemap --alias:wasm-runtime=./build/wasm.js --bundle --outdir=dist --allow-overwrite --loader:.woff2=dataurl --define:DEV=false && cp -rvf dist/* ../music-site-dist/", 8 | "dev": "esbuild ui/index.ts filter_worklet=ui/worklets/filter_worklet.js loop_worker=ui/worklets/loop_worker.js --define:DEBUG=true --external:module --loader:.wasm=binary --sourcemap --alias:wasm-runtime=./build/wasm.js --bundle --outdir=dist --allow-overwrite --watch --loader:.woff2=dataurl --servedir=dist --define:DEV=true", 9 | "create-parameters": "esbuild scripts/create-parameters.ts --platform=node --external:module --alias:wasm-runtime=./build/wasm.js --bundle --outdir=scripts --allow-overwrite --loader:.woff2=dataurl --tree-shaking=true --define:DEV=false && node scripts/create-parameters.js", 10 | "format": "npx prettier ui --write --no-semi" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@replit/codemirror-emacs": "^6.0.1", 16 | "@replit/codemirror-vim": "^6.0.14", 17 | "codemirror": "^6.0.1", 18 | "codemirror-lang-janet": "^0.7.2", 19 | "localforage": "^1.10.0", 20 | "tsx": "^4.16.2", 21 | "typescript-parser": "^2.6.1" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.9.1", 25 | "@typescript-eslint/eslint-plugin": "^6.7.5", 26 | "@typescript-eslint/parser": "^6.7.5", 27 | "@typescript/lib-dom": "npm:@types/web@^0.0.142", 28 | "esbuild": "^0.19.3", 29 | "eslint": "^8.57.0", 30 | "eslint-config-standard": "^17.1.0", 31 | "eslint-plugin-import": "^2.29.1", 32 | "eslint-plugin-n": "^16.6.2", 33 | "eslint-plugin-promise": "^6.6.0", 34 | "globals": "^15.9.0", 35 | "prettier": "3.3.3", 36 | "typescript-eslint": "^8.3.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/worklets/loop_worker.js: -------------------------------------------------------------------------------- 1 | function mod_wrap(i, i_max) { 2 | return ((i % i_max) + i_max) % i_max; 3 | } 4 | 5 | class LoopProcessor extends AudioWorkletProcessor { 6 | buffer; 7 | loopTime_s; 8 | 9 | newBuffer() { 10 | this.buffer = new Float32Array(this.loopTime_s * sampleRate); 11 | console.log("resizing buffer to!", this.buffer.length); 12 | } 13 | 14 | constructor(options) { 15 | super(); 16 | this.loopTime_s = options.processorOptions.loopTime_s; 17 | this.newBuffer(); 18 | 19 | this.port.onmessage = (e) => { 20 | //message is for now a loopTime_s 21 | if (e.data.loopTime_s !== this.loopTime_s) { 22 | this.loopTime_s = e.data.loopTime_s; 23 | this.newBuffer(); 24 | } 25 | }; 26 | } 27 | 28 | static get parameterDescriptors() { 29 | return [ 30 | { 31 | name: "latency", 32 | defaultValue: 30, 33 | minValue: 1, 34 | maxValue: 500, 35 | automationRate: "k-rate", 36 | }, 37 | ]; 38 | } 39 | 40 | process(inputs, outputs, parameters) { 41 | const output = outputs[0]; //first input 42 | const input = inputs[0]; //first output 43 | 44 | //TODO assumption here is 1 channel 45 | for (let o = 0; o < output.length; o++) { 46 | for (let s = 0; s < output[0].length; s++) { 47 | for (let i = 0; i < input.length; i++) { 48 | //record ahead of time, no monitoring. 49 | // TODO Maybe add a monitoring option? 50 | //this.buffer[mod_wrap(s + currentFrame - Math.floor(parameters.latency[0]/(1000*sampleRate)), this.buffer.length)] = this.buffer[mod_wrap(s + currentFrame - Math.floor(parameters.latency[0]/(1000*sampleRate)), this.buffer.length)] + input[i][s] 51 | this.buffer[mod_wrap(s + currentFrame, this.buffer.length)] = 52 | this.buffer[mod_wrap(s + currentFrame, this.buffer.length)] + 53 | input[i][s]; 54 | } 55 | output[o][s] = 56 | this.buffer[ 57 | mod_wrap( 58 | s + 59 | currentFrame + 60 | Math.floor((parameters.latency[0] * sampleRate) / 1000), 61 | this.buffer.length, 62 | ) 63 | ]; 64 | } 65 | } 66 | return true; 67 | } 68 | } 69 | 70 | registerProcessor("loop-processor", LoopProcessor); 71 | -------------------------------------------------------------------------------- /ui/wire.ts: -------------------------------------------------------------------------------- 1 | import { instrumentsByName } from "./audio" 2 | import { Keyboard } from "./keyboard" 3 | import { registerMidiNoteInput, deregisterMidiNoteInput } from "./midi_manager" 4 | 5 | class Wire { 6 | static friendlyName = "wire" 7 | 8 | name: string 9 | from: string 10 | to: string 11 | toParam: string 12 | 13 | constructor(name) { 14 | this.name = name 15 | } 16 | 17 | getDestination() { 18 | const toInst = instrumentsByName[this.to] 19 | const destination: AudioNode | AudioParam = this.toParam 20 | ? toInst.resolvedParams[ 21 | toInst.params.findIndex((param) => param.name === this.toParam) 22 | ] 23 | : toInst.inputNode 24 | return destination 25 | } 26 | 27 | setup({ from, to, toParam }) { 28 | if (this.from && from && this.to) { 29 | console.debug("already connected", this.from, this.to, this.toParam) 30 | return 31 | } 32 | this.from = from 33 | this.to = to 34 | this.toParam = toParam?.slice(1) //TODO fix this, we shouldn't be getting these here 35 | 36 | const toInst = instrumentsByName[to] 37 | const fromInst = instrumentsByName[from] 38 | 39 | if (from === ":midi") { 40 | registerMidiNoteInput(to, toInst) 41 | } else if (fromInst instanceof Keyboard) { 42 | fromInst.registerEvents(toInst) 43 | } else if (fromInst.outputNode && toInst.inputNode) { 44 | // TODO this find call is inefficient, use the param index. 45 | // It's what it's there for! To stop this sort of thing 46 | const destination = this.getDestination() 47 | fromInst.outputNode.connect(destination) 48 | console.debug("connecting", this.from, this.to) 49 | } else { 50 | console.warn(`No output or input to wire together ${this.name}`) 51 | } 52 | } 53 | 54 | disconnect() { 55 | const fromInst = instrumentsByName[this.from] 56 | const toInst = instrumentsByName[this.to] 57 | 58 | if (this.from === ":midi") { 59 | deregisterMidiNoteInput(this.to) 60 | } else if (fromInst instanceof Keyboard) { 61 | fromInst.deregisterEvents(toInst) 62 | } 63 | else if (toInst) { 64 | const destination = this.getDestination() 65 | console.debug("disconnecting", this.from, this.to) 66 | fromInst?.outputNode.disconnect(destination) 67 | } 68 | } 69 | } 70 | 71 | export { Wire } 72 | -------------------------------------------------------------------------------- /ui/scope.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Knob } from "./knob" 3 | import { loadSample } from "./utils" 4 | 5 | class Scope extends Effect { 6 | static friendlyName = "scope" 7 | 8 | buffer: AudioBuffer 9 | canvas: Element 10 | ctx //TODO this is overloaded with audiocontext, rename! 11 | height = 65 12 | width = 150 13 | fftSize = 2048 14 | 15 | constructor(context: AudioContext, parentEl: Element, name: string) { 16 | super(context, parentEl, name) 17 | 18 | this.webAudioNodes.analyserNode = context.createAnalyser() 19 | this.webAudioNodes.analyserNode.fftSize = this.fftSize 20 | this.buffer = new Uint8Array( 21 | this.webAudioNodes.analyserNode.frequencyBinCount, 22 | ) 23 | 24 | this.inputNode = this.webAudioNodes.analyserNode 25 | this.outputNode = this.webAudioNodes.analyserNode 26 | 27 | this.setupUI() 28 | } 29 | 30 | async setup() { 31 | return this 32 | } 33 | 34 | setupUI() { 35 | this.knobsEl = document.createElement("div") 36 | this.knobsEl.className = "knobs" 37 | this.el.appendChild(this.knobsEl) 38 | 39 | this.canvas = document.createElement("canvas") 40 | 41 | this.canvas.width = this.width 42 | this.canvas.height = this.height 43 | this.canvas.style.width = this.width 44 | this.canvas.style.height = this.height 45 | this.knobsEl.appendChild(this.canvas) 46 | 47 | this.ctx = this.canvas.getContext("2d") 48 | this.ctx.strokeStyle = "white" 49 | this.draw() 50 | } 51 | 52 | draw() { 53 | this.ctx.clearRect(0, 0, this.width, this.height) 54 | 55 | this.ctx.font = "5px pixeled" 56 | 57 | this.ctx.beginPath() 58 | this.ctx.moveTo(0, this.height / 2) 59 | 60 | //draw our waveform 61 | let x = 0 62 | 63 | this.webAudioNodes.analyserNode.getByteTimeDomainData(this.buffer) 64 | //pcm data is in [-1.0,1.0] 65 | const sampleStride = Math.ceil(this.buffer.length / this.width) 66 | 67 | //TODO zooming, split the range of channel data before iterating, faster! 68 | for (let sample = 0; sample < this.buffer.length; sample += sampleStride) { 69 | this.ctx.lineTo(x, (this.buffer[sample] * this.height) / 256) 70 | x++ 71 | } 72 | 73 | //this.ctx.lineTo(this.width, this.height/2) 74 | this.ctx.stroke() 75 | window.requestAnimationFrame(this.draw.bind(this)) 76 | } 77 | } 78 | 79 | export { Scope } 80 | -------------------------------------------------------------------------------- /ui/midi_manager.ts: -------------------------------------------------------------------------------- 1 | //This module listens to MIDI messages, and forwards events onto handlers that are registered with it. 2 | // 3 | 4 | const CC = 0xb0 5 | const NOTE_ON = 0x90 6 | const cc_registrations = { 7 | //cc to callback //TODO make this use the input too they will clash if someone has multiple midi controllers 8 | } 9 | 10 | const note_registrations = {} 11 | 12 | let midi 13 | 14 | let registeringFunction //the current function trying to get an assignment to the next cc parameter 15 | 16 | //TODO make this async 17 | function init() { 18 | function onMIDISuccess(midiAccess) { 19 | console.log("MIDI ready!") 20 | midi = midiAccess // store in the global (in real usage, would probably keep in an object instance) 21 | window.midi = midi 22 | listenToMidiMessages(midi, 0) 23 | } 24 | 25 | function onMIDIFailure(msg) { 26 | console.error(`Failed to get MIDI access - ${msg}`) 27 | } 28 | 29 | navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIFailure) 30 | } 31 | 32 | function registerCC(callback) { 33 | registeringFunction = callback 34 | if (!midi) { 35 | init() 36 | } 37 | } 38 | 39 | function onMIDIMessage(event) { 40 | if (event.data[0] === CC) { 41 | if (registeringFunction != undefined) { 42 | console.log("registered CC event") 43 | cc_registrations[event.data[1]] = registeringFunction 44 | registeringFunction = undefined //clear this, for the next registration 45 | } else if (cc_registrations[event.data[1]]) { 46 | //we have a function registered 47 | cc_registrations[event.data[1]](event.data[2]) //call the function 48 | } 49 | } else if (event.data[0] === NOTE_ON) { 50 | Object.values(note_registrations).forEach((note_registration) => { 51 | const dur = event.data[2] > 0 ? -1 : 0 52 | note_registration.play( 53 | event.data[1], 54 | note_registration.audioContext.currentTime, 55 | dur, 56 | ) 57 | }) 58 | } 59 | } 60 | 61 | function listenToMidiMessages(midiAccess, indexOfPort) { 62 | midiAccess.inputs.forEach((entry) => { 63 | entry.onmidimessage = onMIDIMessage 64 | }) 65 | } 66 | 67 | function registerMidiNoteInput(toName: string, to: Instrument) { 68 | note_registrations[toName] = to 69 | } 70 | 71 | function deregisterMidiNoteInput(toName: string) { 72 | delete note_registrations[toName] 73 | } 74 | 75 | export { init, registerCC, registerMidiNoteInput, deregisterMidiNoteInput } 76 | -------------------------------------------------------------------------------- /ui/worklets/filter_worklet.js: -------------------------------------------------------------------------------- 1 | class FilterProcessor extends AudioWorkletProcessor { 2 | inputBuffer; 3 | outputBuffer; 4 | inputStart; 5 | outputStart; 6 | filter; 7 | initialised = false; 8 | static get parameterDescriptors() { 9 | return [ 10 | { 11 | name: "cutoff", 12 | defaultValue: 1000.0, 13 | minValue: 40, 14 | maxValue: 12000, 15 | automationRate: "k-rate", 16 | }, 17 | { 18 | name: "Q", 19 | defaultValue: 1.0, 20 | minValue: 0.02, 21 | maxValue: 2.0, 22 | automationRate: "k-rate", 23 | }, 24 | ]; 25 | } 26 | 27 | constructor() { 28 | super(); 29 | this.WEBEAUDIO_FRAME_SIZE = 128; 30 | 31 | this.port.onmessage = (e) => { 32 | const key = Object.keys(e.data)[0]; 33 | const value = e.data[key]; 34 | if (key === "wasm") { 35 | // Instanciate 36 | WebAssembly.instantiate(value).then((result) => { 37 | /* result : {module: Module, instance: Instance} */ 38 | // exposes C functions to the outside world. only for readness 39 | const exports = result.instance.exports; 40 | // Gets pointer to wasm module memory 41 | this.inputStart = exports._Z14inputBufferPtrv(); 42 | this.outputStart = exports._Z15outputBufferPtrv(); 43 | // Create shadow buffer of float. 44 | this.inputBuffer = new Float32Array( 45 | exports.memory.buffer, 46 | this.inputStart, 47 | this.WEBEAUDIO_FRAME_SIZE, 48 | ); 49 | this.outputBuffer = new Float32Array( 50 | exports.memory.buffer, 51 | this.outputStart, 52 | this.WEBEAUDIO_FRAME_SIZE, 53 | ); 54 | exports._Z4initv(); 55 | // Gets the filter function 56 | this.filter = exports._Z6filterff; 57 | this.initialised = true; 58 | }); 59 | } 60 | }; 61 | } 62 | 63 | process(inputList, outputList, parameters) { 64 | if ( 65 | this.initialised && 66 | inputList.length > 0 && 67 | inputList[0].length > 0 && 68 | outputList.length > 0 && 69 | outputList[0].length > 0 70 | ) { 71 | this.inputBuffer.set(inputList[0][0]); 72 | this.filter(parameters.cutoff[0], parameters.Q[0]); 73 | outputList[0][0].set(this.outputBuffer); 74 | } 75 | return true; 76 | } 77 | } 78 | registerProcessor("filter-processor", FilterProcessor); 79 | -------------------------------------------------------------------------------- /scripts/create-parameters.ts: -------------------------------------------------------------------------------- 1 | import { TypescriptParser, ClassDeclaration } from "typescript-parser" 2 | import { readFile, readdir, writeFile } from "node:fs/promises" 3 | 4 | // This file parses the contents of all the audio modules. 5 | // It then tries to find the parameter mapping in each module and generate some janet for parameter mappings. 6 | // 7 | // This is quite brittle, and ugly, and doesn't work with modules loaded dynamically. 8 | // I'd like to move this to the browser land (No parsing required) 9 | // and have the browser inject all instrument defenitions and parameter mappings to the janet compiler. 10 | // Oh well, this works for now. 11 | // 12 | 13 | const rx = /\[([^)]+)\]/ 14 | 15 | async function createParameters() { 16 | const paramsMap = {} 17 | const parser = new TypescriptParser() 18 | 19 | await Promise.all( 20 | (await readdir("./ui")).map(async (file) => { 21 | if (file.endsWith(".ts")) { 22 | const parsed = await parser.parseFile(`./ui/${file}`, "workspace root") 23 | const classDeclarations = parsed.declarations.filter( 24 | (declaration) => declaration instanceof ClassDeclaration, 25 | ) 26 | await Promise.all( 27 | classDeclarations.map(async (classDeclaration) => { 28 | const paramsDef = classDeclaration.properties.find( 29 | (property) => property.name === "params", 30 | ) 31 | const friendlyNameDef = classDeclaration.properties.find( 32 | (property) => property.name === "friendlyName", 33 | ) 34 | if (paramsDef && friendlyNameDef) { 35 | //Ok, this looks good. let's open the file 36 | const contents = await readFile(`./ui/${file}`) 37 | 38 | const matches = rx.exec( 39 | contents.subarray(paramsDef.start, paramsDef.end).toString(), 40 | ) 41 | const friendlyName = contents 42 | .subarray(friendlyNameDef.start, friendlyNameDef.end) 43 | .toString() 44 | ?.split('"') 45 | ?.at(1) 46 | ?.trim() 47 | 48 | if (matches && friendlyName) { 49 | paramsMap[friendlyName] = eval(matches[0]) 50 | } else { 51 | console.warn("couldn't genereate params for", file) 52 | } 53 | } 54 | }), 55 | ) 56 | } 57 | }), 58 | ) 59 | 60 | if (Object.values(paramsMap).length > 0) { 61 | janetFormattedParamsMap = Object.entries(paramsMap) 62 | .map( 63 | ([friendlyName, params]) => 64 | ` :${friendlyName} @{\n` + 65 | params.map((param, idx) => ` :${param.name} ${idx}\n`).join("") + 66 | " }\n", 67 | ) 68 | .join("") 69 | 70 | const fileContents = 71 | "(def *inst_params* @{\n" + janetFormattedParamsMap + "})" 72 | 73 | console.log(fileContents) 74 | await writeFile("./src/params.janet", fileContents) 75 | } else { 76 | console.error( 77 | "something went wrong trying to generate parameter definitions", 78 | ) 79 | } 80 | } 81 | 82 | createParameters() 83 | -------------------------------------------------------------------------------- /ui/reverb.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { loadSample } from "./utils" 3 | 4 | class ConvolutionReverb extends Effect { 5 | static friendlyName = "reverb" 6 | 7 | params = [{ name: "wet-dry", path: "crossfaderNode.panner.pan", min: -1, max: 1 }] 8 | 9 | impulseBuffer: AudioBuffer 10 | impulseURL: string 11 | preDelay: number 12 | decayTime: number 13 | 14 | async regenerateImpulse(){ 15 | const minVal = 1/(2^16) 16 | const totalLength = this.decayTime + this.preDelay 17 | 18 | const expFactor = Math.log(minVal) / this.decayTime 19 | const myArrayBuffer = this.audioContext.createBuffer( 20 | 2, 21 | this.audioContext.sampleRate * totalLength, 22 | this.audioContext.sampleRate, 23 | ) 24 | for (let channel = 0; channel < myArrayBuffer.numberOfChannels; channel++) { 25 | // This gives us the actual array that contains the data 26 | const nowBuffering = myArrayBuffer.getChannelData(channel); 27 | for (let i = 0; i < myArrayBuffer.length; i++) { 28 | if(i / this.audioContext.sampleRate > this.preDelay){ 29 | nowBuffering[i] = Math.exp(expFactor*(i/this.audioContext.sampleRate - this.preDelay))*(Math.random() * 2 - 1) 30 | } 31 | } 32 | } 33 | 34 | this.webAudioNodes.convolver.buffer = myArrayBuffer 35 | } 36 | 37 | constructor(context: AudioContext, parentEl: Element, name: string) { 38 | super(context, parentEl, name) 39 | this.webAudioNodes.gainNode = context.createGain() 40 | this.webAudioNodes.convolver = context.createConvolver() 41 | this.webAudioNodes.gainNode.connect(this.webAudioNodes.convolver) 42 | 43 | this.preDelay = 0.01 44 | this.decayTime = 1.0 45 | 46 | this.regenerateImpulse() 47 | 48 | this.inputNode = this.webAudioNodes.gainNode 49 | this.outputNode = this.webAudioNodes.convolver 50 | 51 | this.createWetDry() 52 | 53 | this.knobsEl = document.createElement("div") 54 | this.knobsEl.className = "knobs" 55 | this.el.appendChild(this.knobsEl) 56 | 57 | this.resolveParams() 58 | this.setupKnobs() 59 | } 60 | 61 | async setup({impulse, ...rest}) { 62 | this.updateParamIfChanged(0, rest["wet-dry"]) 63 | if(impulse){ 64 | if (this.impulseURL != impulse) { 65 | const arraybuffer = await loadSample(impulse) 66 | this.impulseBuffer = await this.audioContext.decodeAudioData(arraybuffer) 67 | this.webAudioNodes.convolver.buffer = this.impulseBuffer 68 | this.impulseURL = impulse 69 | } 70 | } 71 | else{ 72 | const decayTime = rest["decay-time"] 73 | const preDelay = rest["predelay"] 74 | let needsUpdate = false 75 | if(decayTime){ 76 | const parsedDecayTime = parseFloat(decayTime) 77 | if(this.decayTime !== parsedDecayTime){ 78 | this.decayTime = parsedDecayTime 79 | needsUpdate = true 80 | } 81 | } 82 | if(preDelay){ 83 | const parsedPreDelay = parseFloat(preDelay) 84 | if(this.preDelay !== parsedPreDelay){ 85 | this.preDelay = parsedPreDelay 86 | needsUpdate = true 87 | } 88 | } 89 | 90 | if(needsUpdate){ 91 | await this.regenerateImpulse() 92 | } 93 | } 94 | 95 | return this 96 | } 97 | } 98 | 99 | export { ConvolutionReverb } 100 | -------------------------------------------------------------------------------- /test/python/test.py: -------------------------------------------------------------------------------- 1 | import time 2 | from selenium import webdriver 3 | from selenium.webdriver.common.keys import Keys 4 | from selenium.webdriver.common.by import By 5 | from selenium.common import NoSuchElementException, ElementNotInteractableException 6 | from selenium.webdriver.support.wait import WebDriverWait 7 | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities 8 | 9 | from ipdb import set_trace 10 | 11 | 12 | import pytest 13 | 14 | LOCAL_SERVER = "http://localhost:8000" 15 | BACH_TRACK = "?t=tracks/bach.janet" 16 | JUNGLE_TRACK = "?t=tracks/jungle.janet" 17 | CRYSTAL_TRACK = "?t=tracks/gypsy_woman.janet" 18 | ETUDE_TRACK = "?t=tracks/etude.janet" 19 | KEYBOARD_TRACK = "?t=tracks/keyboards.janet" 20 | ACID_TRACK = "?t=tracks/acid.janet" 21 | PARAMETERS_TRACK = "?t=tracks/parameter_changing.janet" 22 | WIRING_TRACK = "?t=tracks/wiring.janet" 23 | SCALES_CHORDS = "?t=tracks/scales_chords.janet" 24 | TUTOR = "?tutor" 25 | 26 | def get_wait(driver): 27 | errors = [NoSuchElementException, ElementNotInteractableException] 28 | wait = WebDriverWait(driver, timeout=2, poll_frequency=.2, ignored_exceptions=errors) 29 | return wait 30 | 31 | def execute_code(driver): 32 | introEl = driver.find_element(By.ID, "intro") 33 | introEl.click() 34 | 35 | codeEl = driver.find_element(By.CLASS_NAME, "cm-line") 36 | 37 | wait = get_wait(driver) 38 | 39 | wait.until(lambda d : codeEl.click() or True) 40 | 41 | def check_janet_logs(driver): 42 | try: 43 | errorEl = driver.find_element(By.CLASS_NAME, "janet-err") 44 | assert not errorEl 45 | except NoSuchElementException: 46 | assert True 47 | 48 | @pytest.fixture 49 | def setup_driver(): 50 | print("starting driver") 51 | 52 | driver = webdriver.Firefox() 53 | yield driver 54 | 55 | print("checking janet logs") 56 | check_janet_logs(driver) 57 | print("exiting execution") 58 | driver.quit() 59 | 60 | def test_jungle(setup_driver): 61 | setup_driver.get(LOCAL_SERVER + JUNGLE_TRACK) 62 | execute_code(setup_driver) 63 | time.sleep(10) 64 | 65 | def test_bach(setup_driver): 66 | setup_driver.get(LOCAL_SERVER + BACH_TRACK) 67 | execute_code(setup_driver) 68 | time.sleep(10) 69 | 70 | def test_crystal(setup_driver): 71 | setup_driver.get(LOCAL_SERVER + CRYSTAL_TRACK) 72 | execute_code(setup_driver) 73 | time.sleep(10) 74 | 75 | def test_etude(setup_driver): 76 | setup_driver.get(LOCAL_SERVER + ETUDE_TRACK) 77 | execute_code(setup_driver) 78 | time.sleep(10) 79 | 80 | def test_keyboards(setup_driver): 81 | setup_driver.get(LOCAL_SERVER + KEYBOARD_TRACK) 82 | execute_code(setup_driver) 83 | time.sleep(15) 84 | 85 | def test_keyboards(setup_driver): 86 | setup_driver.get(LOCAL_SERVER + TUTOR) 87 | execute_code(setup_driver) 88 | time.sleep(60) 89 | 90 | def test_acid(setup_driver): 91 | setup_driver.get(LOCAL_SERVER + ACID_TRACK) 92 | execute_code(setup_driver) 93 | time.sleep(15) 94 | 95 | def test_parameters(setup_driver): 96 | setup_driver.get(LOCAL_SERVER + PARAMETERS_TRACK) 97 | execute_code(setup_driver) 98 | time.sleep(15) 99 | 100 | def test_wiring(setup_driver): 101 | setup_driver.get(LOCAL_SERVER + WIRING_TRACK) 102 | execute_code(setup_driver) 103 | time.sleep(15) 104 | 105 | def test_scales_chords(setup_driver): 106 | setup_driver.get(LOCAL_SERVER + SCALES_CHORDS) 107 | execute_code(setup_driver) 108 | time.sleep(15) 109 | -------------------------------------------------------------------------------- /ui/breakbeat_instrument.ts: -------------------------------------------------------------------------------- 1 | import { Sampler } from "./sampler_generic" 2 | import { bpm } from "./index" 3 | 4 | class BreakbeatSampler extends Sampler { 5 | static friendlyName = "breakbeat_sampler" 6 | length_bars = 8 //default to two bars 7 | sampleStart = 0 8 | sampleEnd = 1 9 | slices = [this.sampleStart] 10 | slicesWithEnd = [this.sampleStart, this.sampleEnd] //for visualisation 11 | 12 | params = [ 13 | { name: "gain", path: "gainNode.gain", min: 0.001, max: 1, logScale: true }, 14 | ] 15 | 16 | constructor(context: AudioContext, parentEl: Element, name: string) { 17 | super(context, parentEl, name) 18 | this.setupUI() 19 | } 20 | 21 | async setup({ url, length_beats, slices, gain }) { 22 | //TODO make bpm dynamic 23 | this.length_bars = parseFloat(length_beats) 24 | const parsedSlices = slices 25 | .slice(1, -1) 26 | .split(" ") 27 | .map((slice) => parseFloat(slice)) 28 | this.sampleStart = parsedSlices[0] 29 | this.sampleEnd = parsedSlices[parsedSlices.length - 1] 30 | this.slices = parsedSlices.slice(0, -1) //ignore last slice, it's the end marker, not an onset 31 | this.slicesWithEnd = [...this.slices, this.sampleEnd] 32 | this.updateParamIfChanged(0, gain) //TODO move this to the parent element 33 | super.setup(url) 34 | return this 35 | } 36 | 37 | play(note, startTime, dur) { 38 | if (this.buffer) { 39 | const wantSampleLength_s = (this.length_bars / bpm) * 60 //the sample length we want to squish down to 40 | 41 | const playbackRate = 42 | ((this.sampleEnd - this.sampleStart) * this.buffer.duration) / 43 | wantSampleLength_s 44 | //Don't include the end (last slice point) in this 45 | const sliceTime = 46 | this.slices[note % this.slices.length] * this.buffer.duration 47 | 48 | //we get a noteOn, we just want to play the slice that they refer to, nothing else 49 | let playDur = dur 50 | if (dur < 0) { 51 | // seconds? multiplier 52 | const sliceTime = 53 | this.slices[note % this.slices.length] * this.buffer.duration 54 | const nextSliceTime = 55 | this.slicesWithEnd[(note % this.slices.length) + 1] * 56 | this.buffer.duration 57 | playDur = (nextSliceTime - sliceTime) / playbackRate 58 | } 59 | 60 | this.playSample(startTime, playDur, sliceTime, playbackRate) 61 | } 62 | } 63 | 64 | /* 65 | * Gets the x position at a normalised time within a sample 66 | */ 67 | getXForNTime(nTime: number) { 68 | return nTime * this.width 69 | } 70 | 71 | drawSlices() { 72 | const start = this.getXForNTime(this.sampleStart) 73 | const end = this.getXForNTime(this.sampleEnd) 74 | this.ctx.beginPath() 75 | this.ctx.setLineDash([]) 76 | this.ctx.moveTo(start, 0) 77 | this.ctx.lineTo(start, this.height) 78 | this.ctx.moveTo(end, 0) 79 | this.ctx.lineTo(end, this.height) 80 | this.ctx.stroke() 81 | 82 | this.ctx.beginPath() 83 | this.ctx.setLineDash([5, 5]) 84 | this.slices.slice(1).forEach((slice) => { 85 | const x = this.getXForNTime(slice) 86 | this.ctx.moveTo(x, 0) 87 | this.ctx.lineTo(x, this.height) 88 | }) 89 | 90 | this.ctx.stroke() 91 | } 92 | 93 | draw() { 94 | super.draw() 95 | if (this.buffer) { 96 | this.drawSlices() 97 | } 98 | window.requestAnimationFrame(this.draw.bind(this)) 99 | } 100 | } 101 | 102 | export { BreakbeatSampler } 103 | -------------------------------------------------------------------------------- /src/dsl_helpers.janet: -------------------------------------------------------------------------------- 1 | (use ./harmony) 2 | (use ./globals) 3 | 4 | (defmacro nicedescribe [x] 5 | ~(if 6 | (string? ,x) ,x 7 | (string/format "%n" ,x) 8 | ) 9 | ) 10 | 11 | (defmacro inst [instType name & args] 12 | ~(do 13 | (assert (not (get (dyn ,*instruments*) ,name)) (string "instrument already declared: " ,name)) 14 | (put (dyn ,*instruments*) ,name @[(length (dyn ,*instruments*)) ,instType ,;(map nicedescribe args)]) 15 | ,name 16 | ) 17 | ) 18 | 19 | (defn play_ [pitch channel &named vel dur] 20 | (cond 21 | (or (array? pitch) (tuple? pitch)) (array/concat ;(map (fn [n] (play_ n channel :vel vel :dur dur)) pitch)) 22 | (= pitch :rest) @[] 23 | (do 24 | (def pitch_ (note pitch)) 25 | (def vel_ (if vel vel 1.0)) 26 | (def dur_ (if dur dur -1.0)) 27 | @[[channel pitch_ vel_ (dyn :current-time) dur_]] 28 | ) 29 | ) 30 | ) 31 | 32 | (defmacro change_ [channel to &named cType dur] 33 | (with-syms [$cType $dur] 34 | ~(let [,$cType (if ,cType ,cType -0.01) ,$dur (if ,dur ,dur -1.0)] 35 | (tuple ,channel ,to ,$cType (dyn :current-time) ,$dur) 36 | ) 37 | ) 38 | ) 39 | 40 | (defn squish-rest-reducer [accum el] 41 | (def [currentNote currentLen] el) 42 | (match currentNote 43 | :tie (do # else pop a value off the stack push it back on with the tie length added on 44 | (def [lastNote lastNoteLen] (array/pop accum)) 45 | (array/push accum [lastNote (+ lastNoteLen currentLen)]) 46 | ) 47 | _ (array/push accum el) 48 | ) 49 | ) 50 | 51 | #Rests are nil 52 | (defn squish-rests [pattern] 53 | (reduce squish-rest-reducer @[(get pattern 0)] (array/slice pattern 1)) 54 | ) 55 | 56 | (defmacro encodeParam [inst instParam] 57 | ~(- (inc (+ (blshift ,inst 5) ,instParam))) 58 | ) 59 | 60 | (defn quantiseModulo [modTime measure] ## This could be a terrible idea. 61 | # float64 eps?? probably a bit generous 62 | (if (< (math/abs (- modTime measure)) 1e-20) 63 | 0 64 | modTime 65 | ) 66 | ) 67 | 68 | # Following Gratefully borrowed from alect https://alectroemel.com/posts/2023-09-26-physics-vectors.html 69 | (defmacro vectorize [fn] 70 | (let [original-fn (symbol :original/ fn)] 71 | ~(upscope 72 | # save the original function under a new name 73 | (def ,original-fn ,fn) 74 | 75 | # now redefine the function 76 | (defn ,(symbol fn) [& args] 77 | # Use reduce to accumulate all the arguments with the function. 78 | # There are 4 possible situations to consider. 79 | # The comments below will use + as a standing for the provided function. 80 | 81 | # special case - can be unary 82 | (if (= (length args) 1) 83 | (,original-fn (first args)) 84 | (reduce2 85 | |(cond 86 | # [x1 y1] + [x2 y2] => [(x1 + x2) (y1 + y2)] 87 | (and (indexed? $0) (indexed? $1)) 88 | (tuple ;(map ,original-fn $0 $1)) 89 | 90 | # [x y] + n => [(x + n) (y + n)] 91 | (and (indexed? $0) (number? $1)) 92 | (tuple ;(map (fn [v] (,original-fn v $1)) $0)) 93 | 94 | # n + [x y] => [(x + n) (y + n)] 95 | (and (number? $0) (indexed? $1)) 96 | (tuple ;(map (fn [v] (,original-fn v $0)) $1)) 97 | 98 | # n1 + n2 => n1 + n2 99 | (and (number? $0) (number? $1)) 100 | (,original-fn $0 $1) 101 | ) 102 | args)))) 103 | ) 104 | ) 105 | 106 | (defmacro notify_args [fn] 107 | (let [original-fn (symbol :original/ fn)] 108 | ~(upscope 109 | # save the original function under a new name 110 | (def ,original-fn ,fn) 111 | 112 | # now redefine the function 113 | (defn ,(symbol fn) [& args] 114 | (,original-fn ;(map ,note args)) 115 | ) 116 | ) 117 | ) 118 | ) 119 | -------------------------------------------------------------------------------- /src/instruments.janet: -------------------------------------------------------------------------------- 1 | (use ./dsl_helpers) 2 | 3 | (defmacro reverb 4 | ````Creates a convolution reverb module with a given `name` 5 | Grabs an impulse from the URL of the `impulse` parameter 6 | 7 | **Example** 8 | ``` 9 | (reverb :hello-verb :impulse "http://impulses.com/big_impulse.wav") 10 | ``` 11 | ```` 12 | [name &named impulse wet-dry decay-time predelay] 13 | ~(inst ,:reverb ,name :impulse ,impulse :wet-dry ,wet-dry :decay-time ,decay-time :predelay ,predelay) 14 | ) 15 | 16 | (defmacro Dlay 17 | ````Creates a delay module with a given `name` 18 | * `delay_time` is given in beats 19 | * `feedback` is a number 20 | 21 | **Example** 22 | ``` 23 | # Creates a delay module with a delay line length of 0.75 beats and a feedback of 50% 24 | (Dlay :hello-delay :delay_time 0.75 :feedback 0.5) 25 | ``` 26 | ```` 27 | [name &named delay_time feedback] 28 | ~(inst ,:Dlay ,name :delay_time ,delay_time :feedback ,feedback) 29 | ) 30 | 31 | (defmacro looper 32 | ````Creates a looping module with a given `name` 33 | `loop_time` is given in beats 34 | 35 | **Example** 36 | ``` 37 | # Creates a looping module with a loop time of 4 beats 38 | (looper :hello-looper :loop_time 4) 39 | ``` 40 | ```` 41 | [name &named loop_time] 42 | ~(inst ,:looper ,name :loop_time ,loop_time) 43 | ) 44 | 45 | (defmacro distortion [name &named amount] 46 | ~(inst ,:distortion ,name :amount ,amount) 47 | ) 48 | 49 | (defmacro compressor [name &named threshold knee ratio attack release] 50 | ~(inst ,:compressor ,name :threshold ,threshold :knee ,knee :ratio ,ratio :attack ,attack :release ,release) 51 | ) 52 | 53 | (defmacro line_in [name] 54 | ~(inst ,:line_in ,name) 55 | ) 56 | 57 | (defmacro sample [name &named url pitch gain attack release loop-start loop-end] 58 | (with-syms [$note] 59 | ~(let [,$note (note ,pitch)] 60 | (inst :pitched_sampler ,name :url ,url :pitch ,$note :gain ,gain :attack ,attack :release ,release :loop_start ,loop-start :loop_end ,loop-end) 61 | ) 62 | ) 63 | ) 64 | 65 | (defmacro drums [name &named hits] 66 | ~(inst ,:drums ,name :hits ,hits) 67 | ) 68 | 69 | (defmacro gain [name &named gain] 70 | ~(inst ,:gain ,name :gain ,gain) 71 | ) 72 | 73 | (defmacro keyboard [name] 74 | ~(inst ,:keyboard ,name) 75 | ) 76 | 77 | (defmacro chorus [name] 78 | ~(inst ,:chorus ,name) 79 | ) 80 | 81 | (defmacro panner [name &named pan] 82 | ~(inst ,:panner ,name :pan ,pan) 83 | ) 84 | 85 | (defmacro breakbeat [name &named url length_beats slices gain] 86 | (with-syms [$slices] 87 | ~(let [,$slices 88 | (cond 89 | (int? ,slices) (tuple ;(map (fn [x] (/ x ,slices)) (range 0 (+ ,slices 1)))) 90 | (tuple? ,slices) ,slices 91 | (error "slices not a number of slices or tuple of slice times") 92 | ) 93 | ] 94 | (inst ,:breakbeat_sampler ,name :url ,url :length_beats ,length_beats :slices ,$slices :gain ,gain) 95 | ) 96 | ) 97 | ) 98 | 99 | (defmacro synth [name &named wave gain attack release] 100 | ~(inst ,:synth ,name :wave ,wave :gain ,gain :attack ,attack :release ,release) 101 | ) 102 | 103 | (defmacro biquad [name &named filter_type frequency detune Q gain] 104 | ~(inst ,:biquad ,name :filter_type ,filter_type :frequency ,frequency :detune ,detune :Q ,Q :gain ,gain) 105 | ) 106 | 107 | (defmacro oscillator [name &named wave frequency] 108 | ~(inst ,:oscillator ,name :wave ,wave :frequency ,frequency) 109 | ) 110 | 111 | (defmacro lfo [name &named wave frequency magnitude] 112 | ~(inst ,:lfo ,name :wave ,wave :frequency ,frequency :magnitude ,magnitude) 113 | ) 114 | 115 | (defmacro scope [name] 116 | ~(inst ,:scope ,name) 117 | ) 118 | 119 | (defmacro ladder [name &named cutoff Q] 120 | ~(inst ,:ladder_filter ,name :cutoff ,cutoff :Q ,Q) 121 | ) 122 | 123 | (defmacro constant [name &named constant] 124 | ~(inst ,:constant ,name :constant ,constant) 125 | ) 126 | -------------------------------------------------------------------------------- /ui/sampler_instrument.ts: -------------------------------------------------------------------------------- 1 | import { Instrument } from "./instruments" 2 | import { loadSample } from "./utils" 3 | 4 | interface SampleBuffer { 5 | // Represents an individual hit 6 | sampleURL: string 7 | buffer: AudioBuffer 8 | lastHitTimes: Array 9 | } 10 | 11 | function sortedIndex(array, value) { 12 | var low = 0, 13 | high = array.length; 14 | 15 | while (low < high) { 16 | var mid = low + high >>> 1; 17 | if (array[mid] < value) low = mid + 1; 18 | else high = mid; 19 | } 20 | return low; 21 | } 22 | 23 | function insertSorted(arr, val){ 24 | arr.splice(sortedIndex(arr, val)+ 1, 0, val) 25 | } 26 | 27 | class Sampler extends Instrument { 28 | static friendlyName = "drums" 29 | height = 65 30 | width = 65 31 | ctx 32 | canvas 33 | 34 | settings = { 35 | detune: 0, 36 | loop: false, 37 | loopStart: 0, 38 | loopEnd: 100, 39 | playbackRate: 1, 40 | } 41 | buffers: Array 42 | 43 | constructor(context: AudioContext, parentEl: Element, name: string) { 44 | super(context, parentEl, name) 45 | 46 | this.webAudioNodes.gainNode = context.createGain() 47 | this.webAudioNodes.gainNode.gain.value = 1.0 48 | 49 | this.outputNode = this.webAudioNodes.gainNode 50 | this.buffers = [] 51 | this.setupUI() 52 | } 53 | 54 | setupUI() { 55 | this.canvas = document.createElement("canvas") 56 | 57 | this.canvas.width = this.width 58 | this.canvas.height = this.height 59 | this.canvas.style.width = this.width 60 | this.canvas.style.height = this.height 61 | this.el.appendChild(this.canvas) 62 | 63 | this.ctx = this.canvas.getContext("2d") 64 | this.draw() 65 | } 66 | 67 | draw() { 68 | this.ctx.clearRect(0, 0, this.width, this.height) 69 | 70 | this.buffers.forEach((buffer, i) => { 71 | const lastHitTimeIdx = buffer.lastHitTimes.findLastIndex(time => time < this.audioContext.currentTime) 72 | if (lastHitTimeIdx !== -1) { 73 | buffer.lastHitTimes.splice(0, lastHitTimeIdx) 74 | const lastHitTime = buffer.lastHitTimes.at(0) 75 | 76 | const x = i % 8 77 | const y = Math.floor(i / 8) 78 | 79 | const opacity = 255*Math.max(1 -(this.audioContext.currentTime - lastHitTime), 0) 80 | this.ctx.fillStyle = `rgb(${opacity} ${opacity} ${opacity})` 81 | this.ctx.fillRect(x * 8 + 1, y * 8 + 1, 7, 7) 82 | } 83 | }) 84 | 85 | window.requestAnimationFrame(this.draw.bind(this)) 86 | } 87 | 88 | async setup({ hits }) { 89 | const samples = hits 90 | .slice(1, -1) 91 | .split(" ") 92 | .map((str) => str.replaceAll('"', "")) 93 | const newBuffers = Array(samples.length) 94 | await Promise.all( 95 | samples.map(async (sampleURL, i) => { 96 | const sampleBuffer: SampleBuffer = {} 97 | if (this.buffers[i]?.sampleURL != sampleURL) { 98 | const buffer = await loadSample(sampleURL) 99 | const decoded = await this.audioContext.decodeAudioData(buffer) 100 | sampleBuffer.buffer = decoded 101 | sampleBuffer.sampleURL = sampleURL 102 | sampleBuffer.lastHitTimes = [] 103 | } else { 104 | sampleBuffer.buffer = this.buffers[i].buffer 105 | sampleBuffer.lastHitTimes = this.buffers[i].lastHitTimes 106 | sampleBuffer.sampleURL = sampleURL 107 | } 108 | newBuffers[i] = sampleBuffer 109 | }), 110 | ) 111 | this.buffers = newBuffers 112 | return this 113 | } 114 | 115 | play(note, startTime, dur) { 116 | const player = this.audioContext.createBufferSource() 117 | player.connect(this.webAudioNodes.gainNode) 118 | const sampleBuffer = this.buffers[Math.floor(note) % this.buffers.length] 119 | player.buffer = sampleBuffer?.buffer 120 | player.start(startTime) 121 | if (dur >= 0) { 122 | player.stop(startTime + dur) 123 | } else { 124 | player.stop(startTime + player.buffer?.duration || 0) 125 | } 126 | insertSorted(sampleBuffer.lastHitTimes, startTime) 127 | } 128 | } 129 | 130 | export { Sampler } 131 | -------------------------------------------------------------------------------- /ui/index.ts: -------------------------------------------------------------------------------- 1 | import InitializeWasm from "wasm-runtime" 2 | import type { Module } from "wasm-runtime" 3 | import { initAudio, newInstrumentMappings } from "./audio" 4 | import { 5 | initCodeEditor, 6 | saveCurrentScript, 7 | scheduleCompilation, 8 | } from "./editor" 9 | import { codeReload } from "./loop_manager" 10 | import { OutputChannel } from "./errors" 11 | import { editor } from "./editor" 12 | import { background } from "./dark_theme" 13 | import { tutor, continueTutorial } from "./tutor" 14 | import "./css/main.css" 15 | 16 | let bpm 17 | let janetRuntime 18 | let compiledImage //the image that is compiling, but not yet ready been pushed to the running loops, TODO MAYBE set this to null/undefined if the compilation hasn't succeeded? 19 | let outputChannel: OutputChannel 20 | let instrumentElement: HTMLElement 21 | 22 | function addAboutSection() {} 23 | 24 | async function main(runtime: Module) { 25 | janetRuntime = runtime 26 | 27 | instrumentElement = document.createElement("div") 28 | instrumentElement.className = "instruments" 29 | instrumentElement.style.background = background 30 | instrumentElement.style.color = "white" 31 | 32 | //initialize code UI 33 | const codeElement = document.createElement("div") 34 | codeElement.className = "code" 35 | codeElement.id = "code" 36 | document.body.appendChild(codeElement) 37 | document.body.appendChild(instrumentElement) 38 | 39 | const infoElement = document.createElement("a") 40 | infoElement.href = "/about.html" 41 | infoElement.innerText = "i" 42 | infoElement.className = "info" 43 | 44 | document.body.appendChild(infoElement) 45 | 46 | await initCodeEditor(codeElement, onChange, onCodeReload) 47 | window.editor = editor 48 | onChange() 49 | continueTutorial() 50 | 51 | const outputChannelElement = document.createElement("pre") 52 | outputChannelElement.className = "output-channel" 53 | codeElement.appendChild(outputChannelElement) 54 | outputChannel.target = outputChannelElement 55 | 56 | if(DEV){ 57 | onCodeReload() 58 | } 59 | } 60 | 61 | function onChange() { 62 | outputChannel.clearErrors() 63 | const result = runtime.trane_compile(editor.state.doc.toString()) 64 | if (!result.isError) { 65 | compiledImage = result.image 66 | } else { 67 | compiledImage = undefined 68 | } 69 | } 70 | 71 | async function onCodeReload() { 72 | scheduleCompilation(0, onChange) //TODO this is a bit wasteful. In reality we need to check if there have been any edits to the buffer since the last compilation and schedule one if so. 73 | console.log("got code reload message") 74 | if (compiledImage) { 75 | saveCurrentScript() 76 | const startResult = janetRuntime.trane_start(compiledImage) 77 | const { environment, lloop_names, instrument_mappings } = startResult 78 | if (startResult.bpm && startResult.bpm > 0 && bpm === undefined) { 79 | bpm = startResult.bpm 80 | } else if (bpm === undefined) { 81 | //fallback to 120bpm 82 | bpm = 120 83 | } 84 | 85 | await initAudio() //no-op if already initialised 86 | await newInstrumentMappings(instrument_mappings) 87 | codeReload(environment, lloop_names) 88 | continueTutorial() 89 | } else { 90 | console.log("tried to reload without an image") 91 | } 92 | } 93 | 94 | document.addEventListener("DOMContentLoaded", (_) => { 95 | outputChannel = new OutputChannel() 96 | const opts = { 97 | print: (x: string) => { 98 | outputChannel.print(x, false) 99 | }, 100 | printErr: (x: string) => { 101 | outputChannel.print(x, true) 102 | }, 103 | } 104 | 105 | switch (window.location.pathname) { 106 | case "/": 107 | InitializeWasm(opts).then((runtime: Module) => { 108 | window.runtime = runtime 109 | const introElement = document.getElementById("intro") 110 | introElement?.addEventListener("click", () => { 111 | introElement.classList.add("intro-clicked") 112 | main(runtime) 113 | }) 114 | }) 115 | break 116 | } 117 | }) 118 | 119 | export { bpm, instrumentElement, janetRuntime } 120 | 121 | if (DEV) { 122 | new EventSource("/esbuild").addEventListener("change", () => 123 | location.reload(), 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /ui/loop_manager.ts: -------------------------------------------------------------------------------- 1 | import { context, play } from "./audio" 2 | import { bpm, janetRuntime } from "./index" 3 | 4 | const LEAD_TIME_MILLIS = 500 //hack, let's see if this helps 5 | let current_time_beats = 0 6 | let start_time_seconds 7 | 8 | let envRunning //The environment that _is_ running. On a code change signal, we'll set this 9 | 10 | const loops: Map = {} 11 | 12 | interface LoopState { 13 | current_time_beats: number 14 | started: boolean 15 | } 16 | 17 | function init() { 18 | //requires the audioContext and runtime to be initialised 19 | if (start_time_seconds !== undefined) { 20 | //don't bother if we've already started 21 | return 22 | } 23 | start_time_seconds = context.currentTime 24 | masterLoop() //async, fires this off 25 | } 26 | 27 | function scheduleEvents(events) { 28 | for (let i = 0; i < events.size(); i++) { 29 | const note = events.get(i) 30 | play( 31 | note.channel, 32 | note.pitch, 33 | note.vel, 34 | start_time_seconds + 35 | beat_time_to_real_seconds(note.start) + 36 | LEAD_TIME_MILLIS / 1000.0, 37 | beat_time_to_real_seconds(note.dur), 38 | ) 39 | } 40 | } 41 | 42 | // lets make a master loop, exists by sleeping for 4 bars, sets up other code to run, and keeps a master clock of things 43 | 44 | function codeReload(environment, lloop_names) { 45 | const allLoopNames: Set = new Set() 46 | 47 | if (lloop_names) { 48 | for (let i = 0; i < lloop_names.size(); i++) { 49 | const loop_name = lloop_names.get(i) 50 | allLoopNames.add(loop_name) 51 | } 52 | } 53 | 54 | const newLoops = [...allLoopNames].filter((x) => !loops[x]) 55 | const loopsToDie = Object.keys(loops).filter((x) => !allLoopNames.has(x)) 56 | 57 | newLoops.forEach((loop_name) => { 58 | console.log(`setting up loop ${loop_name} to run`) 59 | loops[loop_name] = { started: false } 60 | }) 61 | 62 | loopsToDie.forEach((loop_name) => { 63 | delete loops[loop_name] 64 | }) 65 | 66 | envRunning = environment 67 | } 68 | 69 | function beat_time_to_real_seconds(beat) { 70 | return (beat / bpm) * 60 71 | } 72 | 73 | async function masterLoop() { 74 | const loopsToStart = Object.keys(loops).filter( 75 | (loop_name) => !loops[loop_name].started, 76 | ) 77 | loopsToStart.forEach((loop_name) => { 78 | const currentLoopState = loops[loop_name] 79 | currentLoopState.started = true 80 | currentLoopState.current_time_beats = current_time_beats 81 | console.log(`starting ${loop_name}`) 82 | execute_loop(loop_name) //async, fires this off and doesn't wait for a return 83 | }) 84 | const rest_length = 4 85 | 86 | const wakeup_time_s = 87 | start_time_seconds + 88 | beat_time_to_real_seconds(current_time_beats + rest_length) - 89 | LEAD_TIME_MILLIS / 1000 //preempt the wakeup time by 250s 90 | const elapsed_time = context.currentTime - start_time_seconds 91 | 92 | const sleepFor_s = wakeup_time_s - elapsed_time 93 | const scheduleForMillis = sleepFor_s * 1000 94 | current_time_beats = current_time_beats + rest_length 95 | setTimeout(masterLoop, scheduleForMillis) //TODO make this run immediately on code ready, or on keypress 96 | } 97 | 98 | async function execute_loop(loop_name) { 99 | const currentLoopState = loops[loop_name] 100 | if (!currentLoopState) { 101 | console.log(`loop ${loop_name} is dead, not running`) 102 | return 103 | } 104 | const result = janetRuntime.trane_continue( 105 | envRunning, 106 | loop_name.slice(1), 107 | currentLoopState.current_time_beats, 108 | ) 109 | if (!result.isError) { 110 | scheduleEvents(result.notes) 111 | } else { 112 | console.log("error in " + loop_name + " killing it") 113 | delete loops[loop_name] 114 | } 115 | 116 | const wakeup_time_s = 117 | start_time_seconds + 118 | beat_time_to_real_seconds( 119 | currentLoopState.current_time_beats + result.rest_length, 120 | ) - 121 | LEAD_TIME_MILLIS / 1000 //preempt the wakeup time by 250s 122 | const elapsed_time = context.currentTime - start_time_seconds 123 | 124 | const sleepFor_s = wakeup_time_s - elapsed_time 125 | 126 | if (sleepFor_s < 0) { 127 | console.warn("sleeptime is negative: " + sleepFor_s) 128 | } 129 | 130 | const scheduleForMillis = sleepFor_s * 1000 131 | 132 | //update the timer before we wake up 133 | currentLoopState.current_time_beats = 134 | currentLoopState.current_time_beats + result.rest_length 135 | setTimeout(() => execute_loop(loop_name), scheduleForMillis) 136 | } 137 | 138 | export { init, codeReload } 139 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Trane Docs 2 | Trane is a [domain specific language](https://en.wikipedia.org/wiki/Domain-specific_language) for creating music in the browser. It's written in [Janet](https://janet-lang.org/), an embeddable lisp-like language. 3 | 4 | 5 | ## Getting started 6 | Creating music in Trane is largely done in two steps. Creating **instruments** and sending **events** to those instruments. 7 | 8 | ## Creating Instruments 9 | You define instruments by declaring an audio graph at the top-level. 10 | 11 | For example, to create simple sine oscillator synth, you can write this: 12 | ``` 13 | (synth :hello-world :wave "sine") 14 | ``` 15 | Instruments have to have a name, like `:hello-synth` so that we can send events to them later on. 16 | This synth isn't very useful on it's own. It's just a synth sitting there, not connected to anything. 17 | We can wire it up to the master output (probably your speakers/headphones) by using a **wire**. 18 | 19 | ``` 20 | (wire :hello-world :out) 21 | ``` 22 | 23 | OK! Now we're ready to start sending events to this synthesizer. 24 | 25 | ## Sequencing Events 26 | Now that we have an instrument hooked up to our output, we can start sending it notes to play. 27 | 28 | In Trane, you can send events from a **live_loop** 29 | 30 | ``` 31 | (live_loop :hello-loop # create a live_loop, call it :hello-loop 32 | (play :C3 :hello-world :dur 0.5) # send the note :C3 to :hello-world, with a duration of 0.5 33 | (sleep 1) # sleep for 1 beat 34 | ) 35 | ``` 36 | 37 | Let's have a look at what's going on here. 38 | A **live_loop** is like a regular loop that continues forever. We set one up here with the name `:hello-loop`. 39 | It sends a `:C3` note to our synthesizer every beat. 40 | 41 | You can change the body of the live loop while it's running. 42 | Try changing the note from a `:C3` to a `:D3` (remember to re-evaluate your changes!). 43 | The live-loop will run the new body the next time it runs. In this example it runs every beat. 44 | 45 | ## Chaining effects 46 | 47 | Let's create an effects chain. First let's delete any code we already have, starting from scratch. 48 | 49 | First we'll create a sampler instrument, and wire it up to the output 50 | ``` 51 | (sample :hello-sample :url "tracks/choir%20g%20maj.wav" :pitch :G3) # load a sample from the web with a pitch of :G3 52 | ``` 53 | Next let's create a lowpass [biquad filter](https://en.wikipedia.org/wiki/Digital_biquad_filter) 54 | 55 | 56 | ``` 57 | (biquad :hello-biquad :type "lowpass") 58 | ``` 59 | 60 | And let's wire them together like this `:hello-sample` -> `:hello-filter` -> `:out` 61 | ``` 62 | (wire :hello-sample :hello-biquad) 63 | (wire :hello-biquad :out) 64 | ``` 65 | 66 | We'll send in some notes so that we can hear our changes to the audio graph too. 67 | ``` 68 | (live_loop :sample-player 69 | (play :G3 :hello-sample :dur 32) 70 | (sleep 8) 71 | ) 72 | ``` 73 | 74 | OK! You should be able to hear a sample playing now. Try messing around with the knobs on the right. 75 | 76 | 77 | ### Chaining 78 | This method of wiring instruments and effects together works, but it's quite hard to see the arrangement of the audio graph. 79 | 80 | **Chaining** instruments and effects lets us achieve the same thing, but it's a bit more intuitive and uses less code. 81 | 82 | To create the same audio graph as above, we can instead write the following: 83 | ``` 84 | (chain 85 | (sample :hello-sample :url "tracks/choir%20g%20maj.wav" :pitch :G3) 86 | (biquad :hello-biquad :type "lowpass") 87 | :out 88 | ) 89 | ``` 90 | (If you see an error you'll need to delete your previous audio graph first: your sampler, biquad and wires) 91 | The code above is shorthand for the manual instrument creation and wiring we wrote before. 92 | It's much easier to read. 93 | 94 | ## Instrument parameters 95 | Let's keep the code around from the last section. You can change the instrument parameters with the knobs on the right. What if you want to change them from code? 96 | 97 | Let's create a live_loop that sweeps through the filter cutoff. 98 | 99 | ``` 100 | (live_loop :filter-sweeper 101 | (for i 200 900 # i in range 200 900 102 | (change :hello-biquad :frequency i) # set the frequency cutoff to the value of 'i' 103 | (sleep 0.01) 104 | ) 105 | ) 106 | ``` 107 | 108 | **change** lets us update parameters on effects and instruments. 109 | Other methods that do this are **target**, **itarget**, **lin** and **exp**. 110 | 111 | ### More 112 | This documentation is incomplete. For a fuller picture, check out the [DSL](https://github.com/gwegash/trane/blob/master/src/dsl.janet) and the [examples](https://github.com/gwegash/tracks). 113 | 114 | ## Further Reading 115 | * [Trane Examples](https://github.com/gwegash/tracks) 116 | * [janet-lang.org](https://janet-lang.org/) 117 | * [Janet for Mortals](https://janet.guide/) 118 | -------------------------------------------------------------------------------- /ui/sineInstrument.ts: -------------------------------------------------------------------------------- 1 | import { Instrument } from "./instruments" 2 | import { Knob } from "./knob" 3 | import { note_to_frequency } from "./utils" 4 | import { nullGain } from "./audio" 5 | import { EnvelopeNode } from "./envelope" 6 | //import "./css/synth.css" 7 | 8 | // oscillators[i] -> envelopeGain -> gain -> output 9 | class SawSynth extends Instrument { 10 | static friendlyName = "synth" 11 | oscillatorType = "sawtooth" 12 | 13 | params = [ 14 | { name: "gain", path: "gainNode.gain", min: 0.001, max: 1, lastValue: 1.0 }, 15 | { 16 | name: "attack", 17 | path: "attackNode.offset", 18 | min: 0, 19 | max: 1, 20 | lastValue: 0.01, 21 | }, 22 | { 23 | name: "release", 24 | path: "releaseNode.offset", 25 | min: 0, 26 | max: 1, 27 | lastValue: 0.01, 28 | }, 29 | ] 30 | 31 | /* 32 | * Adds an oscillatorEnvelope to the WebAudioNodes 33 | * 34 | */ 35 | 36 | addVoice() { 37 | const voice = { 38 | signal: this.audioContext.createOscillator(), 39 | envelope: new EnvelopeNode(this.audioContext), 40 | } 41 | 42 | voice.signal.type = this.oscillatorType 43 | voice.signal.connect(voice.envelope.gain) 44 | voice.signal.start() 45 | voice.envelope.connect(this.webAudioNodes.gainNode) 46 | 47 | return voice 48 | } 49 | 50 | removeVoice(voice) { 51 | voice.signal.stop() 52 | voice.signal.disconnect() 53 | voice.envelope.disconnect() 54 | } 55 | 56 | constructor(context: AudioContext, parentEl: Element, name: string) { 57 | super(context, parentEl, name) 58 | 59 | this.webAudioNodes.gainNode = context.createGain() 60 | this.webAudioNodes.gainNode.gain.value = 0.5 61 | this.outputNode = this.webAudioNodes.gainNode 62 | 63 | this.webAudioNodes.attackNode = context.createConstantSource() 64 | this.webAudioNodes.attackNode.start() 65 | this.webAudioNodes.attackNode.connect(nullGain) 66 | this.webAudioNodes.releaseNode = context.createConstantSource() 67 | this.webAudioNodes.releaseNode.start() 68 | this.webAudioNodes.releaseNode.connect(nullGain) 69 | 70 | this.webAudioNodes.attackNode.offset.value = 0.01 71 | this.webAudioNodes.releaseNode.offset.value = 0.01 72 | 73 | //instantiate our oscillators 74 | this.webAudioNodes.voices = {} 75 | 76 | this.setupUI() 77 | 78 | this.voice = this.addVoice() 79 | } 80 | 81 | setup({ wave, gain, attack, release }) { 82 | this.oscillatorType = wave 83 | for (const osc of Object.values(this.webAudioNodes.voices)) { 84 | osc.signal.type = wave 85 | } 86 | this.updateParamIfChanged(0, gain) 87 | this.updateParamIfChanged(1, attack) 88 | this.updateParamIfChanged(2, release) 89 | } 90 | 91 | //TODO oscillator cleanup 92 | play(note, startTime, dur) { 93 | //seconds dur === 0 means noteOff, dur<0 means noteOn. Else play for duration 94 | let voice 95 | if (dur > 0) { 96 | voice = this.addVoice() 97 | } else if (dur < 0) { 98 | voice = this.addVoice() 99 | this.webAudioNodes.voices[note] = voice 100 | } else { 101 | voice = this.webAudioNodes.voices[note] 102 | delete this.webAudioNodes.voices[note] // this is on its way out 103 | setTimeout(() => this.removeVoice(voice), 5000) //5 second deletion? might eat into release time 104 | } 105 | 106 | const frequency = note_to_frequency(note) 107 | voice.signal.frequency.setTargetAtTime(frequency, startTime, 0.001) 108 | 109 | if (dur > 0) { 110 | voice.envelope.play( 111 | startTime, 112 | dur, 113 | 1.0, 114 | this.webAudioNodes.attackNode.offset.value, 115 | 0.1, 116 | 0.9, 117 | this.webAudioNodes.releaseNode.offset.value, 118 | ) 119 | 120 | setTimeout( 121 | () => this.removeVoice(voice), 122 | (startTime - 123 | this.audioContext.currentTime + 124 | dur + 125 | this.webAudioNodes.releaseNode.offset.value) * 126 | 1000 + 127 | 3000, 128 | ) //3 second just to be sure 129 | } else if (dur < 0) { 130 | voice.envelopeGain.gain.setValueAtTime(0, startTime) 131 | voice.envelopeGain.gain.linearRampToValueAtTime( 132 | 1, 133 | startTime + this.webAudioNodes.attackNode.offset.value, 134 | ) 135 | } else if (dur === 0) { 136 | voice.envelopeGain.gain.cancelScheduledValues(startTime) 137 | voice.envelopeGain.gain.setValueAtTime( 138 | voice.envelopeGain.gain.value, 139 | startTime, 140 | ) 141 | voice.envelopeGain.gain.linearRampToValueAtTime( 142 | 0, 143 | startTime + this.webAudioNodes.releaseNode.offset.value, 144 | ) 145 | } 146 | } 147 | 148 | setupUI() { 149 | this.knobsEl = document.createElement("div") 150 | this.knobsEl.className = "knobs" 151 | this.el.appendChild(this.knobsEl) 152 | this.resolveParams() 153 | this.setupKnobs() 154 | } 155 | } 156 | 157 | export { SawSynth } 158 | -------------------------------------------------------------------------------- /ui/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "./effect" 2 | import { Instrument, getTabIndex } from "./instruments" 3 | 4 | const rows = [ 5 | ["2", "3", null, "5", "6", "7"], 6 | ["q", "w", "e", "r", "t", "y", "u"], 7 | ["s", "d", null, "g", "h", "j"], 8 | ["z", "x", "c", "v", "b", "n", "m"], 9 | ] 10 | 11 | const keyToSemitone = { 12 | "2": 13, 13 | "3": 15, 14 | "5": 18, 15 | "6": 20, 16 | "7": 22, 17 | q: 12, 18 | w: 14, 19 | e: 16, 20 | r: 17, 21 | t: 19, 22 | y: 21, 23 | u: 23, 24 | s: 1, 25 | d: 3, 26 | g: 6, 27 | h: 8, 28 | j: 10, 29 | z: 0, 30 | x: 2, 31 | c: 4, 32 | v: 5, 33 | b: 7, 34 | n: 9, 35 | m: 11, 36 | } 37 | 38 | class Keyboard extends Effect { 39 | static friendlyName = "keyboard" 40 | canvasCtx //TODO this is overloaded with audiocontext, rename! 41 | height = 65 42 | width = 120 43 | canvas 44 | pressedKeys: Set 45 | noteRegistrations: Set 46 | 47 | baseKey = 60 //middle C 48 | 49 | constructor(context: AudioContext, parentEl: Element, name: string) { 50 | super(context, parentEl, name) 51 | this.noteRegistrations = new Set() 52 | 53 | this.setupUI() 54 | } 55 | 56 | registerEvents(to: Instrument) { 57 | console.debug(`Keyboard: ${this.name} wired up to ${to.name}`) 58 | this.noteRegistrations.add(to) 59 | } 60 | 61 | deregisterEvents(to: Instrument) { 62 | console.debug(`Keyboard: ${this.name} disconnecting from ${to.name}`) 63 | this.noteRegistrations.delete(to) 64 | } 65 | 66 | async setup() { 67 | return this 68 | } 69 | 70 | setupUI() { 71 | this.el.tabIndex = getTabIndex() 72 | this.knobsEl = document.createElement("div") 73 | this.knobsEl.className = "knobs" 74 | this.el.appendChild(this.knobsEl) 75 | 76 | this.canvas = document.createElement("canvas") 77 | 78 | this.canvas.width = this.width 79 | this.canvas.height = this.height 80 | this.canvas.style.width = this.width 81 | this.canvas.style.height = this.height 82 | this.knobsEl.appendChild(this.canvas) 83 | 84 | this.canvasCtx = this.canvas.getContext("2d") 85 | this.canvasCtx.imageSmoothingEnabled = false 86 | this.canvasCtx.strokeStyle = "white" 87 | 88 | this.pressedKeys = new Set() 89 | this.el.addEventListener("keydown", (event: KeyboardEvent) => { 90 | if ( 91 | keyToSemitone[event.key] !== undefined && 92 | !this.pressedKeys.has(event.key) 93 | ) { 94 | //second conditions stops key repeats 95 | this.pressedKeys.add(event.key) 96 | this.noteRegistrations.forEach((inst) => { 97 | inst.play( 98 | this.baseKey + keyToSemitone[event.key], 99 | this.audioContext.currentTime, 100 | -1, 101 | ) // negative time is forever 102 | }) 103 | } 104 | }) 105 | window.addEventListener("keyup", (event: KeyboardEvent) => { 106 | if (this.pressedKeys.has(event.key)) { 107 | this.pressedKeys.delete(event.key) 108 | if (keyToSemitone[event.key] !== undefined) { 109 | this.noteRegistrations.forEach((inst) => { 110 | inst.play( 111 | this.baseKey + keyToSemitone[event.key], 112 | this.audioContext.currentTime, 113 | 0, 114 | ) // negative time is forever 115 | }) 116 | } 117 | } 118 | }) 119 | 120 | this.draw() 121 | //this.resolveParams() 122 | //this.setupKnobs() 123 | } 124 | 125 | draw() { 126 | this.canvasCtx.fillStyle = "#333" 127 | this.canvasCtx.fillRect(0, 0, this.width, this.height) 128 | this.canvasCtx.font = "10px pixeled" 129 | const gap = 1 130 | const boxWidth = ((this.height - (rows.length + 1)) * gap) / 4 131 | 132 | let y = gap 133 | for (let row = 0; row < rows.length; row++) { 134 | const keys = rows[row] 135 | let x = row % 2 == 1 ? gap : gap + boxWidth / 2 136 | keys.forEach((key) => { 137 | if (key) { 138 | if (row % 2 == 1) { 139 | this.canvasCtx.fillStyle = "white" 140 | this.canvasCtx.strokeStyle = "black" 141 | } else { 142 | this.canvasCtx.fillStyle = "black" 143 | this.canvasCtx.strokeStyle = "white" 144 | } 145 | if (this.pressedKeys.has(key)) { 146 | //override if we're pressing the key 147 | this.canvasCtx.fillStyle = "#555" 148 | } 149 | this.canvasCtx.fillRect(x, y, boxWidth, boxWidth) 150 | 151 | //for the wider characters 152 | let xAdjust = 5 153 | if (key === "j") { 154 | xAdjust = 6 155 | } else if (key === "m") { 156 | xAdjust = 3 157 | } 158 | 159 | this.canvasCtx.strokeText(key, x + xAdjust, y + boxWidth - 4) 160 | } 161 | x += boxWidth + gap + 1 162 | }) 163 | y += boxWidth + gap 164 | } 165 | 166 | window.requestAnimationFrame(this.draw.bind(this)) 167 | } 168 | } 169 | 170 | export { Keyboard } 171 | -------------------------------------------------------------------------------- /ui/css/main.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | /* HTML5 display-role reset for older browsers */ 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | body { 109 | line-height: 1; 110 | -webkit-font-smoothing: none; 111 | -moz-osx-font-smoothing: grayscale; 112 | font-smooth: never; 113 | background: black; 114 | height: 100vh; 115 | width: 100vw; 116 | } 117 | ol, 118 | ul { 119 | list-style: none; 120 | } 121 | blockquote, 122 | q { 123 | quotes: none; 124 | } 125 | blockquote:before, 126 | blockquote:after, 127 | q:before, 128 | q:after { 129 | content: ""; 130 | content: none; 131 | } 132 | table { 133 | border-collapse: collapse; 134 | border-spacing: 0; 135 | } 136 | 137 | .instruments { 138 | height: 100vh; 139 | min-width: 520px; 140 | display: flex; 141 | flex-direction: row; 142 | flex-wrap: wrap; 143 | align-content: flex-start; 144 | } 145 | 146 | .instrument-name { 147 | padding-top: 1px; 148 | } 149 | 150 | .instrument { 151 | display: flex; 152 | justify-content: space-between; 153 | flex-direction: column; 154 | width: fit-content; 155 | height: 90px; 156 | border: solid 1px gray; 157 | padding: 1px; 158 | margin: 1px; 159 | font-family: "ibmvga"; 160 | align-items: center; 161 | } 162 | 163 | .knobs { 164 | display: flex; 165 | align-items: top; 166 | flex-direction: row; 167 | justify-content: space-around; 168 | font-family: "ibmvga"; 169 | width: max-content; 170 | border: solid 1px gray; 171 | padding: 2px; 172 | margin: 1px; 173 | } 174 | 175 | .settings { 176 | display: flex; 177 | align-items: top; 178 | flex-direction: column; 179 | justify-content: space-around; 180 | font-family: "ibmvga"; 181 | width: max-content; 182 | border: solid 1px gray; 183 | padding: 2px; 184 | margin: 1px; 185 | } 186 | 187 | .info { 188 | border: solid 1px gray; 189 | position: absolute; 190 | font-family: "ibmvga"; 191 | right: 0; 192 | bottom: 0; 193 | padding: 2px; 194 | } 195 | 196 | .select { 197 | appearance: none; 198 | } 199 | 200 | .settings-keybindings { 201 | color: white; 202 | background: black; 203 | font-family: "ibmvga"; 204 | } 205 | 206 | .code { 207 | height: 100vh; 208 | display: flex; 209 | flex-direction: column; 210 | justify-content: space-between; 211 | flex-grow: 1; 212 | scrollbar-color: white black; 213 | scrollbar-width: thin; 214 | max-width: calc(100vw - 520px); 215 | order: 0; 216 | } 217 | 218 | .janet-err { 219 | font-family: "ibmvga"; 220 | color: red; 221 | font-size: larger; 222 | } 223 | 224 | .output-channel { 225 | background: black; 226 | color: white; 227 | max-height: 20vh; 228 | overflow-x: hidden; 229 | overflow-y: scroll; 230 | display: flex; 231 | flex-direction: column-reverse; 232 | } 233 | 234 | .main-area { 235 | display: flex; 236 | flex-direction: row; 237 | position: absolute; 238 | left: 0; 239 | top: 0; 240 | width: 100vw; 241 | height: 100vh; 242 | overflow: hidden; 243 | } 244 | 245 | .intro { 246 | height: 100vh; 247 | width: 100vw; 248 | display: flex; 249 | justify-content: center; 250 | align-items: center; 251 | color: white; 252 | background: black; 253 | cursor: pointer; 254 | } 255 | 256 | .intro-title { 257 | } 258 | .about-area { 259 | height: 100vh; 260 | width: 100vw; 261 | display: flex; 262 | flex-direction: column; 263 | justify-content: center; 264 | align-items: center; 265 | color: white; 266 | background: black; 267 | font-family: "ibmvga"; 268 | font-size: "large"; 269 | } 270 | 271 | .about-area > p { 272 | margin: 1em; 273 | } 274 | 275 | a:link { 276 | color: white; 277 | } 278 | 279 | a:visited { 280 | color: white; 281 | } 282 | 283 | .about-area > div { 284 | margin: 1em; 285 | border-top: 1px solid white; 286 | width: 22rem; 287 | } 288 | 289 | .intro-clicked { 290 | display: none; 291 | } 292 | -------------------------------------------------------------------------------- /ui/editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, basicSetup } from "codemirror" 2 | import { keymap } from "@codemirror/view" 3 | import { janet } from "codemirror-lang-janet" 4 | import { vim } from "@replit/codemirror-vim" 5 | import { emacs } from "@replit/codemirror-emacs" 6 | import { Prec } from "@codemirror/state" 7 | 8 | import { tutor } from "./tutor" 9 | 10 | import { basicDark } from "./dark_theme" 11 | 12 | import localforage from "localforage" 13 | 14 | let editor 15 | 16 | //TODO move evaluator to a web worker 17 | const evaluateAfter_ms = 300 //stops thrashing of the evaluator and keeps good scheduling 18 | let reloadEvent 19 | 20 | function scheduleCompilation(after, onCodeChange) { 21 | reloadEvent && clearTimeout(reloadEvent) 22 | if (after === 0) { 23 | onCodeChange() 24 | } else { 25 | reloadEvent = setTimeout(onCodeChange, evaluateAfter_ms) 26 | } 27 | } 28 | 29 | const defaultCode = `# Hello 30 | # Trane is a music playground 31 | # It's written in Janet, a lisp-like language 32 | 33 | # To execute the code below, press Alt+Enter. (⌥+Enter on Mac) 34 | 35 | (chain 36 | (sample :hello-sample :url "samples/Cmin 7th 3.wav" :pitch :c3) 37 | #(biquad :hello-filter :filter_type "lowpass") 38 | :out 39 | ) 40 | 41 | (live_loop :player 42 | (play (pick 12 24 36) :hello-sample :dur 64) 43 | #(target :hello-filter :frequency (rand 50 10000) 10) 44 | (sleep 6) 45 | ) 46 | # All those samples playing at once might cause some clipping. Try to adjust the level with the gain knob on the right. 47 | # try uncommenting both the lines above. 48 | 49 | # Have a look at the about page for more info: https://lisp.trane.studio/about.html 50 | ` 51 | 52 | async function loadTrackURL() { 53 | const urlParams = new URLSearchParams(window.location.search) 54 | const trackURL = urlParams.get("t") 55 | let trackString 56 | 57 | if (trackURL) { 58 | trackString = await fetch(trackURL) 59 | } 60 | return trackString?.text() 61 | } 62 | 63 | async function initCodeEditor(el, onCodeChange, onCodeReload) { 64 | const savedScript = await loadSavedScript() 65 | const keybindings = await loadKeybindings() 66 | 67 | const trackFromURL = await loadTrackURL() 68 | 69 | const tutorDoc = tutor !== null ? "#" : undefined 70 | 71 | let keybindingsExtention 72 | if (keybindings === "vim") { 73 | keybindingsExtention = vim 74 | } else if (keybindings === "emacs") { 75 | keybindingsExtention = emacs 76 | } 77 | 78 | const extensions = [ 79 | basicSetup, 80 | janet(), 81 | EditorView.updateListener.of(function (viewUpdate: ViewUpdate) { 82 | if (viewUpdate.docChanged) { 83 | scheduleCompilation(evaluateAfter_ms, onCodeChange) 84 | } 85 | }), 86 | 87 | Prec.highest(keymap.of([{ key: "Alt-Enter", run: onCodeReload }])), 88 | EditorView.theme({ 89 | "&": { minHeight: "10rem", flexBasis: "10rem", flexGrow: "1" }, 90 | ".cm-scroller": { overflow: "auto" }, 91 | }), 92 | basicDark, 93 | ] 94 | 95 | keybindingsExtention ? extensions.push(keybindingsExtention()) : null 96 | 97 | editor = new EditorView({ 98 | doc: tutorDoc || trackFromURL || savedScript || defaultCode, 99 | extensions, 100 | parent: el, 101 | }) 102 | 103 | el.ondragenter = dragenter 104 | el.ondragover = dragover 105 | el.ondrop = dropFile 106 | } 107 | 108 | async function saveCurrentScript() { 109 | if(window.location.origin !== window.location.href.slice(0, -1)){ 110 | window.history.pushState({}, "", window.location.origin) //TODO get rid of this once we have a server 111 | } 112 | const value = await localforage.setItem( 113 | "saved_script", 114 | editor.state.doc.toString(), 115 | ) 116 | } 117 | 118 | async function loadSavedScript(): Promise { 119 | return await localforage.getItem("saved_script") 120 | } 121 | 122 | async function loadKeybindings(): Promise { 123 | return await localStorage.getItem("keybindings") 124 | } 125 | 126 | async function saveKeybindings(keybindings) { 127 | return await localStorage.setItem("keybindings", keybindings) 128 | } 129 | 130 | async function dropFile(e) { 131 | e.stopPropagation() 132 | e.preventDefault() 133 | 134 | const dt = e.dataTransfer 135 | const files = dt.files 136 | 137 | if (files.length == 1) { 138 | try { 139 | const value = await localforage.setItem( 140 | files[0].name, 141 | files[0].arrayBuffer(), 142 | ) 143 | 144 | const cursor = editor.state.selection.main.head 145 | const transaction = editor.state.update({ 146 | changes: { 147 | from: cursor, 148 | insert: `"local://${files[0].name}"`, 149 | }, 150 | // the next 2 lines will set the appropriate cursor position after inserting the new text. 151 | selection: { anchor: cursor + 1 }, 152 | scrollIntoView: true, 153 | }) 154 | 155 | if (transaction) { 156 | editor.dispatch(transaction) 157 | } 158 | 159 | // This code runs once the value has been loaded 160 | // from the offline store. 161 | } catch (err) { 162 | // This code runs if there were any errors. 163 | console.log(err) 164 | } 165 | } else { 166 | console.error("more than one file dropped into the sample bay") 167 | } 168 | } 169 | 170 | function dragenter(e) { 171 | e.stopPropagation() 172 | e.preventDefault() 173 | } 174 | 175 | function dragover(e) { 176 | e.stopPropagation() 177 | e.preventDefault() 178 | } 179 | 180 | export { initCodeEditor, editor, saveCurrentScript, scheduleCompilation } 181 | -------------------------------------------------------------------------------- /ui/instruments.ts: -------------------------------------------------------------------------------- 1 | import { base0B as atomColor } from "./dark_theme" 2 | import { resolvePath } from "./utils" 3 | import { Knob } from "./knob" 4 | 5 | let tabIndex = 0 6 | 7 | const getTabIndex = () => { 8 | tabIndex += 1 9 | return tabIndex 10 | } 11 | 12 | interface Voice { 13 | signal: AudioNode 14 | envelopeGain: GainNode 15 | } 16 | 17 | function isVoice(audioNode: AudioNode | Voice) { 18 | return "signal" in audioNode && "envelopeGain" in audioNode 19 | } 20 | 21 | interface Param { 22 | name: string 23 | path: string // string representation of a WebAudioNode parameter. Take a look at the WebAudioNodes interface below. Each of those Nodes has several params. Used to map names to WebAudio paths in the janet code 24 | min: number 25 | max: number 26 | logScale: boolean 27 | lastValue: number //sets the last value this was changed from the instrument definition. We set the param to this value if the setup() function recieves a different value to this one. We don't want to always override in case this is performance. Also the initial value that the node is set to 28 | isWorklet?: boolean //AudioworkletNodes, unlike regular audioNodes place their parameters in a ParameterMap, which makes resolution a bit trickier 29 | } 30 | 31 | type WebAudioNodes = Record 32 | 33 | class GraphNode { 34 | static friendlyName: string //name given in the language 35 | static params: Array 36 | audioContext: AudioContext 37 | webAudioNodes: WebAudioNodes = {} 38 | outputNode: AudioNode 39 | name: string 40 | el: HTMLElement 41 | knobsEl: Element 42 | resolvedParams: Array 43 | 44 | constructor(context: AudioContext, parentEl: Element, name: string) { 45 | this.audioContext = context 46 | this.name = name 47 | 48 | this.el = document.createElement("div") 49 | this.el.className = "instrument" 50 | parentEl.appendChild(this.el) 51 | 52 | const nameEl = document.createElement("p") 53 | nameEl.className = "instrument-name" 54 | nameEl.style.color = atomColor 55 | nameEl.innerText = this.name 56 | this.el.appendChild(nameEl) 57 | } 58 | 59 | async setup() { 60 | return this 61 | } 62 | 63 | setupKnobs() { 64 | //TODO knob deletion 65 | this.params.forEach((param) => { 66 | const resolvedParam = this.resolveParam(param) 67 | new Knob( 68 | this.audioContext, 69 | this.knobsEl, 70 | resolvedParam, 71 | param.name, 72 | param.min, 73 | param.max, 74 | param.logScale, 75 | ) 76 | }) 77 | } 78 | 79 | updateParamIfChanged(paramIndex, newVal) { 80 | if (newVal) { 81 | const parsed = parseFloat(newVal) 82 | if (parsed !== this.params[paramIndex].lastValue) { 83 | this.params[paramIndex].lastValue = parsed 84 | this.resolvedParams[paramIndex].setTargetAtTime( 85 | parsed, 86 | this.audioContext.currentTime, 87 | 0.01, 88 | ) 89 | } 90 | } 91 | } 92 | 93 | /* 94 | * Resolves this effects parameters, puts a reference to each audio param inside of resolvedParams in the order they appear in params for fast lookup at play time. 95 | */ 96 | resolveParam(param: Parameter) { 97 | if (!param.isWorklet) { 98 | const splitPath = param.path.split(".") 99 | const resolvedParam = resolvePath(this.webAudioNodes, splitPath) 100 | return resolvedParam 101 | } else { 102 | const splitPath = param.path.split(".") // worklet paths are of the form "node.param" 103 | const node = this.webAudioNodes[splitPath[0]] 104 | const resolvedParam = node.parameters.get(splitPath[1]) 105 | return resolvedParam 106 | } 107 | } 108 | 109 | /* 110 | * Resolves this effects parameters, puts a reference to each audio param inside of resolvedParams in the order they appear in params for fast lookup at play time. 111 | */ 112 | resolveParams() { 113 | this.resolvedParams = [] 114 | 115 | this.params.forEach((param) => { 116 | const resolvedParam = this.resolveParam(param) 117 | this.resolvedParams.push(resolvedParam) 118 | }) 119 | } 120 | 121 | //eslint-disable-next-line 122 | change( 123 | paramIndex: number, 124 | type: number, 125 | to: number, 126 | startTime: number, 127 | dur: number, 128 | ) { 129 | if (type < 0) { 130 | this.resolvedParams[paramIndex].setTargetAtTime(to, startTime, -type) 131 | } else if (type === 0) { 132 | this.resolvedParams[paramIndex].linearRampToValueAtTime(to, startTime) 133 | } else if (type === 1) { 134 | this.resolvedParams[paramIndex].exponentialRampToValueAtTime( 135 | to, 136 | startTime, 137 | ) 138 | } else if (type === 2) { 139 | this.resolvedParams[paramIndex].setValueAtTime(to, startTime) 140 | } 141 | } 142 | 143 | disconnect() { 144 | Object.values(this.webAudioNodes).forEach((node) => { 145 | node.disconnect() 146 | }) 147 | this.el?.remove() 148 | } 149 | 150 | _generate_params() { 151 | //gyah, get rid of this somewhere 152 | console.log( 153 | `:${this.friendlyName} @{ 154 | ${this.params?.map((param, i) => ":" + param.name + " " + i).join("\n")} 155 | } 156 | `, 157 | ) 158 | } 159 | } 160 | 161 | class Instrument extends GraphNode { 162 | //eslint-disable-next-line 163 | play(note, startTime, dur) {} 164 | 165 | disconnect() { 166 | Object.values(this.webAudioNodes).forEach((node) => { 167 | if (!node.disconnect) { 168 | Object.values(node).forEach((n) => { 169 | if (isVoice(n)) { 170 | n.signal.stop() 171 | n.signal.disconnect() 172 | n.envelopeGain.disconnect() 173 | } 174 | }) 175 | } else { 176 | node.disconnect() 177 | } 178 | }) 179 | this.el?.remove() 180 | } 181 | } 182 | 183 | export { GraphNode, Instrument, WebAudioNodes, Param, getTabIndex, isNil} 184 | -------------------------------------------------------------------------------- /ui/pitched_sampler.ts: -------------------------------------------------------------------------------- 1 | import { Sampler } from "./sampler_generic" 2 | import { note_to_frequency } from "./utils" 3 | import { nullGain } from "./audio" 4 | 5 | class PitchedSampler extends Sampler { 6 | static friendlyName = "pitched_sampler" 7 | samplePitch: number 8 | params = [ // TODO why do we override the defaults?? 9 | { 10 | name: "gain", 11 | path: "gainNode.gain", 12 | min: 0.001, 13 | max: 1, 14 | lastValue: 1.0, 15 | }, 16 | { 17 | name: "attack", 18 | path: "attackNode.offset", 19 | min: 0, 20 | max: 1, 21 | lastValue: 0.01, 22 | }, 23 | { 24 | name: "release", 25 | path: "releaseNode.offset", 26 | min: 0, 27 | max: 1, 28 | lastValue: 0.01, 29 | }, 30 | { 31 | name: "loop_start", 32 | path: "loopStart.offset", 33 | min: 0, 34 | max: 1, 35 | lastValue: Number.POSITIVE_INFINITY, 36 | }, 37 | { 38 | name: "loop_end", 39 | path: "loopEnd.offset", 40 | min: 0, 41 | max: 1, 42 | lastValue: Number.NEGATIVE_INFINITY, 43 | }, 44 | ] 45 | 46 | //TODO this is shared almost verbatim between the synth and this. Maybe refactor into instrument? 47 | addVoice() { 48 | const voice = { 49 | envelopeGain: this.audioContext.createGain(), 50 | } 51 | 52 | voice.envelopeGain.gain.setValueAtTime(0, this.audioContext.currentTime) 53 | voice.envelopeGain.connect(this.webAudioNodes.gainNode) 54 | 55 | return voice 56 | } 57 | 58 | removeVoice(voice) { 59 | voice.envelopeGain.disconnect() 60 | voice.signal.stop(this.audioContext.currentTime) 61 | } 62 | 63 | constructor(context: AudioContext, parentEl: Element, name: string) { 64 | super(context, parentEl, name) 65 | 66 | this.webAudioNodes.attackNode = context.createConstantSource() 67 | this.webAudioNodes.attackNode.start() 68 | this.webAudioNodes.attackNode.connect(nullGain) 69 | this.webAudioNodes.releaseNode = context.createConstantSource() 70 | this.webAudioNodes.releaseNode.start() 71 | this.webAudioNodes.releaseNode.connect(nullGain) 72 | 73 | this.webAudioNodes.loopStart = context.createConstantSource() 74 | this.webAudioNodes.loopStart.start() 75 | this.webAudioNodes.loopStart.connect(nullGain) 76 | 77 | this.webAudioNodes.loopEnd = context.createConstantSource() 78 | this.webAudioNodes.loopEnd.start() 79 | this.webAudioNodes.loopEnd.connect(nullGain) 80 | 81 | this.webAudioNodes.attackNode.offset.value = 82 | this.webAudioNodes.releaseNode.offset.value = 0.01 83 | 84 | //instantiate our oscillators 85 | this.webAudioNodes.voices = {} 86 | this.setupUI() 87 | } 88 | 89 | async setup({ url, pitch, gain, attack, release, loop_start, loop_end }) { 90 | if (pitch) { 91 | this.samplePitch = parseFloat(pitch) 92 | } else { 93 | this.samplePitch = 69.0 94 | } 95 | 96 | this.updateParamIfChanged(0, gain) 97 | this.updateParamIfChanged(1, attack) 98 | this.updateParamIfChanged(2, release) 99 | this.updateParamIfChanged(3, loop_start) 100 | this.updateParamIfChanged(4, loop_end) 101 | 102 | super.setup(url) 103 | 104 | return this 105 | } 106 | 107 | play(note, startTime, dur) { 108 | let voice 109 | 110 | const sampleFreq = note_to_frequency(this.samplePitch) 111 | const desiredFreq = note_to_frequency(note) 112 | 113 | const playbackRate = desiredFreq / sampleFreq 114 | 115 | if (dur > 0) { 116 | voice = this.addVoice() 117 | } else if (dur < 0) { 118 | voice = this.addVoice() 119 | this.webAudioNodes.voices[note] = voice 120 | } else { 121 | voice = this.webAudioNodes.voices[note] 122 | delete this.webAudioNodes.voices[note] // this is on its way out 123 | setTimeout(() => this.removeVoice(voice), 5000) //5 second deletion? might eat into release time 124 | } 125 | 126 | if (dur > 0) { 127 | voice.envelopeGain.gain.setValueAtTime(0, startTime) 128 | voice.envelopeGain.gain.linearRampToValueAtTime( 129 | 1, 130 | startTime + Math.min(this.webAudioNodes.attackNode.offset.value, dur), 131 | ) 132 | voice.envelopeGain.gain.setValueAtTime(1, startTime + dur) 133 | voice.envelopeGain.gain.linearRampToValueAtTime( 134 | 0, 135 | startTime + dur + this.webAudioNodes.releaseNode.offset.value, 136 | ) 137 | 138 | voice.signal = this.playSample( 139 | startTime, 140 | dur + this.webAudioNodes.releaseNode.offset.value, 141 | 0, 142 | playbackRate, 143 | voice.envelopeGain, 144 | this.webAudioNodes.loopStart.offset.value*this.buffer.duration, 145 | this.webAudioNodes.loopEnd.offset.value*this.buffer.duration, 146 | ) 147 | setTimeout( 148 | () => this.removeVoice(voice), 149 | (startTime - 150 | this.audioContext.currentTime + 151 | dur + 152 | this.webAudioNodes.releaseNode.offset.value) * 153 | 1000 + 154 | 3000, 155 | ) //3 second just to be sure 156 | } else if (dur < 0) { 157 | voice.envelopeGain.gain.setValueAtTime(0, startTime) 158 | voice.envelopeGain.gain.linearRampToValueAtTime( 159 | 1, 160 | startTime + this.webAudioNodes.attackNode.offset.value, 161 | ) 162 | voice.signal = this.playSample( 163 | startTime, 164 | dur + this.webAudioNodes.releaseNode.offset.value, 165 | 0, 166 | playbackRate, 167 | voice.envelopeGain, 168 | ) 169 | } else if (dur === 0) { 170 | voice.envelopeGain.gain.cancelScheduledValues(startTime) 171 | voice.envelopeGain.gain.setValueAtTime( 172 | voice.envelopeGain.gain.value, 173 | startTime, 174 | ) 175 | voice.envelopeGain.gain.linearRampToValueAtTime( 176 | 0, 177 | startTime + this.webAudioNodes.releaseNode.offset.value, 178 | ) 179 | } 180 | } 181 | 182 | draw() { 183 | super.draw() 184 | window.requestAnimationFrame(this.draw.bind(this)) 185 | } 186 | } 187 | 188 | export { PitchedSampler } 189 | -------------------------------------------------------------------------------- /ui/tutor.ts: -------------------------------------------------------------------------------- 1 | import { editor } from "./editor" 2 | import { instrumentsByName } from "./audio" 3 | 4 | const urlParams = new URLSearchParams(window.location.search) 5 | const tutor = urlParams.get("tutor") 6 | 7 | interface ITutorBlock { 8 | text: string 9 | timePerKey: number // in seconds 10 | } 11 | 12 | interface ITutorInstruction { 13 | condition: (string) => boolean //takes code, returns if condition has been satisfied 14 | codeBlock: ITutorBlock 15 | where: (string) => number //takes code, returns position in code 16 | timeUntilNext?: number 17 | } 18 | 19 | function sleep(s) { 20 | return new Promise((resolve) => setTimeout(resolve, 1000 * s)) 21 | } 22 | 23 | let programCounter = 0 24 | let currentlyAnimating = false // lock on the animation 25 | 26 | const tutorInstructions: Array = [ 27 | { 28 | condition: (_) => true, 29 | codeBlock: { text: " Hello.\n", timePerKey: 1.0 / 30 }, 30 | where: (code) => code.length, 31 | timeUntilNext: 0.5, 32 | }, 33 | { 34 | condition: (_) => true, 35 | codeBlock: { 36 | text: "# trane is a music playground.\n# To execute the code below, press Alt+Enter. (⌥+Enter on Mac)\n", 37 | timePerKey: 1.0 / 30, 38 | }, 39 | where: (code) => code.length, 40 | timeUntilNext: 0.3, 41 | }, 42 | { 43 | condition: (_) => true, 44 | codeBlock: { 45 | text: ` 46 | (chain # chain these together 47 | (sample :hello-sample :url "samples/Cmin 7th 3.wav" :pitch :C3) # create a sampler 48 | # (biquad :hello-filter :filter_type "lowpass") # create a lowpass filter 49 | :out # plug them into the output 50 | ) 51 | `, 52 | timePerKey: 1.0 / 30, 53 | }, 54 | where: (code) => code.length, 55 | }, 56 | { 57 | condition: (_) => true, 58 | codeBlock: { 59 | text: "\n# Very nice. You can create instruments in trane.\n", 60 | timePerKey: 1.0 / 30, 61 | }, 62 | where: (code) => code.length, 63 | timeUntilNext: 1, 64 | }, 65 | { 66 | condition: (_) => true, 67 | codeBlock: { 68 | text: "# The sample you've loaded shows up on the right.\n", 69 | timePerKey: 1.0 / 30, 70 | }, 71 | where: (code) => code.length, 72 | timeUntilNext: 1, 73 | }, 74 | { 75 | condition: (_) => true, 76 | codeBlock: { 77 | text: "# Lets try sending some notes to the sampler.\n", 78 | timePerKey: 1.0 / 30, 79 | }, 80 | where: (code) => code.length, 81 | timeUntilNext: 1, 82 | }, 83 | { 84 | condition: (_) => true, 85 | codeBlock: { 86 | text: "\n# Try executing the code below. (Alt+Enter or ⌥+Enter)\n", 87 | timePerKey: 1.0 / 30, 88 | }, 89 | where: (code) => code.length, 90 | timeUntilNext: 1, 91 | }, 92 | { 93 | condition: (_) => true, 94 | codeBlock: { 95 | text: ` 96 | (live_loop :player # loop forever 97 | (play (pick :C1 :C2 :C3) :hello-sample :dur 64) # pick a note in 3 octaves, send it to our sampler, play for at most 64 beats 98 | # (target :hello-filter :frequency (rand 50 10000) 10) # change the filter cutoff to a random frequency 99 | (sleep 6) # sleep for 6 beats 100 | ) 101 | `, 102 | timePerKey: 1.0 / 30, 103 | }, 104 | where: (code) => code.length, 105 | }, 106 | { 107 | condition: (_) => true, 108 | codeBlock: { 109 | text: "\n# Lovely. I reckon you're going to hear some distortion and clipping soon.\n# The sample is a bit too loud. Try turning it down with the gain knob on the right.\n", 110 | timePerKey: 1.0 / 30, 111 | }, 112 | where: (code) => code.length, 113 | timeUntilNext: 9, 114 | }, 115 | { 116 | condition: (_) => true, 117 | codeBlock: { 118 | text: "\n# OK. You can try uncommenting the 'biquad' and 'target' lines.\n# Remember to press (Alt+Enter or ⌥+Enter) to evaluate your changes\n", 119 | timePerKey: 1.0 / 30, 120 | }, 121 | where: (code) => code.length, 122 | }, 123 | { 124 | condition: () => instrumentsByName[":hello-filter"], 125 | codeBlock: { 126 | text: "\n# Great stuff. Please feel free to mess around with the code some more.\n\n# For more information, docs and examples check out the github:\n# https://github.com/gwegash/trane", 127 | timePerKey: 1.0 / 30, 128 | }, 129 | where: (code) => code.length, 130 | }, 131 | ] 132 | 133 | async function continueTutorial() { 134 | if (tutor === null) { 135 | return 136 | } 137 | const code = editor.state.doc.toString() 138 | const currentInstruction = tutorInstructions[programCounter] 139 | if (programCounter >= tutorInstructions.length) { 140 | console.log("end of tutorial") 141 | return 142 | } 143 | 144 | if (currentInstruction.condition(code) && !currentlyAnimating) { 145 | currentlyAnimating = true 146 | // TODO disable updates on the document 147 | const whereInDoc = currentInstruction.where(code) 148 | // Insert text at the start of the document 149 | 150 | //reduces-concats spaces to avoid the whitespace timeout 151 | const codeBlockReduced = currentInstruction.codeBlock.text.split("").reduce( 152 | (accum, val) => { 153 | val === " " ? accum.push(accum.pop() + " ") : accum.push(val) 154 | return accum 155 | }, 156 | [""], 157 | ) 158 | 159 | let currentWritePos = whereInDoc 160 | for (let i = 0; i < codeBlockReduced.length; i++) { 161 | editor.dispatch({ 162 | changes: { from: currentWritePos, insert: codeBlockReduced[i] }, 163 | }) 164 | 165 | currentWritePos += codeBlockReduced[i].length 166 | await sleep(currentInstruction.codeBlock.timePerKey) 167 | } 168 | programCounter++ 169 | currentlyAnimating = false 170 | if (currentInstruction.timeUntilNext) { 171 | //we continue straight till the next one 172 | setTimeout(continueTutorial, currentInstruction.timeUntilNext * 1000) 173 | } 174 | } 175 | } 176 | 177 | export { tutor, continueTutorial } 178 | -------------------------------------------------------------------------------- /ui/audio.ts: -------------------------------------------------------------------------------- 1 | import { SawSynth } from "./sineInstrument" 2 | import { Sampler } from "./sampler_instrument" 3 | import { BreakbeatSampler } from "./breakbeat_instrument" 4 | import { PitchedSampler } from "./pitched_sampler" 5 | import { ConvolutionReverb } from "./reverb" 6 | import { Compressor } from "./compressor" 7 | import { Delay } from "./delay" 8 | import { Distortion } from "./distortion" 9 | import { Gain } from "./gain" 10 | import { Output } from "./master_out" 11 | import { LoopInstrument } from "./loop_instrument" 12 | import { Biquad } from "./biquad" 13 | import { Oscillator } from "./oscillator" 14 | import { Constant } from "./constant" 15 | import { Scope } from "./scope" 16 | import { LFO } from "./lfo" 17 | import { MIDIInst } from "./midi_inst" 18 | import { Chorus } from "./chorus" 19 | import { LineIn } from "./line_in_inst" 20 | import { Panner } from "./panner" 21 | import { LadderFilter } from "./ladder_filter" 22 | import { Keyboard } from "./keyboard" 23 | import type { GraphNode, Instrument } from "./instruments" 24 | import type { Effect } from "./effect" 25 | import { Wire } from "./wire" 26 | import "./css/fonts.css" 27 | import { bpm, instrumentElement as instrumentEl } from "./index" 28 | import { init as initLoopManager } from "./loop_manager" 29 | 30 | let instruments 31 | const instrumentsByName: Record = {} //a mapping from instrumentName to 32 | let context 33 | let nullGain //for chrome based implementation details on constant sources 34 | 35 | const instMap = Object.fromEntries( 36 | [ 37 | Output, 38 | SawSynth, 39 | Gain, 40 | Sampler, 41 | BreakbeatSampler, 42 | PitchedSampler, 43 | ConvolutionReverb, 44 | Delay, 45 | MIDIInst, 46 | Distortion, 47 | Keyboard, 48 | Compressor, 49 | Biquad, 50 | Constant, 51 | Oscillator, 52 | LFO, 53 | Scope, 54 | Panner, 55 | LoopInstrument, 56 | Chorus, 57 | LineIn, 58 | LadderFilter, 59 | ].map((instDef) => [instDef.friendlyName, instDef]), 60 | ) 61 | 62 | function friendlyNameToInstrument(friendlyName, name) { 63 | //TODO refactor this 64 | if (friendlyName === "wire") { 65 | return new Wire(name) 66 | } else { 67 | return new instMap[friendlyName](context, instrumentEl, name) 68 | } 69 | } 70 | 71 | async function initWorklets() { 72 | await Promise.all( 73 | [ 74 | "loop_worker.js", //TODO rename to worklet 75 | "filter_worklet.js", 76 | ].map( 77 | async (worker_url) => await context.audioWorklet.addModule(worker_url), 78 | ), 79 | ) 80 | } 81 | async function initAudio() { 82 | //don't bother if we've already started the context 83 | if (context) { 84 | return 85 | } 86 | 87 | console.log(Output.friendlyName) 88 | context = new AudioContext({ sampleRate: 48000, latencyHint: 0 }) 89 | initLoopManager() //no-op if already initialised 90 | nullGain = context.createGain() 91 | nullGain.gain.value = 0.0 92 | nullGain.connect(context.destination) 93 | 94 | // init worklet modules 95 | await initWorklets() 96 | } 97 | 98 | async function newInstrumentMappings(new_instrument_mappings) { 99 | const newInstruments = new Array(new_instrument_mappings.size()) //our new channel -> instrument array 100 | 101 | for (let i = 0; i < new_instrument_mappings.size(); i++) { 102 | const instrument_mapping = new_instrument_mappings.get(i) 103 | 104 | let inst 105 | 106 | // if we have already instantiated this instrument 107 | if (instrumentsByName[instrument_mapping.name]) { 108 | inst = instrumentsByName[instrument_mapping.name] 109 | } else { 110 | //we need to instantiate it 111 | inst = friendlyNameToInstrument( 112 | instrument_mapping.args.get(0).slice(1), 113 | instrument_mapping.name 114 | ) 115 | instrumentsByName[instrument_mapping.name] = inst 116 | } 117 | 118 | const argsMap = {} //call setup on already-instantiated instruments. TODO notice if an instrument has changed type 119 | for (let j = 1; j < instrument_mapping.args.size(); j += 2) { 120 | const val = instrument_mapping.args.get(j + 1) 121 | //Nils to undefined 122 | argsMap[instrument_mapping.args.get(j).slice(1)] = val === "nil" ? undefined : val 123 | } 124 | 125 | inst.setup(argsMap) 126 | 127 | newInstruments[instrument_mapping.channel] = inst 128 | } 129 | 130 | // handle deletion of instruments that have gone 131 | 132 | const instsToDelete = instruments?.filter( 133 | (maybeOldInst: Instrument) => 134 | !newInstruments.find((newInst) => newInst.name == maybeOldInst.name), 135 | ) 136 | 137 | instsToDelete?.forEach((inst) => deleteInstrument(inst.name)) 138 | 139 | instruments = newInstruments 140 | window.instruments = instruments 141 | } 142 | 143 | function deleteInstrument(name) { 144 | //TODO should delete an instrument after some time (for now lets just set up a time after which all sounds should have stopped) 145 | // 146 | console.log("deleting instrument " + name) 147 | const inst = instrumentsByName[name] 148 | instrumentsByName[name] = undefined 149 | setTimeout(() => inst.disconnect(), 10) //disconnect all webAudioNodes after 10ms 150 | } 151 | 152 | // I think this needs a rename 153 | function play(channel, note, vel, startTime, dur) { 154 | //seconds 155 | if (channel >= 0) { 156 | //TODO rename this to routeEvent or something, it handles changes and plays 157 | instruments[channel].play(note, startTime, dur) 158 | } else { 159 | const chanNormalized = -channel - 1 160 | const paramIndex = ((1 << 5) - 1) & chanNormalized 161 | const channelIndex = chanNormalized >> 5 162 | //console.log(`param: ${paramIndex} chan: ${channelIndex}`) 163 | instruments[channelIndex].change(paramIndex, vel, note, startTime, dur) 164 | } 165 | } 166 | 167 | function instruments2GraphViz() { 168 | return instruments 169 | .filter((inst) => inst.friendlyName == "wire") 170 | .map((wire) => { 171 | return `\"${wire.from}\" -> \"${wire.to}\" [label = \"${wire.toParam ? ":" + wire.toParam : ""}\"];` 172 | }) 173 | .join("\n") 174 | } 175 | 176 | window.instruments2GraphViz = instruments2GraphViz 177 | 178 | export { 179 | initAudio, 180 | play, 181 | context, 182 | newInstrumentMappings, 183 | instrumentsByName, 184 | instMap, 185 | getTabIndex, 186 | nullGain, 187 | } 188 | -------------------------------------------------------------------------------- /ui/sampler_generic.ts: -------------------------------------------------------------------------------- 1 | import { Instrument } from "./instruments" 2 | import { Knob } from "./knob" 3 | import { loadSample } from "./utils" 4 | 5 | interface Playhead { 6 | startTime: number 7 | startTimeInAudio: number 8 | dur: number 9 | playbackRate: number 10 | } 11 | 12 | // All sample times/quantities are normalised to the length of the sample 13 | // samplers -> hpf -> lpf -> gain -> output 14 | class Sampler extends Instrument { 15 | buffer: AudioBuffer 16 | canvas: Element 17 | ctx //TODO this is overloaded with audiocontext, rename! 18 | height = 65 19 | width = 350 20 | sampleURL: string 21 | 22 | playheads: Set 23 | 24 | constructor(context: AudioContext, parentEl: Element, name: string) { 25 | super(context, parentEl, name) 26 | this.playheads = new Set() 27 | 28 | this.webAudioNodes.gainNode = context.createGain() 29 | this.webAudioNodes.gainNode.gain.value = 1.0 30 | this.outputNode = this.webAudioNodes.gainNode 31 | } 32 | 33 | async setup(sampleURL) { 34 | if (this.sampleURL !== sampleURL) { 35 | await this.loadSample(sampleURL) 36 | } 37 | 38 | return this 39 | } 40 | 41 | async loadSample(sampleURLRaw) { 42 | const buffer = await loadSample(sampleURLRaw) 43 | if (buffer) { 44 | const decoded = await this.audioContext.decodeAudioData(buffer) 45 | this.buffer = decoded 46 | this.sampleURL = sampleURLRaw 47 | } else { 48 | this.buffer = undefined 49 | this.sampleURL = undefined 50 | } 51 | } 52 | 53 | addPlayhead(playhead: Playhead) { 54 | this.playheads.add(playhead) 55 | setTimeout( 56 | () => this.playheads.delete(playhead), 57 | (playhead.startTime - this.audioContext.currentTime + playhead.dur) * 58 | 1000 + 59 | 50, 60 | ) 61 | } 62 | 63 | playSample( 64 | startTime, 65 | dur, 66 | timeInAudio, 67 | rate, 68 | envelopeNode: AudioNode | undefined = undefined, 69 | loop_start, 70 | loop_end, 71 | ) { 72 | if (this.buffer) { 73 | const player = this.audioContext.createBufferSource() 74 | player.playbackRate.value = rate 75 | player.connect(envelopeNode ? envelopeNode : this.webAudioNodes.gainNode) 76 | player.buffer = this.buffer 77 | player.loop = loop_start >= 0 && loop_start < this.buffer.duration && loop_end > 0 && loop_end <= this.buffer.duration 78 | if(player.loop){ 79 | player.loopStart = loop_start 80 | player.loopEnd = loop_end 81 | } 82 | player.start(startTime, timeInAudio) 83 | if (dur >= 0) { 84 | player.stop(startTime + dur) 85 | } 86 | 87 | this.addPlayhead({ 88 | startTime, 89 | dur, 90 | startTimeInAudio: timeInAudio, 91 | playbackRate: player.playbackRate.value, 92 | }) //TODO looping do i even want looping? surely easier to say na and leave the user to loop 93 | return player 94 | } 95 | } 96 | 97 | setupUI() { 98 | this.knobsEl = document.createElement("div") 99 | this.knobsEl.className = "knobs" 100 | this.el.appendChild(this.knobsEl) 101 | 102 | this.canvas = document.createElement("canvas") 103 | 104 | this.canvas.width = this.width 105 | this.canvas.height = this.height 106 | this.canvas.style.width = this.width 107 | this.canvas.style.height = this.height 108 | this.knobsEl.appendChild(this.canvas) 109 | 110 | this.ctx = this.canvas.getContext("2d") 111 | this.ctx.strokeStyle = "white" 112 | this.draw() 113 | 114 | this.resolveParams() 115 | this.setupKnobs() 116 | } 117 | 118 | draw() { 119 | this.ctx.clearRect(0, 0, this.width, this.height) 120 | 121 | this.ctx.font = "5px pixeled" 122 | 123 | this.ctx.beginPath() 124 | this.ctx.setLineDash([]) 125 | this.ctx.moveTo(0, this.height / 2) 126 | 127 | //draw our waveform 128 | let x = 0 129 | 130 | //TODO should we allow this? I think so, or should we enforce that the sample is loaded before we get here? I think this is OK, if a bit messy. It still allows messages to be routed around fine 131 | if (this.buffer) { 132 | const PCMBuffer = this.buffer.getChannelData(0) 133 | //pcm data is in [-1.0,1.0] 134 | const sampleStride = Math.ceil(PCMBuffer.length / this.width) 135 | 136 | //TODO zooming, split the range of channel data before iterating, faster! 137 | for (let sample = 0; sample < PCMBuffer.length; sample += sampleStride) { 138 | this.ctx.lineTo( 139 | x, 140 | this.height / 2 + 141 | (this.webAudioNodes.gainNode.gain.value * 142 | PCMBuffer[sample] * 143 | this.height) / 144 | 2, 145 | ) 146 | x++ 147 | } 148 | 149 | this.playheads.forEach((playhead) => { 150 | if ( 151 | this.audioContext.currentTime >= playhead.startTime && 152 | this.audioContext.currentTime < playhead.startTime + playhead.dur 153 | ) { 154 | //we're at a time where this should be playing! 155 | const playheadX = 156 | (this.width * 157 | (playhead.startTimeInAudio + 158 | (this.audioContext.currentTime - playhead.startTime) * 159 | playhead.playbackRate)) / 160 | this.buffer.duration 161 | this.ctx.moveTo(playheadX, 0) 162 | this.ctx.lineTo(playheadX, this.height) 163 | } 164 | }) 165 | this.ctx.stroke() 166 | } else { 167 | const eyeWidth = 10 168 | const eyeHeight = 20 169 | const frownHeight = 10 170 | this.ctx.fillStyle = "white" 171 | this.ctx.fillRect( 172 | this.width / 2 - eyeWidth, 173 | Math.floor(this.height / 2) - eyeHeight, 174 | 1, 175 | 1, 176 | ) 177 | this.ctx.fillRect( 178 | this.width / 2 + eyeWidth, 179 | Math.floor(this.height / 2) - eyeHeight, 180 | 1, 181 | 1, 182 | ) 183 | this.ctx.beginPath() 184 | this.ctx.arc( 185 | this.width / 2, 186 | this.height / 2 - frownHeight, 187 | 4.0, 188 | -Math.PI / 2 - 1, 189 | -Math.PI / 2 + 1, 190 | ) 191 | this.ctx.stroke() 192 | this.ctx.font = "10px ibmvga" 193 | this.ctx.fillText("couldn't load sample", this.width / 2 - 45, 50) 194 | } 195 | } 196 | } 197 | 198 | export { Sampler } 199 | -------------------------------------------------------------------------------- /ui/dark_theme.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | import { Extension } from "@codemirror/state" 3 | import { HighlightStyle, syntaxHighlighting } from "@codemirror/language" 4 | import { tags as t } from "@lezer/highlight" 5 | 6 | const base00 = "#000000", 7 | base01 = "#DDDDDD", 8 | base02 = "#B9D2FF", 9 | base03 = "#b0b0b0", 10 | base04 = "#d0d0d0", 11 | base05 = "#e0e0e0", 12 | base06 = "#808080", 13 | base07 = "#000000", 14 | base08 = "#A54543", 15 | base09 = "#fc6d24", 16 | base0A = "#fda331", 17 | base0B = "#8abeb7", 18 | base0C = "#b5bd68", 19 | base0D = "#6fb3d2", 20 | base0E = "#cc99cc", 21 | base0F = "#6987AF" 22 | 23 | const invalid = base09, 24 | darkBackground = "#292d30", 25 | highlightBackground = base02 + "30", 26 | background = base00, 27 | tooltipBackground = base01, 28 | selection = "#202325", 29 | cursor = base01 30 | 31 | /// The editor theme styles for Basic Dark. 32 | export const basicDarkTheme = EditorView.theme( 33 | { 34 | "&": { 35 | color: base01, 36 | backgroundColor: background, 37 | }, 38 | 39 | ".cm-content": { 40 | caretColor: cursor, 41 | fontFamily: "ibmvga", 42 | }, 43 | 44 | ".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor }, 45 | "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": 46 | { backgroundColor: selection }, 47 | 48 | ".cm-panels": { backgroundColor: darkBackground, color: base03 }, 49 | ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, 50 | ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, 51 | 52 | ".cm-searchMatch": { 53 | backgroundColor: base02, 54 | outline: `1px solid ${base03}`, 55 | color: base07, 56 | }, 57 | ".cm-searchMatch.cm-searchMatch-selected": { 58 | backgroundColor: base05, 59 | color: base07, 60 | }, 61 | 62 | ".cm-activeLine": { backgroundColor: highlightBackground }, 63 | ".cm-selectionMatch": { backgroundColor: highlightBackground }, 64 | 65 | "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { 66 | outline: `1px solid ${base03}`, 67 | }, 68 | 69 | "&.cm-focused .cm-matchingBracket": { 70 | backgroundColor: base02, 71 | color: base07, 72 | }, 73 | 74 | ".cm-gutters": { 75 | borderRight: `1px solid #ffffff10`, 76 | color: base06, 77 | backgroundColor: darkBackground, 78 | fontFamily: "ibmvga", 79 | }, 80 | 81 | ".cm-activeLineGutter": { 82 | backgroundColor: highlightBackground, 83 | }, 84 | 85 | ".cm-foldPlaceholder": { 86 | backgroundColor: "transparent", 87 | border: "none", 88 | color: base02, 89 | }, 90 | 91 | ".cm-tooltip": { 92 | border: "none", 93 | backgroundColor: tooltipBackground, 94 | }, 95 | ".cm-tooltip .cm-tooltip-arrow:before": { 96 | borderTopColor: "transparent", 97 | borderBottomColor: "transparent", 98 | }, 99 | ".cm-tooltip .cm-tooltip-arrow:after": { 100 | borderTopColor: tooltipBackground, 101 | borderBottomColor: tooltipBackground, 102 | }, 103 | ".cm-tooltip-autocomplete": { 104 | "& > ul > li[aria-selected]": { 105 | backgroundColor: highlightBackground, 106 | color: base03, 107 | }, 108 | }, 109 | }, 110 | { dark: true }, 111 | ) 112 | 113 | /// The highlighting style for code in the Basic Light theme. 114 | export const basicDarkHighlightStyle = HighlightStyle.define([ 115 | { tag: t.keyword, color: base0A }, 116 | { 117 | tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], 118 | color: base0C, 119 | }, 120 | { tag: [t.variableName], color: base0D }, 121 | { tag: [t.function(t.variableName)], color: base0A }, 122 | { tag: [t.labelName], color: base09 }, 123 | { 124 | tag: [t.color, t.constant(t.name), t.standard(t.name)], 125 | color: base0A, 126 | }, 127 | { tag: [t.definition(t.name), t.separator], color: base0E }, 128 | { tag: [t.brace], color: base0E }, 129 | { 130 | tag: [t.annotation], 131 | color: invalid, 132 | }, 133 | { 134 | tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], 135 | color: base0A, 136 | }, 137 | { 138 | tag: [t.typeName, t.className], 139 | color: base0D, 140 | }, 141 | { 142 | tag: [t.operator, t.operatorKeyword], 143 | color: base0E, 144 | }, 145 | { 146 | tag: [t.tagName], 147 | color: base0A, 148 | }, 149 | { 150 | tag: [t.squareBracket], 151 | color: base0E, 152 | }, 153 | { 154 | tag: [t.angleBracket], 155 | color: base0E, 156 | }, 157 | { 158 | tag: [t.attributeName], 159 | color: base0D, 160 | }, 161 | { 162 | tag: [t.regexp], 163 | color: base0A, 164 | }, 165 | { 166 | tag: [t.quote], 167 | color: base01, 168 | }, 169 | { tag: [t.string], color: base0C }, 170 | { 171 | tag: t.link, 172 | color: base0F, 173 | textDecoration: "underline", 174 | textUnderlinePosition: "under", 175 | }, 176 | { 177 | tag: [t.url, t.escape, t.special(t.string)], 178 | color: base0B, 179 | }, 180 | { tag: [t.meta], color: base08 }, 181 | { tag: [t.comment], color: base06, fontStyle: "italic" }, 182 | { tag: t.monospace, color: base01 }, 183 | { tag: t.strong, fontWeight: "bold", color: base0A }, 184 | { tag: t.emphasis, fontStyle: "italic", color: base0D }, 185 | { tag: t.strikethrough, textDecoration: "line-through" }, 186 | { tag: t.heading, fontWeight: "bold", color: base01 }, 187 | { tag: t.special(t.heading1), fontWeight: "bold", color: base01 }, 188 | { tag: t.heading1, fontWeight: "bold", color: base01 }, 189 | { 190 | tag: [t.heading2, t.heading3, t.heading4], 191 | fontWeight: "bold", 192 | color: base01, 193 | }, 194 | { 195 | tag: [t.heading5, t.heading6], 196 | color: base01, 197 | }, 198 | { tag: [t.atom, t.bool, t.special(t.variableName)], color: base0B }, 199 | { 200 | tag: [t.processingInstruction, t.inserted], 201 | color: base0B, 202 | }, 203 | { 204 | tag: [t.contentSeparator], 205 | color: base0D, 206 | }, 207 | { tag: t.invalid, color: base02, borderBottom: `1px dotted ${invalid}` }, 208 | ]) 209 | 210 | /// Extension to enable the Basic Dark theme (both the editor theme and 211 | /// the highlight style). 212 | const basicDark: Extension = [ 213 | basicDarkTheme, 214 | syntaxHighlighting(basicDarkHighlightStyle), 215 | ] 216 | 217 | export { basicDark, base0B, background } 218 | -------------------------------------------------------------------------------- /ui/knob.ts: -------------------------------------------------------------------------------- 1 | import { lerp } from "./utils" 2 | import { registerCC } from "./midi_manager" 3 | import "./css/knob.css" 4 | 5 | const SIZE_PX = 40 6 | const KNOB_START_ROTATION = Math.PI / 8 7 | const KNOB_SENSITIVITY = 1 / 200 //vertical pixels before sweeping through the whole range 8 | const KNOB_TIME_CONSTANT = 1 / 10000 9 | const KNOB_KNOTCH_LENGTH = 0.5 //How much of the circle the indicator shows 10 | const CC_PRINT_TIMEOUT = 1 //Stop showing the message 1s after midi 11 | 12 | class Knob { 13 | parameter 14 | min 15 | max 16 | 17 | logMin 18 | logMax 19 | logScale 20 | 21 | //internal 22 | headingEl 23 | containerEl 24 | canvas 25 | audioContext 26 | ctx 27 | 28 | mouseStart 29 | mouseMoveEvent 30 | 31 | hovered = false 32 | clicked = false 33 | lastCCChange = 0 34 | 35 | getRotation() { 36 | const normalised = this.logScale 37 | ? (Math.log2(this.parameter.value) - this.logMin) / 38 | (this.logMax - this.logMin) 39 | : (this.parameter.value - this.min) / (this.max - this.min) 40 | return lerp( 41 | 2 * Math.PI - KNOB_START_ROTATION, 42 | KNOB_START_ROTATION, 43 | normalised, 44 | ) 45 | } 46 | 47 | shouldDrawParamValue() { 48 | return ( 49 | this.hovered || 50 | this.clicked || 51 | this.audioContext.currentTime < this.lastCCChange + CC_PRINT_TIMEOUT 52 | ) 53 | } 54 | 55 | setFromCC(byteVal) { 56 | const normalised = byteVal / 127.0 57 | 58 | const target = this.logScale 59 | ? Math.pow(2, this.logMin + (this.logMax - this.logMin) * normalised) 60 | : this.min + (this.max - this.min) * normalised 61 | 62 | this.lastCCChange = this.audioContext.currentTime 63 | 64 | this.parameter.setTargetAtTime( 65 | target, 66 | this.audioContext.currentTime, 67 | KNOB_TIME_CONSTANT, 68 | ) 69 | } 70 | 71 | constructor( 72 | audioContext, 73 | parentEl, 74 | parameter, 75 | knobName, 76 | min, 77 | max, 78 | logScale = false, 79 | ) { 80 | this.audioContext = audioContext 81 | this.parameter = parameter 82 | this.min = min !== undefined ? min : parameter.minValue 83 | this.max = max !== undefined ? max : parameter.maxValue 84 | 85 | this.logMin = Math.log2(min) 86 | this.logMax = Math.log2(max) 87 | this.logScale = logScale 88 | 89 | this.headingEl = document.createElement("p") 90 | this.headingEl.className = "heading" 91 | this.headingEl.innerText = knobName 92 | 93 | this.canvas = document.createElement("canvas") 94 | 95 | this.canvas.width = SIZE_PX 96 | this.canvas.height = SIZE_PX 97 | 98 | this.containerEl = document.createElement("div") 99 | this.containerEl.className = "knob-container" 100 | this.containerEl.appendChild(this.headingEl) 101 | this.containerEl.appendChild(this.canvas) 102 | this.containerEl.style.userSelect = "none" 103 | parentEl.appendChild(this.containerEl) 104 | 105 | this.ctx = this.canvas.getContext("2d") 106 | this.ctx.fillStyle = "white" 107 | this.ctx.strokeStyle = "white" 108 | this.draw() 109 | 110 | this.canvas.addEventListener("mousedown", (e) => { 111 | if (e.shiftKey) { 112 | //register a midi CC to control this 113 | registerCC(this.setFromCC.bind(this)) 114 | } else { 115 | //move the param 116 | this.mouseStart = e.pageY 117 | this.clicked = true 118 | 119 | const startVal = this.logScale 120 | ? Math.log2(this.parameter.value) 121 | : this.parameter.value 122 | 123 | const onMouseMove = (e) => { 124 | const normalised = (this.mouseStart - e.pageY) * KNOB_SENSITIVITY 125 | 126 | const target = this.logScale 127 | ? Math.pow( 128 | 2, 129 | Math.min( 130 | Math.max( 131 | startVal + normalised * (this.logMax - this.logMin), 132 | this.logMin, 133 | ), 134 | this.logMax, 135 | ), 136 | ) 137 | : Math.min( 138 | Math.max( 139 | startVal + normalised * (this.max - this.min), 140 | this.min, 141 | ), 142 | this.max, 143 | ) 144 | 145 | this.parameter.setTargetAtTime( 146 | target, 147 | this.audioContext.currentTime, 148 | KNOB_TIME_CONSTANT, 149 | ) 150 | } 151 | 152 | this.mouseMoveEvent = document.addEventListener( 153 | "mousemove", 154 | onMouseMove, 155 | ) 156 | 157 | //eslint-disable-next-line 158 | document.addEventListener( 159 | "mouseup", 160 | (e) => { 161 | this.clicked = false 162 | document.removeEventListener("mousemove", onMouseMove) 163 | }, 164 | { once: true }, 165 | ) 166 | } 167 | }) 168 | 169 | //eslint-disable-next-line 170 | this.canvas.addEventListener("mouseover", (e) => { 171 | this.hovered = true 172 | 173 | this.canvas.addEventListener( 174 | "mouseleave", 175 | (e) => { 176 | this.hovered = false 177 | }, 178 | { once: true }, 179 | ) 180 | }) 181 | } 182 | 183 | draw() { 184 | this.ctx.clearRect(0, 0, SIZE_PX, SIZE_PX) 185 | 186 | this.ctx.beginPath() 187 | const radius = SIZE_PX / 2 - 6 188 | this.ctx.arc(SIZE_PX / 2, SIZE_PX / 2, radius, 0, 2 * Math.PI) 189 | 190 | const knotchLength = this.shouldDrawParamValue() ? KNOB_KNOTCH_LENGTH : 0 191 | this.ctx.moveTo( 192 | SIZE_PX / 2 + Math.sin(this.getRotation()) * radius * knotchLength, 193 | SIZE_PX / 2 + Math.cos(this.getRotation()) * radius * knotchLength, 194 | ) 195 | this.ctx.lineTo( 196 | SIZE_PX / 2 + Math.sin(this.getRotation()) * radius, 197 | SIZE_PX / 2 + Math.cos(this.getRotation()) * radius, 198 | ) 199 | this.ctx.stroke() 200 | 201 | if (this.shouldDrawParamValue()) { 202 | const valueString = `${this.parameter.value.toFixed(2)}` 203 | const maxTextWidth = this.ctx.measureText(valueString).width 204 | this.ctx.fillText( 205 | valueString, 206 | SIZE_PX / 2 - maxTextWidth / 2, 207 | SIZE_PX / 2 + 5, 208 | ) 209 | } 210 | 211 | this.ctx.font = "10px ibmvga" 212 | this.ctx.fillText(`${this.min}`, 1, SIZE_PX) 213 | 214 | const maxTextWidth = this.ctx.measureText(`${this.max}`).width 215 | this.ctx.fillText(`${this.max}`, SIZE_PX - maxTextWidth, SIZE_PX) 216 | window.requestAnimationFrame(this.draw.bind(this)) 217 | } 218 | } 219 | 220 | export { Knob } 221 | -------------------------------------------------------------------------------- /test/dsltest.janet: -------------------------------------------------------------------------------- 1 | (use judge) 2 | 3 | (use ../src/globals) 4 | (use ../src/dsl) 5 | (use ../src/instruments) 6 | (use ../src/dsl_helpers) 7 | (use ../src/harmony) 8 | 9 | 10 | (test (+ 2 :c3) 38) 11 | (test (+ [2 3] :c3) [38 39]) 12 | (test (+ [:c3 :d4] :c3) [72 86]) 13 | (test (+ [:c3 :d4] [:c3 1]) [72 51]) 14 | (test (+ :d3 :c1) 50) 15 | (test (* :d3 :c1) 456) 16 | (test (- :d3 :c1) 26) 17 | 18 | (setdyn *instruments* @{}) 19 | 20 | (def a 4) 21 | (test-macro (breakbeat :break "local://thing" 4 4) 22 | (let [<1> (cond (int? nil) (tuple (splice (map (fn [x] (/ x nil)) (range 0 (+ nil 1))))) (tuple? nil) nil (error "slices not a number of slices or tuple of slice times"))] 23 | (inst :breakbeat_sampler :break :url nil :length_beats nil :slices <1> :gain nil))) 24 | 25 | (test-macro (synth :synth "sawtooth") 26 | (inst :synth :synth :wave nil :gain nil :attack nil :release nil)) 27 | 28 | (deftest-type loop_body 29 | :setup (fn [] (do 30 | (setdyn *instruments* @{:out @[0 :out] :midi @[1 :midi]}) 31 | (setdyn *self* @{:notes @[] :rng (math/rng)}) 32 | )) 33 | :reset (fn [context] (do 34 | (setdyn *instruments* @{:out @[0 :out] :midi @[1 :midi]}) 35 | )) 36 | ) 37 | 38 | (deftest: loop_body "test_instrument mappings" [context] 39 | (breakbeat :break :url "local://thing" :length_beats 4 :slices 8) 40 | (test (dyn *instruments*) 41 | @{:break @[2 42 | :breakbeat_sampler 43 | ":url" 44 | "local://thing" 45 | ":length_beats" 46 | "4" 47 | ":slices" 48 | "(0 0.125 0.25 0.375 0.5 0.625 0.75 0.875 1)" 49 | ":gain" 50 | "nil"] 51 | :midi @[1 :midi] 52 | :out @[0 :out]}) 53 | ) 54 | 55 | (deftest: loop_body "test_several_instruments" [context] 56 | (breakbeat :breaks :url "local://thing.wav" :length_beats 4 :slices [0.1 0.2 0.3]) 57 | (breakbeat :breaksa :url "local://thing.wav" :length_beats 4 :slices [0.1 0.2 0.3]) 58 | 59 | (chain :breaks :breaksa :out) 60 | (test (dyn *instruments*) 61 | @{"breaks->wire->breaksa" @[4 62 | :wire 63 | ":from" 64 | ":breaks" 65 | ":to" 66 | ":breaksa" 67 | ":toParam" 68 | "nil"] 69 | "breaksa->wire->out" @[5 70 | :wire 71 | ":from" 72 | ":breaksa" 73 | ":to" 74 | ":out" 75 | ":toParam" 76 | "nil"] 77 | :breaks @[2 78 | :breakbeat_sampler 79 | ":url" 80 | "local://thing.wav" 81 | ":length_beats" 82 | "4" 83 | ":slices" 84 | "(0.1 0.2 0.3)" 85 | ":gain" 86 | "nil"] 87 | :breaksa @[3 88 | :breakbeat_sampler 89 | ":url" 90 | "local://thing.wav" 91 | ":length_beats" 92 | "4" 93 | ":slices" 94 | "(0.1 0.2 0.3)" 95 | ":gain" 96 | "nil"] 97 | :midi @[1 :midi] 98 | :out @[0 :out]}) 99 | ) 100 | 101 | (deftest: loop_body "test_procedural_graph" [context] 102 | #(for i 0 10 103 | (chain 104 | (synth :hiiiii :wave "triangle") 105 | (gain :geaan) 106 | :out 107 | ) 108 | #) 109 | #(test (dyn *instruments*)) 110 | ) 111 | 112 | (deftest: loop_body "test_several_instruments" [context] 113 | (breakbeat :breaks :url "local://thing.wav" :length_beats 4 :slices [0.1 0.2 0.3]) 114 | (chorus :chorus) 115 | (chain :breaks :chorus :out) 116 | (test (dyn *instruments*) 117 | @{"breaks->wire->chorus" @[4 118 | :wire 119 | ":from" 120 | ":breaks" 121 | ":to" 122 | ":chorus" 123 | ":toParam" 124 | "nil"] 125 | "chorus->wire->out" @[5 126 | :wire 127 | ":from" 128 | ":chorus" 129 | ":to" 130 | ":out" 131 | ":toParam" 132 | "nil"] 133 | :breaks @[2 134 | :breakbeat_sampler 135 | ":url" 136 | "local://thing.wav" 137 | ":length_beats" 138 | "4" 139 | ":slices" 140 | "(0.1 0.2 0.3)" 141 | ":gain" 142 | "nil"] 143 | :chorus @[3 :chorus] 144 | :midi @[1 :midi] 145 | :out @[0 :out]}) 146 | ) 147 | 148 | (deftest: loop_body "test_new_inst_def" [context] 149 | (sample :samples :url "thing.wav" :pitch :c4) 150 | (test (dyn *instruments*) 151 | @{:midi @[1 :midi] 152 | :out @[0 :out] 153 | :samples @[2 154 | :pitched_sampler 155 | ":url" 156 | "thing.wav" 157 | ":pitch" 158 | "48" 159 | ":gain" 160 | "nil" 161 | ":attack" 162 | "nil" 163 | ":release" 164 | "nil"]}) 165 | ) 166 | 167 | (deftest: loop_body "test_drum_inst" [context] 168 | (drums :drum :hits ["1" "2" "3" "4"]) 169 | (test (dyn *instruments*) 170 | @{:drum @[2 171 | :drums 172 | ":hits" 173 | "(\"1\" \"2\" \"3\" \"4\")"] 174 | :midi @[1 :midi] 175 | :out @[0 :out]}) 176 | ) 177 | 178 | (deftest: loop_body "test_til" [context] 179 | (setdyn :current-time 0) 180 | (test (dyn :current-time) 0) 181 | (sleep 1) 182 | (test (dyn :current-time) 1) 183 | (test (time) 1) 184 | (test (til 8) 7) 185 | (sleep 7) 186 | (test (til 8) 0) 187 | (sleep 7.999999) 188 | (sleep (til 8)) 189 | (test (til 8) 0) 190 | (sleep 8) 191 | (test (til 8) 0) 192 | ) 193 | 194 | (deftest: loop_body "test_play" [context] 195 | (gain :my-gaine) 196 | (test-macro (play 0 :my-gaine) 197 | (array/push (get (dyn *self*) :notes) (splice (play_ 0 2)))) 198 | ) 199 | 200 | (deftest: loop_body "test_change" [context] 201 | (gain :my-gaine) 202 | (change :my-gaine :gain 8) 203 | (lin :my-gaine :gain 9) 204 | (exp :my-gaine :gain 10) 205 | (itarget :my-gaine :gain 11) 206 | (test (get (dyn *self*) :notes) 0) 207 | ) 208 | -------------------------------------------------------------------------------- /src/dsl.janet: -------------------------------------------------------------------------------- 1 | (use ./globals) 2 | (use ./harmony) 3 | (use ./euclid) 4 | (use ./params) 5 | (use ./instruments) 6 | (use ./dsl_helpers) 7 | 8 | (defn uclid [pat n steps] 9 | (def hits (euclid n steps)) 10 | (map (fn [step] (if step pat :tie)) hits) 11 | ) 12 | 13 | (defmacro bpm 14 | ````Sets the bpm (beats per minute) of the current track. 15 | 16 | Can only be set once per track, BPM changes are currently unsupported. 17 | To change BPM, reload the page 18 | **Example** 19 | ``` 20 | (bpm 120) # Sets the BPM to 120 beats per minute 21 | ``` 22 | ```` 23 | [beats_per_minute] 24 | (assert (not (dyn *bpm*)) (string "bpm already defined")) 25 | (assert (number? beats_per_minute) (string "bpm not a literal number")) 26 | (setdyn *bpm* beats_per_minute) 27 | ) 28 | 29 | (defmacro sleep 30 | ````Advances time in the current 'live-loop' by the specified `length`, in beats 31 | 32 | **Example** 33 | ``` 34 | (sleep 4) # advance time by 4 beats 35 | ``` 36 | ```` 37 | [length] 38 | ~(setdyn :current-time (+ (dyn :current-time) ,length)) 39 | ) 40 | 41 | ## Eww, this is very nested, refactor me pleaaaaase 42 | (defmacro change 43 | ````Changes the parameter `param` knob of a module `instName` to `to` 44 | 45 | **Example** 46 | ``` 47 | (change :gain-example :gain 0.5) # changes the gain knob on a gain module ':gain-example' to 0.5 48 | ``` 49 | ```` 50 | [instName param to & rest] 51 | (with-syms [$inst $instChannel $instMap $paramIdx] 52 | ~(let [,$inst (get (dyn ,*instruments*) ,instName)] 53 | (let [,$instChannel (first ,$inst)] 54 | (if ,$instChannel 55 | (assert ,$instChannel (string "instrument not found: " ,instName )) 56 | (pp (dyn ,*instruments*)) 57 | ) 58 | 59 | (let [,$instChannel (first ,$inst)] 60 | (let [,$instMap (get ,*inst_params* (get ,$inst 1))] 61 | (assert ,$instMap (string "instrument type not found " (get ,$inst 1))) 62 | 63 | (let [,$paramIdx (get ,$instMap ,param)] 64 | (assert ,$paramIdx (string "paramater " ,param " does not exist in instrument " (get ,$inst 1))) 65 | (array/push (get (dyn *self*) :notes) (change_ (encodeParam ,$instChannel ,$paramIdx) ,to ,;rest)) 66 | ) 67 | ) 68 | ) 69 | ) 70 | ) 71 | ) 72 | ) 73 | 74 | (defmacro lin 75 | ````Changes the parameter `param` knob of a module `instName` to `to` 76 | Approaches `to` from its last value linearly 77 | 78 | **Example** 79 | ``` 80 | (lin :gain-example :gain 0.5) # linearly changes the gain knob on a gain module ':gain-example' to 0.5 81 | ``` 82 | ```` 83 | [instName paramIdx to] 84 | ~(change ,instName ,paramIdx ,to ,:cType 0) 85 | ) 86 | 87 | (defmacro exp 88 | ````Changes the parameter `param` knob of a module `instName` to `to` 89 | Approaches `to` from its last value exponentially 90 | 91 | **Example** 92 | ``` 93 | (exp :gain-example :gain 0.5) # changes the gain knob on a gain module ':gain-example' to 0.5, approaches exponentially 94 | ``` 95 | ```` 96 | [instName paramIdx to] 97 | ~(change ,instName ,paramIdx ,to ,:cType 1) 98 | ) 99 | 100 | (defmacro itarget 101 | ````Instantaneously changes the parameter `param` knob of a module `instName` to `to` 102 | 103 | **Example** 104 | ``` 105 | (itarget :gain-example :gain 0.5) # instantaneously changes the gain knob on a gain module ':gain-example' to 0.5 106 | ``` 107 | ```` 108 | [instName paramIdx to] 109 | ~(change ,instName ,paramIdx ,to ,:cType 2) 110 | ) 111 | 112 | (defmacro target [instName paramIdx to k] # see change() in instruments.ts 113 | ````changes the parameter `param` knob of a module `instName` to `to` 114 | Approaches the `to` by a rate defined by the constant `k` 115 | 116 | **Example** 117 | ``` 118 | (target :gain-example :gain 0.5 0.1) # changes the gain knob on a gain module ':gain-example' to 0.5, by a rate 0.1 119 | ``` 120 | ```` 121 | (with-syms [$k] 122 | ~(let [,$k (if ,k ,k 0.01)] 123 | (assert (> ,$k 0) "time constant must be positive") 124 | (change ,instName ,paramIdx ,to ,:cType (- ,$k)) 125 | ) 126 | ) 127 | ) 128 | 129 | (defmacro time 130 | ````Returns the current time, in beats, of the containing live-loop 131 | 132 | **Example** 133 | ``` 134 | (time) # -> 32 135 | ``` 136 | ```` 137 | 138 | [] 139 | ~(dyn :current-time) 140 | ) 141 | 142 | (defmacro til 143 | ````Returns the time until the next measure of `when_beats` 144 | 145 | **Example** 146 | ``` 147 | (time) # -> 32 148 | (sleep (til 64)) # -> (sleep 32) 149 | ``` 150 | ```` 151 | [when_beats] 152 | ~(quantiseModulo (- ,when_beats (mod (time) ,when_beats)) ,when_beats) 153 | ) 154 | 155 | (defn rep 156 | ````Returns a repeated array filled with `what` #times 157 | 158 | **Example** 159 | ``` 160 | (rep [1 2 3] 3) # -> @[(1 2 3) (1 2 3) (1 2 3)] 161 | ``` 162 | ```` 163 | [what times] 164 | (array/new-filled times what) 165 | ) 166 | 167 | (defmacro wire 168 | ````Wires the output of 'from' the input of `to` 169 | Accepts an optional `toParam` which specifies a named parameter, or knob, of `to` to wire the output to. 170 | 171 | **Example** 172 | ``` 173 | # Wires the output of :signal into the :frequency parameter of :filter 174 | (wire :signal :filter :frequency) 175 | ``` 176 | ```` 177 | [from to &opt toParam] 178 | (with-syms [$fromInstName $toInstName $toInst $instType $instMap] 179 | ~(let [,$fromInstName ,from ,$toInstName ,to] 180 | (assert (get (dyn ,*instruments*) ,$toInstName) (string "dest instrument not found: " ,$toInstName)) 181 | (assert (get (dyn ,*instruments*) ,$fromInstName) (string "source inst not found: " ,$fromInstName)) 182 | 183 | (if ,toParam 184 | (let [,$instType (get (get (dyn ,*instruments*) ,$toInstName) 1)] 185 | (let [,$instMap (get ,*inst_params* ,$instType)] 186 | (assert ,$instMap (string "instrument type not found " ,$instType)) 187 | (assert (or (not ,toParam) (get ,$instMap ,toParam)) (string "paramater " ,toParam " does not exist in instrument " ,$instType)) 188 | ) 189 | ) 190 | ) 191 | 192 | # TODO assert to can recieve audio, ie is an effect 193 | (inst ,:wire (string ,$fromInstName "->wire->" ,$toInstName ;(if ,toParam ["," ,toParam] [])) :from ,$fromInstName :to ,$toInstName :toParam ,toParam) 194 | ) 195 | ) 196 | ) 197 | 198 | (defn chain [& forms] 199 | ````Chaining function, chains together modules. Wires the output of the first into the input of the second, 200 | and the output of the second into the output of the third etc. 201 | 202 | **Example** 203 | ``` 204 | # Wires the output of an oscillator into a gain, wire the gain into the output 205 | (chain 206 | (oscillator :hello-osc :wave_type "sine") 207 | (gain :hello-gain) 208 | :out 209 | ) 210 | ``` 211 | ```` 212 | (def firstInst (first forms)) 213 | (def nextInst (get forms 1)) 214 | (if (and firstInst nextInst) 215 | (do 216 | (wire firstInst nextInst) 217 | (chain ;(tuple/slice forms 1)) 218 | ) 219 | ) 220 | ) 221 | 222 | # TODO maybe better as a macro? 223 | (defn P 224 | ````Evaluates a given subdivision `pattern` over a number of beats given by `lengthBeats` 225 | Returns a list of `[note, duration]` pairs that can be scheduled. 226 | 227 | **Example** 228 | ``` 229 | (P [0 [1 1] 0 0] 4) # -> @[(0 1) (1 0.5) (1 0.5) (0 1) (0 1)] 230 | ``` 231 | ```` 232 | [pattern lengthBeats] 233 | (cond 234 | (or (number? pattern) (nil? pattern) (string? pattern) (keyword? pattern)) @[[pattern lengthBeats]] 235 | (or (array? pattern) (tuple? pattern)) (do 236 | (def elementLength (/ lengthBeats (length pattern))) 237 | (squish-rests (array/concat ;(map (fn [element] (P element elementLength)) pattern))) 238 | ) 239 | ) 240 | ) 241 | 242 | (defmacro play 243 | ````Plays a `note` on a given instrument `instName` 244 | Also accepts a `:dur` duration parameter, in beats. 245 | 246 | **Example** 247 | ``` 248 | (play :C4 :my-sampler :dur 0.5) # plays a :C4 on :my-sampler for 0.5 beats 249 | (play 0 :my-drum :dur 2) # plays a note 0 on :my-drum for 2 beats 250 | ``` 251 | ```` 252 | [note instName & rest] 253 | (if note 254 | (do 255 | (def instChannel (first (get (dyn *instruments*) instName))) 256 | (assert instChannel (string "instrument not found: " instName)) 257 | ~(array/push (get (dyn *self*) :notes) ;(play_ ,note ,instChannel ,;rest)) 258 | ) 259 | ) 260 | ) 261 | 262 | (defmacro pick 263 | ````Picks an item randomly from the arguments `picks` 264 | 265 | **Example** 266 | ``` 267 | (pick 1 2 3) # -> 1 268 | (pick 1 2 3) # -> 3 269 | ``` 270 | ```` 271 | [& picks] 272 | ~(get [,;picks] (math/rng-int (get (dyn *self*) :rng) (length [,;picks]))) 273 | ) 274 | 275 | (defn rand 276 | ````Picks a number uniformly between `lo` and `hi` 277 | 278 | **Example** 279 | ``` 280 | (rand 0 1) # -> 0.566847 281 | ``` 282 | ```` 283 | [lo hi] 284 | (+ lo (* (- hi lo) (math/rng-uniform (get (dyn *self*) :rng)))) 285 | ) 286 | 287 | (defmacro timesel 288 | ````Indexes into the array or tuple `arr` with the current time, with the index increasing by one after `changeEvery` time has passed, modulo the length `arr`. 289 | 290 | **Example** 291 | ``` 292 | (time) # -> 5 293 | (timesel [1 2 3 4] 1) # -> 2 294 | (timesel [1 2 3 4] 2) # -> 3 295 | ``` 296 | ```` 297 | [arr changeEvery] 298 | ~(get ,arr (% (math/floor (/ (dyn :current-time) ,changeEvery)) (length ,arr))) 299 | ) 300 | 301 | (defmacro seed 302 | ````Sets the random seed of the current live-loop. Useful for repeatable random patterns 303 | 304 | **Example** 305 | ``` 306 | (seed 5) 307 | ``` 308 | ```` 309 | [seed] 310 | ~(set ((dyn *self*) :rng) (math/rng ,seed)) 311 | ) 312 | 313 | (defmacro live_loop 314 | ````Creates a live-loop of a given name to schedule notes or parameter changes from 315 | 316 | **Example** 317 | ``` 318 | (live-loop :hello-world 319 | (play :c4 :hello-synth :dur 0.25) 320 | (sleep 0.5) 321 | ) 322 | ``` 323 | ```` 324 | [name & instructions] 325 | ~(put (dyn ,*lloops*) ,name (fiber/new (fn [] 326 | (with-dyns [*self* @{:notes @[] :rng (math/rng)}] 327 | (forever 328 | (set ((dyn *self*) :start-time) (dyn :current-time)) 329 | ,;instructions 330 | (yield [(- (dyn :current-time) (get (dyn *self*) :start-time)) (get (dyn *self*) :notes)]) 331 | (set ((dyn *self*) :notes) @[]) 332 | ) 333 | ) 334 | ) :yei))) 335 | 336 | 337 | (vectorize +) 338 | (notify_args +) 339 | (vectorize -) 340 | (notify_args -) 341 | (vectorize /) 342 | (notify_args /) 343 | (vectorize *) 344 | (notify_args *) 345 | -------------------------------------------------------------------------------- /src/harmony.janet: -------------------------------------------------------------------------------- 1 | #gratefully taken from https://github.com/yuma-m/pychord/blob/44d3db5c075efdda4e7b4ecdb9cdef074b2aab0d/pychord/constants/qualities.py 2 | (def- chord_qualities @{ 3 | :5 [0 7] 4 | :maj [0 4 7] 5 | :major [0 4 7] 6 | :m [0 3 7] 7 | :min [0 3 7] 8 | :minor [0 3 7] 9 | :- [0 3 7] 10 | :dim [0 3 6] 11 | :diminished [0 3 6] 12 | :b5 [0 4 6] 13 | :aug [0 4 8] 14 | :sus2 [0 2 7] 15 | :sus4 [0 5 7] 16 | :sus [0 5 7] 17 | :6 [0 4 7 9] 18 | :6b5 [0 4 6 9] 19 | :6-5 [0 4 6 9] 20 | :7 [0 4 7 10] 21 | :7-5 [0 4 6 10] 22 | :7b5 [0 4 6 10] 23 | :7+5 [0 4 8 10] 24 | :7s5 [0 4 8 10] 25 | :7sus4 [0 5 7 10] 26 | :m6 [0 3 7 9] 27 | :m7 [0 3 7 10] 28 | :m7 [0 3 7 10] 29 | :m7-5 [0 3 6 10] 30 | :m7b5 [0 3 6 10] 31 | :m7+5 [0 3 8 10] 32 | :m7s5 [0 3 8 10] 33 | :dim6 [0 3 6 8] 34 | :dim7 [0 3 6 9] 35 | :M7 [0 4 7 11] 36 | :maj7 [0 4 7 11] 37 | :M7+5 [0 4 8 11] 38 | :mM7 [0 3 7 11] 39 | :add4 [0 4 5 7] 40 | :Madd4 [0 4 5 7] 41 | :madd4 [0 3 5 7] 42 | :add9 [0 4 7 14] 43 | :Madd9 [0 4 7 14] 44 | :madd9 [0 3 7 14] 45 | :sus4add9 [0 5 7 14] 46 | :sus4add2 [0 2 5 7] 47 | :2 [0 4 7 14] 48 | :add11 [0 4 7 17] 49 | :4 [0 4 7 17] 50 | :m69 [0 3 7 9 14] 51 | :69 [0 4 7 9 14] 52 | :9 [0 4 7 10 14] 53 | :m9 [0 3 7 10 14] 54 | :M9 [0 4 7 11 14] 55 | :maj9 [0 4 7 11 14] 56 | :9sus4 [0 5 7 10 14] 57 | :7-9 [0 4 7 10 13] 58 | :7b9 [0 4 7 10 13] 59 | :7+9 [0 4 7 10 15] 60 | :7s9 [0 4 7 10 15] 61 | :9-5 [0 4 6 10 14] 62 | :9b5 [0 4 6 10 14] 63 | :9+5 [0 4 8 10 14] 64 | :9s5 [0 4 8 10 14] 65 | :7s9b5 [0 4 6 10 15] 66 | :7s9s5 [0 4 8 10 15] 67 | :m7b9b5 [0 3 6 10 13] 68 | :7b9b5 [0 4 6 10 13] 69 | :7b9s5 [0 4 8 10 13] 70 | :11 [0 7 10 14 17] 71 | :7+11 [0 4 7 10 18] 72 | :7s11 [0 4 7 10 18] 73 | :M7+11 [0 4 7 11 18] 74 | :M7s11 [0 4 7 11 18] 75 | :7b9s9 [0 4 7 10 13 15] 76 | :7b9s11 [0 4 7 10 13 18] 77 | :7s9s11 [0 4 7 10 15 18] 78 | :7-13 [0 4 7 10 20] 79 | :7b13 [0 4 7 10 20] 80 | :m7add11 [0 3 7 10 17] 81 | :M7add11 [0 4 7 11 17] 82 | :mM7add11 [0 3 7 11 17] 83 | :7b9b13 [0 4 7 10 13 17 20] 84 | :9+11 [0 4 7 10 14 18] 85 | :9s11 [0 4 7 10 14 18] 86 | :m11 [0 3 7 10 14 17] 87 | :13 [0 4 7 10 14 21] 88 | :13-9 [0 4 7 10 13 21] 89 | :13b9 [0 4 7 10 13 21] 90 | :13+9 [0 4 7 10 15 21] 91 | :13s9 [0 4 7 10 15 21] 92 | :13+11 [0 4 7 10 18 21] 93 | :13s11 [0 4 7 10 18 21] 94 | :maj13 [0 4 7 11 14 21] 95 | :M7add13 [0 4 7 9 11 14] 96 | } 97 | ) 98 | 99 | (def- midi_notes @{ 100 | :c 0 101 | :db 1 102 | :cs 1 103 | :d 2 104 | :eb 3 105 | :ds 3 106 | :e 4 107 | :f 5 108 | :fs 6 109 | :gb 6 110 | :g 7 111 | :ab 8 112 | :gs 8 113 | :a 9 114 | :bb 10 115 | :as 10 116 | :b 11 117 | :cb 11 118 | 119 | :c0 0 120 | :db0 1 121 | :cs0 1 122 | :d0 2 123 | :eb0 3 124 | :ds0 3 125 | :e0 4 126 | :f0 5 127 | :fs0 6 128 | :gb0 6 129 | :g0 7 130 | :ab0 8 131 | :gs0 8 132 | :a0 9 133 | :bb0 10 134 | :as0 10 135 | :b0 11 136 | :cb0 11 137 | 138 | :c1 12 139 | :db1 13 140 | :cs1 13 141 | :d1 14 142 | :eb1 15 143 | :ds1 15 144 | :e1 16 145 | :f1 17 146 | :fs1 18 147 | :gb1 18 148 | :g1 19 149 | :ab1 20 150 | :gs1 20 151 | :a1 21 152 | :bb1 22 153 | :as1 22 154 | :b1 23 155 | :cb1 23 156 | 157 | :c2 24 158 | :db2 25 159 | :cs2 25 160 | :d2 26 161 | :eb2 27 162 | :ds2 27 163 | :e2 28 164 | :f2 29 165 | :fs2 30 166 | :gb2 30 167 | :g2 31 168 | :ab2 32 169 | :gs2 32 170 | :a2 33 171 | :bb2 34 172 | :as2 34 173 | :b2 35 174 | :cb2 35 175 | 176 | :c3 36 177 | :db3 37 178 | :cs3 37 179 | :d3 38 180 | :eb3 39 181 | :ds3 39 182 | :e3 40 183 | :f3 41 184 | :fs3 42 185 | :gb3 42 186 | :g3 43 187 | :ab3 44 188 | :gs3 44 189 | :a3 45 190 | :bb3 46 191 | :as3 46 192 | :b3 47 193 | :cb3 47 194 | 195 | :c4 48 196 | :db4 49 197 | :cs4 49 198 | :d4 50 199 | :eb4 51 200 | :ds4 51 201 | :e4 52 202 | :f4 53 203 | :fs4 54 204 | :gb4 54 205 | :g4 55 206 | :ab4 56 207 | :gs4 56 208 | :a4 57 209 | :bb4 58 210 | :as4 58 211 | :b4 59 212 | :cb4 59 213 | 214 | :c5 60 215 | :db5 61 216 | :cs5 61 217 | :d5 62 218 | :eb5 63 219 | :ds5 63 220 | :e5 64 221 | :f5 65 222 | :fs5 66 223 | :gb5 66 224 | :g5 67 225 | :ab5 68 226 | :gs5 68 227 | :a5 69 228 | :bb5 70 229 | :as5 70 230 | :b5 71 231 | :cb5 71 232 | 233 | :c6 72 234 | :db6 73 235 | :cs6 73 236 | :d6 74 237 | :eb6 75 238 | :ds6 75 239 | :e6 76 240 | :f6 77 241 | :fs6 78 242 | :gb6 78 243 | :g6 79 244 | :ab6 80 245 | :gs6 80 246 | :a6 81 247 | :bb6 82 248 | :as6 82 249 | :b6 83 250 | :cb6 83 251 | 252 | :c7 84 253 | :db7 85 254 | :cs7 85 255 | :d7 86 256 | :eb7 87 257 | :ds7 87 258 | :e7 88 259 | :f7 89 260 | :fs7 90 261 | :gb7 90 262 | :g7 91 263 | :ab7 92 264 | :gs7 92 265 | :a7 93 266 | :bb7 94 267 | :as7 94 268 | :b7 95 269 | :cb7 95 270 | 271 | :c8 96 272 | :db8 97 273 | :cs8 97 274 | :d8 98 275 | :eb8 99 276 | :ds8 99 277 | :e8 100 278 | :f8 101 279 | :fs8 102 280 | :gb8 102 281 | :g8 103 282 | :ab8 104 283 | :gs8 104 284 | :a8 105 285 | :bb8 106 286 | :as8 106 287 | :b8 107 288 | :cb8 107 289 | 290 | :c9 108 291 | :db9 109 292 | :cs9 109 293 | :d9 110 294 | :eb9 111 295 | :ds9 111 296 | :e9 112 297 | :f9 113 298 | :fs9 114 299 | :gb9 114 300 | :g9 115 301 | :ab9 116 302 | :gs9 116 303 | :a9 117 304 | :bb9 118 305 | :as9 118 306 | :b9 119 307 | :cb9 119 308 | 309 | :C 0 310 | :Db 1 311 | :Cs 1 312 | :D 2 313 | :Eb 3 314 | :Ds 3 315 | :E 4 316 | :F 5 317 | :Fs 6 318 | :Gb 6 319 | :G 7 320 | :Ab 8 321 | :Gs 8 322 | :A 9 323 | :Bb 10 324 | :As 10 325 | :B 11 326 | :Cb 11 327 | 328 | :C0 0 329 | :Db0 1 330 | :Cs0 1 331 | :D0 2 332 | :Eb0 3 333 | :Ds0 3 334 | :E0 4 335 | :F0 5 336 | :Fs0 6 337 | :Gb0 6 338 | :G0 7 339 | :Ab0 8 340 | :Gs0 8 341 | :A0 9 342 | :Bb0 10 343 | :As0 10 344 | :B0 11 345 | :Cb0 11 346 | 347 | :C1 12 348 | :Db1 13 349 | :Cs1 13 350 | :D1 14 351 | :Eb1 15 352 | :Ds1 15 353 | :E1 16 354 | :F1 17 355 | :Fs1 18 356 | :Gb1 18 357 | :G1 19 358 | :Ab1 20 359 | :Gs1 20 360 | :A1 21 361 | :Bb1 22 362 | :As1 22 363 | :B1 23 364 | :Cb1 23 365 | 366 | :C2 24 367 | :Db2 25 368 | :Cs2 25 369 | :D2 26 370 | :Eb2 27 371 | :Ds2 27 372 | :E2 28 373 | :F2 29 374 | :Fs2 30 375 | :Gb2 30 376 | :G2 31 377 | :Ab2 32 378 | :Gs2 32 379 | :A2 33 380 | :Bb2 34 381 | :As2 34 382 | :B2 35 383 | :Cb2 35 384 | 385 | :C3 36 386 | :Db3 37 387 | :Cs3 37 388 | :D3 38 389 | :Eb3 39 390 | :Ds3 39 391 | :E3 40 392 | :F3 41 393 | :Fs3 42 394 | :Gb3 42 395 | :G3 43 396 | :Ab3 44 397 | :Gs3 44 398 | :A3 45 399 | :Bb3 46 400 | :As3 46 401 | :B3 47 402 | :Cb3 47 403 | 404 | :C4 48 405 | :Db4 49 406 | :Cs4 49 407 | :D4 50 408 | :Eb4 51 409 | :Ds4 51 410 | :E4 52 411 | :F4 53 412 | :Fs4 54 413 | :Gb4 54 414 | :G4 55 415 | :Ab4 56 416 | :Gs4 56 417 | :A4 57 418 | :Bb4 58 419 | :As4 58 420 | :B4 59 421 | :Cb4 59 422 | 423 | :C5 60 424 | :Db5 61 425 | :Cs5 61 426 | :D5 62 427 | :Eb5 63 428 | :Ds5 63 429 | :E5 64 430 | :F5 65 431 | :Fs5 66 432 | :Gb5 66 433 | :G5 67 434 | :Ab5 68 435 | :Gs5 68 436 | :A5 69 437 | :Bb5 70 438 | :As5 70 439 | :B5 71 440 | :Cb5 71 441 | 442 | :C6 72 443 | :Db6 73 444 | :Cs6 73 445 | :D6 74 446 | :Eb6 75 447 | :Ds6 75 448 | :E6 76 449 | :F6 77 450 | :Fs6 78 451 | :Gb6 78 452 | :G6 79 453 | :Ab6 80 454 | :Gs6 80 455 | :A6 81 456 | :Bb6 82 457 | :As6 82 458 | :B6 83 459 | :Cb6 83 460 | 461 | :C7 84 462 | :Db7 85 463 | :Cs7 85 464 | :D7 86 465 | :Eb7 87 466 | :Ds7 87 467 | :E7 88 468 | :F7 89 469 | :Fs7 90 470 | :Gb7 90 471 | :G7 91 472 | :Ab7 92 473 | :Gs7 92 474 | :A7 93 475 | :Bb7 94 476 | :As7 94 477 | :B7 95 478 | :Cb7 95 479 | 480 | :C8 96 481 | :Db8 97 482 | :Cs8 97 483 | :D8 98 484 | :Eb8 99 485 | :Ds8 99 486 | :E8 100 487 | :F8 101 488 | :Fs8 102 489 | :Gb8 102 490 | :G8 103 491 | :Ab8 104 492 | :Gs8 104 493 | :A8 105 494 | :Bb8 106 495 | :As8 106 496 | :B8 107 497 | :Cb8 107 498 | 499 | :C9 108 500 | :Db9 109 501 | :Cs9 109 502 | :D9 110 503 | :Eb9 111 504 | :Ds9 111 505 | :E9 112 506 | :F9 113 507 | :Fs9 114 508 | :Gb9 114 509 | :G9 115 510 | :Ab9 116 511 | :Gs9 116 512 | :A9 117 513 | :Bb9 118 514 | :As9 118 515 | :B9 119 516 | :Cb9 119 517 | } 518 | ) 519 | 520 | (def- scales @{ 521 | :bebop [0 2 4 5 7 9 10 11] 522 | :blues [0 3 5 6 7 10] 523 | :flamenco [0 1 4 5 7 8 11] 524 | :harmonic_minor [0 2 3 5 7 8 11] 525 | :hirajoshi [0 4 6 7 11] 526 | :melodic_minor [0 2 3 5 7 9 11] 527 | :minor_pentatonic [0 3 5 7 10] 528 | :major_pentatonic [0 2 4 7 9] 529 | :minor [0 2 3 5 7 8 10] 530 | :major [0 2 4 5 7 9 11] 531 | } 532 | ) 533 | 534 | (defn note? [n] 535 | (or 536 | (number? n) 537 | (not (nil? (get midi_notes n))) 538 | ) 539 | ) 540 | 541 | (defn notes? [n] 542 | (if (indexed? n) 543 | (all note? n) 544 | (note? n) 545 | ) 546 | ) 547 | 548 | (defn- note_single [quality] 549 | (if (number? quality) 550 | quality 551 | (get midi_notes quality) 552 | ) 553 | ) 554 | 555 | (defn- notes 556 | [qualities] 557 | (map note_single qualities) 558 | ) 559 | 560 | (defn note 561 | ````Returns a MIDI note or notes that corresponds to the given `quality(s)` 562 | 563 | **Example** 564 | ``` 565 | (note :c4) # -> 48 566 | (note [:c4 :d4]) # -> [48 50] 567 | ``` 568 | ```` 569 | [quality] 570 | (cond 571 | (note? quality) (note_single quality) 572 | (notes? quality) (notes quality) 573 | (errorf "not a note or notes %q" quality) 574 | ) 575 | ) 576 | 577 | (defn- single_note_chord_scale_generator [tones rootNum] 578 | (fn [n] 579 | (let [idx (% n (length tones))] 580 | (+ 581 | rootNum 582 | (* 12 (math/floor (/ n (length tones)))) 583 | (get tones (if (>= idx 0) idx (+ (length tones) idx))) 584 | ) 585 | ) 586 | ) 587 | ) 588 | 589 | (defn- chord_scale_generator [tones rootNum] 590 | (fn [notes_or_note] 591 | (if (or (array? notes_or_note) (tuple? notes_or_note)) 592 | (map (single_note_chord_scale_generator tones rootNum) notes_or_note) 593 | ((single_note_chord_scale_generator tones rootNum) notes_or_note) 594 | ) 595 | ) 596 | ) 597 | 598 | (defn scale 599 | ````Returns a MIDI scale generator of a given root and quality 600 | 601 | For qualities see [harmony.janet](https://github.com/gwegash/trane/blob/master/src/harmony.janet#L518) 602 | 603 | **Example** 604 | ``` 605 | ((scale :C3 :minor) [0 1 2 3 4 5 6]) # -> @[36 38 39 41 43 44 46] 606 | ((scale :C3 :minor) 0) # -> 36 607 | ``` 608 | ```` 609 | [root quality] 610 | (def rootNum (note root)) 611 | (def tones (get scales quality)) 612 | (if tones 613 | (chord_scale_generator tones rootNum) 614 | (errorf "not a scale %q" quality) 615 | ) 616 | ) 617 | 618 | (defn chord 619 | ````Returns a MIDI chord generator of a given root and quality 620 | 621 | For qualities see [harmony.janet](https://github.com/gwegash/trane/blob/master/src/harmony.janet#L3) 622 | 623 | **Example** 624 | ``` 625 | ((chord :C3 :min) [0 1 2]) # -> @[36 39 43] 626 | ((chord :C3 :min) 0) # -> 36 627 | ``` 628 | ```` 629 | [root quality] 630 | (def rootNum (note root)) 631 | (def tones (get chord_qualities quality)) 632 | (if tones 633 | (chord_scale_generator tones rootNum) 634 | (errorf "not a chord %q" quality) 635 | ) 636 | ) 637 | -------------------------------------------------------------------------------- /src/driver.cpp: -------------------------------------------------------------------------------- 1 | // A lot of this is taken (gratefully) from https://github.com/ianthehenry/toodle.studio 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include "janet.h" 7 | 8 | using std::string; 9 | 10 | static JanetFunction *janetfn_evaluate = NULL; 11 | static JanetFunction *janetfn_run = NULL; 12 | static JanetFunction *janetfn_print_instruments = NULL; 13 | static JanetFunction *janetfn_print_loops = NULL; 14 | 15 | Janet env_lookup(JanetTable *env, const char *name) { 16 | Janet out; 17 | janet_resolve(env, janet_csymbol(name), &out); 18 | return out; 19 | } 20 | 21 | JanetFunction *env_lookup_function(JanetTable *env, const char *name) { 22 | Janet value = env_lookup(env, name); 23 | if (!janet_checktype(value, JANET_FUNCTION)) { 24 | janet_panicf("expected %s to be a function, got %q\n", name, value); 25 | } 26 | return janet_unwrap_function(value); 27 | } 28 | 29 | JanetTable *env_lookup_table(JanetTable *env, const char *name) { 30 | Janet value = env_lookup(env, name); 31 | if (!janet_checktype(value, JANET_TABLE)) { 32 | janet_panicf("expected %s to be a table, got %q\n", name, value); 33 | } 34 | return janet_unwrap_table(value); 35 | } 36 | 37 | bool call_fn(JanetFunction *fn, int argc, const Janet *argv, Janet *out) { 38 | JanetFiber *fiber = NULL; 39 | if (janet_pcall(fn, argc, argv, out, &fiber) == JANET_SIGNAL_OK) { 40 | return true; 41 | } else { 42 | janet_stacktrace(fiber, *out); 43 | return false; 44 | } 45 | } 46 | 47 | // TODO fractional time 48 | 49 | struct Note { 50 | int32_t channel; 51 | double pitch; 52 | double vel; 53 | double start; 54 | double dur; 55 | }; 56 | 57 | struct Instrument { 58 | int32_t channel; 59 | string name; 60 | std::vector args; //arguments used to set parameters on instruments at compile time. (Ie this sample or samples) 61 | }; 62 | 63 | struct CompileResult { 64 | bool is_error; 65 | string error; 66 | uintptr_t image; 67 | }; 68 | 69 | struct StartResult { 70 | /* TODO: also background color, fade info? */ 71 | uintptr_t environment; 72 | std::vector lloop_names; 73 | std::vector instrument_mappings; 74 | double bpm; 75 | }; 76 | 77 | struct ContinueResult { 78 | bool is_error; 79 | string error; 80 | std::vector notes; 81 | double rest_length; 82 | }; 83 | 84 | CompileResult compilation_error(string message) { 85 | return (CompileResult) { 86 | .is_error = true, 87 | .error = message, 88 | .image = NULL, 89 | }; 90 | } 91 | 92 | ContinueResult continue_error(string message) { 93 | return (ContinueResult) { 94 | .is_error = true, 95 | .error = message, 96 | .notes = std::vector(), 97 | .rest_length = 0.0, 98 | }; 99 | } 100 | 101 | void retain_environment(uintptr_t environment_ptr) { 102 | janet_gcroot(janet_wrap_table(reinterpret_cast(environment_ptr))); 103 | } 104 | void release_environment(uintptr_t environment_ptr) { 105 | janet_gcunroot(janet_wrap_table(reinterpret_cast(environment_ptr))); 106 | } 107 | 108 | void retain_image(uintptr_t image_ptr) { 109 | janet_gcroot(janet_wrap_buffer(reinterpret_cast(image_ptr))); 110 | } 111 | void release_image(uintptr_t image_ptr) { 112 | janet_gcunroot(janet_wrap_buffer(reinterpret_cast(image_ptr))); 113 | } 114 | 115 | CompileResult trane_compile(string source) { 116 | if (janetfn_evaluate == NULL) { 117 | fprintf(stderr, "unable to initialize evaluator\n"); 118 | return compilation_error("function uninitialized"); 119 | } 120 | 121 | Janet environment; 122 | const Janet args[1] = { janet_cstringv(source.c_str()) }; 123 | if (!call_fn(janetfn_evaluate, 1, args, &environment)) { 124 | return compilation_error("compilation error"); 125 | } 126 | 127 | JanetTable *reverse_lookup = env_lookup_table(janet_core_env(NULL), "make-image-dict"); 128 | JanetBuffer *image = janet_buffer(2 << 8); 129 | janet_marshal(image, environment, reverse_lookup, 0); 130 | 131 | janet_gcroot(janet_wrap_buffer(image)); 132 | return (CompileResult) { 133 | .is_error = false, 134 | .error = "", 135 | .image = reinterpret_cast(image), 136 | }; 137 | } 138 | 139 | StartResult trane_start(uintptr_t image_ptr) { 140 | JanetBuffer *image = reinterpret_cast(image_ptr); 141 | JanetTable *lookup = env_lookup_table(janet_core_env(NULL), "load-image-dict"); 142 | Janet environment = janet_unmarshal(image->data, image->count, 0, lookup, NULL); 143 | if (!janet_checktype(environment, JANET_TABLE)) { 144 | janet_panicf("%q is not an environment table", environment); 145 | } 146 | 147 | const Janet args[1] = { environment }; 148 | 149 | janet_gcroot(environment); 150 | JanetTable * envTable = janet_unwrap_table(environment); 151 | Janet lloopsTable = janet_table_get(envTable, janet_ckeywordv("lloops")); 152 | 153 | const Janet tableArgs[1] = { lloopsTable }; 154 | 155 | // 156 | //grab lloop names 157 | Janet keys_result; 158 | 159 | janet_gcroot(keys_result); 160 | call_fn(janetfn_print_loops, 1, tableArgs, &keys_result); 161 | janet_gcunroot(keys_result); 162 | 163 | JanetArray * keys_unparsed = janet_unwrap_array(keys_result); 164 | 165 | int32_t count = keys_unparsed->count; 166 | 167 | auto keys_vec = std::vector(); 168 | 169 | for (int32_t i = 0; i < count; i++) { 170 | const string lloop_name = reinterpret_cast janet_unwrap_string(keys_unparsed->data[i]); 171 | keys_vec.push_back(lloop_name); 172 | } 173 | 174 | //Grab the bpm 175 | const Janet janetBpm = janet_table_get(envTable, janet_ckeywordv("bpm")); 176 | janet_gcroot(janetBpm); 177 | double bpm = janet_unwrap_number(janetBpm); 178 | janet_gcunroot(janetBpm); 179 | 180 | // 181 | //grab instrument mappings 182 | Janet instruments_table = janet_table_get(envTable, janet_ckeywordv("instruments")); 183 | 184 | const Janet kvsArgs[1] = { instruments_table }; 185 | 186 | Janet kvs_result; 187 | 188 | janet_gcroot(kvs_result); 189 | call_fn(janetfn_print_instruments, 1, kvsArgs, &kvs_result); 190 | janet_gcunroot(kvs_result); 191 | 192 | JanetArray * instruments_unparsed = janet_unwrap_array(kvs_result); 193 | 194 | int32_t instruments_count = instruments_unparsed->count / 2; //KVS comes in pairs of key, val so we divide by 2 195 | 196 | auto instruments_vec = std::vector(); 197 | 198 | for (int32_t i = 0; i < instruments_count; i++) { 199 | const string instrument_name = reinterpret_cast janet_unwrap_string(instruments_unparsed->data[i*2]); 200 | 201 | JanetArray * instrument_params_unparsed = janet_unwrap_array(instruments_unparsed->data[i*2 + 1]); 202 | int32_t instrument_params_count = instrument_params_unparsed->count; 203 | 204 | const int32_t instrument_channel = janet_unwrap_integer(instrument_params_unparsed->data[0]); //first value is our channel 205 | 206 | auto instruments_params_vec = std::vector(); 207 | 208 | for (int32_t j = 1; j < instrument_params_count; j++) { 209 | const string param_string = reinterpret_cast janet_unwrap_string(instrument_params_unparsed->data[j]); 210 | instruments_params_vec.push_back(param_string); 211 | } 212 | 213 | instruments_vec.push_back((Instrument) { 214 | instrument_channel, 215 | instrument_name, 216 | instruments_params_vec, 217 | }); 218 | } 219 | 220 | struct sort_by_channel //We do this so that wires will be created after the instruments they connect 221 | { 222 | inline bool operator() (const Instrument& instrument1, const Instrument& instrument2) 223 | { 224 | return (instrument1.channel < instrument2.channel); 225 | } 226 | }; 227 | 228 | std::sort(instruments_vec.begin(), instruments_vec.end(), sort_by_channel()); 229 | 230 | return (StartResult) { 231 | .environment = reinterpret_cast(envTable), 232 | .lloop_names = keys_vec, 233 | .instrument_mappings = instruments_vec, 234 | .bpm = bpm 235 | }; 236 | } 237 | 238 | ContinueResult trane_continue(uintptr_t environment_ptr, string loop_name, double start_beat) { 239 | if (janetfn_run == NULL) { 240 | janet_panicf("unable to initialize runner"); 241 | } 242 | 243 | JanetTable *environment = reinterpret_cast(environment_ptr); 244 | 245 | Janet run_result; 246 | const Janet args[3] = { janet_wrap_table(environment), janet_ckeywordv(loop_name.c_str()), janet_wrap_number(start_beat)}; 247 | if (!call_fn(janetfn_run, 3, args, &run_result)) { 248 | return continue_error("evaluation error"); 249 | } 250 | janet_gcroot(run_result); //TODO this might want to go before the run call 251 | janet_gcunroot(run_result); 252 | 253 | const Janet * result_unparsed = janet_unwrap_tuple(run_result); 254 | 255 | const double rest_time = janet_unwrap_number(result_unparsed[0]); 256 | JanetArray *notes = janet_unwrap_array(result_unparsed[1]); 257 | int32_t count = notes->count; 258 | 259 | auto note_vec = std::vector(); 260 | 261 | for (int32_t i = 0; i < count; i++) { 262 | const Janet * note = janet_unwrap_tuple(notes->data[i]); 263 | const int32_t channel = janet_unwrap_integer(note[0]); 264 | const double pitch = janet_unwrap_number(note[1]); 265 | const double vel = janet_unwrap_number(note[2]); 266 | const double start = janet_unwrap_number(note[3]); 267 | const double dur = janet_unwrap_number(note[4]); 268 | 269 | note_vec.push_back((Note) { 270 | channel, 271 | pitch, 272 | vel, 273 | start, 274 | dur, 275 | }); 276 | } 277 | 278 | return (ContinueResult) { 279 | .is_error = false, 280 | .error = "", 281 | .notes = note_vec, 282 | .rest_length = rest_time, 283 | }; 284 | } 285 | 286 | // TODO: just use JanetBuffer? Why am I bothering with this? 287 | unsigned char *read_file(const char *filename, size_t *length) { 288 | size_t capacity = 2 << 17; 289 | unsigned char *src = (unsigned char *)malloc(capacity * sizeof(unsigned char)); 290 | assert(src); 291 | size_t total_bytes_read = 0; 292 | FILE *file = fopen(filename, "r"); 293 | assert(file); 294 | size_t bytes_read; 295 | do { 296 | size_t remaining_capacity = capacity - total_bytes_read; 297 | if (remaining_capacity == 0) { 298 | capacity <<= 1; 299 | src = (unsigned char *)realloc(src, capacity * sizeof(unsigned char)); 300 | assert(src); 301 | remaining_capacity = capacity - total_bytes_read; 302 | } 303 | 304 | bytes_read = fread(&src[total_bytes_read], sizeof(unsigned char), remaining_capacity, file); 305 | total_bytes_read += bytes_read; 306 | } while (bytes_read > 0); 307 | 308 | fclose(file); 309 | *length = total_bytes_read; 310 | return src; 311 | } 312 | 313 | EMSCRIPTEN_KEEPALIVE 314 | int main() { 315 | janet_init(); 316 | JanetTable *lookup = env_lookup_table(janet_core_env(NULL), "load-image-dict"); 317 | 318 | size_t image_length; 319 | unsigned char *image = read_file("trane.jimage", &image_length); 320 | 321 | Janet environment = janet_unmarshal(image, image_length, 0, lookup, NULL); 322 | if (!janet_checktype(environment, JANET_TABLE)) { 323 | janet_panicf("invalid image %q", environment); 324 | } 325 | 326 | janetfn_evaluate = env_lookup_function(janet_unwrap_table(environment), "evaluator/evaluate"); 327 | janet_gcroot(janet_wrap_function(janetfn_evaluate)); 328 | janetfn_run = env_lookup_function(janet_unwrap_table(environment), "runner/run"); 329 | janet_gcroot(janet_wrap_function(janetfn_run)); 330 | janetfn_print_instruments = env_lookup_function(janet_unwrap_table(environment), "runner/print_instruments"); 331 | janet_gcroot(janet_wrap_function(janetfn_print_instruments)); 332 | janetfn_print_loops = env_lookup_function(janet_unwrap_table(environment), "runner/print_loops"); 333 | janet_gcroot(janet_wrap_function(janetfn_print_loops)); 334 | } 335 | 336 | EMSCRIPTEN_BINDINGS(module) { 337 | using namespace emscripten; 338 | 339 | value_object("Note") 340 | .field("channel", &Note::channel) 341 | .field("pitch", &Note::pitch) 342 | .field("vel", &Note::vel) 343 | .field("start", &Note::start) 344 | .field("dur", &Note::dur) 345 | ; 346 | 347 | value_object("Instrument") 348 | .field("channel", &Instrument::channel) 349 | .field("name", &Instrument::name) 350 | .field("args", &Instrument::args) 351 | ; 352 | 353 | register_vector("InstrumentVector"); 354 | register_vector("NoteVector"); 355 | register_vector("StringVector"); 356 | 357 | value_object("CompileResult") 358 | .field("isError", &CompileResult::is_error) 359 | .field("error", &CompileResult::error) 360 | .field("image", &CompileResult::image) 361 | ; 362 | 363 | value_object("StartResult") 364 | .field("environment", &StartResult::environment) 365 | .field("lloop_names", &StartResult::lloop_names) 366 | .field("instrument_mappings", &StartResult::instrument_mappings) 367 | .field("bpm", &StartResult::bpm) 368 | ; 369 | 370 | value_object("ContinueResult") 371 | .field("isError", &ContinueResult::is_error) 372 | .field("error", &ContinueResult::error) 373 | .field("notes", &ContinueResult::notes) 374 | .field("rest_length", &ContinueResult::rest_length) 375 | ; 376 | 377 | function("trane_compile", &trane_compile); 378 | function("trane_start", &trane_start); 379 | function("trane_continue", &trane_continue); 380 | function("retain_environment", &retain_environment); 381 | function("release_environment", &release_environment); 382 | function("retain_image", &retain_image); 383 | function("release_image", &release_image); 384 | }; 385 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # trane API 2 | 3 | ## dsl 4 | 5 | [P](#P), [bpm](#bpm), [chain](#chain), [change](#change), [exp](#exp), [itarget](#itarget), [lin](#lin), [live_loop](#live_loop), [pick](#pick), [play](#play), [rand](#rand), [rep](#rep), [seed](#seed), [sleep](#sleep), [target](#target), [til](#til), [time](#time), [timesel](#timesel), [uclid](#uclid), [wire](#wire) 6 | 7 | ### P 8 | 9 | **function** | [source][1] 10 | 11 | ```janet 12 | (P pattern lengthBeats) 13 | ``` 14 | 15 | Evaluates a given subdivision `pattern` over a number of beats given by `lengthBeats` 16 | Returns a list of `[note, duration]` pairs that can be scheduled. 17 | 18 | **Example** 19 | ``` 20 | (P [0 [1 1] 0 0] 4) # -> @[(0 1) (1 0.5) (1 0.5) (0 1) (0 1)] 21 | ``` 22 | 23 | [1]: ../src/dsl.janet#L223 24 | 25 | ### bpm 26 | 27 | **macro** | [source][2] 28 | 29 | ```janet 30 | (bpm beats_per_minute) 31 | ``` 32 | 33 | Sets the bpm (beats per minute) of the current track. 34 | 35 | Can only be set once per track, BPM changes are currently unsupported. 36 | To change BPM, reload the page 37 | **Example** 38 | ``` 39 | (bpm 120) # Sets the BPM to 120 beats per minute 40 | ``` 41 | 42 | [2]: ../src/dsl.janet#L13 43 | 44 | ### chain 45 | 46 | **function** | [source][3] 47 | 48 | ```janet 49 | (chain & forms) 50 | ``` 51 | 52 | 53 | 54 | [3]: ../src/dsl.janet#L198 55 | 56 | ### change 57 | 58 | **macro** | [source][4] 59 | 60 | ```janet 61 | (change instName param to & rest) 62 | ``` 63 | 64 | Changes the parameter `param` knob of a module `instName` to `to` 65 | 66 | **Example** 67 | ``` 68 | (change :gain-example :gain 0.5) # changes the gain knob on a gain module ':gain-example' to 0.5 69 | ``` 70 | 71 | [4]: ../src/dsl.janet#L42 72 | 73 | ### exp 74 | 75 | **macro** | [source][5] 76 | 77 | ```janet 78 | (exp instName paramIdx to) 79 | ``` 80 | 81 | Changes the parameter `param` knob of a module `instName` to `to` 82 | Approaches `to` from its last value exponentially 83 | 84 | **Example** 85 | ``` 86 | (exp :gain-example :gain 0.5) # changes the gain knob on a gain module ':gain-example' to 0.5, approaches exponentially 87 | ``` 88 | 89 | [5]: ../src/dsl.janet#L87 90 | 91 | ### itarget 92 | 93 | **macro** | [source][6] 94 | 95 | ```janet 96 | (itarget instName paramIdx to) 97 | ``` 98 | 99 | Instantaneously changes the parameter `param` knob of a module `instName` to `to` 100 | 101 | **Example** 102 | ``` 103 | (itarget :gain-example :gain 0.5) # instantaneously changes the gain knob on a gain module ':gain-example' to 0.5 104 | ``` 105 | 106 | [6]: ../src/dsl.janet#L100 107 | 108 | ### lin 109 | 110 | **macro** | [source][7] 111 | 112 | ```janet 113 | (lin instName paramIdx to) 114 | ``` 115 | 116 | Changes the parameter `param` knob of a module `instName` to `to` 117 | Approaches `to` from its last value linearly 118 | 119 | **Example** 120 | ``` 121 | (lin :gain-example :gain 0.5) # linearly changes the gain knob on a gain module ':gain-example' to 0.5 122 | ``` 123 | 124 | [7]: ../src/dsl.janet#L74 125 | 126 | ### live_loop 127 | 128 | **macro** | [source][8] 129 | 130 | ```janet 131 | (live_loop name & instructions) 132 | ``` 133 | 134 | Creates a live-loop of a given name to schedule notes or parameter changes from 135 | 136 | **Example** 137 | ``` 138 | (live-loop :hello-world 139 | (play :c4 :hello-synth :dur 0.25) 140 | (sleep 0.5) 141 | ) 142 | ``` 143 | 144 | [8]: ../src/dsl.janet#L313 145 | 146 | ### pick 147 | 148 | **macro** | [source][9] 149 | 150 | ```janet 151 | (pick & picks) 152 | ``` 153 | 154 | Picks an item randomly from the arguments `picks` 155 | 156 | **Example** 157 | ``` 158 | (pick 1 2 3) # -> 1 159 | (pick 1 2 3) # -> 3 160 | ``` 161 | 162 | [9]: ../src/dsl.janet#L262 163 | 164 | ### play 165 | 166 | **macro** | [source][10] 167 | 168 | ```janet 169 | (play note instName & rest) 170 | ``` 171 | 172 | Plays a `note` on a given instrument `instName` 173 | Also accepts a `:dur` duration parameter, in beats. 174 | 175 | **Example** 176 | ``` 177 | (play :C4 :my-sampler :dur 0.5) # plays a :C4 on :my-sampler for 0.5 beats 178 | (play 0 :my-drum :dur 2) # plays a note 0 on :my-drum for 2 beats 179 | ``` 180 | 181 | [10]: ../src/dsl.janet#L242 182 | 183 | ### rand 184 | 185 | **macro** | [source][11] 186 | 187 | ```janet 188 | (rand lo hi) 189 | ``` 190 | 191 | Picks a number uniformly between `lo` and `hi` 192 | 193 | **Example** 194 | ``` 195 | (rand 0 1) # -> 0.566847 196 | ``` 197 | 198 | [11]: ../src/dsl.janet#L275 199 | 200 | ### rep 201 | 202 | **function** | [source][12] 203 | 204 | ```janet 205 | (rep what times) 206 | ``` 207 | 208 | Returns a repeated array filled with `what` #times 209 | 210 | **Example** 211 | ``` 212 | (rep [1 2 3] 3) # -> @[(1 2 3) (1 2 3) (1 2 3)] 213 | ``` 214 | 215 | [12]: ../src/dsl.janet#L155 216 | 217 | ### seed 218 | 219 | **macro** | [source][13] 220 | 221 | ```janet 222 | (seed seed) 223 | ``` 224 | 225 | Sets the random seed of the current live-loop. Useful for repeatable random patterns 226 | 227 | **Example** 228 | ``` 229 | (seed 5) 230 | ``` 231 | 232 | [13]: ../src/dsl.janet#L301 233 | 234 | ### sleep 235 | 236 | **macro** | [source][14] 237 | 238 | ```janet 239 | (sleep length) 240 | ``` 241 | 242 | Advances time in the current 'live-loop' by the specified `length`, in beats 243 | 244 | **Example** 245 | ``` 246 | (sleep 4) # advance time by 4 beats 247 | ``` 248 | 249 | [14]: ../src/dsl.janet#L29 250 | 251 | ### target 252 | 253 | **macro** | [source][15] 254 | 255 | ```janet 256 | (target instName paramIdx to k) 257 | ``` 258 | 259 | 260 | 261 | [15]: ../src/dsl.janet#L112 262 | 263 | ### til 264 | 265 | **macro** | [source][16] 266 | 267 | ```janet 268 | (til when_beats) 269 | ``` 270 | 271 | Returns the time until the next measure of `when_beats` 272 | 273 | **Example** 274 | ``` 275 | (time) # -> 32 276 | (sleep (til 64)) # -> (sleep 32) 277 | ``` 278 | 279 | [16]: ../src/dsl.janet#L142 280 | 281 | ### time 282 | 283 | **macro** | [source][17] 284 | 285 | ```janet 286 | (time) 287 | ``` 288 | 289 | Returns the current time, in beats, of the containing live-loop 290 | 291 | **Example** 292 | ``` 293 | (time) # -> 32 294 | ``` 295 | 296 | [17]: ../src/dsl.janet#L129 297 | 298 | ### timesel 299 | 300 | **macro** | [source][18] 301 | 302 | ```janet 303 | (timesel arr changeEvery) 304 | ``` 305 | 306 | Indexes into the array or tuple `arr` with the current time, with the index increasing by one after `changeEvery` time has passed, modulo the length `arr`. 307 | 308 | **Example** 309 | ``` 310 | (time) # -> 5 311 | (timesel [1 2 3 4] 1) # -> 2 312 | (timesel [1 2 3 4] 2) # -> 3 313 | ``` 314 | 315 | [18]: ../src/dsl.janet#L287 316 | 317 | ### uclid 318 | 319 | **function** | [source][19] 320 | 321 | ```janet 322 | (uclid pat n steps) 323 | ``` 324 | 325 | 326 | 327 | [19]: ../src/dsl.janet#L8 328 | 329 | ### wire 330 | 331 | **macro** | [source][20] 332 | 333 | ```janet 334 | (wire from to &opt toParam) 335 | ``` 336 | 337 | Wires the output of 'from' the input of `to` 338 | Accepts an optional `toParam` which specifies a named parameter, or knob, of `to` to wire the output to. 339 | 340 | **Example** 341 | ``` 342 | # Wires the output of :signal into the :frequency parameter of :filter 343 | (wire :signal :filter :frequency) 344 | ``` 345 | 346 | [20]: ../src/dsl.janet#L167 347 | 348 | ## harmony 349 | 350 | [chord](#chord), [note](#note), [notes](#notes), [scale](#scale) 351 | 352 | ### chord 353 | 354 | **function** | [source][21] 355 | 356 | ```janet 357 | (chord root quality) 358 | ``` 359 | 360 | Returns a MIDI chord generator of a given root and quality 361 | 362 | For qualities see [harmony.janet](https://github.com/gwegash/trane/blob/master/src/harmony.janet#L3) 363 | 364 | **Example** 365 | ``` 366 | ((chord :C3 :min) [0 1 2]) # -> @[36 39 43] 367 | ((chord :C3 :min) 0) # -> 36 368 | ``` 369 | 370 | [21]: ../src/harmony.janet#L594 371 | 372 | ### note 373 | 374 | **function** | [source][22] 375 | 376 | ```janet 377 | (note quality) 378 | ``` 379 | 380 | Returns a MIDI note that corresponds to the given `quality` 381 | 382 | **Example** 383 | ``` 384 | (note :c4) # -> 48 385 | ``` 386 | 387 | [22]: ../src/harmony.janet#L537 388 | 389 | ### notes 390 | 391 | **function** | [source][23] 392 | 393 | ```janet 394 | (notes & qualities) 395 | ``` 396 | 397 | A mapped version of note 398 | 399 | **Example** 400 | ``` 401 | (notes :c3 :e3 :g3) # -> @[36 40 43] 402 | ``` 403 | 404 | [23]: ../src/harmony.janet#L614 405 | 406 | ### scale 407 | 408 | **function** | [source][24] 409 | 410 | ```janet 411 | (scale root quality) 412 | ``` 413 | 414 | Returns a MIDI scale generator of a given root and quality 415 | 416 | For qualities see [harmony.janet](https://github.com/gwegash/trane/blob/master/src/harmony.janet#L518) 417 | 418 | **Example** 419 | ``` 420 | ((scale :C3 :minor) [0 1 2 3 4 5 6]) # -> @[36 38 39 41 43 44 46] 421 | ((scale :C3 :minor) 0) # -> 36 422 | ``` 423 | 424 | [24]: ../src/harmony.janet#L574 425 | 426 | ## instruments 427 | 428 | [Dlay](#Dlay), [biquad](#biquad), [breakbeat](#breakbeat), [chorus](#chorus), [compressor](#compressor), [constant](#constant), [distortion](#distortion), [drums](#drums), [gain](#gain), [keyboard](#keyboard), [ladder](#ladder), [lfo](#lfo), [line_in](#line_in), [looper](#looper), [oscillator](#oscillator), [panner](#panner), [reverb](#reverb), [sample](#sample), [scope](#scope), [synth](#synth) 429 | 430 | ### Dlay 431 | 432 | **macro** | [source][25] 433 | 434 | ```janet 435 | (Dlay name &named delay_time feedback) 436 | ``` 437 | 438 | Creates a delay module with a given `name` 439 | * `delay_time` is given in beats 440 | * `feedback` is a number 441 | 442 | **Example** 443 | ``` 444 | # Creates a delay module with a delay line length of 0.75 beats and a feedback of 50% 445 | (Dlay :hello-delay :delay_time 0.75 :feedback 0.5) 446 | ``` 447 | 448 | [25]: ../src/instruments.janet#L16 449 | 450 | ### biquad 451 | 452 | **macro** | [source][26] 453 | 454 | ```janet 455 | (biquad name &named filter_type frequency detune Q gain) 456 | ``` 457 | 458 | 459 | 460 | [26]: ../src/instruments.janet#L103 461 | 462 | ### breakbeat 463 | 464 | **macro** | [source][27] 465 | 466 | ```janet 467 | (breakbeat name &named url length_beats slices) 468 | ``` 469 | 470 | 471 | 472 | [27]: ../src/instruments.janet#L85 473 | 474 | ### chorus 475 | 476 | **macro** | [source][28] 477 | 478 | ```janet 479 | (chorus name) 480 | ``` 481 | 482 | 483 | 484 | [28]: ../src/instruments.janet#L77 485 | 486 | ### compressor 487 | 488 | **macro** | [source][29] 489 | 490 | ```janet 491 | (compressor name &named threshold knee ratio attack release) 492 | ``` 493 | 494 | 495 | 496 | [29]: ../src/instruments.janet#L49 497 | 498 | ### constant 499 | 500 | **macro** | [source][30] 501 | 502 | ```janet 503 | (constant name &named constant) 504 | ``` 505 | 506 | 507 | 508 | [30]: ../src/instruments.janet#L123 509 | 510 | ### distortion 511 | 512 | **macro** | [source][31] 513 | 514 | ```janet 515 | (distortion name &named amount) 516 | ``` 517 | 518 | 519 | 520 | [31]: ../src/instruments.janet#L45 521 | 522 | ### drums 523 | 524 | **macro** | [source][32] 525 | 526 | ```janet 527 | (drums name &named hits) 528 | ``` 529 | 530 | 531 | 532 | [32]: ../src/instruments.janet#L65 533 | 534 | ### gain 535 | 536 | **macro** | [source][33] 537 | 538 | ```janet 539 | (gain name &named gain) 540 | ``` 541 | 542 | 543 | 544 | [33]: ../src/instruments.janet#L69 545 | 546 | ### keyboard 547 | 548 | **macro** | [source][34] 549 | 550 | ```janet 551 | (keyboard name) 552 | ``` 553 | 554 | 555 | 556 | [34]: ../src/instruments.janet#L73 557 | 558 | ### ladder 559 | 560 | **macro** | [source][35] 561 | 562 | ```janet 563 | (ladder name &named cutoff Q) 564 | ``` 565 | 566 | 567 | 568 | [35]: ../src/instruments.janet#L119 569 | 570 | ### lfo 571 | 572 | **macro** | [source][36] 573 | 574 | ```janet 575 | (lfo name &named wave frequency magnitude) 576 | ``` 577 | 578 | 579 | 580 | [36]: ../src/instruments.janet#L111 581 | 582 | ### line_in 583 | 584 | **macro** | [source][37] 585 | 586 | ```janet 587 | (line_in name) 588 | ``` 589 | 590 | 591 | 592 | [37]: ../src/instruments.janet#L53 593 | 594 | ### looper 595 | 596 | **macro** | [source][38] 597 | 598 | ```janet 599 | (looper name &named loop_time) 600 | ``` 601 | 602 | Creates a looping module with a given `name` 603 | `loop_time` is given in beats 604 | 605 | **Example** 606 | ``` 607 | # Creates a looping module with a loop time of 4 beats 608 | (looper :hello-looper :loop_time 4) 609 | ``` 610 | 611 | [38]: ../src/instruments.janet#L31 612 | 613 | ### oscillator 614 | 615 | **macro** | [source][39] 616 | 617 | ```janet 618 | (oscillator name &named wave frequency) 619 | ``` 620 | 621 | 622 | 623 | [39]: ../src/instruments.janet#L107 624 | 625 | ### panner 626 | 627 | **macro** | [source][40] 628 | 629 | ```janet 630 | (panner name &named pan) 631 | ``` 632 | 633 | 634 | 635 | [40]: ../src/instruments.janet#L81 636 | 637 | ### reverb 638 | 639 | **macro** | [source][41] 640 | 641 | ```janet 642 | (reverb name &named impulse) 643 | ``` 644 | 645 | Creates a convolution reverb module with a given `name` 646 | Grabs an impulse from the URL of the `impulse` parameter 647 | 648 | **Example** 649 | ``` 650 | (reverb :hello-verb :impulse "http://impulses.com/big_impulse.wav") 651 | ``` 652 | 653 | [41]: ../src/instruments.janet#L3 654 | 655 | ### sample 656 | 657 | **macro** | [source][42] 658 | 659 | ```janet 660 | (sample name &named url pitch gain attack release) 661 | ``` 662 | 663 | 664 | 665 | [42]: ../src/instruments.janet#L57 666 | 667 | ### scope 668 | 669 | **macro** | [source][43] 670 | 671 | ```janet 672 | (scope name) 673 | ``` 674 | 675 | 676 | 677 | [43]: ../src/instruments.janet#L115 678 | 679 | ### synth 680 | 681 | **macro** | [source][44] 682 | 683 | ```janet 684 | (synth name &named wave gain attack release) 685 | ``` 686 | 687 | 688 | 689 | [44]: ../src/instruments.janet#L99 690 | 691 | --------------------------------------------------------------------------------