UNPKG

ixfx

Version:

Bundle of ixfx libraries

1,784 lines (1,768 loc) 94.4 kB
import { n as __exportAll } from "./chunk-CaR5F9JI.js"; import { E as omitChars, H as continuously, T as indexOfCharCode, h as intervalToMs, w as chunkByLength } from "./src-BUqDa_u7.js"; import { C as numberTest, M as resultThrow, b as isPowerOfTwo, v as integerTest } from "./src-C_hvyftg.js"; import { n as getErrorMessage } from "./src-B7f_ks6F.js"; import { D as mapWithEmptyFallback } from "./src-CxEyGbiK.js"; import { v as QueueMutable } from "./src-DOorb7Rs.js"; import { n as SimpleEventEmitter } from "./src-CRR1VQls.js"; import { f as scale, mt as maxFast, pt as max, rt as clamp, w as numberArrayCompute } from "./src-Cebc3sfq.js"; import { W as resolveEl } from "./src-BoQVyMUt.js"; import { l as number } from "./src-DIuRzACc.js"; import { E as retryTask, R as eventRace, T as retryFunction, U as debounce, a as to, i as init, o as waitFor, r as StateMachineWithEvents } from "./src-C3g81yvt.js"; import { h as shortGuid, u as string } from "./src-BH_hkHiA.js"; import { r as manualCapture } from "./src-CHw3eZpa.js"; //#region ../packages/io/src/codec.ts /** * Handles utf-8 text encoding/decoding */ var Codec = class { enc = new TextEncoder(); dec = new TextDecoder(`utf-8`); /** * Convert string to Uint8Array buffer * @param text * @returns */ toBuffer(text) { return this.enc.encode(text); } /** * Returns a string from a provided buffer * @param buffer * @returns */ fromBuffer(buffer) { return this.dec.decode(buffer); } }; //#endregion //#region ../packages/io/src/string-receive-buffer.ts /** * Receives text */ var StringReceiveBuffer = class { buffer = ``; stream; constructor(onData, separator = `\n`) { this.onData = onData; this.separator = separator; } async close() { const s = this.stream; if (!s) return; await s.abort(); await s.close(); } clear() { this.buffer = ``; } writable() { if (this.stream === void 0) this.stream = this.createWritable(); return this.stream; } createWritable() { const b = this; return new WritableStream({ write(chunk) { b.add(chunk); }, close() { b.clear(); } }); } addImpl(string_) { const pos = string_.indexOf(this.separator); if (pos < 0) { this.buffer += string_; return ``; } const part = string_.substring(0, pos); try { this.onData(this.buffer + part); string_ = string_.substring(part.length + this.separator.length); } catch (ex) { console.warn(ex); } this.buffer = ``; return string_; } add(string_) { while (string_.length > 0) string_ = this.addImpl(string_); } }; //#endregion //#region ../packages/io/src/string-write-buffer.ts /** * Buffers a queue of strings. * * When text is queued via {@link add}, it is chopped up * into chunks and sent in serial to the `dataHandler` function. * Data is processed at a set rate, by default 10ms. * * ```js * const dataHandler = (data:string) => { * // Do something with queued data. * // eg. send to serial port * } * * // Create a buffer with a chunk size of 100 characters * const b = new StringWriteBuffer(dataHandler, { chunkSize: 100 }); * b.add('some text'); // Write to buffer * // dataHandler will be called until queued data is empty * ``` * * It's also possible to get the buffer as a WritableStream<string>: * ```js * const dataHandler = (data:string) => { ... } * const b = new StringWriteBuffer(dataHandler, 100); * const s = b.writable(); * ``` * * Other functions: * ```js * b.close(); // Close buffer * b.clear(); // Clear queued data, but don't close anything * ``` */ var StringWriteBuffer = class { paused = false; queue = new QueueMutable(); writer; stream; closed = false; chunkSize; /** * Constructor * @param dataHandler Calback to 'send' data onwards * @param opts Options */ constructor(dataHandler, opts = {}) { this.dataHandler = dataHandler; this.chunkSize = opts.chunkSize ?? -1; this.writer = continuously(async () => { await this.onWrite(); }, opts.interval ?? 10); } /** * Close writer (async) */ async close() { if (this.closed) return; const w = this.stream?.getWriter(); w?.releaseLock(); await w?.close(); this.closed = true; } /** * Clear queued data. * * Throws an error if {@link close} has been called. */ clear() { if (this.closed) throw new Error(`Buffer closed`); this.queue = new QueueMutable(); } /** * Gets the buffer as a writable stream. * * Do not close stream directly, use .close on this class instead. * * Throws an error if .close() has been called. * @returns Underlying stream */ writable() { if (this.closed) throw new Error(`Buffer closed`); if (this.stream === void 0) this.stream = this.createWritable(); return this.stream; } createWritable() { const b = this; return new WritableStream({ write(chunk) { b.add(chunk); }, close() { b.clear(); } }); } /** * Run in a `continunously` loop to process queued data * @returns _False_ if queue is empty and loop should stop. _True_ if it shoud continue. */ async onWrite() { if (this.queue.isEmpty) return false; if (this.paused) { console.warn(`WriteBuffer.onWrite: paused...`); return true; } const s = this.queue.dequeue(); if (s === void 0) return false; await this.dataHandler(s); return true; } /** * Returns _true_ if {@link close} has been called. */ get isClosed() { return this.closed; } /** * Adds some queued data to send. * Longer strings are automatically chunked up according to the buffer's settings. * * Throws an error if {@link close} has been called. * @param stringToQueue */ add(stringToQueue) { if (this.closed) throw new Error(`Buffer closed`); if (this.chunkSize > 0) this.queue.enqueue(...chunkByLength(stringToQueue, this.chunkSize)); else this.queue.enqueue(stringToQueue); this.writer.start(); } }; //#endregion //#region ../packages/io/src/generic-state-transitions.ts const genericStateTransitionsInstance = { ready: `connecting`, connecting: [`connected`, `closed`], connected: [`closed`], closed: `connecting` }; //#endregion //#region ../packages/io/src/ble-device.ts var BleDevice = class extends SimpleEventEmitter { states; codec; rx; tx; gatt; verboseLogging = false; rxBuffer; txBuffer; constructor(device, config) { super(); this.device = device; this.config = config; this.verboseLogging = config.debug; this.txBuffer = new StringWriteBuffer(async (data) => { await this.writeInternal(data); }, config); this.rxBuffer = new StringReceiveBuffer((line) => { this.fireEvent(`data`, { data: line }); }); this.codec = new Codec(); this.states = new StateMachineWithEvents(genericStateTransitionsInstance, { initial: `ready` }); this.states.addEventListener(`change`, (event) => { this.fireEvent(`change`, event); this.verbose(`${event.priorState} -> ${event.newState}`); if (event.priorState === `connected`) { this.rxBuffer.clear(); this.txBuffer.clear(); } }); device.addEventListener(`gattserverdisconnected`, () => { if (this.isClosed) return; this.verbose(`GATT server disconnected`); this.states.state = `closed`; }); this.verbose(`ctor ${device.name} ${device.id}`); } get isConnected() { return this.states.state === `connected`; } get isClosed() { return this.states.state === `closed`; } write(txt) { if (this.states.state !== `connected`) throw new Error(`Cannot write while state is ${this.states.state}`); this.txBuffer.add(txt); } async writeInternal(txt) { this.verbose(`writeInternal ${txt}`); const tx = this.tx; if (tx === void 0) throw new Error(`Unexpectedly without tx characteristic`); try { await tx.writeValue(this.codec.toBuffer(txt)); } catch (error) { this.warn(error); } } disconnect() { if (this.states.state !== `connected`) return; this.gatt?.disconnect(); } async connect() { const attempts = this.config.connectAttempts ?? 3; this.states.state = `connecting`; this.verbose(`connect`); const gatt = this.device.gatt; if (gatt === void 0) throw new Error(`Gatt not available on device`); await retryFunction(async () => { this.verbose(`connect.retry`); const server = await gatt.connect(); this.verbose(`Getting primary service`); const service = await server.getPrimaryService(this.config.service); this.verbose(`Getting characteristics`); const rx = await service.getCharacteristic(this.config.rxGattCharacteristic); const tx = await service.getCharacteristic(this.config.txGattCharacteristic); rx.addEventListener(`characteristicvaluechanged`, (event) => { this.onRx(event); }); this.rx = rx; this.tx = tx; this.gatt = gatt; this.states.state = `connected`; await rx.startNotifications(); return true; }, { limitAttempts: attempts, startAt: 200 }); } onRx(event) { if (this.rx === void 0) return; const view = event.target.value; if (view === void 0) return; let text = this.codec.fromBuffer(view.buffer); const plzStop = indexOfCharCode(text, 19); const plzStart = indexOfCharCode(text, 17); if (plzStart && plzStop < plzStart) { this.verbose(`Tx plz start`); text = omitChars(text, plzStart, 1); this.txBuffer.paused = false; } if (plzStop && plzStop > plzStart) { this.verbose(`Tx plz stop`); text = omitChars(text, plzStop, 1); this.txBuffer.paused = true; } this.rxBuffer.add(text); } verbose(m) { if (this.verboseLogging) console.info(this.config.name, m); } log(m) { console.log(this.config.name, m); } warn(m) { console.warn(this.config.name, m); } }; //#endregion //#region ../packages/io/src/nordic-ble-device.ts var nordic_ble_device_exports = /* @__PURE__ */ __exportAll({ NordicBleDevice: () => NordicBleDevice, defaultOpts: () => defaultOpts }); const defaultOpts = { chunkSize: 20, service: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, txGattCharacteristic: `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, rxGattCharacteristic: `6e400003-b5a3-f393-e0a9-e50e24dcca9e`, name: `NordicDevice`, connectAttempts: 5, debug: false }; var NordicBleDevice = class extends BleDevice { constructor(device, opts = {}) { super(device, { ...defaultOpts, ...opts }); } }; //#endregion //#region ../packages/io/src/audio/visualiser.ts /** * Visualiser component * * Usage: import visualiser.js. Instantiate on document load, and pass in the * parent element into the constructor. * * eg: const v = new Visualiser(document.getElementById('renderer')); * * Data must be passed to the component via renderFreq or renderWave. * * Draws on https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API */ var AudioVisualiser = class { freqMaxRange = 200; audio; parent; lastPointer = { x: 0, y: 0 }; pointerDown = false; pointerClicking = false; pointerClickDelayMs = 100; pointerDelaying = false; waveTracker; freqTracker; el; constructor(parentElement, audio) { this.audio = audio; this.parent = parentElement; this.waveTracker = number(); this.freqTracker = number(); parentElement.innerHTML = ` <section> <button id="rendererComponentToggle">🔼</button> <div> <h1>Visualiser</h1> <div style="display:flex; flex-wrap: wrap"> <div class="visPanel"> <h2>Frequency distribution</h2> <br /> <canvas id="rendererComponentFreqData" height="200" width="400"></canvas> </div> <div class="visPanel"> <h2>Waveform</h2> <button id="rendererComponentWaveReset">Reset</button> <div> Press and hold on wave to measure </div> <br /> <canvas id="rendererComponentWaveData" height="200" width="400"></canvas> </div> </div> </div> </section> `; this.el = parentElement.children[0]; document.getElementById(`rendererComponentToggle`)?.addEventListener(`click`, () => { this.setExpanded(!this.isExpanded()); }); this.el.addEventListener(`pointermove`, (e) => { this.onPointer(e); }); this.el.addEventListener(`pointerup`, () => { this.pointerDelaying = false; this.pointerDown = false; }); this.el.addEventListener(`pointerdown`, () => { this.pointerDelaying = true; setTimeout(() => { if (this.pointerDelaying) { this.pointerDelaying = false; this.pointerDown = true; } }, this.pointerClickDelayMs); }); this.el.addEventListener(`pointerleave`, () => { this.pointerDelaying = false; this.pointerDown = false; }); document.getElementById(`rendererComponentWaveReset`)?.addEventListener(`click`, () => { this.clear(); }); } renderFreq(freq) { if (!this.isExpanded()) return; if (!freq) return; const canvas = document.getElementById(`rendererComponentFreqData`); if (canvas === null) throw new Error(`Cannot find canvas element`); const g = canvas.getContext(`2d`); if (g === null) throw new Error(`Cannot create drawing context`); const bins = freq.length; const canvasWidth = canvas.clientWidth; const canvasHeight = canvas.clientHeight; g.clearRect(0, 0, canvasWidth, canvasHeight); const pointer = this.getPointerRelativeTo(canvas); const width = canvasWidth / bins; const minMax = numberArrayCompute(freq); for (let index = 0; index < bins; index++) { if (!Number.isFinite(freq[index])) continue; const valueRelative = (freq[index] - minMax.min) / this.freqMaxRange; const height = Math.abs(canvasHeight * valueRelative); const offset = canvasHeight - height; const hue = index / bins * 360; const left = index * width; g.fillStyle = `hsl(${hue}, 100%, 50%)`; if (pointer.y > 0 && pointer.y <= canvasHeight && pointer.x >= left && pointer.x <= left + width) { if (this.freqTracker.id !== index.toString()) this.freqTracker = number({ id: index.toString() }); this.freqTracker.seen(freq[index]); const freqMma = this.freqTracker.getMinMaxAvg(); g.fillStyle = `black`; if (this.audio) g.fillText(`Frequency (${index}) at pointer: ${this.audio.getFrequencyAtIndex(index).toLocaleString(`en`)} - ${this.audio.getFrequencyAtIndex(index + 1).toLocaleString(`en`)}`, 2, 10); g.fillText(`Raw value: ${freq[index].toFixed(2)}`, 2, 20); g.fillText(`Min: ${freqMma.min.toFixed(2)}`, 2, 40); g.fillText(`Max: ${freqMma.max.toFixed(2)}`, 60, 40); g.fillText(`Avg: ${freqMma.avg.toFixed(2)}`, 120, 40); } g.fillRect(left, offset, width, height); } } isExpanded() { const contentsElement = this.el.querySelector(`div`); if (contentsElement === null) throw new Error(`contents div not found`); return contentsElement.style.display === ``; } setExpanded(value) { const contentsElement = this.el.querySelector(`div`); const button = this.el.querySelector(`button`); if (button === null) throw new Error(`Button element not found`); if (contentsElement === null) throw new Error(`Contents element not found`); if (value) { contentsElement.style.display = ``; button.innerText = `🔼`; } else { contentsElement.style.display = `none`; button.innerText = `🔽`; } } clear() { this.clearCanvas(document.getElementById(`rendererComponentFreqData`)); this.clearCanvas(document.getElementById(`rendererComponentWaveData`)); } clearCanvas(canvas) { if (canvas === null) throw new Error(`Canvas is null`); const g = canvas.getContext(`2d`); if (g === null) throw new Error(`Cannot create drawing context`); g.fillStyle = `white`; g.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight); } renderWave(wave, bipolar = true) { if (!this.isExpanded()) return; if (!wave) return; const canvas = document.getElementById(`rendererComponentWaveData`); if (canvas === null) throw new Error(`Cannot find wave canvas`); const g = canvas.getContext(`2d`); if (g === null) throw new Error(`Cannot create drawing context for wave`); const canvasWidth = canvas.clientWidth; const canvasHeight = canvas.clientHeight; const pointer = this.getPointerRelativeTo(canvas); const infoAreaHeight = 20; const infoAreaWidth = 60; const bins = wave.length; g.fillStyle = `white`; g.fillRect(0, 0, infoAreaWidth, infoAreaHeight); const width = canvasWidth / bins; g.fillStyle = `rgba(255, 255, 255, 0.03)`; g.fillRect(0, 20, canvasWidth, canvasHeight); g.fillStyle = `red`; if (bipolar) g.fillRect(0, canvasHeight / 2, canvasWidth, 1); else g.fillRect(0, canvasHeight - 1, canvasWidth, 1); g.lineWidth = 1; g.strokeStyle = `black`; g.beginPath(); let x = 0; for (let index = 0; index < bins; index++) { const height = wave[index] * canvasHeight; const y = bipolar ? canvasHeight / 2 - height : canvasHeight - height; if (index === 0) g.moveTo(x, y); else g.lineTo(x, y); x += width; if (this.pointerDown) this.waveTracker.seen(wave[index]); } g.lineTo(canvasWidth, bipolar ? canvasHeight / 2 : canvasHeight); g.stroke(); if (this.pointerDown) { const waveMma = this.waveTracker.getMinMaxAvg(); g.fillStyle = `rgba(255,255,0,1)`; g.fillRect(infoAreaWidth, 0, 150, 20); g.fillStyle = `black`; g.fillText(`Min: ` + waveMma.min.toFixed(2), 60, 10); g.fillText(`Max: ` + waveMma.max.toFixed(2), 110, 10); g.fillText(`Avg: ` + waveMma.avg.toFixed(2), 160, 10); } else this.waveTracker.reset(); if (pointer.y > 0 && pointer.y <= canvasHeight && pointer.x >= 0 && pointer.x <= canvasWidth) { g.fillStyle = `black`; g.fillText(`Level: ` + (1 - pointer.y / canvasHeight).toFixed(2), 2, 10); } } getPointerRelativeTo(elem) { const rect = elem.getBoundingClientRect(); return { x: this.lastPointer.x - rect.left - window.scrollX, y: this.lastPointer.y - rect.top - window.scrollY }; } onPointer(event) { this.lastPointer = { x: event.pageX, y: event.pageY }; event.preventDefault(); } }; //#endregion //#region ../packages/io/src/audio/analyser.ts /** * Basic audio analyser. Returns back waveform and FFT analysis. Use {@link analyserPeakLevel} if you want sound level, or {@link analyserFrequency} if you just want FFT results. * * ```js * const onData = (freq, wave, analyser) => { * // Demo: Get FFT results just for 100Hz-1KHz. * const freqSlice = analyser.sliceByFrequency(100,1000,freq); * * // Demo: Get FFT value for a particular frequency (1KHz) * const amt = freq[analyser.getIndexForFrequency(1000)]; * } * analyserBasic(onData, {fftSize: 512}); * ``` * * An `Analyser` instance is returned and can be controlled: * ```js * const analyser = analyserBasic(onData); * analyser.paused = true; * ``` * * Note: Browers won't allow microphone access unless the call has come from a user-interaction, eg pointerup event handler. * * @param onData Handler for data * @param opts Options * @returns Analyser instance */ const analyserBasic = (onData, opts = {}) => new AudioAnalyser((node, analyser) => { const freq = new Float32Array(node.frequencyBinCount); const wave = new Float32Array(node.fftSize); node.getFloatFrequencyData(freq); node.getFloatTimeDomainData(wave); onData(freq, wave, analyser); }, opts); /** * Basic audio analyser. Returns FFT analysis. Use {@link analyserPeakLevel} if you want the sound level, or {@link analyserBasic} if you also want the waveform. * * ```js * const onData = (freq, analyser) => { * // Demo: Print out each sound frequency (Hz) and amount of energy in that band * for (let i=0;i<freq.length;i++) { * const f = analyser.getFrequencyAtIndex(0); * console.log(`${i}. frequency: ${f} amount: ${freq[i]}`); * } * } * analyserFrequency(onData, {fftSize:512}); * ``` * * Note: Browers won't allow microphone access unless the call has come from a user-interaction, eg pointerup event handler. * * @param onData * @param opts * @returns */ const analyserFrequency = (onData, opts = {}) => new AudioAnalyser((node, analyser) => { const freq = new Float32Array(node.frequencyBinCount); node.getFloatFrequencyData(freq); onData(freq, analyser); }, opts); /** * Basic audio analyser which reports the peak sound level. * * ```js * analyserPeakLevel(level => { * console.log(level); * }); * ``` * * Note: Browers won't allow microphone access unless the call has come from a user-interaction, eg pointerup event handler. * @param onData * @param opts * @returns */ const analyserPeakLevel = (onData, opts = {}) => new AudioAnalyser((node, analyser) => { const wave = new Float32Array(node.fftSize); node.getFloatTimeDomainData(wave); onData(maxFast(wave), analyser); }, opts); /** * Helper for doing audio analysis. It takes case of connecting the audio stream, running in a loop and pause capability. * * Provide a function which works with an [AnalyserNode](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode), and does something with the result. * ```js * const myAnalysis = (node, analyser) => { * const freq = new Float32Array(node.frequencyBinCount); * node.getFloatFrequencyData(freq); * // Do something with frequency data... * } * const a = new Analyser(myAnalysis); * ``` * * Helper functions provide ready-to-use Analysers: * * {@link analyserPeakLevel} peak decibel reading * * {@link analyserFrequency} FFT results * * {@link analyserBasic} FFT results and waveform * * Note: Browers won't allow microphone access unless the call has come from a user-interaction, eg pointerup event handler. * */ var AudioAnalyser = class { showVis; fftSize; smoothingTimeConstant; #isPaused = false; debug; #initInProgress = false; visualiser; audioCtx; analyserNode; analyse; constructor(analyse, opts = {}) { this.showVis = opts.showVis ?? false; this.fftSize = opts.fftSize ?? 1024; this.debug = opts.debug ?? false; this.smoothingTimeConstant = opts.smoothingTimeConstant ?? .8; resultThrow(integerTest(this.fftSize, `positive`, `opts.fftSize`), numberTest(this.smoothingTimeConstant, `percentage`, `opts.smoothingTimeConstant`)); if (!isPowerOfTwo(this.fftSize)) throw new Error(`fftSize must be a power of two from 32 to 32768 (${this.fftSize})`); if (this.fftSize < 32) throw new Error(`fftSize must be at least 32`); if (this.fftSize > 32768) throw new Error(`fftSize must be no greater than 32768`); this.analyse = analyse; this.paused = false; this.init(); const visualiserEl = document.querySelector(`#audio-visualiser`); if (visualiserEl) { const visualiser = new AudioVisualiser(visualiserEl, this); visualiser.setExpanded(this.showVis); this.visualiser = visualiser; } } init() { if (this.#initInProgress) { if (this.debug) console.debug(`Init already in progress`); return; } this.#initInProgress = true; navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { this.onMicSuccess(stream); }).catch((error) => { this.#initInProgress = false; console.error(error); }); } get paused() { return this.#isPaused; } set paused(v) { if (v === this.#isPaused) return; this.#isPaused = v; if (v) { if (this.debug) console.log(`Paused`); } else { if (this.debug) console.log(`Unpaused`); window.requestAnimationFrame(this.analyseLoop.bind(this)); } } setup(context, stream) { const analyser = context.createAnalyser(); analyser.fftSize = this.fftSize; analyser.smoothingTimeConstant = this.smoothingTimeConstant; context.createMediaStreamSource(stream).connect(analyser); return analyser; } onMicSuccess(stream) { try { const context = new AudioContext(); context.addEventListener(`statechange`, () => { if (this.debug) console.log(`Audio context state: ${context.state}`); }); this.audioCtx = context; this.analyserNode = this.setup(context, stream); window.requestAnimationFrame(this.analyseLoop.bind(this)); } catch (error) { this.#initInProgress = false; console.error(error); } } analyseLoop() { if (this.paused) { if (this.debug) console.log(`Paused`); return; } const a = this.analyserNode; if (a === void 0) { console.warn(`Analyser undefined`); return; } try { this.analyse(a, this); } catch (error) { console.error(error); } window.requestAnimationFrame(this.analyseLoop.bind(this)); } /** * Returns the maximum FFT value within the given frequency range */ getFrequencyRangeMax(lowFreq, highFreq, freqData) { return max(this.sliceByFrequency(lowFreq, highFreq, freqData)); } /** * Returns a sub-sampling of frequency analysis data that falls between * `lowFreq` and `highFreq`. * @param lowFreq Low frequency * @param highFreq High frequency * @param freqData Full-spectrum frequency data * @returns Sub-sampling of analysis */ sliceByFrequency(lowFreq, highFreq, freqData) { const lowIndex = this.getIndexForFrequency(lowFreq); const highIndex = this.getIndexForFrequency(highFreq); return freqData.slice(lowIndex, highIndex); } /** * Returns the starting frequency for a given binned frequency index. * @param index Array index * @returns Sound frequency */ getFrequencyAtIndex(index) { const a = this.analyserNode; const ctx = this.audioCtx; if (a === void 0) throw new Error(`Analyser not available`); if (ctx === void 0) throw new Error(`Audio context not available`); resultThrow(integerTest(index, `positive`, `index`)); if (index > a.frequencyBinCount) throw new Error(`Index ${index} exceeds frequency bin count ${a.frequencyBinCount}`); return index * ctx.sampleRate / (a.frequencyBinCount * 2); } /** * Returns a binned array index for a given frequency * @param freq Sound frequency * @returns Array index into frequency bins */ getIndexForFrequency(freq) { const a = this.analyserNode; if (a === void 0) throw new Error(`Analyser not available`); const nyquist = a.context.sampleRate / 2; const index = Math.round(freq / nyquist * a.frequencyBinCount); if (index < 0) return 0; if (index >= a.frequencyBinCount) return a.frequencyBinCount - 1; return index; } }; //#endregion //#region ../packages/io/src/audio/from-audio-element.ts /** * Scans page for <AUDIO> elements and creates playable controllers for them. * It uses the element's 'id' attribute as a way of fetching one later. * * ```js * const ae = new AudioElements(); * ae.init(); // Initialise * * const a = ae.get('kick'); // Get the source that had id 'kick' * ``` */ var AudioElements = class { #initialised = false; #sources = /* @__PURE__ */ new Map(); filterType = `lowpass`; constructor() {} init() { if (this.#initialised) return; this.#initialised = true; for (const element of document.querySelectorAll(`audio`)) this.#sources.set(element.id, createFromAudioElement(element, this.filterType)); } /** * Gets a BasicAudio instance by key * @param key * @returns BasicAudio instance, or undefined */ get(key) { this.init(); return this.#sources.get(key); } }; /** * Create a BasicAudioElement instance from an <AUDIO> tag in the HTML document. * * See {@link AudioElements} to automatically create sources from all <AUDIO> elements. * @param audioElementOrQuery Element or query (eg '#some-id') * @param filterType Filter type. Defaults to 'lowpass' * @returns */ function createFromAudioElement(audioElementOrQuery, filterType = `lowpass`) { const el = resolveEl(audioElementOrQuery); const context = new AudioContext(); const source = context.createMediaElementSource(el); const pan = context.createStereoPanner(); const gain = context.createGain(); const filter = context.createBiquadFilter(); filter.type = filterType; source.connect(gain); gain.connect(pan); pan.connect(filter); filter.connect(context.destination); return { pan, gain, filter, id: el.id, ctx: context, el }; } //#endregion //#region ../packages/io/src/audio/from-oscillator.ts /** * Initialise audio with an oscillator source * @param oscillatorOptions * @returns BasicAudio instance */ function createOscillator(oscillatorOptions = {}) { const context = new AudioContext(); const oscType = oscillatorOptions.type ?? `sawtooth`; const oscFreq = oscillatorOptions.frequency ?? 440; const id = oscillatorOptions.id ?? shortGuid(); const source = context.createOscillator(); source.type = oscType; source.frequency.setValueAtTime(oscFreq, context.currentTime); const pan = context.createStereoPanner(); const gain = context.createGain(); const filter = context.createBiquadFilter(); source.connect(gain); gain.connect(pan); pan.connect(filter); filter.connect(context.destination); return { pan, gain, filter, ctx: context, osc: source, id }; } //#endregion //#region ../packages/io/src/audio/index.ts var audio_exports = /* @__PURE__ */ __exportAll({ AudioAnalyser: () => AudioAnalyser, AudioElements: () => AudioElements, AudioVisualiser: () => AudioVisualiser, analyserBasic: () => analyserBasic, analyserFrequency: () => analyserFrequency, analyserPeakLevel: () => analyserPeakLevel, createFromAudioElement: () => createFromAudioElement, createOscillator: () => createOscillator }); //#endregion //#region ../packages/io/src/midi/midi-fns.ts /** * Sends a note on and note off * @param port * @param channel * @param note * @param velocity * @param duration * @param delay */ const sendNote = (port, channel, note, velocity, duration = 200, delay) => { const noteOn = { channel, note, velocity, command: `noteon` }; const noteOff = { channel, note, velocity: 0, command: `noteoff` }; port.send(pack(noteOn), delay); port.send(pack(noteOff), window.performance.now() + duration); }; /** * Parses MIDI data from an array into a MidiMessage * * ```js * function onMidiMessage(event: MIDIMessageEvent) { * const msg = unpack(event.data); * // { command, channel, note, velocity } * } * * // Where 'input' is a MIDIInput * input.addEventListener(`midimessage`, onMidiMessage); * ``` * @param data * @returns */ const unpack = (data) => { let command; const first = data[0]; const second = data[1]; const third = data[2]; let channel = 0; if (first >= 144 && first <= 159) { channel = first - 143; command = third === 0 ? `noteoff` : `noteon`; } else if (first >= 128 && first <= 143) { channel = first - 127; command = `noteoff`; } else if (first >= 160 && first <= 175) { channel = first - 159; command = `poly-at`; } else if (first >= 176 && first <= 191) { channel = first - 175; command = `cc`; } else if (first >= 192 && first <= 207) { channel = first - 191; command = `progchange`; } else if (first >= 208 && first <= 223) { channel = first - 207; command = `at`; } else if (first >= 224 && first <= 239) { channel = first - 223; command = `pitchbend`; } if (command === void 0) throw new Error(`Unknown command: '${command}'`); else return { command, note: second, velocity: third, channel }; }; /** * Packs a MidiMessage into an array for sending to a MIDIOutput. * * ```js * const msg: Midi.MidiMessage = { * command: `cc`, * channel: 1, * velocity: 50, * note: 40 * } * * // Where 'output' is a MIDIOutput * output.send(pack(msg)); * ``` * @param message * @returns */ const pack = (message) => { const data = new Uint8Array(3); data[1] = message.note; data[2] = message.velocity; switch (message.command) { case `cc`: data[0] = message.channel + 175; break; case `noteon`: data[0] = message.channel + 143; break; case `noteoff`: data[0] = message.channel + 127; break; case `pitchbend`: data[0] = message.channel + 223; break; case `poly-at`: data[0] = message.channel + 159; break; case `progchange`: data[0] = message.channel + 191; break; case `at`: data[0] = message.channel + 207; break; default: throw new Error(`Command not supported '${message.command}'`); } return Array.from(data); }; //#endregion //#region ../packages/io/src/midi/notes.ts const notesRaw = `0 C-1 8.176 1 C#-1 8.662 2 D-1 9.177 3 D#-1 9.723 4 E-1 10.301 5 F-1 10.913 6 F#-1 11.562 7 G-1 12.250 8 G#-1 12.978 9 A-1 13.750 10 A#-1 14.568 11 B-1 15.434 12 C0 16.352 13 C#0 17.324 14 D0 18.354 15 D#0 19.445 16 E0 20.602 17 F0 21.827 18 F#0 23.125 19 G0 24.500 20 G#0 25.957 21 A0 27.500 22 A#0 29.135 23 B0 30.868 24 C1 32.703 25 C#1 34.648 26 D1 36.708 27 D#1 38.891 28 E1 41.203 29 F1 43.654 30 F#1 46.249 31 G1 48.999 32 G#1 51.913 33 A1 55.000 34 A#1 58.270 35 B1 61.735 36 C2 65.406 37 C#2 69.296 38 D2 73.416 39 D#2 77.782 40 E2 82.407 41 F2 87.307 42 F#2 92.499 43 G2 97.999 44 G#2 103.826 45 A2 110.000 46 A#2 116.541 47 B2 123.471 48 C3 130.813 49 C#3 138.591 50 D3 146.832 51 D#3 155.563 52 E3 164.814 53 F3 174.614 54 F#3 184.997 55 G3 195.998 56 G#3 207.652 57 A3 220.000 58 A#3 233.082 59 B3 246.942 60 C4 261.626 61 C#4 277.183 62 D4 293.665 63 D#4 311.127 64 E4 329.628 65 F4 349.228 66 F#4 369.994 67 G4 391.995 68 G#4 415.305 69 A4 440.000 70 A#4 466.164 71 B4 493.883 72 C5 523.251 73 C#5 554.365 74 D5 587.330 75 D#5 622.254 76 E5 659.255 77 F5 698.456 78 F#5 739.989 79 G5 783.991 80 G#5 830.609 81 A5 880.000 82 A#5 932.328 83 B5 987.767 84 C6 1046.502 85 C#6 1108.731 86 D6 1174.659 87 D#6 1244.508 88 E6 1318.510 89 F6 1396.913 90 F#6 1479.978 91 G6 1567.982 92 G#6 1661.219 93 A6 1760.000 94 A#6 1864.655 95 B6 1975.533 96 C7 2093.005 97 C#7 2217.461 98 D7 2349.318 99 D#7 2489.016 100 E7 2637.020 101 F7 2793.826 102 F#7 2959.955 103 G7 3135.963 104 G#7 3322.438 105 A7 3520.000 106 A#7 3729.310 107 B7 3951.066 108 C8 4186.009 109 C#8 4434.922 110 D8 4698.636 111 D#8 4978.032 112 E8 5274.041 113 F8 5587.652 114 F#8 5919.911 115 G8 6271.927 116 G#8 6644.875 117 A8 7040.000 118 A#8 7458.620 119 B8 7902.133 120 C9 8372.018 121 C#9 8869.844 122 D9 9397.273 123 D#9 9956.063 124 E9 10548.080 125 F9 11175.300 126 F#9 11839.820 127 G9 12543.850`; const notesParsed = []; const getParsedNotes = () => { if (notesParsed.length > 0) return notesParsed; const lines = notesRaw.split("\n"); for (const line of lines) { const s = line.split(`\t`); if (s.length !== 3) { console.warn(`Expected three elements, got ${s.length}. Line:`, s); continue; } notesParsed.push([ Number.parseInt(s[0]), s[1].toUpperCase(), Number.parseFloat(s[2]) ]); } return notesParsed; }; const noteNameToNumber = (name) => { const notes = getParsedNotes(); name = name.toUpperCase(); const n = notes.find((n) => n[1] === name); if (n) return n[0]; return NaN; }; const noteNameToFrequency = (name) => { const notes = getParsedNotes(); name = name.toUpperCase(); const n = notes.find((n) => n[1] === name); if (n) return n[2]; return NaN; }; const noteNumberToName = (number) => { const n = getParsedNotes().find((n) => n[0] === number); if (n) return n[1]; return ``; }; const noteNumberToFrequency = (number) => { const n = getParsedNotes().find((n) => n[0] === number); if (n) return n[2]; return NaN; }; //#endregion //#region ../packages/io/src/midi/manager.ts /** * Midi Manager makes simplifies connecting to ports * and having omni input/output * * ```js * const midi = new MidiManager(); // By default connects to all ins and outs * midi.addEventListener(`message`, event => { * // Do something with received MIDI data from any input * }) * * midi.scan(); * midi.send({ command: `cc`, note: 10, channel: 1, velocity: 20 }); * * // Sends note '10' on channel 1, velocity 100, duration 200ms * midi.sendNote(1, 10, 100, 200); * ``` * Events: * * open/close: Connected port is open/closed * * deviceConnected/deviceDisconnected: A port is newly available or unavailable * * message: MIDI event received */ var MidiManager = class extends SimpleEventEmitter { verbose = true; #state; #access; #inUse = []; #known = []; #omniInput = true; #omniOutput = true; #connectAllInputsDebounced = debounce(() => this.#connectAllInputs(), 1e3); #connectAllOutputsDebounced = debounce(() => this.#connectAllOutputs(), 1e3); constructor() { super(); this.#state = { initialised: false, errorReason: `` }; this.#throwIfNotSupported(); } *getInUse() { for (const p of this.#inUse) yield p; } *getInUseInput() { for (const p of this.#inUse) if (p.type === `input`) yield p; } *getInUseOutput() { for (const p of this.#inUse) if (p.type === `output`) yield p; } *known() { for (const p of this.#known) yield p; } *knownInput() { for (const p of this.#known) if (p.type === `input`) yield p; } *knownOutput() { for (const p of this.#known) if (p.type === `output`) yield p; } async scan() { await this.#init(); const a = this.#access; if (!a) return; a.inputs.forEach((port) => { this.#updatePort(port); }); a.outputs.forEach((port) => { this.#updatePort(port); }); if (this.#omniInput) this.#connectAllInputsDebounced(); if (this.#omniOutput) this.#connectAllOutputsDebounced(); } /** * Sends a message to a port. * * If port is omitted, all open output ports are used. * @param message * @param port * @param timestamp */ send(message, port, timestamp) { const packed = pack(message); if (typeof port === `undefined`) { for (const p of this.#inUse) if (p.type === `output`) p.send(packed, timestamp); } else port.send(packed, timestamp); } sendNote(channel, note, velocity, duration, delay, port) { if (port === void 0) for (const port of this.getInUseOutput()) this.sendNote(channel, note, velocity, duration, delay, port); else sendNote(port, channel, note, velocity, duration, delay); } #updatePort(p) { if (p.state === `connected`) { this.#onPortConnected(p); if (p.connection === `open`) this.#onPortOpen(p); } else if (p.state === `disconnected`) this.#onPortDisconnected(p); } #onMessage = (event) => { const raw = event.data; const port = event.currentTarget; if (!raw) return; const data = unpack(raw); if (data.command === `noteoff` || data.command === `noteon`) { this.fireEvent(`message`, { ...data, port, raw, frequency: noteNumberToFrequency(data.note), noteName: noteNumberToName(data.note) }); return; } this.fireEvent(`message`, { ...data, port, raw }); }; #onPortOpen(port) { const inUse = this.#inUse.find((p) => p.id === port.id); this.#logVerbose(`onPortOpen: id: ${port.id} name: ${port.name} (${port.type})`); if (inUse) { this.#logVerbose(`-- bug, port already in use?`); return; } this.#onPortConnected(port); if (port.type === `input`) port.addEventListener(`midimessage`, this.#onMessage); this.#inUse = [...this.#inUse, port]; this.fireEvent(`open`, { port }); } #onPortClose(port) { if (!this.#inUse.find((p) => p.id === port.id)) return; if (port.type === `input`) port.removeEventListener(`midimessage`, this.#onMessage); this.#inUse = this.#inUse.filter((p) => p.id !== port.id); this.fireEvent(`close`, { port }); } /** * New device connected, but not necessarily open * @param port */ #onPortConnected(port) { if (this.#known.find((p) => p.id === port.id)) return; this.#known = [...this.#known, port]; this.fireEvent(`deviceConnected`, { port }); } /** * Device disconnected * @param port * @returns */ #onPortDisconnected(port) { if (!this.#known.find((p) => p.id === port.id)) return; this.#onPortClose(port); this.#known = this.#known.filter((p) => p.id !== port.id); this.fireEvent(`deviceDisconnected`, { port }); } #isPortInUse(port) { return this.#inUse.find((p) => p.id === port.id) !== void 0; } async closeAll(what = `both`) { for (const p of this.#inUse) { if (p.type == `input` && (what === `both` || what === `input`)) await p.close(); if (p.type == `output` && (what === `both` || what === `output`)) await p.close(); } } async setOmniInput(value) { this.#omniInput = value; await this.closeAll(`input`); if (value) await this.#connectAllInputs(); } get omniInput() { return this.#omniInput; } async setOmniOutput(value) { this.#omniOutput = value; await this.closeAll(`output`); if (value) await this.#connectAllOutputs(); } get omniOutput() { return this.#omniOutput; } async #connectAllInputs() { const a = this.#access; if (!a) return; a.inputs.forEach(async (input) => { if (input.connection === `closed`) { if (this.#isPortInUse(input)) throw new Error(`Bug: Input closed, but inUse?`); await input.open(); } }); } async #connectAllOutputs() { const a = this.#access; if (!a) return; a.outputs.forEach(async (output) => { if (output.connection === `closed`) { if (this.#isPortInUse(output)) throw new Error(`Bug: Output closed, but inUse?`); await output.open(); } }); } dumpToStringLines() { const returnValue = []; const portToString = (p) => ` - ${p.name} (${p.type}) state: ${p.state} conn: ${p.connection} id: ${p.id}`; returnValue.push(`MidiManager`); returnValue.push(`In Use:`); returnValue.push(...mapWithEmptyFallback(this.#inUse, portToString, ` (none)`)); returnValue.push(`Known:`); returnValue.push(...mapWithEmptyFallback(this.#known, portToString, ` (none)`)); return returnValue; } #onStateChange(event) { const port = event.port; if (port === null) return; if (port.state === `connected`) { if (port.connection === `open`) this.#onPortOpen(port); else if (port.connection === `closed`) { this.#onPortClose(port); this.#onPortConnected(port); if (this.#omniInput && port.type === `input`) this.#connectAllInputsDebounced(); else if (this.#omniOutput && port.type === `output`) this.#connectAllOutputsDebounced(); } } else if (port.state === `disconnected`) this.#onPortDisconnected(port); } /** * Opens `port`. * * If `exclusive` is _true_, all other ports of that type * (ie. input or output) are closed before the port is opened. * * If `exclusive` is _false_ (default), already open ports are left open. * @param port * @param exclusive */ async open(port, exclusive = false) { if (exclusive) { if (port.type === `input`) await this.closeAll(`input`); else if (port.type === `output`) await this.closeAll(`output`); } port.open(); } #logVerbose(message) { if (!this.verbose) return; console.log(`MIDI`, message); } #setState(state) { this.#state = { ...this.#state, ...state }; this.#logVerbose(`State change: ${JSON.stringify(this.#state)}`); } async #init() { if (this.#state.initialised && this.#access !== void 0) return; if ((await navigator.permissions.query({ name: "midi", software: true, sysex: false })).state === `denied`) { this.#access = void 0; this.#setState({ initialised: false, errorReason: `Permission denied` }); return; } this.#access = await navigator.requestMIDIAccess({ software: true, sysex: false }); this.#access.addEventListener(`statechange`, (event) => { this.#onStateChange(event); }); this.#setState({ initialised: true, errorReason: `` }); } #isSupported() { if (!navigator.requestMIDIAccess) return false; return true; } #throwIfNotSupported() { if (!window.isSecureContext) throw new Error(`Code is not running in a secure context. Load it via https`); if (!this.#isSupported()) throw new Error(`MIDI not supported in this browser`); } findKnownPort(fn) { return this.#known.find(fn); } *filterKnownPort(fn) { yield* this.#known.filter(fn); } findInUsePort(fn) { return this.#inUse.find(fn); } *filterInUsePort(fn) { yield* this.#inUse.filter(fn); } }; //#endregion //#region ../packages/io/src/midi/control.ts var Feedback = class { channel = 0; cc = -1; note = -1; output; portName; constructor(options = {}) { this.channel = options.channel ?? -1; this.cc = options.cc ?? -1; this.note = options.note ?? -1; this.output = options.output; this.portName = options.portName; } setOutputPort(port) { if (port.type === `input`) return false; if (this.portName !== void 0) { if (port.name !== this.portName) return false; } this.output = port; return true; } sendRaw(value) { if (!this.output) return false; if (this.channel < 0) return false; if (this.cc < 0 && this.note < 0) return false; let message; if (this.cc >= 0) { message = { channel: this.channel, command: `cc`, note: this.cc, velocity: value }; console.log(message); this.output.send(pack(message)); return true; } console.log(`sendNote: ch: ${this.channel} note: ${this.note} vel: ${value}`); sendNote(this.output, this.channel, this.note, value, 200); return true; } }; var Control = class Control extends SimpleEventEmitter { static controlCount = 0; inputChannel = 1; inputCommand = `cc`; inputNote = -1; inputVelocityScale = [0, 127]; feedbackChannel = 1; feedbackCommand = `cc`; feedbackNote = -1; feedbackVelocity = 1; name = `Control-${Control.controlCount++}`; lastMessage; onInputMessage(message) { if (this.inputChannel >= 0 && message.channel !== this.inputChannel) return false; if (this.inputNote >= 0 && message.note !== this.inputNote) return false; if (this.inputCommand !== void 0 && message.command !== this.inputCommand) return false; this.lastMessage = message; this.fireEvent(`change`, { velocity: message.velocity, velocityScaled: this.#scaleVelocity(message.velocity), control: this }); return true; } #scaleVelocity(v) { return scale(v, this.inputVelocityScale[0], this.inputVelocityScale[1]); } get scaledVelocity() { if (this.lastMessage) return this.#scaleVelocity(this.lastMessage.velocity); return NaN; } }; //#endregion //#region ../packages/io/src/midi/midi-fighter.ts /** * Connects to a DJ Tech Tools Midi Fighter controller. * * Use the 'state' event and wait for state to be 'ready'. * * ```js * const mf = new MidiFighter(); * mf.addEventListener(`state`, event => { * if (event.state === `ready`) { * // Can work with device now * mf.bank = 1; * } * }); * mf.addEventListener(`encoder`, event => { * // Do something with encoder value * }); * mf.setPort(someMidiInputPort); * mf.setPort(someMidiOutputPort); * ``` * Assumes default settings are loaded on the controller * * Supports * * Listening for encoder moves and button presses * * Changing colour pip below each encoder * * Setting LED bar for each encoder * * Changing banks, or detecting when the user has done so via the physical buttons * * Events: * * bankChange: Current bank has changed * * sideButton: Side button pressed * * switch: Encoder has been pressed * * encoder: Encoder has been moved * * state: Midi Fighter has both input/output ports or not. */ var MidiFighter = class extends SimpleEventEmitter { encoders = []; #currentBank = -1; #state = `disconnected`; #inputPort; #outputPort; /** * If true, messages sent to Midi Fighter are printed to console */ logOutgoing = false; /** * Channel bank change events are received on */ bankChangeChannel = 4; /** * Channel side button press events are received on */ sideButtonChannel = 4; constructor() { super(); for (let bank = 1; bank < 5; bank++) for (let encoder = 1; encoder < 17; encoder++) { const enc = new MidiFighterEncoder(this, { bank, encoder }); this.encoders.push(enc); enc.addEventListener(`encoder`, (event) => { this.fireEvent(`encoder`, event); }); enc.addEventListener(`switch`, (event) => { this.fireEvent(`switch`, event); }); } } /** * Our input/output port has changed state * @param event * @returns */ #onPortState = (event) => { const port = event.port; if (!port) return; if (port === this.#outputPort && (port.state === `disconnected` || port.connection === `closed`)) this.#outputPortUnbind(); if (port === this.#inputPort && (port.state === `disconnected` || port.connection === `closed`)) this.#inputPortUnbind(); if (this.#outputPort !== void 0 && this.#inputPort !== void 0) this.#setState(`ready`); else this.#setState(`disconnected`); }; /** * Unsubscribe from events of current input port, if we have one */ #inputPortUnbind() { const ip = this.#inputPort; if (ip !== void 0) { ip.removeEventListener(`statechange`, this.#onPortState); ip.removeEventListener(`midimessage`, this.#onMessage); } this.#inputPort = void 0; } /** * Unsubcribe from events of current output port, if we have one */ #outputPortUnbind() { const op = this.#outputPort; if (op !== void 0) op.removeEventListener(`statechange`, this.#onPortState); this.#outputPort = void 0; } /** * Sets a port for this instance to use. * This will need to be called separately for the input and output ports * @param port */ setPort(port) { if (port.name === `Midi Fighter Twister`) { if (port.type === `output`) { this.#outputPortUnbind(); this.#outputPort = port; if (this.#outputPort !== void 0) this.#outputPort.addEventListener(`statechange`, this.#onPortState); } else if (port.type === `input`) { this.#inputPortUnbind(); this.#inputPort = port; if (port !== void 0) { this.#inputPort.addEventListener(`midimessage`, this.#onMessage); this.#inputPort.addEventListener(`statechange`, this.#onPortState); } } } if (this.#outputPort !== void 0 && this.#inputPort !== void 0) this.#setState(`ready`); } #setState(state) { const previous = this.#state; if (previous === state) return; this.#state = state; this.fireEvent(`state`, { previous, state, mf: this }); } #onMessage = (event) => { const data = event.data; if (!data) return; const message = unpack(data); if (message.channel === this.bankChangeChannel) { if (message.command === `cc` && message.note < 4) { this.#onBankChange(message.note + 1, false); return; } } if (message.channel === this.sideButtonChannel && message.command == `cc`) { let buttonBank = -1; let position; let side; if (message.note === 8) { buttonBank = 1; position = `top`; side = `left`; } else if (message.note === 10) { buttonBank = 1; side = `left`; position = `bottom`; } else if (message.note === 11) { buttonBank = 1; side = `right`; pos