@grame/faust-web-component 0.1.0

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.
@@ -0,0 +1,418 @@
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, getInputDevices, deviceUpdateCallbacks } 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
+
269
+ runButton.onclick = async () => {
270
+ if (audioCtx.state === "suspended") {
271
+ await audioCtx.resume()
272
+ }
273
+ await faustPromise
274
+ // Compile Faust code
275
+ const code = editor.state.doc.toString()
276
+ try {
277
+ await mono_generator.compile(compiler, "main", code, "")
278
+ } catch (e: any) {
279
+ setError(editor, e)
280
+ return
281
+ }
282
+ // Clear any old errors
283
+ clearError(editor)
284
+
285
+ // Create an audio node from compiled Faust
286
+ if (node !== undefined) node.disconnect()
287
+ node = (await mono_generator.createNode(audioCtx))!
288
+ if (node.numberOfInputs > 0) {
289
+ audioInputSelector.disabled = false
290
+ updateInputDevices(await getInputDevices())
291
+ await connectInput()
292
+ } else {
293
+ audioInputSelector.disabled = true
294
+ audioInputSelector.innerHTML = "<option>Audio input</option>"
295
+ }
296
+ node.connect(audioCtx.destination)
297
+ stopButton.disabled = false
298
+ for (const tabButton of tabButtons) {
299
+ tabButton.disabled = false
300
+ }
301
+ openSidebar()
302
+ // Clear old tab contents
303
+ for (const tab of tabContents) {
304
+ while (tab.lastChild) tab.lastChild.remove()
305
+ }
306
+ // Create scope & spectrum plots
307
+ analyser = new AnalyserNode(audioCtx, {
308
+ fftSize: Math.pow(2, 11), minDecibels: -96, maxDecibels: 0, smoothingTimeConstant: 0.85
309
+ })
310
+ node.connect(analyser)
311
+ scope = new Scope(tabContents[2])
312
+ spectrum = new Scope(tabContents[3])
313
+ // If there are UI elements, open Faust UI (controls tab); otherwise open spectrum analyzer.
314
+ const ui = node.getUI()
315
+ openTab(ui.length > 1 || ui[0].items.length > 0 ? 0 : 3)
316
+ // Create controls via Faust UI
317
+ const faustUI = new FaustUI({ ui, root: faustUIRoot })
318
+ faustUI.paramChangeByUI = (path, value) => node?.setParamValue(path, value)
319
+ node.setOutputParamHandler((path, value) => faustUI.paramChangeByDSP(path, value))
320
+ // Create SVG block diagram
321
+ setSVG(svgDiagrams.from("main", code, "")["process.svg"])
322
+ }
323
+
324
+ const setSVG = (svgString: string) => {
325
+ faustDiagram.innerHTML = svgString
326
+
327
+ for (const a of faustDiagram.querySelectorAll("a")) {
328
+ a.onclick = e => {
329
+ e.preventDefault()
330
+ const filename = (a.href as any as SVGAnimatedString).baseVal
331
+ const svgString = compiler.fs().readFile("main-svg/" + filename, { encoding: "utf8" }) as string
332
+ setSVG(svgString)
333
+ }
334
+ }
335
+ }
336
+
337
+ let animPlot: number | undefined
338
+ const drawScope = () => {
339
+ scope!.renderScope([{
340
+ analyser: analyser!,
341
+ style: "rgb(212, 100, 100)",
342
+ edgeThreshold: 0.09,
343
+ }])
344
+ animPlot = requestAnimationFrame(drawScope)
345
+ }
346
+
347
+ const drawSpectrum = () => {
348
+ spectrum!.renderSpectrum(analyser!)
349
+ animPlot = requestAnimationFrame(drawSpectrum)
350
+ }
351
+
352
+ const openTab = (i: number) => {
353
+ for (const [j, tab] of tabButtons.entries()) {
354
+ if (i === j) {
355
+ tab.classList.add("active")
356
+ tabContents[j].classList.add("active")
357
+ } else {
358
+ tab.classList.remove("active")
359
+ tabContents[j].classList.remove("active")
360
+ }
361
+ }
362
+ if (i === 2) {
363
+ scope!.onResize()
364
+ if (animPlot !== undefined) cancelAnimationFrame(animPlot)
365
+ animPlot = requestAnimationFrame(drawScope)
366
+ } else if (i === 3) {
367
+ spectrum!.onResize()
368
+ if (animPlot !== undefined) cancelAnimationFrame(animPlot)
369
+ animPlot = requestAnimationFrame(drawSpectrum)
370
+ } else if (animPlot !== undefined) {
371
+ cancelAnimationFrame(animPlot)
372
+ animPlot = undefined
373
+ }
374
+ }
375
+
376
+ for (const [i, tabButton] of tabButtons.entries()) {
377
+ tabButton.onclick = () => openTab(i)
378
+ }
379
+
380
+ stopButton.onclick = () => {
381
+ if (node !== undefined) {
382
+ node.disconnect()
383
+ node.destroy()
384
+ node = undefined
385
+ stopButton.disabled = true
386
+ // TODO: Maybe disable controls in faust-ui tab.
387
+ }
388
+ }
389
+
390
+ const audioInputSelector = this.shadowRoot!.querySelector("#audio-input") as HTMLSelectElement
391
+
392
+ const updateInputDevices = (devices: MediaDeviceInfo[]) => {
393
+ if (audioInputSelector.disabled) return
394
+ while (audioInputSelector.lastChild) audioInputSelector.lastChild.remove()
395
+ for (const device of devices) {
396
+ if (device.kind === "audioinput") {
397
+ audioInputSelector.appendChild(new Option(device.label || device.deviceId, device.deviceId))
398
+ }
399
+ }
400
+ }
401
+ deviceUpdateCallbacks.push(updateInputDevices)
402
+
403
+ const connectInput = async () => {
404
+ const deviceId = audioInputSelector.value
405
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId, echoCancellation: false, noiseSuppression: false, autoGainControl: false } })
406
+ if (input) {
407
+ input.disconnect()
408
+ input = undefined
409
+ }
410
+ if (node && node.numberOfInputs > 0) {
411
+ input = audioCtx.createMediaStreamSource(stream)
412
+ input.connect(node!)
413
+ }
414
+ }
415
+
416
+ audioInputSelector.onchange = connectInput
417
+ }
418
+ }
@@ -0,0 +1,212 @@
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 } 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 node: IFaustMonoWebAudioNode | undefined
115
+ //let node: IFaustPolyWebAudioNode | undefined
116
+ let input: MediaStreamAudioSourceNode | undefined
117
+ let faustUI: FaustUI
118
+
119
+ const setup = async () => {
120
+ await faustPromise
121
+ // Compile Faust code
122
+ await mono_generator.compile(compiler, "main", code, "")
123
+ //await poly_generator.compile(compiler, "main", code, "")
124
+ // Create controls via Faust UI
125
+ const ui = mono_generator.getUI()
126
+ //const ui = poly_generator.getUI()
127
+ faustUI = new FaustUI({ ui, root: faustUIRoot })
128
+ faustUIRoot.style.width = faustUI.minWidth * 1.25 + "px"
129
+ faustUIRoot.style.height = faustUI.minHeight * 1.25 + "px"
130
+ faustUI.resize()
131
+ }
132
+
133
+ const start = async () => {
134
+ if (audioCtx.state === "suspended") {
135
+ await audioCtx.resume()
136
+ }
137
+ // Create an audio node from compiled Faust
138
+ if (node === undefined) {
139
+ node = (await mono_generator.createNode(audioCtx))!
140
+ //node = (await poly_generator.createNode(audioCtx, 16))!
141
+ }
142
+
143
+ faustUI.paramChangeByUI = (path, value) => node?.setParamValue(path, value)
144
+ node.setOutputParamHandler((path, value) => faustUI.paramChangeByDSP(path, value))
145
+
146
+ if (node.numberOfInputs > 0) {
147
+ audioInputSelector.disabled = false
148
+ updateInputDevices(await getInputDevices())
149
+ await connectInput()
150
+ } else {
151
+ audioInputSelector.disabled = true
152
+ audioInputSelector.innerHTML = "<option>Audio input</option>"
153
+ }
154
+
155
+ accessMIDIDevice(midiInputCallback(node))
156
+ .then(() => {
157
+ console.log('Successfully connected to the MIDI device.');
158
+ })
159
+ .catch((error) => {
160
+ console.error('Error accessing MIDI device:', error.message);
161
+ });
162
+
163
+ node.connect(audioCtx.destination)
164
+ powerButton.style.color = "#ffa500"
165
+ }
166
+
167
+ const stop = () => {
168
+ node?.disconnect()
169
+ powerButton.style.color = "#fff"
170
+ }
171
+
172
+ powerButton.onclick = () => {
173
+ if (on) {
174
+ stop()
175
+ } else {
176
+ start()
177
+ }
178
+ on = !on
179
+ }
180
+
181
+ const audioInputSelector = this.shadowRoot!.querySelector("#audio-input") as HTMLSelectElement
182
+
183
+ const updateInputDevices = (devices: MediaDeviceInfo[]) => {
184
+ if (audioInputSelector.disabled) return
185
+ while (audioInputSelector.lastChild) audioInputSelector.lastChild.remove()
186
+ for (const device of devices) {
187
+ if (device.kind === "audioinput") {
188
+ audioInputSelector.appendChild(new Option(device.label || device.deviceId, device.deviceId))
189
+ }
190
+ }
191
+ }
192
+ deviceUpdateCallbacks.push(updateInputDevices)
193
+
194
+ const connectInput = async () => {
195
+ const deviceId = audioInputSelector.value
196
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId, echoCancellation: false, noiseSuppression: false, autoGainControl: false } })
197
+ if (input) {
198
+ input.disconnect()
199
+ input = undefined
200
+ }
201
+ if (node && node.numberOfInputs > 0) {
202
+ input = audioCtx.createMediaStreamSource(stream)
203
+ input.connect(node!)
204
+ }
205
+ }
206
+
207
+ audioInputSelector.onchange = connectInput
208
+
209
+ setup()
210
+ }
211
+ }
212
+