mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-10 12:02:48 +00:00
feat: learn
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
children,
|
||||
}: {
|
||||
replay: ReplayPlayer | Replay;
|
||||
cursor: boolean;
|
||||
keys: boolean;
|
||||
cursor?: boolean;
|
||||
keys?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export class ChordsReplayPlugin
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(this.tokens);
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
if (replay.stepper.held.size === 0) {
|
||||
|
||||
101
src/lib/learn/chords.ts
Normal file
101
src/lib/learn/chords.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export const words = derived(
|
||||
[chords, osLayout],
|
||||
([chords, layout]) =>
|
||||
new Map<string, ChordInfo>(
|
||||
chords
|
||||
.map((chord) => ({
|
||||
chord,
|
||||
output: chord.phrase.map((action) =>
|
||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
||||
),
|
||||
}))
|
||||
.filter(({ output }) => output.every((it) => !!it))
|
||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
||||
),
|
||||
);
|
||||
|
||||
interface Score {
|
||||
lastTyped: number;
|
||||
score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
||||
|
||||
export const learnConfigDefault = {
|
||||
maxScore: 3,
|
||||
minScore: -3,
|
||||
scoreBlend: 0.5,
|
||||
weakRate: 0.8,
|
||||
weakBoost: 0.5,
|
||||
maxWeak: 3,
|
||||
newRate: 0.3,
|
||||
initialNewRate: 0.9,
|
||||
initialCount: 10,
|
||||
};
|
||||
export const learnConfigStored = persistentWritable<
|
||||
Partial<typeof learnConfigDefault>
|
||||
>("learn-config", {});
|
||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
||||
...learnConfigDefault,
|
||||
...config,
|
||||
}));
|
||||
|
||||
let lastWord: string | undefined;
|
||||
|
||||
function shuffle<T>(array: T[]): 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]!];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
||||
return array[
|
||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
||||
];
|
||||
}
|
||||
|
||||
export const nextWord = derived(
|
||||
[words, scores, learnConfig],
|
||||
([words, scores, config]) => {
|
||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
||||
|
||||
values.sort(([, a], [, b]) => a.score - b.score);
|
||||
const weakCount =
|
||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
||||
values.length + 1) - 1;
|
||||
const weak = randomLog2(values, weakCount);
|
||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
||||
lastWord = weak[0];
|
||||
return weak[0];
|
||||
}
|
||||
|
||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
||||
const recent = randomLog2(values);
|
||||
const newRate =
|
||||
values.length < config.initialCount
|
||||
? config.initialNewRate
|
||||
: config.newRate;
|
||||
if (
|
||||
recent &&
|
||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
||||
Math.random() > newRate)
|
||||
) {
|
||||
lastWord = recent[0];
|
||||
return recent[0];
|
||||
}
|
||||
|
||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
||||
const word = newWord || recent?.[0] || weak?.[0];
|
||||
lastWord = word;
|
||||
return word;
|
||||
},
|
||||
);
|
||||
@@ -108,32 +108,30 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html webManifestLink}
|
||||
<!--{@html webManifestLink}-->
|
||||
<title>{$LL.TITLE()}</title>
|
||||
<meta name="description" content={$LL.DESCRIPTION()} />
|
||||
<meta name="theme-color" content={data.themeColor} />
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window on:keydown={handleHotkey} />
|
||||
<svelte:window onkeydown={handleHotkey} />
|
||||
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
|
||||
<Sidebar />
|
||||
<!-- <PickChangesDialog /> -->
|
||||
|
||||
<!-- <PickChangesDialog /> -->
|
||||
<PageTransition>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</PageTransition>
|
||||
|
||||
<PageTransition>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
<Footer />
|
||||
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
||||
<BrowserWarning />
|
||||
{/if}
|
||||
</PageTransition>
|
||||
|
||||
<Footer />
|
||||
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
||||
<BrowserWarning />
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -146,6 +144,6 @@
|
||||
"sidebar main"
|
||||
"sidebar footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: 1fr ;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,18 +15,42 @@
|
||||
}
|
||||
});
|
||||
|
||||
const routes = [
|
||||
[
|
||||
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
|
||||
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
|
||||
{ href: "/config/settings/", icon: "tune", title: "Config" },
|
||||
],
|
||||
[
|
||||
{ href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||
{ href: "/learn", icon: "description", title: "Docs" },
|
||||
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
||||
],
|
||||
[
|
||||
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
|
||||
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||
],
|
||||
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
|
||||
|
||||
let connectButton: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
<div class="sidebar">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/config/layout/" class="icon">tune</a></li>
|
||||
<li><a href="/learn" class="wip icon">school</a></li>
|
||||
<li><a href="/editor" class="wip icon">edit_document</a></li>
|
||||
<li><a href="/chat" class="wip icon">chat</a></li>
|
||||
<li><a href="/plugin" class="wip icon">code</a></li>
|
||||
</ul>
|
||||
{#each routes as group}
|
||||
<ul>
|
||||
{#each group as { href, icon, title, wip }}
|
||||
<li>
|
||||
<a class:wip {href}>
|
||||
<div class="icon">{icon}</div>
|
||||
<div class="content">
|
||||
{title}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</nav>
|
||||
<ul class="sidebar-footer">
|
||||
<li>
|
||||
@@ -56,6 +80,7 @@
|
||||
.sidebar {
|
||||
margin: 8px;
|
||||
padding-inline-end: 8px;
|
||||
width: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -64,25 +89,46 @@
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
|
||||
&.wip {
|
||||
color: var(--md-sys-color-error);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wip::after {
|
||||
content: "experiment";
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: var(--md-sys-color-tertiary);
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
border-radius: 100%;
|
||||
padding: 2px;
|
||||
ul + ul::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background: var(--md-sys-color-outline);
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1 +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>
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { chords } from "$lib/undo-redo";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { fly } from "svelte/transition";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
|
||||
const speedRating = [
|
||||
[400, "+100", "excited", true],
|
||||
[700, "+50", "satisfied", true],
|
||||
[1400, "+25", "neutral", true],
|
||||
[3000, "0", "dissatisfied", false],
|
||||
[Infinity, "-50", "sad", false],
|
||||
] as const;
|
||||
const accuracyRating = [
|
||||
[2, "+100", "calm", true],
|
||||
[3, "+50", "content", false],
|
||||
[5, "+25", "stressed", false],
|
||||
[7, "0", "frustrated", false],
|
||||
[14, "-25", "very_dissatisfied", false],
|
||||
[Infinity, "-50", "extremely_dissatisfied", false],
|
||||
] as const;
|
||||
|
||||
let next: Chord[] = [];
|
||||
let nextHandle: number;
|
||||
let took: number | undefined;
|
||||
let delta = 0;
|
||||
|
||||
let speed: readonly [number, string, string, boolean] | undefined;
|
||||
let accuracy: readonly [number, string, string, boolean] | undefined;
|
||||
let progress = 0;
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
let userInput = "";
|
||||
|
||||
onMount(() => {
|
||||
runTest();
|
||||
});
|
||||
|
||||
function runTest() {
|
||||
if (took === undefined) {
|
||||
took = performance.now();
|
||||
delta = 0;
|
||||
attempts = 0;
|
||||
userInput = "";
|
||||
if (next.length === 0) {
|
||||
next = Array.from(
|
||||
{ length: 5 },
|
||||
() => $chords[Math.floor(Math.random() * $chords.length)]!,
|
||||
);
|
||||
} else {
|
||||
next.shift();
|
||||
next.push($chords[Math.floor(Math.random() * $chords.length)]!);
|
||||
next = next;
|
||||
}
|
||||
}
|
||||
if (
|
||||
userInput ===
|
||||
next[0]!.phrase
|
||||
.map((it) => (it === 32 ? " " : KEYMAP_CODES.get(it)!.id))
|
||||
.join("") +
|
||||
" "
|
||||
) {
|
||||
took = undefined;
|
||||
speed = speedRating.find(([max]) => delta <= max);
|
||||
accuracy = accuracyRating.find(([max]) => attempts <= max);
|
||||
progress++;
|
||||
} else {
|
||||
delta = performance.now() - took;
|
||||
}
|
||||
|
||||
nextHandle = requestAnimationFrame(runTest);
|
||||
}
|
||||
|
||||
let debounceTimer = 0;
|
||||
|
||||
function backspace(event: KeyboardEvent) {
|
||||
if (event.code === "Backspace") {
|
||||
userInput = userInput.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function input(event: KeyboardEvent) {
|
||||
const stamp = performance.now();
|
||||
if (stamp - debounceTimer > 50) {
|
||||
attempts++;
|
||||
}
|
||||
debounceTimer = stamp;
|
||||
userInput += event.key;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (nextHandle) {
|
||||
cancelAnimationFrame(nextHandle);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={backspace} on:keypress={input} />
|
||||
|
||||
<h1>Vocabulary Trainer</h1>
|
||||
|
||||
{#if next[0]}
|
||||
<div class="row">
|
||||
{#key progress}
|
||||
<div
|
||||
in:fly={{ duration: 300, x: -48 }}
|
||||
out:fly={{ duration: 1000, x: 128 }}
|
||||
class="rating"
|
||||
>
|
||||
{#if speed}
|
||||
<span class="rating-item">
|
||||
<span
|
||||
style:color="var(--md-sys-color-{speed[3] ? `primary` : `error`})"
|
||||
class="icon">timer</span
|
||||
>
|
||||
{speed[1]}
|
||||
<span class="icon">sentiment_{speed[2]}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if accuracy}
|
||||
<span class="rating-item">
|
||||
<span
|
||||
style:color="var(--md-sys-color-{accuracy[3]
|
||||
? `primary`
|
||||
: `error`})"
|
||||
class="icon">target</span
|
||||
>
|
||||
{accuracy[1]}
|
||||
<span class="icon">sentiment_{accuracy[2]}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
<div class="hint" style:opacity={delta > 3000 ? 1 : 0}>
|
||||
{#each next[0].actions as action}
|
||||
<Action {action} display="keys" />
|
||||
{/each}
|
||||
</div>
|
||||
<div>
|
||||
{userInput}
|
||||
</div>
|
||||
{#each next as chord, i}
|
||||
<div class="words" style:opacity={1 - i / next.length}>
|
||||
{#each chord.phrase as action}
|
||||
<Action {action} />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>You don't have any chords</p>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.row {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.rating-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
left: -48px;
|
||||
width: max-content;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user