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'),
  };
}