Files
gods-eye-view/src/voice/realtimeInputPolicy.js
T
Sameh Khamis 1de4f635ca feat(voice): render the voice control from a presenter
createVoiceControl now returns a presenter instead of building the
control with innerHTML: the session, cost, input, card and panel owners
write its state by part and listen for interactions, and VoiceControl.vue
renders it in the command dock, between the location bar and the visual
presets as before. Focus moves wait for the render; the waveform's bars
publish apart from the control and only when they change; the minutes
field keeps a draft until its owner writes it. Owner tests use a real
presenter on their own store, and view tests render the control.
2026-10-31 19:22:03 -07:00

175 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t } from '../i18n/index.js';
export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
// The input meter is intentionally stricter than the assistant-output meter:
// microphones carry room tone even after browser noise suppression, whereas the
// incoming Realtime stream is already clean speech audio.
export const MICROPHONE_VISUALIZER_GATE = 0.12;
export const ASSISTANT_VISUALIZER_GATE = 0.04;
/**
* Returns whether a keyboard event represents the hold-Space voice shortcut.
* @param {KeyboardEvent|object|null} event
* @returns {boolean}
*/
export function isPushToTalkKey(event) {
return event?.code === 'Space' || event?.key === ' ';
}
export const SPACE_INTERACTIVE_SELECTOR = [
'button',
'input',
'textarea',
'select',
'option',
'a[href]',
'summary',
'audio[controls]',
'video[controls]',
'[contenteditable]',
'[tabindex]',
'[role="button"]',
'[role="checkbox"]',
'[role="combobox"]',
'[role="link"]',
'[role="listbox"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="radio"]',
'[role="searchbox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="switch"]',
'[role="tab"]',
'[role="textbox"]',
'[role="treeitem"]',
].join(', ');
/**
* Returns whether Space began on the map surface reserved for push-to-talk.
* Cesium gives its canvas `tabindex="0"` for camera input, which otherwise
* makes the generic focus guard treat a map click like a button activation.
*/
export function isPushToTalkSurface(target) {
if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false;
if (target.id === 'world-overlay-canvas') return true;
return Boolean(target.closest?.('#cesiumContainer'));
}
/** Returns whether a focused target owns Space for UI interaction. */
export function isInteractiveSpaceTarget(target) {
if (!target) return false;
if (isPushToTalkSurface(target)) return false;
if (target.isContentEditable) return true;
return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR));
}
/** Returns whether Space belongs to a text-entry surface. */
export function isEditingSpaceTarget(target) {
if (!target) return false;
if (target.isContentEditable || target.closest?.('[contenteditable]'))
return true;
if (
target.closest?.(
'textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]',
)
) {
return true;
}
const input = target.closest?.('input');
if (!input) return false;
const type = String(input.type || 'text').toLowerCase();
return [
'text',
'search',
'email',
'url',
'tel',
'password',
'number',
'date',
'datetime-local',
'month',
'week',
'time',
].includes(type);
}
/** Protects text entry and modified shortcuts from push-to-talk arbitration. */
export function shouldHandlePushToTalkKeyDown(event) {
if (!isPushToTalkKey(event) || event.defaultPrevented) return false;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return false;
return !isEditingSpaceTarget(event.target);
}
/**
* Avoids a click/Space race that could stop an active voice session mid-turn.
* @param {boolean} spaceKeyHeld
* @returns {boolean}
*/
export function shouldIgnoreVoiceButtonClick(spaceKeyHeld) {
return Boolean(spaceKeyHeld);
}
/**
* Selects input or output frequency data for the active voice speaker.
* @param {'idle'|'user'|'ai'} speaker
* @param {{analyser: AnalyserNode|null, data: Uint8Array|null}} input
* @param {{analyser: AnalyserNode|null, data: Uint8Array|null}} output
* @returns {{analyser: AnalyserNode, data: Uint8Array}|null}
*/
export function selectVoiceVisualizerSignal(speaker, input, output) {
const signal = speaker === 'ai' ? output : input;
return signal?.analyser && signal?.data ? signal : null;
}
/**
* Keeps analysing buffered assistant audio after the response-done control event.
* @param {'idle'|'user'|'ai'} currentSpeaker
* @param {'idle'|'user'|'ai'} nextSpeaker
* @param {boolean} keepCurrent
* @returns {'idle'|'user'|'ai'}
*/
export function resolveVoiceVisualizerSpeaker(
currentSpeaker,
nextSpeaker,
keepCurrent = false,
) {
if (keepCurrent && currentSpeaker === 'ai') return 'ai';
return nextSpeaker === 'user' || nextSpeaker === 'ai' ? nextSpeaker : 'idle';
}
/**
* Resolves the in-app help tray copy for the current push-to-talk state.
* @param {boolean} pushToTalkMode
* @param {boolean} pushToTalkKeyHeld
* @returns {string}
*/
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
return pushToTalkMode && pushToTalkKeyHeld
? t('Release Space to send')
: t('Hold Space to speak · tap Space to activate focused controls');
}
/**
* Removes low-level room noise before it can animate the voice meter.
* @param {number} level - Normalized frequency energy (0–1).
* @param {number} threshold - Noise-floor cutoff (0–1).
* @returns {number} Re-normalized audible level (0–1).
*/
export function gateVoiceVisualizerLevel(level, threshold) {
const cleanLevel = Number.isFinite(level)
? Math.min(1, Math.max(0, level))
: 0;
const cleanThreshold = Number.isFinite(threshold)
? Math.min(0.95, Math.max(0, threshold))
: 0;
if (cleanLevel <= cleanThreshold) return 0;
return (cleanLevel - cleanThreshold) / (1 - cleanThreshold);
}