From e37b38085d1cc156e6fb6106d5f678b2438ec993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thea=20Sch=C3=B6bl?= Date: Thu, 16 Jan 2025 17:12:56 +0100 Subject: [PATCH] feat: sentence trainer prototype feat: layout learner prototype --- flake.nix | 12 +- package.json | 3 +- src/lib/charrecorder/TrackText.svelte | 20 + src/lib/charrecorder/TrackWpm.svelte | 20 + src/lib/charrecorder/core/plugins/chords.ts | 1 - src/lib/charrecorder/core/plugins/text.ts | 23 + src/lib/charrecorder/renderer/renderer.ts | 1 + src/lib/components/layout/KeyText.svelte | 8 + src/lib/components/layout/KeyboardKey.svelte | 7 + src/lib/serial/device.ts | 96 ++-- src/lib/util/shuffle.ts | 15 + src/routes/(app)/learn/+page.svelte | 235 +--------- src/routes/(app)/learn/chords/+page.svelte | 231 ++++++++++ src/routes/(app)/learn/layout/+page.svelte | 132 ++++++ src/routes/(app)/learn/sentence/+page.svelte | 422 ++++++++++++++++++ .../(app)/learn/sentence/StatsTable.svelte | 90 ++++ .../learn/{Pick.svelte => sentence/types.ts} | 0 17 files changed, 1056 insertions(+), 260 deletions(-) create mode 100644 src/lib/charrecorder/TrackText.svelte create mode 100644 src/lib/charrecorder/TrackWpm.svelte create mode 100644 src/lib/charrecorder/core/plugins/text.ts create mode 100644 src/lib/util/shuffle.ts create mode 100644 src/routes/(app)/learn/chords/+page.svelte create mode 100644 src/routes/(app)/learn/layout/+page.svelte create mode 100644 src/routes/(app)/learn/sentence/+page.svelte create mode 100644 src/routes/(app)/learn/sentence/StatsTable.svelte rename src/routes/(app)/learn/{Pick.svelte => sentence/types.ts} (100%) diff --git a/flake.nix b/flake.nix index a9ba63b6..68109f5d 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ]) diff --git a/package.json b/package.json index d4120386..22d95fea 100644 --- a/package.json +++ b/package.json @@ -93,5 +93,6 @@ "web-serial-polyfill": "^1.0.15", "workbox-window": "^7.3.0" }, - "type": "module" + "type": "module", + "packageManager": "pnpm@8.15.4+sha1.c85a4305534f76d461407b59277b954bac97b5c4" } diff --git a/src/lib/charrecorder/TrackText.svelte b/src/lib/charrecorder/TrackText.svelte new file mode 100644 index 00000000..70292e33 --- /dev/null +++ b/src/lib/charrecorder/TrackText.svelte @@ -0,0 +1,20 @@ + diff --git a/src/lib/charrecorder/TrackWpm.svelte b/src/lib/charrecorder/TrackWpm.svelte new file mode 100644 index 00000000..5113b942 --- /dev/null +++ b/src/lib/charrecorder/TrackWpm.svelte @@ -0,0 +1,20 @@ + diff --git a/src/lib/charrecorder/core/plugins/chords.ts b/src/lib/charrecorder/core/plugins/chords.ts index 2b889450..c12f39a8 100644 --- a/src/lib/charrecorder/core/plugins/chords.ts +++ b/src/lib/charrecorder/core/plugins/chords.ts @@ -85,7 +85,6 @@ export class ChordsReplayPlugin } } } - console.log(this.tokens); clearTimeout(this.timeout); if (replay.stepper.held.size === 0) { diff --git a/src/lib/charrecorder/core/plugins/text.ts b/src/lib/charrecorder/core/plugins/text.ts new file mode 100644 index 00000000..e5e2243e --- /dev/null +++ b/src/lib/charrecorder/core/plugins/text.ts @@ -0,0 +1,23 @@ +import type { ReplayPlayer } from "../player"; +import type { ReplayPlugin, StoreContract } from "../types"; + +export class TextPlugin implements StoreContract, 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); + } +} diff --git a/src/lib/charrecorder/renderer/renderer.ts b/src/lib/charrecorder/renderer/renderer.ts index 50a0b235..1008374a 100644 --- a/src/lib/charrecorder/renderer/renderer.ts +++ b/src/lib/charrecorder/renderer/renderer.ts @@ -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); } diff --git a/src/lib/components/layout/KeyText.svelte b/src/lib/components/layout/KeyText.svelte index 03529d43..ece0ada3 100644 --- a/src/lib/components/layout/KeyText.svelte +++ b/src/lib/components/layout/KeyText.svelte @@ -11,6 +11,9 @@ const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } = getContext("visual-layout-config"); const activeLayer = getContext>("active-layer"); + const currentAction = getContext> | undefined>( + "highlight-action", + ); let { key, @@ -47,6 +50,7 @@ ]} {@const hasIcon = !dynamicMapping && !!icon} diff --git a/src/lib/components/layout/KeyboardKey.svelte b/src/lib/components/layout/KeyboardKey.svelte index b5eacf58..172ce25e 100644 --- a/src/lib/components/layout/KeyboardKey.svelte +++ b/src/lib/components/layout/KeyboardKey.svelte @@ -8,11 +8,14 @@ KeyboardEventHandler, MouseEventHandler, } from "svelte/elements"; + import { type Writable } from "svelte/store"; const { scale, margin, strokeWidth } = getContext( "visual-layout-config", ); + const highlight = getContext> | undefined>("highlight"); + let { i, key, @@ -35,6 +38,8 @@ 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; @@ -185,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", @@ -278,14 +281,15 @@ export class CharaDevice { */ async send( expectedLength: T, - ...command: string[] + command: string[], + timeout: number | undefined = 5000, ): Promise> { 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< @@ -307,7 +311,7 @@ export class CharaDevice { } async getChordCount(): Promise { - const [count] = await this.send(1, "CML C0"); + const [count] = await this.send(1, ["CML", "C0"]); return Number.parseInt(count); } @@ -315,7 +319,11 @@ export class CharaDevice { * Retrieves a chord by index */ async getChord(index: number | number[]): Promise { - 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), @@ -326,29 +334,30 @@ export class CharaDevice { * Retrieves the phrase for a set of actions */ async getChordPhrase(actions: number[]): Promise { - 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) { - 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}`); } @@ -360,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}`); } @@ -371,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); } @@ -384,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}`); } @@ -395,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}`); } @@ -406,10 +428,11 @@ export class CharaDevice { * Retrieves a setting from the device */ async getSetting(id: number): Promise { - 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})`, @@ -421,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"]); } /** @@ -437,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 { + const [value] = await this.send(1, ["QRY", "KEY"], undefined); + return Number(value); } /** @@ -446,7 +474,7 @@ export class CharaDevice { * This is useful for debugging when there is a suspected heap or stack issue. */ async getRamBytesAvailable(): Promise { - 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 { diff --git a/src/lib/util/shuffle.ts b/src/lib/util/shuffle.ts new file mode 100644 index 00000000..31096799 --- /dev/null +++ b/src/lib/util/shuffle.ts @@ -0,0 +1,15 @@ +/** + * https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm + */ +export function shuffleInPlace(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(array: T[]): T[] { + const result = [...array]; + shuffleInPlace(result); + return result; +} diff --git a/src/routes/(app)/learn/+page.svelte b/src/routes/(app)/learn/+page.svelte index 6672030a..392b7554 100644 --- a/src/routes/(app)/learn/+page.svelte +++ b/src/routes/(app)/learn/+page.svelte @@ -1,231 +1,24 @@ -

WIP

- - - -{#key $nextWord} -

- {$nextWord} - {#if $scores[$nextWord!] === undefined} - new - {:else if ($scores[$nextWord!]?.score ?? 0) < 0} - weak - {/if} -

- -
- - - -
-{/key} - -{#key $nextWord} -
- {}} /> -
-{/key} - - -
- - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => a.score - b.score) - .splice(0, 10) as [word, score]} - - - - - {/each} - -
Weak
{word}{score.score.toFixed(2)}
- - - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => b.score - a.score) - .splice(0, 10) as [word, score]} - - - - - {/each} - -
Strong
{word}{score.score.toFixed(2)}
- - - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => b.lastTyped - a.lastTyped) - .splice(0, 10) as [word, score]} - - - - {/each} - -
Rehearse
{word}
-
- -
- Settings - - - - {#each Object.entries(learnConfigDefault) as [key, value]} - - - - - - {/each} - -
{key} - ($learnConfigStored[key] = event.target.value)} - /> - - -
-
+ diff --git a/src/routes/(app)/learn/chords/+page.svelte b/src/routes/(app)/learn/chords/+page.svelte new file mode 100644 index 00000000..bcf47610 --- /dev/null +++ b/src/routes/(app)/learn/chords/+page.svelte @@ -0,0 +1,231 @@ + + +

WIP

+ + + +{#key $nextWord} +

+ {$nextWord} + {#if $scores[$nextWord!] === undefined} + new + {:else if ($scores[$nextWord!]?.score ?? 0) < 0} + weak + {/if} +

+ +
+ + + +
+{/key} + +{#key $nextWord} +
+ {}} /> +
+{/key} + + +
+ + + + + + {#each Object.entries($scores) + .sort(([, a], [, b]) => a.score - b.score) + .splice(0, 10) as [word, score]} + + + + + {/each} + +
Weak
{word}{score.score.toFixed(2)}
+ + + + + + + {#each Object.entries($scores) + .sort(([, a], [, b]) => b.score - a.score) + .splice(0, 10) as [word, score]} + + + + + {/each} + +
Strong
{word}{score.score.toFixed(2)}
+ + + + + + + {#each Object.entries($scores) + .sort(([, a], [, b]) => b.lastTyped - a.lastTyped) + .splice(0, 10) as [word, score]} + + + + {/each} + +
Rehearse
{word}
+
+ +
+ Settings + + + + {#each Object.entries(learnConfigDefault) as [key, value]} + + + + + + {/each} + +
{key} + ($learnConfigStored[key] = event.target.value)} + /> + + +
+
+ + diff --git a/src/routes/(app)/learn/layout/+page.svelte b/src/routes/(app)/learn/layout/+page.svelte new file mode 100644 index 00000000..c81beb09 --- /dev/null +++ b/src/routes/(app)/learn/layout/+page.svelte @@ -0,0 +1,132 @@ + + +
+
+ +
+ + +
+ + diff --git a/src/routes/(app)/learn/sentence/+page.svelte b/src/routes/(app)/learn/sentence/+page.svelte new file mode 100644 index 00000000..1bb92bb7 --- /dev/null +++ b/src/routes/(app)/learn/sentence/+page.svelte @@ -0,0 +1,422 @@ + + +
+

Sentence Trainer

+ +
+ {#each masteryThresholds as _, i} + + {/each} + {#each masteryThresholds as _, i} +
+ {/each} +
+
+ {#each sentenceWords as _, i} + {#if i !== sentenceWords.length - 1} + {@const word = sentenceWords.slice(i, i + 2).join(" ")} + {@const mastery = wordMastery.get(word) ?? 0} +
+ {/if} + {/each} + {#each sentenceWords as word, i} + {@const mastery = wordMastery.get(word)} +
+ {word} +
+ {/each} + {#each sentenceWords as _, i} + {#if i < sentenceWords.length - 2} + {@const word = sentenceWords.slice(i, i + 3).join(" ")} + {@const mastery = wordMastery.get(word) ?? 0} +
+ {/if} + {/each} +
+ +
+
+ {#if level === masteryThresholds.length - 1 && progress === 1} +
+ You have mastered this sentence! +
+ {:else} + {#key recorder} +
+ + + + + +
+ {/key} + {/if} +
+
+ {#if devTools} +
Dev Tools
+ + + {#each masteryThresholds as _, i} + + + + + + {/each} + +
L{i + 1}
+ + + {#each wordStats.entries() as [word, stats]} + {@const mastery = wordMastery.get(word) ?? 0} + + + + {#each stats as stat} + + {/each} + + {/each} + +
{word}{Math.round(mastery * 100)}%{stat}
+ {/if} +
+ + diff --git a/src/routes/(app)/learn/sentence/StatsTable.svelte b/src/routes/(app)/learn/sentence/StatsTable.svelte new file mode 100644 index 00000000..59d341bd --- /dev/null +++ b/src/routes/(app)/learn/sentence/StatsTable.svelte @@ -0,0 +1,90 @@ + + +
+

Stats for Level {level}

+ + + + + + + + + + + + + + + {#each Object.entries(wordStats) as [word, times]} + + + + + + + + + + {/each} + +
WordLast WPMBest WPMAttemptsAverage Time
{word}{getLastWPM(times, word.split(" ").length)}{getBestWPM(times, word.split(" ").length)}{times.length}{calculateAverageTime(times)}
+
+ + diff --git a/src/routes/(app)/learn/Pick.svelte b/src/routes/(app)/learn/sentence/types.ts similarity index 100% rename from src/routes/(app)/learn/Pick.svelte rename to src/routes/(app)/learn/sentence/types.ts