Spaces:
Running on Zero
Running on Zero
File size: 7,292 Bytes
7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 b4ca58d 7146e75 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | // THIS MODULE IS TRANSPORT-AGNOSTIC AND IS THE ONLY IMPLEMENTATION OF THE TURN LOOP.
// Both avatar.js (inline gr.HTML) and avatar-iframe.js (postMessage) import it.
// Do NOT add turn behaviour to either transport file - add it here, or the iframe
// fallback silently loses the feature. tests/test_transport_seam.py enforces this.
//
// The renderer is reachable only through the six-method stagePort. The host bridge is
// reachable only through getServer(), which is a GETTER rather than a value so this
// module never holds a host object and can be constructed before the bridge exists.
//
// Push-to-talk landed in wave 4. Note that mic.js and asr.js are imported HERE and
// constructed lazily by this module rather than being handed in by a transport: mic
// capture and ASR run in the parent document under BOTH transports, and the iframe
// transport has no AudioContext of its own to lend. Constructing them here means the
// two transport files needed zero lines of change to gain push-to-talk, which is the
// strongest possible form of the guarantee the seam exists to give. Both are still
// injectable through the factory so a harness can substitute a different model.
import { createAsr } from './asr.js';
import { createMic, isHallucination, REJECT } from './mic.js';
/**
* A deferred method. Calling one now fails loudly and names the plan that fills it in,
* which is the difference between a placeholder and an accidental no-op. The parity
* guard is therefore meaningful from this wave rather than only after the last one.
*/
function notWiredYet(name, plan) {
return () => {
throw new Error(`Avatar.${name}() is not wired yet - plan ${plan} implements it`);
};
}
/**
* @param {object} opts
* @param {object} opts.stagePort the renderer boundary
* @param {Function} [opts.emit] the facade's event bus
* @param {Function} [opts.getServer] returns the host bridge, or null when there is none
* @param {object} [opts.mic] overrides the mic built from avatar/mic.js
* @param {object} [opts.asr] overrides the ASR built from avatar/asr.js
* @param {object} [opts.asrOptions] model/dtype/device overrides for the default ASR
* @param {object} [opts.micOptions] capture overrides for the default mic
*/
export function createTurnLoop({
stagePort,
emit = () => {},
getServer = () => null,
mic = null,
asr = null,
asrOptions = {},
micOptions = {},
} = {}) {
if (!stagePort) throw new Error('createTurnLoop: a stagePort is required');
// The turn loop's own slice of __debug. The facade merges this object; it does not
// own it, and this module does not own the facade's. Every key is initialised here
// rather than on first use, because the parity suite compares __debug KEY SETS across
// transports and a lazily-added key would make that comparison time-dependent.
const state = {
thinking: false,
listening: false,
lastTurnId: null,
replayCount: 0,
asrTier: null,
asrModel: null,
lastTranscript: null,
micRejectedCount: 0,
};
let speaking = false;
function setListeningState(on) {
state.listening = on;
stagePort.setListening(on);
}
const micInstance =
mic ||
createMic({
emit,
onListening: setListeningState,
// Push-to-talk exists to make acoustic feedback impossible, so the mic refuses to
// open while the avatar is thinking or speaking. mic.js adds the 200 ms tail after
// speech-end on top of this.
isBusy: () => state.thinking || speaking,
...micOptions,
});
const asrInstance = asr || createAsr({ emit, ...asrOptions });
/**
* The facade calls this for every event it fans out, under both transports - the
* iframe transport forwards the frame's events through the same bus. It is how this
* module learns that speech started or ended without holding a reference to the
* audio path, which belongs to the stage.
*/
function observe(name, data) {
if (name === 'speech-start') {
speaking = true;
} else if (name === 'speech-end') {
speaking = false;
micInstance.noteSpeechEnd();
} else if (name === 'asr-tier' && data) {
state.asrTier = data.tier ?? null;
state.asrModel = data.model ?? null;
}
}
return {
state,
getServer,
observe,
mic: micInstance,
asr: asrInstance,
setThinking(value) {
const on = !!value;
state.thinking = on;
stagePort.setThinking(on);
return on;
},
setListening(value) {
const on = !!value;
state.listening = on;
stagePort.setListening(on);
return on;
},
/**
* Re-play the cached directive. There is deliberately no network access of any
* kind in here: plan 01-09's test_replay asserts zero requests with a browser
* request listener, and a cache miss must fail rather than quietly re-download.
*/
async replay() {
state.replayCount += 1;
try {
return await stagePort.replayCached();
} catch (err) {
emit('error', { message: String(err?.message ?? err), where: 'replay' });
throw err;
}
},
/**
* pointerdown on the push-to-talk control. Plan 01-08 wires the control itself;
* the behaviour is here so both transports get it from one implementation.
*
* @returns {Promise<boolean>} whether capture actually started
*/
async startListening() {
const started = await micInstance.start();
if (!started) state.micRejectedCount = micInstance.__debug.rejectedCount;
return started;
},
/**
* pointerup. Gate -> ASR -> 'transcript'.
*
* A gated-out push emits NOTHING - not an empty transcript, which every downstream
* consumer would then have to special-case - and returns null.
*
* @returns {Promise<string|null>} the transcript, or null when nothing survived
*/
async stopListening() {
const utterance = await micInstance.stop();
state.micRejectedCount = micInstance.__debug.rejectedCount;
if (!utterance.ok) return null;
let result;
try {
result = await asrInstance.transcribe(utterance.samples, utterance.sampleRate);
} catch (err) {
emit('error', { message: String(err?.message ?? err), where: 'stopListening' });
return null;
}
state.asrTier = asrInstance.getTier();
state.asrModel = asrInstance.getModel();
// Second line of defence: the audio passed the gate but Whisper still produced
// subtitle boilerplate. Only short pushes are eligible - see mic.js.
if (!result.text || isHallucination(result.text, utterance.durationMs)) {
micInstance.noteTranscriptRejected(
result.text ? REJECT.HALLUCINATION : REJECT.NO_AUDIO
);
state.micRejectedCount = micInstance.__debug.rejectedCount;
return null;
}
state.lastTranscript = result.text;
emit('transcript', {
text: result.text,
durationMs: Math.round(utterance.durationMs),
tier: result.tier,
gated: false,
});
return result.text;
},
dispatchTurn: notWiredYet('dispatchTurn', '01-08'),
requestSlower: notWiredYet('requestSlower', '01-08'),
};
}
|