├── .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 | 
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 |
--------------------------------------------------------------------------------