Files
gods-eye-view/src/voice/realtimeInputPolicy.js
T
German KhokhlovandClaude Opus 5.5 13efd411b6 refactor(i18n): key messages by English text; add Russian
Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:56 +04:00

188 lines
5.6 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);
}
/**
* Restores the CSS-owned standby baseline for every visualizer bar.
* @param {Iterable<HTMLElement>|null|undefined} bars
* @returns {void}
*/
export function resetVoiceVisualizerBars(bars) {
if (!bars) return;
for (const bar of bars) {
bar.style.removeProperty('--audio-level');
bar.style.removeProperty('--audio-opacity');
}
}