22 Commits

Author SHA1 Message Date
9266702cbb feat: add sentence wpm stage 2025-01-16 20:41:00 +01:00
77e2d2b20e feat: sentence trainer idle timeout 2025-01-16 17:50:52 +01:00
7819f546a6 fix: package manager 2025-01-16 17:15:25 +01:00
e37b38085d feat: sentence trainer prototype
feat: layout learner prototype
2025-01-16 17:12:56 +01:00
a3bf9ac32b 2.2.3 2025-01-15 11:31:47 +01:00
David Villafaña
5bd3245084 fix: typographical error (#156)
Co-authored-by: David Rog Desktop <dvillafanaiv@proton.me>
2025-01-15 11:30:46 +01:00
1cd2ec318a 2.2.2 2025-01-14 13:35:53 +01:00
6c8bfa0272 fix: ota update 2025-01-14 13:31:22 +01:00
f69be14b5e 2.2.1 2025-01-06 19:34:28 +01:00
dce554fc66 fix: set pnpm version in github actions correctly 2025-01-06 19:32:43 +01:00
f152dbdcf5 fix: set node/pnpm versions correctly 2025-01-06 19:31:04 +01:00
6a29e6a2fc 2.2.0 2025-01-06 19:25:45 +01:00
9bf3801fef Mark factory flash as wip 2025-01-06 19:25:27 +01:00
d2accfb838 Squash merge fix-vocabulary-export into master 2024-12-09 18:41:26 +01:00
b8a376b93b feat: update m4g 2024-12-09 18:35:05 +01:00
588719df91 feat: support factory flashing 2024-11-23 19:02:35 +01:00
6a0dad9dad feat: android support 2024-11-23 15:07:35 +01:00
f3704e4051 2.1.0 2024-11-20 22:26:59 +01:00
3e6298717e feat: m4gr 2024-11-19 22:25:01 +01:00
aced0bbbb7 feat: m4g support 2024-11-19 17:48:50 +01:00
Raymond Li
36874c59e3 Temporarily make chat available 2024-11-19 06:08:37 +00:00
9dc61a3482 fix: exclude pre-rendered ccos update pages 2024-11-08 16:04:50 +01:00
32 changed files with 1474 additions and 342 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
version: 9
- name: 🐉 Use Node.js 22.4.x
uses: actions/setup-node@v3
with:

View File

@@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
overlays = [
(import rust-overlay)
(final: prev: {
nodejs = prev.nodejs_22;
corepack = prev.corepack_22;
})
];
pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [
@@ -46,8 +52,8 @@
];
packages =
(with pkgs; [
nodejs_22
nodePackages.pnpm
nodejs
pnpm
rust-bin
fontMin
])

View File

@@ -110,6 +110,9 @@ const config = {
"experiment",
"code",
"dictionary",
"developer_board",
"developer_board_off",
"memory",
],
codePoints: {
speed: "e9e4",

View File

@@ -1,11 +1,11 @@
{
"name": "charachorder-device-manager",
"version": "2.0.2",
"version": "2.2.3",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
"node": ">=18.16",
"pnpm": ">=8.6"
"node": ">=22.4",
"pnpm": ">=9.4"
},
"repository": {
"type": "git",
@@ -62,6 +62,7 @@
"codemirror": "^6.0.1",
"cypress": "^13.13.2",
"d3": "^7.9.0",
"esptool-js": "^0.4.7",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.4",
"glob": "^11.0.0",
@@ -89,6 +90,7 @@
"vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
},
"type": "module"

30
pnpm-lock.yaml generated
View File

@@ -92,6 +92,9 @@ importers:
d3:
specifier: ^7.9.0
version: 7.9.0
esptool-js:
specifier: ^0.4.7
version: 0.4.7
flexsearch:
specifier: ^0.7.43
version: 0.7.43
@@ -173,6 +176,9 @@ importers:
vitest:
specifier: ^2.1.4
version: 2.1.4(@types/node@20.14.10)(jsdom@25.0.1)(sass@1.80.6)(terser@5.31.1)
web-serial-polyfill:
specifier: ^1.0.15
version: 1.0.15
workbox-window:
specifier: ^7.3.0
version: 7.3.0
@@ -1645,6 +1651,9 @@ packages:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
atob-lite@2.0.0:
resolution: {integrity: sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==}
autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14}
@@ -2250,6 +2259,9 @@ packages:
esm-env@1.0.0:
resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
esptool-js@0.4.7:
resolution: {integrity: sha512-xVwtSVDRsvjXSEvNFrorgJfB71RFFkZkL+hs7O7gW5hgPrKGywZxo2U5LJddzkJ6eE31QinNVyywc0OaSntZCw==}
esrap@1.2.2:
resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==}
@@ -3084,6 +3096,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -4060,6 +4075,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-serial-polyfill@1.0.15:
resolution: {integrity: sha512-usZN7kGRkEWr8DzRWxW+og55L1fHo4hNIwxCSCfWKpM+i0L+2AwzupMvkDFxnJNqUFOhLaD3PlgAOJxUOUrAoA==}
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@@ -5796,6 +5814,8 @@ snapshots:
at-least-node@1.0.0: {}
atob-lite@2.0.0: {}
autoprefixer@10.4.20(postcss@8.4.39):
dependencies:
browserslist: 4.24.2
@@ -6519,6 +6539,12 @@ snapshots:
esm-env@1.0.0: {}
esptool-js@0.4.7:
dependencies:
atob-lite: 2.0.0
pako: 2.1.0
tslib: 2.6.3
esrap@1.2.2:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@@ -7336,6 +7362,8 @@ snapshots:
pako@0.2.9: {}
pako@2.1.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -8336,6 +8364,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-serial-polyfill@1.0.15: {}
webidl-conversions@4.0.2: {}
webidl-conversions@7.0.0: {}

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "2.0.2"
version = "2.2.3"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "2.0.2" },
"package": { "productName": "amacc1ng", "version": "2.2.3" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

View File

@@ -3,35 +3,35 @@ col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -0,0 +1,37 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -13,7 +13,7 @@
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ReplayPlayer } from "./core/player";
import { TextPlugin } from "./core/plugins/text";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { text = $bindable("") } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new TextPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
text = value;
});
return unsubscribe;
});
</script>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import { WpmReplayPlugin } from "./core/plugins/wpm";
import type { ReplayPlayer } from "./core/player";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { wpm = $bindable(0) } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new WpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
}
}
}
console.log(this.tokens);
clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) {

View File

@@ -0,0 +1,23 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
private subscribers = new Set<(value: string) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const text = replay.stepper.text
.filter((it) => it.source !== "ghost")
.map((it) => it.text)
.join("");
for (const subscription of this.subscribers) {
subscription(text);
}
});
}
subscribe(subscription: (value: string) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -36,6 +36,7 @@ export class TextRenderer {
);
this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0");
this.cursorNode.setAttribute("class", "cursor");
this.svg.appendChild(this.cursorNode);
}

View File

@@ -11,6 +11,9 @@
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action",
);
let {
key,
@@ -47,6 +50,7 @@
]}
{@const hasIcon = !dynamicMapping && !!icon}
<text
class:hidden={$currentAction?.has(actionId) === false}
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"}
text-anchor="middle"
@@ -96,4 +100,8 @@
text:focus-within {
outline: none;
}
text.hidden {
opacity: 0.2;
}
</style>

View File

@@ -8,11 +8,14 @@
KeyboardEventHandler,
MouseEventHandler,
} from "svelte/elements";
import { type Writable } from "svelte/store";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config",
);
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
let {
i,
key,
@@ -35,6 +38,8 @@
<g
class="key-group"
class:highlight={$highlight?.has(key.id) === true}
class:faded={$highlight?.has(key.id) === false}
{onclick}
{onkeypress}
{onfocusin}
@@ -131,12 +136,14 @@
stroke-opacity: 0.3;
}
g.faded,
g:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
}
g.highlight,
g:focus-within {
color: var(--md-sys-color-primary);
outline: none;

View File

@@ -37,6 +37,10 @@
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
),
M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
@@ -70,6 +74,7 @@
width: 100%;
height: 100%;
max-height: 20cm;
}
fieldset {

View File

@@ -26,6 +26,7 @@ const KEY_COUNTS = {
LITE: 67,
X: 256,
M4G: 90,
M4GR: 90,
} as const;
if (
@@ -36,6 +37,13 @@ if (
await import("./tauri-serial");
}
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => {
@@ -126,9 +134,9 @@ export class CharaDevice {
await this.port.close();
this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version),
await this.send(1, ["VERSION"]).then(([version]) => version),
);
const [company, device, chipset] = await this.send(3, "ID");
const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
@@ -177,9 +185,12 @@ export class CharaDevice {
});
}
private async internalRead() {
private async internalRead(timeoutMs: number | undefined) {
try {
const { value } = await timeout(this.reader.read(), 5000);
const { value } =
timeoutMs !== undefined
? await timeout(this.reader.read(), timeoutMs)
: await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
@@ -270,14 +281,15 @@ export class CharaDevice {
*/
async send<T extends number>(
expectedLength: T,
...command: string[]
command: string[],
timeout: number | undefined = 5000,
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => {
await send(...command);
const commandString = command
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read();
const readResult = await read(timeout);
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -299,7 +311,7 @@ export class CharaDevice {
}
async getChordCount(): Promise<number> {
const [count] = await this.send(1, "CML C0");
const [count] = await this.send(1, ["CML", "C0"]);
return Number.parseInt(count);
}
@@ -307,7 +319,11 @@ export class CharaDevice {
* Retrieves a chord by index
*/
async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
const [actions, phrase] = await this.send(2, [
"CML",
"C1",
index.toString(),
]);
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
@@ -318,29 +334,30 @@ export class CharaDevice {
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(
1,
`CML C2 ${stringifyChordActions(actions)}`,
);
const [phrase] = await this.send(1, [
"CML",
"C2",
stringifyChordActions(actions),
]);
return phrase === "2" ? undefined : parsePhrase(phrase);
}
async setChord(chord: Chord) {
const [status] = await this.send(
1,
const [status] = await this.send(1, [
"CML",
"C3",
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
);
]);
if (status !== "0") console.error(`Failed with status ${status}`);
}
async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(
1,
`CML C4 ${stringifyChordActions(chord.actions)}`,
);
const status = await this.send(1, [
"CML",
"C4",
stringifyChordActions(chord.actions),
]);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
}
@@ -352,7 +369,13 @@ export class CharaDevice {
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
const [status] = await this.send(1, [
"VAR",
"B4",
`A${layer}`,
id.toString(),
action.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -363,7 +386,12 @@ export class CharaDevice {
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
const [position, status] = await this.send(2, [
"VAR",
"B3",
`A${layer}`,
id.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position);
}
@@ -376,7 +404,7 @@ export class CharaDevice {
* **This does not need to be called for chords**
*/
async commit() {
const [status] = await this.send(1, "VAR B0");
const [status] = await this.send(1, ["VAR", "B0"]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -387,10 +415,12 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(
1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
const [status] = await this.send(1, [
"VAR",
"B2",
id.toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -398,10 +428,11 @@ export class CharaDevice {
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(
2,
`VAR B1 ${id.toString(16).toUpperCase()}`,
);
const [value, status] = await this.send(2, [
"VAR",
"B1",
id.toString(16).toUpperCase(),
]);
if (status !== "0")
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -413,14 +444,14 @@ export class CharaDevice {
* Reboots the device
*/
async reboot() {
await this.send(0, "RST");
await this.send(0, ["RST"]);
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send(0, "RST BOOTLOADER");
await this.send(0, ["RST", "BOOTLOADER"]);
}
/**
@@ -429,7 +460,12 @@ export class CharaDevice {
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, `RST ${type}`);
await this.send(0, ["RST", type]);
}
async queryKey(): Promise<number> {
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
return Number(value);
}
/**
@@ -438,7 +474,7 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue.
*/
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
}
async updateFirmware(file: File | Blob): Promise<void> {

15
src/lib/util/shuffle.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
*/
export function shuffleInPlace<T>(array: T[]) {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!];
}
}
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
shuffleInPlace(result);
return result;
}

View File

@@ -27,9 +27,9 @@
external: true,
},
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
],
/*[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
],*/
] satisfies {

View File

@@ -2,6 +2,7 @@
import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import type { LoaderOptions, ESPLoader } from "esptool-js";
let { data } = $props();
@@ -9,7 +10,12 @@
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let terminalOutput = $state("");
let step = $state(0);
let eraseAll = $state(false);
let espLoader;
async function update() {
working = true;
@@ -18,7 +24,9 @@
const port = $serialPort!;
$serialPort = undefined;
try {
const file = await fetch(otaUrl!).then((it) => it.blob());
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota?.name}`,
).then((it) => it.blob());
await port.updateFirmware(file);
@@ -36,18 +44,7 @@
: undefined,
);
let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.device : undefined,
);
let uf2Url = $derived(
data.uf2
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.uf2.name}`
: undefined,
);
let otaUrl = $derived(
data.ota
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.ota.name}`
: undefined,
currentDevice ? currentDevice === data.meta.target : undefined,
);
/**
@@ -84,10 +81,12 @@
}
async function getFileSystem() {
if (!uf2Url) return;
const uf2Promise = fetch(uf2Url).then((it) => it.blob());
if (!data.meta.update.uf2) return;
const uf2Promise = fetch(
`${data.meta.path}/${data.meta.update.uf2.name}`,
).then((it) => it.blob());
const handle = await window.showSaveFilePicker({
id: `${data.device}-update`,
id: `${data.meta.target}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
@@ -102,21 +101,104 @@
await uf2.stream().pipeTo(writable);
step = 4;
}
async function espBootloader() {
$serialPort?.forget();
const port = await navigator.serial.requestPort();
port.open({ baudRate: 1200 });
}
async function connectEsp(port: SerialPort): Promise<ESPLoader> {
const esptool = data.meta.update.esptool!;
const { Transport, ESPLoader } = await import("esptool-js");
const espLoader = new ESPLoader({
transport: new Transport(port),
baudrate: 9600, // Number(esptool.baud),
romBaudrate: 9600, // Number(esptool.baud),
debugLogging: true,
terminal: {
clean: () => {
terminalOutput = "";
},
writeLine: (data) => {
terminalOutput += data + "\n";
},
write: (data) => {
terminalOutput += data;
},
},
} satisfies LoaderOptions);
await espLoader.detectChip(esptool.before);
if (!espLoader.IS_STUB) {
await espLoader.runStub();
}
return espLoader;
}
async function flashImages() {
const port = await navigator.serial.requestPort();
try {
const esptool = data.meta.update.esptool!;
espLoader = await connectEsp(port);
const fileArray = await Promise.all(
Object.entries(esptool.files).map(([offset, name]) =>
fetch(`${data.meta.path}/${name}`)
.then((it) => it.blob())
.then((it) => it.text())
.then((it) => ({
address: Number(offset),
data: it,
})),
),
);
await espLoader.writeFlash({
flashSize: esptool.flash_size,
flashMode: esptool.flash_mode,
flashFreq: esptool.flash_freq,
compress: true,
eraseAll,
fileArray,
});
} finally {
port.close();
}
}
async function eraseSPI() {
const port = await navigator.serial.requestPort();
try {
console.log(data.meta);
const spiFlash = data.meta.spi_flash!;
espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach(
(spiFlash.connection.clk << 0) |
(spiFlash.connection.q << 8) |
(spiFlash.connection.d << 16) |
(spiFlash.connection.cs << 24),
);
espLoader.flashId();*/
} finally {
port.close();
}
}
</script>
<div class="container">
<h2>
<a class="inline-link" href="/ccos">CCOS</a> /
<a
href="/ccos/{data.device}"
href="/ccos/{data.meta.target}"
class="device inline-link"
class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{data.device}</a
class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
>
/ <em class="version">{data.version}</em>
/ <em class="version">{data.meta.version}</em>
</h2>
{#if data.ota && !data.device.endsWith("m0")}
{#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
@@ -136,7 +218,7 @@
{$serialPort.chipset}</b
>
will be updated from <b class="version">{$serialPort.version}</b> to
<b class="version">{data.version}</b>
<b class="version">{data.meta.version}</b>
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
@@ -158,27 +240,6 @@
<h3>Manual Update</h3>
{/if}
<ul class="files">
{#if data.uf2}
<li>
<a target="_blank" download href={uf2Url}
>{data.uf2.name} <span class="icon">download</span><span class="size"
>{toByteUnit(data.uf2.size)}</span
></a
>
</li>
{/if}
{#if data.ota}
<li>
<a target="_blank" download href={otaUrl}
>{data.ota.name} <span class="icon">download</span><span class="size"
>{toByteUnit(data.uf2.size)}</span
></a
>
</li>
{/if}
</ul>
{#if isCorrectDevice === false}
<div transition:slide class="incorrect-device">
These files are incompatible with your device
@@ -186,7 +247,6 @@
{/if}
<section>
<h4>UF2 Instructions</h4>
<ol>
<li>
<button class="inline-button" onclick={connect}
@@ -227,6 +287,37 @@
</li>
</ol>
</section>
{#if data.meta.update.esptool}
<section>
<h3>Factory Flash (WIP)</h3>
<p>
If everything else fails, you can go through the same process that is
being used in the factory.
</p>
<p>
This will temporarily brick your device if the process is not done
completely or incorrectly.
</p>
<div class="esp-buttons">
<button onclick={espBootloader}
><span class="icon">memory</span>ESP Bootloader</button
>
<button onclick={flashImages}
><span class="icon">developer_board</span>Flash Images</button
>
<label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
>
<button onclick={eraseSPI}
><span class="icon">developer_board</span>Erase SPI Flash</button
>
</div>
<pre>{terminalOutput}</pre>
</section>
{/if}
</div>
<style lang="scss">
@@ -239,6 +330,10 @@
margin-block-start: 4em;
}
pre {
overflow: auto;
}
.primary {
color: var(--md-sys-color-primary);
}
@@ -249,6 +344,7 @@
.container {
width: calc(min(100%, 16cm));
overflow: auto;
}
@keyframes rotate {
@@ -402,4 +498,8 @@
.incorrect-device {
color: var(--md-sys-color-error);
}
.esp-buttons {
display: flex;
}
</style>

View File

@@ -1,20 +1,50 @@
import type { PageLoad } from "./$types";
import type { FileListing, Listing } from "../../listing";
import type { VersionMeta } from "./meta";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
);
const data: Listing[] = await result.json();
const meta: VersionMeta | undefined = data.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
).then((res) => res.json())
: undefined;
return {
uf2: data.find(
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
) as FileListing,
ota: data.find(
(entry) => entry.type === "file" && entry.name === "firmware.bin",
),
version: params.version,
device: params.device,
meta: {
version: meta?.version ?? params.version,
target: meta?.target ?? params.device,
path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
git_commit: meta?.git_commit ?? "",
git_is_dirty: meta?.git_is_dirty ?? false,
git_date: meta?.git_date ?? data[0]?.mtime ?? "",
public_build: meta?.public_build ?? !params.version.startsWith("."),
development_mode: meta?.development_mode ?? 0,
update: {
uf2:
(data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
) as FileListing) ?? undefined,
ota:
data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.ota ?? "firmware.bin"),
) ?? undefined,
esptool: meta?.update?.esptool ?? undefined,
},
files: data.filter(
(entry) =>
entry.type === "file" && (!meta?.files || entry.name in meta.files),
) as FileListing[],
spi_flash: meta?.spi_flash ?? undefined,
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,41 @@
export interface VersionMeta {
version: string;
target: string;
git_commit: string;
git_is_dirty: boolean;
git_date: string;
public_build: boolean;
development_mode: number;
update: {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface SPIFlashInfo {
type: string;
size: string;
connection: SPIConnection;
}
export interface SPIConnection {
clk: number;
q: number;
d: number;
hd: number;
cs: number;
}
export interface EspToolData {
chip: string;
baud: string;
before: string;
after: string;
flash_mode: string;
flash_freq: string;
flash_size: string;
files: Record<string, string>;
}

View File

@@ -43,7 +43,7 @@
buildIndex($chords, $osLayout).then(searchIndex.set);
});
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>, onlyPhrase: boolean = false) {
const plainPhrase: string[] = [""];
const extraActions: string[] = [];
const extraCodes: string[] = [];
@@ -103,6 +103,10 @@
return result ?? `0x${it.toString(16)}`;
});
if (onlyPhrase) {
return plainPhrase.join();
}
return [
...plainPhrase,
`+${input.join("+")}`,
@@ -182,7 +186,7 @@
function downloadVocabulary() {
const vocabulary = new Set(
$chords.map((it) =>
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "",
"phrase" in it ? encodeChord(it, $osLayout, true).trim() : "",
),
);
vocabulary.delete("");

View File

@@ -1,12 +1,13 @@
import type { Action } from "svelte/action";
import ConfirmChallenge from "./ConfirmChallenge.svelte";
import tippy from "tippy.js";
import { mount, unmount } from "svelte";
export const confirmChallenge: Action<
HTMLElement,
{ onConfirm: () => void; challenge: string }
> = (node, { onConfirm, challenge }) => {
let component: ConfirmChallenge | undefined;
let component: {} | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, {
interactive: true,
@@ -15,15 +16,22 @@ export const confirmChallenge: Action<
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active");
if (component === undefined) {
component = new ConfirmChallenge({ target, props: { challenge } });
component.$on("confirm", () => {
edit.hide();
onConfirm();
component = mount(ConfirmChallenge, {
target,
props: {
challenge,
onconfirm() {
edit.hide();
onConfirm();
},
},
});
}
},
onHidden() {
component?.$destroy();
if (component) {
unmount(component);
}
target?.classList.remove("active");
component = undefined;
},

View File

@@ -1,231 +1,24 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<ul>
<li><a href="/learn/layout/">Layout</a></li>
<li><a href="/learn/chords/">Chords</a></li>
<li><a href="/learn/sentence/">Sentences</a></li>
</ul>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
ul {
margin: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
list-style-type: none;
padding: 0;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
a {
border: 1px solid var(--md-sys-color-outline);
width: 128px;
height: 128px;
}
</style>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
}
</style>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable, derived } from "svelte/store";
import { layout } from "$lib/undo-redo";
import Action from "$lib/components/Action.svelte";
import { serialPort } from "$lib/serial/connection";
let hasStarted = $state(false);
setContext<VisualLayoutConfig>("visual-layout-config", {
scale: 50,
inactiveScale: 0.5,
inactiveOpacity: 0.4,
strokeWidth: 1,
margin: 5,
fontSize: 9,
iconFontSize: 14,
});
const actions = derived(layout, (layout) => {
const result = new Set<number>();
for (const layer of layout) {
for (const key of layer) {
result.add(key.action);
}
}
return [...result];
});
const currentAction = writable(0);
const expected = derived(
[layout, currentAction],
([layout, currentAction]) => {
const result: Array<{ layer: number; key: number }> = [];
for (let layer = 0; layer <= layout.length; layer++) {
if (layout[layer] === undefined) {
continue;
}
for (let key = 0; key <= layout[layer].length; key++) {
if (layout[layer][key]?.action === currentAction) {
result.push({ layer, key });
}
}
}
return result;
},
);
const highlight = derived(
expected,
(expected) => new Set(expected.map(({ key }) => key)),
);
const highlightAction = derived(
currentAction,
(currentAction) => new Set([currentAction]),
);
const currentLayer = writable(0);
setContext("highlight", highlight);
setContext("highlight-action", highlightAction);
setContext("active-layer", currentLayer);
async function next() {
console.log("Next");
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
if (nextAction !== undefined) {
currentAction.set(nextAction);
currentLayer.set($expected[0]?.layer ?? 0);
const key = await $serialPort?.queryKey();
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
console.log("Correct", key);
} else {
console.log("Incorrect", key);
}
next();
}
}
$effect(() => {
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
hasStarted = true;
next();
}
});
</script>
<section>
<div class="challenge">
<Action display="inline-keys" action={$currentAction}></Action>
</div>
<Layout />
</section>
<style lang="scss">
.challenge {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100px;
font-size: 24px;
}
.input {
width: 100%;
height: 100px;
border: 1px solid black;
}
section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,580 @@
<script lang="ts">
import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
import { onMount } from "svelte";
import TrackText from "$lib/charrecorder/TrackText.svelte";
import { browser } from "$app/environment";
import { expoIn, expoOut } from "svelte/easing";
function viaLocalStorage<T>(key: string, initial: T) {
try {
return JSON.parse(localStorage.getItem(key) ?? "");
} catch {
return initial;
}
}
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"],
[3000, 2500, "Pairs"],
[5000, 3500, "Trios"],
]),
);
const avgWordLength = 5;
function reset() {
localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout");
window.location.reload();
}
let inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
);
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
);
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true",
);
let sentenceWords = $derived(inputSentence.split(" "));
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
let totalMs = $derived(inputSentence.length * msPerChar);
let msPerWord = $derived(
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
);
let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>();
let wordMastery = new SvelteMap<string, number>();
let text = $state("");
let level = $state(0);
let bestWPM = $state(0);
let wpm = $state(0);
let chords: InferredChord[] = $state([]);
let recorder = $state(new ReplayRecorder());
let idle = $state(true);
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
let cooldown = $state(false);
onMount(() => {
selectNextWord();
});
$effect(() => {
if (wpm > bestWPM) {
bestWPM = wpm;
}
});
$effect(() => {
localStorage.setItem("idle-timeout", idleTime.toString());
});
$effect(() => {
localStorage.setItem(
"mastery-thresholds",
JSON.stringify(masteryThresholds),
);
});
let words = $derived.by(() => {
const words = inputSentence.trim().split(" ");
switch (level) {
case 0: {
shuffleInPlace(words);
return words;
}
case 1: {
const pairs = [];
for (let i = 0; i < words.length - 1; i++) {
pairs.push(`${words[i]} ${words[i + 1]}`);
}
shuffleInPlace(pairs);
return pairs;
}
case 2: {
const trios = [];
for (let i = 0; i < words.length - 2; i++) {
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
}
shuffleInPlace(trios);
return trios;
}
default: {
return [inputSentence];
}
}
});
$effect(() => {
for (const [word, speeds] of wordStats.entries()) {
const level = word.split(" ").length - 1;
const masteryThreshold = masteryThresholds[level];
if (masteryThreshold === undefined) continue;
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
wordMastery.set(
word,
1 -
Math.min(
1,
Math.max(
0,
(averageSpeed - masteryThreshold[1]) /
(masteryThreshold[0] - masteryThreshold[1]),
),
),
);
}
});
let progress = $derived(
level === masteryThresholds.length
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
: words.length > 0
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
words.length
: 0,
);
let mastered = $derived(
words.length > 0
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
: 0,
);
$effect(() => {
if (progress === 1 && level < masteryThresholds.length) {
level++;
}
});
function selectNextWord() {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
currentWord = nextWord;
recorder = new ReplayRecorder(nextWord);
}
function checkInput() {
if (recorder.player.stepper.challenge.length === 0) return;
const replay = recorder.finish(false);
const elapsed = replay.finish - replay.start! - idleTime;
if (elapsed < masteryThresholds[level]![0]) {
const prevStats = wordStats.get(currentWord) ?? [];
prevStats.push(elapsed);
wordStats.set(currentWord, prevStats.slice(-10));
}
text = "";
setTimeout(() => {
selectNextWord();
});
}
$effect(() => {
if (!idle || !text) return;
if (text.trim() !== currentWord.trim()) return;
if (level === masteryThresholds.length) {
const replay = recorder.finish();
const elapsed = replay.finish - replay.start!;
text = "";
recorder = new ReplayRecorder(currentWord);
console.log(elapsed, totalMs);
wpm = (totalMs / elapsed) * wpmTarget;
} else {
checkInput();
}
});
function onkey(event: KeyboardEvent) {
if (idleTimeout) {
clearTimeout(idleTimeout);
}
idle = false;
recorder.next(event);
idleTimeout = setTimeout(() => {
idle = true;
}, idleTime);
}
</script>
<div>
<h1>Sentence Trainer</h1>
<div class="levels">
{#each masteryThresholds as [, , title], i}
<button
class:active={level === i}
class:mastered={i < level || progress === 1}
class="threshold"
onclick={() => {
level = i;
selectNextWord();
}}
>
{title}
</button>
{/each}
<button
class:active={level === masteryThresholds.length}
class:mastered={masteryThresholds.length < level || progress === 1}
class="threshold"
onclick={() => {
level = masteryThresholds.length;
selectNextWord();
}}
>
{wpmTarget} WPM
</button>
{#each masteryThresholds as _, i}
<div
class="progress"
style:--progress="{-100 *
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
style:--mastered="{-100 *
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
class:active={level === i}
></div>
{/each}
<div
class="progress"
style:--progress="-100%"
style:--mastered="{-100 *
(1 -
(level === masteryThresholds.length
? progress
: masteryThresholds.length < level
? 1
: 0))}%"
class:active={level === masteryThresholds.length}
></div>
</div>
<div class="sentence">
{#each sentenceWords as _, i}
{#if i !== sentenceWords.length - 1}
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 2) + 1}
style:grid-column="{i + 1} / span 2"
style:border-bottom="none"
></div>
{/if}
{/each}
{#each sentenceWords as word, i}
{@const mastery = wordMastery.get(word)}
<div
class="word"
class:mastered={mastery === 1}
style:opacity={mastery ?? 0}
style:grid-row={3}
style:grid-column={i + 1}
>
{word}
</div>
{/each}
{#each sentenceWords as _, i}
{#if i < sentenceWords.length - 2}
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 3) + 4}
style:grid-column="{i + 1} / span 3"
style:border-top="none"
></div>
{/if}
{/each}
</div>
{#if level === masteryThresholds.length}
{@const maxDigits = 4}
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
<div class="finish" transition:slide>
<div
class="wpm"
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
style:opacity={progress}
style:font-size="3rem"
style:color="var(--md-sys-color-{progress === 1
? 'primary'
: 'on-background'})"
style:scale={(progress + 0.5) / 2}
>
{#each indices as i}
{@const char = wpmString[i]}
{#key char}
<div
style:grid-column={i + 1}
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
>
{char}
</div>
{/key}
{/each}
<div style:grid-column={maxDigits + 3} style:justify-self="start">
WPM
</div>
</div>
<div
class="wpm"
style:grid-template-columns="4ch 1ch auto"
style:font-size="1.5rem"
>
{#key wpm}
<div
style:grid-column={1}
style:justify-self="end"
transition:fade={{ duration: 200 }}
>
{Math.floor(wpm)}
</div>
{/key}
<div style:grid-column={3} style:justify-self="start">WPM</div>
</div>
</div>
{/if}
<ChordHud {chords} />
<div class="container">
<div
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
tabindex="0"
role="textbox"
>
{#key recorder}
<div class="input" transition:fade={{ duration: 200 }}>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackText bind:text />
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
</div>
</div>
{#if devTools}
<div>Dev Tools</div>
<button onclick={reset}>Reset</button>
<label>Idle Time <input bind:value={idleTime} /></label>
<table>
<tbody>
<tr>
<th>Total</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span
>ms</td
>
</tr>
<tr>
<th>Char</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span
>ms</td
>
</tr>
<tr>
<th>Word</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span
>ms</td
>
</tr>
</tbody>
</table>
<table>
<tbody>
{#each masteryThresholds as _, i}
<tr>
<th>L{i + 1}</th>
<td><input bind:value={masteryThresholds[i]![0]} /></td>
<td><input bind:value={masteryThresholds[i]![1]} /></td>
<td><input bind:value={masteryThresholds[i]![2]} /></td>
</tr>
{/each}
</tbody>
</table>
<table>
<tbody>
{#each wordStats.entries() as [word, stats]}
{@const mastery = wordMastery.get(word) ?? 0}
<tr>
<th>{word}</th>
<td
style:color="var(--md-sys-color-{mastery === 1
? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td
>
{#each stats as stat}
<td>{stat}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style lang="scss">
.levels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
button {
margin: 0;
font-size: 1rem;
}
}
.wpm {
width: min-content;
display: grid;
transition: scale 0.2s ease;
* {
grid-row: 1;
}
}
.finish {
display: grid;
grid-template-rows: repeat(2, 1fr);
font-weight: bold;
justify-items: center;
align-items: center;
}
.sentence {
display: grid;
width: min-content;
gap: 4px 1ch;
grid-template-rows: repeat(4, auto);
margin-block: 1rem;
.word,
.arch {
transition: opacity 0.2s ease;
&.mastered {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
}
.arch {
border: 2px solid var(--md-sys-color-outline);
height: 8px;
}
}
.progress {
position: relative;
height: 1rem;
width: auto;
background: var(--md-sys-color-outline-variant);
border: none;
overflow: hidden;
grid-row: 2;
&::before,
&::after {
position: absolute;
content: "";
display: block;
height: 100%;
width: 100%;
transition: transform 0.2s;
}
&::before {
background: var(--md-sys-color-outline);
transform: translateX(var(--progress));
}
&::after {
background: var(--md-sys-color-primary);
transform: translateX(var(--mastered));
}
}
.threshold {
width: auto;
justify-self: center;
opacity: 0.5;
transition: opacity 0.2s;
grid-row: 1;
&.mastered,
&.active {
opacity: 1;
}
&.mastered {
color: var(--md-sys-color-primary);
}
}
.input-section {
display: grid;
cursor: text;
:global(.cursor) {
opacity: 0;
}
}
.input {
display: flex;
grid-row: 1;
grid-column: 1;
font-size: 1.5rem;
padding: 1rem;
max-width: 16cm;
outline: 2px dashed transparent;
border-radius: 0.25rem;
margin-block: 1rem;
transition:
outline 0.2s ease,
border-radius 0.2s ease;
}
.input-section:focus-within {
outline: none;
.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
}
:global(.cursor) {
opacity: 1;
}
}
</style>

View File

@@ -58,6 +58,7 @@ export default defineConfig({
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
"prerendered/**/*.html",
],
globIgnores: ["prerendered/pages/ccos/**/*"],
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},