mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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.
271 lines
7.3 KiB
JavaScript
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,
|
|
};
|
|
},
|
|
};
|
|
}
|