Files
gods-eye-view/src/voice/narration.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00

271 lines
7.3 KiB
JavaScript

import { progressLine } from './speech.js';
/**
* Timing policy for filling silence while tools run. Tuned against the turn
* spans' `max_silence_ms`.
*/
const NARRATION_TIMING = Object.freeze({
/** Start the earcon when nothing is audible this long after end of speech. */
earconAfterMs: 700,
/** First spoken progress line when a tool is still running after this. */
firstLineMs: 1500,
/** Minimum spacing between spoken lines. */
stepGapMs: 3000,
/** Spoken step lines per turn, not counting the one "still working" line. */
maxStepLines: 2,
/** One "still working" line when a tool runs this long. */
stillWorkingMs: 8000,
/** The earcon never runs longer than this in one turn. */
earconMaxMs: 15000,
/** After the last tool, the earcon waits this long for the reply to start. */
replyGraceMs: 3000,
});
/**
* Provider-neutral progress narration for one voice session.
*
* Three voices, cheapest last: the model's own preamble (detected, never
* generated here), code-authored progress lines for the current tool step,
* and a quiet earcon when nothing else is audible. The adapter supplies how a
* line is spoken (`speak`) and when speaking is allowed (`canSpeak`: not while
* the user is talking or holding push-to-talk, and not over another reply).
*
* Staleness: every timer re-checks the turn generation and that its tool is
* still running. The last `toolFinished()` clears pending lines and calls
* `retract`, which drops a line nobody has heard but lets an audible one
* finish. `cancel()` (barge-in, new user turn, session stop) is new intent:
* it calls `stop`, which ends the line even if it is playing.
*/
export function createNarrationScheduler({
speak,
retract = () => {},
stop = retract,
earcon = null,
canSpeak = () => true,
onNarration = () => {},
now = () => performance.now(),
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (id) => clearTimeout(id),
timing = NARRATION_TIMING,
} = {}) {
let generation = 0;
let tools = new Map();
let timers = new Set();
let audibleNow = false;
let preambleSpoken = false;
let stepLines = 0;
let stillSpoken = false;
let lastLineAt = -Infinity;
let firstLineScheduled = false;
let earconOn = false;
let earconStartedAt = 0;
function later(ms, fn) {
const epoch = generation;
const id = setTimer(
() => {
timers.delete(id);
if (epoch === generation) fn();
},
Math.max(0, ms),
);
timers.add(id);
return id;
}
function clearTimers() {
for (const id of timers) clearTimer(id);
timers = new Set();
}
function stopEarcon() {
if (!earconOn) return;
earconOn = false;
try {
earcon?.stop();
} catch {
/* Audio feedback is best effort. */
}
}
function startEarcon() {
if (earconOn || audibleNow || !earcon) return;
if (earcon.available && !earcon.available()) return;
try {
earcon.start();
earconOn = true;
earconStartedAt = now();
onNarration('earcon');
later(timing.earconMaxMs, stopEarcon);
} catch {
earconOn = false;
}
}
function currentTool() {
let latest = null;
for (const tool of tools.values()) if (tool.narrate) latest = tool;
return latest;
}
function trySpeak(line, kind) {
if (!line || !canSpeak()) return false;
let spoken = false;
try {
spoken = speak(line, { kind }) !== false;
} catch {
spoken = false;
}
if (!spoken) return false;
lastLineAt = now();
stopEarcon();
onNarration('progress');
return true;
}
function speakStep() {
const tool = currentTool();
if (!tool || preambleSpoken || stepLines >= timing.maxStepLines) return;
if (tool.spokenStep === tool.step) return;
const line = progressLine(tool.step, tool.stepLabel || tool.label);
if (trySpeak(line, 'step')) {
stepLines++;
tool.spokenStep = tool.step;
}
}
function scheduleStepChange() {
const wait = lastLineAt + timing.stepGapMs - now();
if (wait <= 0) speakStep();
else later(wait, speakStep);
}
function reset() {
generation++;
clearTimers();
stopEarcon();
tools = new Map();
preambleSpoken = false;
stepLines = 0;
stillSpoken = false;
lastLineAt = -Infinity;
firstLineScheduled = false;
}
return {
/** End of user speech (or a typed command): a new turn begins. */
userTurnEnded() {
reset();
audibleNow = false;
later(timing.earconAfterMs, () => {
if (!audibleNow) startEarcon();
});
},
/** Assistant audio became audible (model reply or a progress line). */
audible() {
audibleNow = true;
stopEarcon();
},
/** Assistant audio stopped. */
quiet() {
audibleNow = false;
if (tools.size) {
later(timing.earconAfterMs, () => {
if (!audibleNow && tools.size) startEarcon();
});
}
},
/** The model spoke a preamble before calling its tool. */
preamble() {
preambleSpoken = true;
},
/**
* A tool began. `narrate:false` keeps it silent (instant tools, Radio).
* @param {string} id
* @param {{name?:string,label?:string,narrate?:boolean}} info
*/
toolStarted(id, { name = '', label = '', narrate = true } = {}) {
tools.set(id, {
name,
label,
narrate,
step: null,
stepLabel: '',
spokenStep: null,
startedAt: now(),
});
if (!narrate) return;
if (!firstLineScheduled) {
firstLineScheduled = true;
later(timing.firstLineMs, speakStep);
}
later(timing.stillWorkingMs, () => {
const tool = tools.get(id);
if (!tool || stillSpoken) return;
if (trySpeak(progressLine('still', tool.label), 'still'))
stillSpoken = true;
});
},
/** A tool reported a step. */
progress(id, { step, label } = {}) {
const tool = tools.get(id);
if (!tool || !step || tool.step === step) return;
tool.step = step;
tool.stepLabel = label || '';
// Before the first line is due the timer picks up the latest step.
if (stepLines > 0 && tool.narrate) scheduleStepChange();
},
/** A tool finished; after the last one no progress line may start. */
toolFinished(id) {
if (!tools.delete(id)) return;
if (tools.size) return;
generation++;
clearTimers();
firstLineScheduled = false;
try {
retract();
} catch {
/* The adapter decides whether an unheard line can be dropped. */
}
// Keep the earcon only until the reply starts, and never past a short
// grace when the reply turns out to be silent.
if (earconOn) {
const remaining = timing.earconMaxMs - (now() - earconStartedAt);
later(Math.min(timing.replyGraceMs, remaining), stopEarcon);
}
},
/**
* Barge-in, a new user turn, or session stop: new intent, so a line is
* stopped even if it is already audible (unlike toolFinished).
*/
cancel() {
reset();
audibleNow = false;
try {
stop();
} catch {
/* no-op */
}
},
/** Test/diagnostic view. */
state() {
return {
tools: tools.size,
stepLines,
stillSpoken,
preambleSpoken,
earconOn,
pendingTimers: timers.size,
};
},
};
}