feat: sentence trainer prototype

feat: layout learner prototype
This commit is contained in:
2025-01-16 17:12:56 +01:00
parent a3bf9ac32b
commit e37b38085d
17 changed files with 1056 additions and 260 deletions

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

@@ -134,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;
@@ -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<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<
@@ -307,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);
}
@@ -315,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),
@@ -326,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}`);
}
@@ -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<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})`,
@@ -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<number> {
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<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;
}