@grame/faust-web-component 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +68 -0
- package/package.json +2 -2
- package/public/vite.svg +1 -0
- package/src/common.ts +114 -0
- package/src/editor.ts +88 -0
- package/src/faust-editor.ts +451 -0
- package/src/faust-widget.ts +226 -0
- package/src/faustText.svg +2 -0
- package/src/main.ts +5 -0
- package/src/scope.ts +205 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +23 -0
- package/vite.config.js +13 -0
package/index.html
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
<!doctype html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8" />
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7
|
+
<title>Vite + TS</title>
|
8
|
+
<script type="module" src="/src/main.ts"></script>
|
9
|
+
<style>
|
10
|
+
#content {
|
11
|
+
width: 50%;
|
12
|
+
margin: auto;
|
13
|
+
}
|
14
|
+
</style>
|
15
|
+
</head>
|
16
|
+
<body>
|
17
|
+
<div id="content">
|
18
|
+
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
|
19
|
+
<faust-editor>
|
20
|
+
import("stdfaust.lib");
|
21
|
+
process = no.noise;
|
22
|
+
</faust-editor>
|
23
|
+
<p>Nostrum, id deserunt inventore ut veritatis iusto autem soluta officia debitis omnis, dolor sint cum, illo quam dignissimos fuga quod optio nesciunt.</p>
|
24
|
+
<!-- Need to comment below example due to occurrence of `<`. -->
|
25
|
+
<faust-editor>
|
26
|
+
<!--
|
27
|
+
import("stdfaust.lib");
|
28
|
+
ctFreq = 500;
|
29
|
+
q = 5;
|
30
|
+
gain = 1;
|
31
|
+
process = no.noise : _ <: fi.resonlp(ctFreq,q,gain),fi.resonlp(ctFreq,q,gain);
|
32
|
+
-->
|
33
|
+
</faust-editor>
|
34
|
+
<p>Ex velit voluptates, laudantium laboriosam est quis veniam temporibus tempore minima aspernatur, modi labore molestiae provident adipisci voluptatem dolorem voluptatibus asperiores possimus.</p>
|
35
|
+
<faust-editor>
|
36
|
+
<!--
|
37
|
+
import("stdfaust.lib");
|
38
|
+
ctFreq = hslider("cutoffFrequency",500,50,10000,0.01);
|
39
|
+
q = hslider("q",5,1,30,0.1);
|
40
|
+
gain = hslider("gain",1,0,1,0.01);
|
41
|
+
process = no.noise : fi.resonlp(ctFreq,q,gain);
|
42
|
+
-->
|
43
|
+
</faust-editor>
|
44
|
+
<p>Nam tempora officiis sunt nisi cupiditate expedita magnam atque. Unde, itaque.</p>
|
45
|
+
<faust-editor>
|
46
|
+
<!--
|
47
|
+
import("stdfaust.lib");
|
48
|
+
process =
|
49
|
+
dm.cubicnl_demo : // distortion
|
50
|
+
dm.wah4_demo <: // wah pedal
|
51
|
+
dm.phaser2_demo : // stereo phaser
|
52
|
+
dm.compressor_demo : // stereo compressor
|
53
|
+
dm.zita_light; // stereo reverb
|
54
|
+
-->
|
55
|
+
</faust-editor>
|
56
|
+
<p>Minus, ducimus consequuntur? Corrupti animi aut magni nihil, dolor eos tenetur, autem deserunt et iure culpa, suscipit minus quia velit laudantium asperiores.</p>
|
57
|
+
<faust-widget>
|
58
|
+
<!--
|
59
|
+
import("stdfaust.lib");
|
60
|
+
ctFreq = hslider("cutoffFrequency",500,50,10000,0.01);
|
61
|
+
q = hslider("q",5,1,30,0.1);
|
62
|
+
gain = hslider("gain",1,0,1,0.01);
|
63
|
+
process = no.noise : fi.resonlp(ctFreq,q,gain);
|
64
|
+
-->
|
65
|
+
</faust-widget>
|
66
|
+
</div>
|
67
|
+
</body>
|
68
|
+
</html>
|
package/package.json
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
{
|
2
2
|
"name": "@grame/faust-web-component",
|
3
3
|
"description": "Web component embedding the Faust Compiler",
|
4
|
-
"version": "0.2.
|
4
|
+
"version": "0.2.3",
|
5
5
|
"module": "dist/faust-web-component.js",
|
6
|
-
"files": ["dist/faust-web-component.js"],
|
6
|
+
"files": ["src/", "public/", "index.html", "tsconfig.json", "vite.config.js", "dist/faust-web-component.js"],
|
7
7
|
"type": "module",
|
8
8
|
"scripts": {
|
9
9
|
"dev": "vite",
|
package/public/vite.svg
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
package/src/common.ts
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
import { IFaustMonoWebAudioNode, IFaustPolyWebAudioNode, FaustCompiler, FaustMonoDspGenerator, FaustPolyDspGenerator, FaustSvgDiagrams, LibFaust, instantiateFaustModuleFromFile } from "@grame/faustwasm"
|
2
|
+
import jsURL from "@grame/faustwasm/libfaust-wasm/libfaust-wasm.js?url"
|
3
|
+
import dataURL from "@grame/faustwasm/libfaust-wasm/libfaust-wasm.data?url"
|
4
|
+
import wasmURL from "@grame/faustwasm/libfaust-wasm/libfaust-wasm.wasm?url"
|
5
|
+
import { library } from "@fortawesome/fontawesome-svg-core"
|
6
|
+
import { faPlay, faStop, faUpRightFromSquare, faSquareCaretLeft, faAnglesLeft, faAnglesRight, faSliders, faDiagramProject, faWaveSquare, faChartLine, faPowerOff } from "@fortawesome/free-solid-svg-icons"
|
7
|
+
|
8
|
+
for (const icon of [faPlay, faStop, faUpRightFromSquare, faSquareCaretLeft, faAnglesLeft, faAnglesRight, faSliders, faDiagramProject, faWaveSquare, faChartLine, faPowerOff]) {
|
9
|
+
library.add(icon)
|
10
|
+
}
|
11
|
+
|
12
|
+
export let compiler: FaustCompiler
|
13
|
+
export let svgDiagrams: FaustSvgDiagrams
|
14
|
+
export const mono_generator = new FaustMonoDspGenerator() // TODO: Support polyphony
|
15
|
+
export const poly_generator = new FaustPolyDspGenerator() // TODO: Support polyphony
|
16
|
+
|
17
|
+
async function loadFaust() {
|
18
|
+
// Setup Faust
|
19
|
+
const module = await instantiateFaustModuleFromFile(jsURL, dataURL, wasmURL)
|
20
|
+
const libFaust = new LibFaust(module)
|
21
|
+
compiler = new FaustCompiler(libFaust)
|
22
|
+
svgDiagrams = new FaustSvgDiagrams(compiler)
|
23
|
+
}
|
24
|
+
|
25
|
+
export const faustPromise = loadFaust()
|
26
|
+
export const audioCtx = new AudioContext()
|
27
|
+
|
28
|
+
export const deviceUpdateCallbacks: ((d: MediaDeviceInfo[]) => void)[] = []
|
29
|
+
let devices: MediaDeviceInfo[] = []
|
30
|
+
async function _getInputDevices() {
|
31
|
+
if (navigator.mediaDevices) {
|
32
|
+
navigator.mediaDevices.ondevicechange = _getInputDevices
|
33
|
+
try {
|
34
|
+
await navigator.mediaDevices.getUserMedia({ audio: true })
|
35
|
+
} catch (e) { }
|
36
|
+
devices = await navigator.mediaDevices.enumerateDevices()
|
37
|
+
for (const callback of deviceUpdateCallbacks) {
|
38
|
+
callback(devices)
|
39
|
+
}
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
let getInputDevicesPromise: Promise<void> | undefined
|
44
|
+
export async function getInputDevices() {
|
45
|
+
if (getInputDevicesPromise === undefined) {
|
46
|
+
getInputDevicesPromise = _getInputDevices()
|
47
|
+
}
|
48
|
+
await getInputDevicesPromise
|
49
|
+
return devices
|
50
|
+
}
|
51
|
+
|
52
|
+
export async function accessMIDIDevice(
|
53
|
+
onMIDIMessage: (data) => void
|
54
|
+
): Promise<void> {
|
55
|
+
return new Promise<void>((resolve, reject) => {
|
56
|
+
if (navigator.requestMIDIAccess) {
|
57
|
+
navigator
|
58
|
+
.requestMIDIAccess()
|
59
|
+
.then((midiAccess) => {
|
60
|
+
const inputDevices = midiAccess.inputs.values();
|
61
|
+
let midiInput: WebMidi.MIDIInput | null = null;
|
62
|
+
for (const midiInput of inputDevices) {
|
63
|
+
midiInput.onmidimessage = (event) => {
|
64
|
+
onMIDIMessage(event.data);
|
65
|
+
};
|
66
|
+
resolve();
|
67
|
+
}
|
68
|
+
})
|
69
|
+
.catch((error) => {
|
70
|
+
reject(error);
|
71
|
+
});
|
72
|
+
} else {
|
73
|
+
reject(new Error('Web MIDI API is not supported by this browser.'));
|
74
|
+
}
|
75
|
+
});
|
76
|
+
}
|
77
|
+
|
78
|
+
// Set up MIDI input callback
|
79
|
+
export const midiInputCallback = (node: IFaustMonoWebAudioNode | IFaustPolyWebAudioNode) => {
|
80
|
+
return (data) => {
|
81
|
+
|
82
|
+
const cmd = data[0] >> 4;
|
83
|
+
const channel = data[0] & 0xf;
|
84
|
+
const data1 = data[1];
|
85
|
+
const data2 = data[2];
|
86
|
+
|
87
|
+
if (channel === 9) return;
|
88
|
+
else if (cmd === 8 || (cmd === 9 && data2 === 0)) node.keyOff(channel, data1, data2);
|
89
|
+
else if (cmd === 9) node.keyOn(channel, data1, data2);
|
90
|
+
else if (cmd === 11) node.ctrlChange(channel, data1, data2);
|
91
|
+
else if (cmd === 14) node.pitchWheel(channel, (data2 * 128.0 + data1));
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
// Analyze the metadata of a Faust JSON file extract the [midi:on] and [nvoices:n] options
|
96
|
+
export function extractMidiAndNvoices(jsonData: JSONData): { midi: boolean, nvoices: number } {
|
97
|
+
const optionsMetadata = jsonData.meta.find(meta => meta.options);
|
98
|
+
if (optionsMetadata) {
|
99
|
+
const options = optionsMetadata.options;
|
100
|
+
|
101
|
+
const midiRegex = /\[midi:(on|off)\]/;
|
102
|
+
const nvoicesRegex = /\[nvoices:(\d+)\]/;
|
103
|
+
|
104
|
+
const midiMatch = options.match(midiRegex);
|
105
|
+
const nvoicesMatch = options.match(nvoicesRegex);
|
106
|
+
|
107
|
+
const midi = midiMatch ? midiMatch[1] === "on" : false;
|
108
|
+
const nvoices = nvoicesMatch ? parseInt(nvoicesMatch[1]) : -1;
|
109
|
+
|
110
|
+
return { midi, nvoices };
|
111
|
+
} else {
|
112
|
+
return { midi: false, nvoices: -1 };
|
113
|
+
}
|
114
|
+
}
|
package/src/editor.ts
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
import {EditorView} from "codemirror"
|
2
|
+
// Most of the basic CodeMirror setup, sans folds.
|
3
|
+
import {lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, keymap} from "@codemirror/view"
|
4
|
+
import {history, defaultKeymap, historyKeymap} from "@codemirror/commands"
|
5
|
+
import {indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching} from "@codemirror/language"
|
6
|
+
import {highlightSelectionMatches, searchKeymap} from "@codemirror/search"
|
7
|
+
import {closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap} from "@codemirror/autocomplete"
|
8
|
+
import {EditorState} from "@codemirror/state"
|
9
|
+
// Custom CodeMirror setup
|
10
|
+
import {StreamLanguage} from "@codemirror/language"
|
11
|
+
import {clike} from "@codemirror/legacy-modes/mode/clike"
|
12
|
+
import {lintKeymap, setDiagnostics, openLintPanel, closeLintPanel} from "@codemirror/lint"
|
13
|
+
|
14
|
+
const keywords = "process component import library declare with environment route waveform soundfile"
|
15
|
+
const atoms = "mem prefix int float rdtable rwtable select2 select3 ffunction fconstant fvariable button checkbox vslider hslider nentry vgroup hgroup tgroup vbargraph hbargraph attach acos asin atan atan2 cos sin tan exp log log10 pow sqrt abs min max fmod remainder floor ceil rint"
|
16
|
+
|
17
|
+
function words(str: string) {
|
18
|
+
const obj: {[key: string]: true} = {}
|
19
|
+
const words = str.split(" ")
|
20
|
+
for (let i = 0; i < words.length; i++) obj[words[i]] = true
|
21
|
+
return obj
|
22
|
+
}
|
23
|
+
|
24
|
+
const faustLanguage = StreamLanguage.define(clike({
|
25
|
+
name: "clike",
|
26
|
+
multiLineStrings: true,
|
27
|
+
keywords: words(keywords),
|
28
|
+
atoms: words(atoms),
|
29
|
+
hooks: {
|
30
|
+
"@": () => "meta",
|
31
|
+
"'": () => "meta",
|
32
|
+
}
|
33
|
+
}))
|
34
|
+
|
35
|
+
export function createEditor(parent: HTMLElement, doc: string) {
|
36
|
+
return new EditorView({
|
37
|
+
parent,
|
38
|
+
doc,
|
39
|
+
extensions: [
|
40
|
+
lineNumbers(),
|
41
|
+
highlightActiveLineGutter(),
|
42
|
+
highlightSpecialChars(),
|
43
|
+
history(),
|
44
|
+
// foldGutter(),
|
45
|
+
drawSelection(),
|
46
|
+
dropCursor(),
|
47
|
+
EditorState.allowMultipleSelections.of(true),
|
48
|
+
indentOnInput(),
|
49
|
+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
50
|
+
bracketMatching(),
|
51
|
+
closeBrackets(),
|
52
|
+
autocompletion(),
|
53
|
+
rectangularSelection(),
|
54
|
+
crosshairCursor(),
|
55
|
+
highlightActiveLine(),
|
56
|
+
highlightSelectionMatches(),
|
57
|
+
keymap.of([
|
58
|
+
...closeBracketsKeymap,
|
59
|
+
...defaultKeymap,
|
60
|
+
...searchKeymap,
|
61
|
+
...historyKeymap,
|
62
|
+
...completionKeymap,
|
63
|
+
...lintKeymap
|
64
|
+
]),
|
65
|
+
faustLanguage,
|
66
|
+
],
|
67
|
+
})
|
68
|
+
}
|
69
|
+
|
70
|
+
export function setError(editor: EditorView, error: Error) {
|
71
|
+
// Extract line number if available
|
72
|
+
const rawMessage = error.message.trim()
|
73
|
+
const match = rawMessage.match(/^main : (\d+) : (.*)$/)
|
74
|
+
const message = match ? match[2] : rawMessage
|
75
|
+
const { from, to } = match ? editor.state.doc.line(+match[1]) : { from: 0, to: 0 }
|
76
|
+
// Show error in editor
|
77
|
+
editor.dispatch(setDiagnostics(editor.state, [{
|
78
|
+
from, to,
|
79
|
+
severity: "error",
|
80
|
+
message,
|
81
|
+
}]))
|
82
|
+
openLintPanel(editor)
|
83
|
+
}
|
84
|
+
|
85
|
+
export function clearError(editor: EditorView) {
|
86
|
+
editor.dispatch(setDiagnostics(editor.state, []))
|
87
|
+
closeLintPanel(editor)
|
88
|
+
}
|
@@ -0,0 +1,451 @@
|
|
1
|
+
import { icon } from "@fortawesome/fontawesome-svg-core"
|
2
|
+
import { IFaustMonoWebAudioNode } from "@grame/faustwasm"
|
3
|
+
import { FaustUI } from "@shren/faust-ui"
|
4
|
+
import faustCSS from "@shren/faust-ui/dist/esm/index.css?inline"
|
5
|
+
import Split from "split.js"
|
6
|
+
import { faustPromise, audioCtx, compiler, svgDiagrams, mono_generator, poly_generator, getInputDevices, deviceUpdateCallbacks, accessMIDIDevice, midiInputCallback, extractMidiAndNvoices } from "./common"
|
7
|
+
import { createEditor, setError, clearError } from "./editor"
|
8
|
+
import { Scope } from "./scope"
|
9
|
+
import faustSvg from "./faustText.svg"
|
10
|
+
|
11
|
+
const template = document.createElement("template")
|
12
|
+
template.innerHTML = `
|
13
|
+
<div id="root">
|
14
|
+
<div id="controls">
|
15
|
+
<button title="Run" class="button" id="run" disabled>${icon({ prefix: "fas", iconName: "play" }).html[0]}</button>
|
16
|
+
<button title="Stop" class="button" id="stop" disabled>${icon({ prefix: "fas", iconName: "stop" }).html[0]}</button>
|
17
|
+
<a title="Open in Faust IDE" id="ide" href="https://faustide.grame.fr/" class="button" target="_blank">${icon({ prefix: "fas", iconName: "up-right-from-square" }).html[0]}</a>
|
18
|
+
<select id="audio-input" class="dropdown" disabled>
|
19
|
+
<option>Audio input</option>
|
20
|
+
</select>
|
21
|
+
<!-- TODO: MIDI input
|
22
|
+
<select id="midi-input" class="dropdown" disabled>
|
23
|
+
<option>MIDI input</option>
|
24
|
+
</select>
|
25
|
+
-->
|
26
|
+
<!-- TODO: volume control? <input id="volume" type="range" min="0" max="100"> -->
|
27
|
+
<a title="Faust website" id="faust" href="https://faust.grame.fr/" target="_blank"><img src="${faustSvg}" height="15px" /></a>
|
28
|
+
</div>
|
29
|
+
<div id="content">
|
30
|
+
<div id="editor"></div>
|
31
|
+
<div id="sidebar">
|
32
|
+
<div id="sidebar-buttons">
|
33
|
+
<button title="Controls" id="tab-ui" class="button tab" disabled>${icon({ prefix: "fas", iconName: "sliders" }).html[0]}</button>
|
34
|
+
<button title="Block Diagram" id="tab-diagram" class="button tab" disabled>${icon({ prefix: "fas", iconName: "diagram-project" }).html[0]}</button>
|
35
|
+
<button title="Scope" id="tab-scope" class="button tab" disabled>${icon({ prefix: "fas", iconName: "wave-square" }).html[0]}</button>
|
36
|
+
<button title="Spectrum" id="tab-spectrum" class="button tab" disabled>${icon({ prefix: "fas", iconName: "chart-line" }).html[0]}</button>
|
37
|
+
</div>
|
38
|
+
<div id="sidebar-content">
|
39
|
+
<div id="faust-ui"></div>
|
40
|
+
<div id="faust-diagram"></div>
|
41
|
+
<div id="faust-scope"></div>
|
42
|
+
<div id="faust-spectrum"></div>
|
43
|
+
</div>
|
44
|
+
</div>
|
45
|
+
</div>
|
46
|
+
</div>
|
47
|
+
<style>
|
48
|
+
#root {
|
49
|
+
border: 1px solid black;
|
50
|
+
border-radius: 5px;
|
51
|
+
box-sizing: border-box;
|
52
|
+
}
|
53
|
+
|
54
|
+
*, *:before, *:after {
|
55
|
+
box-sizing: inherit;
|
56
|
+
}
|
57
|
+
|
58
|
+
#controls {
|
59
|
+
background-color: #384d64;
|
60
|
+
border-bottom: 1px solid black;
|
61
|
+
display: flex;
|
62
|
+
}
|
63
|
+
|
64
|
+
#faust {
|
65
|
+
margin-left: auto;
|
66
|
+
margin-right: 10px;
|
67
|
+
display: flex;
|
68
|
+
align-items: center;
|
69
|
+
}
|
70
|
+
|
71
|
+
#faust-ui {
|
72
|
+
width: 232px;
|
73
|
+
max-height: 150px;
|
74
|
+
}
|
75
|
+
|
76
|
+
#faust-scope, #faust-spectrum {
|
77
|
+
min-width: 232px;
|
78
|
+
min-height: 150px;
|
79
|
+
}
|
80
|
+
|
81
|
+
#faust-diagram {
|
82
|
+
max-width: 232px;
|
83
|
+
height: 150px;
|
84
|
+
}
|
85
|
+
|
86
|
+
#content {
|
87
|
+
display: flex;
|
88
|
+
}
|
89
|
+
|
90
|
+
#editor {
|
91
|
+
flex-grow: 1;
|
92
|
+
overflow-y: auto;
|
93
|
+
}
|
94
|
+
|
95
|
+
#editor .cm-editor {
|
96
|
+
height: 100%;
|
97
|
+
}
|
98
|
+
|
99
|
+
.cm-diagnostic {
|
100
|
+
font-family: monospace;
|
101
|
+
}
|
102
|
+
|
103
|
+
.cm-diagnostic-error {
|
104
|
+
background-color: #fdf2f5 !important;
|
105
|
+
color: #a4000f !important;
|
106
|
+
border-color: #a4000f !important;
|
107
|
+
}
|
108
|
+
|
109
|
+
#sidebar {
|
110
|
+
display: flex;
|
111
|
+
max-width: 100%;
|
112
|
+
}
|
113
|
+
|
114
|
+
.tab {
|
115
|
+
flex-grow: 1;
|
116
|
+
}
|
117
|
+
|
118
|
+
#sidebar-buttons .tab.active {
|
119
|
+
background-color: #bbb;
|
120
|
+
}
|
121
|
+
|
122
|
+
#sidebar-buttons {
|
123
|
+
background-color: #f5f5f5;
|
124
|
+
display: flex;
|
125
|
+
flex-direction: column;
|
126
|
+
}
|
127
|
+
|
128
|
+
#sidebar-buttons .button {
|
129
|
+
background-color: #f5f5f5;
|
130
|
+
color: #000;
|
131
|
+
width: 20px;
|
132
|
+
height: 20px;
|
133
|
+
padding: 4px;
|
134
|
+
}
|
135
|
+
|
136
|
+
#sidebar-buttons .button:hover {
|
137
|
+
background-color: #ddd;
|
138
|
+
}
|
139
|
+
|
140
|
+
#sidebar-buttons .button:active {
|
141
|
+
background-color: #aaa;
|
142
|
+
}
|
143
|
+
|
144
|
+
#sidebar-content {
|
145
|
+
background-color: #fff;
|
146
|
+
border-left: 1px solid #ccc;
|
147
|
+
overflow: auto;
|
148
|
+
flex-grow: 1;
|
149
|
+
max-height: 100%;
|
150
|
+
}
|
151
|
+
|
152
|
+
#sidebar-content > div {
|
153
|
+
display: none;
|
154
|
+
}
|
155
|
+
|
156
|
+
#sidebar-content > div.active {
|
157
|
+
display: block;
|
158
|
+
}
|
159
|
+
|
160
|
+
a.button {
|
161
|
+
appearance: button;
|
162
|
+
}
|
163
|
+
|
164
|
+
.button {
|
165
|
+
background-color: #384d64;
|
166
|
+
border: 0;
|
167
|
+
padding: 5px;
|
168
|
+
width: 25px;
|
169
|
+
height: 25px;
|
170
|
+
color: #fff;
|
171
|
+
}
|
172
|
+
|
173
|
+
.button:hover {
|
174
|
+
background-color: #4b71a1;
|
175
|
+
}
|
176
|
+
|
177
|
+
.button:active {
|
178
|
+
background-color: #373736;
|
179
|
+
}
|
180
|
+
|
181
|
+
.button:disabled {
|
182
|
+
opacity: 0.65;
|
183
|
+
cursor: not-allowed;
|
184
|
+
pointer-events: none;
|
185
|
+
}
|
186
|
+
|
187
|
+
#controls > .button > svg {
|
188
|
+
width: 15px;
|
189
|
+
height: 15px;
|
190
|
+
vertical-align: top;
|
191
|
+
}
|
192
|
+
|
193
|
+
.dropdown {
|
194
|
+
height: 19px;
|
195
|
+
margin: 3px 0 3px 10px;
|
196
|
+
border: 0;
|
197
|
+
background: #fff;
|
198
|
+
}
|
199
|
+
|
200
|
+
.gutter {
|
201
|
+
background-color: #f5f5f5;
|
202
|
+
background-repeat: no-repeat;
|
203
|
+
background-position: 50%;
|
204
|
+
}
|
205
|
+
|
206
|
+
.gutter.gutter-horizontal {
|
207
|
+
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
|
208
|
+
cursor: col-resize;
|
209
|
+
}
|
210
|
+
|
211
|
+
${faustCSS}
|
212
|
+
</style>
|
213
|
+
`
|
214
|
+
|
215
|
+
export default class FaustEditor extends HTMLElement {
|
216
|
+
constructor() {
|
217
|
+
super()
|
218
|
+
}
|
219
|
+
|
220
|
+
connectedCallback() {
|
221
|
+
const code = this.innerHTML.replace("<!--", "").replace("-->", "").trim()
|
222
|
+
this.attachShadow({ mode: "open" }).appendChild(template.content.cloneNode(true))
|
223
|
+
|
224
|
+
const ideLink = this.shadowRoot!.querySelector("#ide") as HTMLAnchorElement
|
225
|
+
ideLink.onfocus = () => {
|
226
|
+
// Open current contents of editor in IDE
|
227
|
+
const urlParams = new URLSearchParams()
|
228
|
+
urlParams.set("inline", btoa(editor.state.doc.toString()).replace("+", "-").replace("/", "_"))
|
229
|
+
ideLink.href = `https://faustide.grame.fr/?${urlParams.toString()}`
|
230
|
+
}
|
231
|
+
|
232
|
+
const editorEl = this.shadowRoot!.querySelector("#editor") as HTMLDivElement
|
233
|
+
const editor = createEditor(editorEl, code)
|
234
|
+
|
235
|
+
const runButton = this.shadowRoot!.querySelector("#run") as HTMLButtonElement
|
236
|
+
const stopButton = this.shadowRoot!.querySelector("#stop") as HTMLButtonElement
|
237
|
+
const faustUIRoot = this.shadowRoot!.querySelector("#faust-ui") as HTMLDivElement
|
238
|
+
const faustDiagram = this.shadowRoot!.querySelector("#faust-diagram") as HTMLDivElement
|
239
|
+
const sidebar = this.shadowRoot!.querySelector("#sidebar") as HTMLDivElement
|
240
|
+
const sidebarContent = this.shadowRoot!.querySelector("#sidebar-content") as HTMLDivElement
|
241
|
+
const tabButtons = [...this.shadowRoot!.querySelectorAll(".tab")] as HTMLButtonElement[]
|
242
|
+
const tabContents = [...sidebarContent.querySelectorAll("div")] as HTMLDivElement[]
|
243
|
+
|
244
|
+
const split = Split([editorEl, sidebar], {
|
245
|
+
sizes: [100, 0],
|
246
|
+
minSize: [0, 20],
|
247
|
+
gutterSize: 7,
|
248
|
+
snapOffset: 150,
|
249
|
+
onDragEnd: () => { scope?.onResize(); spectrum?.onResize() },
|
250
|
+
})
|
251
|
+
|
252
|
+
faustPromise.then(() => runButton.disabled = false)
|
253
|
+
|
254
|
+
const defaultSizes = [70, 30]
|
255
|
+
let sidebarOpen = false
|
256
|
+
const openSidebar = () => {
|
257
|
+
if (!sidebarOpen) {
|
258
|
+
split.setSizes(defaultSizes)
|
259
|
+
}
|
260
|
+
sidebarOpen = true
|
261
|
+
}
|
262
|
+
|
263
|
+
let node: IFaustMonoWebAudioNode | undefined
|
264
|
+
let input: MediaStreamAudioSourceNode | undefined
|
265
|
+
let analyser: AnalyserNode | undefined
|
266
|
+
let scope: Scope | undefined
|
267
|
+
let spectrum: Scope | undefined
|
268
|
+
let gmidi = false
|
269
|
+
let gnvoices = -1
|
270
|
+
|
271
|
+
|
272
|
+
runButton.onclick = async () => {
|
273
|
+
if (audioCtx.state === "suspended") {
|
274
|
+
await audioCtx.resume()
|
275
|
+
}
|
276
|
+
await faustPromise
|
277
|
+
// Compile Faust code
|
278
|
+
const code = editor.state.doc.toString()
|
279
|
+
let generator = null
|
280
|
+
try {
|
281
|
+
// Compile Faust code to access JSON metadata
|
282
|
+
await mono_generator.compile(compiler, "main", code, "")
|
283
|
+
const json = mono_generator.getMeta()
|
284
|
+
let { midi, nvoices } = extractMidiAndNvoices(json);
|
285
|
+
gmidi = midi;
|
286
|
+
gnvoices = nvoices;
|
287
|
+
|
288
|
+
// Build the generator
|
289
|
+
generator = nvoices > 0 ? poly_generator : mono_generator;
|
290
|
+
await generator.compile(compiler, "main", code, "");
|
291
|
+
|
292
|
+
} catch (e: any) {
|
293
|
+
setError(editor, e)
|
294
|
+
return
|
295
|
+
}
|
296
|
+
// Clear any old errors
|
297
|
+
clearError(editor)
|
298
|
+
|
299
|
+
// Create an audio node from compiled Faust
|
300
|
+
if (node !== undefined) node.disconnect()
|
301
|
+
if (gnvoices > 0) {
|
302
|
+
node = (await poly_generator.createNode(audioCtx, gnvoices))!
|
303
|
+
} else {
|
304
|
+
node = (await mono_generator.createNode(audioCtx))!
|
305
|
+
}
|
306
|
+
if (node.numberOfInputs > 0) {
|
307
|
+
audioInputSelector.disabled = false
|
308
|
+
updateInputDevices(await getInputDevices())
|
309
|
+
await connectInput()
|
310
|
+
} else {
|
311
|
+
audioInputSelector.disabled = true
|
312
|
+
audioInputSelector.innerHTML = "<option>Audio input</option>"
|
313
|
+
}
|
314
|
+
node.connect(audioCtx.destination)
|
315
|
+
stopButton.disabled = false
|
316
|
+
for (const tabButton of tabButtons) {
|
317
|
+
tabButton.disabled = false
|
318
|
+
}
|
319
|
+
|
320
|
+
// Access MIDI device
|
321
|
+
if (gmidi) {
|
322
|
+
accessMIDIDevice(midiInputCallback(node))
|
323
|
+
.then(() => {
|
324
|
+
console.log('Successfully connected to the MIDI device.');
|
325
|
+
})
|
326
|
+
.catch((error) => {
|
327
|
+
console.error('Error accessing MIDI device:', error.message);
|
328
|
+
});
|
329
|
+
}
|
330
|
+
|
331
|
+
openSidebar()
|
332
|
+
// Clear old tab contents
|
333
|
+
for (const tab of tabContents) {
|
334
|
+
while (tab.lastChild) tab.lastChild.remove()
|
335
|
+
}
|
336
|
+
// Create scope & spectrum plots
|
337
|
+
analyser = new AnalyserNode(audioCtx, {
|
338
|
+
fftSize: Math.pow(2, 11), minDecibels: -96, maxDecibels: 0, smoothingTimeConstant: 0.85
|
339
|
+
})
|
340
|
+
node.connect(analyser)
|
341
|
+
scope = new Scope(tabContents[2])
|
342
|
+
spectrum = new Scope(tabContents[3])
|
343
|
+
|
344
|
+
// If there are UI elements, open Faust UI (controls tab); otherwise open spectrum analyzer.
|
345
|
+
const ui = node.getUI()
|
346
|
+
openTab(ui.length > 1 || ui[0].items.length > 0 ? 0 : 3)
|
347
|
+
|
348
|
+
// Create controls via Faust UI
|
349
|
+
const faustUI = new FaustUI({ ui, root: faustUIRoot })
|
350
|
+
faustUI.paramChangeByUI = (path, value) => node?.setParamValue(path, value)
|
351
|
+
node.setOutputParamHandler((path, value) => faustUI.paramChangeByDSP(path, value))
|
352
|
+
|
353
|
+
// Create SVG block diagram
|
354
|
+
setSVG(svgDiagrams.from("main", code, "")["process.svg"])
|
355
|
+
}
|
356
|
+
|
357
|
+
const setSVG = (svgString: string) => {
|
358
|
+
faustDiagram.innerHTML = svgString
|
359
|
+
|
360
|
+
for (const a of faustDiagram.querySelectorAll("a")) {
|
361
|
+
a.onclick = e => {
|
362
|
+
e.preventDefault()
|
363
|
+
const filename = (a.href as any as SVGAnimatedString).baseVal
|
364
|
+
const svgString = compiler.fs().readFile("main-svg/" + filename, { encoding: "utf8" }) as string
|
365
|
+
setSVG(svgString)
|
366
|
+
}
|
367
|
+
}
|
368
|
+
}
|
369
|
+
|
370
|
+
let animPlot: number | undefined
|
371
|
+
const drawScope = () => {
|
372
|
+
scope!.renderScope([{
|
373
|
+
analyser: analyser!,
|
374
|
+
style: "rgb(212, 100, 100)",
|
375
|
+
edgeThreshold: 0.09,
|
376
|
+
}])
|
377
|
+
animPlot = requestAnimationFrame(drawScope)
|
378
|
+
}
|
379
|
+
|
380
|
+
const drawSpectrum = () => {
|
381
|
+
spectrum!.renderSpectrum(analyser!)
|
382
|
+
animPlot = requestAnimationFrame(drawSpectrum)
|
383
|
+
}
|
384
|
+
|
385
|
+
const openTab = (i: number) => {
|
386
|
+
for (const [j, tab] of tabButtons.entries()) {
|
387
|
+
if (i === j) {
|
388
|
+
tab.classList.add("active")
|
389
|
+
tabContents[j].classList.add("active")
|
390
|
+
} else {
|
391
|
+
tab.classList.remove("active")
|
392
|
+
tabContents[j].classList.remove("active")
|
393
|
+
}
|
394
|
+
}
|
395
|
+
if (i === 2) {
|
396
|
+
scope!.onResize()
|
397
|
+
if (animPlot !== undefined) cancelAnimationFrame(animPlot)
|
398
|
+
animPlot = requestAnimationFrame(drawScope)
|
399
|
+
} else if (i === 3) {
|
400
|
+
spectrum!.onResize()
|
401
|
+
if (animPlot !== undefined) cancelAnimationFrame(animPlot)
|
402
|
+
animPlot = requestAnimationFrame(drawSpectrum)
|
403
|
+
} else if (animPlot !== undefined) {
|
404
|
+
cancelAnimationFrame(animPlot)
|
405
|
+
animPlot = undefined
|
406
|
+
}
|
407
|
+
}
|
408
|
+
|
409
|
+
for (const [i, tabButton] of tabButtons.entries()) {
|
410
|
+
tabButton.onclick = () => openTab(i)
|
411
|
+
}
|
412
|
+
|
413
|
+
stopButton.onclick = () => {
|
414
|
+
if (node !== undefined) {
|
415
|
+
node.disconnect()
|
416
|
+
node.destroy()
|
417
|
+
node = undefined
|
418
|
+
stopButton.disabled = true
|
419
|
+
// TODO: Maybe disable controls in faust-ui tab.
|
420
|
+
}
|
421
|
+
}
|
422
|
+
|
423
|
+
const audioInputSelector = this.shadowRoot!.querySelector("#audio-input") as HTMLSelectElement
|
424
|
+
|
425
|
+
const updateInputDevices = (devices: MediaDeviceInfo[]) => {
|
426
|
+
if (audioInputSelector.disabled) return
|
427
|
+
while (audioInputSelector.lastChild) audioInputSelector.lastChild.remove()
|
428
|
+
for (const device of devices) {
|
429
|
+
if (device.kind === "audioinput") {
|
430
|
+
audioInputSelector.appendChild(new Option(device.label || device.deviceId, device.deviceId))
|
431
|
+
}
|
432
|
+
}
|
433
|
+
}
|
434
|
+
deviceUpdateCallbacks.push(updateInputDevices)
|
435
|
+
|
436
|
+
const connectInput = async () => {
|
437
|
+
const deviceId = audioInputSelector.value
|
438
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId, echoCancellation: false, noiseSuppression: false, autoGainControl: false } })
|
439
|
+
if (input) {
|
440
|
+
input.disconnect()
|
441
|
+
input = undefined
|
442
|
+
}
|
443
|
+
if (node && node.numberOfInputs > 0) {
|
444
|
+
input = audioCtx.createMediaStreamSource(stream)
|
445
|
+
input.connect(node!)
|
446
|
+
}
|
447
|
+
}
|
448
|
+
|
449
|
+
audioInputSelector.onchange = connectInput
|
450
|
+
}
|
451
|
+
}
|
@@ -0,0 +1,226 @@
|
|
1
|
+
import { icon } from "@fortawesome/fontawesome-svg-core"
|
2
|
+
import faustCSS from "@shren/faust-ui/dist/esm/index.css?inline"
|
3
|
+
import faustSvg from "./faustText.svg"
|
4
|
+
import { IFaustMonoWebAudioNode } from "@grame/faustwasm"
|
5
|
+
import { IFaustPolyWebAudioNode } from "@grame/faustwasm"
|
6
|
+
import { FaustUI } from "@shren/faust-ui"
|
7
|
+
import { faustPromise, audioCtx, mono_generator, poly_generator, compiler, getInputDevices, deviceUpdateCallbacks, accessMIDIDevice, midiInputCallback, extractMidiAndNvoices } from "./common"
|
8
|
+
|
9
|
+
const template = document.createElement("template")
|
10
|
+
template.innerHTML = `
|
11
|
+
<div id="root">
|
12
|
+
<div id="controls">
|
13
|
+
<button title="On/off" class="button" id="power" disabled>${icon({ prefix: "fas", iconName: "power-off" }).html[0]}</button>
|
14
|
+
<select id="audio-input" class="dropdown" disabled>
|
15
|
+
<option>Audio input</option>
|
16
|
+
</select>
|
17
|
+
<!-- TODO: MIDI input
|
18
|
+
<select id="midi-input" class="dropdown" disabled>
|
19
|
+
<option>MIDI input</option>
|
20
|
+
</select>
|
21
|
+
-->
|
22
|
+
<!-- TODO: volume control? <input id="volume" type="range" min="0" max="100"> -->
|
23
|
+
<a title="Faust website" id="faust" href="https://faust.grame.fr/" target="_blank"><img src="${faustSvg}" height="15px" /></a>
|
24
|
+
</div>
|
25
|
+
<div id="faust-ui"></div>
|
26
|
+
</div>
|
27
|
+
<style>
|
28
|
+
#root {
|
29
|
+
border: 1px solid black;
|
30
|
+
border-radius: 5px;
|
31
|
+
box-sizing: border-box;
|
32
|
+
display: inline-block;
|
33
|
+
background-color: #384d64;
|
34
|
+
}
|
35
|
+
|
36
|
+
*, *:before, *:after {
|
37
|
+
box-sizing: inherit;
|
38
|
+
}
|
39
|
+
|
40
|
+
#controls {
|
41
|
+
display: flex;
|
42
|
+
margin-bottom: -20px;
|
43
|
+
position: relative;
|
44
|
+
z-index: 1;
|
45
|
+
}
|
46
|
+
|
47
|
+
#faust {
|
48
|
+
margin-left: auto;
|
49
|
+
padding-left: 10px;
|
50
|
+
margin-right: 10px;
|
51
|
+
display: flex;
|
52
|
+
align-items: center;
|
53
|
+
}
|
54
|
+
|
55
|
+
a.button {
|
56
|
+
appearance: button;
|
57
|
+
}
|
58
|
+
|
59
|
+
.button {
|
60
|
+
background-color: #384d64;
|
61
|
+
border: 0;
|
62
|
+
padding: 5px;
|
63
|
+
width: 25px;
|
64
|
+
height: 25px;
|
65
|
+
color: #fff;
|
66
|
+
}
|
67
|
+
|
68
|
+
.button:hover {
|
69
|
+
background-color: #4b71a1;
|
70
|
+
}
|
71
|
+
|
72
|
+
.button:active {
|
73
|
+
background-color: #373736;
|
74
|
+
}
|
75
|
+
|
76
|
+
.button:disabled {
|
77
|
+
opacity: 0.65;
|
78
|
+
cursor: not-allowed;
|
79
|
+
pointer-events: none;
|
80
|
+
}
|
81
|
+
|
82
|
+
#controls > .button > svg {
|
83
|
+
width: 15px;
|
84
|
+
height: 15px;
|
85
|
+
vertical-align: top;
|
86
|
+
}
|
87
|
+
|
88
|
+
.dropdown {
|
89
|
+
height: 19px;
|
90
|
+
margin: 3px 0 3px 10px;
|
91
|
+
border: 0;
|
92
|
+
background: #fff;
|
93
|
+
}
|
94
|
+
|
95
|
+
${faustCSS}
|
96
|
+
</style>
|
97
|
+
`
|
98
|
+
|
99
|
+
export default class FaustWidget extends HTMLElement {
|
100
|
+
constructor() {
|
101
|
+
super()
|
102
|
+
}
|
103
|
+
|
104
|
+
connectedCallback() {
|
105
|
+
const code = this.innerHTML.replace("<!--", "").replace("-->", "").trim()
|
106
|
+
this.attachShadow({ mode: "open" }).appendChild(template.content.cloneNode(true))
|
107
|
+
|
108
|
+
const powerButton = this.shadowRoot!.querySelector("#power") as HTMLButtonElement
|
109
|
+
const faustUIRoot = this.shadowRoot!.querySelector("#faust-ui") as HTMLDivElement
|
110
|
+
|
111
|
+
faustPromise.then(() => powerButton.disabled = false)
|
112
|
+
|
113
|
+
let on = false
|
114
|
+
let gmidi = false
|
115
|
+
let gnvoices = -1
|
116
|
+
let node: IFaustMonoWebAudioNode | IFaustPolyWebAudioNode
|
117
|
+
let input: MediaStreamAudioSourceNode | undefined
|
118
|
+
let faustUI: FaustUI
|
119
|
+
|
120
|
+
const setup = async () => {
|
121
|
+
await faustPromise
|
122
|
+
// Compile Faust code to access JSON metadata
|
123
|
+
await mono_generator.compile(compiler, "main", code, "")
|
124
|
+
const json = mono_generator.getMeta()
|
125
|
+
let { midi, nvoices } = extractMidiAndNvoices(json);
|
126
|
+
gmidi = midi;
|
127
|
+
gnvoices = nvoices;
|
128
|
+
|
129
|
+
// Build the generator and generate UI
|
130
|
+
const generator = nvoices > 0 ? poly_generator : mono_generator;
|
131
|
+
await generator.compile(compiler, "main", code, "");
|
132
|
+
const ui = generator.getUI();
|
133
|
+
|
134
|
+
faustUI = new FaustUI({ ui, root: faustUIRoot });
|
135
|
+
faustUIRoot.style.width = faustUI.minWidth * 1.25 + "px";
|
136
|
+
faustUIRoot.style.height = faustUI.minHeight * 1.25 + "px";
|
137
|
+
faustUI.resize();
|
138
|
+
}
|
139
|
+
|
140
|
+
const start = async () => {
|
141
|
+
if (audioCtx.state === "suspended") {
|
142
|
+
await audioCtx.resume()
|
143
|
+
}
|
144
|
+
|
145
|
+
// Create an audio node from compiled Faust
|
146
|
+
if (node === undefined) {
|
147
|
+
if (gnvoices > 0) {
|
148
|
+
node = (await poly_generator.createNode(audioCtx, gnvoices))!
|
149
|
+
} else {
|
150
|
+
node = (await mono_generator.createNode(audioCtx))!
|
151
|
+
}
|
152
|
+
}
|
153
|
+
|
154
|
+
// Access MIDI device
|
155
|
+
if (gmidi) {
|
156
|
+
accessMIDIDevice(midiInputCallback(node))
|
157
|
+
.then(() => {
|
158
|
+
console.log('Successfully connected to the MIDI device.');
|
159
|
+
})
|
160
|
+
.catch((error) => {
|
161
|
+
console.error('Error accessing MIDI device:', error.message);
|
162
|
+
});
|
163
|
+
}
|
164
|
+
|
165
|
+
faustUI.paramChangeByUI = (path, value) => node?.setParamValue(path, value)
|
166
|
+
node.setOutputParamHandler((path, value) => faustUI.paramChangeByDSP(path, value))
|
167
|
+
|
168
|
+
if (node.numberOfInputs > 0) {
|
169
|
+
audioInputSelector.disabled = false
|
170
|
+
updateInputDevices(await getInputDevices())
|
171
|
+
await connectInput()
|
172
|
+
} else {
|
173
|
+
audioInputSelector.disabled = true
|
174
|
+
audioInputSelector.innerHTML = "<option>Audio input</option>"
|
175
|
+
}
|
176
|
+
|
177
|
+
node.connect(audioCtx.destination)
|
178
|
+
powerButton.style.color = "#ffa500"
|
179
|
+
}
|
180
|
+
|
181
|
+
const stop = () => {
|
182
|
+
node?.disconnect()
|
183
|
+
powerButton.style.color = "#fff"
|
184
|
+
}
|
185
|
+
|
186
|
+
powerButton.onclick = () => {
|
187
|
+
if (on) {
|
188
|
+
stop()
|
189
|
+
} else {
|
190
|
+
start()
|
191
|
+
}
|
192
|
+
on = !on
|
193
|
+
}
|
194
|
+
|
195
|
+
const audioInputSelector = this.shadowRoot!.querySelector("#audio-input") as HTMLSelectElement
|
196
|
+
|
197
|
+
const updateInputDevices = (devices: MediaDeviceInfo[]) => {
|
198
|
+
if (audioInputSelector.disabled) return
|
199
|
+
while (audioInputSelector.lastChild) audioInputSelector.lastChild.remove()
|
200
|
+
for (const device of devices) {
|
201
|
+
if (device.kind === "audioinput") {
|
202
|
+
audioInputSelector.appendChild(new Option(device.label || device.deviceId, device.deviceId))
|
203
|
+
}
|
204
|
+
}
|
205
|
+
}
|
206
|
+
deviceUpdateCallbacks.push(updateInputDevices)
|
207
|
+
|
208
|
+
const connectInput = async () => {
|
209
|
+
const deviceId = audioInputSelector.value
|
210
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId, echoCancellation: false, noiseSuppression: false, autoGainControl: false } })
|
211
|
+
if (input) {
|
212
|
+
input.disconnect()
|
213
|
+
input = undefined
|
214
|
+
}
|
215
|
+
if (node && node.numberOfInputs > 0) {
|
216
|
+
input = audioCtx.createMediaStreamSource(stream)
|
217
|
+
input.connect(node!)
|
218
|
+
}
|
219
|
+
}
|
220
|
+
|
221
|
+
audioInputSelector.onchange = connectInput
|
222
|
+
|
223
|
+
setup()
|
224
|
+
}
|
225
|
+
}
|
226
|
+
|
@@ -0,0 +1,2 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
2
|
+
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" xml:space="preserve" width="64.333328" height="12.414666" viewBox="0 0 64.333327 12.414666"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath clipPathUnits="userSpaceOnUse" id="clipPath18"><path d="M 0,93.507 H 93.507 V 0 H 0 Z" id="path16"/></clipPath></defs><g style="fill:#ffffff" id="g10" transform="matrix(1.3333333,0,0,-1.3333333,-31.440266,68.495732)"><g id="g12" style="fill:#ffffff"><g id="g14" clip-path="url(#clipPath18)" style="fill:#ffffff"><g id="g20" transform="translate(69.0172,49.3868)" style="fill:#ffffff"><path d="M 0,0 V -7.026 H -2.017 V 0 H -4.843 V 1.747 H 2.813 V 0 Z m -8.276,-5.615 c -0.176,-0.333 -0.415,-0.62 -0.718,-0.863 -0.302,-0.243 -0.658,-0.433 -1.067,-0.569 -0.409,-0.136 -0.849,-0.204 -1.321,-0.204 -0.435,0 -0.833,0.041 -1.193,0.124 -0.361,0.082 -0.685,0.191 -0.973,0.327 -0.289,0.135 -0.548,0.29 -0.779,0.463 -0.232,0.174 -0.431,0.352 -0.598,0.534 l 1.278,1.384 c 0.115,-0.125 0.248,-0.252 0.4,-0.381 0.152,-0.129 0.321,-0.247 0.509,-0.352 0.187,-0.106 0.392,-0.191 0.614,-0.255 0.221,-0.065 0.46,-0.097 0.716,-0.097 0.15,0 0.3,0.021 0.447,0.064 0.149,0.043 0.285,0.107 0.41,0.191 0.124,0.084 0.223,0.185 0.296,0.303 0.073,0.119 0.11,0.253 0.11,0.404 0,0.327 -0.158,0.581 -0.474,0.762 -0.316,0.181 -0.817,0.36 -1.504,0.536 -0.333,0.078 -0.64,0.192 -0.922,0.342 -0.283,0.151 -0.526,0.332 -0.729,0.543 -0.204,0.211 -0.361,0.453 -0.474,0.726 -0.112,0.274 -0.169,0.578 -0.169,0.914 0,0.368 0.065,0.714 0.195,1.038 0.13,0.325 0.328,0.611 0.595,0.859 0.266,0.247 0.597,0.443 0.993,0.589 0.397,0.145 0.865,0.218 1.406,0.218 0.446,0 0.842,-0.046 1.187,-0.137 0.344,-0.092 0.637,-0.201 0.877,-0.329 0.24,-0.127 0.437,-0.258 0.591,-0.391 0.155,-0.134 0.271,-0.239 0.348,-0.316 l -1.143,-1.266 c -0.099,0.078 -0.212,0.162 -0.338,0.255 -0.127,0.093 -0.268,0.179 -0.425,0.258 -0.156,0.08 -0.33,0.147 -0.521,0.201 -0.19,0.053 -0.391,0.08 -0.601,0.08 -0.142,0 -0.28,-0.022 -0.416,-0.067 -0.135,-0.046 -0.258,-0.105 -0.367,-0.178 -0.11,-0.073 -0.198,-0.161 -0.264,-0.265 -0.066,-0.103 -0.1,-0.211 -0.1,-0.323 0,-0.301 0.159,-0.544 0.477,-0.73 0.318,-0.185 0.753,-0.34 1.307,-0.465 0.327,-0.077 0.645,-0.184 0.955,-0.319 0.311,-0.136 0.588,-0.314 0.832,-0.536 0.244,-0.222 0.441,-0.493 0.591,-0.814 0.15,-0.321 0.225,-0.711 0.225,-1.172 0,-0.392 -0.088,-0.754 -0.263,-1.086 m -10.307,1.854 c 0,-0.527 -0.086,-1.01 -0.257,-1.449 -0.172,-0.439 -0.418,-0.813 -0.74,-1.124 -0.321,-0.31 -0.71,-0.553 -1.167,-0.729 -0.456,-0.175 -0.965,-0.263 -1.526,-0.263 -0.56,0 -1.067,0.086 -1.522,0.257 -0.455,0.171 -0.841,0.414 -1.159,0.729 -0.318,0.315 -0.563,0.691 -0.734,1.13 -0.171,0.439 -0.256,0.922 -0.256,1.449 v 5.446 h 1.985 v -5.271 c 0,-0.295 0.029,-0.56 0.086,-0.795 0.058,-0.235 0.153,-0.437 0.284,-0.606 0.131,-0.169 0.304,-0.301 0.521,-0.394 0.217,-0.094 0.481,-0.14 0.795,-0.14 0.309,0 0.57,0.046 0.785,0.14 0.214,0.093 0.388,0.225 0.521,0.394 0.133,0.169 0.227,0.371 0.283,0.606 0.056,0.235 0.084,0.5 0.084,0.795 v 5.271 h 2.017 z M -30.68,-7.026 c -0.596,0 -1.078,0.482 -1.078,1.077 0,0.595 0.482,1.078 1.078,1.078 0.595,0 1.077,-0.483 1.077,-1.078 0,-0.595 -0.482,-1.077 -1.077,-1.077 m -1.702,3.551 -1.067,3.314 -1.092,-3.314 -0.054,-0.181 h -1.899 l 2.018,5.403 h 2.196 l 1.977,-5.4 -2.023,-0.005 z m -3.708,-3.551 c -0.595,0 -1.078,0.482 -1.078,1.077 0,0.595 0.483,1.078 1.078,1.078 0.595,0 1.078,-0.483 1.078,-1.078 0,-0.595 -0.483,-1.077 -1.078,-1.077 M -39.541,0 h -3.912 v -1.991 h 2.974 v -1.696 h -2.974 v -3.339 h -1.984 v 8.773 h 5.896 z" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path22"/></g></g></g></g></svg>
|
package/src/main.ts
ADDED
package/src/scope.ts
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
// Based on https://codepen.io/ContemporaryInsanity/pen/Mwvqpb
|
2
|
+
|
3
|
+
export class Scope {
|
4
|
+
container: HTMLElement
|
5
|
+
canvas: HTMLCanvasElement
|
6
|
+
ctx: CanvasRenderingContext2D
|
7
|
+
|
8
|
+
constructor(container: HTMLElement) {
|
9
|
+
this.container = container
|
10
|
+
this.container.classList.add("scope")
|
11
|
+
|
12
|
+
this.canvas = document.createElement("canvas")
|
13
|
+
this.canvas.style.transformOrigin = "top left"
|
14
|
+
this.ctx = this.canvas.getContext("2d")!
|
15
|
+
this.onResize = this.onResize.bind(this)
|
16
|
+
|
17
|
+
this.container.appendChild(this.canvas)
|
18
|
+
this.onResize()
|
19
|
+
window.addEventListener("resize", this.onResize)
|
20
|
+
}
|
21
|
+
|
22
|
+
get canvasWidth() { return this.canvas.width / devicePixelRatio }
|
23
|
+
set canvasWidth(canvasWidth) {
|
24
|
+
this.canvas.width = Math.floor(canvasWidth * devicePixelRatio)
|
25
|
+
this.canvas.style.width = `${canvasWidth}px`
|
26
|
+
}
|
27
|
+
|
28
|
+
get canvasHeight() { return this.canvas.height / devicePixelRatio }
|
29
|
+
set canvasHeight(canvasHeight) {
|
30
|
+
this.canvas.height = Math.floor(canvasHeight * devicePixelRatio)
|
31
|
+
this.canvas.style.height = `${canvasHeight}px`
|
32
|
+
}
|
33
|
+
|
34
|
+
renderScope(toRender: { analyser: AnalyserNode, style: string, edgeThreshold: number }[] = []) {
|
35
|
+
// grid
|
36
|
+
this.ctx.fillStyle = "white"
|
37
|
+
this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
|
38
|
+
this.ctx.lineWidth = 1
|
39
|
+
this.ctx.strokeStyle = "rgba(200, 200, 200, 0.5)"
|
40
|
+
this.ctx.fillStyle = "rgba(200, 200, 200, 0.5)"
|
41
|
+
this.ctx.beginPath()
|
42
|
+
|
43
|
+
const numHorzSteps = 8
|
44
|
+
const horzStep = this.canvasWidth / numHorzSteps
|
45
|
+
for (let i = horzStep; i < this.canvasWidth; i += horzStep) {
|
46
|
+
this.ctx.moveTo(i, 0)
|
47
|
+
this.ctx.lineTo(i, this.canvasHeight)
|
48
|
+
}
|
49
|
+
|
50
|
+
const numVertSteps = 4
|
51
|
+
const vertStep = this.canvasHeight / numVertSteps
|
52
|
+
for (let i = 0; i < this.canvasHeight; i += vertStep) {
|
53
|
+
this.ctx.moveTo(0, i)
|
54
|
+
this.ctx.lineTo(this.canvasWidth, i)
|
55
|
+
}
|
56
|
+
this.ctx.stroke()
|
57
|
+
|
58
|
+
// 0 line
|
59
|
+
this.ctx.strokeStyle = "rgba(100, 100, 100, 0.5)"
|
60
|
+
this.ctx.beginPath()
|
61
|
+
this.ctx.lineWidth = 2
|
62
|
+
this.ctx.moveTo(0, this.canvasHeight / 2)
|
63
|
+
this.ctx.lineTo(this.canvasWidth, this.canvasHeight / 2)
|
64
|
+
this.ctx.stroke()
|
65
|
+
|
66
|
+
// waveforms
|
67
|
+
toRender.forEach(({ analyser, style = "rgb(43, 156, 212)", edgeThreshold = 0 }) => {
|
68
|
+
if (analyser === undefined) { return }
|
69
|
+
|
70
|
+
const timeData = new Float32Array(analyser.frequencyBinCount)
|
71
|
+
let risingEdge = 0
|
72
|
+
|
73
|
+
analyser.getFloatTimeDomainData(timeData)
|
74
|
+
|
75
|
+
this.ctx.lineWidth = 2
|
76
|
+
this.ctx.strokeStyle = style
|
77
|
+
|
78
|
+
this.ctx.beginPath()
|
79
|
+
|
80
|
+
while (timeData[risingEdge] > 0 &&
|
81
|
+
risingEdge <= this.canvasWidth &&
|
82
|
+
risingEdge < timeData.length) {
|
83
|
+
risingEdge++
|
84
|
+
}
|
85
|
+
|
86
|
+
if (risingEdge >= this.canvasWidth) { risingEdge = 0 }
|
87
|
+
|
88
|
+
|
89
|
+
while (timeData[risingEdge] < edgeThreshold &&
|
90
|
+
risingEdge <= this.canvasWidth &&
|
91
|
+
risingEdge< timeData.length) {
|
92
|
+
risingEdge++
|
93
|
+
}
|
94
|
+
|
95
|
+
if (risingEdge >= this.canvasWidth) { risingEdge = 0 }
|
96
|
+
|
97
|
+
for (let x = risingEdge; x < timeData.length && x - risingEdge < this.canvasWidth; x++) {
|
98
|
+
const y = this.canvasHeight - (((timeData[x] + 1) / 2) * this.canvasHeight)
|
99
|
+
this.ctx.lineTo(x - risingEdge, y)
|
100
|
+
}
|
101
|
+
|
102
|
+
this.ctx.stroke()
|
103
|
+
})
|
104
|
+
|
105
|
+
// markers
|
106
|
+
this.ctx.fillStyle = "black"
|
107
|
+
this.ctx.font = "11px Courier"
|
108
|
+
this.ctx.textAlign = "left"
|
109
|
+
const numMarkers = 4
|
110
|
+
const markerStep = this.canvasHeight / numMarkers
|
111
|
+
for (let i = 0; i <= numMarkers; i++) {
|
112
|
+
this.ctx.textBaseline =
|
113
|
+
i === 0 ? "top"
|
114
|
+
: i === numMarkers ? "bottom"
|
115
|
+
: "middle"
|
116
|
+
|
117
|
+
const value = ((numMarkers - i) - (numMarkers / 2)) / numMarkers * 2
|
118
|
+
this.ctx.textAlign = "left"
|
119
|
+
this.ctx.fillText(value + "", 5, i * markerStep)
|
120
|
+
this.ctx.textAlign = "right"
|
121
|
+
this.ctx.fillText(value + "", this.canvasWidth - 5, i * markerStep)
|
122
|
+
}
|
123
|
+
}
|
124
|
+
|
125
|
+
renderSpectrum(analyser: AnalyserNode) {
|
126
|
+
const freqData = new Uint8Array(analyser.frequencyBinCount)
|
127
|
+
|
128
|
+
analyser.getByteFrequencyData(freqData)
|
129
|
+
|
130
|
+
this.ctx.fillStyle = "white"
|
131
|
+
this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
|
132
|
+
|
133
|
+
this.ctx.lineWidth = 2
|
134
|
+
this.ctx.strokeStyle = "rgb(43, 156, 212)"
|
135
|
+
this.ctx.beginPath()
|
136
|
+
|
137
|
+
for (let i = 0; i < freqData.length; i++) {
|
138
|
+
const x = (Math.log(i / 1)) / (Math.log(freqData.length / 1)) * this.canvasWidth
|
139
|
+
const height = (freqData[i] * this.canvasHeight) / 256
|
140
|
+
this.ctx.lineTo(x, this.canvasHeight - height)
|
141
|
+
}
|
142
|
+
this.ctx.stroke()
|
143
|
+
|
144
|
+
const fontSize = 12
|
145
|
+
|
146
|
+
// frequencies
|
147
|
+
function explin(value: number, inMin: number, inMax: number, outMin: number, outMax: number) {
|
148
|
+
inMin = Math.max(inMin, 1)
|
149
|
+
outMin = Math.max(outMin, 1)
|
150
|
+
return Math.log10(value / inMin) / Math.log10(inMax / inMin) * (outMax - outMin) + outMin
|
151
|
+
}
|
152
|
+
|
153
|
+
const nyquist = analyser.context.sampleRate / 2;
|
154
|
+
[0, 100, 300, 1000, 3000, 10000, 20000].forEach(freq => {
|
155
|
+
const minFreq = 20
|
156
|
+
const x = freq <= 0
|
157
|
+
? fontSize - 5
|
158
|
+
: explin(freq, minFreq, nyquist, 0, this.canvasWidth)
|
159
|
+
|
160
|
+
this.ctx.fillStyle = "black"
|
161
|
+
this.ctx.textBaseline = "middle"
|
162
|
+
this.ctx.textAlign = "right"
|
163
|
+
this.ctx.font = `${fontSize}px Courier`
|
164
|
+
this.ctx.save()
|
165
|
+
this.ctx.translate(x, this.canvasHeight - 5)
|
166
|
+
this.ctx.rotate(Math.PI * 0.5)
|
167
|
+
this.ctx.fillText(`${freq.toFixed(0)}hz`, 0, 0)
|
168
|
+
this.ctx.restore()
|
169
|
+
});
|
170
|
+
|
171
|
+
[0, -3, -6, -12].forEach(db => {
|
172
|
+
const x = 5
|
173
|
+
const amp = Math.pow(10, db * 0.05)
|
174
|
+
const y = (1 - amp) * this.canvasHeight
|
175
|
+
|
176
|
+
this.ctx.fillStyle = "black"
|
177
|
+
this.ctx.textBaseline = "top"
|
178
|
+
this.ctx.textAlign = "left"
|
179
|
+
this.ctx.font = `${fontSize}px Courier`
|
180
|
+
this.ctx.fillText(`${db.toFixed(0)}db`, x, y)
|
181
|
+
})
|
182
|
+
}
|
183
|
+
|
184
|
+
onResize() {
|
185
|
+
this.canvasWidth = 0
|
186
|
+
this.canvasHeight = 0
|
187
|
+
|
188
|
+
const rect = this.container.getBoundingClientRect()
|
189
|
+
const style = getComputedStyle(this.container)
|
190
|
+
|
191
|
+
let borderLeft = style.getPropertyValue("border-left-width")
|
192
|
+
let left = borderLeft === "" ? 0 : parseFloat(borderLeft)
|
193
|
+
let borderRight = style.getPropertyValue("border-right-width")
|
194
|
+
let right = borderRight === "" ? 0 : parseFloat(borderRight)
|
195
|
+
this.canvasWidth = rect.width - left - right
|
196
|
+
|
197
|
+
let borderTop = style.getPropertyValue("border-top-width")
|
198
|
+
let top = borderTop === "" ? 0 : parseFloat(borderTop)
|
199
|
+
let borderBottom = style.getPropertyValue("border-bottom-width")
|
200
|
+
let bottom = borderBottom === "" ? 0 : parseFloat(borderBottom)
|
201
|
+
this.canvasHeight = rect.height - top - bottom
|
202
|
+
|
203
|
+
this.ctx.scale(devicePixelRatio, devicePixelRatio)
|
204
|
+
}
|
205
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"target": "ES2020",
|
4
|
+
"useDefineForClassFields": true,
|
5
|
+
"module": "ESNext",
|
6
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7
|
+
"skipLibCheck": true,
|
8
|
+
|
9
|
+
/* Bundler mode */
|
10
|
+
"moduleResolution": "bundler",
|
11
|
+
"allowImportingTsExtensions": true,
|
12
|
+
"resolveJsonModule": true,
|
13
|
+
"isolatedModules": true,
|
14
|
+
"noEmit": true,
|
15
|
+
|
16
|
+
/* Linting */
|
17
|
+
"strict": true,
|
18
|
+
"noUnusedLocals": true,
|
19
|
+
"noUnusedParameters": true,
|
20
|
+
"noFallthroughCasesInSwitch": true
|
21
|
+
},
|
22
|
+
"include": ["src"]
|
23
|
+
}
|
package/vite.config.js
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
import { resolve } from 'path'
|
2
|
+
import { defineConfig } from 'vite'
|
3
|
+
|
4
|
+
export default defineConfig({
|
5
|
+
build: {
|
6
|
+
lib: {
|
7
|
+
entry: resolve(__dirname, "src/main.ts"),
|
8
|
+
name: "faust_web_component",
|
9
|
+
formats: ["iife"],
|
10
|
+
fileName: () => "faust-web-component.js",
|
11
|
+
},
|
12
|
+
},
|
13
|
+
})
|