UNPKG

ixfx

Version:

A framework for programming interactivity

1,673 lines (1,642 loc) 56.5 kB
import { number } from "./chunk-RS6FVVTS.js"; import { manualCapture } from "./chunk-QR64IITR.js"; import { shortGuid } from "./chunk-B5XDBTUM.js"; import { max, maxFast } from "./chunk-YG33FJI6.js"; import { continuously, retryFunction, retryTask, waitFor } from "./chunk-3PQXJLTZ.js"; import { StateMachineWithEvents, StateMachine_exports } from "./chunk-LHELVIWO.js"; import { QueueMutable } from "./chunk-VYCNRTDD.js"; import { SimpleEventEmitter, eventRace, intervalToMs } from "./chunk-72EKR3DZ.js"; import { resolveEl } from "./chunk-ICXKAKPN.js"; import { minMaxAvg } from "./chunk-LKZ4HZTV.js"; import { indexOfCharCode, omitChars, splitByLength, string } from "./chunk-F2KVRMEV.js"; import { getErrorMessage } from "./chunk-4IJNRUE7.js"; import { isPowerOfTwo, throwIntegerTest, throwNumberTest } from "./chunk-UC4AQMTL.js"; import { __export } from "./chunk-L5EJU35C.js"; // src/io/index.ts var io_exports = {}; __export(io_exports, { Audio: () => audio_exports, Bluetooth: () => NordicBleDevice_exports, Camera: () => Camera_exports, Codec: () => Codec, Espruino: () => Espruino_exports, FrameProcessor: () => FrameProcessor, Serial: () => Serial_exports, StringReceiveBuffer: () => StringReceiveBuffer, StringWriteBuffer: () => StringWriteBuffer, VideoFile: () => VideoFile_exports, genericStateTransitionsInstance: () => genericStateTransitionsInstance, reconnectingWebsocket: () => reconnectingWebsocket }); // src/io/NordicBleDevice.ts var NordicBleDevice_exports = {}; __export(NordicBleDevice_exports, { NordicBleDevice: () => NordicBleDevice, defaultOpts: () => defaultOpts }); // src/io/Codec.ts var Codec = class { constructor() { this.enc = new TextEncoder(); this.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); } }; // src/io/StringReceiveBuffer.ts var StringReceiveBuffer = class { constructor(onData, separator = ` `) { this.onData = onData; this.separator = separator; this.buffer = ``; } 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(str) { const pos = str.indexOf(this.separator); if (pos < 0) { this.buffer += str; return ``; } const part = str.substring(0, pos); try { this.onData(this.buffer + part); str = str.substring(part.length + this.separator.length); } catch (ex) { console.warn(ex); } this.buffer = ``; return str; } add(str) { while (str.length > 0) { str = this.addImpl(str); } } }; // src/io/StringWriteBuffer.ts var StringWriteBuffer = class { /** * Constructor * @param dataHandler Calback to 'send' data onwards * @param opts Options */ constructor(dataHandler, opts = {}) { this.dataHandler = dataHandler; this.paused = false; this.queue = new QueueMutable(); this.closed = false; 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(...splitByLength(stringToQueue, this.chunkSize)); } else { this.queue.enqueue(stringToQueue); } this.writer.start(); } }; // src/io/GenericStateTransitions.ts var genericStateTransitionsInstance = Object.freeze({ ready: `connecting`, connecting: [`connected`, `closed`], connected: [`closed`], closed: `connecting` }); // src/io/BleDevice.ts var BleDevice = class extends SimpleEventEmitter { constructor(device, config) { super(); this.device = device; this.config = config; this.verboseLogging = false; 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) { const rx = this.rx; if (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); } }; // src/io/NordicBleDevice.ts var 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 }); } }; // src/io/audio/index.ts var audio_exports = {}; __export(audio_exports, { AudioAnalyser: () => AudioAnalyser2, AudioElements: () => AudioElements, AudioVisualiser: () => AudioVisualiser, analyserBasic: () => analyserBasic, analyserFrequency: () => analyserFrequency, analyserPeakLevel: () => analyserPeakLevel, createFromAudioElement: () => createFromAudioElement, createOscillator: () => createOscillator }); // src/io/audio/Visualiser.ts var AudioVisualiser = class { constructor(parentElem, audio) { this.freqMaxRange = 200; this.lastPointer = { x: 0, y: 0 }; this.pointerDown = false; this.pointerClicking = false; this.pointerClickDelayMs = 100; this.pointerDelaying = false; this.audio = audio; this.parent = parentElem; this.waveTracker = number(); this.freqTracker = number(); parentElem.innerHTML = ` <section> <button id="rendererComponentToggle">\u{1F53C}</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 = parentElem.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 = minMaxAvg(freq); for (let i = 0; i < bins; i++) { if (!Number.isFinite(freq[i])) continue; const value = freq[i] - minMax.min; const valueRelative = value / this.freqMaxRange; const height = Math.abs(canvasHeight * valueRelative); const offset = canvasHeight - height; const hue = i / bins * 360; const left = i * 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 !== i.toString()) { this.freqTracker = number({ id: i.toString() }); } this.freqTracker.seen(freq[i]); const freqMma = this.freqTracker.getMinMaxAvg(); g.fillStyle = `black`; if (this.audio) { g.fillText( `Frequency (${i}) at pointer: ${this.audio.getFrequencyAtIndex(i).toLocaleString(`en`)} - ${this.audio.getFrequencyAtIndex(i + 1).toLocaleString(`en`)}`, 2, 10 ); } g.fillText(`Raw value: ${freq[i].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 contentsElem = this.el.querySelector(`div`); if (contentsElem === null) throw new Error(`contents div not found`); return contentsElem.style.display === ``; } setExpanded(value) { const contentsElem = this.el.querySelector(`div`); const button = this.el.querySelector(`button`); if (button === null) throw new Error(`Button element not found`); if (contentsElem === null) throw new Error(`Contents element not found`); if (value) { contentsElem.style.display = ``; button.innerText = `\u{1F53C}`; } else { contentsElem.style.display = `none`; button.innerText = `\u{1F53D}`; } } clear() { this.clearCanvas( document.getElementById(`rendererComponentFreqData`) ); this.clearCanvas( document.getElementById(`rendererComponentWaveData`) ); } // Clears a canvas to white 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); } // Renders waveform data. // Adapted from MDN's AnalyserNode.getFloatTimeDomainData() example 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 i = 0; i < bins; i++) { const height = wave[i] * canvasHeight; const y = bipolar ? canvasHeight / 2 - height : canvasHeight - height; if (i === 0) { g.moveTo(x, y); } else { g.lineTo(x, y); } x += width; if (this.pointerDown) this.waveTracker.seen(wave[i]); } 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 ); } } // Yields pointer position relative to given element getPointerRelativeTo(elem) { const rect = elem.getBoundingClientRect(); return { x: this.lastPointer.x - rect.left - window.scrollX, //elem.offsetLeft + window.scrollX, y: this.lastPointer.y - rect.top - window.scrollY //elem.offsetTop + window.scrollY }; } // Keeps track of last pointer position in page coordinate space onPointer(evt) { this.lastPointer = { x: evt.pageX, y: evt.pageY }; evt.preventDefault(); } // getMinMax(data, start = 0, end = data.length) { // if (end > data.length) throw new Error(`end is past size of array`); // if (start < 0) throw new Error(`start should be at least 0`); // if (end <= start) throw new Error(`end should be greater than start`); // let max = Number.MIN_SAFE_INTEGER; // let min = Number.MAX_SAFE_INTEGER; // for (let i = start; i < end; i++) { // max = Math.max(data[i], max); // min = Math.min(data[i], min); // } // if (!Number.isFinite(max)) max = 0; // if (!Number.isFinite(min)) min = 0; // return {max: max, min: min}; // } }; // src/io/audio/Analyser.ts var analyserBasic = (onData, opts = {}) => new AudioAnalyser2((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); var analyserFrequency = (onData, opts = {}) => new AudioAnalyser2((node, analyser) => { const freq = new Float32Array(node.frequencyBinCount); node.getFloatFrequencyData(freq); onData(freq, analyser); }, opts); var analyserPeakLevel = (onData, opts = {}) => new AudioAnalyser2((node, analyser) => { const wave = new Float32Array(node.fftSize); node.getFloatTimeDomainData(wave); onData(maxFast(wave), analyser); }, opts); var AudioAnalyser2 = class { #isPaused = false; #initInProgress = false; constructor(analyse, opts = {}) { this.showVis = opts.showVis ?? false; this.fftSize = opts.fftSize ?? 1024; this.debug = opts.debug ?? false; this.smoothingTimeConstant = opts.smoothingTimeConstant ?? 0.8; throwIntegerTest(this.fftSize, `positive`, `opts.fftSize`); throwNumberTest( 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; const micSource = context.createMediaStreamSource(stream); micSource.connect(analyser); return analyser; } // Microphone successfully initalised, now have access to audio data 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)); } // visualise(wave, freq) { // if (!this.visualiser) return; // this.visualiser.renderWave(wave, true); // this.visualiser.renderFreq(freq); // } /** * Returns the maximum FFT value within the given frequency range */ getFrequencyRangeMax(lowFreq, highFreq, freqData) { const samples = this.sliceByFrequency(lowFreq, highFreq, freqData); return max(samples); } /** * 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); const samples = freqData.slice(lowIndex, highIndex); return samples; } /** * 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`); throwIntegerTest(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; } }; // src/io/audio/FromAudioElement.ts var AudioElements = class { constructor() { this.#initialised = false; this.#sources = /* @__PURE__ */ new Map(); this.filterType = `lowpass`; } #initialised; #sources; 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); } }; 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 }; } // src/io/audio/FromOscillator.ts 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 }; } // src/io/Espruino.ts var Espruino_exports = {}; __export(Espruino_exports, { EspruinoBleDevice: () => EspruinoBleDevice, EspruinoSerialDevice: () => EspruinoSerialDevice, bangle: () => bangle, connectBle: () => connectBle, deviceEval: () => deviceEval, puck: () => puck, serial: () => serial }); // src/io/EspruinoBleDevice.ts var EspruinoBleDevice = class extends NordicBleDevice { /** * Creates instance. You probably would rather use {@link puck} to create. * @param device * @param opts */ constructor(device, opts = {}) { super(device, opts); this.evalReplyBluetooth = true; this.evalTimeoutMs = opts.evalTimeoutMs ?? 5 * 1e3; } /** * Writes a script to Espruino. * * It will first send a CTRL+C to cancel any previous input, `reset()` to clear the board, * and then the provided `code` followed by a new line. * * Use {@link eval} instead to execute remote code and get the result back. * * ```js * // Eg from https://www.espruino.com/Web+Bluetooth * writeScript(` * setInterval(() => Bluetooth.println(E.getTemperature()), 1000); * NRF.on('disconnect',()=>reset()); * `); * ``` * * @param code Code to send. A new line is added automatically. */ // eslint-disable-next-line @typescript-eslint/require-await async writeScript(code) { this.write(`reset(); `); this.write(`${code} `); } /** * Sends some code to be executed on the Espruino. The result * is packaged into JSON and sent back to your code. An exception is * thrown if code can't be executed for some reason. * * ```js * const sum = await e.eval(`2+2`); * ``` * * It will wait for a period of time for a well-formed response from the * Espruino. This might not happen if there is a connection problem * or a syntax error in the code being evaled. In cases like the latter, * it will take up to `timeoutMs` (default 5 seconds) before we give up * waiting for a correct response and throw an error. * * Tweaking of the timeout may be required if `eval()` is giving up too quickly * or too slowly. A default timeout can be given when creating the class. * * Options: * timeoutMs: Timeout for execution. 5 seconds by default * assumeExclusive If true, eval assumes all replies from controller are in response to eval. True by default * debug: If true, execution is traced via `warn` callback * @param code Code to run on the Espruino. * @param opts Options * @param warn Function to pass warning/trace messages to. If undefined, this.warn is used, printing to console. */ async eval(code, opts = {}, warn) { const debug = opts.debug ?? false; const warnCallback = warn ?? ((m) => { this.warn(m); }); return deviceEval(code, opts, this, `Bluetooth.println`, debug, warnCallback); } /* const timeoutMs = opts.timeoutMs ?? this.evalTimeoutMs; const assumeExclusive = opts.assumeExclusive ?? true; if (typeof code !== `string`) throw new Error(`code parameter should be a string`); return new Promise((resolve, reject) => { // Generate a random id so reply can be matched up with this request const id = randomString(5); const onData = (d:DataEvent) => { try { // Parse reply, expecting JSON. const dd = JSON.parse(d.data); // Check for reply field, and that it matches if (`reply` in dd) { if (dd.reply === id) { done(); // Stop waiting for result if (`result` in dd) { resolve(dd.result); } } else { this.warn(`Expected reply ${id}, got ${dd.reply}`); } } } catch (ex:unknown) { // If there was a syntax error, response won't be JSON if (assumeExclusive) { // Fail with unexpected reply as the message done(d.data); } else { // Unexpected reply, but we cannot be sure if it's in response to eval or // some other code running on board. So just warn and eventually timeout this.warn(ex); } } }; const onStateChange = (e:StateChangeEvent) => { if (e.newState !== `connected`) done(`State changed to '${e.newState}', aborting`); }; this.addEventListener(`data`, onData); this.addEventListener(`change`, onStateChange); // Init waitFor const done = waitFor(timeoutMs, (reason:string) => { reject(reason); }, () => { // If we got a response or there was a timeout, remove event listeners this.removeEventListener(`data`, onData); this.removeEventListener(`change`, onStateChange); }); this.write(`\x10Bluetooth.println(JSON.stringify({reply:"${id}", result:JSON.stringify(${code})}))\n`); }); */ }; // src/io/Serial.ts var Serial_exports = {}; __export(Serial_exports, { Device: () => Device }); // src/io/JsonDevice.ts var JsonDevice = class extends SimpleEventEmitter { constructor(config = {}) { super(); this.verboseLogging = false; this.verboseLogging = config.debug ?? false; this.chunkSize = config.chunkSize ?? 1024; this.connectAttempts = config.connectAttempts ?? 3; this.name = config.name ?? `JsonDevice`; 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(); } }); } 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 close() { if (this.states.state !== `connected`) return; this.onClosed(); } async connect() { const attempts = this.connectAttempts; this.states.state = `connecting`; await this.onPreConnect(); await retryFunction( async () => { await this.onConnectAttempt(); this.states.state = `connected`; return true; }, { limitAttempts: attempts, startAt: 200 } ); } onRx(event) { const view = event.target.value; if (view === void 0) return; let string_ = this.codec.fromBuffer(view.buffer); const plzStop = indexOfCharCode(string_, 19); const plzStart = indexOfCharCode(string_, 17); if (plzStart && plzStop < plzStart) { this.verbose(`Tx plz start`); string_ = omitChars(string_, plzStart, 1); this.txBuffer.paused = false; } if (plzStop && plzStop > plzStart) { this.verbose(`Tx plz stop`); string_ = omitChars(string_, plzStop, 1); this.txBuffer.paused = true; } this.rxBuffer.add(string_); } verbose(m) { if (this.verboseLogging) console.info(`${this.name}`, m); } log(m) { console.log(`${this.name}`, m); } warn(m) { console.warn(`${this.name}`, m); } }; // src/io/Serial.ts var Device = class extends JsonDevice { constructor(config = {}) { super(config); this.config = config; this.abort = new AbortController(); const eol = config.eol ?? `\r `; this.baudRate = config.baudRate ?? 9600; if (config.name === void 0) this.name = `Serial.Device`; this.rxBuffer.separator = eol; } /** * Writes text collected in buffer * @param txt */ async writeInternal(txt) { if (this.tx === void 0) throw new Error(`tx not ready`); try { this.tx.write(txt); } catch (error) { this.warn(error); } } onClosed() { this.tx?.releaseLock(); this.abort.abort(`closing port`); this.states.state = `closed`; } onPreConnect() { return Promise.resolve(); } async onConnectAttempt() { let reqOpts = {}; const openOpts = { baudRate: this.baudRate }; if (this.config.filters) reqOpts = { filters: [...this.config.filters] }; this.port = await navigator.serial.requestPort(reqOpts); this.port.addEventListener(`disconnect`, (_) => { this.close(); }); await this.port.open(openOpts); const txW = this.port.writable; const txText = new TextEncoderStream(); if (txW !== null) { txText.readable.pipeTo(txW, { signal: this.abort.signal }).catch((error) => { console.log(`Serial.onConnectAttempt txText pipe:`); console.log(error); }); this.tx = txText.writable.getWriter(); } const rxR = this.port.readable; const rxText = new TextDecoderStream(); if (rxR !== null) { rxR.pipeTo(rxText.writable, { signal: this.abort.signal }).catch((error) => { console.log(`Serial.onConnectAttempt rxR pipe:`); console.log(error); }); rxText.readable.pipeTo(this.rxBuffer.writable(), { signal: this.abort.signal }).catch((error) => { console.log(`Serial.onConnectAttempt rxText pipe:`); console.log(error); try { this.port?.close(); } catch (error2) { console.log(error2); } }); } } }; // src/io/EspruinoSerialDevice.ts var EspruinoSerialDevice = class extends Device { constructor(opts) { super(opts); this.evalReplyBluetooth = false; if (opts === void 0) opts = {}; this.evalTimeoutMs = opts.evalTimeoutMs ?? 5 * 1e3; } async disconnect() { return super.close(); } /** * Writes a script to Espruino. * * It will first send a CTRL+C to cancel any previous input, `reset()` to clear the board, * and then the provided `code` followed by a new line. * * Use {@link eval} instead to execute remote code and get the result back. * * ```js * // Eg from https://www.espruino.com/Web+Bluetooth * writeScript(` * setInterval(() => Bluetooth.println(E.getTemperature()), 1000); * NRF.on('disconnect',()=>reset()); * `); * ``` * * @param code Code to send. A new line is added automatically. */ writeScript(code) { this.write(`reset(); `); this.write(`${code} `); } /** * Sends some code to be executed on the Espruino. The result * is packaged into JSON and sent back to your code. An exception is * thrown if code can't be executed for some reason. * * ```js * const sum = await e.eval(`2+2`); * ``` * * It will wait for a period of time for a well-formed response from the * Espruino. This might not happen if there is a connection problem * or a syntax error in the code being evaled. In cases like the latter, * it will take up to `timeoutMs` (default 5 seconds) before we give up * waiting for a correct response and throw an error. * * Tweaking of the timeout may be required if `eval()` is giving up too quickly * or too slowly. A default timeout can be given when creating the class. * * Options: * timeoutMs: Timeout for execution. 5 seconds by default * assumeExclusive: If true, eval assumes all replies from controller are in response to eval. True by default * debug: If true, execution is traced via `warn` callback * @param code Code to run on the Espruino. * @param opts Options * @param warn Function to pass warning/trace messages to. If undefined, this.warn is used, printing to console. */ async eval(code, opts = {}, warn) { const debug = opts.debug ?? false; const warner = warn ?? ((m) => { this.warn(m); }); return deviceEval(code, opts, this, `USB.println`, debug, warner); } }; // src/io/Espruino.ts var puck = async (opts = {}) => { const name = opts.name ?? `Puck`; const debug = opts.debug ?? false; const device = await navigator.bluetooth.requestDevice({ filters: getFilters(opts, `Puck.js`), optionalServices: [defaultOpts.service] }); if (opts.debug) { console.info(`Espruino.puck device name: ${device.name}`); } const d = new EspruinoBleDevice(device, { name, debug }); await d.connect(); return d; }; var bangle = async (opts = {}) => { const name = opts.name ?? `Bangle`; const debug = opts.debug ?? false; const device = await navigator.bluetooth.requestDevice({ filters: getFilters(opts, `Bangle.js`), optionalServices: [defaultOpts.service] }); if (opts.debug) { console.info(`Espruino.bangle device name: ${device.name}`); } const d = new EspruinoBleDevice(device, { name, debug }); await d.connect(); return d; }; var serial = async (opts = {}) => { const d = new EspruinoSerialDevice(opts); await d.connect(); return d; }; var getFilters = (opts, defaultNamePrefix) => { const filters = []; if (opts.filters) { filters.push(...opts.filters); } else if (opts.name) { filters.push({ name: opts.name }); console.info(`Filtering Bluetooth devices by name '${opts.name}'`); } else { filters.push({ namePrefix: defaultNamePrefix }); } return filters; }; var connectBle = async (opts = {}) => { const device = await navigator.bluetooth.requestDevice({ filters: getFilters(opts, `Puck.js`), optionalServices: [defaultOpts.service] }); const d = new EspruinoBleDevice(device, { name: `Espruino`, ...opts }); await d.connect(); return d; }; var deviceEval = async (code, opts = {}, device, evalReplyPrefix, debug, warn) => { const timeoutMs = opts.timeoutMs ?? device.evalTimeoutMs; const assumeExclusive = opts.assumeExclusive ?? true; if (typeof code !== `string`) { throw new TypeError(`Param 'code' should be a string. Got: ${typeof code}`); } return new Promise((resolve, reject) => { const id = string(5); const onData = (d) => { try { let cleaned = d.data.trim(); if (cleaned.startsWith(`>{`) && cleaned.endsWith(`}`)) { cleaned = cleaned.slice(1); } const dd = JSON.parse(cleaned); if (`reply` in dd) { if (dd.reply === id) { done(); if (`result` in dd) { resolve(dd.result); } } else { warn(`Expected reply ${id}, got ${dd.reply}`); } } else { warn(`Expected packet, missing 'reply' field. Got: ${d.data}`); } } catch (error) { if (assumeExclusive) { done(`Unexpected reply: ${d.data}. Error: ${getErrorMessage(error)}`); } else { warn(getErrorMessage(error)); } } }; const onStateChange = (event) => { if (event.newState !== `connected`) { done(`State changed to '${event.newState}', aborting`); } }; device.addEventListener(`data`, onData); device.addEventListener(`change`, onStateChange); const done = waitFor( timeoutMs, (reason) => { reject(new Error(reason)); }, (_success) => { device.removeEventListener(`data`, onData); device.removeEventListener(`change`, onStateChange); } ); const source = `${evalReplyPrefix}(JSON.stringify({reply:"${id}", result:JSON.stringify(${code})})) `; if (debug) warn(source); device.write(source); }); }; // src/io/Camera.ts var Camera_exports = {}; __export(Camera_exports, { dumpDevices: () => dumpDevices, start: () => start }); var startTimeoutMs = 1e4; var dumpDevices = async (filterKind = `videoinput`) => { const devices = await navigator.mediaDevices.enumerateDevices(); for (const d of devices) { if (d.kind !== filterKind) continue; console.log(d.label); console.log(` Kind: ${d.kind}`); console.log(` Device id: ${d.deviceId}`); } }; var start = async (constraints = {}) => { const videoEl = document.createElement(`VIDEO`); videoEl.style.display = `none`; videoEl.playsInline = true; videoEl.muted = true; videoEl.classList.add(`ixfx-camera`); document.body.append(videoEl); let stopVideo = () => { }; const dispose = () => { try { stopVideo(); } catch { } videoEl.remove(); }; try { const r = await startWithVideoEl(videoEl, constraints); stopVideo = r.dispose; return { videoEl, dispose }; } catch (error) { console.error(error); dispose(); throw error; } }; var startWithVideoEl = async (videoEl, constraints = {}) => { if (videoEl === void 0) throw new Error(`videoEl undefined`); if (videoEl === null) throw new Error(`videoEl null`); const maxResolution = constraints.max; const minResolution = constraints.min; const idealResolution = constraints.ideal; const c = { audio: false, video: { width: {}, height: {} } }; if (constraints.facingMode === `front`) { constraints = { ...constraints, facingMode: `user` }; } if (constraints.facingMode === `back`) { constraints = { ...constraints, facingMode: `environment` }; } if (constraints.facingMode) { c.video.facingMode = constraints.facingMode; } if (constraints.deviceId) { c.video.deviceId = constraints.deviceId; } if (idealResolution) { c.video.width = { ...c.video.width, ideal: idealResolution.width }; c.video.height = { ...c.video.height, ideal: idealResolution.height }; } if (maxResolution) { c.video.width = { ...c.video.width, max: maxResolution.width }; c.video.height = { ...c.video.height, max: maxResolution.height }; } if (minResolution) { c.video.width = { ...c.video.width, min: minResolution.width }; c.video.height = { ...c.video.height, min: minResolution.height }; } const done = waitFor( constraints.startTimeoutMs ?? startTimeoutMs, (reason) => { throw new Error(`Camera getUserMedia failed: ${reason}`); } ); try { const stream = await navigator.mediaDevices.getUserMedia(c); const dispose = () => { videoEl.pause(); const t = stream.getTracks(); for (const track of t) track.stop(); }; videoEl.srcObject = stream; done(); const returnValue = { videoEl, dispose }; const p = new Promise((resolve, reject) => { videoEl.addEventListener(`loadedmetadata`, () => { videoEl.play().then(() => { resolve(returnValue); }).catch((error) => { reject(error); }); }); }); return p; } catch (error) { done(getErrorMessage(error)); throw error; } }; // src/io/VideoFile.ts var VideoFile_exports = {}; __export(VideoFile_exports, { start: () => start2 }); var start2 = async (file) => { const videoEl = document.createElement(`VIDEO`); videoEl.style.display = `none`; videoEl.playsInline = true; videoEl.muted = true; videoEl.classList.add(`ixfx-video`); document.body.appendChild(videoEl); let stopVideo = () => { }; const dispose = () => { try { stopVideo(); } catch { } videoEl.remove(); }; try { const r = await startWithVideoEl2(videoEl, file); stopVideo = r.dispose; return { videoEl, dispose }; } catch (ex) { console.error(ex); dispose(); throw ex; } }; var startWithVideoEl2 = async (videoEl, file) => { if (videoEl === void 0) throw new Error(`videoEl undefined`); if (videoEl === null) throw new Error(`videoEl null`); const url = URL.createObjectURL(file); videoEl.src = url; videoEl.loop = true; const dispose = () => { videoEl.pause(); }; const ret = { videoEl, dispose }; const p = new Promise((resolve, reject) => { videoEl.addEventListener(`loadedmetadata`, () => { videoEl.play().then(() => { resolve(ret); }).catch((ex) => { reject(ex); }); }); }); return p; }; // src/io/FrameProcessor.ts var FrameProcessor = class { /** * Create a new frame processor * @param opts */ constructor(opts = {}) { this._teardownNeeded = false; this._state = `ready`; this._source = ``; this._timer = performance.now(); this._showCanvas = opts.showCanvas ?? false; this._showPreview = opts.showPreview ?? false; this._cameraConstraints = opts.cameraConstraints ?? void 0; this._captureCanvasEl = opts.captureCanvasEl ?? void 0; this._postCaptureDraw = opts.postCaptureDraw; } /** * Hides or shows the raw source in the DOM * @param enabled Preview enabled */ showPreview(enabled) { if (this._state === `disposed`) throw new Error(`Disposed`); let el; switch (this._source) { case `camera`: { el = this._cameraStartResult?.videoEl; if (el !== void 0) el.style.display = enabled ? `block` : `none`; break; } } this._showPreview = enabled; } /** * Shows or hides the Canvas we're capturing to * @param enabled */ showCanvas(enabled) { if (this._state === `disposed`) throw new Error(`Disposed`); let el; if (this._source === `camera` || this._source === `video`) { el = this._videoSourceCapture?.canvasEl; if (el !== void 0) el.style.display = enabled ? `block` : `none`; } else throw new Error(`Source not implemented: ${this._source}`); this._showCanvas = enabled; } /** * Returns the current capturer instance * @returns */ getCapturer() { if (this._state === `disposed`) throw new Error(`Disposed`); if (this._source === `camera` || this._source === `video`) { return this._videoSourceCapture; } throw new Error(`Source kind not supported ${this._source}`); } /** * Grab frames from a video camera source and initialises * frame processor. * * If `constraints` are not specified, it will use the ones * provided when creating the class, or defaults. * * @param constraints Override of constraints when requesting camera access */ async useCamera(constraints) { if (this._state === `disposed`) throw new Error(`Disposed`); this._source = `camera`; if (this._teardownNeeded) this.teardown(); if (constraints) this._cameraConstraints; await this.init(); } async useVideo(file) { if (this._state === `disposed`) t