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.
@@ -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
+ }
@@ -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.0",
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
+ }
@@ -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
+ }