diff --git a/src/lib/serial/connection.ts b/src/lib/serial/connection.ts index 1bddddba..b9efd762 100644 --- a/src/lib/serial/connection.ts +++ b/src/lib/serial/connection.ts @@ -2,6 +2,7 @@ import {writable} from "svelte/store" import {CharaDevice} from "$lib/serial/device" import type {Chord} from "$lib/serial/chord" import type {Writable} from "svelte/store" +import type {CharaLayout} from "$lib/serialization/layout" export const serialPort = writable() @@ -14,8 +15,6 @@ export const serialLog = writable([]) export const chords = writable([]) -export type CharaLayout = [number[], number[], number[]] - export const layout = writable([[], [], []]) export const unsavedChanges = writable(0) diff --git a/src/lib/serial/serialization.ts b/src/lib/serial/serialization.ts index 1b48f138..b8b0ce61 100644 --- a/src/lib/serial/serialization.ts +++ b/src/lib/serial/serialization.ts @@ -10,7 +10,7 @@ export async function stringifyCompressed(chords: any): Promise { * Decompress JSON.parse with gzip */ export async function parseCompressed(blob: Blob): Promise { - const stream = blob.stream().pipeThrough(new DecompressionStream("gzip")) + const stream = blob.stream().pipeThrough(new DecompressionStream("deflate")) return await new Response(stream).json() } diff --git a/src/lib/serialization/actions.spec.ts b/src/lib/serialization/actions.spec.ts new file mode 100644 index 00000000..d4d166fb --- /dev/null +++ b/src/lib/serialization/actions.spec.ts @@ -0,0 +1,28 @@ +import {describe, it, expect} from "vitest" +import {compressActions, decompressActions} from "./actions" + +describe("layout", function () { + const actions = [1, 5, 2, 1023, 42, 2, 4, 78] + + describe("compression", function () { + it("should compress back and forth arrays divisible by 4", function () { + expect(decompressActions(compressActions(actions))).toEqual(actions) + }) + + it("should compress back and forth arrays divisible not divisible by 4", function () { + expect(decompressActions(compressActions([...actions, 1023, 512, 123]))).toEqual([ + ...actions, + 1023, + 512, + 123, + ]) + expect(decompressActions(compressActions([...actions, 1023, 512]))).toEqual([...actions, 1023, 512]) + expect(decompressActions(compressActions([...actions, 1023]))).toEqual([...actions, 1023]) + }) + + it("should compress alternating 0/1023", function () { + const array = Array.from({length: 128}).map((_, i) => (i % 2 === 0 ? 0 : 1023)) + expect(decompressActions(compressActions(array))).toEqual(array) + }) + }) +}) diff --git a/src/lib/serialization/actions.ts b/src/lib/serialization/actions.ts new file mode 100644 index 00000000..2dea113d --- /dev/null +++ b/src/lib/serialization/actions.ts @@ -0,0 +1,38 @@ +/** + * Compresses an action list into a Uint8Array of 10-bit integers, supporting values of up to 1023 + */ +export function compressActions(actions: number[]): Uint8Array { + const overflow = actions.length % 4 + const array = new Uint8Array( + Math.ceil((actions.length - overflow) * 1.25 + (overflow === 0 ? 0 : overflow + 1)), + ) + let arrayOffset = 0 + for (let i = 0; i < actions.length; i += 4) { + let final = 0 + for (let j = 0; j < 4 && i + j < actions.length; j++) { + const action = actions[i + j] + array[arrayOffset++] = (action >>> 2) & 0xff + final |= (action & 0x03) << (j * 2) + } + array[arrayOffset++] = final + } + console.assert(arrayOffset === array.length) + return array +} + +/** + * Decompresses actions + * + * @see {compressActions} + */ +export function decompressActions(raw: Uint8Array): number[] { + const actions: number[] = [] + for (let i = 0; i < raw.length + 4; i += 5) { + const overflow = raw[Math.min(i + 4, raw.length - 1)] + + for (let j = 0; j < 4 && i + j < raw.length - 1; j++) { + actions.push((raw[i + j] << 2) | ((overflow >>> (j * 2)) & 0x3)) + } + } + return actions +} diff --git a/src/lib/serialization/base64.spec.ts b/src/lib/serialization/base64.spec.ts new file mode 100644 index 00000000..aa96c5b8 --- /dev/null +++ b/src/lib/serialization/base64.spec.ts @@ -0,0 +1,12 @@ +import {describe, it, expect} from "vitest" +import {fromBase64, toBase64} from "./base64" + +describe("base64", function () { + const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21]) + + it("should convert back-forth", async function () { + expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual( + data.buffer, + ) + }) +}) diff --git a/src/lib/serialization/base64.ts b/src/lib/serialization/base64.ts new file mode 100644 index 00000000..8d62d023 --- /dev/null +++ b/src/lib/serialization/base64.ts @@ -0,0 +1,30 @@ +/** + * Encodes a gzipped binary blob to a base64 string. + * + * Note that the string is url-compatible base64, + * meaning some chars are swapped for compatibility + */ +export async function toBase64(blob: Blob): Promise { + return new Promise(async resolve => { + const reader = new FileReader() + reader.onloadend = function () { + resolve( + (reader.result as string) + .replace(/^data:application\/octet-stream;base64,/, "") + .replaceAll("+", ".") + .replaceAll("/", "_") + .replaceAll("=", "-"), + ) + } + reader.readAsDataURL(blob) + }) +} + +export async function fromBase64(base64: string): Promise { + return fetch( + `data:application/octet-stream;base64,${base64 + .replaceAll(".", "+") + .replaceAll("_", "/") + .replaceAll("-", "=")}`, + ).then(it => it.blob()) +} diff --git a/src/lib/serialization/layout.sample.json b/src/lib/serialization/layout.sample.json new file mode 100644 index 00000000..710e1584 --- /dev/null +++ b/src/lib/serialization/layout.sample.json @@ -0,0 +1,21 @@ +[ + [ + 600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603, 114, 298, 32, 101, 604, 105, + 127, 46, 111, 605, 39, 512, 44, 117, 552, 513, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566, + 567, 609, 563, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296, 544, 116, 613, + 108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518, 551, 542, 616, 336, 338, 335, 337, 617, 566, + 568, 565, 567 + ], + [ + 0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51, 50, 0, 52, 127, 54, 53, 0, + 96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519, + 297, 0, 98, 120, 91, 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516, 59, 115, + 0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567 + ], + [ + 0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314, 298, 316, 315, 0, 317, 127, + 319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0, + 563, 63, 519, 297, 0, 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317, 299, 319, + 318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338, 335, 337, 0, 570, 572, 569, 571 + ] +] diff --git a/src/lib/serialization/layout.ts b/src/lib/serialization/layout.ts new file mode 100644 index 00000000..f486dda3 --- /dev/null +++ b/src/lib/serialization/layout.ts @@ -0,0 +1,28 @@ +import {compressActions, decompressActions} from "./actions" +import {fromBase64, toBase64} from "$lib/serialization/base64" + +export type CharaLayout = [number[], number[], number[]] + +/** + * Serialize a layout into a micro package + */ +export async function serializeLayout(layout: CharaLayout): Promise { + const items = compressActions(layout.flat()) + const stream = new Blob([items]).stream().pipeThrough(new CompressionStream("deflate")) + return new Response(stream).blob() +} + +export async function deserializeLayout(layout: Blob): Promise { + const stream = layout.stream().pipeThrough(new DecompressionStream("deflate")) + const raw = await new Response(stream).arrayBuffer() + const actions = decompressActions(new Uint8Array(raw)) + return [actions.slice(0, 90), actions.slice(90, 180), actions.slice(180, 270)] +} + +export async function layoutAsUrlComponent(layout: CharaLayout): Promise { + return serializeLayout(layout).then(toBase64) +} + +export async function layoutFromUrlComponent(base64: string): Promise { + return fromBase64(base64).then(deserializeLayout) +} diff --git a/src/routes/config/layout/+page.svelte b/src/routes/config/layout/+page.svelte index 3a4f9362..f50720d7 100644 --- a/src/routes/config/layout/+page.svelte +++ b/src/routes/config/layout/+page.svelte @@ -1,22 +1,22 @@