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]+</></g' > $@.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 Linux<sup>1</sup>
- Edge 89+ on macOS and Windows
- Chrome on Android<sup>2</sup>, without audio<sup>3</sup>
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://<your-computer-name>: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
================================================
<!DOCTYPE html>
<!--
Copyright 2021 James Deery
Released under the MIT licence, https://opensource.org/licenses/MIT
-->
<html lang="en">
<head>
<title>M8 Display</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#000" />
<link rel="shortcut icon" type="image/png" href="favicon.png">
<link rel="apple-touch-icon" href="icon.png">
<link rel="manifest" href="app.webmanifest">
<link rel="stylesheet" href="build/index.css">
</head>
<body>
<div id="display">
<canvas id="canvas" width="320" height="240"></canvas>
<div id="buttons" class="hidden">
<button id="connect">Connect</button>
</div>
<div id="mapping-buttons"></div>
<div id="mapping-help">
<p class="select-action">Click a key to add a new mapping</p>
<p class="enter-input">Press a keyboard key or gamepad button</p>
</div>
<div id="controls">
<div data-action="edit" style="top: 2.0833%; left: 76.5625%"></div>
<div data-action="option" style="top: 2.0833%; left: 53.125%"></div>
<div data-action="up" style="top: 6.25%; left: 25%"></div>
<div data-action="left" style="top: 37.5%; left: 1.5625%"></div>
<div data-action="down" style="top: 37.5%; left: 25%"></div>
<div data-action="right" style="top: 37.5%; left: 48.4375%"></div>
<div data-action="select" style="top: 70.8333%; left: 27.3438%"></div>
<div data-action="start" style="top: 70.8333%; left: 50.7813%"></div>
</div>
</div>
<div id="serial-fail" class="error hidden">
<p>Failed to connect over Serial.
Are you sure your Teensy is connected and the M8 Headless firmware is loaded?</p>
<p>Make sure that there are no other programs running which may have the serial port open.</p>
<p>On Linux you may also need make sure you have permission to open the serial port (eg. make yourself a member of the <code>dialout</code> group).</p>
<p>It is also possible there's a bug in this code.
There may be some messages in the developer console that will help with debugging.</a>
</div>
<div id="usb-fail" class="error hidden">
<p>Failed to connect with WebUSB.
Are you sure your Teensy is connected and the M8 Headless firmware is loaded?</p>
<p>Connecting with WebUSB is known not to work on Windows, Linux and some Samsung phones.
Please make sure you are using an up to date version of Chrome or Edge (89+).</p>
</div>
<div id="no-serial-usb" class="error hidden">
<p>Your browser doesn't appear to have Serial or WebUSB support.
These are only currently supported in Chrome and some Chrome-derived browsers.</p>
</div>
<div id="info">
<h1>M8 Display</h1>
<p>This is a display for the <a href="https://github.com/DirtyWave/M8HeadlessFirmware" target="_blank" rel="noopener">M8 Headless firmware</a> running on a Teensy 4.1, or a <a href="https://dirtywave.com/" target="_blank" rel="noopener">real M8 device</a>.
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.</p>
<p>By default the arrow keys on your keyboard map to the arrow keys on the M8.
Shift is <kbd>Left Shift</kbd>, Play is <kbd>Space</kbd>, Option is <kbd>Z</kbd>, and Edit is <kbd>X</kbd>.
These control mappings and other settings can be configured from the menu.</p>
<p>A virtual keyboard from <kbd>A</kbd> to <kbd>'</kbd> lets you send MIDI notes.
Use <kbd>-</kbd>/<kbd>=</kbd> to change octaves and <kbd>[</kbd>/<kbd>]</kbd> to change velocity.</p>
<p>Now that this page has loaded it will work completely offline.
All settings are stored locally by your browser.</p>
<p>Source code and more details are available at <a href="https://github.com/derkyjadex/M8WebDisplay" target="_blank" rel="noopener">github.com/<wbr>derkyjadex/<wbr>M8WebDisplay</a>.</p>
<button>OK</button>
<p class="build">Build BUILDNUM</p>
</div>
<div id="settings" class="hidden">
<button id="menu-button">Menu</button>
</div>
<div id="reload" class="hidden">
<button>An update is available. Click to reload.</button>
</div>
<div id="capture-shield"></div>
<div id="firmware" class="hidden">
<p class="select-file-msg">Select a firmware file</p>
<input type="file" accept=".hex">
<p class="file-loading-msg">Loading file...</p>
<p class="file-error-msg">The selected file does not appear to be a valid Teensy 4.1 firmware file</p>
<p class="select-device-msg">Connect your Teensy board and press the little button on the board</p>
<button class="select-device">Select Teensy Device</button>
<p class="device-error-msg">The selected device does not appear to be a Teensy 4.1</p>
<p class="flash-msg">Ready to flash</p>
<button class="flash">Flash</button>
<p class="flashing-msg">Flashing...</p>
<progress class="flash"></progress>
<p class="flash-error-msg">There was an error flashing the device. You can try again.</p>
<p class="flash-success-msg">Flashing completed successfully</p>
<button class="close">Close</button>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>
================================================
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);
}
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
SYMBOL INDEX (115 symbols across 15 files)
FILE: js/audio.js
function start (line 9) | async function start(attempts = 1) {
function findDeviceId (line 58) | async function findDeviceId() {
function stop (line 69) | async function stop() {
function waitForUserGesture (line 74) | function waitForUserGesture() {
function enable (line 87) | function enable() {
function disable (line 95) | function disable() {
FILE: js/firmware.js
function setState (line 7) | function setState(state) {
function open (line 11) | function open() {
function flash (line 17) | async function flash(blocks, device, onProgress) {
function isTeensy (line 50) | function isTeensy(device) {
FILE: js/gl-renderer.js
constant MAX_RECTS (line 8) | const MAX_RECTS = 1024;
class Renderer (line 10) | class Renderer {
method constructor (line 24) | constructor(bg, onBackgroundChanged) {
method setFont (line 47) | setFont(f) {
method _setupRects (line 64) | _setupRects(gl) {
method _renderRects (line 98) | _renderRects(gl) {
method drawRect (line 126) | drawRect(x, y, w, h, r, g, b) {
method _setupText (line 159) | _setupText(gl) {
method _renderText (line 219) | _renderText(gl) {
method drawText (line 238) | drawText(c, x, y, r, g, b) {
method _setupWave (line 255) | _setupWave(gl) {
method _renderWave (line 268) | _renderWave(gl) {
method drawWave (line 284) | drawWave(r, g, b, data) {
method _renderFrame (line 302) | _renderFrame() {
method _queueFrame (line 312) | _queueFrame() {
method clear (line 319) | clear() {
function compileShader (line 330) | function compileShader(gl, name, type) {
function linkProgram (line 341) | function linkProgram(gl, name, vertexShader, fragmentShader) {
function buildProgram (line 354) | function buildProgram(gl, name) {
FILE: js/hex.js
constant START_LINE (line 4) | const START_LINE = Symbol('START_LINE');
constant HEX1 (line 5) | const HEX1 = Symbol('HEX1');
constant HEX2 (line 6) | const HEX2 = Symbol('HEX2');
function readHexToBlocks (line 8) | async function readHexToBlocks(file, blockSize, offset) {
function fromHex (line 185) | function fromHex(byte) {
class HexFormatError (line 198) | class HexFormatError extends Error {
method constructor (line 199) | constructor(...params) {
FILE: js/input.js
function handleInput (line 51) | function handleInput(input, isDown, e) {
function handleControl (line 69) | function handleControl(isDown, e) {
function handleAction (line 82) | function handleAction(action, isDown, e) {
function setup (line 109) | function setup(connection_) {
function pollGamepads (line 158) | function pollGamepads() {
function startMapping (line 262) | function startMapping() {
function stopMapping (line 268) | function stopMapping() {
function captureNextInput (line 275) | function captureNextInput() {
function cancelCapture (line 285) | function cancelCapture() {
function startMapKey (line 289) | async function startMapKey(keyElement, action) {
function resetMappings (line 313) | function resetMappings() {
function clearMappings (line 321) | function clearMappings() {
FILE: js/keyboard.js
function handleKey (line 37) | function handleKey(input, isDown, e) {
function setup (line 91) | function setup(connection_) {
FILE: js/main.js
function setBackground (line 18) | function setBackground(r, g, b) {
function resize (line 36) | function resize() {
function connectionChanged (line 123) | function connectionChanged(isConnected) {
function setupConnection (line 151) | function setupConnection(connection, errorMessage) {
FILE: js/parser.js
constant NORMAL (line 4) | const NORMAL = Symbol('normal');
constant ESCAPE (line 5) | const ESCAPE = Symbol('escape');
constant ERROR (line 6) | const ERROR = Symbol('error');
constant EMPTY (line 8) | const EMPTY = new Uint8Array(0);
class Parser (line 10) | class Parser {
method constructor (line 16) | constructor(renderer) {
method _processFrame (line 20) | _processFrame(frame) {
method process (line 87) | process(data) {
method reset (line 143) | reset() {
FILE: js/renderer.js
class Renderer (line 4) | class Renderer {
method constructor (line 27) | constructor(bg, onBackgroundChanged) {
method setFont (line 36) | setFont(f) {
method _buildText (line 43) | _buildText() {
method _renderFrame (line 104) | _renderFrame() {
method _queueFrame (line 143) | _queueFrame() {
method drawRect (line 150) | drawRect(x, y, w, h, r, g, b) {
method drawText (line 164) | drawText(c, x, y, r, g, b) {
method drawWave (line 176) | drawWave(r, g, b, data) {
method clear (line 193) | clear() {
FILE: js/serial.js
class SerialConnection (line 6) | class SerialConnection {
method constructor (line 12) | constructor(parser, onConnectionChanged) {
method isConnected (line 24) | get isConnected() {
method _startReading (line 28) | async _startReading() {
method _send (line 49) | async _send(msg) {
method sendKeys (line 61) | async sendKeys(state) {
method sendNoteOn (line 65) | async sendNoteOn(note, vel) {
method sendNoteOff (line 69) | async sendNoteOff() {
method _reset (line 73) | async _reset() {
method disconnect (line 80) | async disconnect() {
method connect (line 94) | async connect(autoConnecting = false) {
method _requestPort (line 141) | async _requestPort() {
FILE: js/settings.js
function setupToggle (line 40) | function setupToggle(setting, title, defaultValue) {
function setupSelect (line 61) | function setupSelect(setting, title, options, defaultValue) {
function setupButton (line 89) | function setupButton(setting, title) {
function load (line 102) | function load(setting, defaultValue) {
function save (line 110) | function save(setting, value) {
function onChange (line 116) | function onChange(setting, action) {
function get (line 123) | function get(setting) {
FILE: js/usb.js
class UsbConnection (line 6) | class UsbConnection {
method constructor (line 12) | constructor(parser, onConnectionChanged) {
method isConnected (line 24) | get isConnected() {
method _startReading (line 28) | async _startReading() {
method _send (line 45) | async _send(msg) {
method sendKeys (line 57) | async sendKeys(state) {
method sendNoteOn (line 61) | async sendNoteOn(note, vel) {
method sendNoteOff (line 65) | async sendNoteOff() {
method _reset (line 69) | async _reset() {
method disconnect (line 76) | async disconnect() {
method connect (line 89) | async connect(autoConnecting = false) {
method _requestDevice (line 144) | async _requestDevice() {
FILE: js/util.js
function show (line 4) | function show(query) {
function hide (line 10) | function hide(query) {
function toggle (line 16) | function toggle(query) {
function wait (line 24) | function wait(time) {
function appendButton (line 28) | function appendButton(target, title, onClick) {
function on (line 42) | function on(target, eventType, action, useCapture) {
function off (line 54) | function off(target, eventType, action, useCapture) {
FILE: js/wake.js
function connectionChanged (line 10) | function connectionChanged(isConnected_) {
function updateLock (line 18) | async function updateLock() {
FILE: js/worker-setup.js
function reload (line 9) | function reload() {
function setup (line 19) | async function setup() {
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (104K chars).
[
{
"path": ".gitignore",
"chars": 35,
"preview": "node_modules/\nbuild/\ncert/\ndeploy\n\n"
},
{
"path": "LICENSE",
"chars": 1056,
"preview": "Copyright 2021-2022 James Deery\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis so"
},
{
"path": "Makefile",
"chars": 4504,
"preview": "# Copyright 2021-2022 James Deery\n# Released under the MIT licence, https://opensource.org/licenses/MIT\n\nDEPLOY = \\\n\tbui"
},
{
"path": "README.md",
"chars": 4137,
"preview": "# M8 Headless Web Display\n\nThis is alternative frontend for [M8 Headless](https://github.com/DirtyWave/M8HeadlessFirmwar"
},
{
"path": "app.webmanifest",
"chars": 347,
"preview": "{\n \"name\": \"M8 Display\",\n \"short_name\": \"M8 Display\",\n \"description\": \"Web display for M8 Headless\",\n \"start_url\": \""
},
{
"path": "css/common.scss",
"chars": 188,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n$m8: #00efff;\n$m8-"
},
{
"path": "css/display.scss",
"chars": 3696,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common'"
},
{
"path": "css/firmware.scss",
"chars": 2193,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *"
},
{
"path": "css/form.scss",
"chars": 1566,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *"
},
{
"path": "css/index.scss",
"chars": 1485,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *"
},
{
"path": "css/settings.scss",
"chars": 2804,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *"
},
{
"path": "index.html",
"chars": 5632,
"preview": "<!DOCTYPE html>\n<!--\n Copyright 2021 James Deery\n Released under the MIT licence, https://opensource.org/licenses/"
},
{
"path": "js/audio.js",
"chars": 2174,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait"
},
{
"path": "js/firmware.js",
"chars": 2851,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show, hid"
},
{
"path": "js/gl-renderer.js",
"chars": 12026,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport * as Shader"
},
{
"path": "js/hex.js",
"chars": 6181,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst START_LINE ="
},
{
"path": "js/input.js",
"chars": 8673,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { appe"
},
{
"path": "js/keyboard.js",
"chars": 1961,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport * as Settin"
},
{
"path": "js/main.js",
"chars": 4713,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { UsbC"
},
{
"path": "js/parser.js",
"chars": 4264,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst NORMAL = Sym"
},
{
"path": "js/renderer.js",
"chars": 6490,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nexport class Rende"
},
{
"path": "js/serial.js",
"chars": 4189,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait"
},
{
"path": "js/settings.js",
"chars": 3327,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show"
},
{
"path": "js/usb.js",
"chars": 4378,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait"
},
{
"path": "js/util.js",
"chars": 1462,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nexport functi"
},
{
"path": "js/wake.js",
"chars": 1016,
"preview": "// Copyright 2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { on } from"
},
{
"path": "js/worker-setup.js",
"chars": 1291,
"preview": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show"
},
{
"path": "js/worker.js",
"chars": 861,
"preview": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst cacheName = "
},
{
"path": "package.json",
"chars": 156,
"preview": "{\n \"dependencies\": {\n \"juice\": \"^7.0.0\",\n \"local-web-server\": \"^5.3.0\",\n \"rollup\": \"^2.38.1\",\n \"sass\": \"^1."
},
{
"path": "shaders/blit.frag",
"chars": 277,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\npr"
},
{
"path": "shaders/blit.vert",
"chars": 421,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nou"
},
{
"path": "shaders/rect.frag",
"chars": 235,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\npr"
},
{
"path": "shaders/rect.vert",
"chars": 610,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nla"
},
{
"path": "shaders/text1.frag",
"chars": 347,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\npr"
},
{
"path": "shaders/text1.vert",
"chars": 856,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nla"
},
{
"path": "shaders/text2.frag",
"chars": 347,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\npr"
},
{
"path": "shaders/text2.vert",
"chars": 946,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nla"
},
{
"path": "shaders/wave.frag",
"chars": 238,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\npr"
},
{
"path": "shaders/wave.vert",
"chars": 443,
"preview": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nla"
}
]
About this extraction
This page contains the full source code of the derkyjadex/M8WebDisplay GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (96.1 KB), approximately 25.6k tokens, and a symbol index with 115 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.