even-toolkit 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/stt/providers/whisper-local/provider.js +1 -1
- package/glasses/action-bar.ts +57 -0
- package/glasses/action-map.ts +41 -0
- package/glasses/bridge.ts +306 -0
- package/glasses/canvas-renderer.ts +86 -0
- package/glasses/composer.ts +69 -0
- package/glasses/gestures.ts +60 -0
- package/glasses/index.ts +10 -0
- package/glasses/keep-alive.ts +30 -0
- package/glasses/keyboard.ts +64 -0
- package/glasses/layout.ts +121 -0
- package/glasses/paginate-text.ts +85 -0
- package/glasses/png-utils.ts +97 -0
- package/glasses/splash.ts +298 -0
- package/glasses/text-clean.ts +38 -0
- package/glasses/text-utils.ts +50 -0
- package/glasses/timer-display.ts +91 -0
- package/glasses/types.ts +59 -0
- package/glasses/upng.d.ts +19 -0
- package/glasses/useFlashPhase.ts +30 -0
- package/glasses/useGlasses.ts +214 -0
- package/package.json +3 -1
- package/stt/audio/buffer.ts +40 -0
- package/stt/audio/pcm-utils.ts +60 -0
- package/stt/audio/resample.ts +18 -0
- package/stt/audio/vad.ts +61 -0
- package/stt/engine.ts +274 -0
- package/stt/i18n.ts +39 -0
- package/stt/index.ts +10 -0
- package/stt/providers/deepgram.ts +178 -0
- package/stt/providers/web-speech.ts +221 -0
- package/stt/providers/whisper-api.ts +146 -0
- package/stt/providers/whisper-local/provider.ts +226 -0
- package/stt/providers/whisper-local/worker.ts +40 -0
- package/stt/react/useSTT.ts +113 -0
- package/stt/registry.ts +24 -0
- package/stt/sources/glass-bridge.ts +67 -0
- package/stt/sources/microphone.ts +75 -0
- package/stt/types.ts +104 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared text utilities for G2 glasses display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Truncate text to maxLen, appending ~ if truncated */
|
|
6
|
+
export function truncate(text: string, maxLen: number): string {
|
|
7
|
+
return text.length > maxLen ? text.slice(0, maxLen - 1) + '~' : text;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Up arrow scroll indicator */
|
|
11
|
+
export const SCROLL_UP = '\u25B2'; // ▲
|
|
12
|
+
|
|
13
|
+
/** Down arrow scroll indicator */
|
|
14
|
+
export const SCROLL_DOWN = '\u25BC'; // ▼
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a header line with title on the left and action bar on the right.
|
|
18
|
+
*
|
|
19
|
+
* @param title Left-side text (e.g. "Step 1/4: Toast the pepper")
|
|
20
|
+
* @param actionBar Right-side action bar string from buildActionBar()
|
|
21
|
+
*/
|
|
22
|
+
export function buildHeaderLine(title: string, actionBar: string): string {
|
|
23
|
+
return `${title} ${actionBar}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply scroll indicators to a windowed line array.
|
|
28
|
+
* Replaces the first/last visible line with ▲/▼ if there's more content above/below.
|
|
29
|
+
*
|
|
30
|
+
* @param lines The visible lines array (will be mutated)
|
|
31
|
+
* @param start Start index into the full content
|
|
32
|
+
* @param totalCount Total number of content lines
|
|
33
|
+
* @param visibleCount Number of visible lines in the window
|
|
34
|
+
* @param lineFactory Function to create a DisplayLine (e.g. `(text) => line(text, 'meta', false)`)
|
|
35
|
+
*/
|
|
36
|
+
export function applyScrollIndicators<T>(
|
|
37
|
+
lines: T[],
|
|
38
|
+
start: number,
|
|
39
|
+
totalCount: number,
|
|
40
|
+
visibleCount: number,
|
|
41
|
+
lineFactory: (text: string) => T,
|
|
42
|
+
): void {
|
|
43
|
+
if (lines.length === 0) return;
|
|
44
|
+
if (start > 0) {
|
|
45
|
+
lines[0] = lineFactory(SCROLL_UP);
|
|
46
|
+
}
|
|
47
|
+
if (start + visibleCount < totalCount) {
|
|
48
|
+
lines[lines.length - 1] = lineFactory(SCROLL_DOWN);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unicode timer display for G2 glasses.
|
|
3
|
+
*
|
|
4
|
+
* Confirmed working on G2: █ (full block), ─ (box drawing horizontal)
|
|
5
|
+
* NOT working on G2: ░ ▒ ▓ (shading), ╔═╗║ (double box drawing), ▀▄ (half blocks)
|
|
6
|
+
*
|
|
7
|
+
* Renders as 2 lines — text centered using ─ padding to match bar width:
|
|
8
|
+
* ─────── ▶ 06:44 ───────
|
|
9
|
+
* ████████████────────────
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BLOCK_FULL = '\u2588'; // █ (filled portion)
|
|
13
|
+
const LINE_THIN = '\u2500'; // ─ (remaining portion + centering filler)
|
|
14
|
+
const ICON_PLAY = '\u25B6'; // ▶
|
|
15
|
+
const ICON_PAUSE = '\u2588'; // █ (single block for paused)
|
|
16
|
+
const ICON_DONE = 'OK';
|
|
17
|
+
const ICON_IDLE = '--';
|
|
18
|
+
|
|
19
|
+
export interface TimerState {
|
|
20
|
+
running: boolean;
|
|
21
|
+
remaining: number;
|
|
22
|
+
total: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatTime(seconds: number): string {
|
|
26
|
+
const m = Math.floor(seconds / 60);
|
|
27
|
+
const s = seconds % 60;
|
|
28
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Center text above bar — spaces are ~4.5x narrower than █/─ on G2 font */
|
|
32
|
+
function center(text: string, barWidth: number): string {
|
|
33
|
+
const pad = Math.max(0, Math.floor((barWidth - text.length) / 2));
|
|
34
|
+
return ' '.repeat(Math.round(pad * 6.7)) + text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a 2-line timer display for the G2 glasses.
|
|
39
|
+
* Line 1: ─── icon MM:SS ─── (centered with ─ filler, same visual width as bar)
|
|
40
|
+
* Line 2: ████████████──────── (progress bar)
|
|
41
|
+
*
|
|
42
|
+
* @param timer Current timer state
|
|
43
|
+
* @param barWidth Number of characters for the progress bar (default 24)
|
|
44
|
+
*/
|
|
45
|
+
export function renderTimerLines(timer: TimerState, barWidth = 18): string[] {
|
|
46
|
+
const { running, remaining, total } = timer;
|
|
47
|
+
|
|
48
|
+
if (total === 0 && remaining === 0) {
|
|
49
|
+
return [
|
|
50
|
+
center(` ${ICON_IDLE} 00:00 `, barWidth),
|
|
51
|
+
LINE_THIN.repeat(barWidth),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (remaining <= 0 && total > 0) {
|
|
56
|
+
return [
|
|
57
|
+
center(` ${ICON_DONE} 00:00 `, barWidth),
|
|
58
|
+
BLOCK_FULL.repeat(barWidth),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const icon = running ? ICON_PLAY : ICON_PAUSE;
|
|
63
|
+
const time = formatTime(remaining);
|
|
64
|
+
const progress = total > 0 ? (total - remaining) / total : 0;
|
|
65
|
+
const filled = Math.round(progress * barWidth);
|
|
66
|
+
const empty = barWidth - filled;
|
|
67
|
+
const bar = BLOCK_FULL.repeat(filled) + LINE_THIN.repeat(empty);
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
center(` ${icon} ${time} `, barWidth),
|
|
71
|
+
bar,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render a single-line compact timer (for tight spaces).
|
|
77
|
+
*/
|
|
78
|
+
export function renderTimerCompact(timer: TimerState): string {
|
|
79
|
+
const { running, remaining, total } = timer;
|
|
80
|
+
|
|
81
|
+
if (total === 0 && remaining === 0) {
|
|
82
|
+
return `${ICON_IDLE} 00:00`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (remaining <= 0 && total > 0) {
|
|
86
|
+
return `${ICON_DONE} DONE`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const icon = running ? ICON_PLAY : ICON_PAUSE;
|
|
90
|
+
return `${icon} ${formatTime(remaining)}`;
|
|
91
|
+
}
|
package/glasses/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ── Display line types (legacy, still used for single-text pages) ──
|
|
2
|
+
|
|
3
|
+
export type LineStyle = 'normal' | 'meta' | 'separator' | 'inverted';
|
|
4
|
+
|
|
5
|
+
export interface DisplayLine {
|
|
6
|
+
text: string;
|
|
7
|
+
inverted: boolean;
|
|
8
|
+
style: LineStyle;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DisplayData {
|
|
12
|
+
lines: DisplayLine[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function line(text: string, style: LineStyle = 'normal', inverted = false): DisplayLine {
|
|
16
|
+
return { text, inverted, style };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function separator(): DisplayLine {
|
|
20
|
+
return { text: '', inverted: false, style: 'separator' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Column layout types (for multi-text-container pages) ──
|
|
24
|
+
|
|
25
|
+
export interface ColumnData {
|
|
26
|
+
/** One string per column — each column is a separate text container at a fixed pixel position */
|
|
27
|
+
columns: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Image tile types (for chart/image pages) ──
|
|
31
|
+
|
|
32
|
+
export interface ImageTileData {
|
|
33
|
+
tiles: { id: number; name: string; bytes: Uint8Array }[];
|
|
34
|
+
/** Text shown below images (or empty for no-bounce overlay) */
|
|
35
|
+
text?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Page layout modes ──
|
|
39
|
+
|
|
40
|
+
export type PageMode =
|
|
41
|
+
| 'splash' // initial splash screen (text or image)
|
|
42
|
+
| 'text' // single full-screen text container (settings, simple screens)
|
|
43
|
+
| 'columns' // multiple side-by-side text containers (watchlist, tables)
|
|
44
|
+
| 'home' // image tile + text + empty overlay (home screens)
|
|
45
|
+
| 'chart'; // 3 image tiles + text (chart detail)
|
|
46
|
+
|
|
47
|
+
// ── Glass action types ──
|
|
48
|
+
|
|
49
|
+
export type GlassActionType = 'HIGHLIGHT_MOVE' | 'SELECT_HIGHLIGHTED' | 'GO_BACK';
|
|
50
|
+
|
|
51
|
+
export type GlassAction =
|
|
52
|
+
| { type: 'HIGHLIGHT_MOVE'; direction: 'up' | 'down' }
|
|
53
|
+
| { type: 'SELECT_HIGHLIGHTED' }
|
|
54
|
+
| { type: 'GO_BACK' };
|
|
55
|
+
|
|
56
|
+
export interface GlassNavState {
|
|
57
|
+
highlightedIndex: number;
|
|
58
|
+
screen: string;
|
|
59
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module 'upng-js' {
|
|
2
|
+
export function encode(
|
|
3
|
+
imgs: ArrayBuffer[],
|
|
4
|
+
w: number,
|
|
5
|
+
h: number,
|
|
6
|
+
cnum: number,
|
|
7
|
+
dels?: number[],
|
|
8
|
+
forbidPlte?: boolean,
|
|
9
|
+
): ArrayBuffer;
|
|
10
|
+
export function decode(buffer: ArrayBuffer): {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
depth: number;
|
|
14
|
+
ctype: number;
|
|
15
|
+
frames: Array<{ rect: { x: number; y: number; width: number; height: number }; delay: number }>;
|
|
16
|
+
data: ArrayBuffer;
|
|
17
|
+
};
|
|
18
|
+
export function toRGBA8(img: ReturnType<typeof decode>): ArrayBuffer[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared flash phase hook for blinking action button indicators.
|
|
3
|
+
* Toggles a boolean every 500ms when active.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const flashPhase = useFlashPhase(isInActiveMode);
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useEffect } from 'react';
|
|
10
|
+
|
|
11
|
+
const FLASH_INTERVAL_MS = 500;
|
|
12
|
+
|
|
13
|
+
export function useFlashPhase(active: boolean): boolean {
|
|
14
|
+
const [phase, setPhase] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active) {
|
|
18
|
+
setPhase(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const interval = setInterval(() => {
|
|
23
|
+
setPhase((prev) => !prev);
|
|
24
|
+
}, FLASH_INTERVAL_MS);
|
|
25
|
+
|
|
26
|
+
return () => clearInterval(interval);
|
|
27
|
+
}, [active]);
|
|
28
|
+
|
|
29
|
+
return phase;
|
|
30
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
|
+
import type { DisplayData, GlassAction, GlassNavState, ColumnData } from './types';
|
|
4
|
+
import { EvenHubBridge, type ColumnConfig } from './bridge';
|
|
5
|
+
import { mapGlassEvent } from './action-map';
|
|
6
|
+
import { bindKeyboard } from './keyboard';
|
|
7
|
+
import { activateKeepAlive, deactivateKeepAlive } from './keep-alive';
|
|
8
|
+
import type { SplashHandle } from './splash';
|
|
9
|
+
|
|
10
|
+
export interface UseGlassesConfig<S> {
|
|
11
|
+
getSnapshot: () => S;
|
|
12
|
+
/** Convert snapshot to single text display (for 'text' mode) */
|
|
13
|
+
toDisplayData: (snapshot: S, nav: GlassNavState) => DisplayData;
|
|
14
|
+
/** Convert snapshot to column data (for 'columns' mode) — optional */
|
|
15
|
+
toColumns?: (snapshot: S, nav: GlassNavState) => ColumnData;
|
|
16
|
+
onGlassAction: (action: GlassAction, nav: GlassNavState, snapshot: S) => GlassNavState;
|
|
17
|
+
deriveScreen: (path: string) => string;
|
|
18
|
+
appName: string;
|
|
19
|
+
/** Page mode per screen — return 'text', 'columns', or 'home'. Default: 'text' */
|
|
20
|
+
getPageMode?: (screen: string) => 'text' | 'columns' | 'home';
|
|
21
|
+
/** Column layout config — default: 3 equal columns across 576px */
|
|
22
|
+
columns?: ColumnConfig[];
|
|
23
|
+
/** Home page image tiles — sent when getPageMode returns 'home'. Create with createSplash().getTiles() */
|
|
24
|
+
homeImageTiles?: { id: number; name: string; bytes: Uint8Array; x: number; y: number; w: number; h: number }[];
|
|
25
|
+
/**
|
|
26
|
+
* Optional image-based splash screen.
|
|
27
|
+
* When provided, shows the splash image instead of the default text splash,
|
|
28
|
+
* then waits minTimeMs before switching to app content.
|
|
29
|
+
* Create with `createSplash()` from 'even-toolkit/splash'.
|
|
30
|
+
*/
|
|
31
|
+
splash?: SplashHandle;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useGlasses<S>(config: UseGlassesConfig<S>): void {
|
|
35
|
+
const location = useLocation();
|
|
36
|
+
const navigate = useNavigate();
|
|
37
|
+
|
|
38
|
+
const hubRef = useRef<EvenHubBridge | null>(null);
|
|
39
|
+
const navRef = useRef<GlassNavState>({ highlightedIndex: 0, screen: '' });
|
|
40
|
+
const lastSnapshotRef = useRef<S | null>(null);
|
|
41
|
+
const sendingRef = useRef(false);
|
|
42
|
+
const pendingRef = useRef(false);
|
|
43
|
+
const navigateRef = useRef(navigate);
|
|
44
|
+
navigateRef.current = navigate;
|
|
45
|
+
|
|
46
|
+
const configRef = useRef(config);
|
|
47
|
+
configRef.current = config;
|
|
48
|
+
const lastHadImagesRef = useRef(false);
|
|
49
|
+
|
|
50
|
+
const sendDisplay = useCallback(async () => {
|
|
51
|
+
if (sendingRef.current || !hubRef.current) {
|
|
52
|
+
// Queue a retry — the current in-flight send has stale data
|
|
53
|
+
pendingRef.current = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
sendingRef.current = true;
|
|
57
|
+
pendingRef.current = false;
|
|
58
|
+
try {
|
|
59
|
+
const hub = hubRef.current;
|
|
60
|
+
const snapshot = configRef.current.getSnapshot();
|
|
61
|
+
const nav = navRef.current;
|
|
62
|
+
const getMode = configRef.current.getPageMode ?? (() => 'text');
|
|
63
|
+
const mode = getMode(nav.screen);
|
|
64
|
+
|
|
65
|
+
// Build display text from lines
|
|
66
|
+
const data = configRef.current.toDisplayData(snapshot, nav);
|
|
67
|
+
const text = data.lines.map(l => {
|
|
68
|
+
if (l.style === 'separator') return '\u2500'.repeat(44);
|
|
69
|
+
if (l.inverted) return `\u25B6 ${l.text}`;
|
|
70
|
+
return ` ${l.text}`;
|
|
71
|
+
}).join('\n');
|
|
72
|
+
|
|
73
|
+
if (mode === 'columns' && configRef.current.toColumns) {
|
|
74
|
+
const cols = configRef.current.toColumns(snapshot, nav);
|
|
75
|
+
if (hub.currentMode === 'columns') {
|
|
76
|
+
await hub.updateColumns(cols.columns);
|
|
77
|
+
} else {
|
|
78
|
+
await hub.showColumnPage(cols.columns);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
// All modes use raw bridge (home layout) for consistent rendering.
|
|
82
|
+
// 'home' mode includes image tiles; 'text' mode passes no images.
|
|
83
|
+
const tiles = mode === 'home' ? configRef.current.homeImageTiles : undefined;
|
|
84
|
+
const imageTiles = tiles?.map(t => ({ id: t.id, name: t.name, x: t.x, y: t.y, w: t.w, h: t.h }));
|
|
85
|
+
const hasImages = !!imageTiles?.length;
|
|
86
|
+
// Rebuild container when switching modes or changing between image/no-image layouts
|
|
87
|
+
const needsRebuild = hub.currentMode !== 'home' || hasImages !== lastHadImagesRef.current;
|
|
88
|
+
|
|
89
|
+
if (!needsRebuild) {
|
|
90
|
+
await hub.updateHomeText(text);
|
|
91
|
+
} else {
|
|
92
|
+
await hub.showHomePage(text, imageTiles);
|
|
93
|
+
if (tiles) {
|
|
94
|
+
for (const tile of tiles) {
|
|
95
|
+
await hub.sendImage(tile.id, tile.name, tile.bytes);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lastHadImagesRef.current = hasImages;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// SDK unavailable — glasses panel won't update, web still works
|
|
103
|
+
} finally {
|
|
104
|
+
sendingRef.current = false;
|
|
105
|
+
// If a send was queued while we were busy, flush again with fresh data
|
|
106
|
+
if (pendingRef.current) {
|
|
107
|
+
pendingRef.current = false;
|
|
108
|
+
sendDisplay();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const flushDisplay = useCallback(() => {
|
|
114
|
+
sendDisplay();
|
|
115
|
+
}, [sendDisplay]);
|
|
116
|
+
|
|
117
|
+
const handleAction = useCallback((action: GlassAction) => {
|
|
118
|
+
const snapshot = configRef.current.getSnapshot();
|
|
119
|
+
const newNav = configRef.current.onGlassAction(action, navRef.current, snapshot);
|
|
120
|
+
navRef.current = newNav;
|
|
121
|
+
flushDisplay();
|
|
122
|
+
}, [flushDisplay]);
|
|
123
|
+
|
|
124
|
+
// Update screen from URL changes
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const newScreen = configRef.current.deriveScreen(location.pathname);
|
|
127
|
+
if (newScreen !== navRef.current.screen) {
|
|
128
|
+
navRef.current = { highlightedIndex: 0, screen: newScreen };
|
|
129
|
+
flushDisplay();
|
|
130
|
+
}
|
|
131
|
+
}, [location.pathname, flushDisplay]);
|
|
132
|
+
|
|
133
|
+
// Initialize bridge, keyboard, keep-alive, and polling
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
|
|
138
|
+
const hub = new EvenHubBridge(configRef.current.columns);
|
|
139
|
+
hubRef.current = hub;
|
|
140
|
+
|
|
141
|
+
navRef.current = {
|
|
142
|
+
highlightedIndex: 0,
|
|
143
|
+
screen: configRef.current.deriveScreen(location.pathname),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
async function initBridge() {
|
|
147
|
+
try {
|
|
148
|
+
await hub.init();
|
|
149
|
+
if (disposed) return;
|
|
150
|
+
|
|
151
|
+
const splash = configRef.current.splash;
|
|
152
|
+
|
|
153
|
+
if (splash) {
|
|
154
|
+
// Image-based splash: show canvas-rendered image, then wait minTime
|
|
155
|
+
await splash.show(hub);
|
|
156
|
+
if (disposed) return;
|
|
157
|
+
|
|
158
|
+
hub.onEvent((event) => {
|
|
159
|
+
const action = mapGlassEvent(event);
|
|
160
|
+
if (action) handleAction(action);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await splash.waitMinTime();
|
|
164
|
+
if (disposed) return;
|
|
165
|
+
|
|
166
|
+
// Clear extra splash tiles (e.g. "Loading..." tile) with black — no rebuild
|
|
167
|
+
await splash.clearExtras(hub);
|
|
168
|
+
|
|
169
|
+
// Splash already set up the home layout — mark it so first render
|
|
170
|
+
// uses updateHomeText instead of rebuilding (avoids blink)
|
|
171
|
+
lastHadImagesRef.current = !!configRef.current.homeImageTiles?.length;
|
|
172
|
+
} else {
|
|
173
|
+
// Default text splash
|
|
174
|
+
await hub.showTextPage(`\n\n ${configRef.current.appName}`);
|
|
175
|
+
if (disposed) return;
|
|
176
|
+
|
|
177
|
+
hub.onEvent((event) => {
|
|
178
|
+
const action = mapGlassEvent(event);
|
|
179
|
+
if (action) handleAction(action);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// SDK not available — app continues without glasses
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Start polling for state changes
|
|
187
|
+
if (!disposed) {
|
|
188
|
+
flushDisplay();
|
|
189
|
+
pollTimer = setInterval(() => {
|
|
190
|
+
const snapshot = configRef.current.getSnapshot();
|
|
191
|
+
if (snapshot !== lastSnapshotRef.current) {
|
|
192
|
+
lastSnapshotRef.current = snapshot;
|
|
193
|
+
flushDisplay();
|
|
194
|
+
}
|
|
195
|
+
}, 100);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
initBridge();
|
|
200
|
+
|
|
201
|
+
const unbindKeyboard = bindKeyboard(handleAction);
|
|
202
|
+
activateKeepAlive(`${configRef.current.appName}_keep_alive`);
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
disposed = true;
|
|
206
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
207
|
+
unbindKeyboard();
|
|
208
|
+
hub.dispose();
|
|
209
|
+
hubRef.current = null;
|
|
210
|
+
deactivateKeepAlive();
|
|
211
|
+
};
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, []);
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "even-toolkit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Design system & component library for Even Realities G2 smart glasses apps — 55+ web components, 191 pixel-art icons, glasses SDK bridge, and design tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/glasses/index.js",
|
|
@@ -338,6 +338,8 @@
|
|
|
338
338
|
"files": [
|
|
339
339
|
"dist",
|
|
340
340
|
"web",
|
|
341
|
+
"glasses",
|
|
342
|
+
"stt",
|
|
341
343
|
"README.md",
|
|
342
344
|
"LICENSE"
|
|
343
345
|
],
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { float32ToWav } from './pcm-utils';
|
|
2
|
+
|
|
3
|
+
/** Audio accumulator — collects Float32 chunks and exports as WAV */
|
|
4
|
+
export function createAudioBuffer(config?: { maxSeconds?: number; sampleRate?: number }) {
|
|
5
|
+
const sampleRate = config?.sampleRate ?? 16000;
|
|
6
|
+
const maxSamples = (config?.maxSeconds ?? 30) * sampleRate;
|
|
7
|
+
const chunks: Float32Array[] = [];
|
|
8
|
+
let totalSamples = 0;
|
|
9
|
+
|
|
10
|
+
function append(chunk: Float32Array): void {
|
|
11
|
+
if (totalSamples + chunk.length > maxSamples) return;
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
totalSamples += chunk.length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getAll(): Float32Array {
|
|
17
|
+
const out = new Float32Array(totalSamples);
|
|
18
|
+
let offset = 0;
|
|
19
|
+
for (const chunk of chunks) {
|
|
20
|
+
out.set(chunk, offset);
|
|
21
|
+
offset += chunk.length;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getWav(): Blob {
|
|
27
|
+
return float32ToWav(getAll(), sampleRate);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clear(): void {
|
|
31
|
+
chunks.length = 0;
|
|
32
|
+
totalSamples = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function duration(): number {
|
|
36
|
+
return totalSamples / sampleRate;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { append, getAll, getWav, clear, duration };
|
|
40
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Convert Uint8Array of 16-bit signed PCM to Int16Array */
|
|
2
|
+
export function uint8ToPcm16(data: Uint8Array): Int16Array {
|
|
3
|
+
return new Int16Array(data.buffer, data.byteOffset, data.byteLength / 2);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Convert Int16Array PCM to Float32Array [-1, 1] */
|
|
7
|
+
export function pcm16ToFloat32(data: Int16Array): Float32Array {
|
|
8
|
+
const out = new Float32Array(data.length);
|
|
9
|
+
for (let i = 0; i < data.length; i++) {
|
|
10
|
+
out[i] = data[i] / 32768;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Convert Float32Array [-1, 1] to Int16Array PCM */
|
|
16
|
+
export function float32ToPcm16(data: Float32Array): Int16Array {
|
|
17
|
+
const out = new Int16Array(data.length);
|
|
18
|
+
for (let i = 0; i < data.length; i++) {
|
|
19
|
+
const s = Math.max(-1, Math.min(1, data[i]));
|
|
20
|
+
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Convert Float32Array to WAV Blob */
|
|
26
|
+
export function float32ToWav(data: Float32Array, sampleRate: number): Blob {
|
|
27
|
+
const pcm = float32ToPcm16(data);
|
|
28
|
+
const buffer = new ArrayBuffer(44 + pcm.byteLength);
|
|
29
|
+
const view = new DataView(buffer);
|
|
30
|
+
|
|
31
|
+
// RIFF header
|
|
32
|
+
writeString(view, 0, 'RIFF');
|
|
33
|
+
view.setUint32(4, 36 + pcm.byteLength, true);
|
|
34
|
+
writeString(view, 8, 'WAVE');
|
|
35
|
+
|
|
36
|
+
// fmt chunk
|
|
37
|
+
writeString(view, 12, 'fmt ');
|
|
38
|
+
view.setUint32(16, 16, true); // chunk size
|
|
39
|
+
view.setUint16(20, 1, true); // PCM format
|
|
40
|
+
view.setUint16(22, 1, true); // mono
|
|
41
|
+
view.setUint32(24, sampleRate, true); // sample rate
|
|
42
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
43
|
+
view.setUint16(32, 2, true); // block align
|
|
44
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
45
|
+
|
|
46
|
+
// data chunk
|
|
47
|
+
writeString(view, 36, 'data');
|
|
48
|
+
view.setUint32(40, pcm.byteLength, true);
|
|
49
|
+
|
|
50
|
+
const bytes = new Uint8Array(buffer, 44);
|
|
51
|
+
bytes.set(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
|
52
|
+
|
|
53
|
+
return new Blob([buffer], { type: 'audio/wav' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeString(view: DataView, offset: number, str: string): void {
|
|
57
|
+
for (let i = 0; i < str.length; i++) {
|
|
58
|
+
view.setUint8(offset + i, str.charCodeAt(i));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Linear interpolation resample from one sample rate to another */
|
|
2
|
+
export function resample(input: Float32Array, fromRate: number, toRate: number): Float32Array {
|
|
3
|
+
if (fromRate === toRate) return input;
|
|
4
|
+
|
|
5
|
+
const ratio = fromRate / toRate;
|
|
6
|
+
const outputLength = Math.round(input.length / ratio);
|
|
7
|
+
const output = new Float32Array(outputLength);
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < outputLength; i++) {
|
|
10
|
+
const srcIndex = i * ratio;
|
|
11
|
+
const srcFloor = Math.floor(srcIndex);
|
|
12
|
+
const srcCeil = Math.min(srcFloor + 1, input.length - 1);
|
|
13
|
+
const frac = srcIndex - srcFloor;
|
|
14
|
+
output[i] = input[srcFloor] * (1 - frac) + input[srcCeil] * frac;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return output;
|
|
18
|
+
}
|
package/stt/audio/vad.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Energy-based Voice Activity Detection */
|
|
2
|
+
|
|
3
|
+
export interface VADConfig {
|
|
4
|
+
silenceThresholdMs?: number; // default 1500
|
|
5
|
+
speechThresholdDb?: number; // default -26
|
|
6
|
+
frameSizeMs?: number; // default 30
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface VADResult {
|
|
10
|
+
isSpeech: boolean;
|
|
11
|
+
speechStarted: boolean;
|
|
12
|
+
speechEnded: boolean;
|
|
13
|
+
energy: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createVAD(config?: VADConfig) {
|
|
17
|
+
const silenceMs = config?.silenceThresholdMs ?? 1500;
|
|
18
|
+
const thresholdDb = config?.speechThresholdDb ?? -26;
|
|
19
|
+
const threshold = Math.pow(10, thresholdDb / 20);
|
|
20
|
+
|
|
21
|
+
let speaking = false;
|
|
22
|
+
let silenceStart = 0;
|
|
23
|
+
|
|
24
|
+
function process(frame: Float32Array): VADResult {
|
|
25
|
+
// RMS energy
|
|
26
|
+
let sum = 0;
|
|
27
|
+
for (let i = 0; i < frame.length; i++) {
|
|
28
|
+
sum += frame[i] * frame[i];
|
|
29
|
+
}
|
|
30
|
+
const rms = Math.sqrt(sum / frame.length);
|
|
31
|
+
const isSpeech = rms > threshold;
|
|
32
|
+
|
|
33
|
+
let speechStarted = false;
|
|
34
|
+
let speechEnded = false;
|
|
35
|
+
|
|
36
|
+
if (isSpeech) {
|
|
37
|
+
if (!speaking) {
|
|
38
|
+
speaking = true;
|
|
39
|
+
speechStarted = true;
|
|
40
|
+
}
|
|
41
|
+
silenceStart = 0;
|
|
42
|
+
} else if (speaking) {
|
|
43
|
+
if (silenceStart === 0) {
|
|
44
|
+
silenceStart = Date.now();
|
|
45
|
+
} else if (Date.now() - silenceStart > silenceMs) {
|
|
46
|
+
speaking = false;
|
|
47
|
+
speechEnded = true;
|
|
48
|
+
silenceStart = 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { isSpeech, speechStarted, speechEnded, energy: rms };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function reset() {
|
|
56
|
+
speaking = false;
|
|
57
|
+
silenceStart = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { process, reset };
|
|
61
|
+
}
|