Offer voice actions on whichever globe has the capability they need instead of refusing them without a Cesium viewer

This commit is contained in:
German Khokhlov committed 2026-11-03 22:42:25 +04:00
1 parent b20579b06a
commit 59a2e31aa8
5 files changed
+390 -110

No files matched your search

+23 -11
View File
@@ -8,8 +8,7 @@
// - a set_layer_visibility call after each switch ran on the engine shown;
// - a call made during a switch waited and ran on the incoming engine, and
// one whose wait ran out was refused with ENGINE_SWITCHING;
// - annotate_map works on Cesium, is refused on Navara, and the mark drawn
// before the switches is still on the board back on Cesium;
// - annotate_map works on both engines and marks survive each switch;
// - the session heard which engine it drives after each switch.
//
// node scripts/qa-voice-engine-switch.mjs http://127.0.0.1:<port>/
@@ -138,7 +137,7 @@ try {
const annotated = await callTool(page, 'annotate_map', ANNOTATION);
assert.equal(annotated.ok, true, JSON.stringify(annotated));
assert.equal(annotated.engine, 'cesium');
const marks = await page.evaluate(() => window.__gevAnnotations.count());
let marks = await page.evaluate(() => window.__gevAnnotations.count());
assert.ok(marks >= 1, 'the mark is on the board');
results.push('PASS annotate_map drew a mark on Cesium');
@@ -154,14 +153,27 @@ try {
await page.evaluate(() => window.__godsEyeView.engine.kind),
kind,
);
if (kind === 'navara') {
const refused = await callTool(page, 'annotate_map', ANNOTATION);
assert.equal(refused.ok, false, JSON.stringify(refused));
assert.equal(refused.engine, 'navara');
} else {
const count = await page.evaluate(() => window.__gevAnnotations.count());
assert.equal(count, marks, 'the mark survived the round trip');
}
const count = await page.evaluate(() => window.__gevAnnotations.count());
assert.equal(count, marks, 'the marks survived the switch');
const annotation = await callTool(page, 'annotate_map', {
annotations: [
{
type: 'pin',
latitude: 42.3554 + marks * 0.01,
longitude: -71.0605,
label: `QA voice mark ${marks}`,
},
],
});
assert.equal(annotation.ok, true, JSON.stringify(annotation));
assert.equal(annotation.engine, kind);
marks = await page.evaluate(() => window.__gevAnnotations.count());
assert.equal(marks, count + 1, 'voice added a visible mark on this engine');
assert.equal(
await page.evaluate(() => window.__gevAnnotations.attached),
true,
);
results.push(`PASS ${kind}: annotate_map drew a mark`);
results.push(`PASS ${kind}: set_layer_visibility ran on ${layer.engine}`);
}
+81 -71
View File
@@ -44,6 +44,7 @@ import { voiceLayer, voiceLayerAliasMap } from './layerManifest.js';
import { layerFeedState } from '../data/feedState.js';
import {
initCameraVerbs,
cameraVerbsSupported,
moveCamera,
flyRoute,
interruptCameraMotion,
@@ -52,6 +53,7 @@ import {
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { engineCapabilities } from '../globe/contract.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
@@ -233,31 +235,9 @@ const FRAME_TARGETS = new Map([
['ships', 'ais-live-vessels'],
]);
/**
* The tools that still need the Cesium viewer or a subsystem built on it:
* annotations and routes over them, camera verbs, the context mode and CCTV. Without a viewer (the Navara engine) each answers a structured
* refusal (UNAVAILABLE_ON_ENGINE) instead of running.
*/
export const VIEWER_ONLY_ACTIONS = Object.freeze([
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]);
/** The refusal code of a viewer-only tool on an engine without a viewer. */
/** A tool's required capability is missing from the attached scene. */
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
/**
* The tools that change the look of the Cesium scene (post stages, bloom,
* sharpen). The Navara engine has no visual styles yet: without a viewer
* these refuse too, rather than answer ok while changing nothing (and
* recording a style the view does not show).
*/
export const CESIUM_LOOK_ACTIONS = Object.freeze([
'set_visual_style',
'set_post_processing',
]);
/** What each refused tool is, for the refusal's message. */
export const UNAVAILABLE_FEATURES = Object.freeze({
control_scene: 'The scene director',
@@ -265,11 +245,9 @@ export const UNAVAILABLE_FEATURES = Object.freeze({
clear_annotations: 'Map annotation',
fly_route: 'Route flying',
move_camera: 'Camera motion (orbit, pan, tilt)',
set_context_mode: 'The Contacts mode',
control_radio: 'Radio',
control_cctv: 'CCTV',
set_visual_style: 'The visual style',
set_post_processing: 'Post-processing (bloom, sharpen)',
set_cyber_sonar: 'Cyber sonar',
});
/** The engine to switch to for a refused feature. */
@@ -332,8 +310,8 @@ const cameraCartographic = (engine) =>
/**
* Create application actions over the supplied scene and services. The
* actions drive the globe `engine` (by default the one registered for the
* viewer); `viewer` is null on an engine without one (Navara), where the
* VIEWER_ONLY_ACTIONS refuse.
* viewer). Each action checks its scene services and render/camera capabilities;
* a missing capability returns UNAVAILABLE_ON_ENGINE on either engine.
*/
export function createGevActionRunner({
viewer,
@@ -376,11 +354,44 @@ export function createGevActionRunner({
{ memory: analystMemory },
));
installViewTargetPrewarm(engine);
// The camera verbs (move_camera, fly_route) still run on the viewer.
if (viewer)
initCameraVerbs(viewer, () => getViewTargetCartesian(engine), {
// Keep Cesium's viewer rig; viewerless engines use the contract rig.
if (cameraVerbsSupported(viewer ?? engine))
initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), {
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
});
const annotationsAvailable = (method) =>
annotations?.attached !== false &&
typeof annotations?.[method] === 'function';
const renderSupported = (capability, method) =>
typeof styleManager?.[method] === 'function' &&
Boolean(viewer || engineCapabilities(engine)[capability]);
const postEffectSupported = (effect) =>
effect === 'bloom'
? renderSupported('bloom', 'setBloom')
: typeof styleManager?.setSharpen === 'function' &&
Boolean(viewer || typeof engine?.render?.setSharpen === 'function');
function actionSupported(name) {
switch (name) {
case 'annotate_map':
return annotationsAvailable('annotate');
case 'clear_annotations':
return annotationsAvailable('clear');
case 'move_camera':
return cameraVerbsSupported(viewer ?? engine);
case 'fly_route':
return (
cameraVerbsSupported(viewer ?? engine) && annotationsAvailable('list')
);
case 'set_visual_style':
return renderSupported('visualStyles', 'setStyle');
case 'set_cyber_sonar':
return renderSupported('contactSonar', 'setCyberSonar');
case 'control_scene':
return Boolean(sceneDirector);
default:
return true;
}
}
// Nested calls (select_nearest_aircraft composing layer + flight) use the
// raw runner; only the outer call gets a voice envelope.
const voiceUnavailable = (action) => ({
@@ -475,13 +486,7 @@ export function createGevActionRunner({
/* Progress is advisory. */
}
};
if (
!viewer &&
((name === 'control_scene' && !sceneDirector) ||
VIEWER_ONLY_ACTIONS.includes(name) ||
CESIUM_LOOK_ACTIONS.includes(name))
)
return unavailableOnEngine(name, engine);
if (!actionSupported(name)) return unavailableOnEngine(name, engine);
// Navigation tools interrupt any continuous camera motion (spec §1.1) —
// checked FIRST because each handler returns.
@@ -843,6 +848,12 @@ export function createGevActionRunner({
const style = normalizeStyle(args.style);
if (!style)
throw new Error(`Unknown visual style: ${args.style || 'missing'}`);
if (
!viewer &&
style !== 'normal' &&
!engine.render.visualStyles.includes(style)
)
return unavailableOnEngine(name, engine, `The ${style} visual style`);
styleManager.setStyle(style, { userInitiated: true });
return { ok: true, action: 'set_visual_style', style };
}
@@ -1371,13 +1382,6 @@ export function createGevActionRunner({
}
if (name === 'set_cyber_sonar') {
if (typeof styleManager?.setCyberSonar !== 'function') {
return {
ok: false,
action: 'set_cyber_sonar',
error: 'Cyber sonar controls are unavailable.',
};
}
return { action: 'set_cyber_sonar', ...styleManager.setCyberSonar(args) };
}
@@ -1405,35 +1409,39 @@ export function createGevActionRunner({
}
if (name === 'set_post_processing') {
const out = { ok: true, action: 'set_post_processing' };
if (args.bloom && typeof args.bloom === 'object') {
Object.assign(
out,
styleManager.setBloom({
enabled:
typeof args.bloom.enabled === 'boolean'
? args.bloom.enabled
const out = { ok: true, action: 'set_post_processing', effects: {} };
for (const effect of ['bloom', 'sharpen']) {
const request = args[effect];
if (!request || typeof request !== 'object') continue;
const method = effect === 'bloom' ? 'setBloom' : 'setSharpen';
const result = postEffectSupported(effect)
? styleManager[method]({
enabled:
typeof request.enabled === 'boolean'
? request.enabled
: undefined,
intensityPct: Number.isFinite(Number(request.intensityPct))
? Number(request.intensityPct)
: undefined,
intensityPct: Number.isFinite(Number(args.bloom.intensityPct))
? Number(args.bloom.intensityPct)
: undefined,
}),
);
})
: unavailableOnEngine(name, engine, `Post-processing (${effect})`);
out.effects[effect] = result;
if (result[effect] !== undefined) out[effect] = result[effect];
}
if (args.sharpen && typeof args.sharpen === 'object') {
Object.assign(
out,
styleManager.setSharpen({
enabled:
typeof args.sharpen.enabled === 'boolean'
? args.sharpen.enabled
: undefined,
intensityPct: Number.isFinite(Number(args.sharpen.intensityPct))
? Number(args.sharpen.intensityPct)
: undefined,
}),
);
const results = Object.values(out.effects);
const failed = results.filter((result) => result.ok === false);
if (failed.length) {
Object.assign(out, failed[0]);
out.ok = false;
out.partial = results.some((result) => result.ok === true);
out.error = failed.map((result) => result.error).join(' ');
}
if (
!results.length &&
!postEffectSupported('bloom') &&
!postEffectSupported('sharpen')
)
return unavailableOnEngine(name, engine);
return out;
}
@@ -1531,6 +1539,8 @@ export function createGevActionRunner({
let result = await runGevAction(name, deictic.args, runOptions);
if (deictic.used && result && typeof result === 'object')
result = { ...result, resolvedFrom: deictic.used };
if (Object.hasOwn(UNAVAILABLE_FEATURES, name))
result = { engine: engine?.kind ?? null, ...result };
const out = attachVoiceResult(name, result, deictic.args, builders);
// Only an accepted, still-current result may become "the last list": a
// cancelled turn or one finishing after the session ended must not
+270
View File
@@ -0,0 +1,270 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { getActiveCameraMotion, releaseCameraVerbs } from '../cameraVerbs.js';
import { createGevActionRunner, UNAVAILABLE_ON_ENGINE } from './gevActions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
globalThis.window ||= { setTimeout, clearTimeout, requestIdleCallback: null };
const route = {
type: 'route',
label: 'walk',
path: [
{ lat: 30, lon: 10 },
{ lat: 30.01, lon: 10 },
],
};
function fixture(t, kind, { native = kind === 'cesium' } = {}) {
const engine = createFakeEngine({
kind,
canvas: new EventTarget(),
visualStyles: { thermal: [] },
bloom: true,
contactSonar: true,
});
let viewer;
if (native) {
const scene = {
canvas: Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
}),
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(10, 30, 5000),
});
viewer = { scene, camera, clock: { onTick: new Cesium.Event() } };
}
const calls = [];
const annotations = {
attached: true,
list: () => [route],
annotate: async (...args) => {
calls.push(['annotate', ...args]);
return { drawn: 1, failed: 0, results: [{ ok: true }] };
},
clear: () => calls.push(['clear']),
};
const styleManager = {
runImmediateNavigation: (noun, navigate) => navigate(),
setStyle: (style) => {
calls.push(['style', style]);
if (!native && style !== 'normal')
engine.render.setStyle(style, { intensity: 1 });
},
setBloom: (options) => {
calls.push(['bloom', options]);
if (!native) engine.render.setBloom({ enabled: options.enabled });
return { ok: true, bloom: options };
},
setSharpen: (options) => {
calls.push(['sharpen', options]);
engine.render.setSharpen({ enabled: options.enabled });
return { ok: true, sharpen: options };
},
setCyberSonar: (options) => {
calls.push(['sonar', options]);
if (!native) engine.render.setContactSonar(options);
return { ok: true, ...options };
},
};
const build = (options = {}) =>
createGevActionRunner({
viewer,
engine,
annotations,
styleManager,
dataManager: { layers: new Map() },
...options,
floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} },
});
t.after(() => releaseCameraVerbs(viewer ?? engine));
return { engine, viewer, annotations, styleManager, calls, build };
}
const cases = [
[
'annotate_map',
{ annotations: [{ type: 'pin', latitude: 30, longitude: 10 }] },
'annotate',
],
['clear_annotations', {}, 'clear'],
['move_camera', { motion: 'pan', direction: 'left' }, null],
['fly_route', {}, null],
['set_visual_style', { style: 'thermal' }, 'style'],
[
'set_post_processing',
{ bloom: { enabled: true }, sharpen: { enabled: true } },
'bloom',
],
['set_cyber_sonar', { enabled: true }, 'sonar'],
];
for (const kind of ['cesium', 'navara']) {
for (const [action, args, call] of cases) {
test(`${kind}: ${action} runs with its capability and reports the engine`, async (t) => {
const f = fixture(t, kind);
// Cesium must keep working without the optional renderer ports.
if (f.viewer) {
delete f.engine.render.setStyle;
delete f.engine.render.setBloom;
delete f.engine.render.setContactSonar;
}
assert.ok(GEV_REALTIME_TOOLS.some((tool) => tool.name === action));
const result = await f.build()(action, args);
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.engine, kind);
if (call) assert.equal(f.calls[0][0], call);
else
assert.equal(
getActiveCameraMotion().rig,
f.viewer ? 'viewer' : 'engine',
);
if (action === 'set_post_processing') {
assert.deepEqual(
f.calls.map(([name]) => name),
['bloom', 'sharpen'],
);
assert.equal(result.effects.bloom.ok, true);
assert.equal(result.effects.sharpen.ok, true);
}
if (!f.viewer && action === 'set_cyber_sonar')
assert.equal(f.engine.render.contactSonar().enabled, true);
if (!f.viewer && action === 'set_visual_style')
assert.equal(f.engine.render.style('thermal').intensity, 1);
});
test(`${kind}: ${action} refuses a missing capability without side effects`, async (t) => {
const f = fixture(t, kind);
if (action === 'annotate_map') delete f.annotations.annotate;
if (action === 'clear_annotations') delete f.annotations.clear;
if (action === 'fly_route') delete f.annotations.list;
if (action === 'move_camera') {
if (f.viewer) delete f.viewer.clock.onTick;
else delete f.engine.camera.lookAt;
}
if (action === 'set_visual_style') {
if (f.viewer) delete f.styleManager.setStyle;
else delete f.engine.render.setStyle;
}
if (action === 'set_post_processing') {
if (f.viewer) {
delete f.styleManager.setBloom;
delete f.styleManager.setSharpen;
} else {
delete f.engine.render.setBloom;
delete f.engine.render.setSharpen;
}
}
if (action === 'set_cyber_sonar') {
if (f.viewer) delete f.styleManager.setCyberSonar;
else delete f.engine.render.setContactSonar;
}
const run = f.build();
f.engine.fake.resetCalls();
const result = await run(action, args);
assert.equal(result.ok, false);
assert.equal(result.code, UNAVAILABLE_ON_ENGINE);
assert.equal(result.unavailable, true);
assert.equal(result.engine, kind);
assert.equal(result.suggestion, '?engine=cesium');
assert.deepEqual(f.calls, []);
assert.deepEqual(f.engine.fake.calls, []);
});
}
for (const action of ['annotate_map', 'clear_annotations', 'fly_route']) {
test(`${kind}: ${action} refuses a detached annotation board`, async (t) => {
const f = fixture(t, kind);
f.annotations.attached = false;
const result = await f.build()(
action,
cases.find(([name]) => name === action)[1],
);
assert.equal(result.code, UNAVAILABLE_ON_ENGINE);
assert.deepEqual(f.calls, []);
});
}
test(`${kind}: fly_route also requires camera verbs`, async (t) => {
const f = fixture(t, kind);
if (f.viewer) delete f.viewer.clock.onTick;
else delete f.engine.camera.lookAt;
assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE);
});
test(`${kind}: control_scene requires an attached director`, async (t) => {
const f = fixture(t, kind);
const missing = await f.build()('control_scene', { action: 'status' });
assert.equal(missing.code, UNAVAILABLE_ON_ENGINE);
assert.equal(missing.engine, kind);
const result = await f.build({
sceneDirector: {
getPlaybackStatus: () => ({ running: false }),
},
})('control_scene', { action: 'status' });
assert.equal(result.ok, true);
assert.equal(result.engine, kind);
assert.equal(result.running, false);
});
for (const missing of ['bloom', 'sharpen']) {
test(`${kind}: post-processing reports unsupported ${missing} and applies the other effect`, async (t) => {
const f = fixture(t, kind);
const method = missing === 'bloom' ? 'setBloom' : 'setSharpen';
delete (f.viewer ? f.styleManager : f.engine.render)[method];
const result = await f.build()('set_post_processing', {
bloom: { enabled: true },
sharpen: { enabled: true },
});
const supported = missing === 'bloom' ? 'sharpen' : 'bloom';
assert.equal(result.ok, false);
assert.equal(result.partial, true);
assert.equal(result.engine, kind);
assert.equal(result.effects[missing].code, UNAVAILABLE_ON_ENGINE);
assert.equal(result.effects[supported].ok, true);
assert.match(result.error, new RegExp(missing));
assert.deepEqual(
f.calls.map(([name]) => name),
[supported],
);
});
}
}
test('Navara refuses a style absent from its visualStyles list', async (t) => {
const f = fixture(t, 'navara');
assert.equal(
(await f.build()('set_visual_style', { style: 'anime' })).code,
UNAVAILABLE_ON_ENGINE,
);
assert.deepEqual(f.calls, []);
});
test('post-processing keeps a controller failure when the next effect succeeds', async (t) => {
const f = fixture(t, 'navara');
f.styleManager.setBloom = () => ({ ok: false, error: 'Bloom failed' });
const result = await f.build()('set_post_processing', {
bloom: { enabled: true },
sharpen: { enabled: true },
});
assert.equal(result.ok, false);
assert.equal(result.partial, true);
assert.equal(result.error, 'Bloom failed');
assert.equal(result.effects.sharpen.ok, true);
});
+14 -26
View File
@@ -1,7 +1,7 @@
// The W1 voice actions on the globe engine (phase 3, Task 9): the same
// results as on the Cesium viewer, through engine.camera / picker /
// projector only, and a structured refusal for the actions that still need
// a viewer when there is none (the Navara engine).
// projector only, and a structured refusal when a required capability
// is missing from the attached scene.
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -10,10 +10,8 @@ import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import {
CESIUM_LOOK_ACTIONS,
UNAVAILABLE_FEATURES,
UNAVAILABLE_ON_ENGINE,
VIEWER_ONLY_ACTIONS,
createGevActionRunner,
} from './gevActions.js';
import { cameraPoseKey } from './pointerContext.js';
@@ -87,16 +85,9 @@ const follow = (engine, position) =>
viewFrom: { x: 0, y: -2000, z: 800 },
});
test('the viewer-only actions are one exported list of real tools', () => {
assert.deepEqual(VIEWER_ONLY_ACTIONS, [
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]);
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
test('every capability-gated action is a real offered tool', () => {
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
for (const name of VIEWER_ONLY_ACTIONS)
for (const name of Object.keys(UNAVAILABLE_FEATURES))
assert.ok(tools.has(name), `${name} is a voice tool`);
});
@@ -119,7 +110,7 @@ for (const kind of ['cesium', 'navara']) test(`${kind} enters cockpit on a ready
assert.equal(result.state.active, true);
});
test('without a viewer the viewer-only actions refuse with a structured result', async () => {
test('missing scene capabilities refuse with a structured result', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const touched = [];
const styleManager = new Proxy(navigatingStyleManager(), {
@@ -128,9 +119,15 @@ test('without a viewer the viewer-only actions refuse with a structured result',
return target[key];
},
});
delete engine.camera.lookAt;
const runner = runnerOn(engine, { styleManager });
engine.fake.resetCalls();
for (const name of VIEWER_ONLY_ACTIONS) {
for (const name of [
'annotate_map',
'clear_annotations',
'fly_route',
'move_camera',
]) {
const result = await runner(name, { action: 'status' });
assert.equal(result.ok, false, name);
assert.equal(result.action, name);
@@ -149,7 +146,7 @@ test('without a viewer the viewer-only actions refuse with a structured result',
assert.deepEqual(engine.fake.calls, [], 'a refusal moves no camera');
});
test('with a viewer the viewer-only actions still run', async () => {
test('with a viewer radio still reports a missing layer', async () => {
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
@@ -443,16 +440,7 @@ test('a fresh frame for capture comes from the engine render loop', async () =>
}
});
test('every refused tool names its feature', () => {
for (const name of [...VIEWER_ONLY_ACTIONS, ...CESIUM_LOOK_ACTIONS])
assert.ok(UNAVAILABLE_FEATURES[name], `${name} has a feature name`);
assert.deepEqual(CESIUM_LOOK_ACTIONS, [
'set_visual_style',
'set_post_processing',
]);
});
test('without a viewer the visual style and post-processing refuse and record nothing', async () => {
test('missing visual style and bloom capabilities refuse and record nothing', async () => {
const engine = engineOverAustin({ kind: 'navara' });
const calls = [];
const styleManager = navigatingStyleManager({
+2 -2
View File
@@ -169,8 +169,8 @@ export function createSceneVoice(options) {
/**
* Compose the standalone action runner with the voice controls, on the globe
* `engine` (by default the one registered for `options.viewer`). Without a
* viewer (the Navara engine) the runner refuses the viewer-only tools.
* `engine` (by default the one registered for `options.viewer`). The runner
* checks each action against the capabilities of the attached scene.
*/
export function initGevVoiceCommands(options) {
const voice = createSceneVoice(options);