@moxxy/channel-kit 0.27.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/dist/frame-pump.d.ts +77 -0
  3. package/dist/frame-pump.d.ts.map +1 -0
  4. package/dist/frame-pump.js +118 -0
  5. package/dist/frame-pump.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +19 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/ingest/dedupe.d.ts +31 -0
  11. package/dist/ingest/dedupe.d.ts.map +1 -0
  12. package/dist/ingest/dedupe.js +66 -0
  13. package/dist/ingest/dedupe.js.map +1 -0
  14. package/dist/ingest/http-server.d.ts +76 -0
  15. package/dist/ingest/http-server.d.ts.map +1 -0
  16. package/dist/ingest/http-server.js +101 -0
  17. package/dist/ingest/http-server.js.map +1 -0
  18. package/dist/pairing/host-code.d.ts +94 -0
  19. package/dist/pairing/host-code.d.ts.map +1 -0
  20. package/dist/pairing/host-code.js +107 -0
  21. package/dist/pairing/host-code.js.map +1 -0
  22. package/dist/pairing/tofu.d.ts +33 -0
  23. package/dist/pairing/tofu.d.ts.map +1 -0
  24. package/dist/pairing/tofu.js +41 -0
  25. package/dist/pairing/tofu.js.map +1 -0
  26. package/dist/permission.d.ts +28 -0
  27. package/dist/permission.d.ts.map +1 -0
  28. package/dist/permission.js +17 -0
  29. package/dist/permission.js.map +1 -0
  30. package/dist/plain-turn-renderer.d.ts +17 -0
  31. package/dist/plain-turn-renderer.d.ts.map +1 -0
  32. package/dist/plain-turn-renderer.js +28 -0
  33. package/dist/plain-turn-renderer.js.map +1 -0
  34. package/dist/secrets.d.ts +18 -0
  35. package/dist/secrets.d.ts.map +1 -0
  36. package/dist/secrets.js +16 -0
  37. package/dist/secrets.js.map +1 -0
  38. package/dist/turn.d.ts +96 -0
  39. package/dist/turn.d.ts.map +1 -0
  40. package/dist/turn.js +106 -0
  41. package/dist/turn.js.map +1 -0
  42. package/dist/voice-reply.d.ts +139 -0
  43. package/dist/voice-reply.d.ts.map +1 -0
  44. package/dist/voice-reply.js +333 -0
  45. package/dist/voice-reply.js.map +1 -0
  46. package/package.json +54 -0
  47. package/src/frame-pump.test.ts +209 -0
  48. package/src/frame-pump.ts +154 -0
  49. package/src/index.ts +66 -0
  50. package/src/ingest/dedupe.test.ts +58 -0
  51. package/src/ingest/dedupe.ts +66 -0
  52. package/src/ingest/http-server.test.ts +120 -0
  53. package/src/ingest/http-server.ts +173 -0
  54. package/src/pairing/host-code.test.ts +121 -0
  55. package/src/pairing/host-code.ts +159 -0
  56. package/src/pairing/tofu.test.ts +62 -0
  57. package/src/pairing/tofu.ts +60 -0
  58. package/src/permission.test.ts +69 -0
  59. package/src/permission.ts +46 -0
  60. package/src/plain-turn-renderer.ts +31 -0
  61. package/src/secrets.test.ts +59 -0
  62. package/src/secrets.ts +26 -0
  63. package/src/turn.test.ts +165 -0
  64. package/src/turn.ts +162 -0
  65. package/src/voice-reply.test.ts +316 -0
  66. package/src/voice-reply.ts +449 -0
@@ -0,0 +1,316 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { Buffer } from 'node:buffer';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import type { Synthesizer } from '@moxxy/sdk';
5
+ import {
6
+ __resetFfmpegProbeForTest,
7
+ audioExtForMime,
8
+ deliverVoiceReply,
9
+ ensureOggOpus,
10
+ resolveVoiceToggle,
11
+ synthesizeReply,
12
+ toSpeech,
13
+ type SynthesizerSource,
14
+ type VoiceReplySink,
15
+ } from './voice-reply.js';
16
+
17
+ beforeEach(() => __resetFfmpegProbeForTest());
18
+ afterEach(() => __resetFfmpegProbeForTest());
19
+
20
+ /** A session whose active synthesizer is `synth` (null = none active). */
21
+ function sessionWith(synth: Synthesizer | null): SynthesizerSource {
22
+ return { synthesizers: { tryGetActive: () => synth } };
23
+ }
24
+
25
+ function fakeSynth(
26
+ impl: (text: string) => { audio: Uint8Array; mimeType: string } | Promise<never>,
27
+ name = 'fake',
28
+ ): Synthesizer {
29
+ return {
30
+ name,
31
+ synthesize: async (text: string) => impl(text) as { audio: Uint8Array; mimeType: string },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * A fake `spawn`: the first arg selects behavior. `-version` probes resolve to
37
+ * `probeOk`; a transcode call pushes `output` to stdout then closes with
38
+ * `transcodeCode` (unless `transcodeThrows`).
39
+ */
40
+ function makeSpawn(opts: {
41
+ probeOk?: boolean;
42
+ output?: Buffer;
43
+ transcodeCode?: number;
44
+ transcodeError?: boolean;
45
+ }): { spawn: any; calls: string[][] } {
46
+ const calls: string[][] = [];
47
+ const spawn = (cmd: string, args: string[]) => {
48
+ calls.push([cmd, ...args]);
49
+ const child = new EventEmitter() as EventEmitter & {
50
+ stdin: { on: () => void; end: () => void };
51
+ stdout: EventEmitter;
52
+ stderr: EventEmitter;
53
+ kill: () => void;
54
+ killed: boolean;
55
+ };
56
+ child.stdout = new EventEmitter();
57
+ child.stderr = new EventEmitter();
58
+ child.killed = false;
59
+ child.kill = () => {
60
+ child.killed = true;
61
+ };
62
+ child.stdin = { on: () => {}, end: () => {} };
63
+ const isProbe = args.includes('-version');
64
+ queueMicrotask(() => {
65
+ if (isProbe) {
66
+ child.emit('close', opts.probeOk ? 0 : 1);
67
+ return;
68
+ }
69
+ // transcode
70
+ if (opts.transcodeError) {
71
+ child.emit('error', new Error('spawn ENOENT'));
72
+ return;
73
+ }
74
+ if ((opts.transcodeCode ?? 0) === 0 && opts.output) {
75
+ child.stdout.emit('data', opts.output);
76
+ }
77
+ child.emit('close', opts.transcodeCode ?? 0);
78
+ });
79
+ return child;
80
+ };
81
+ return { spawn, calls };
82
+ }
83
+
84
+ describe('toSpeech — markdown → spoken text', () => {
85
+ it('replaces fenced code blocks with a spoken placeholder', () => {
86
+ const out = toSpeech('Here you go:\n```ts\nconst x = 1;\n```\nDone.');
87
+ expect(out).toContain('(code omitted)');
88
+ expect(out).not.toContain('const x');
89
+ expect(out).not.toContain('```');
90
+ });
91
+
92
+ it('handles an unterminated trailing fence', () => {
93
+ const out = toSpeech('Start\n```js\nleftover');
94
+ expect(out).toContain('(code omitted)');
95
+ expect(out).not.toContain('leftover');
96
+ });
97
+
98
+ it('keeps link labels and drops urls, strips emphasis/headings/quotes/bullets', () => {
99
+ const out = toSpeech(
100
+ '# Title\n\n**Bold** and _italic_ and `code` see [the docs](https://x.y)\n\n> quoted\n\n- item one\n- item two',
101
+ );
102
+ expect(out).toContain('Title');
103
+ expect(out).toContain('Bold and italic and code');
104
+ expect(out).toContain('the docs');
105
+ expect(out).not.toContain('https://x.y');
106
+ expect(out).not.toMatch(/[#>*_`]/);
107
+ expect(out).toContain('item one');
108
+ });
109
+
110
+ it('turns an image into its alt text', () => {
111
+ expect(toSpeech('![a cat](cat.png)')).toBe('a cat');
112
+ });
113
+ });
114
+
115
+ describe('audioExtForMime', () => {
116
+ it('maps common mimes', () => {
117
+ expect(audioExtForMime('audio/ogg')).toBe('ogg');
118
+ expect(audioExtForMime('audio/opus')).toBe('ogg');
119
+ expect(audioExtForMime('audio/mpeg')).toBe('mp3');
120
+ expect(audioExtForMime('audio/wav')).toBe('wav');
121
+ expect(audioExtForMime('audio/x-weird')).toBe('audio');
122
+ });
123
+ });
124
+
125
+ describe('synthesizeReply', () => {
126
+ it('returns no-synthesizer when none is active', async () => {
127
+ const r = await synthesizeReply(sessionWith(null), 'hello');
128
+ expect(r).toEqual({ ok: false, reason: 'no-synthesizer' });
129
+ });
130
+
131
+ it('returns empty when the reply has no speakable text', async () => {
132
+ const synth = fakeSynth(() => ({ audio: new Uint8Array([1]), mimeType: 'audio/ogg' }));
133
+ const r = await synthesizeReply(sessionWith(synth), ' \n ');
134
+ expect(r).toEqual({ ok: false, reason: 'empty' });
135
+ });
136
+
137
+ it('returns audio on success and cleans markdown before synthesis', async () => {
138
+ let seen = '';
139
+ const synth = fakeSynth((text) => {
140
+ seen = text;
141
+ return { audio: new Uint8Array([9, 9]), mimeType: 'audio/mpeg' };
142
+ });
143
+ const r = await synthesizeReply(sessionWith(synth), 'Hello **world**');
144
+ expect(r).toEqual({ ok: true, audio: new Uint8Array([9, 9]), mimeType: 'audio/mpeg' });
145
+ expect(seen).toBe('Hello world');
146
+ });
147
+
148
+ it('truncates long text at a boundary', async () => {
149
+ let seen = '';
150
+ const synth = fakeSynth((text) => {
151
+ seen = text;
152
+ return { audio: new Uint8Array([1]), mimeType: 'audio/ogg' };
153
+ });
154
+ const long = `${'a'.repeat(50)}. ${'b'.repeat(50)}. ${'c'.repeat(50)}.`;
155
+ await synthesizeReply(sessionWith(synth), long, { maxChars: 60 });
156
+ expect(seen.length).toBeLessThanOrEqual(61);
157
+ expect(seen.endsWith('…')).toBe(true);
158
+ });
159
+
160
+ it('never throws when the backend rejects', async () => {
161
+ const synth: Synthesizer = {
162
+ name: 'boom',
163
+ synthesize: async () => {
164
+ throw new Error('tts down');
165
+ },
166
+ };
167
+ const r = await synthesizeReply(sessionWith(synth), 'hi');
168
+ expect(r).toEqual({ ok: false, reason: 'failed', error: 'tts down' });
169
+ });
170
+
171
+ it('treats empty audio as a failure', async () => {
172
+ const synth = fakeSynth(() => ({ audio: new Uint8Array(), mimeType: 'audio/ogg' }));
173
+ const r = await synthesizeReply(sessionWith(synth), 'hi');
174
+ expect(r.ok).toBe(false);
175
+ if (!r.ok) expect(r.reason).toBe('failed');
176
+ });
177
+ });
178
+
179
+ describe('ensureOggOpus', () => {
180
+ it('passes ogg/opus through untouched (no ffmpeg spawn)', async () => {
181
+ const { spawn, calls } = makeSpawn({});
182
+ const bytes = new Uint8Array([1, 2, 3]);
183
+ const r = await ensureOggOpus(bytes, 'audio/ogg', { spawnImpl: spawn });
184
+ expect(r).toEqual({ audio: bytes, mimeType: 'audio/ogg', transcoded: false, isOpus: true });
185
+ expect(calls).toHaveLength(0);
186
+ });
187
+
188
+ it('transcodes non-opus audio when ffmpeg is present', async () => {
189
+ const output = Buffer.from([7, 7, 7, 7]);
190
+ const { spawn, calls } = makeSpawn({ probeOk: true, output });
191
+ const r = await ensureOggOpus(new Uint8Array([1, 2]), 'audio/mpeg', { spawnImpl: spawn });
192
+ expect(r.isOpus).toBe(true);
193
+ expect(r.transcoded).toBe(true);
194
+ expect(r.mimeType).toBe('audio/ogg');
195
+ expect([...r.audio]).toEqual([7, 7, 7, 7]);
196
+ // probe + transcode.
197
+ expect(calls.length).toBe(2);
198
+ expect(calls[1]).toContain('libopus');
199
+ });
200
+
201
+ it('returns the ORIGINAL bytes with isOpus:false when ffmpeg is missing', async () => {
202
+ const { spawn } = makeSpawn({ probeOk: false });
203
+ const bytes = new Uint8Array([5, 6]);
204
+ const r = await ensureOggOpus(bytes, 'audio/mpeg', { spawnImpl: spawn });
205
+ expect(r).toEqual({ audio: bytes, mimeType: 'audio/mpeg', transcoded: false, isOpus: false });
206
+ });
207
+
208
+ it('falls back to original bytes when the transcode process fails', async () => {
209
+ const { spawn } = makeSpawn({ probeOk: true, transcodeCode: 1 });
210
+ const bytes = new Uint8Array([5, 6]);
211
+ const r = await ensureOggOpus(bytes, 'audio/mpeg', { spawnImpl: spawn });
212
+ expect(r.isOpus).toBe(false);
213
+ expect(r.audio).toBe(bytes);
214
+ });
215
+ });
216
+
217
+ describe('deliverVoiceReply', () => {
218
+ function recordingSink(): { sink: VoiceReplySink; sent: Array<{ meta: unknown; bytes: number[] }> } {
219
+ const sent: Array<{ meta: unknown; bytes: number[] }> = [];
220
+ return {
221
+ sent,
222
+ sink: { send: async (audio, meta) => void sent.push({ meta, bytes: [...audio] }) },
223
+ };
224
+ }
225
+
226
+ it('sends a voice note when a synthesizer is active and audio is opus', async () => {
227
+ const synth = fakeSynth(() => ({ audio: new Uint8Array([1, 2]), mimeType: 'audio/ogg' }));
228
+ const { sink, sent } = recordingSink();
229
+ const { spawn } = makeSpawn({});
230
+ const outcome = await deliverVoiceReply(sessionWith(synth), 'hello', sink, { spawnImpl: spawn });
231
+ expect(outcome).toEqual({ status: 'sent', transcoded: false, isVoiceNote: true });
232
+ expect(sent).toHaveLength(1);
233
+ expect(sent[0]!.meta).toMatchObject({ filename: 'reply.ogg', isVoiceNote: true });
234
+ });
235
+
236
+ it('sends plain audio (isVoiceNote:false, mime-named file) when ffmpeg is missing', async () => {
237
+ const synth = fakeSynth(() => ({ audio: new Uint8Array([1]), mimeType: 'audio/mpeg' }));
238
+ const { sink, sent } = recordingSink();
239
+ const { spawn } = makeSpawn({ probeOk: false });
240
+ const outcome = await deliverVoiceReply(sessionWith(synth), 'hi', sink, { spawnImpl: spawn });
241
+ expect(outcome).toEqual({ status: 'sent', transcoded: false, isVoiceNote: false });
242
+ expect(sent[0]!.meta).toMatchObject({ filename: 'reply.mp3', isVoiceNote: false });
243
+ });
244
+
245
+ it('skips (does not call the sink) when no synthesizer is active', async () => {
246
+ const { sink, sent } = recordingSink();
247
+ const outcome = await deliverVoiceReply(sessionWith(null), 'hi', sink);
248
+ expect(outcome).toEqual({ status: 'skipped', reason: 'no-synthesizer' });
249
+ expect(sent).toHaveLength(0);
250
+ });
251
+
252
+ it('reports a synth failure without calling the sink', async () => {
253
+ const synth: Synthesizer = {
254
+ name: 'boom',
255
+ synthesize: async () => {
256
+ throw new Error('nope');
257
+ },
258
+ };
259
+ const { sink, sent } = recordingSink();
260
+ const outcome = await deliverVoiceReply(sessionWith(synth), 'hi', sink);
261
+ expect(outcome).toEqual({ status: 'failed', reason: 'synth', error: 'nope' });
262
+ expect(sent).toHaveLength(0);
263
+ });
264
+
265
+ it('never throws when the sink throws — returns a delivery failure', async () => {
266
+ const synth = fakeSynth(() => ({ audio: new Uint8Array([1]), mimeType: 'audio/ogg' }));
267
+ const sink: VoiceReplySink = {
268
+ send: async () => {
269
+ throw new Error('network down');
270
+ },
271
+ };
272
+ const outcome = await deliverVoiceReply(sessionWith(synth), 'hi', sink);
273
+ expect(outcome).toEqual({ status: 'failed', reason: 'delivery', error: 'network down' });
274
+ });
275
+ });
276
+
277
+ describe('resolveVoiceToggle', () => {
278
+ const base = {
279
+ hasSynthesizer: true,
280
+ delivery: 'a voice note',
281
+ noSynthesizerHint: 'install tts',
282
+ };
283
+
284
+ it('toggles on an empty arg', () => {
285
+ expect(resolveVoiceToggle({ ...base, arg: '', enabled: false })).toMatchObject({
286
+ enabled: true,
287
+ persist: true,
288
+ });
289
+ expect(resolveVoiceToggle({ ...base, arg: '', enabled: true })).toMatchObject({
290
+ enabled: false,
291
+ persist: true,
292
+ });
293
+ });
294
+
295
+ it('honors explicit on/off', () => {
296
+ expect(resolveVoiceToggle({ ...base, arg: 'on', enabled: false }).enabled).toBe(true);
297
+ expect(resolveVoiceToggle({ ...base, arg: 'off', enabled: true }).enabled).toBe(false);
298
+ });
299
+
300
+ it('status never persists and reports current state', () => {
301
+ const r = resolveVoiceToggle({ ...base, arg: 'status', enabled: true });
302
+ expect(r.persist).toBe(false);
303
+ expect(r.enabled).toBe(true);
304
+ expect(r.reply).toContain('ON');
305
+ });
306
+
307
+ it('appends install guidance when enabling with no synthesizer', () => {
308
+ const r = resolveVoiceToggle({ ...base, arg: 'on', enabled: false, hasSynthesizer: false });
309
+ expect(r.reply).toContain('install tts');
310
+ });
311
+
312
+ it('does not append guidance when turning off', () => {
313
+ const r = resolveVoiceToggle({ ...base, arg: 'off', enabled: true, hasSynthesizer: false });
314
+ expect(r.reply).not.toContain('install tts');
315
+ });
316
+ });