feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes: - Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben - Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts) - Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe, Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand- Änderung, Konfliktblatt. Beute L5: - Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht, Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach Rückgängig, Folgevorschläge beim Speichern - Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“ T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite „Sprache & Datenschutz“; axe-core als exakte devDependency. Backend: NotificationsService liest Empfänger und schreibt in einer Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft. Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint, typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978). Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion. Refs #19 #20 #21 #22 #27 #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendDictation } from "./append-text";
|
||||
|
||||
describe("appendDictation", () => {
|
||||
it("joins with a single space and keeps the existing text", () => {
|
||||
expect(appendDictation("Alt", "neu")).toBe("Alt neu");
|
||||
expect(appendDictation("Alt ", " neu ")).toBe("Alt neu");
|
||||
});
|
||||
|
||||
it("starts from empty without a leading space", () => {
|
||||
expect(appendDictation("", "neu")).toBe("neu");
|
||||
});
|
||||
|
||||
it("ignores empty dictation", () => {
|
||||
expect(appendDictation("Alt", " ")).toBe("Alt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Dictation is added to the end of a field; it never replaces what is there. */
|
||||
export function appendDictation(existing: string, dictated: string): string {
|
||||
const addition = dictated.trim();
|
||||
if (addition === "") {
|
||||
return existing;
|
||||
}
|
||||
const base = existing.trimEnd();
|
||||
return base === "" ? addition : `${base} ${addition}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCloudRecognitionConsent,
|
||||
hasSeenRecordingNotice,
|
||||
markRecordingNoticeSeen,
|
||||
setCloudRecognitionConsent,
|
||||
} from "./consent";
|
||||
|
||||
describe("voice consent", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("defaults to no consent and no seen notice", () => {
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
expect(hasSeenRecordingNotice()).toBe(false);
|
||||
});
|
||||
|
||||
it("stores and withdraws the cloud consent", () => {
|
||||
setCloudRecognitionConsent(true);
|
||||
expect(getCloudRecognitionConsent()).toBe(true);
|
||||
setCloudRecognitionConsent(false);
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
});
|
||||
|
||||
it("remembers that the recording notice was seen", () => {
|
||||
markRecordingNoticeSeen();
|
||||
expect(hasSeenRecordingNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it("consent survives a throwing localStorage", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
expect(hasSeenRecordingNotice()).toBe(false);
|
||||
expect(() => setCloudRecognitionConsent(true)).not.toThrow();
|
||||
expect(() => markRecordingNoticeSeen()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Voice settings that live in the browser only. Every access sits in
|
||||
* try/catch: private windows and blocked site data make localStorage throw,
|
||||
* and the default (no consent, notice not seen) is the safe answer then.
|
||||
*/
|
||||
const CLOUD_CONSENT_KEY = "apicore.voice.cloud-consent";
|
||||
const NOTICE_SEEN_KEY = "apicore.voice.notice-seen";
|
||||
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlag(key: string, value: boolean): void {
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(key, "1");
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Storage is a convenience here; without it the flag simply stays at its default.
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudRecognitionConsent(): boolean {
|
||||
return readFlag(CLOUD_CONSENT_KEY);
|
||||
}
|
||||
|
||||
const consentListeners = new Set<() => void>();
|
||||
|
||||
/** For useSyncExternalStore: re-reads after this tab or another tab changed the consent. */
|
||||
export function subscribeCloudRecognitionConsent(listener: () => void): () => void {
|
||||
consentListeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === CLOUD_CONSENT_KEY || event.key === null) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
consentListeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
export function setCloudRecognitionConsent(value: boolean): void {
|
||||
writeFlag(CLOUD_CONSENT_KEY, value);
|
||||
consentListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function hasSeenRecordingNotice(): boolean {
|
||||
return readFlag(NOTICE_SEEN_KEY);
|
||||
}
|
||||
|
||||
export function markRecordingNoticeSeen(): void {
|
||||
writeFlag(NOTICE_SEEN_KEY, true);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRecognition, detectSpeechSupport } from "./speech-support";
|
||||
import { setCloudRecognitionConsent } from "./consent";
|
||||
|
||||
class FakeRecognition {
|
||||
static instances: FakeRecognition[] = [];
|
||||
lang = "";
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
maxAlternatives = 1;
|
||||
declare processLocally?: boolean;
|
||||
declare phrases?: unknown;
|
||||
handlers: Record<string, (event: unknown) => void> = {};
|
||||
start = vi.fn();
|
||||
stop = vi.fn();
|
||||
abort = vi.fn();
|
||||
constructor() {
|
||||
FakeRecognition.instances.push(this);
|
||||
}
|
||||
set onresult(fn: (event: unknown) => void) {
|
||||
this.handlers.result = fn;
|
||||
}
|
||||
set onend(fn: (event: unknown) => void) {
|
||||
this.handlers.end = fn;
|
||||
}
|
||||
set onerror(fn: (event: unknown) => void) {
|
||||
this.handlers.error = fn;
|
||||
}
|
||||
set onaudiostart(fn: (event: unknown) => void) {
|
||||
this.handlers.audiostart = fn;
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain object stands in for `window`; only the members the module reads exist. */
|
||||
function fakeWindow(extra: Record<string, unknown> = {}): Window {
|
||||
return {
|
||||
navigator: { standalone: false, userAgent: "x" },
|
||||
...extra,
|
||||
} as unknown as Window;
|
||||
}
|
||||
|
||||
describe("detectSpeechSupport", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
FakeRecognition.instances = [];
|
||||
});
|
||||
|
||||
it("returns api false without SpeechRecognition and webkitSpeechRecognition", async () => {
|
||||
const support = await detectSpeechSupport(fakeWindow());
|
||||
|
||||
expect(support).toEqual({
|
||||
api: false,
|
||||
local: "unknown",
|
||||
cloudAllowed: false,
|
||||
isInstalledIosPwa: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports local available via SpeechRecognition.available({ langs: ['de-DE'], processLocally: true })", async () => {
|
||||
const available = vi.fn().mockResolvedValue("available");
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, { available });
|
||||
|
||||
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
|
||||
|
||||
expect(available).toHaveBeenCalledWith({ langs: ["de-DE"], processLocally: true });
|
||||
expect(support.api).toBe(true);
|
||||
expect(support.local).toBe("available");
|
||||
});
|
||||
|
||||
it("maps downloadable and unavailable answers", async () => {
|
||||
for (const answer of ["downloadable", "unavailable"] as const) {
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockResolvedValue(answer),
|
||||
});
|
||||
const support = await detectSpeechSupport(fakeWindow({ webkitSpeechRecognition: Ctor }));
|
||||
expect(support.local).toBe(answer);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unknown when available() is missing or throws", async () => {
|
||||
const missing = await detectSpeechSupport(fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
expect(missing.api).toBe(true);
|
||||
expect(missing.local).toBe("unknown");
|
||||
|
||||
const Throwing = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
});
|
||||
const thrown = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Throwing }));
|
||||
expect(thrown.local).toBe("unknown");
|
||||
});
|
||||
|
||||
it("cloudAllowed follows the stored consent and defaults to false", async () => {
|
||||
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
|
||||
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(false);
|
||||
|
||||
setCloudRecognitionConsent(true);
|
||||
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("detects the installed iOS PWA", async () => {
|
||||
const win = fakeWindow({
|
||||
SpeechRecognition: FakeRecognition,
|
||||
navigator: {
|
||||
standalone: true,
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
|
||||
},
|
||||
});
|
||||
|
||||
expect((await detectSpeechSupport(win)).isInstalledIosPwa).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRecognition", () => {
|
||||
beforeEach(() => {
|
||||
FakeRecognition.instances = [];
|
||||
});
|
||||
|
||||
const options = {
|
||||
lang: "de-DE",
|
||||
continuous: false,
|
||||
interimResults: true,
|
||||
maxAlternatives: 1,
|
||||
processLocally: false,
|
||||
} as const;
|
||||
|
||||
it("returns null without the API", () => {
|
||||
expect(createRecognition(options, fakeWindow())).toBeNull();
|
||||
});
|
||||
|
||||
it("sets processLocally only when requested and phrases only when supported", () => {
|
||||
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
|
||||
createRecognition(options, win);
|
||||
expect("processLocally" in FakeRecognition.instances[0]).toBe(false);
|
||||
|
||||
createRecognition({ ...options, processLocally: true, phrases: ["Varroa"] }, win);
|
||||
const local = FakeRecognition.instances[1];
|
||||
expect(local.processLocally).toBe(true);
|
||||
// The fake engine does not expose phrases at runtime: it must not be invented.
|
||||
expect("phrases" in local).toBe(false);
|
||||
});
|
||||
|
||||
class WithPhrases extends FakeRecognition {
|
||||
constructor() {
|
||||
super();
|
||||
this.phrases = [];
|
||||
}
|
||||
}
|
||||
class FakePhrase {
|
||||
constructor(
|
||||
public phrase: string,
|
||||
public boost: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
it("builds SpeechRecognitionPhrase objects with boost 1 when the engine supports them", () => {
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa", "Königin"] },
|
||||
fakeWindow({ SpeechRecognition: WithPhrases, SpeechRecognitionPhrase: FakePhrase }),
|
||||
);
|
||||
|
||||
expect(FakeRecognition.instances[0].phrases).toEqual([
|
||||
new FakePhrase("Varroa", 1.0),
|
||||
new FakePhrase("Königin", 1.0),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips phrases without a SpeechRecognitionPhrase constructor", () => {
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa"] },
|
||||
fakeWindow({ SpeechRecognition: WithPhrases }),
|
||||
);
|
||||
|
||||
expect(FakeRecognition.instances[0].phrases).toEqual([]);
|
||||
});
|
||||
|
||||
it("never throws when assigning phrases fails", () => {
|
||||
class ThrowingSetter extends FakeRecognition {
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, "phrases", {
|
||||
get: () => [],
|
||||
set: () => {
|
||||
throw new Error("rejected");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
createRecognition(
|
||||
{ ...options, phrases: ["Varroa"] },
|
||||
fakeWindow({ SpeechRecognition: ThrowingSetter, SpeechRecognitionPhrase: FakePhrase }),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("maps downloading to unavailable on the device", async () => {
|
||||
const Ctor = Object.assign(class extends FakeRecognition {}, {
|
||||
available: vi.fn().mockResolvedValue("downloading"),
|
||||
});
|
||||
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
|
||||
expect(support.local).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("maps results into final and interim text", () => {
|
||||
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
const onResult = vi.fn();
|
||||
handle?.on("result", onResult);
|
||||
|
||||
const final = Object.assign(
|
||||
[{ transcript: "Königin gesehen" }, { transcript: "Königin gesehn" }],
|
||||
{ isFinal: true },
|
||||
);
|
||||
const interim = Object.assign([{ transcript: " und Stifte" }], { isFinal: false });
|
||||
FakeRecognition.instances[0].handlers.result({ resultIndex: 0, results: [final, interim] });
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith({
|
||||
finalText: "Königin gesehen",
|
||||
interimText: "und Stifte",
|
||||
alternatives: ["Königin gesehen", "Königin gesehn"],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards end, audiostart and error codes and the control calls", () => {
|
||||
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
|
||||
const onEnd = vi.fn();
|
||||
const onAudio = vi.fn();
|
||||
const onError = vi.fn();
|
||||
handle?.on("end", onEnd);
|
||||
handle?.on("audiostart", onAudio);
|
||||
handle?.on("error", onError);
|
||||
const engine = FakeRecognition.instances[0];
|
||||
|
||||
engine.handlers.end({});
|
||||
engine.handlers.audiostart({});
|
||||
engine.handlers.error({ error: "not-allowed" });
|
||||
handle?.start();
|
||||
handle?.stop();
|
||||
handle?.abort();
|
||||
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
expect(onAudio).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith("not-allowed");
|
||||
expect(engine.start).toHaveBeenCalled();
|
||||
expect(engine.stop).toHaveBeenCalled();
|
||||
expect(engine.abort).toHaveBeenCalled();
|
||||
expect(engine.lang).toBe("de-DE");
|
||||
expect(engine.interimResults).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { getCloudRecognitionConsent } from "./consent";
|
||||
|
||||
export type LocalAvailability = "available" | "downloadable" | "unavailable" | "unknown";
|
||||
|
||||
export interface SpeechSupport {
|
||||
api: boolean;
|
||||
local: LocalAvailability;
|
||||
cloudAllowed: boolean;
|
||||
isInstalledIosPwa: boolean;
|
||||
}
|
||||
|
||||
export interface RecognitionOptions {
|
||||
lang: "de-DE";
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
processLocally: boolean;
|
||||
phrases?: readonly string[];
|
||||
}
|
||||
|
||||
export interface RecognitionResultEvent {
|
||||
finalText: string;
|
||||
interimText: string;
|
||||
alternatives: string[];
|
||||
}
|
||||
|
||||
export interface RecognitionHandle {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
on(event: "result", fn: (e: RecognitionResultEvent) => void): void;
|
||||
on(event: "end" | "audiostart", fn: () => void): void;
|
||||
on(event: "error", fn: (code: string) => void): void;
|
||||
}
|
||||
|
||||
interface RecognitionAlternative {
|
||||
transcript: string;
|
||||
}
|
||||
interface RecognitionResult {
|
||||
isFinal: boolean;
|
||||
length: number;
|
||||
[index: number]: RecognitionAlternative;
|
||||
}
|
||||
interface RawResultEvent {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<RecognitionResult>;
|
||||
}
|
||||
interface RawErrorEvent {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface RecognitionEngine {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
processLocally?: boolean;
|
||||
phrases?: unknown;
|
||||
onresult: ((event: RawResultEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: ((event: RawErrorEvent) => void) | null;
|
||||
onaudiostart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type PhraseConstructor = new (phrase: string, boost: number) => unknown;
|
||||
|
||||
interface RecognitionConstructor {
|
||||
new (): RecognitionEngine;
|
||||
available?: (options: { langs: string[]; processLocally: boolean }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const LANGUAGE = "de-DE";
|
||||
|
||||
function recognitionConstructor(win: Window): RecognitionConstructor | null {
|
||||
const scope = win as unknown as {
|
||||
SpeechRecognition?: RecognitionConstructor;
|
||||
webkitSpeechRecognition?: RecognitionConstructor;
|
||||
};
|
||||
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
function isInstalledIosPwa(win: Window): boolean {
|
||||
const nav = win.navigator as Navigator & { standalone?: boolean };
|
||||
return nav.standalone === true && /iPhone|iPad|iPod/.test(nav.userAgent);
|
||||
}
|
||||
|
||||
function toAvailability(answer: unknown): LocalAvailability {
|
||||
// Chrome answers "downloading" while the language pack arrives. For now that
|
||||
// is treated like "unavailable" on the device: no on-device dictation yet.
|
||||
if (answer === "downloading") {
|
||||
return "unavailable";
|
||||
}
|
||||
return answer === "available" || answer === "downloadable" || answer === "unavailable"
|
||||
? answer
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
async function queryLocalAvailability(ctor: RecognitionConstructor): Promise<LocalAvailability> {
|
||||
if (typeof ctor.available !== "function") {
|
||||
return "unknown";
|
||||
}
|
||||
try {
|
||||
return toAvailability(await ctor.available({ langs: [LANGUAGE], processLocally: true }));
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectSpeechSupport(
|
||||
win: Window = window,
|
||||
): Promise<SpeechSupport> {
|
||||
const ctor = recognitionConstructor(win);
|
||||
return {
|
||||
api: ctor !== null,
|
||||
local: ctor ? await queryLocalAvailability(ctor) : "unknown",
|
||||
cloudAllowed: getCloudRecognitionConsent(),
|
||||
isInstalledIosPwa: isInstalledIosPwa(win),
|
||||
};
|
||||
}
|
||||
|
||||
function mapResult(event: RawResultEvent): RecognitionResultEvent {
|
||||
let finalText = "";
|
||||
let interimText = "";
|
||||
const alternatives: string[] = [];
|
||||
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||
const result = event.results[index];
|
||||
if (result.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (result.isFinal) {
|
||||
finalText += result[0].transcript;
|
||||
for (let alt = 0; alt < result.length; alt += 1) {
|
||||
alternatives.push(result[alt].transcript);
|
||||
}
|
||||
} else {
|
||||
interimText += result[0].transcript;
|
||||
}
|
||||
}
|
||||
return { finalText: finalText.trim(), interimText: interimText.trim(), alternatives };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome takes contextual biasing as `SpeechRecognitionPhrase(phrase, boost)`
|
||||
* objects. Without that constructor or the property the list is skipped; a
|
||||
* failing assignment never breaks dictation.
|
||||
*/
|
||||
function applyPhrases(engine: RecognitionEngine, phrases: readonly string[], win: Window): void {
|
||||
const PhraseCtor = (win as unknown as { SpeechRecognitionPhrase?: PhraseConstructor })
|
||||
.SpeechRecognitionPhrase;
|
||||
if (typeof PhraseCtor !== "function" || !("phrases" in engine)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
engine.phrases = phrases.map((text) => new PhraseCtor(text, 1.0));
|
||||
} catch {
|
||||
// Biasing is optional; recognition works without it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps the browser engine; `null` when the browser has none. */
|
||||
export function createRecognition(
|
||||
options: RecognitionOptions,
|
||||
win: Window = window,
|
||||
): RecognitionHandle | null {
|
||||
const Ctor = recognitionConstructor(win);
|
||||
if (!Ctor) {
|
||||
return null;
|
||||
}
|
||||
const engine = new Ctor();
|
||||
engine.lang = options.lang;
|
||||
engine.continuous = options.continuous;
|
||||
engine.interimResults = options.interimResults;
|
||||
engine.maxAlternatives = options.maxAlternatives;
|
||||
if (options.processLocally) {
|
||||
engine.processLocally = true;
|
||||
}
|
||||
if (options.phrases) {
|
||||
applyPhrases(engine, options.phrases, win);
|
||||
}
|
||||
|
||||
return {
|
||||
start: () => engine.start(),
|
||||
stop: () => engine.stop(),
|
||||
abort: () => engine.abort(),
|
||||
on(event: string, fn: (arg?: never) => void) {
|
||||
const listener = fn as (arg?: unknown) => void;
|
||||
if (event === "result") {
|
||||
engine.onresult = (raw) => listener(mapResult(raw));
|
||||
} else if (event === "end") {
|
||||
engine.onend = () => listener();
|
||||
} else if (event === "audiostart") {
|
||||
engine.onaudiostart = () => listener();
|
||||
} else if (event === "error") {
|
||||
engine.onerror = (raw) => listener(raw.error ?? "unknown");
|
||||
}
|
||||
},
|
||||
} as RecognitionHandle;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VOICE_VOCABULARY } from "./vocabulary";
|
||||
|
||||
// The frontend image build only sees ApiCore-Frontend, so the comparison runs
|
||||
// in the monorepo checkout and is skipped inside that build.
|
||||
const fixture = path.resolve(
|
||||
process.cwd(),
|
||||
"../ApiCore-Backend/test/fixtures/voice/vocabulary.json",
|
||||
);
|
||||
|
||||
describe("voice vocabulary", () => {
|
||||
it.skipIf(!existsSync(fixture))("VOICE_VOCABULARY equals the shared fixture", () => {
|
||||
const shared: unknown = JSON.parse(readFileSync(fixture, "utf8"));
|
||||
|
||||
expect([...VOICE_VOCABULARY]).toEqual(shared);
|
||||
});
|
||||
|
||||
it("contains the core beekeeping terms", () => {
|
||||
for (const term of ["Königin", "Stifte", "Weiselzellen", "Varroa", "Drohnenbrut"]) {
|
||||
expect(VOICE_VOCABULARY).toContain(term);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Beekeeping terms the recognizer should expect. Mirrors
|
||||
* ApiCore-Backend/test/fixtures/voice/vocabulary.json (vocabulary.spec.ts
|
||||
* compares both); the backend builds its Whisper prompt from the same list.
|
||||
*/
|
||||
export const VOICE_VOCABULARY: readonly string[] = [
|
||||
"Königin",
|
||||
"Stifte",
|
||||
"Weiselzellen",
|
||||
"Brutwaben",
|
||||
"Futterkranz",
|
||||
"Honigraum",
|
||||
"Varroa",
|
||||
"Schwarmstimmung",
|
||||
"sanftmütig",
|
||||
"Wabensitz",
|
||||
"Drohnenbrut",
|
||||
"Brutnest",
|
||||
"Brutfläche",
|
||||
"Spielnäpfchen",
|
||||
"Schwarmzellen",
|
||||
"Absperrgitter",
|
||||
"Futterteig",
|
||||
"Ableger",
|
||||
"Jungvolk",
|
||||
"Rähmchen",
|
||||
"Mittelwand",
|
||||
"Flugloch",
|
||||
"Beute",
|
||||
"Zarge",
|
||||
"Ameisensäure",
|
||||
"Oxalsäure",
|
||||
"Windel",
|
||||
"Faulbrut",
|
||||
"Kalkbrut",
|
||||
"Nosema",
|
||||
"Wachsmotten",
|
||||
"Pollen",
|
||||
];
|
||||
Reference in new issue
Block a user