Repository: derkyjadex/M8WebDisplay Branch: master Commit: 6037d59f9322 Files: 39 Total size: 96.1 KB Directory structure: gitextract_wchr2nwj/ ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── app.webmanifest ├── css/ │ ├── common.scss │ ├── display.scss │ ├── firmware.scss │ ├── form.scss │ ├── index.scss │ └── settings.scss ├── index.html ├── js/ │ ├── audio.js │ ├── firmware.js │ ├── gl-renderer.js │ ├── hex.js │ ├── input.js │ ├── keyboard.js │ ├── main.js │ ├── parser.js │ ├── renderer.js │ ├── serial.js │ ├── settings.js │ ├── usb.js │ ├── util.js │ ├── wake.js │ ├── worker-setup.js │ └── worker.js ├── package.json └── shaders/ ├── blit.frag ├── blit.vert ├── rect.frag ├── rect.vert ├── text1.frag ├── text1.vert ├── text2.frag ├── text2.vert ├── wave.frag └── wave.vert ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ node_modules/ build/ cert/ deploy ================================================ FILE: LICENSE ================================================ Copyright 2021-2022 James Deery Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Copyright 2021-2022 James Deery # Released under the MIT licence, https://opensource.org/licenses/MIT DEPLOY = \ build/index.html \ build/worker.js \ app.webmanifest \ build/icon.png CACHE_FILES = \ build/index.html \ build/icon.png \ app.webmanifest DEPLOY_DIR = deploy/ NPM = node_modules/ ifeq ($(shell uname -s),Darwin) BASE64 = base64 -i MD5 = md5 else BASE64 = base64 -w0 MD5 = md5sum endif index.html: build/index.css js/main.js js/main.js: $(filter-out js/main.js,$(wildcard js/*.js)) build/shaders.js build/font1.js build/font2.js @touch $@ build/shaders.js: $(wildcard shaders/*.vert) $(wildcard shaders/*.frag) @echo Building $@ @mkdir -p $(@D) @for i in $^; do \ printf "export const $$(basename $${i} | tr . _) = \`"; \ sed 's/\/\/.*$$//g' $$i \ | perl -0pe 's/([\n;,{}()\[\]=+\-*\/])[ \t\r\n]+/$$1/g'; \ echo "\`;"; \ done > $@ build/font1.js: font1.png @echo Building $@ @mkdir -p $(@D) @printf "export const font1 = 'data:image/png;base64,$$($(BASE64) $^)';" > $@ build/font2.js: font2.png @echo Building $@ @mkdir -p $(@D) @printf "export const font2 = 'data:image/png;base64,$$($(BASE64) $^)';" > $@ build/main.js: js/main.js $(NPM) @echo Building $@ @mkdir -p $(@D) @npx rollup $< \ | npx terser --mangle --toplevel --compress > $@ build/worker.js: js/worker.js $(CACHE_FILES) $(NPM) @echo Building $@ @mkdir -p $(@D) @sed "s/INDEXHASH/$$(cat $(CACHE_FILES) | $(MD5))/" $< \ | npx terser --mangle --compress > $@ css/index.scss: $(filter-out css/index.scss,$(wildcard css/*.scss)) build/font1.scss build/font2.scss @touch $@ build/font1.scss: m8stealth57.woff2 @echo Building $@ @mkdir -p $(@D) @printf "@font-face {\n\ font-family: 'm8stealth57';\n\ src: url('data:font/woff2;base64,$$($(BASE64) $^)') format('woff2');\n\ }" > $@ build/font2.scss: m8stealth89.woff2 @echo Building $@ @mkdir -p $(@D) @printf "@font-face {\n\ font-family: 'm8stealth89';\n\ src: url('data:font/woff2;base64,$$($(BASE64) $^)') format('woff2');\n\ }" > $@ build/index.css: css/index.scss $(NPM) @echo Building $@ @mkdir -p $(@D) @npx sass --style=compressed $< > $@ build/index.html: index.html build/index.css build/main.js favicon.png $(NPM) @echo Building $@ @mkdir -p $(@D) @sed "s/BUILDNUM/$$(date -u +"%Y-%m-%dT%H:%M:%S") $$(git rev-parse --short HEAD)$$(test -z "$$(git status --porcelain)" || printf X)/" $< \ | sed -e 's/"build\/index.css"/"index.css"/' \ | sed -e 's/"js\/main.js"/"main.js"/' \ | sed -e 's|"favicon.png"|"data:image/png;base64,'$$($(BASE64) favicon.png)'"|' \ | sed -e 's/^ *//' \ | perl -0pe 's/>[ \t\r\n]+ $@.tmp @npx juice \ --apply-style-tags false \ --remove-style-tags false \ $@.tmp $@ @rm $@.tmp build/icon.png: icon.png @echo Building $@ @cp $< $@ cert/cert.conf: $(NPM) @echo Building $@ @mkdir -p cert @echo "[req]\n\ distinguished_name=dn\n\ req_extensions=ext\n\ prompt=no\n\ [dn]\n\ CN=DevCert\n\ OU=DEV\n\ [ext]\n\ keyUsage=nonRepudiation,digitalSignature,keyEncipherment\n\ basicConstraints=critical,CA:TRUE,pathlen:0\n\ subjectAltName=DNS:localhost,$$(\ npx ws --list-network-interfaces \ | grep '^-' \ | sed -E 's/^- .+: ([0-9.]+)$$/IP:\1/g' \ | sed -E 's/^- .+: (.+)$$/DNS:\1/g' \ | paste -sd ',' -)" > $@ cert/private-key.pem: @echo Building $@ @mkdir -p cert @openssl genrsa -out $@ 2048 cert/server.csr: cert/private-key.pem cert/cert.conf @echo Building $@ @openssl req \-new \ -nodes \ -sha256 \ -key cert/private-key.pem \ -config cert/cert.conf \ -out $@ cert/server.crt: cert/private-key.pem cert/cert.conf cert/server.csr @echo Building $@ @openssl x509 \ -req \ -sha256 \ -days 90 \ -in cert/server.csr \ -signkey cert/private-key.pem \ -extfile cert/cert.conf \ -extensions ext \ -out $@ $(NPM): @echo Installing node packages @npm ci all: $(DEPLOY) clean: @echo Cleaning @rm -r build/* ifeq ($(HTTPS),true) run: index.html cert/private-key.pem cert/server.crt $(NPM) @npx ws \ --log.format dev \ --rewrite '/worker.js -> /js/worker.js' \ --blacklist /cert/private-key.pem \ --key cert/private-key.pem \ --cert cert/server.crt else run: index.html $(NPM) @npx ws \ --log.format dev \ --rewrite '/worker.js -> /js/worker.js' \ --blacklist /cert/private-key.pem endif deploy: $(DEPLOY) @echo Deploying @mkdir -p $(DEPLOY_DIR) @rm -rf $(DEPLOY_DIR)/* @cp $^ $(DEPLOY_DIR) .PHONY: all run deploy clean ================================================ FILE: README.md ================================================ # M8 Headless Web Display This is alternative frontend for [M8 Headless](https://github.com/DirtyWave/M8HeadlessFirmware). It runs entirely in the browser and only needs to be hosted on a server to satisfy browser security policies. No network communication is involved. Try it out at https://derkyjadex.github.io/M8WebDisplay/. Features: - Render the M8 display - Route M8's audio out to the default audio output - Keyboard and gamepad input - Custom key/button mapping - Touch-compatible on-screen keys - Firmware loader - Full offline support - Installable as a [PWA](https://en.wikipedia.org/wiki/Progressive_web_application) ## Supported Platforms The following should generally work, details are below. - Chrome 89+ on macOS, Windows and Linux1 - Edge 89+ on macOS and Windows - Chrome on Android2, without audio3 The web display uses the Web Serial API to communicate with the M8. This API is currently only supported by desktop versions of Google Chrome and Microsoft Edge in versions 89 or later. For Chrome on Android the code can fallback to using the WebUSB API. 1. On Ubuntu and Debian systems (and perhaps others) users do not have permission to access the M8's serial port by default. You will need to add yourself to the `dialout` group and restart your login session/reboot. After this you should be able to connect normally. 2. Newer Samsung phones appear to handle USB serial in a way that prevents Chrome from being able to open the device. There is an [outstanding Chromium bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1099521#c21) to fix this. 3. The way that that Android handles USB audio devices (such as the M8) prevents us from being able to redirect the audio to the phone's speakers or headphone output. When the M8 is attached, Android appears to completely disable the internal audio interface and uses the M8 for all audio input and output instead. So the page is able to receive the audio from the M8 but it does not have anywhere to redirect it to other than the M8 itself. ## Developing To build this project you need a standard unix-like environment and a recent-ish version of [Node.js](https://nodejs.org/) (15.6 works, earlier versions might not). You should be able to build on macOS, Linux and [WSL](https://docs.microsoft.com/en-us/windows/wsl/) on Windows. From a fresh clone, run this in your terminal: ``` make run ``` This will download the necessary node packages, build the files required to run a debug version of the display and launch a local web server. If this is successful you can open http://localhost:8000/ in Chrome to launch the display. Press `ctrl-c` to stop the server. You can edit the `*.js` files and simply refresh the page to see the changes. If you edit the `*.scss` files or the shaders you will need to run `make` to regenerate the necessary files before refreshing. You can do this from another terminal window/tab, there is no need to restart the server. Chrome requires that pages are served securely in order to enable features such as the Serial API. Normally this means using HTTPS but there is an exception when you use `localhost`. If you want to test your changes on another computer on your network you will need to run the local web server with HTTPS: ``` make run HTTPS=true ``` This will generate a certificate and the local web server will now work from `https://:8000` (the full list of addresses is shown in the command output). When you use this address you will need to either ignore the security warning or install the certificate at `cert/server.crt` as a trusted Certificate Authority on your device. To build a release version of the display run: ``` make deploy ``` This will build and copy the release files to the `deploy/` directory. These files can be hosted on any static web server as long as has an HTTPS address. ## TODO/Ideas - Avoid/automatically recover from bad frames - Auto-reboot for firmware loader/real M8 support - Selectable audio output device ## Licence This code is released under the MIT licence. See LICENSE for more details. ================================================ FILE: app.webmanifest ================================================ { "name": "M8 Display", "short_name": "M8 Display", "description": "Web display for M8 Headless", "start_url": ".", "display": "standalone", "background_color": "#000", "theme_color": "#000", "icons": [ { "src": "icon.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" } ] } ================================================ FILE: css/common.scss ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT $m8: #00efff; $m8-font: 'm8stealth57'; $m8-font-big: 'm8stealth89'; $shield-z: 1000; ================================================ FILE: css/display.scss ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT @use 'common' as *; #display { position: relative; width: 100vw; height: calc(100vw * 3 / 4); max-width: calc(100vh * 4 / 3); max-height: 100vh; margin: 0 auto; top: 0; bottom: 0; left: 0; right: 0; user-select: none; canvas, svg { position: absolute; image-rendering: pixelated; width: 100%; height: 100%; } svg text { font-family: $m8-font; font-size: 16px; } svg.big text { font-family: $m8-font-big; font-size: 24px; } #buttons { position: absolute; top: 25px; width: 100%; text-align: center; body.mapping & { display: none; } } #mapping-buttons { display: none; position: absolute; top: 25px; width: 100%; text-align: center; body.mapping & { display: block; } button { margin: 0 5px 10px; } } &.with-controls, body.mapping & { height: calc(100vw * 6 / 4); max-width: calc(100vh * 4 / 6); canvas, svg { height: 50%; } #controls { display: block; } } #controls { display: none; position: relative; width: 100%; height: 50%; top: 50%; > div { position: absolute; width: 20.3125%; height: 27.0833%; border: 3px solid #aaa; border-radius: 10px; background-color: #333; box-sizing: border-box; padding: 5px; text-align: center; font-size: 80%; transition-property: border-color, background-color; transition-duration: 200ms; &.active { border-color: #0cf; background-color: #0cf; transition-duration: 0s; &[data-action="edit"] { border-color: #d48; background-color: #d48; } &[data-action="option"] { border-color: #66c; background-color: #66c; } &[data-action="select"] { border-color: #d73; background-color: #d73; } &[data-action="start"] { border-color: #5a3; background-color: #5a3; } } &.mapping { border-color: #e30; z-index: $shield-z + 1; } } } #mapping-help { display: none; position: absolute; bottom: 50%; width: 100%; font-family: $m8-font; text-align: center; body.mapping & { display: block; .select-action { display: block; } .enter-input { display: none; } } body.mapping.capturing & { .select-action { display: none; } .enter-input { display: block; position: relative; z-index: $shield-z + 1; } } } } #capture-shield { display: none; position: fixed; top: 0; bottom: 0; left: 0; right: 0; background-color: rgba(0, 0, 0, 0.75); z-index: $shield-z; body.mapping.capturing & { display: block; } } ================================================ FILE: css/firmware.scss ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT @use 'common' as *; #firmware { position: fixed; top: 0; bottom: 0; left: 0; right: 0; padding: 100px; background-color: rgba(0, 0, 0, 0.75); text-align: center; font-family: $m8-font; line-height: 1.5; &.hidden { display: none; } p.select-file-msg, input[type=file], p.file-loading-msg, p.file-error-msg, p.select-device-msg, button.select-device, p.device-error-msg, p.flash-msg, button.flash, p.flashing-msg, progress.flash, p.flash-error-msg, p.flash-success-msg { display: none; margin: 20px auto; } p.file-error-msg, p.device-error-msg, p.flash-error-msg { color: #e22; } &.file-select { p.select-file-msg, input[type=file] { display: block; } } &.file-loading { p.file-loading-msg { display: block; } button.close { display: none; } } &.file-error { p.select-file-msg, input[type=file], p.file-error-msg { display: block; } } &.file-loaded { p.select-device-msg, button.select-device { display: block; } } &.device-error { p.select-device-msg, button.select-device, p.device-error-msg { display: block; } } &.device-selected { p.flash-msg, button.flash { display: block; } } &.flashing { p.flashing-msg, progress.flash { display: block; } button.close { display: none; } } &.flash-error { p.select-device-msg, button.select-device, p.flash-error-msg { display: block; } } &.flash-success { p.flash-success-msg { display: block; } } button.close { display: block; position: absolute; right: 100px; bottom: 100px; } } ================================================ FILE: css/form.scss ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT @use 'common' as *; $menu-time: 80ms; label, input, select, button { appearance: none; font-family: $m8-font; font-size: 14px; background-color: #000; color: #fff; &:hover { background-color: $m8; color: #000; } &:active { background-color: darken($m8, 10%); color: #fff; } &:focus, &:focus-within { outline: none; color: $m8; &:hover { color: #000; } &:active { color: #fff; } } } button { border: 3px solid #fff; padding: 20px; } input[type=checkbox] { font-family: $m8-font; &::after { content: 'OFF'; } &:checked::after { content: 'ON'; } } input[type=file] { border: 3px solid #fff; padding: 20px; } select { padding: 5px 32px 5px 5px; background-color: transparent; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='3' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 10px center; background-size: 16px; font-family: sans-serif; font-size: 12px; } progress { border: 3px solid #fff; height: 40px; width: 100%; &::-webkit-progress-bar { background-color: #000; } &::-webkit-progress-value { background-color: $m8; } } ================================================ FILE: css/index.scss ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT @use 'common' as *; @use '../build/font1'; @use '../build/font2'; body { font-family: sans-serif; background-color: #000; color: #eee; margin: 0px; font-size: 16px; } a, a:visited { color: $m8; text-decoration: none; &:hover { text-decoration: underline; } &:focus { outline: 1px solid $m8; } } kbd { font-family: sans-serif; background-color: #444; color: $m8; font-size: 16px; margin: 0 2px; padding: 2px 5px; border-radius: 3px; } .hidden { display: none; } .build { color: #888; } @import 'form'; @import 'display'; @import 'settings'; @import 'firmware'; #info { position: absolute; top: 100px; left: 100px; right: 100px; padding: 20px; background-color: rgba(0, 0, 0, 0.75); text-align: center; line-height: 1.5; h1 { font-family: $m8-font; font-size: 32px; margin: 0 0 16px; } } .error { position: absolute; top: 100px; left: 100px; right: 100px; border: 3px solid #fff; padding: 20px; background-color: #a22; user-select: text; p { margin-top: 0; } p:last-child { margin-bottom: 0; } } #reload { position: fixed; bottom: 0; left: 0; right: 0; text-align: center; button { border-bottom-width: 0; } } ================================================ FILE: css/settings.scss ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT @use 'common' as *; #settings { position: fixed; top: 0; bottom: 0; left: 0; right: 0; background-color: rgba(0, 0, 0, 0.5); transition: background-color $menu-time linear 0s; user-select: none; &.hidden { display: block; width: 0; height: 0; background-color: rgba(0, 0, 0, 0); .setting { visibility: hidden; margin-left: -323px; transition: none; } #menu-button { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='1.5' d='M2 4h12M2 8h12M2 12h12'/%3e%3c/svg%3e"); } } &.hidden.auto-hide { #menu-button { opacity: 0%; transition: opacity 0.8s linear 0.8s; &:hover, &:focus { opacity: 100%; transition: opacity 0s; } } } #menu-button { width: 32px; height: 32px; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='1.5' d='M3 3l10 10M3 13l10-10'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-color: transparent; border-color: transparent; font-size: 0; margin-bottom: -3px; &:hover { background-color: $m8; } &:active { background-color: darken($m8, 10%); color: #fff; } &:focus { color: $m8; border-color: $m8; &:hover { color: #000; } &:active { color: #fff; } } } .setting { width: 320px; border: 3px solid #fff; border-width: 3px 3px 0 0; margin-left: 0; transition: margin-left $menu-time linear 0s; &:last-child { border-bottom-width: 3px; } label { display: block; position: relative; padding: 10px; input, select { position: absolute; top: 0; bottom: 0; right: 0; margin: 0; border: 0; } input[type=checkbox] { padding: 10px; } select { width: 50%; } } button { display: block; width: 100%; margin: 0; border: 0; padding: 10px; } } } ================================================ FILE: index.html ================================================ M8 Display

Click a key to add a new mapping

Press a keyboard key or gamepad button

M8 Display

This is a display for the M8 Headless firmware running on a Teensy 4.1, or a real M8 device. You can use keyboard and gamepad input or use on-screen controls. Where possible, the audio output from the M8 is routed to your default audio output device. Firmware can be installed and updated from the menu.

By default the arrow keys on your keyboard map to the arrow keys on the M8. Shift is Left Shift, Play is Space, Option is Z, and Edit is X. These control mappings and other settings can be configured from the menu.

A virtual keyboard from A to ' lets you send MIDI notes. Use -/= to change octaves and [/] to change velocity.

Now that this page has loaded it will work completely offline. All settings are stored locally by your browser.

Source code and more details are available at github.com/derkyjadex/M8WebDisplay.

Build BUILDNUM

================================================ FILE: js/audio.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { wait, on, off } from './util.js'; let ctx; let enabled = true; export async function start(attempts = 1) { if (ctx || !enabled) return; try { ctx = new AudioContext(); await navigator.mediaDevices.getUserMedia({ audio: true }); let deviceId; while (true) { deviceId = await findDeviceId(); if (deviceId) break; if (--attempts > 0) { await wait(300); } else { break; } } if (!deviceId) throw new Error('M8 not found'); const stream = await navigator.mediaDevices .getUserMedia({ audio: { deviceId: { exact: deviceId }, autoGainControl: false, echoCancellation: false, noiseSuppression: false } }) const source = ctx.createMediaStreamSource(stream); source.connect(ctx.destination); if (ctx.state !== 'running') { waitForUserGesture(); } } catch (err) { console.error(err); stop(); } if (!enabled) { stop(); } } async function findDeviceId() { const devices = await navigator.mediaDevices.enumerateDevices(); return devices .filter(d => d.kind === 'audioinput' && /M8/.test(d.label) && d.deviceId !== 'default' && d.deviceId !== 'communications') .map(d => d.deviceId)[0]; } export async function stop() { ctx && await ctx.close().catch(() => {}); ctx = null; } function waitForUserGesture() { const events = ['keydown', 'mousedown', 'touchstart']; function resume() { ctx && ctx.resume(); events.forEach(e => off(document, e, resume)); } events.forEach(e => on(document, e, resume)); } export function enable() { if (enabled) return; enabled = true; start(); } export function disable() { enabled = false; stop(); } ================================================ FILE: js/firmware.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { show, hide, toggle, on, wait } from './util.js'; import { readHexToBlocks } from './hex.js'; function setState(state) { document.getElementById('firmware').className = state; } export function open() { blocks = null; device = null; setState('file-select'); } async function flash(blocks, device, onProgress) { onProgress(0); await device.open(); try { const buffer = new Uint8Array(64 + 1024); for (let i = 0; i < blocks.length; i++) { if (i !== 0 && (!blocks[i] || blocks[i].every(b => b === 0xff))) continue; const addr = i * 1024; buffer[0] = addr & 0xff; buffer[1] = (addr >> 8) & 0xff; buffer[2] = (addr >> 16) & 0xff; buffer.set(blocks[i], 64); await device.sendReport(0, buffer); onProgress((i + 1) / blocks.length); await wait(i === 0 ? 1500 : 5); } buffer.fill(0); buffer[0] = 0xff; buffer[1] = 0xff; buffer[2] = 0xff; await device.sendReport(0, buffer); } finally { await device.close().catch(() => {}); } } function isTeensy(device) { const info = device.collections[0]; return info && info.usagePage === 0xff9c && info.usage === 0x25; } let blocks = null; let device = null; on('#firmware button.close', 'click', () => { blocks = null; device = null; document .querySelector('#firmware input') .value = null; setState('hidden'); }); on('#firmware input', 'change', async e => { blocks = null; const file = e.target.files[0]; if (!file) return; setState('file-loading'); try { blocks = await readHexToBlocks(file, 1024, 0x60000000); } catch (error) { console.error(error); setState('file-error'); return; } setState('file-loaded'); }); on('#firmware button.select-device', 'click', async () => { device = null; const result = await navigator.hid.requestDevice({ filters: [{ vendorId: 0x16c0, productId: 0x0478 }] }); device = result && result[0]; if (!device) return; if (isTeensy(device)) { setState('device-selected'); } else { device = null; setState('device-error'); } }); on('#firmware button.flash', 'click', async () => { const progress = document.querySelector('#firmware progress.flash'); setState('flashing'); try { await flash(blocks, device, p => progress.value = p); } catch (error) { console.error(error); setState('flash-error'); return; } setState('flash-success'); }); ================================================ FILE: js/gl-renderer.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import * as Shaders from '../build/shaders.js'; import { font1 } from '../build/font1.js'; import { font2 } from '../build/font2.js'; const MAX_RECTS = 1024; export class Renderer { _canvas; _gl; _bg = [0, 0, 0]; _frameQueued = false; _onBackgroundChanged; _fontConfig = [ //glyph x, y, hoffset, voffset [8,10,0,0], [10,12,0,-40] ]; _fontId = 0; constructor(bg, onBackgroundChanged) { this._bg = [bg[0] / 255, bg[1] / 255, bg[2] / 255]; this._onBackgroundChanged = onBackgroundChanged; this._canvas = document.getElementById('canvas') this._gl = this._canvas.getContext('webgl2', { alpha: false, antialias: false }); const gl = this._gl; this._setupRects(gl); this._setupText(gl); this._setupWave(gl); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.viewport(0,0, 320, 240); this._queueFrame(); } setFont(f) { if(this._fontId == f) return; this._fontId = f; const gl = this._gl; this._setupText(gl); } _rectShader; _rectVao; _rectShapes = new Uint16Array(MAX_RECTS * 6); _rectColours = new Uint8Array(this._rectShapes.buffer, 8); _rectCount = 0; _rectsClear = true; _rectsTex; _rectsFramebuffer; _blitShader; _setupRects(gl) { this._rectShader = buildProgram(gl, 'rect'); this._rectVao = gl.createVertexArray(); gl.bindVertexArray(this._rectVao); this._rectShapes.glBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._rectShapes.glBuffer); gl.bufferData(gl.ARRAY_BUFFER, this._rectShapes, gl.STREAM_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 4, gl.UNSIGNED_SHORT, false, 12, 0); gl.vertexAttribDivisor(0, 1); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.UNSIGNED_BYTE, true, 12, 8); gl.vertexAttribDivisor(1, 1); this._rectsTex = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this._rectsTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 320, 240, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); this._rectsFramebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._rectsTex, 0); this._blitShader = buildProgram(gl, 'blit'); gl.useProgram(this._blitShader); gl.uniform1i(gl.getUniformLocation(this._blitShader, 'src'), 0); } _renderRects(gl) { if (this._rectsClear) { gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer); gl.clearColor(this._bg[0], this._bg[1], this._bg[2], 1); gl.clear(gl.COLOR_BUFFER_BIT); this._rectsClear = false; } if (this._rectCount > 0) { gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer); gl.useProgram(this._rectShader); gl.bindVertexArray(this._rectVao); gl.bindBuffer(gl.ARRAY_BUFFER, this._rectShapes.glBuffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._rectShapes.subarray(0, this._rectCount * 6)); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this._rectCount); this._rectCount = 0; } gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.useProgram(this._blitShader); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } drawRect(x, y, w, h, r, g, b) { if (x === 0 && y === 0 && w >= 320 && h >= 240) { this._onBackgroundChanged(r, g, b); this._bg = [r / 255, g / 255, b / 255]; this._rectCount = 0; this._rectsClear = true; } else if (this._rectCount < MAX_RECTS) { const i = this._rectCount; this._rectShapes[i * 6 + 0] = x; this._rectShapes[i * 6 + 1] = y ? y+this._fontConfig[this._fontId][3] : y; this._rectShapes[i * 6 + 2] = w; this._rectShapes[i * 6 + 3] = h; this._rectColours[i * 12 + 0] = r; this._rectColours[i * 12 + 1] = g; this._rectColours[i * 12 + 2] = b; this._rectCount++; } if (this._rectCount >= MAX_RECTS) { this._renderRects(this._gl); } this._queueFrame(); } _textShader; _textVao; _textTex; _textColours = new Uint8Array(40 * 24 * 3); _textChars = new Uint8Array(40 * 24); _setupText(gl) { if(this._fontId == 0) { this._textShader = buildProgram(gl, 'text1'); } else { this._textShader = buildProgram(gl, 'text2'); } gl.useProgram(this._textShader); gl.uniform1i(gl.getUniformLocation(this._textShader, 'font'), 1); this._textVao = gl.createVertexArray(); gl.bindVertexArray(this._textVao); this._textColours.glBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._textColours.glBuffer); gl.bufferData(gl.ARRAY_BUFFER, this._textColours, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.UNSIGNED_BYTE, true, 0, 0); gl.vertexAttribDivisor(0, 1); this._textChars.glBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._textChars.glBuffer); gl.bufferData(gl.ARRAY_BUFFER, this._textChars, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 1, gl.UNSIGNED_BYTE, false, 0, 0); gl.vertexAttribDivisor(1, 1); this._textTex = gl.createTexture(); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this._textTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); if(this._fontId == 0) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 470, 7, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); const fontImage1 = new Image(); fontImage1.onload = () => { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this._textTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 470, 7, 0, gl.RGBA, gl.UNSIGNED_BYTE, fontImage1); this._queueFrame(); } fontImage1.src = font1; } else { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 752, 9, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); const fontImage2 = new Image(); fontImage2.onload = () => { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this._textTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 752, 9, 0, gl.RGBA, gl.UNSIGNED_BYTE, fontImage2); this._queueFrame(); } fontImage2.src = font2; } } _renderText(gl) { gl.useProgram(this._textShader); gl.bindVertexArray(this._textVao); if (this._textColours.updated) { gl.bindBuffer(gl.ARRAY_BUFFER, this._textColours.glBuffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._textColours); this._textColours.updated = false; } if (this._textChars.updated) { gl.bindBuffer(gl.ARRAY_BUFFER, this._textChars.glBuffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._textChars); this._textChars.updated = false; } gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 40 * 24); } drawText(c, x, y, r, g, b) { const i = Math.floor(y / this._fontConfig[this._fontId][1]) * 40 + Math.floor(x / this._fontConfig[this._fontId][0]); if(i >= 960) return; this._textChars[i] = c - 32; this._textChars.updated = true; this._textColours[i * 3 + 0] = r; this._textColours[i * 3 + 1] = g; this._textColours[i * 3 + 2] = b; this._textColours.updated = true; this._queueFrame(); } _waveData = new Uint8Array(320); _waveColour = new Float32Array([0.5, 1, 1]); _waveOn = false; _setupWave(gl) { this._waveShader = buildProgram(gl, 'wave'); this._waveShader.colourUniform = gl.getUniformLocation(this._waveShader, 'colour'); this._waveVao = gl.createVertexArray(); gl.bindVertexArray(this._waveVao); this._waveData.glBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._waveData.glBuffer); gl.bufferData(gl.ARRAY_BUFFER, this._waveData, gl.STREAM_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribIPointer(0, 1, gl.UNSIGNED_BYTE, 1, 0); } _renderWave(gl) { if (this._waveOn) { gl.useProgram(this._waveShader); gl.uniform3fv(this._waveShader.colourUniform, this._waveColour); gl.bindVertexArray(this._waveVao); if (this._waveData.updated) { gl.bindBuffer(gl.ARRAY_BUFFER, this._waveData.glBuffer); gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._waveData); this._waveData.updated = false; } gl.drawArrays(gl.POINTS, 0, 320); } } drawWave(r, g, b, data) { this._waveColour[0] = r / 255; this._waveColour[1] = g / 255; this._waveColour[2] = b / 255; if (data.length != 0) { this._waveData.fill(-1); this._waveData.set(data, 320-data.length); this._waveData.updated = true; this._waveOn = true; this._queueFrame(); } else if (this._waveOn) { this._waveOn = false; this._queueFrame(); } } _renderFrame() { const gl = this._gl; this._renderRects(gl); this._renderText(gl); this._renderWave(gl); this._frameQueued = false; } _queueFrame() { if (!this._frameQueued) { requestAnimationFrame(() => this._renderFrame()); this._frameQueued = true; } } clear() { this._rectsClear = true; this._rectCount = 0; this._textChars.fill(0); this._textChars.updated = true; this._waveOn = false; this._queueFrame(); } } function compileShader(gl, name, type) { const shader = gl.createShader(type); gl.shaderSource(shader, Shaders[name]); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(`Failed to compile shader (${name}): ${gl.getShaderInfoLog(shader)}`); return shader; } function linkProgram(gl, name, vertexShader, fragmentShader) { const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`Failed to link program (${name}): ${gl.getProgramInfoLog(program)}`); return program; } function buildProgram(gl, name) { return linkProgram( gl, name, compileShader(gl, `${name}_vert`, gl.VERTEX_SHADER), compileShader(gl, `${name}_frag`, gl.FRAGMENT_SHADER)); } ================================================ FILE: js/hex.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT const START_LINE = Symbol('START_LINE'); const HEX1 = Symbol('HEX1'); const HEX2 = Symbol('HEX2'); export async function readHexToBlocks(file, blockSize, offset) { const blocks = []; for await (let { address, data } of parseHex(file)) { address -= offset; if (address < 0) throw new HexFormatError(`Negative address after applying offset`); let blockIndex = Math.floor(address / blockSize); let dataIndex = 0; while (dataIndex < data.length) { let block = blocks[blockIndex]; if (!block) { block = blocks[blockIndex] = new Uint8Array(blockSize); block.fill(0xff); } const blockStart = blockIndex * blockSize; const blockEnd = blockStart + blockSize; const copyStart = address + dataIndex - blockStart; const copyEnd = address + data.length > blockEnd ? blockSize : copyStart + data.length - dataIndex; for (let i = copyStart; i < copyEnd; i++) { block[i] = data[dataIndex++]; } blockIndex++; } } return blocks; } async function* parseHex(file) { const buffer = new Uint8Array(260); let state = START_LINE; let line = 1; let char = 0; let bufferIndex = 0; let value = 0; let checksum = 0; let lineBytes = 1; let seenEnd = false; let baseAddress = 0; const reader = file.stream().getReader(); while (true) { const result = await reader.read(); if (!result.value) break; for (const byte of result.value) { char++; if (seenEnd) throw new HexFormatError( `Unexpected data after end record, line ${line} char ${char}`); switch (state) { case START_LINE: switch (byte) { case 0x3a: // : state = HEX1; break; default: throw new HexFormatError( `Expecting ':' at start of line ${line} char ${char}`); } break; case HEX1: if (byte === 0x0d) // \r continue; if (byte === 0x0a) { // \n if (bufferIndex < lineBytes) throw new HexFormatError( `Unexpected end of line on line ${line} char ${char}`); if (checksum !== 0) throw new HexFormatError( `Invalid checksum on line ${line}`); switch (buffer[3]) { case 0x00: yield { address: baseAddress + buffer[1] * 256 + buffer[2], data: buffer.subarray(4, lineBytes - 1) }; break; case 0x01: seenEnd = true; break; case 0x02: baseAddress = (buffer[4] * 256 + buffer[5]) << 4; break; case 0x03: break; case 0x04: baseAddress = (buffer[4] * 256 + buffer[5]) << 16; break; case 0x05: break; default: throw new HexFormatError( `Invalid record type on line ${line}`); } state = START_LINE; line++; char = 0; bufferIndex = 0; checksum = 0; lineBytes = 1; } else { if (bufferIndex >= lineBytes) throw new HexFormatError( `Record too long on line ${line} char ${char}`); const hexValue = fromHex(byte); if (hexValue === null) throw new HexFormatError( `Expecting hex character on line ${line} char ${char}`); value = hexValue * 16; state = HEX2; } break; case HEX2: const hexValue = fromHex(byte); if (hexValue === null) throw new HexFormatError( `Expecting hex character on line ${line} char ${char}`); value += hexValue; checksum = (checksum + value) & 0xFF; if (bufferIndex === 0) { lineBytes = value + 5; } buffer[bufferIndex++] = value; state = HEX1; break; } } if (result.done) break; } if (seenEnd) return; if (state != HEX1 || byte < lineBytes) throw new HexFormatError( `Unexpected end of file, line ${line} char ${char}`); if (checksum !== 0) throw new HexFormatError( `Invalid checksum on line ${line}`); const type = buffer[3]; if (type !== 0x01) throw new HexFormatError( `Missing end of file record, line ${line}`); } function fromHex(byte) { if (byte >= 0x30 && byte <= 0x39) return byte - 0x30; if (byte >= 0x41 && byte <= 0x46) return byte - 0x37; if (byte >= 0x61 && byte <= 0x66) return byte - 0x57; return null; } export class HexFormatError extends Error { constructor(...params) { super(...params); this.name = 'HexFormatError'; } } ================================================ FILE: js/input.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { appendButton, on, off } from './util.js'; import * as Settings from './settings.js'; import * as Keyboard from './keyboard.js'; let connection; let keyState = 0; const keyBitMap = { up: 6, down: 5, left: 7, right: 2, select: 4, start: 3, option: 1, edit: 0 }; const defaultInputMap = Object.freeze({ ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', ShiftLeft: 'select', Space: 'start', KeyZ: 'option', KeyX: 'edit', Gamepad12: 'up', Gamepad64: 'up', Gamepad13: 'down', Gamepad65: 'down', Gamepad14: 'left', Gamepad66: 'left', Gamepad15: 'right', Gamepad67: 'right', Gamepad8: 'select', Gamepad2: 'select', Gamepad5: 'select', Gamepad9: 'start', Gamepad3: 'start', Gamepad1: 'option', Gamepad0: 'edit' }); const inputMap = {}; function handleInput(input, isDown, e) { if (!input) return; if (resolveCapture) { e && e.preventDefault(); if (isDown) { resolveCapture(input); } return; } if (Keyboard.handleKey(input, isDown, e)) return; handleAction(inputMap[input], isDown, e); } function handleControl(isDown, e) { const action = e.target.dataset.action; if (!action) return; if (isMapping && isDown && !resolveCapture) { startMapKey(e.target, action); } else { handleAction(action, isDown, e); } } function handleAction(action, isDown, e) { if (!action) return; e && e.preventDefault(); const bit = keyBitMap[action]; if (bit === undefined) return; const newState = isDown ? keyState | (1 << bit) : keyState & ~(1 << bit); if (newState === keyState) return; keyState = newState; connection.sendKeys(keyState); document .querySelector(`#controls > [data-action="${action}"]`) .classList .toggle('active', isDown); } export function setup(connection_) { connection = connection_; Keyboard.setup(connection); on(document, 'keydown', e => handleInput(e.code, true, e)); on(document, 'keyup', e => handleInput(e.code, false, e)); const controls = document.getElementById('controls'); on(controls, 'mousedown', e => handleControl(true, e)); on(controls, 'touchstart', e => handleControl(true, e)); on(controls, 'mouseup', e => handleControl(false, e)); on(controls, 'touchend', e => handleControl(false, e)); appendButton('#mapping-buttons', 'Reset to Default', resetMappings); appendButton('#mapping-buttons', 'Clear All', clearMappings); appendButton('#mapping-buttons', 'Done', stopMapping); Object.assign( inputMap, Settings.load('inputMap', defaultInputMap)); } let gamepadsRunning = false; const gamepadStates = []; const hatMap = { 0: [true, false, false, false], 1: [true, false, false, true], 2: [false, false, false, true], 3: [false, true, false, true], 4: [false, true, false, false], 5: [false, true, true, false], 6: [false, false, true, false], 7: [true, false, true, false], 8: [false, false, false, false], 15: [false, false, false, false], }; function pollGamepads() { if (!gamepadsRunning) return; let somethingPresent = false; for (const gamepad of navigator.getGamepads()) { if (!gamepad || !gamepad.connected) continue; somethingPresent = true; let state = gamepadStates[gamepad.index]; if (!state) { state = gamepadStates[gamepad.index] = { buttons: [], axes: Array(gamepad.axes.length).fill(null).map(_ => ({})) }; } if (gamepad.mapping !== 'standard') { for (let i = 0; i < gamepad.axes.length; i++) { if (state.axes[i].isHat === false) continue; // Heuristics to locate a d-pad or // "hat switch" masquerading as an axis const value = (gamepad.axes[i] + 1) * 3.5; const error = Math.abs(Math.round(value) - value); const hatPosition = hatMap[Math.round(value)]; if (error > 4.8e-7 || hatPosition === undefined) { // definitely not a hat based on this value state.axes[i].isHat = false; continue; } else if (value === 0 && state.axes[i].isHat !== true) { // could be a hat but could also be an unpressed trigger continue; } else { // almost certainly a hat - we're very close to a "special" // value and we haven't seen any invalid values state.axes[i].isHat = true; } for (let b = 0; b < 4; b++) { const pressed = hatPosition[b]; if (state.buttons[64 + b] !== pressed) { state.buttons[64 + b] = pressed; handleInput(`Gamepad${64 + b}`, pressed); } } } } for (let i = 0; i < gamepad.axes.length; i++) { const value = gamepad.axes[i]; if (state.axes[i].isHat === true || Math.abs(value) > 1) continue; const negative = value <= -0.5; const positive = value >= 0.5; if (state.axes[i].negative !== negative) { state.axes[i].negative = negative; handleInput(`GamepadAxis${i}-`, negative); } if (state.axes[i].positive !== positive) { state.axes[i].positive = positive; handleInput(`GamepadAxis${i}+`, positive); } } for (let i = 0; i < gamepad.buttons.length; i++) { const pressed = gamepad.buttons[i].pressed; if (state.buttons[i] !== pressed) { state.buttons[i] = pressed; handleInput(`Gamepad${i}`, pressed); } } } if (somethingPresent) { requestAnimationFrame(pollGamepads); } else { gamepadsRunning = false; } } on(window, 'gamepadconnected', e => { if (e.gamepad.mapping !== 'standard') { console.warn('Non-standard gamepad attached. Mappings may be funny.'); } if (!gamepadsRunning) { gamepadsRunning = true; pollGamepads(); } }); on(window, 'gamepaddisconnected', e => { gamepadStates[e.gamepad.index] = null; }); export let isMapping = false; let resolveMapping = null; let resolveCapture = null; export function startMapping() { isMapping = true; document.body.classList.add('mapping'); return new Promise(resolve => { resolveMapping = resolve }); } export function stopMapping() { cancelCapture(); document.body.classList.remove('mapping'); isMapping = false; resolveMapping && resolveMapping(); } export function captureNextInput() { cancelCapture(); return new Promise( resolve => { resolveCapture = resolve; }) .then(input => { resolveCapture = null; return input; }); } export function cancelCapture() { resolveCapture && resolveCapture(null); } async function startMapKey(keyElement, action) { const cancel = e => { e.stopPropagation(); cancelCapture(); }; on(document.body, 'mousedown', cancel, true); on(document.body, 'touchstart', cancel, true); document.body.classList.add('capturing'); keyElement.classList.add('mapping'); try { const input = await captureNextInput(); if (input) { inputMap[input] = action; Settings.save('inputMap', inputMap); } } finally { keyElement.classList.remove('mapping'); document.body.classList.remove('capturing'); off(document.body, 'touchstart', cancel, true); off(document.body, 'mousedown', cancel, true); } } export function resetMappings() { for (const input of Object.keys(inputMap)) { delete inputMap[input]; } Object.assign(inputMap, defaultInputMap); Settings.save('inputMap', inputMap); } export function clearMappings() { for (const input of Object.keys(inputMap)) { delete inputMap[input]; } Settings.save('inputMap', inputMap); } ================================================ FILE: js/keyboard.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import * as Settings from './settings.js'; const keyMap = Object.freeze({ KeyA: 0, KeyW: 1, KeyS: 2, KeyE: 3, KeyD: 4, KeyF: 5, KeyT: 6, KeyG: 7, KeyY: 8, KeyH: 9, KeyU: 10, KeyJ: 11, KeyK: 12, KeyO: 13, KeyL: 14, KeyP: 15, Semicolon: 16, Quote: 17, BracketLeft: 'velDown', BracketRight: 'velUp', Minus: 'octDown', Equal: 'octUp' }); let connection; let enabled = true; let oct = 3; let vel = 103; let currentKey = null; export function handleKey(input, isDown, e) { if (!enabled || !e || e.ctrlKey || e.metaKey || e.altKey) return false; const key = keyMap[input]; if (key === undefined) return false; if (e.repeat) return true; switch (key) { case 'octDown': if (isDown) { oct = Math.max(oct - 1, 0); } break; case 'octUp': if (isDown) { oct = Math.min(oct + 1, 10); } break; case 'velDown': if (isDown) { vel = Math.max(vel - 8, 7); } break; case 'velUp': if (isDown) { vel = Math.min(vel + 8, 127); } break; default: const note = key + oct * 12; if (note > 128) return false; if (isDown) { currentKey = key; connection.sendNoteOn(note, vel); } else if (key === currentKey) { connection.sendNoteOff(); } break; } return true; } export function setup(connection_) { connection = connection_; Settings.onChange('virtualKeyboard', value => { enabled = value; connection.sendNoteOff(); }); } ================================================ FILE: js/main.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { UsbConnection } from './usb.js'; import { SerialConnection } from './serial.js'; import { Parser } from './parser.js'; import { Renderer as OldRenderer } from './renderer.js'; import { Renderer as GlRenderer } from './gl-renderer.js'; import { show, hide, toggle, appendButton, on } from './util.js'; import { setup as setupWorker } from './worker-setup.js'; import * as Input from './input.js'; import * as Audio from './audio.js'; import * as Settings from './settings.js'; import * as Firmware from './firmware.js'; import * as Wake from './wake.js'; function setBackground(r, g, b) { const colour = `rgb(${r}, ${g}, ${b})`; document.body.style.backgroundColor = colour; document.documentElement.style.backgroundColor = colour; Settings.save('background', [r, g, b]); } const bg = Settings.load('background', [0, 0, 0]); setBackground(bg[0], bg[1], bg[2]); const renderer = Settings.get('displayType') === 'webgl2' ? new GlRenderer(bg, setBackground) : new OldRenderer(bg, setBackground); const parser = new Parser(renderer); let resizeCanvas = (function() { const display = document.getElementById('display'); const canvas = document.getElementById('canvas'); function resize() { const ratio = devicePixelRatio; const dW = display.clientWidth * ratio; const svg = document.getElementById('screen'); if (Settings.get('snapPixels') && dW <= 1600) { let dH = display.clientHeight * ratio; if (Settings.get('showControls') || Input.isMapping) { dH /= 2; } const width = Math.floor(dW / 320) * 320 / ratio; const height = Math.floor(dH / 240) * 240 / ratio; const left = Math.round((dW / ratio - width) / 2); const top = Math.round((dH / ratio - height) / 2); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.style.left = `${left}px`; canvas.style.top = `${top}px`; if (svg) { svg.style.width = `${width}px`; svg.style.height = `${height}px`; svg.style.left = `${left}px`; svg.style.top = `${top}px`; } } else { canvas.style.width = null; canvas.style.height = null; canvas.style.left = null; canvas.style.top = null; if (svg) { svg.style.width = null; svg.style.height = null; svg.style.left = null; svg.style.top = null; } } } on(window, 'resize', resize); window.matchMedia('screen and (min-resolution: 2dppx)') .addListener(resize); resize(); return resize; })(); Settings.onChange('showControls', value => { document .getElementById('display') .classList .toggle('with-controls', value); resizeCanvas(); }); Settings.onChange('enableAudio', value => { if (value) { Audio.enable(); } else { Audio.disable(); } }); Settings.onChange('snapPixels', () => resizeCanvas()); Settings.onChange('controlMapping', () => { hide('#info'); Input.startMapping().then(resizeCanvas); resizeCanvas(); }); Settings.onChange('firmware', () => { hide('#info'); Firmware.open(); }); Settings.onChange('fullscreen', () => { if (document.fullscreenElement) { document.exitFullscreen(); } else { document.body.requestFullscreen(); } }); Settings.onChange('about', () => show('#info')); function connectionChanged(isConnected) { if (isConnected) { hide('#buttons, .error, #info'); Audio.start(10); } else { renderer.clear(); show('#buttons'); Audio.stop(); } Wake.connectionChanged(isConnected); } if (navigator.serial) { setupConnection( new SerialConnection(parser, connectionChanged), '#serial-fail'); } else if (navigator.usb) { setupConnection( new UsbConnection(parser, connectionChanged), '#usb-fail'); } else { show('#no-serial-usb'); } function setupConnection(connection, errorMessage) { Input.setup(connection); on('#connect', 'click', () => connection.connect() .catch(() => { hide('#info'); show(errorMessage); })); on(window, 'beforeunload', e => connection.disconnect()); connection.connect(true).catch(() => {}); } on('#info button', 'click', () => hide('#info')); setupWorker(); ================================================ FILE: js/parser.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT const NORMAL = Symbol('normal'); const ESCAPE = Symbol('escape'); const ERROR = Symbol('error'); const EMPTY = new Uint8Array(0); export class Parser { _state = NORMAL; _buffer = new Uint8Array(512); _i = 0; _renderer; constructor(renderer) { this._renderer = renderer; } _processFrame(frame) { switch (frame[0]) { case 0xfe: if (frame.length === 12) { this._renderer.drawRect( frame[1] + frame[2] * 256, frame[3] + frame[4] * 256, frame[5] + frame[6] * 256, frame[7] + frame[8] * 256, frame[9], frame[10], frame[11]); } else { console.log('Bad RECT frame'); } break; case 0xfd: if (frame.length === 12) { this._renderer.drawText( frame[1], frame[2] + frame[3] * 256, frame[4] + frame[5] * 256, frame[6], frame[7], frame[8]); } else { console.log('Bad TEXT frame'); } break; case 0xfc: // wave if (frame.length === 4) { this._renderer.drawWave( frame[1], frame[2], frame[3], EMPTY); } else if (frame.length <= 324) { this._renderer.drawWave( frame[1], frame[2], frame[3], frame.subarray(4)); } else { console.log('Bad WAVE frame'); } break; case 0xfb: // joypad if (frame.length !== 3) { console.log('Bad JPAD frame'); } break; case 0xff: // system this._renderer.setFont(frame[5]); break; default: console.log('BAD FRAME'); } } process(data) { for (let i = 0; i < data.length; i++) { const b = data[i]; switch (this._state) { case NORMAL: switch (b) { case 0xc0: this._processFrame(this._buffer.subarray(0, this._i)); this._i = 0; break; case 0xdb: this._state = ESCAPE; break; default: this._buffer[this._i++] = b; break; } break; case ESCAPE: switch (b) { case 0xdc: this._buffer[this._i++] = 0xc0; this._state = NORMAL; break; case 0xdd: this._buffer[this._i++] = 0xdb; this._state = NORMAL; break; default: this._state = ERROR; console.log('Unexpected SLIP sequence'); break; } break; case ERROR: switch (b) { case 0xc0: this._state = NORMAL; this._i = 0; console.log('SLIP recovered'); break; default: break; } } } } reset() { this._state = NORMAL; this._i = 0; } } ================================================ FILE: js/renderer.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT export class Renderer { _canvas; _ctx; _textNodes = []; _backgroundColour = 'rgb(0, 0, 0)'; _frameQueued = false; _rects = []; _waveColour = 'rgb(255, 255, 255)'; _waveData = new Uint8Array(320); _waveOn = false; _textUpdates = {}; _onBackgroundChanged; _fontConfig = [ //glyph x, y, hoffset, voffset [8,10,0,0], [10,12,0,-40] ]; _fontId = 0; constructor(bg, onBackgroundChanged) { this._backgroundColour = `rgb(${bg[0]}, ${bg[1]}, ${bg[2]})`; this._onBackgroundChanged = onBackgroundChanged; this._canvas = document.getElementById('canvas'); this._ctx = canvas.getContext('2d'); this._buildText(); } setFont(f) { if(this._fontId == f) return; this._fontId = f; this._buildText(); this.clear(); } _buildText() { const xmlns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(xmlns, 'svg'); const canvas = document.getElementById('canvas'); svg.setAttributeNS(null, 'viewBox', '0 0 640 480'); svg.setAttributeNS(null, 'id', 'screen'); svg.setAttributeNS(null, 'style', canvas.getAttribute('style')); if(this._fontId == 1) { svg.setAttributeNS(null, 'class', 'big'); } while (svg.firstChild) { svg.removeChild(svg.lastChild); } var start = 0; if(this._fontId == 1) { start = 3; } for (let y = start; y < 25; y++) { for (let x = 0; x < 39; x++) { const e = document.createElementNS(xmlns, 'text'); const x_offset = x * (this._fontConfig[this._fontId][0] * 2); var y_offset = 0; if(this._fontId == 1) { y_offset = ((y - 3) * (this._fontConfig[this._fontId][1] * 2))+(this._fontConfig[this._fontId][1] * 2) - 16; if(y == 3) { y_offset += 20; } } else { y_offset = (y * (this._fontConfig[this._fontId][1] * 2))+(this._fontConfig[this._fontId][1] * 2); } if(this._fontId == 1) { y_offset += 10; } e.setAttributeNS(null, 'x', x_offset); e.setAttributeNS(null, 'y', y_offset); e.setAttributeNS(null, 'fill', '_000'); const t = document.createTextNode(''); e.appendChild(t); svg.appendChild(e); this._textNodes[y * 39 + x] = { node: t, char: 32, fill: '_000' }; } } if (document.contains(document.getElementById('screen'))) { document.getElementById('screen').remove(); } this._canvas.insertAdjacentElement('afterend', svg); } _renderFrame() { for (let i = 0; i < this._rects.length; i++) { const rect = this._rects[i]; this._ctx.fillStyle = rect.colour; this._ctx.fillRect(rect.x, rect.y, rect.w, rect.h); } this._rects.length = 0; if (this._waveUpdated) { this._ctx.fillStyle = this._backgroundColour; this._ctx.fillRect(0, 0, 320, 21); if (this._waveOn) { this._ctx.fillStyle = this._waveColour; for (let i = 0; i < this._waveData.length; i++) { if(this._waveData[i] == 255) continue; const y = Math.min(this._waveData[i], 20); this._ctx.fillRect(i, y, 1, 1); } } } this._waveUpdated = false; for (const [_, update] of Object.entries(this._textUpdates)) { const node = update.node; if (update.char !== node.char) { node.node.nodeValue = String.fromCharCode(update.char); node.char = update.char; } if (update.fill !== node.fill) { node.node.parentElement.setAttributeNS(null, 'fill', update.fill); node.fill = update.fill; } } this._textUpdates = {}; this._frameQueued = false; } _queueFrame() { if (!this._frameQueued) { requestAnimationFrame(() => this._renderFrame()); this._frameQueued = true; } } drawRect(x, y, w, h, r, g, b) { const colour = `rgb(${r}, ${g}, ${b})` if (x === 0 && y === 0 && w >= 320 && h >= 240) { this._rects.length = 0; this._backgroundColour = colour; this._onBackgroundChanged(r, g, b); } if(this._fontId == 1) { y += (this._fontConfig[this._fontId][3]); } this._rects.push({ colour, x, y, w, h }); this._queueFrame(); } drawText(c, x, y, r, g, b) { const i = Math.floor(y / this._fontConfig[this._fontId][1]) * 39 + Math.floor(x / this._fontConfig[this._fontId][0]); if (this._textNodes[i]) { this._textUpdates[i] = { node: this._textNodes[i], char: c, fill: `rgb(${r}, ${g}, ${b})` }; this._queueFrame(); } } drawWave(r, g, b, data) { this._waveColour = `rgb(${r}, ${g}, ${b})` if (data.length != 0) { this._waveData.fill(-1); this._waveData.set(data, 320-data.length); this._waveOn = true; this._waveUpdated = true; this._queueFrame(); } else if (this._waveOn) { this._waveOn = false; this._waveUpdated = true; this._queueFrame(); } } clear() { this._rects = [{ colour: this._backgroundColour, x: 0, y: 0, w: 320, h: 240, }]; this._waveOn = false; this._waveUpdated = true; this._textUpdates = {}; for (let i = 0; i < this._textNodes.length; i++) { this._textUpdates[i] = { node: this._textNodes[i], char: 32, fill: `rgb(0, 0, 0)` }; } this._queueFrame(); } } ================================================ FILE: js/serial.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { wait, on } from './util.js'; export class SerialConnection { _port; _parser; _onConnectionChanged; _waitingForUserSelection; constructor(parser, onConnectionChanged) { this._parser = parser; this._onConnectionChanged = onConnectionChanged; this._waitingForUserSelection = false; on(navigator.serial, 'connect', e => { if (!this._waitingForUserSelection) { this.connect(true).catch(() => {}); } }); } get isConnected() { return !!this._port; } async _startReading() { try { while (this._port) { const { value, done } = await this._port.reader.read(); if (value) { try { this._parser.process(value); } catch (err) { console.error(err); } } if (done) return; } } catch (err) { console.error(err); this.disconnect(); } } async _send(msg) { if (!this._port || !this._port.writer) return; try { await this._port.writer.write(new Uint8Array(msg)); } catch (err) { console.error(err); this.disconnect(); } } async sendKeys(state) { this._send([0x43, state]); } async sendNoteOn(note, vel) { this._send([0x4B, note, vel]); } async sendNoteOff() { this._send([0x4B, 255]); } async _reset() { await this._port.writer.write(new Uint8Array([0x44])); await wait(50); this._parser.reset(); await this._port.writer.write(new Uint8Array([0x45, 0x52])); } async disconnect() { const port = this._port; if (!port) return; this._port = null; port.writer && await port.writer.write(new Uint8Array([0x44])).catch(() => {}); port.reader && await port.reader.cancel().catch(() => {}); await port.close().catch(() => {}); this._onConnectionChanged(false); } async connect(autoConnecting = false) { if (this._port) return; try { const ports = (await navigator.serial.getPorts()) .filter(p => { const info = p.getInfo(); return info.usbVendorId === 0x16c0 && info.usbProductId === 0x048a }); this._port = ports.length === 1 ? ports[0] : null; if (!this._port) { if (autoConnecting) { this._onConnectionChanged(false); } else { this._port = await this._requestPort(); } } if (!this._port) return; await this._port.open({ baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', bufferSize: 4096 }); this._port.reader = await this._port.readable.getReader(); this._port.writer = await this._port.writable.getWriter(); await this._reset(); this._startReading(); this._onConnectionChanged(true); } catch (err) { console.error(err); this.disconnect(err); throw err; } } async _requestPort() { this._waitingForUserSelection = true; try { return await navigator.serial.requestPort({ filters: [{ usbVendorId: 0x16c0, usbProductId: 0x048a }] }); } catch (err) { if (err.code !== DOMException.NOT_FOUND_ERR) { throw err; } else { return null; } } finally { this._waitingForUserSelection = false; } } } ================================================ FILE: js/settings.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { show, hide, toggle, appendButton, on } from './util.js'; on('#menu-button', 'click', () => toggle('#settings')); on('#settings', 'click', e => { if (e.target.id === 'settings') { hide('#settings'); } }); const actions = {}; const values = {}; setupToggle('showControls', 'Show Controls', false); setupToggle('hideMenu', 'Hide Menu', false); setupToggle('enableAudio', 'Enable Audio', true); setupSelect( 'displayType', 'Display Type', { webgl2: 'WebGL2', old: 'Canvas + SVG' }, 'webgl2'); setupToggle('snapPixels', 'Snap Pixels', true); setupToggle('virtualKeyboard', 'Virtual Keyboard', true); setupToggle('preventSleep', 'Prevent Sleep', false); setupButton('controlMapping', 'Control Mapping'); setupButton('firmware', 'Load Firmware'); setupButton('fullscreen', 'Fullscreen'); setupButton('about', 'About'); onChange('hideMenu', value => document .getElementById('settings') .classList .toggle('auto-hide', value)); function setupToggle(setting, title, defaultValue) { const value = load(setting, defaultValue); const div = document.createElement('div'); div.classList.add('setting'); const label = document.createElement('label'); label.innerText = title; div.append(label); const input = document.createElement('input'); input.setAttribute('type', 'checkbox'); input.checked = value; label.append(input); on(input, 'change', () => save(setting, input.checked)); document .getElementById('settings') .append(div); } function setupSelect(setting, title, options, defaultValue) { const value = load(setting, defaultValue); const div = document.createElement('div'); div.classList.add('setting'); const label = document.createElement('label'); label.innerText = title; div.append(label); const select = document.createElement('select'); for (const [value, title] of Object.entries(options)) { const option = document.createElement('option'); option.value = value; option.text = title; select.append(option); } select.value = value; label.append(select); on(select, 'change', () => save(setting, select.value)); document .getElementById('settings') .append(div); } function setupButton(setting, title) { const div = document.createElement('div'); div.classList.add('setting'); appendButton(div, title, () => { hide('#settings'); actions[setting] && actions[setting](); }); document .getElementById('settings') .append(div); } export function load(setting, defaultValue) { let value = localStorage[setting]; value = value === undefined ? defaultValue : JSON.parse(value); values[setting] = value; return value; } export function save(setting, value) { values[setting] = value; actions[setting] && actions[setting](value); localStorage[setting] = JSON.stringify(value); } export function onChange(setting, action) { actions[setting] = action; if (get(setting) !== undefined) { action(get(setting)); } } export function get(setting) { return values[setting]; } ================================================ FILE: js/usb.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { wait, on } from './util.js'; export class UsbConnection { _device; _parser; _onConnectionChanged; _waitingForUserSelection; constructor(parser, onConnectionChanged) { this._parser = parser; this._onConnectionChanged = onConnectionChanged; this._waitingForUserSelection = false; on(navigator.usb, 'connect', e => { if (!this._waitingForUserSelection) { this.connect(true).catch(() => {}); } }); } get isConnected() { return !!this._device; } async _startReading() { try { while (this._device) { const result = await this._device.transferIn(3, 512); if (result.status !== 'ok') { this.disconnect(); } else { this._parser.process(new Uint8Array(result.data.buffer)); } } } catch (err) { console.error(err); this.disconnect(); } } async _send(msg) { if (!this._device) return; try { await this._device.transferOut(3, new Uint8Array(msg)); } catch (err) { console.error(err); this.disconnect(); } } async sendKeys(state) { this._send([0x43, state]); } async sendNoteOn(note, vel) { this._send([0x4B, note, vel]); } async sendNoteOff() { this._send([0x4B, 255]); } async _reset() { await this._device.transferOut(3, new Uint8Array([0x44])); await wait(50); this._parser.reset(); await this._device.transferOut(3, new Uint8Array([0x45, 0x52])); } async disconnect() { const device = this._device; if (!device) return; this._device = null; await device.transferOut(3, new Uint8Array([0x44])).catch(() => {}); await device.close().catch(() => {}); this._onConnectionChanged(false); } async connect(autoConnecting = false) { if (this._device) return; try { const devices = (await navigator.usb.getDevices()) .filter(d => d.vendorId === 0x16c0 && d.productId === 0x048a); this._device = devices.length === 1 ? devices[0] : null; if (!this._device) { if (autoConnecting) { this._onConnectionChanged(false); } else { this._device = await this._requestDevice(); } } if (!this._device) return; await this._device.open(); await this._device.selectConfiguration(1); await this._device.claimInterface(1); await this._device.controlTransferOut( { requestType: 'class', recipient: 'interface', request: 0x22, value: 0x03, index: 0x01 }); await this._device.controlTransferOut( { requestType: 'class', recipient: 'interface', request: 0x20, value: 0x00, index: 0x01 }, new Uint8Array([0x80, 0x25, 0x00, 0x00, 0x00, 0x00, 0x08])); await this._reset(); this._startReading(); this._onConnectionChanged(true); } catch (err) { console.error(err); this.disconnect(err); throw err; } } async _requestDevice() { this._waitingForUserSelection = true; try { return await navigator.usb.requestDevice({ filters: [{ vendorId: 0x16c0, productId: 0x048a }] }); } catch (err) { if (err.code !== DOMException.NOT_FOUND_ERR) { throw err; } else { return null; } } finally { this._waitingForUserSelection = false; } } } ================================================ FILE: js/util.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT export function show(query) { document .querySelectorAll(query) .forEach(e => e.classList.remove('hidden')); } export function hide(query) { document .querySelectorAll(query) .forEach(e => e.classList.add('hidden')); } export function toggle(query) { document .querySelectorAll(query) .forEach(e => e.classList.contains('hidden') ? e.classList.remove('hidden') : e.classList.add('hidden')); } export function wait(time) { return new Promise(resolve => setTimeout(resolve, time)); } export function appendButton(target, title, onClick) { const button = document.createElement('button'); button.innerText = title; on(button, 'click', onClick); if (typeof target === 'string') { target = document.querySelector(target) } target.append(button); return button; } export function on(target, eventType, action, useCapture) { if (typeof target === 'string') { target = document.querySelectorAll(target); } else if (!(target instanceof Array)) { target = [target]; } for (const element of target) { element.addEventListener(eventType, action, useCapture); } } export function off(target, eventType, action, useCapture) { target.removeEventListener(eventType, action, useCapture); } ================================================ FILE: js/wake.js ================================================ // Copyright 2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { on } from './util.js'; import * as Settings from './settings.js'; let isConnected = false; let wakeLock = null; export function connectionChanged(isConnected_) { isConnected = isConnected_; updateLock(); } Settings.onChange('preventSleep', () => updateLock()); on(document, 'visibilitychange', () => updateLock()); async function updateLock() { if (!navigator.wakeLock) return; const shouldBeOn = isConnected && Settings.get('preventSleep') && document.visibilityState === 'visible'; const isOn = wakeLock && !wakeLock.released; if (!shouldBeOn && isOn) { wakeLock.release(); wakeLock = null; } else if (shouldBeOn && !isOn) { try { wakeLock = await navigator.wakeLock.request('screen'); on(wakeLock, 'release', () => updateLock()); } catch { wakeLock = null; } } } ================================================ FILE: js/worker-setup.js ================================================ // Copyright 2021-2022 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT import { show, hide, on } from './util.js'; const updateInterval = 30 * 60 * 1000; let reloading = false; function reload() { if (!reloading) { window.location.reload(); reloading = true; } } let reloadAction = () => {}; on('#reload button', 'click', () => reloadAction()); export async function setup() { on(navigator.serviceWorker, 'controllerchange', () => reload()); let firstInstall = !navigator.serviceWorker.controller; const reg = await navigator.serviceWorker.register('worker.js'); on(reg, 'updatefound', () => { if (firstInstall) { firstInstall = false; return; } const newWorker = reg.installing; on(newWorker, 'statechange', () => { if (newWorker.state === 'installed') { if (navigator.serviceWorker.controller) { reloadAction = () => newWorker.postMessage({ action: 'skipWaiting' }); } else { reloadAction = reload; } show('#reload'); } }); }); setInterval(() => reg.update(), updateInterval); } ================================================ FILE: js/worker.js ================================================ // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT const cacheName = 'INDEXHASH'; self.addEventListener('install', event => { event.waitUntil( caches.open(cacheName) .then(cache => cache.addAll(['.', 'icon.png', 'app.webmanifest']))); }); self.addEventListener('activate', event => event.waitUntil( caches.keys() .then(keys => Promise.all(keys .filter(key => key !== cacheName) .map(key => caches.delete(key)))))); self.addEventListener('fetch', event => event.respondWith( caches.match(event.request) .then(response => response || fetch(event.request)))); self.addEventListener('message', event => { if (event.data.action === 'skipWaiting') { self.skipWaiting(); } }); ================================================ FILE: package.json ================================================ { "dependencies": { "juice": "^7.0.0", "local-web-server": "^5.3.0", "rollup": "^2.38.1", "sass": "^1.32.5", "terser": "^5.5.1" } } ================================================ FILE: shaders/blit.frag ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT precision highp float; uniform sampler2D src; in vec2 srcCoord; out vec4 fragColour; void main() { fragColour = texelFetch(src, ivec2(srcCoord), 0); } ================================================ FILE: shaders/blit.vert ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT out vec2 srcCoord; const vec2 corners[] = vec2[]( vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)); void main() { vec2 pos = corners[gl_VertexID] * vec2(2.0, 2.0) + vec2(-1.0, -1.0); gl_Position = vec4(pos, 0.0, 1.0); srcCoord = corners[gl_VertexID] * vec2(320.0, 240.0); } ================================================ FILE: shaders/rect.frag ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT precision highp float; in vec3 colourV; out vec4 fragColour; void main() { fragColour = vec4(colourV, 1.0); } ================================================ FILE: shaders/rect.vert ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT layout(location = 0) in vec4 shape; layout(location = 1) in vec3 colour; out vec3 colourV; const vec2 corners[] = vec2[]( vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)); const vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0); const vec2 camOffset = vec2(-160.0, -120.0); void main() { vec2 pos = shape.xy; vec2 size = shape.zw; pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale; gl_Position = vec4(pos, 0.0, 1.0); colourV = colour; } ================================================ FILE: shaders/text1.frag ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT precision highp float; uniform sampler2D font; in vec2 fontCoord; in vec3 colourV; out vec4 fragColour; void main() { vec4 fontTexel = texelFetch(font, ivec2(fontCoord), 0); fragColour = vec4(colourV, fontTexel.r); } ================================================ FILE: shaders/text1.vert ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT layout(location = 0) in vec3 colour; layout(location = 1) in float char; out vec3 colourV; out vec2 fontCoord; const vec2 corners[] = vec2[]( vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)); const vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0); const vec2 camOffset = vec2(-160.0, -120.0); const vec2 size = vec2(5.0, 7.0); void main() { float row; float col = modf(float(gl_InstanceID) / 40.0, row) * 40.0; vec2 pos = vec2(col, row) * vec2(8.0, 10.0) + vec2(0.0, 3.0); pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale; gl_Position = vec4(char == 0.0 ? vec2(2.0) : pos, 0.0, 1.0); colourV = colour; fontCoord = (vec2(char - 1.0, 0.0) + corners[gl_VertexID]) * size; } ================================================ FILE: shaders/text2.frag ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT precision highp float; uniform sampler2D font; in vec2 fontCoord; in vec3 colourV; out vec4 fragColour; void main() { vec4 fontTexel = texelFetch(font, ivec2(fontCoord), 0); fragColour = vec4(colourV, fontTexel.r); } ================================================ FILE: shaders/text2.vert ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT layout(location = 0) in vec3 colour; layout(location = 1) in float char; out vec3 colourV; out vec2 fontCoord; const vec2 corners[] = vec2[]( vec2(0, 0), vec2(0, 1), vec2(1, 0), vec2(1, 1)); const vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0); const vec2 camOffset = vec2(-160.0, -120.0); const vec2 size = vec2(8.0, 9.0); void main() { float row; float col = modf(float(gl_InstanceID) / 40.0, row) * 40.0; row = row - 3.0; vec2 pos = vec2(col, row) * vec2(10.0, 12.0) + vec2(0.0, 0.0); if(row == 0.0) { pos = pos + vec2(0.0, 5.0); } pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale; gl_Position = vec4(char == 0.0 ? vec2(2.0) : pos, 0.0, 1.0); colourV = colour; fontCoord = (vec2(char - 1.0, 0.0) + corners[gl_VertexID]) * size; } ================================================ FILE: shaders/wave.frag ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT precision highp float; uniform vec3 colour; out vec4 fragColour; void main() { fragColour = vec4(colour, 1.0); } ================================================ FILE: shaders/wave.vert ================================================ #version 300 es // Copyright 2021 James Deery // Released under the MIT licence, https://opensource.org/licenses/MIT layout(location = 0) in uint value; const vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0); const vec2 camOffset = vec2(-160.0, -120.0); void main() { vec2 pos = vec2(float(gl_VertexID), float(value)); pos = (pos + vec2(0.5) + camOffset) * camScale; gl_PointSize = 1.0; gl_Position = vec4(pos, 0.0, 1.0); }