File size: 4,598 Bytes
29393b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * WO-2 Pipeline 1, T3 — Amazon Transcribe Streaming bridge.
 *
 * Takes the in-process PCM buffer T2 accumulated (an AudioSession's
 * drained Float32Array) and streams it to Amazon Transcribe Streaming over
 * the AWS SDK's HTTP/2 connection. Emits partial results as they arrive
 * (drives EVE's "processing" micro-expression) and the final result once
 * Transcribe settles on stable text.
 *
 * Credentials: retrieved via the AMANDA vault MCP (`get_key("aws")`) at
 * call time, never hardcoded, never read from a committed .env value.
 */

/**
 * Converts a Float32Array of PCM samples in [-1, 1] into the 16-bit signed
 * little-endian PCM bytes Transcribe Streaming expects for `MediaEncoding:
 * 'pcm'`. Clamps out-of-range samples rather than wrapping, since a wrapped
 * sample is audibly worse (a loud click) than a clamped one (brief clipping).
 */
export function float32ToPcm16(samples) {
  const out = Buffer.alloc(samples.length * 2);
  for (let i = 0; i < samples.length; i++) {
    const clamped = Math.max(-1, Math.min(1, samples[i]));
    const intSample = Math.round(clamped * 32767);
    out.writeInt16LE(intSample, i * 2);
  }
  return out;
}

/**
 * Wraps a single Float32Array buffer as the one-shot AsyncIterable the AWS
 * SDK's `AudioStream` parameter expects. Real streaming (many chunks over
 * time) would yield multiple times; T2's current buffering model hands us
 * one complete utterance at speech-end, so one yield is correct here.
 */
async function* singleChunkAudioStream(pcm16) {
  yield { AudioEvent: { AudioChunk: pcm16 } };
}

/**
 * Runs one Transcribe Streaming session against a single buffered
 * utterance. Calls `onPartial(text)` for every `IsPartial: true` result and
 * resolves with the final stable transcript once received.
 *
 * `clientFactory` and `commandFactory` are injectable so tests can supply a
 * mock without needing real AWS credentials or network access — the
 * production caller (see wireAudioPipeline in run.mjs, T4) passes the real
 * AWS SDK classes.
 */
export async function transcribeUtterance({
  samples,
  sampleRateHz,
  onPartial,
  TranscribeStreamingClient,
  StartStreamTranscriptionCommand,
  clientConfig,
}) {
  if (samples.length === 0) {
    return { text: "", partialCount: 0 };
  }

  const client = new TranscribeStreamingClient(clientConfig ?? { region: "us-east-1" });
  const pcm16 = float32ToPcm16(samples);

  const command = new StartStreamTranscriptionCommand({
    LanguageCode: "en-US",
    MediaSampleRateHertz: sampleRateHz,
    MediaEncoding: "pcm",
    AudioStream: singleChunkAudioStream(pcm16),
    EnablePartialResultsStabilization: true,
    PartialResultsStability: "medium",
  });

  const response = await client.send(command);
  let finalText = "";
  let partialCount = 0;

  for await (const event of response.TranscriptResultStream ?? []) {
    const results = event.TranscriptEvent?.Transcript?.Results ?? [];
    for (const result of results) {
      const text = result.Alternatives?.[0]?.Transcript ?? "";
      if (result.IsPartial) {
        partialCount += 1;
        onPartial?.(text);
      } else {
        finalText = text;
      }
    }
  }

  return { text: finalText, partialCount };
}

/**
 * Per-connection re-entrancy guard: WO-2 REQ says a new speech-start while
 * a Transcribe stream is active for that session must close the current
 * stream gracefully and open a new one, not silently interleave two.
 *
 * Since `transcribeUtterance` above is a single-shot call over one
 * complete buffered utterance (not a genuinely long-lived stream), "close
 * gracefully" here means: abandon interest in the in-flight call's result
 * rather than awaiting/broadcasting it, and let a new call start
 * immediately. `TranscribeSessionGuard` tracks an increasing generation
 * number per session id so a stale in-flight call's result is detected and
 * dropped instead of racing a newer one to the broadcast.
 */
export class TranscribeSessionGuard {
  constructor() {
    /** @type {Map<string, number>} sessionId -> current generation */
    this.generations = new Map();
  }

  /** Call when starting a new utterance for this session; returns a token
   * to check against later. */
  begin(sessionId) {
    const next = (this.generations.get(sessionId) ?? 0) + 1;
    this.generations.set(sessionId, next);
    return next;
  }

  /** True if `token` is still the most recent generation for this session
   * — i.e. no newer speech-start has superseded this call. */
  isCurrent(sessionId, token) {
    return this.generations.get(sessionId) === token;
  }
}