diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 7add0d1e3..33bd3192d 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -12,6 +12,7 @@ import { installRenderGovernor, uninstallRenderGovernor, } from '../renderGovernor.js'; +import { initGevVoiceCommands } from '../voice/gevRealtime.js'; /** The Navara globe module, loaded on demand. */ const loadNavaraGlobeModule = async () => @@ -109,14 +110,17 @@ export async function createNavaraScene({ /** * The tools without a viewer (the experimental Navara engine): the startup - * chrome, the render governor on the engine, hidden-tab handling and the - * debug handle. The shell shows which features are unavailable. + * chrome, the render governor on the engine, hidden-tab handling, voice on + * the engine and the debug handle. The shell shows which features are + * unavailable. */ export function createViewerlessTools({ scene: { engine, mapStackController, operations }, controls: { styleManager }, data, loadingScreen, + placeSearch, + voice = {}, startChrome, signal, defer, @@ -170,5 +174,26 @@ export function createViewerlessTools({ defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; }); - return { sceneDirector: null, annotations: null, voiceCommands: null }; + // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine; + // the viewer-only ones (director, annotations, cockpit, radio…) refuse. + const voiceCommands = initGevVoiceCommands({ + ...voice, + annotationResolver: operations.annotationResolver, + searchNavigation: operations.searchAndFlyTo, + signal, + placeSearch, + viewer: null, + engine, + styleManager, + dataManager, + sceneDirector: null, + annotations: null, + }); + defer(() => { + voiceCommands.stop({ removeUi: true }); + if (window.__gevVoiceCommands === voiceCommands) + delete window.__gevVoiceCommands; + }); + debug.voiceCommands = voiceCommands; + return { sceneDirector: null, annotations: null, voiceCommands }; } diff --git a/src/app/tools.js b/src/app/tools.js index 0342793ad..0ccca71e0 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -17,8 +17,9 @@ import { /** * Attach scene tools, rendering listeners and the application debug handle. * Without a viewer (the experimental Navara engine) the viewer-bound tools - * (director, annotations, drawing, imagery box, scope mask, tracking, voice, - * embedded views) are not created; their handles are null. + * (director, annotations, drawing, imagery box, scope mask, tracking, + * embedded views) are not created; their handles are null. Voice runs on + * the engine there (src/app/navaraMode.js). */ export function createApplicationTools({ scene, @@ -44,6 +45,8 @@ export function createApplicationTools({ controls, data, loadingScreen, + placeSearch, + voice, startChrome, signal, defer, @@ -189,6 +192,7 @@ export function createApplicationTools({ signal, placeSearch, viewer, + engine, styleManager, dataManager, sceneDirector, diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 9575a1ed5..f9f15124b 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -16,7 +16,7 @@ import { // The tools without a viewer (the experimental Navara engine); they load // from src/app/navaraMode.js. -test('without a viewer the tools run the governor on the engine, show the notice and create no viewer tools', async (t) => { +test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => { const saved = { window: globalThis.window, document: globalThis.document }; const listeners = new Map(); globalThis.window = {}; @@ -34,7 +34,9 @@ test('without a viewer the tools run the governor on the engine, show the notice else globalThis[name] = value; } }); - const engine = createFakeEngine({ kind: 'navara' }); + // The voice pointer listens on the engine's canvas. + const canvas = new EventTarget(); + const engine = createFakeEngine({ kind: 'navara', canvas }); const notices = []; const chrome = []; const cleanups = []; @@ -43,7 +45,27 @@ test('without a viewer the tools run the governor on the engine, show the notice _showGlobalStatusNotice: (label, options) => notices.push({ label, options }), }; - const dataManager = { layers: new Map() }; + const dataManager = { layers: new Map(), getAll: () => [] }; + // Voice without DOM or provider: a control and session stand-in. + const button = new EventTarget(); + button.setAttribute = () => {}; + let uiRemoved = 0; + const voice = { + createControl: () => ({ + button, + root: { dataset: {}, remove: () => uiRemoved++ }, + status: {}, + detail: {}, + }), + createCard: () => null, + createSession: (hooks) => ({ + runner: (voice.runner = hooks.runner), + async start() {}, + stop() {}, + sendText() {}, + sendMapEvent() {}, + }), + }; const tools = await createApplicationTools({ scene: { viewer: null, @@ -52,6 +74,7 @@ test('without a viewer the tools run the governor on the engine, show the notice mapStackController: { kind: 'maps' }, operations: { surface: { kind: 'surface' } }, }, + voice, controls: { styleManager, weatherEffects: null, cockpitCloudEffects: null }, data: { dataManager, presentation: { flushVisible: () => flushed++ } }, loadingScreen: { kind: 'loading' }, @@ -62,11 +85,18 @@ test('without a viewer the tools run the governor on the engine, show the notice signal: new AbortController().signal, defer: (dispose) => cleanups.push(dispose), }); - assert.deepEqual(tools, { - sceneDirector: null, - annotations: null, - voiceCommands: null, + assert.equal(tools.sceneDirector, null); + assert.equal(tools.annotations, null); + assert.ok(tools.voiceCommands, 'voice runs on the engine'); + assert.equal(window.__gevVoiceCommands, tools.voiceCommands); + // The W1 tools drive the engine; the viewer-only ones refuse. + const view = await voice.runner('get_current_view_state'); + assert.equal(view.ok, true); + const scene = await voice.runner('control_scene', { + action: 'list', }); + assert.equal(scene.ok, false); + assert.equal(scene.code, 'UNAVAILABLE_ON_ENGINE'); assert.equal(chrome.length, 1); assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); assert.equal(chrome[0].styleManager, styleManager); @@ -103,9 +133,12 @@ test('without a viewer the tools run the governor on the engine, show the notice assert.equal(debug.engine, engine); assert.equal(debug.sceneDirector, null); assert.equal(debug.dataManager, dataManager); + assert.equal(debug.voiceCommands, tools.voiceCommands); listeners.get('visibilitychange')(); assert.equal(flushed, 1); while (cleanups.length) await cleanups.pop()(); assert.equal(window.__godsEyeView, undefined); + assert.equal(window.__gevVoiceCommands, undefined); + assert.equal(uiRemoved, 1, 'the voice control is removed'); assert.equal(listeners.size, 0); });