@moxxy/plugin-stt-local 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/dist/audio.d.ts +55 -0
  3. package/dist/audio.d.ts.map +1 -0
  4. package/dist/audio.js +197 -0
  5. package/dist/audio.js.map +1 -0
  6. package/dist/decode.d.ts +25 -0
  7. package/dist/decode.d.ts.map +1 -0
  8. package/dist/decode.js +50 -0
  9. package/dist/decode.js.map +1 -0
  10. package/dist/ffmpeg.d.ts +26 -0
  11. package/dist/ffmpeg.d.ts.map +1 -0
  12. package/dist/ffmpeg.js +207 -0
  13. package/dist/ffmpeg.js.map +1 -0
  14. package/dist/host-client.d.ts +70 -0
  15. package/dist/host-client.d.ts.map +1 -0
  16. package/dist/host-client.js +156 -0
  17. package/dist/host-client.js.map +1 -0
  18. package/dist/host-protocol.d.ts +96 -0
  19. package/dist/host-protocol.d.ts.map +1 -0
  20. package/dist/host-protocol.js +70 -0
  21. package/dist/host-protocol.js.map +1 -0
  22. package/dist/index.d.ts +32 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +61 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/local-stt.d.ts +86 -0
  27. package/dist/local-stt.d.ts.map +1 -0
  28. package/dist/local-stt.js +201 -0
  29. package/dist/local-stt.js.map +1 -0
  30. package/dist/models.d.ts +47 -0
  31. package/dist/models.d.ts.map +1 -0
  32. package/dist/models.js +61 -0
  33. package/dist/models.js.map +1 -0
  34. package/dist/platform.d.ts +37 -0
  35. package/dist/platform.d.ts.map +1 -0
  36. package/dist/platform.js +96 -0
  37. package/dist/platform.js.map +1 -0
  38. package/dist/sidecar.d.ts +18 -0
  39. package/dist/sidecar.d.ts.map +1 -0
  40. package/dist/sidecar.js +86 -0
  41. package/dist/sidecar.js.map +1 -0
  42. package/package.json +67 -0
  43. package/src/audio.test.ts +213 -0
  44. package/src/audio.ts +220 -0
  45. package/src/decode.test.ts +126 -0
  46. package/src/decode.ts +74 -0
  47. package/src/ffmpeg.test.ts +142 -0
  48. package/src/ffmpeg.ts +215 -0
  49. package/src/host-client.test.ts +208 -0
  50. package/src/host-client.ts +200 -0
  51. package/src/host-protocol.test.ts +171 -0
  52. package/src/host-protocol.ts +152 -0
  53. package/src/index.ts +108 -0
  54. package/src/live.test.ts +80 -0
  55. package/src/local-stt.test.ts +224 -0
  56. package/src/local-stt.ts +273 -0
  57. package/src/models.test.ts +58 -0
  58. package/src/models.ts +111 -0
  59. package/src/platform.test.ts +77 -0
  60. package/src/platform.ts +109 -0
  61. package/src/sidecar.ts +97 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pure, dependency-free audio DSP for the local Whisper transcriber. sherpa's
3
+ * OfflineRecognizer wants Float32 mono PCM in [-1, 1] at 16 kHz; inbound audio
4
+ * arrives in several shapes, so these helpers convert each to that canonical
5
+ * form IN-PROCESS (no ffmpeg for raw PCM / WAV):
6
+ *
7
+ * - `pcm16ToFloat32` int16 little-endian bytes → Float32 in [-1, 1]
8
+ * - `downmixToMono` interleaved N-channel Float32 → averaged mono
9
+ * - `resampleLinear` linear-interpolation resample between two rates
10
+ * - `parseWav` RIFF/WAVE header parse → PCM16 samples (float) + rate +
11
+ * channels; rejects non-PCM16 (float/compressed) WAV
12
+ *
13
+ * The `decodeToMono16k` orchestrator (in ./decode.ts) drives these plus the
14
+ * ffmpeg path. Everything here is synchronous and unit-tested against synthetic
15
+ * fixtures — the header offsets and the resampler math are load-bearing.
16
+ */
17
+ /** The sample rate sherpa's Whisper feature extractor expects. */
18
+ export declare const TARGET_SAMPLE_RATE = 16000;
19
+ /**
20
+ * Convert raw 16-bit little-endian PCM bytes to Float32 in [-1, 1]. A trailing
21
+ * odd byte (half a sample) is dropped. Channel interleaving is preserved — a
22
+ * stereo buffer stays interleaved, so callers downmix afterwards.
23
+ */
24
+ export declare function pcm16ToFloat32(pcm: Uint8Array | ArrayBuffer): Float32Array;
25
+ /**
26
+ * Average `channels` interleaved Float32 channels down to mono. `channels` must
27
+ * be a positive integer; a value of 1 returns the input unchanged. Trailing
28
+ * samples that don't complete a frame are ignored.
29
+ */
30
+ export declare function downmixToMono(interleaved: Float32Array, channels: number): Float32Array;
31
+ /**
32
+ * Resample mono Float32 audio from `fromRate` to `toRate` by linear
33
+ * interpolation. Cheap and dependency-free — good enough for speech recognition
34
+ * (Whisper is robust to the mild low-pass a linear kernel imposes). Returns the
35
+ * input unchanged when the rates match, and an empty array for empty input.
36
+ */
37
+ export declare function resampleLinear(input: Float32Array, fromRate: number, toRate: number): Float32Array;
38
+ /** True when `bytes` begins with the `RIFF….WAVE` magic of a WAV container. */
39
+ export declare function isRiffWave(bytes: Uint8Array): boolean;
40
+ export interface ParsedWav {
41
+ /** PCM samples as Float32 in [-1, 1], still interleaved if multi-channel. */
42
+ readonly samples: Float32Array;
43
+ readonly sampleRate: number;
44
+ readonly channels: number;
45
+ }
46
+ /**
47
+ * Parse a canonical RIFF/WAVE buffer into PCM16 samples. Only linear 16-bit PCM
48
+ * is accepted — IEEE-float, μ-law, and compressed WAV are rejected with a clear
49
+ * `CONFIG_INVALID` MoxxyError (the caller falls back to ffmpeg for real
50
+ * compressed formats, but a mislabelled/exotic WAV should fail loudly, not
51
+ * silently mis-decode). Walks the chunk list rather than assuming a fixed
52
+ * 44-byte header, so files with extra chunks (`LIST`, `fact`, …) still parse.
53
+ */
54
+ export declare function parseWav(bytes: Uint8Array): ParsedWav;
55
+ //# sourceMappingURL=audio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,kEAAkE;AAClE,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAMzC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,WAAW,GAAG,YAAY,CAa1E;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAiBvF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,YAAY,EACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,YAAY,CA0Bd;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAYrD;AAED,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAOD;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,CAoErD"}
package/dist/audio.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Pure, dependency-free audio DSP for the local Whisper transcriber. sherpa's
3
+ * OfflineRecognizer wants Float32 mono PCM in [-1, 1] at 16 kHz; inbound audio
4
+ * arrives in several shapes, so these helpers convert each to that canonical
5
+ * form IN-PROCESS (no ffmpeg for raw PCM / WAV):
6
+ *
7
+ * - `pcm16ToFloat32` int16 little-endian bytes → Float32 in [-1, 1]
8
+ * - `downmixToMono` interleaved N-channel Float32 → averaged mono
9
+ * - `resampleLinear` linear-interpolation resample between two rates
10
+ * - `parseWav` RIFF/WAVE header parse → PCM16 samples (float) + rate +
11
+ * channels; rejects non-PCM16 (float/compressed) WAV
12
+ *
13
+ * The `decodeToMono16k` orchestrator (in ./decode.ts) drives these plus the
14
+ * ffmpeg path. Everything here is synchronous and unit-tested against synthetic
15
+ * fixtures — the header offsets and the resampler math are load-bearing.
16
+ */
17
+ import { MoxxyError } from '@moxxy/sdk';
18
+ /** The sample rate sherpa's Whisper feature extractor expects. */
19
+ export const TARGET_SAMPLE_RATE = 16_000;
20
+ /** Full-scale divisor: int16 spans [-32768, 32767]; dividing by 32768 keeps
21
+ * the result in [-1, 1) without clipping the negative rail. */
22
+ const INT16_SCALE = 32_768;
23
+ /**
24
+ * Convert raw 16-bit little-endian PCM bytes to Float32 in [-1, 1]. A trailing
25
+ * odd byte (half a sample) is dropped. Channel interleaving is preserved — a
26
+ * stereo buffer stays interleaved, so callers downmix afterwards.
27
+ */
28
+ export function pcm16ToFloat32(pcm) {
29
+ const bytes = pcm instanceof Uint8Array ? pcm : new Uint8Array(pcm);
30
+ const usable = bytes.byteLength & ~1; // drop a trailing odd byte
31
+ const count = usable / 2;
32
+ const out = new Float32Array(count);
33
+ // Read via DataView so we don't depend on the platform being little-endian
34
+ // and don't require the byte offset to be 2-aligned (a subarray view can
35
+ // start on an odd offset).
36
+ const view = new DataView(bytes.buffer, bytes.byteOffset, usable);
37
+ for (let i = 0; i < count; i += 1) {
38
+ out[i] = view.getInt16(i * 2, true) / INT16_SCALE;
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Average `channels` interleaved Float32 channels down to mono. `channels` must
44
+ * be a positive integer; a value of 1 returns the input unchanged. Trailing
45
+ * samples that don't complete a frame are ignored.
46
+ */
47
+ export function downmixToMono(interleaved, channels) {
48
+ if (!Number.isInteger(channels) || channels < 1) {
49
+ throw new MoxxyError({
50
+ code: 'INTERNAL',
51
+ message: `downmixToMono: invalid channel count ${channels}.`,
52
+ });
53
+ }
54
+ if (channels === 1)
55
+ return interleaved;
56
+ const frames = Math.floor(interleaved.length / channels);
57
+ const out = new Float32Array(frames);
58
+ for (let f = 0; f < frames; f += 1) {
59
+ let sum = 0;
60
+ const base = f * channels;
61
+ for (let c = 0; c < channels; c += 1)
62
+ sum += interleaved[base + c];
63
+ out[f] = sum / channels;
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Resample mono Float32 audio from `fromRate` to `toRate` by linear
69
+ * interpolation. Cheap and dependency-free — good enough for speech recognition
70
+ * (Whisper is robust to the mild low-pass a linear kernel imposes). Returns the
71
+ * input unchanged when the rates match, and an empty array for empty input.
72
+ */
73
+ export function resampleLinear(input, fromRate, toRate) {
74
+ if (!Number.isFinite(fromRate) || !Number.isFinite(toRate) || fromRate <= 0 || toRate <= 0) {
75
+ throw new MoxxyError({
76
+ code: 'INTERNAL',
77
+ message: `resampleLinear: invalid rate(s) from=${fromRate} to=${toRate}.`,
78
+ });
79
+ }
80
+ if (fromRate === toRate)
81
+ return input;
82
+ if (input.length === 0)
83
+ return new Float32Array(0);
84
+ if (input.length === 1)
85
+ return Float32Array.of(input[0]);
86
+ const ratio = fromRate / toRate;
87
+ const outLen = Math.max(1, Math.round(input.length / ratio));
88
+ const out = new Float32Array(outLen);
89
+ const lastIdx = input.length - 1;
90
+ for (let i = 0; i < outLen; i += 1) {
91
+ const srcPos = i * ratio;
92
+ const i0 = Math.floor(srcPos);
93
+ if (i0 >= lastIdx) {
94
+ out[i] = input[lastIdx];
95
+ continue;
96
+ }
97
+ const frac = srcPos - i0;
98
+ out[i] = input[i0] * (1 - frac) + input[i0 + 1] * frac;
99
+ }
100
+ return out;
101
+ }
102
+ /** True when `bytes` begins with the `RIFF….WAVE` magic of a WAV container. */
103
+ export function isRiffWave(bytes) {
104
+ return (bytes.length >= 12 &&
105
+ bytes[0] === 0x52 && // R
106
+ bytes[1] === 0x49 && // I
107
+ bytes[2] === 0x46 && // F
108
+ bytes[3] === 0x46 && // F
109
+ bytes[8] === 0x57 && // W
110
+ bytes[9] === 0x41 && // A
111
+ bytes[10] === 0x56 && // V
112
+ bytes[11] === 0x45 // E
113
+ );
114
+ }
115
+ // WAV `fmt ` audio-format codes we care about.
116
+ const WAVE_FORMAT_PCM = 1;
117
+ const WAVE_FORMAT_IEEE_FLOAT = 3;
118
+ const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
119
+ /**
120
+ * Parse a canonical RIFF/WAVE buffer into PCM16 samples. Only linear 16-bit PCM
121
+ * is accepted — IEEE-float, μ-law, and compressed WAV are rejected with a clear
122
+ * `CONFIG_INVALID` MoxxyError (the caller falls back to ffmpeg for real
123
+ * compressed formats, but a mislabelled/exotic WAV should fail loudly, not
124
+ * silently mis-decode). Walks the chunk list rather than assuming a fixed
125
+ * 44-byte header, so files with extra chunks (`LIST`, `fact`, …) still parse.
126
+ */
127
+ export function parseWav(bytes) {
128
+ if (!isRiffWave(bytes)) {
129
+ throw wavError('not a RIFF/WAVE file (bad magic).');
130
+ }
131
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
132
+ let fmt = null;
133
+ let dataOffset = -1;
134
+ let dataLen = 0;
135
+ // Chunks begin right after `RIFF<size>WAVE` (offset 12). Each chunk is a
136
+ // 4-byte id + uint32 little-endian size + payload, word-aligned (odd payloads
137
+ // carry a pad byte).
138
+ let off = 12;
139
+ while (off + 8 <= bytes.byteLength) {
140
+ const id = ascii4(bytes, off);
141
+ const size = view.getUint32(off + 4, true);
142
+ const body = off + 8;
143
+ if (id === 'fmt ') {
144
+ if (body + 16 > bytes.byteLength)
145
+ throw wavError('truncated fmt chunk.');
146
+ fmt = {
147
+ format: view.getUint16(body, true),
148
+ channels: view.getUint16(body + 2, true),
149
+ sampleRate: view.getUint32(body + 4, true),
150
+ bitsPerSample: view.getUint16(body + 14, true),
151
+ };
152
+ }
153
+ else if (id === 'data') {
154
+ dataOffset = body;
155
+ // Clamp a declared size that overruns the buffer (some encoders write 0
156
+ // or 0xffffffff for streamed data) to what's actually present.
157
+ dataLen = Math.min(size, bytes.byteLength - body);
158
+ }
159
+ // Advance past the payload + pad byte. Guard against a zero/garbage size
160
+ // that would loop forever.
161
+ const advance = 8 + size + (size & 1);
162
+ if (advance <= 8)
163
+ break;
164
+ off += advance;
165
+ }
166
+ if (!fmt)
167
+ throw wavError('missing fmt chunk.');
168
+ if (dataOffset < 0)
169
+ throw wavError('missing data chunk.');
170
+ const { format, channels, sampleRate, bitsPerSample } = fmt;
171
+ if (!Number.isInteger(channels) || channels < 1) {
172
+ throw wavError(`unsupported channel count ${channels}.`);
173
+ }
174
+ if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
175
+ throw wavError(`unsupported sample rate ${sampleRate}.`);
176
+ }
177
+ // Accept plain PCM; also accept EXTENSIBLE only when it's declared 16-bit PCM
178
+ // (a common wrapper the codecs emit for the same data). Reject float/exotic.
179
+ const isPcm16 = (format === WAVE_FORMAT_PCM || format === WAVE_FORMAT_EXTENSIBLE) && bitsPerSample === 16;
180
+ if (!isPcm16) {
181
+ const kind = format === WAVE_FORMAT_IEEE_FLOAT
182
+ ? 'IEEE float'
183
+ : format === WAVE_FORMAT_PCM || format === WAVE_FORMAT_EXTENSIBLE
184
+ ? `${bitsPerSample}-bit PCM`
185
+ : `format 0x${format.toString(16)}`;
186
+ throw wavError(`unsupported WAV encoding (${kind}). Only 16-bit PCM WAV is decoded in-process; re-export as PCM16 WAV, or install ffmpeg to decode other formats.`);
187
+ }
188
+ const pcm = bytes.subarray(dataOffset, dataOffset + dataLen);
189
+ return { samples: pcm16ToFloat32(pcm), sampleRate, channels };
190
+ }
191
+ function ascii4(bytes, off) {
192
+ return String.fromCharCode(bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]);
193
+ }
194
+ function wavError(detail) {
195
+ return new MoxxyError({ code: 'CONFIG_INVALID', message: `Invalid WAV audio: ${detail}` });
196
+ }
197
+ //# sourceMappingURL=audio.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.js","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The single entry point that turns whatever audio a caller hands the local
3
+ * Whisper transcriber into the Float32 mono @ 16 kHz that sherpa wants. It
4
+ * dispatches on the MIME type (with a RIFF magic sniff as a backstop):
5
+ *
6
+ * - `MOXXY_PCM16_24KHZ_MIME` (the TUI/desktop mic) → int16→float32, then
7
+ * linear-resample 24 kHz → 16 kHz. Never touches ffmpeg.
8
+ * - `audio/wav` / `audio/x-wav`, or any buffer whose bytes start with the WAV
9
+ * magic → parse the RIFF header (PCM16 only), downmix, resample. No ffmpeg.
10
+ * - everything else (ogg/opus, mp3, m4a, webm, flac, …) → ffmpeg.
11
+ *
12
+ * The MIME string is UNTRUSTED (channels forward a messenger's `mime_type`
13
+ * verbatim), so it's runtime-guarded and canonicalized before matching.
14
+ */
15
+ import type { spawn } from 'node:child_process';
16
+ export interface DecodeOptions {
17
+ /** Injected `spawn` for the ffmpeg path (tests). Defaults to real `spawn`. */
18
+ readonly spawnImpl?: typeof spawn;
19
+ }
20
+ /**
21
+ * Decode `audio` to Float32 mono PCM in [-1, 1] at 16 kHz. Throws a MoxxyError
22
+ * for malformed WAV (non-PCM16) or when ffmpeg is needed but missing.
23
+ */
24
+ export declare function decodeToMono16k(audio: Uint8Array | ArrayBuffer, mimeType: string | undefined, opts?: DecodeOptions): Promise<Float32Array>;
25
+ //# sourceMappingURL=decode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode.d.ts","sourceRoot":"","sources":["../src/decode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAehD,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CACnC;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,GAAG,WAAW,EAC/B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAuBvB"}
package/dist/decode.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The single entry point that turns whatever audio a caller hands the local
3
+ * Whisper transcriber into the Float32 mono @ 16 kHz that sherpa wants. It
4
+ * dispatches on the MIME type (with a RIFF magic sniff as a backstop):
5
+ *
6
+ * - `MOXXY_PCM16_24KHZ_MIME` (the TUI/desktop mic) → int16→float32, then
7
+ * linear-resample 24 kHz → 16 kHz. Never touches ffmpeg.
8
+ * - `audio/wav` / `audio/x-wav`, or any buffer whose bytes start with the WAV
9
+ * magic → parse the RIFF header (PCM16 only), downmix, resample. No ffmpeg.
10
+ * - everything else (ogg/opus, mp3, m4a, webm, flac, …) → ffmpeg.
11
+ *
12
+ * The MIME string is UNTRUSTED (channels forward a messenger's `mime_type`
13
+ * verbatim), so it's runtime-guarded and canonicalized before matching.
14
+ */
15
+ import { MOXXY_PCM16_24KHZ_MIME } from '@moxxy/sdk';
16
+ import { downmixToMono, isRiffWave, parseWav, pcm16ToFloat32, resampleLinear, TARGET_SAMPLE_RATE, } from './audio.js';
17
+ import { decodeViaFfmpeg } from './ffmpeg.js';
18
+ /** The raw mic contract carries mono int16 @ this rate (see the SDK MIME tag). */
19
+ const MOXXY_PCM16_SAMPLE_RATE = 24_000;
20
+ /**
21
+ * Decode `audio` to Float32 mono PCM in [-1, 1] at 16 kHz. Throws a MoxxyError
22
+ * for malformed WAV (non-PCM16) or when ffmpeg is needed but missing.
23
+ */
24
+ export async function decodeToMono16k(audio, mimeType, opts = {}) {
25
+ const bytes = audio instanceof Uint8Array ? audio : new Uint8Array(audio);
26
+ const mt = canonicalMime(mimeType);
27
+ // 1) Raw PCM16 mono @ 24 kHz from the moxxy mic recorder.
28
+ if (mt === MOXXY_PCM16_24KHZ_MIME) {
29
+ const mono = pcm16ToFloat32(bytes);
30
+ return resampleLinear(mono, MOXXY_PCM16_SAMPLE_RATE, TARGET_SAMPLE_RATE);
31
+ }
32
+ // 2) WAV — by declared MIME or by sniffing the RIFF/WAVE magic (a mislabelled
33
+ // or MIME-less WAV still decodes in-process).
34
+ if (mt === 'audio/wav' || mt === 'audio/x-wav' || mt === 'audio/wave' || isRiffWave(bytes)) {
35
+ const wav = parseWav(bytes);
36
+ const mono = wav.channels > 1 ? downmixToMono(wav.samples, wav.channels) : wav.samples;
37
+ return wav.sampleRate === TARGET_SAMPLE_RATE
38
+ ? mono
39
+ : resampleLinear(mono, wav.sampleRate, TARGET_SAMPLE_RATE);
40
+ }
41
+ // 3) Everything else is a compressed container → ffmpeg (already resamples to
42
+ // 16 kHz mono for us).
43
+ return decodeViaFfmpeg(bytes, opts.spawnImpl);
44
+ }
45
+ /** Lower-case, strip a `; codecs=…` parameter, trim. Non-string → ''. */
46
+ function canonicalMime(mimeType) {
47
+ const raw = typeof mimeType === 'string' ? mimeType : '';
48
+ return raw.toLowerCase().split(';')[0].trim();
49
+ }
50
+ //# sourceMappingURL=decode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode.js","sourceRoot":"","sources":["../src/decode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAGpD,OAAO,EACL,aAAa,EACb,UAAU,EACV,QAAQ,EACR,cAAc,EACd,cAAc,EACd,kBAAkB,GACnB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,kFAAkF;AAClF,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAOvC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,KAA+B,EAC/B,QAA4B,EAC5B,OAAsB,EAAE;IAExB,MAAM,KAAK,GAAG,KAAK,YAAY,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IAC1E,MAAM,EAAE,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IAEnC,0DAA0D;IAC1D,IAAI,EAAE,KAAK,sBAAsB,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;QACnC,OAAO,cAAc,CAAC,IAAI,EAAE,uBAAuB,EAAE,kBAAkB,CAAC,CAAC;IAC3E,CAAC;IAED,8EAA8E;IAC9E,iDAAiD;IACjD,IAAI,EAAE,KAAK,WAAW,IAAI,EAAE,KAAK,aAAa,IAAI,EAAE,KAAK,YAAY,IAAI,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3F,MAAM,GAAG,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;QAC5B,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;QACvF,OAAO,GAAG,CAAC,UAAU,KAAK,kBAAkB;YAC1C,CAAC,CAAC,IAAI;YACN,CAAC,CAAC,cAAc,CAAC,IAAI,EAAE,GAAG,CAAC,UAAU,EAAE,kBAAkB,CAAC,CAAC;IAC/D,CAAC;IAED,8EAA8E;IAC9E,0BAA0B;IAC1B,OAAO,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;AAChD,CAAC;AAED,yEAAyE;AACzE,SAAS,aAAa,CAAC,QAA4B;IACjD,MAAM,GAAG,GAAG,OAAO,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;IACzD,OAAO,GAAG,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,CAAC;AACjD,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The ffmpeg fallback for compressed audio (ogg/opus voice notes, mp3, m4a,
3
+ * webm, …) that we can't decode in-process. ffmpeg reads the container on stdin
4
+ * and writes raw 32-bit-float mono PCM at 16 kHz to stdout, which we read
5
+ * straight into a Float32Array — no intermediate WAV, no temp files.
6
+ *
7
+ * Gated behind a presence probe (mirrors channel-kit's voice-reply, cached per
8
+ * process; an injected `spawnImpl` bypasses the cache so tests are
9
+ * deterministic). When ffmpeg is absent we throw a clear, actionable MoxxyError
10
+ * — raw PCM16 and PCM16 WAV keep working without it, only compressed input
11
+ * needs it.
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ import { MoxxyError } from '@moxxy/sdk';
15
+ /**
16
+ * Decode compressed audio bytes to Float32 mono @ 16 kHz via ffmpeg. Throws a
17
+ * `PLUGIN_LOAD_FAILED` MoxxyError (with an OS-specific install hint) when ffmpeg
18
+ * isn't on PATH, and an `INTERNAL` MoxxyError when ffmpeg runs but fails.
19
+ */
20
+ export declare function decodeViaFfmpeg(audio: Uint8Array, spawnImpl?: typeof spawn): Promise<Float32Array>;
21
+ /** The user-facing error raised when compressed audio arrives but ffmpeg is
22
+ * absent. Exported so the decode orchestrator / tests can assert on it. */
23
+ export declare function missingFfmpegError(): MoxxyError;
24
+ /** Reset the cached ffmpeg probe (tests only). */
25
+ export declare function __resetFfmpegProbeForTest(): void;
26
+ //# sourceMappingURL=ffmpeg.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg.d.ts","sourceRoot":"","sources":["../src/ffmpeg.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,OAAO,EAAkB,UAAU,EAAE,MAAM,YAAY,CAAC;AA2BxD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,CAAC,EAAE,OAAO,KAAK,GACvB,OAAO,CAAC,YAAY,CAAC,CAKvB;AAED;4EAC4E;AAC5E,wBAAgB,kBAAkB,IAAI,UAAU,CAQ/C;AAaD,kDAAkD;AAClD,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD"}
package/dist/ffmpeg.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The ffmpeg fallback for compressed audio (ogg/opus voice notes, mp3, m4a,
3
+ * webm, …) that we can't decode in-process. ffmpeg reads the container on stdin
4
+ * and writes raw 32-bit-float mono PCM at 16 kHz to stdout, which we read
5
+ * straight into a Float32Array — no intermediate WAV, no temp files.
6
+ *
7
+ * Gated behind a presence probe (mirrors channel-kit's voice-reply, cached per
8
+ * process; an injected `spawnImpl` bypasses the cache so tests are
9
+ * deterministic). When ffmpeg is absent we throw a clear, actionable MoxxyError
10
+ * — raw PCM16 and PCM16 WAV keep working without it, only compressed input
11
+ * needs it.
12
+ */
13
+ import { Buffer } from 'node:buffer';
14
+ import { spawn } from 'node:child_process';
15
+ import { getInstallHint, MoxxyError } from '@moxxy/sdk';
16
+ import { TARGET_SAMPLE_RATE } from './audio.js';
17
+ /** ffmpeg args: decode any input container → f32le mono @ 16 kHz on stdout. */
18
+ const DECODE_ARGS = [
19
+ '-hide_banner',
20
+ '-loglevel',
21
+ 'error',
22
+ '-i',
23
+ 'pipe:0',
24
+ '-f',
25
+ 'f32le',
26
+ '-ac',
27
+ '1',
28
+ '-ar',
29
+ String(TARGET_SAMPLE_RATE),
30
+ 'pipe:1',
31
+ ];
32
+ /** Ceiling on decoded PCM we buffer. At 16 kHz f32 mono (64 KB/s) this is ~30
33
+ * minutes — well past any voice note, and bounds an adversarial input. */
34
+ const MAX_DECODED_BYTES = 120 * 1024 * 1024;
35
+ const PROBE_TIMEOUT_MS = 1_500;
36
+ const DECODE_TIMEOUT_MS = 60_000;
37
+ /**
38
+ * Decode compressed audio bytes to Float32 mono @ 16 kHz via ffmpeg. Throws a
39
+ * `PLUGIN_LOAD_FAILED` MoxxyError (with an OS-specific install hint) when ffmpeg
40
+ * isn't on PATH, and an `INTERNAL` MoxxyError when ffmpeg runs but fails.
41
+ */
42
+ export async function decodeViaFfmpeg(audio, spawnImpl) {
43
+ const available = await probeFfmpeg(spawnImpl);
44
+ if (!available)
45
+ throw missingFfmpegError();
46
+ const raw = await runDecode(audio, spawnImpl ?? spawn);
47
+ return f32leToFloat32(raw);
48
+ }
49
+ /** The user-facing error raised when compressed audio arrives but ffmpeg is
50
+ * absent. Exported so the decode orchestrator / tests can assert on it. */
51
+ export function missingFfmpegError() {
52
+ const hint = getInstallHint('ffmpeg');
53
+ return new MoxxyError({
54
+ code: 'PLUGIN_LOAD_FAILED',
55
+ message: 'Local Whisper needs ffmpeg to decode compressed audio (ogg/opus, mp3, m4a, webm). Raw PCM and 16-bit PCM WAV transcribe without it.',
56
+ hint: `Install ffmpeg via ${hint.manager}: \`${hint.command}\`.`,
57
+ });
58
+ }
59
+ // Process-cached ffmpeg availability (the probe spawns a subprocess; caching
60
+ // keeps a chatty voice channel from re-probing on every note). An injected
61
+ // `spawnImpl` (tests) bypasses the cache.
62
+ let ffmpegAvailable = null;
63
+ async function probeFfmpeg(spawnImpl) {
64
+ if (spawnImpl)
65
+ return runProbe(spawnImpl);
66
+ ffmpegAvailable ??= runProbe(spawn);
67
+ return ffmpegAvailable;
68
+ }
69
+ /** Reset the cached ffmpeg probe (tests only). */
70
+ export function __resetFfmpegProbeForTest() {
71
+ ffmpegAvailable = null;
72
+ }
73
+ function runProbe(spawnImpl, command = 'ffmpeg') {
74
+ return new Promise((resolve) => {
75
+ let settled = false;
76
+ const done = (v) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ resolve(v);
82
+ };
83
+ let child;
84
+ try {
85
+ child = spawnImpl(command, ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] });
86
+ }
87
+ catch {
88
+ resolve(false);
89
+ return;
90
+ }
91
+ const timer = setTimeout(() => {
92
+ try {
93
+ if (!child.killed)
94
+ child.kill('SIGKILL');
95
+ }
96
+ catch {
97
+ /* ignore */
98
+ }
99
+ done(false);
100
+ }, PROBE_TIMEOUT_MS);
101
+ timer.unref?.();
102
+ child.once('error', () => done(false));
103
+ child.once('close', (code) => done(code === 0));
104
+ });
105
+ }
106
+ function runDecode(audio, spawnImpl, command = 'ffmpeg') {
107
+ return new Promise((resolve, reject) => {
108
+ let child;
109
+ try {
110
+ child = spawnImpl(command, DECODE_ARGS, { stdio: ['pipe', 'pipe', 'pipe'] });
111
+ }
112
+ catch (err) {
113
+ reject(decodeFailure(err instanceof Error ? err.message : String(err)));
114
+ return;
115
+ }
116
+ const out = [];
117
+ const errChunks = [];
118
+ let outBytes = 0;
119
+ let over = false;
120
+ let settled = false;
121
+ const finish = (fn) => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ clearTimeout(timer);
126
+ fn();
127
+ };
128
+ const timer = setTimeout(() => {
129
+ try {
130
+ if (!child.killed)
131
+ child.kill('SIGKILL');
132
+ }
133
+ catch {
134
+ /* ignore */
135
+ }
136
+ finish(() => reject(decodeFailure('ffmpeg decode timed out')));
137
+ }, DECODE_TIMEOUT_MS);
138
+ timer.unref?.();
139
+ child.stdout?.on('data', (c) => {
140
+ if (over)
141
+ return;
142
+ if (outBytes + c.byteLength > MAX_DECODED_BYTES) {
143
+ over = true;
144
+ try {
145
+ child.kill('SIGKILL');
146
+ }
147
+ catch {
148
+ /* ignore */
149
+ }
150
+ finish(() => reject(decodeFailure('decoded audio exceeded the size limit')));
151
+ return;
152
+ }
153
+ outBytes += c.byteLength;
154
+ out.push(Buffer.from(c));
155
+ });
156
+ child.stderr?.on('data', (c) => {
157
+ errChunks.push(Buffer.from(c));
158
+ while (Buffer.concat(errChunks).byteLength > 4_096)
159
+ errChunks.shift();
160
+ });
161
+ child.once('error', (err) => finish(() => reject(decodeFailure(err instanceof Error ? err.message : String(err)))));
162
+ child.once('close', (code) => finish(() => {
163
+ if (over)
164
+ return; // already rejected on the size cap
165
+ if (code === 0 && out.length > 0) {
166
+ resolve(Buffer.concat(out));
167
+ }
168
+ else if (code === 0) {
169
+ reject(decodeFailure('ffmpeg produced no audio'));
170
+ }
171
+ else {
172
+ reject(decodeFailure(`ffmpeg exited ${code}: ${Buffer.concat(errChunks).toString('utf8').trim()}`));
173
+ }
174
+ }));
175
+ const stdin = child.stdin;
176
+ if (stdin) {
177
+ stdin.on('error', () => {
178
+ // EPIPE if ffmpeg died before consuming stdin — the close/error handler
179
+ // reports the real failure; swallow this to avoid an unhandled 'error'.
180
+ });
181
+ try {
182
+ stdin.end(Buffer.from(audio));
183
+ }
184
+ catch {
185
+ /* the close/error path reports it */
186
+ }
187
+ }
188
+ });
189
+ }
190
+ /** Reinterpret little-endian float32 bytes as a Float32Array. Copies through a
191
+ * DataView so a non-4-aligned Buffer offset and big-endian hosts both work. */
192
+ function f32leToFloat32(buf) {
193
+ const usable = buf.byteLength - (buf.byteLength % 4);
194
+ const count = usable / 4;
195
+ const out = new Float32Array(count);
196
+ const view = new DataView(buf.buffer, buf.byteOffset, usable);
197
+ for (let i = 0; i < count; i += 1)
198
+ out[i] = view.getFloat32(i * 4, true);
199
+ return out;
200
+ }
201
+ function decodeFailure(detail) {
202
+ return new MoxxyError({
203
+ code: 'INTERNAL',
204
+ message: `Local Whisper failed to decode audio with ffmpeg: ${detail}`,
205
+ });
206
+ }
207
+ //# sourceMappingURL=ffmpeg.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg.js","sourceRoot":"","sources":["../src/ffmpeg.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEhD,+EAA+E;AAC/E,MAAM,WAAW,GAAG;IAClB,cAAc;IACd,WAAW;IACX,OAAO;IACP,IAAI;IACJ,QAAQ;IACR,IAAI;IACJ,OAAO;IACP,KAAK;IACL,GAAG;IACH,KAAK;IACL,MAAM,CAAC,kBAAkB,CAAC;IAC1B,QAAQ;CACT,CAAC;AAEF;2EAC2E;AAC3E,MAAM,iBAAiB,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;AAE5C,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAC/B,MAAM,iBAAiB,GAAG,MAAM,CAAC;AAEjC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,KAAiB,EACjB,SAAwB;IAExB,MAAM,SAAS,GAAG,MAAM,WAAW,CAAC,SAAS,CAAC,CAAC;IAC/C,IAAI,CAAC,SAAS;QAAE,MAAM,kBAAkB,EAAE,CAAC;IAC3C,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,KAAK,EAAE,SAAS,IAAI,KAAK,CAAC,CAAC;IACvD,OAAO,cAAc,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC;AAED;4EAC4E;AAC5E,MAAM,UAAU,kBAAkB;IAChC,MAAM,IAAI,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC;IACtC,OAAO,IAAI,UAAU,CAAC;QACpB,IAAI,EAAE,oBAAoB;QAC1B,OAAO,EACL,qIAAqI;QACvI,IAAI,EAAE,sBAAsB,IAAI,CAAC,OAAO,OAAO,IAAI,CAAC,OAAO,KAAK;KACjE,CAAC,CAAC;AACL,CAAC;AAED,6EAA6E;AAC7E,2EAA2E;AAC3E,0CAA0C;AAC1C,IAAI,eAAe,GAA4B,IAAI,CAAC;AAEpD,KAAK,UAAU,WAAW,CAAC,SAAwB;IACjD,IAAI,SAAS;QAAE,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC;IAC1C,eAAe,KAAK,QAAQ,CAAC,KAAK,CAAC,CAAC;IACpC,OAAO,eAAe,CAAC;AACzB,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,yBAAyB;IACvC,eAAe,GAAG,IAAI,CAAC;AACzB,CAAC;AAED,SAAS,QAAQ,CAAC,SAAuB,EAAE,OAAO,GAAG,QAAQ;IAC3D,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,IAAI,GAAG,CAAC,CAAU,EAAQ,EAAE;YAChC,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,CAAC,CAAC,CAAC,CAAC;QACb,CAAC,CAAC;QACF,IAAI,KAA+B,CAAC;QACpC,IAAI,CAAC;YACH,KAAK,GAAG,SAAS,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;QACtF,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,KAAK,CAAC,CAAC;YACf,OAAO;QACT,CAAC;QACD,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,IAAI,CAAC;gBACH,IAAI,CAAC,KAAK,CAAC,MAAM;oBAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC3C,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;YACD,IAAI,CAAC,KAAK,CAAC,CAAC;QACd,CAAC,EAAE,gBAAgB,CAAC,CAAC;QACrB,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;QAChB,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACvC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,SAAS,CAAC,KAAiB,EAAE,SAAuB,EAAE,OAAO,GAAG,QAAQ;IAC/E,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,IAAI,KAA+B,CAAC;QACpC,IAAI,CAAC;YACH,KAAK,GAAG,SAAS,CAAC,OAAO,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QAC/E,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,aAAa,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACxE,OAAO;QACT,CAAC;QACD,MAAM,GAAG,GAAa,EAAE,CAAC;QACzB,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,IAAI,IAAI,GAAG,KAAK,CAAC;QACjB,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,MAAM,MAAM,GAAG,CAAC,EAAc,EAAQ,EAAE;YACtC,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,EAAE,EAAE,CAAC;QACP,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,IAAI,CAAC;gBACH,IAAI,CAAC,KAAK,CAAC,MAAM;oBAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC3C,CAAC;YAAC,MAAM,CAAC;gBACP,YAAY;YACd,CAAC;YACD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,aAAa,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC;QACjE,CAAC,EAAE,iBAAiB,CAAC,CAAC;QACtB,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;QAEhB,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE;YACrC,IAAI,IAAI;gBAAE,OAAO;YACjB,IAAI,QAAQ,GAAG,CAAC,CAAC,UAAU,GAAG,iBAAiB,EAAE,CAAC;gBAChD,IAAI,GAAG,IAAI,CAAC;gBACZ,IAAI,CAAC;oBACH,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;gBACxB,CAAC;gBAAC,MAAM,CAAC;oBACP,YAAY;gBACd,CAAC;gBACD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,aAAa,CAAC,uCAAuC,CAAC,CAAC,CAAC,CAAC;gBAC7E,OAAO;YACT,CAAC;YACD,QAAQ,IAAI,CAAC,CAAC,UAAU,CAAC;YACzB,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3B,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE;YACrC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YAC/B,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,UAAU,GAAG,KAAK;gBAAE,SAAS,CAAC,KAAK,EAAE,CAAC;QACxE,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAC1B,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,aAAa,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CACtF,CAAC;QACF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAC3B,MAAM,CAAC,GAAG,EAAE;YACV,IAAI,IAAI;gBAAE,OAAO,CAAC,mCAAmC;YACrD,IAAI,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACjC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9B,CAAC;iBAAM,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACtB,MAAM,CAAC,aAAa,CAAC,0BAA0B,CAAC,CAAC,CAAC;YACpD,CAAC;iBAAM,CAAC;gBACN,MAAM,CACJ,aAAa,CAAC,iBAAiB,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAC5F,CAAC;YACJ,CAAC;QACH,CAAC,CAAC,CACH,CAAC;QAEF,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;QAC1B,IAAI,KAAK,EAAE,CAAC;YACV,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;gBACrB,wEAAwE;gBACxE,wEAAwE;YAC1E,CAAC,CAAC,CAAC;YACH,IAAI,CAAC;gBACH,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;YAChC,CAAC;YAAC,MAAM,CAAC;gBACP,qCAAqC;YACvC,CAAC;QACH,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC;AAED;gFACgF;AAChF,SAAS,cAAc,CAAC,GAAW;IACjC,MAAM,MAAM,GAAG,GAAG,CAAC,UAAU,GAAG,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,CAAC;IACzB,MAAM,GAAG,GAAG,IAAI,YAAY,CAAC,KAAK,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,CAAC;QAAE,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC;IACzE,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,aAAa,CAAC,MAAc;IACnC,OAAO,IAAI,UAAU,CAAC;QACpB,IAAI,EAAE,UAAU;QAChB,OAAO,EAAE,qDAAqD,MAAM,EAAE;KACvE,CAAC,CAAC;AACL,CAAC"}