@ai-ecoverse/pi-audio 1.23.0 → 1.24.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.
package/README.md CHANGED
@@ -35,6 +35,8 @@ pi-audio does not apply that patch. Hosts such as slicc-agent record monkeypatch
35
35
 
36
36
  `calibrateEcho(out, mic, probe, durationMs, signal)` plays a probe and aligns the mic with `AudioOut.reference`. `delayMs` is the lag. `cancellationDb` is how far the leaked copy sits below the reference, and `residualDb` is that leak. A quiet leak recommends `interrupt`, a moderate one `onWords`, and a loud one `headphones`. `chooseCapture` keeps `none` and `wasm`, and moves `browser` to `wasm` when cancellation is under 12 dB. `micOpen('browser')` asks for `echoCancellation` and `playback: 'rtc-loopback'`. `wasm` and `none` turn browser cancellation off and use `webaudio`. `assertNodeEcho` rejects `browser` on Node. `openEchoMic` leaves `browser` and `none` audio as captured. `wasm` runs an injected `EchoCanceller` against `reference`. The homescoop `@ai-ecoverse/wasm-*` echo-canceller package does not exist yet, so this package does not import one. `voiceTurns` passes `options.echo` through `openEchoMic` before `listen`.
37
37
 
38
+ `openNodeAudio({ sink: 'none' })` builds that mix on Node. `node-web-audio-api` (optional peer, BSD-3) is the `AudioContext`, created with `sinkId: { type: 'none' }` when the machine has no output device. `web-audio-api` (the audiojs package, MIT) is the pure-JS fallback: it accepts the same sink value but does not render unless a speaker or a stream is attached, so a silent sink is a discarded stream. Hosts pass the microphone as an `AudioSource`. The package does not open `node-web-audio-api`'s mic, and Node echo mode stays `wasm` or `none` with `playback: 'webaudio'`. Finished worklets post `pi-audio-release` and `bindWorkletNode` disconnects them. Returning `false` from `process` does not stop a processor in node-web-audio-api (issue 202). `openMixer(context)` is the same graph on a context the host already opened.
39
+
38
40
  `followBed(bed, soundscape)` cues a `BedChannel` from `VoicePhase`. A loop waits `graceMs` before it starts, so a short gap stays silent. `keep` leaves the current loop running across chained tools. Speech ducks the bed to `duckUnderSpeech`; listening ducks it to `duckUnderUser`. `voiceTurns` does this when `options.soundscape` is set and `out` is an `AudioOut` (`speech`, `bed`, `reference`, `playback`). A plain `AudioSink` still receives only the spoken audio.
39
41
 
40
42
  `voiceTurns` captions include `agent_spoken`. `upTo` follows `AudioSink` playback: per character when the chunk has alignment, and evenly across the sentence when it does not. `captionsFromHistory(entries)` rebuilds user captions from entries whose `data.source` is `voice`, and agent captions from the assistant text. A `voice.truncate` entry adds `agent_spoken` and `agent_cut` at `heardUpTo`, so the rest of the original text stays available as unheard.
package/dist/index.js CHANGED
@@ -17,11 +17,13 @@ import {
17
17
  openEchoMic
18
18
  } from "./echo.js";
19
19
  import { VoiceRequestError } from "./errors.js";
20
+ import { openMixer } from "./mixer.js";
20
21
  import {
21
22
  getVoiceModel,
22
23
  listVoiceModels,
23
24
  registerVoiceModel
24
25
  } from "./models.js";
26
+ import { openNodeAudio } from "./node-host.js";
25
27
  import { planDelivery, planTruncation, RealtimeLedger, realtimeSocket } from "./realtime.js";
26
28
  import {
27
29
  connectRealtime,
@@ -40,15 +42,21 @@ import { TranscriptionEventStream } from "./transcription.js";
40
42
  import { voiceTurns } from "./turns.js";
41
43
  import { addAudioCounters, priceAudio } from "./usage.js";
42
44
  import { energyVad } from "./vad.js";
45
+ import { bindWorkletNode, WORKLET_RELEASE } from "./worklet/release.js";
46
+ import { AUDIO_TAP_PROCESSOR, audioTapWorkletUrl } from "./worklet/tap.js";
43
47
  export {
48
+ AUDIO_TAP_PROCESSOR,
44
49
  RealtimeLedger,
45
50
  SpeechEventStream,
46
51
  TranscriptionEventStream,
47
52
  VoiceRequestError,
53
+ WORKLET_RELEASE,
48
54
  addAudioCounters,
49
55
  askAgent,
50
56
  assertNodeEcho,
57
+ audioTapWorkletUrl,
51
58
  bindThinker,
59
+ bindWorkletNode,
52
60
  blankUsage,
53
61
  calibrateEcho,
54
62
  captionsFromHistory,
@@ -68,6 +76,8 @@ export {
68
76
  micOpen,
69
77
  mintRealtimeToken,
70
78
  openEchoMic,
79
+ openMixer,
80
+ openNodeAudio,
71
81
  planDelivery,
72
82
  planTruncation,
73
83
  priceAudio,
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["export type {\n AudioChunk,\n AudioEncoding,\n AudioFormat,\n AudioSink,\n AudioSource,\n AudioTag,\n CharAlignment,\n TextSpan,\n} from './audio.ts';\nexport { resolveVoiceAuth } from './auth.ts';\nexport { blankUsage } from './blank-usage.ts';\nexport type { CaptionHistoryEntry } from './captions.ts';\nexport { captionsFromHistory } from './captions.ts';\nexport {\n convertAudio,\n convertAudioSource,\n decodeAudioBase64,\n encodeAudioBase64,\n} from './convert.ts';\nexport type { EchoCanceller, EchoControl, EchoMode, EchoReport, MicOpen } from './echo.ts';\nexport {\n assertNodeEcho,\n calibrateEcho,\n chooseCapture,\n echoRuntime,\n measureEcho,\n micOpen,\n openEchoMic,\n} from './echo.ts';\nexport { VoiceRequestError } from './errors.ts';\nexport type {\n Delivery,\n DeliverySupport,\n KnownRealtimeApi,\n KnownSpeechApi,\n KnownTranscriptionApi,\n RealtimeApi,\n RealtimeModel,\n SpeechApi,\n SpeechModel,\n TranscriptionApi,\n TranscriptionModel,\n VoiceInfo,\n VoiceModel,\n VoiceModelType,\n} from './models.ts';\nexport {\n getVoiceModel,\n listVoiceModels,\n registerVoiceModel,\n} from './models.ts';\nexport type {\n DeliveryPlan,\n RealtimeApiProvider,\n RealtimeContext,\n RealtimeEvent,\n RealtimeOptions,\n RealtimeSession,\n RealtimeSocket,\n RealtimeToken,\n TruncationPlan,\n} from './realtime.ts';\nexport { planDelivery, planTruncation, RealtimeLedger, realtimeSocket } from './realtime.ts';\nexport {\n connectRealtime,\n listen,\n registerRealtimeApiProvider,\n registerSpeechApiProvider,\n registerTranscriptionApiProvider,\n speak,\n transcribe,\n} from './registry.ts';\nexport type { AudioOut, BedChannel, Cue, CueSource, Soundscape } from './soundscape.ts';\nexport { followBed } from './soundscape.ts';\nexport type { SpeechApiProvider, SpeechEvent, SpeechInput } from './speech.ts';\nexport { SpeechEventStream } from './speech.ts';\nexport type { VoiceAgentEvent, VoiceMessageChange } from './spoken.ts';\nexport type {\n BindThinkerOptions,\n BoundThinker,\n Thinker,\n ThinkerCaption,\n ThinkerPhase,\n ThinkerUpdate,\n} from './talker.ts';\nexport { askAgent, bindThinker, talkerTools } from './talker.ts';\nexport type { RealtimeConstraints } from './tokens.ts';\nexport { mintRealtimeToken } from './tokens.ts';\nexport type {\n AudioInput,\n ListenOptions,\n Transcript,\n TranscriptionApiProvider,\n TranscriptionEvent,\n TranscriptSegment,\n} from './transcription.ts';\nexport { TranscriptionEventStream } from './transcription.ts';\nexport type {\n BargeIn,\n CaptionEvent,\n EchoRecommendation,\n VoiceConversation,\n VoiceCut,\n VoiceModels,\n VoicePhase,\n VoiceSession,\n VoiceTurnMeta,\n VoiceTurnOptions,\n} from './turns.ts';\nexport { voiceTurns } from './turns.ts';\nexport type {\n AudioCost,\n AudioCounters,\n AudioUsage,\n RealtimeAudioCost,\n SpeechAudioCost,\n TranscriptionAudioCost,\n} from './usage.ts';\nexport { addAudioCounters, priceAudio } from './usage.ts';\nexport type { VadHook, VadUpdate } from './vad.ts';\nexport { energyVad } from './vad.ts';\n"],
5
- "mappings": "AAUA,SAAS,wBAAwB;AACjC,SAAS,kBAAkB;AAE3B,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,yBAAyB;AAiBlC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAYP,SAAS,cAAc,gBAAgB,gBAAgB,sBAAsB;AAC7E;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,iBAAiB;AAE1B,SAAS,yBAAyB;AAUlC,SAAS,UAAU,aAAa,mBAAmB;AAEnD,SAAS,yBAAyB;AASlC,SAAS,gCAAgC;AAazC,SAAS,kBAAkB;AAS3B,SAAS,kBAAkB,kBAAkB;AAE7C,SAAS,iBAAiB;",
4
+ "sourcesContent": ["export type {\n AudioChunk,\n AudioEncoding,\n AudioFormat,\n AudioSink,\n AudioSource,\n AudioTag,\n CharAlignment,\n TextSpan,\n} from './audio.ts';\nexport { resolveVoiceAuth } from './auth.ts';\nexport { blankUsage } from './blank-usage.ts';\nexport type { CaptionHistoryEntry } from './captions.ts';\nexport { captionsFromHistory } from './captions.ts';\nexport {\n convertAudio,\n convertAudioSource,\n decodeAudioBase64,\n encodeAudioBase64,\n} from './convert.ts';\nexport type { EchoCanceller, EchoControl, EchoMode, EchoReport, MicOpen } from './echo.ts';\nexport {\n assertNodeEcho,\n calibrateEcho,\n chooseCapture,\n echoRuntime,\n measureEcho,\n micOpen,\n openEchoMic,\n} from './echo.ts';\nexport { VoiceRequestError } from './errors.ts';\nexport type { Mixer, OpenMixerOptions } from './mixer.ts';\nexport { openMixer } from './mixer.ts';\nexport type {\n Delivery,\n DeliverySupport,\n KnownRealtimeApi,\n KnownSpeechApi,\n KnownTranscriptionApi,\n RealtimeApi,\n RealtimeModel,\n SpeechApi,\n SpeechModel,\n TranscriptionApi,\n TranscriptionModel,\n VoiceInfo,\n VoiceModel,\n VoiceModelType,\n} from './models.ts';\nexport {\n getVoiceModel,\n listVoiceModels,\n registerVoiceModel,\n} from './models.ts';\nexport type {\n NodeAudioEngine,\n NodeAudioModule,\n NodeAudioOptions,\n NodeAudioSession,\n} from './node-host.ts';\nexport { openNodeAudio } from './node-host.ts';\nexport type {\n DeliveryPlan,\n RealtimeApiProvider,\n RealtimeContext,\n RealtimeEvent,\n RealtimeOptions,\n RealtimeSession,\n RealtimeSocket,\n RealtimeToken,\n TruncationPlan,\n} from './realtime.ts';\nexport { planDelivery, planTruncation, RealtimeLedger, realtimeSocket } from './realtime.ts';\nexport {\n connectRealtime,\n listen,\n registerRealtimeApiProvider,\n registerSpeechApiProvider,\n registerTranscriptionApiProvider,\n speak,\n transcribe,\n} from './registry.ts';\nexport type { AudioOut, BedChannel, Cue, CueSource, Soundscape } from './soundscape.ts';\nexport { followBed } from './soundscape.ts';\nexport type { SpeechApiProvider, SpeechEvent, SpeechInput } from './speech.ts';\nexport { SpeechEventStream } from './speech.ts';\nexport type { VoiceAgentEvent, VoiceMessageChange } from './spoken.ts';\nexport type {\n BindThinkerOptions,\n BoundThinker,\n Thinker,\n ThinkerCaption,\n ThinkerPhase,\n ThinkerUpdate,\n} from './talker.ts';\nexport { askAgent, bindThinker, talkerTools } from './talker.ts';\nexport type { RealtimeConstraints } from './tokens.ts';\nexport { mintRealtimeToken } from './tokens.ts';\nexport type {\n AudioInput,\n ListenOptions,\n Transcript,\n TranscriptionApiProvider,\n TranscriptionEvent,\n TranscriptSegment,\n} from './transcription.ts';\nexport { TranscriptionEventStream } from './transcription.ts';\nexport type {\n BargeIn,\n CaptionEvent,\n EchoRecommendation,\n VoiceConversation,\n VoiceCut,\n VoiceModels,\n VoicePhase,\n VoiceSession,\n VoiceTurnMeta,\n VoiceTurnOptions,\n} from './turns.ts';\nexport { voiceTurns } from './turns.ts';\nexport type {\n AudioCost,\n AudioCounters,\n AudioUsage,\n RealtimeAudioCost,\n SpeechAudioCost,\n TranscriptionAudioCost,\n} from './usage.ts';\nexport { addAudioCounters, priceAudio } from './usage.ts';\nexport type { VadHook, VadUpdate } from './vad.ts';\nexport { energyVad } from './vad.ts';\nexport { bindWorkletNode, WORKLET_RELEASE } from './worklet/release.ts';\nexport { AUDIO_TAP_PROCESSOR, audioTapWorkletUrl } from './worklet/tap.ts';\n"],
5
+ "mappings": "AAUA,SAAS,wBAAwB;AACjC,SAAS,kBAAkB;AAE3B,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,yBAAyB;AAElC,SAAS,iBAAiB;AAiB1B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAOP,SAAS,qBAAqB;AAY9B,SAAS,cAAc,gBAAgB,gBAAgB,sBAAsB;AAC7E;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,iBAAiB;AAE1B,SAAS,yBAAyB;AAUlC,SAAS,UAAU,aAAa,mBAAmB;AAEnD,SAAS,yBAAyB;AASlC,SAAS,gCAAgC;AAazC,SAAS,kBAAkB;AAS3B,SAAS,kBAAkB,kBAAkB;AAE7C,SAAS,iBAAiB;AAC1B,SAAS,iBAAiB,uBAAuB;AACjD,SAAS,qBAAqB,0BAA0B;",
6
6
  "names": []
7
7
  }
package/dist/mixer.js ADDED
@@ -0,0 +1,337 @@
1
+ import { chunkSeconds } from "./audio.js";
2
+ import { convertAudio } from "./convert.js";
3
+ import { echoRuntime } from "./echo.js";
4
+ import { VoiceRequestError } from "./errors.js";
5
+ import { bindWorkletNode, isWorkletRelease } from "./worklet/release.js";
6
+ import { AUDIO_TAP_PROCESSOR, loadAudioTap } from "./worklet/tap.js";
7
+ async function openMixer(context, options = {}) {
8
+ const playback = playbackOf(options);
9
+ const mix = context.createGain();
10
+ const speechGain = context.createGain();
11
+ const bedGain = context.createGain();
12
+ speechGain.connect(mix);
13
+ bedGain.connect(mix);
14
+ await loadAudioTap(context.audioWorklet, options.tapUrl);
15
+ const tap = new (workletNode(options))(context, AUDIO_TAP_PROCESSOR, {
16
+ numberOfInputs: 1,
17
+ numberOfOutputs: 1,
18
+ channelCount: 2,
19
+ outputChannelCount: [2]
20
+ });
21
+ mix.connect(tap);
22
+ tap.connect(context.destination);
23
+ const queue = new ChunkQueue();
24
+ const release = watchTap(tap, queue, context);
25
+ const speech = new SpeechOut(context, speechGain);
26
+ const bed = new BedPlayer(context, bedGain);
27
+ return {
28
+ context,
29
+ speech,
30
+ bed,
31
+ reference: queue,
32
+ playback,
33
+ close() {
34
+ release();
35
+ bed.close();
36
+ speech.flush();
37
+ queue.close();
38
+ disconnect(speechGain);
39
+ disconnect(mix);
40
+ }
41
+ };
42
+ }
43
+ class ChunkQueue {
44
+ #chunks = [];
45
+ #waiters = [];
46
+ #closed = false;
47
+ push(chunk) {
48
+ if (this.#closed) return;
49
+ const waiter = this.#waiters.shift();
50
+ if (waiter) {
51
+ waiter({ value: chunk, done: false });
52
+ return;
53
+ }
54
+ if (this.#chunks.length === 32) this.#chunks.shift();
55
+ this.#chunks.push(chunk);
56
+ }
57
+ close() {
58
+ if (this.#closed) return;
59
+ this.#closed = true;
60
+ const waiters = this.#waiters.splice(0);
61
+ for (const waiter of waiters) waiter({ value: void 0, done: true });
62
+ }
63
+ [Symbol.asyncIterator]() {
64
+ return this;
65
+ }
66
+ next() {
67
+ const queued = this.#chunks.shift();
68
+ if (queued) return Promise.resolve({ value: queued, done: false });
69
+ if (this.#closed) return Promise.resolve({ value: void 0, done: true });
70
+ return new Promise((resolve) => {
71
+ this.#waiters.push(resolve);
72
+ });
73
+ }
74
+ }
75
+ class SpeechOut {
76
+ #context;
77
+ #gain;
78
+ #listeners = /* @__PURE__ */ new Set();
79
+ #sources = [];
80
+ #timers = [];
81
+ #origin = -1;
82
+ #writtenMs = 0;
83
+ #nextAt = 0;
84
+ constructor(context, gain) {
85
+ this.#context = context;
86
+ this.#gain = gain;
87
+ }
88
+ get playedMs() {
89
+ if (this.#origin < 0) return 0;
90
+ const elapsed = (this.#context.currentTime - this.#origin) * 1e3;
91
+ if (elapsed <= 0) return 0;
92
+ if (elapsed > this.#writtenMs) return this.#writtenMs;
93
+ return elapsed;
94
+ }
95
+ write(chunk, tag) {
96
+ const channels = planar(chunk);
97
+ const frames = channels[0].length;
98
+ if (frames === 0) return;
99
+ const buffer = this.#context.createBuffer(channels.length, frames, chunk.format.sampleRate);
100
+ writeBuffer(buffer, channels);
101
+ const source = this.#context.createBufferSource();
102
+ source.buffer = buffer;
103
+ source.connect(this.#gain);
104
+ const start = Math.max(this.#context.currentTime, this.#nextAt);
105
+ source.start(start);
106
+ this.#nextAt = start + chunkSeconds(chunk);
107
+ if (this.#origin < 0) this.#origin = start;
108
+ this.#writtenMs += chunkSeconds(chunk) * 1e3;
109
+ if (tag) this.#emit(tag, start);
110
+ source.onended = () => disconnect(source);
111
+ this.#sources.push(source);
112
+ }
113
+ flush() {
114
+ this.#stopSources();
115
+ this.#clearTimers();
116
+ this.#origin = -1;
117
+ this.#writtenMs = 0;
118
+ this.#nextAt = this.#context.currentTime;
119
+ }
120
+ onPlayback(listener) {
121
+ this.#listeners.add(listener);
122
+ return () => {
123
+ this.#listeners.delete(listener);
124
+ };
125
+ }
126
+ #emit(tag, start) {
127
+ const delay = (start - this.#context.currentTime) * 1e3;
128
+ const fire = () => {
129
+ for (const listener of this.#listeners) listener(tag, 0);
130
+ };
131
+ if (delay <= 0) {
132
+ fire();
133
+ return;
134
+ }
135
+ this.#timers.push(setTimeout(fire, delay));
136
+ }
137
+ #stopSources() {
138
+ const sources = this.#sources.splice(0);
139
+ for (const source of sources) {
140
+ source.stop();
141
+ disconnect(source);
142
+ }
143
+ }
144
+ #clearTimers() {
145
+ for (const timer of this.#timers.splice(0)) clearTimeout(timer);
146
+ }
147
+ }
148
+ class BedPlayer {
149
+ #context;
150
+ #output;
151
+ #loops = [];
152
+ #shots = [];
153
+ #timers = [];
154
+ #generation = 0;
155
+ constructor(context, output) {
156
+ this.#context = context;
157
+ this.#output = output;
158
+ }
159
+ play(cue, fadeMs) {
160
+ if (cue.kind === "keep" || cue.kind === "silence") return;
161
+ if (cue.kind === "loop") this.#clear(this.#loops, 0);
162
+ const gain = this.#context.createGain();
163
+ ramp(gain.gain, cue.gain, this.#context.currentTime, fadeMs);
164
+ gain.connect(this.#output);
165
+ const token = this.#generation;
166
+ const node = this.#start(cue, gain, token);
167
+ if (!node) return;
168
+ this.#keep(cue.kind, node);
169
+ }
170
+ duck(gain, rampMs) {
171
+ ramp(this.#output.gain, gain, this.#context.currentTime, rampMs);
172
+ }
173
+ stop(fadeMs) {
174
+ this.#generation += 1;
175
+ this.#clear(this.#loops, fadeMs);
176
+ this.#clear(this.#shots, fadeMs);
177
+ }
178
+ close() {
179
+ this.stop(0);
180
+ for (const timer of this.#timers.splice(0)) clearTimeout(timer);
181
+ disconnect(this.#output);
182
+ }
183
+ #start(cue, gain, token) {
184
+ const source = cue.source;
185
+ if ("url" in source) {
186
+ void this.#loadUrl(source.url, gain, cue.kind, token).catch(() => disconnect(gain));
187
+ return void 0;
188
+ }
189
+ if ("buffer" in source) return this.#buffer(source.buffer, gain, cue.kind);
190
+ return this.#synth(source, gain);
191
+ }
192
+ #synth(source, gain) {
193
+ const node = source.synth(this.#context);
194
+ node.connect(gain);
195
+ startNode(node);
196
+ return node;
197
+ }
198
+ #buffer(buffer, gain, kind) {
199
+ const source = this.#context.createBufferSource();
200
+ source.buffer = buffer;
201
+ source.loop = kind === "loop";
202
+ source.connect(gain);
203
+ source.start();
204
+ if (kind === "oneShot") source.onended = () => disconnect(source);
205
+ return source;
206
+ }
207
+ async #loadUrl(url, gain, kind, token) {
208
+ const response = await fetch(url).catch(() => void 0);
209
+ if (response === void 0 || !response.ok || token !== this.#generation) {
210
+ disconnect(gain);
211
+ return;
212
+ }
213
+ const bytes = await response.arrayBuffer();
214
+ if (token !== this.#generation) {
215
+ disconnect(gain);
216
+ return;
217
+ }
218
+ const buffer = await decoded(this.#context, bytes);
219
+ if (buffer === void 0 || token !== this.#generation) {
220
+ disconnect(gain);
221
+ return;
222
+ }
223
+ this.#keep(kind, this.#buffer(buffer, gain, kind));
224
+ }
225
+ #keep(kind, node) {
226
+ if (kind === "loop") this.#loops.push(node);
227
+ else this.#shots.push(node);
228
+ }
229
+ #clear(list, fadeMs) {
230
+ const nodes = list.splice(0);
231
+ for (const node of nodes) this.#end(node, fadeMs);
232
+ }
233
+ #end(node, fadeMs) {
234
+ const stoppable = node;
235
+ if (fadeMs <= 0) {
236
+ if (typeof stoppable.stop === "function") stoppable.stop();
237
+ disconnect(node);
238
+ return;
239
+ }
240
+ if (typeof stoppable.stop === "function") {
241
+ stoppable.stop(this.#context.currentTime + fadeMs / 1e3);
242
+ }
243
+ this.#timers.push(setTimeout(() => disconnect(node), fadeMs));
244
+ }
245
+ }
246
+ function playbackOf(options) {
247
+ const playback = options.playback ?? "webaudio";
248
+ const runtime = options.runtime ?? echoRuntime(hasMediaDevices());
249
+ if (runtime === "node" && playback === "rtc-loopback") {
250
+ throw new VoiceRequestError("error", "Node playback is webaudio");
251
+ }
252
+ return playback;
253
+ }
254
+ function hasMediaDevices() {
255
+ return typeof navigator !== "undefined" && navigator.mediaDevices !== void 0;
256
+ }
257
+ function workletNode(options) {
258
+ if (options.workletNode) return options.workletNode;
259
+ const ctor = globalThis.AudioWorkletNode;
260
+ if (ctor) return ctor;
261
+ throw new VoiceRequestError("error", "AudioWorkletNode is not available");
262
+ }
263
+ function watchTap(tap, queue, context) {
264
+ tap.port.onmessage = (event) => {
265
+ const samples = readSamples(event.data);
266
+ if (!samples) return;
267
+ queue.push({
268
+ data: new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength).slice(),
269
+ format: { encoding: "pcmf32", sampleRate: context.sampleRate, channels: 1 },
270
+ timestamp: context.currentTime * 1e3
271
+ });
272
+ };
273
+ return bindWorkletNode(tap);
274
+ }
275
+ function readSamples(data) {
276
+ if (isWorkletRelease(data)) return void 0;
277
+ if (typeof data !== "object" || data === null || !("samples" in data)) return void 0;
278
+ if (!(data.samples instanceof Float32Array) || data.samples.length === 0) return void 0;
279
+ return data.samples;
280
+ }
281
+ function planar(chunk) {
282
+ const pcm = chunk.format.encoding === "pcmf32" ? chunk : convertAudio(chunk, { ...chunk.format, encoding: "pcmf32" });
283
+ const channels = pcm.format.channels;
284
+ const view = new DataView(pcm.data.buffer, pcm.data.byteOffset, pcm.data.byteLength);
285
+ const frames = Math.floor(pcm.data.byteLength / (4 * channels));
286
+ const out = [];
287
+ for (let channel = 0; channel < channels; channel += 1) {
288
+ const samples = new Float32Array(frames);
289
+ for (let frame = 0; frame < frames; frame += 1) {
290
+ samples[frame] = view.getFloat32((frame * channels + channel) * 4, true);
291
+ }
292
+ out.push(samples);
293
+ }
294
+ return out;
295
+ }
296
+ function writeBuffer(buffer, channels) {
297
+ if (typeof buffer.copyToChannel === "function") {
298
+ for (let index = 0; index < channels.length; index += 1) {
299
+ buffer.copyToChannel(channels[index], index);
300
+ }
301
+ return;
302
+ }
303
+ for (let index = 0; index < channels.length; index += 1) {
304
+ buffer.getChannelData(index).set(channels[index]);
305
+ }
306
+ }
307
+ function ramp(param, value, when, fadeMs) {
308
+ param.cancelScheduledValues(when);
309
+ if (fadeMs <= 0) {
310
+ param.setValueAtTime(value, when);
311
+ return;
312
+ }
313
+ param.setValueAtTime(param.value, when);
314
+ param.linearRampToValueAtTime(value, when + fadeMs / 1e3);
315
+ }
316
+ function startNode(node) {
317
+ const startable = node;
318
+ if (typeof startable.start === "function") startable.start();
319
+ }
320
+ async function decoded(context, bytes) {
321
+ try {
322
+ return await context.decodeAudioData(bytes);
323
+ } catch {
324
+ return void 0;
325
+ }
326
+ }
327
+ function disconnect(node) {
328
+ try {
329
+ node.disconnect();
330
+ } catch {
331
+ return;
332
+ }
333
+ }
334
+ export {
335
+ openMixer
336
+ };
337
+ //# sourceMappingURL=mixer.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/mixer.ts"],
4
+ "sourcesContent": ["import { type AudioChunk, type AudioSink, type AudioTag, chunkSeconds } from './audio.ts';\nimport { convertAudio } from './convert.ts';\nimport { echoRuntime } from './echo.ts';\nimport { VoiceRequestError } from './errors.ts';\nimport type { AudioOut, Cue, CueSource } from './soundscape.ts';\nimport { bindWorkletNode, isWorkletRelease, type WorkletNodeLike } from './worklet/release.ts';\nimport { AUDIO_TAP_PROCESSOR, loadAudioTap } from './worklet/tap.ts';\n\nexport interface Mixer extends AudioOut {\n close(): void;\n}\n\nexport interface MixerWorkletNode extends WorkletNodeLike {\n connect(destination: AudioNode): AudioNode;\n}\n\nexport interface OpenMixerOptions {\n playback?: 'webaudio' | 'rtc-loopback';\n runtime?: 'node' | 'browser';\n tapUrl?: string;\n workletNode?: new (\n context: BaseAudioContext,\n name: string,\n options?: AudioWorkletNodeOptions\n ) => MixerWorkletNode;\n}\n\nexport async function openMixer(\n context: BaseAudioContext,\n options: OpenMixerOptions = {}\n): Promise<Mixer> {\n const playback = playbackOf(options);\n const mix = context.createGain();\n const speechGain = context.createGain();\n const bedGain = context.createGain();\n speechGain.connect(mix);\n bedGain.connect(mix);\n await loadAudioTap(context.audioWorklet, options.tapUrl);\n const tap = new (workletNode(options))(context, AUDIO_TAP_PROCESSOR, {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n channelCount: 2,\n outputChannelCount: [2],\n });\n mix.connect(tap as unknown as AudioNode);\n tap.connect(context.destination);\n const queue = new ChunkQueue();\n const release = watchTap(tap, queue, context);\n const speech = new SpeechOut(context, speechGain);\n const bed = new BedPlayer(context, bedGain);\n return {\n context,\n speech,\n bed,\n reference: queue,\n playback,\n close() {\n release();\n bed.close();\n speech.flush();\n queue.close();\n disconnect(speechGain);\n disconnect(mix);\n },\n };\n}\n\nclass ChunkQueue implements AsyncIterable<AudioChunk> {\n readonly #chunks: AudioChunk[] = [];\n readonly #waiters: Array<(result: IteratorResult<AudioChunk>) => void> = [];\n #closed = false;\n\n push(chunk: AudioChunk): void {\n if (this.#closed) return;\n const waiter = this.#waiters.shift();\n if (waiter) {\n waiter({ value: chunk, done: false });\n return;\n }\n if (this.#chunks.length === 32) this.#chunks.shift();\n this.#chunks.push(chunk);\n }\n\n close(): void {\n if (this.#closed) return;\n this.#closed = true;\n const waiters = this.#waiters.splice(0);\n for (const waiter of waiters) waiter({ value: undefined, done: true });\n }\n\n [Symbol.asyncIterator](): AsyncIterator<AudioChunk> {\n return this;\n }\n\n next(): Promise<IteratorResult<AudioChunk>> {\n const queued = this.#chunks.shift();\n if (queued) return Promise.resolve({ value: queued, done: false });\n if (this.#closed) return Promise.resolve({ value: undefined, done: true });\n return new Promise((resolve) => {\n this.#waiters.push(resolve);\n });\n }\n}\n\nclass SpeechOut implements AudioSink {\n readonly #context: BaseAudioContext;\n readonly #gain: GainNode;\n readonly #listeners = new Set<(tag: AudioTag, offsetMs: number) => void>();\n readonly #sources: AudioBufferSourceNode[] = [];\n readonly #timers: Array<ReturnType<typeof setTimeout>> = [];\n #origin = -1;\n #writtenMs = 0;\n #nextAt = 0;\n\n constructor(context: BaseAudioContext, gain: GainNode) {\n this.#context = context;\n this.#gain = gain;\n }\n\n get playedMs(): number {\n if (this.#origin < 0) return 0;\n const elapsed = (this.#context.currentTime - this.#origin) * 1000;\n if (elapsed <= 0) return 0;\n if (elapsed > this.#writtenMs) return this.#writtenMs;\n return elapsed;\n }\n\n write(chunk: AudioChunk, tag?: AudioTag): void {\n const channels = planar(chunk);\n const frames = channels[0].length;\n if (frames === 0) return;\n const buffer = this.#context.createBuffer(channels.length, frames, chunk.format.sampleRate);\n writeBuffer(buffer, channels);\n const source = this.#context.createBufferSource();\n source.buffer = buffer;\n source.connect(this.#gain);\n const start = Math.max(this.#context.currentTime, this.#nextAt);\n source.start(start);\n this.#nextAt = start + chunkSeconds(chunk);\n if (this.#origin < 0) this.#origin = start;\n this.#writtenMs += chunkSeconds(chunk) * 1000;\n if (tag) this.#emit(tag, start);\n source.onended = () => disconnect(source);\n this.#sources.push(source);\n }\n\n flush(): void {\n this.#stopSources();\n this.#clearTimers();\n this.#origin = -1;\n this.#writtenMs = 0;\n this.#nextAt = this.#context.currentTime;\n }\n\n onPlayback(listener: (tag: AudioTag, offsetMs: number) => void): () => void {\n this.#listeners.add(listener);\n return () => {\n this.#listeners.delete(listener);\n };\n }\n\n #emit(tag: AudioTag, start: number): void {\n const delay = (start - this.#context.currentTime) * 1000;\n const fire = () => {\n for (const listener of this.#listeners) listener(tag, 0);\n };\n if (delay <= 0) {\n fire();\n return;\n }\n this.#timers.push(setTimeout(fire, delay));\n }\n\n #stopSources(): void {\n const sources = this.#sources.splice(0);\n for (const source of sources) {\n source.stop();\n disconnect(source);\n }\n }\n\n #clearTimers(): void {\n for (const timer of this.#timers.splice(0)) clearTimeout(timer);\n }\n}\n\nclass BedPlayer {\n readonly #context: BaseAudioContext;\n readonly #output: GainNode;\n readonly #loops: AudioNode[] = [];\n readonly #shots: AudioNode[] = [];\n readonly #timers: Array<ReturnType<typeof setTimeout>> = [];\n #generation = 0;\n\n constructor(context: BaseAudioContext, output: GainNode) {\n this.#context = context;\n this.#output = output;\n }\n\n play(cue: Cue, fadeMs: number): void {\n if (cue.kind === 'keep' || cue.kind === 'silence') return;\n if (cue.kind === 'loop') this.#clear(this.#loops, 0);\n const gain = this.#context.createGain();\n ramp(gain.gain, cue.gain, this.#context.currentTime, fadeMs);\n gain.connect(this.#output);\n const token = this.#generation;\n const node = this.#start(cue, gain, token);\n if (!node) return;\n this.#keep(cue.kind, node);\n }\n\n duck(gain: number, rampMs: number): void {\n ramp(this.#output.gain, gain, this.#context.currentTime, rampMs);\n }\n\n stop(fadeMs: number): void {\n this.#generation += 1;\n this.#clear(this.#loops, fadeMs);\n this.#clear(this.#shots, fadeMs);\n }\n\n close(): void {\n this.stop(0);\n for (const timer of this.#timers.splice(0)) clearTimeout(timer);\n disconnect(this.#output);\n }\n\n #start(\n cue: Extract<Cue, { kind: 'loop' | 'oneShot' }>,\n gain: GainNode,\n token: number\n ): AudioNode | undefined {\n const source = cue.source;\n if ('url' in source) {\n void this.#loadUrl(source.url, gain, cue.kind, token).catch(() => disconnect(gain));\n return undefined;\n }\n if ('buffer' in source) return this.#buffer(source.buffer, gain, cue.kind);\n return this.#synth(source, gain);\n }\n\n #synth(source: Extract<CueSource, { synth: unknown }>, gain: GainNode): AudioNode {\n const node = source.synth(this.#context);\n node.connect(gain);\n startNode(node);\n return node;\n }\n\n #buffer(buffer: AudioBuffer, gain: GainNode, kind: 'loop' | 'oneShot'): AudioNode {\n const source = this.#context.createBufferSource();\n source.buffer = buffer;\n source.loop = kind === 'loop';\n source.connect(gain);\n source.start();\n if (kind === 'oneShot') source.onended = () => disconnect(source);\n return source;\n }\n\n async #loadUrl(\n url: string,\n gain: GainNode,\n kind: 'loop' | 'oneShot',\n token: number\n ): Promise<void> {\n const response = await fetch(url).catch(() => undefined);\n if (response === undefined || !response.ok || token !== this.#generation) {\n disconnect(gain);\n return;\n }\n const bytes = await response.arrayBuffer();\n if (token !== this.#generation) {\n disconnect(gain);\n return;\n }\n const buffer = await decoded(this.#context, bytes);\n if (buffer === undefined || token !== this.#generation) {\n disconnect(gain);\n return;\n }\n this.#keep(kind, this.#buffer(buffer, gain, kind));\n }\n\n #keep(kind: 'loop' | 'oneShot', node: AudioNode): void {\n if (kind === 'loop') this.#loops.push(node);\n else this.#shots.push(node);\n }\n\n #clear(list: AudioNode[], fadeMs: number): void {\n const nodes = list.splice(0);\n for (const node of nodes) this.#end(node, fadeMs);\n }\n\n #end(node: AudioNode, fadeMs: number): void {\n const stoppable = node as AudioNode & { stop?: (when?: number) => void };\n if (fadeMs <= 0) {\n if (typeof stoppable.stop === 'function') stoppable.stop();\n disconnect(node);\n return;\n }\n if (typeof stoppable.stop === 'function') {\n stoppable.stop(this.#context.currentTime + fadeMs / 1000);\n }\n this.#timers.push(setTimeout(() => disconnect(node), fadeMs));\n }\n}\n\nfunction playbackOf(options: OpenMixerOptions): 'webaudio' | 'rtc-loopback' {\n const playback = options.playback ?? 'webaudio';\n const runtime = options.runtime ?? echoRuntime(hasMediaDevices());\n if (runtime === 'node' && playback === 'rtc-loopback') {\n throw new VoiceRequestError('error', 'Node playback is webaudio');\n }\n return playback;\n}\n\nfunction hasMediaDevices(): boolean {\n return typeof navigator !== 'undefined' && navigator.mediaDevices !== undefined;\n}\n\nfunction workletNode(options: OpenMixerOptions): NonNullable<OpenMixerOptions['workletNode']> {\n if (options.workletNode) return options.workletNode;\n const ctor = (globalThis as { AudioWorkletNode?: OpenMixerOptions['workletNode'] })\n .AudioWorkletNode;\n if (ctor) return ctor;\n throw new VoiceRequestError('error', 'AudioWorkletNode is not available');\n}\n\nfunction watchTap(tap: WorkletNodeLike, queue: ChunkQueue, context: BaseAudioContext): () => void {\n tap.port.onmessage = (event) => {\n const samples = readSamples(event.data);\n if (!samples) return;\n queue.push({\n data: new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength).slice(),\n format: { encoding: 'pcmf32', sampleRate: context.sampleRate, channels: 1 },\n timestamp: context.currentTime * 1000,\n });\n };\n return bindWorkletNode(tap);\n}\n\nfunction readSamples(data: unknown): Float32Array | undefined {\n if (isWorkletRelease(data)) return undefined;\n if (typeof data !== 'object' || data === null || !('samples' in data)) return undefined;\n if (!(data.samples instanceof Float32Array) || data.samples.length === 0) return undefined;\n return data.samples;\n}\n\nfunction planar(chunk: AudioChunk): Float32Array[] {\n const pcm =\n chunk.format.encoding === 'pcmf32'\n ? chunk\n : convertAudio(chunk, { ...chunk.format, encoding: 'pcmf32' });\n const channels = pcm.format.channels;\n const view = new DataView(pcm.data.buffer, pcm.data.byteOffset, pcm.data.byteLength);\n const frames = Math.floor(pcm.data.byteLength / (4 * channels));\n const out: Float32Array[] = [];\n for (let channel = 0; channel < channels; channel += 1) {\n const samples = new Float32Array(frames);\n for (let frame = 0; frame < frames; frame += 1) {\n samples[frame] = view.getFloat32((frame * channels + channel) * 4, true);\n }\n out.push(samples);\n }\n return out;\n}\n\nfunction writeBuffer(buffer: AudioBuffer, channels: Float32Array[]): void {\n if (typeof buffer.copyToChannel === 'function') {\n for (let index = 0; index < channels.length; index += 1) {\n buffer.copyToChannel(channels[index] as Float32Array<ArrayBuffer>, index);\n }\n return;\n }\n for (let index = 0; index < channels.length; index += 1) {\n buffer.getChannelData(index).set(channels[index] as Float32Array<ArrayBuffer>);\n }\n}\n\nfunction ramp(param: AudioParam, value: number, when: number, fadeMs: number): void {\n param.cancelScheduledValues(when);\n if (fadeMs <= 0) {\n param.setValueAtTime(value, when);\n return;\n }\n param.setValueAtTime(param.value, when);\n param.linearRampToValueAtTime(value, when + fadeMs / 1000);\n}\n\nfunction startNode(node: AudioNode): void {\n const startable = node as AudioNode & { start?: (when?: number) => void };\n if (typeof startable.start === 'function') startable.start();\n}\n\nasync function decoded(\n context: BaseAudioContext,\n bytes: ArrayBuffer\n): Promise<AudioBuffer | undefined> {\n try {\n return await context.decodeAudioData(bytes);\n } catch {\n return undefined;\n }\n}\n\nfunction disconnect(node: { disconnect(): void }): void {\n try {\n node.disconnect();\n } catch {\n return;\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,132 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { VoiceRequestError } from "./errors.js";
3
+ import { openMixer } from "./mixer.js";
4
+ const TAP_NAME = "pi-audio-tap.js";
5
+ const AUDIO_TAP_CLASSIC = [
6
+ "class PiAudioTap extends AudioWorkletProcessor {",
7
+ " constructor() {",
8
+ " super();",
9
+ " this.released = false;",
10
+ " this.posted = false;",
11
+ " var port = this.port;",
12
+ " if (port) {",
13
+ " port.onmessage = function (event) {",
14
+ ' if (event.data && event.data.type === "pi-audio-release") this.released = true;',
15
+ " }.bind(this);",
16
+ " }",
17
+ " }",
18
+ " process(inputs, outputs) {",
19
+ " if (this.released) {",
20
+ " if (!this.posted) {",
21
+ " this.posted = true;",
22
+ ' if (this.port) this.port.postMessage({ type: "pi-audio-release" });',
23
+ " }",
24
+ " return true;",
25
+ " }",
26
+ " var input = (inputs && inputs[0]) || [];",
27
+ " var output = (outputs && outputs[0]) || [];",
28
+ " for (var channel = 0; channel < output.length; channel++) {",
29
+ " var dst = output[channel];",
30
+ " if (!dst) continue;",
31
+ " var src = input[channel] || input[0];",
32
+ " if (!src) { dst.fill(0); continue; }",
33
+ " if (src.length === dst.length) dst.set(src);",
34
+ " else {",
35
+ " var count = Math.min(src.length, dst.length);",
36
+ " for (var frame = 0; frame < count; frame++) dst[frame] = src[frame];",
37
+ " }",
38
+ " }",
39
+ " var heard = input[0];",
40
+ " if (heard && heard.length && this.port) {",
41
+ " var copy = new Float32Array(heard.length);",
42
+ " copy.set(heard);",
43
+ " this.port.postMessage({ samples: copy });",
44
+ " }",
45
+ " return true;",
46
+ " }",
47
+ "}",
48
+ "registerProcessor('pi-audio-tap', PiAudioTap);",
49
+ ""
50
+ ].join("\n");
51
+ async function loadNodeAudioEngine(engine) {
52
+ if (engine === "node-web-audio-api") {
53
+ return await import("node-web-audio-api");
54
+ }
55
+ return await import("web-audio-api");
56
+ }
57
+ async function openNodeAudio(options = {}) {
58
+ const engine = options.engine ?? "auto";
59
+ const sink = options.sink ?? "none";
60
+ const load = options.load ?? loadNodeAudioEngine;
61
+ const names = engine === "auto" ? ["node-web-audio-api", "web-audio-api"] : [engine];
62
+ const tapUrl = await writeTap();
63
+ let failure;
64
+ for (const name of names) {
65
+ try {
66
+ return await startEngine(name, sink, options.sampleRate, load, tapUrl);
67
+ } catch (error) {
68
+ failure = error;
69
+ if (engine !== "auto") break;
70
+ }
71
+ }
72
+ throw new VoiceRequestError("error", failureText(failure));
73
+ }
74
+ async function startEngine(engine, sink, sampleRate, load, tapUrl) {
75
+ const loaded = await load(engine);
76
+ const built = await nodeContextOptions(engine, sink, sampleRate);
77
+ const context = new loaded.AudioContext(built.options);
78
+ try {
79
+ const out = await openMixer(context, {
80
+ playback: "webaudio",
81
+ runtime: "node",
82
+ tapUrl,
83
+ workletNode: loaded.AudioWorkletNode
84
+ });
85
+ if (context.state === "suspended") await context.resume();
86
+ return {
87
+ engine,
88
+ sink,
89
+ context,
90
+ out,
91
+ close: () => closeSession(context, out, built.stream)
92
+ };
93
+ } catch (error) {
94
+ await closeSession(context, void 0, built.stream);
95
+ throw error;
96
+ }
97
+ }
98
+ async function nodeContextOptions(engine, sink, sampleRate) {
99
+ const options = {};
100
+ if (sampleRate !== void 0) options.sampleRate = sampleRate;
101
+ if (sink === "default") return { options };
102
+ if (engine === "node-web-audio-api") {
103
+ options.sinkId = { type: "none" };
104
+ return { options };
105
+ }
106
+ const stream = new PassThrough();
107
+ stream.resume();
108
+ options.sinkId = stream;
109
+ return { options, stream };
110
+ }
111
+ async function closeSession(context, out, stream) {
112
+ if (out) out.close();
113
+ if (stream) stream.destroy();
114
+ await context.close();
115
+ }
116
+ function failureText(error) {
117
+ if (error instanceof Error) return error.message;
118
+ return "Node audio engine is unavailable";
119
+ }
120
+ async function writeTap() {
121
+ const { writeFileSync } = await import("node:fs");
122
+ const { tmpdir } = await import("node:os");
123
+ const { join } = await import("node:path");
124
+ const file = join(tmpdir(), TAP_NAME);
125
+ writeFileSync(file, AUDIO_TAP_CLASSIC);
126
+ return file;
127
+ }
128
+ export {
129
+ loadNodeAudioEngine,
130
+ openNodeAudio
131
+ };
132
+ //# sourceMappingURL=node-host.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/node-host.ts"],
4
+ "sourcesContent": ["import { PassThrough } from 'node:stream';\nimport { VoiceRequestError } from './errors.ts';\nimport { type Mixer, type OpenMixerOptions, openMixer } from './mixer.ts';\n\nexport type NodeAudioEngine = 'node-web-audio-api' | 'web-audio-api';\n\nexport interface NodeAudioModule {\n AudioContext: new (options?: Record<string, unknown>) => NodeAudioContext;\n AudioWorkletNode: NonNullable<OpenMixerOptions['workletNode']>;\n}\n\nexport interface NodeAudioContext {\n readonly state: AudioContextState;\n resume(): Promise<void>;\n close(): Promise<void>;\n readonly sampleRate: number;\n readonly currentTime: number;\n readonly destination: AudioDestinationNode;\n readonly audioWorklet: AudioWorklet;\n createGain(): GainNode;\n createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer;\n createBufferSource(): AudioBufferSourceNode;\n decodeAudioData(buffer: ArrayBuffer): Promise<AudioBuffer>;\n}\n\nexport interface NodeAudioOptions {\n engine?: NodeAudioEngine | 'auto';\n sink?: 'none' | 'default';\n sampleRate?: number;\n load?: (engine: NodeAudioEngine) => Promise<NodeAudioModule>;\n}\n\nexport interface NodeAudioSession {\n readonly engine: NodeAudioEngine;\n readonly sink: 'none' | 'default';\n readonly context: BaseAudioContext;\n readonly out: Mixer;\n close(): Promise<void>;\n}\n\nconst TAP_NAME = 'pi-audio-tap.js';\n\nconst AUDIO_TAP_CLASSIC = [\n 'class PiAudioTap extends AudioWorkletProcessor {',\n ' constructor() {',\n ' super();',\n ' this.released = false;',\n ' this.posted = false;',\n ' var port = this.port;',\n ' if (port) {',\n ' port.onmessage = function (event) {',\n ' if (event.data && event.data.type === \"pi-audio-release\") this.released = true;',\n ' }.bind(this);',\n ' }',\n ' }',\n ' process(inputs, outputs) {',\n ' if (this.released) {',\n ' if (!this.posted) {',\n ' this.posted = true;',\n ' if (this.port) this.port.postMessage({ type: \"pi-audio-release\" });',\n ' }',\n ' return true;',\n ' }',\n ' var input = (inputs && inputs[0]) || [];',\n ' var output = (outputs && outputs[0]) || [];',\n ' for (var channel = 0; channel < output.length; channel++) {',\n ' var dst = output[channel];',\n ' if (!dst) continue;',\n ' var src = input[channel] || input[0];',\n ' if (!src) { dst.fill(0); continue; }',\n ' if (src.length === dst.length) dst.set(src);',\n ' else {',\n ' var count = Math.min(src.length, dst.length);',\n ' for (var frame = 0; frame < count; frame++) dst[frame] = src[frame];',\n ' }',\n ' }',\n ' var heard = input[0];',\n ' if (heard && heard.length && this.port) {',\n ' var copy = new Float32Array(heard.length);',\n ' copy.set(heard);',\n ' this.port.postMessage({ samples: copy });',\n ' }',\n ' return true;',\n ' }',\n '}',\n \"registerProcessor('pi-audio-tap', PiAudioTap);\",\n '',\n].join('\\n');\n\nexport async function loadNodeAudioEngine(engine: NodeAudioEngine): Promise<NodeAudioModule> {\n if (engine === 'node-web-audio-api') {\n return (await import('node-web-audio-api')) as unknown as NodeAudioModule;\n }\n return (await import('web-audio-api')) as unknown as NodeAudioModule;\n}\n\nexport async function openNodeAudio(options: NodeAudioOptions = {}): Promise<NodeAudioSession> {\n const engine = options.engine ?? 'auto';\n const sink = options.sink ?? 'none';\n const load = options.load ?? loadNodeAudioEngine;\n const names: NodeAudioEngine[] =\n engine === 'auto' ? ['node-web-audio-api', 'web-audio-api'] : [engine];\n const tapUrl = await writeTap();\n let failure: unknown;\n for (const name of names) {\n try {\n return await startEngine(name, sink, options.sampleRate, load, tapUrl);\n } catch (error) {\n failure = error;\n if (engine !== 'auto') break;\n }\n }\n throw new VoiceRequestError('error', failureText(failure));\n}\n\nasync function startEngine(\n engine: NodeAudioEngine,\n sink: 'none' | 'default',\n sampleRate: number | undefined,\n load: (engine: NodeAudioEngine) => Promise<NodeAudioModule>,\n tapUrl: string\n): Promise<NodeAudioSession> {\n const loaded = await load(engine);\n const built = await nodeContextOptions(engine, sink, sampleRate);\n const context = new loaded.AudioContext(built.options);\n try {\n const out = await openMixer(context as unknown as BaseAudioContext, {\n playback: 'webaudio',\n runtime: 'node',\n tapUrl,\n workletNode: loaded.AudioWorkletNode,\n });\n if (context.state === 'suspended') await context.resume();\n return {\n engine,\n sink,\n context: context as unknown as BaseAudioContext,\n out,\n close: () => closeSession(context, out, built.stream),\n };\n } catch (error) {\n await closeSession(context, undefined, built.stream);\n throw error;\n }\n}\n\nasync function nodeContextOptions(\n engine: NodeAudioEngine,\n sink: 'none' | 'default',\n sampleRate: number | undefined\n): Promise<{ options: Record<string, unknown>; stream?: PassThrough }> {\n const options: Record<string, unknown> = {};\n if (sampleRate !== undefined) options.sampleRate = sampleRate;\n if (sink === 'default') return { options };\n if (engine === 'node-web-audio-api') {\n options.sinkId = { type: 'none' };\n return { options };\n }\n const stream = new PassThrough();\n stream.resume();\n options.sinkId = stream;\n return { options, stream };\n}\n\nasync function closeSession(\n context: NodeAudioContext,\n out: Mixer | undefined,\n stream: PassThrough | undefined\n): Promise<void> {\n if (out) out.close();\n if (stream) stream.destroy();\n await context.close();\n}\n\nfunction failureText(error: unknown): string {\n if (error instanceof Error) return error.message;\n return 'Node audio engine is unavailable';\n}\n\nasync function writeTap(): Promise<string> {\n const { writeFileSync } = await import('node:fs');\n const { tmpdir } = await import('node:os');\n const { join } = await import('node:path');\n const file = join(tmpdir(), TAP_NAME);\n writeFileSync(file, AUDIO_TAP_CLASSIC);\n return file;\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -7,8 +7,12 @@ export { convertAudio, convertAudioSource, decodeAudioBase64, encodeAudioBase64,
7
7
  export type { EchoCanceller, EchoControl, EchoMode, EchoReport, MicOpen } from './echo.ts';
8
8
  export { assertNodeEcho, calibrateEcho, chooseCapture, echoRuntime, measureEcho, micOpen, openEchoMic, } from './echo.ts';
9
9
  export { VoiceRequestError } from './errors.ts';
10
+ export type { Mixer, OpenMixerOptions } from './mixer.ts';
11
+ export { openMixer } from './mixer.ts';
10
12
  export type { Delivery, DeliverySupport, KnownRealtimeApi, KnownSpeechApi, KnownTranscriptionApi, RealtimeApi, RealtimeModel, SpeechApi, SpeechModel, TranscriptionApi, TranscriptionModel, VoiceInfo, VoiceModel, VoiceModelType, } from './models.ts';
11
13
  export { getVoiceModel, listVoiceModels, registerVoiceModel, } from './models.ts';
14
+ export type { NodeAudioEngine, NodeAudioModule, NodeAudioOptions, NodeAudioSession, } from './node-host.ts';
15
+ export { openNodeAudio } from './node-host.ts';
12
16
  export type { DeliveryPlan, RealtimeApiProvider, RealtimeContext, RealtimeEvent, RealtimeOptions, RealtimeSession, RealtimeSocket, RealtimeToken, TruncationPlan, } from './realtime.ts';
13
17
  export { planDelivery, planTruncation, RealtimeLedger, realtimeSocket } from './realtime.ts';
14
18
  export { connectRealtime, listen, registerRealtimeApiProvider, registerSpeechApiProvider, registerTranscriptionApiProvider, speak, transcribe, } from './registry.ts';
@@ -29,3 +33,5 @@ export type { AudioCost, AudioCounters, AudioUsage, RealtimeAudioCost, SpeechAud
29
33
  export { addAudioCounters, priceAudio } from './usage.ts';
30
34
  export type { VadHook, VadUpdate } from './vad.ts';
31
35
  export { energyVad } from './vad.ts';
36
+ export { bindWorkletNode, WORKLET_RELEASE } from './worklet/release.ts';
37
+ export { AUDIO_TAP_PROCESSOR, audioTapWorkletUrl } from './worklet/tap.ts';
@@ -0,0 +1,15 @@
1
+ import type { AudioOut } from './soundscape.ts';
2
+ import { type WorkletNodeLike } from './worklet/release.ts';
3
+ export interface Mixer extends AudioOut {
4
+ close(): void;
5
+ }
6
+ export interface MixerWorkletNode extends WorkletNodeLike {
7
+ connect(destination: AudioNode): AudioNode;
8
+ }
9
+ export interface OpenMixerOptions {
10
+ playback?: 'webaudio' | 'rtc-loopback';
11
+ runtime?: 'node' | 'browser';
12
+ tapUrl?: string;
13
+ workletNode?: new (context: BaseAudioContext, name: string, options?: AudioWorkletNodeOptions) => MixerWorkletNode;
14
+ }
15
+ export declare function openMixer(context: BaseAudioContext, options?: OpenMixerOptions): Promise<Mixer>;
@@ -0,0 +1,34 @@
1
+ import { type Mixer, type OpenMixerOptions } from './mixer.ts';
2
+ export type NodeAudioEngine = 'node-web-audio-api' | 'web-audio-api';
3
+ export interface NodeAudioModule {
4
+ AudioContext: new (options?: Record<string, unknown>) => NodeAudioContext;
5
+ AudioWorkletNode: NonNullable<OpenMixerOptions['workletNode']>;
6
+ }
7
+ export interface NodeAudioContext {
8
+ readonly state: AudioContextState;
9
+ resume(): Promise<void>;
10
+ close(): Promise<void>;
11
+ readonly sampleRate: number;
12
+ readonly currentTime: number;
13
+ readonly destination: AudioDestinationNode;
14
+ readonly audioWorklet: AudioWorklet;
15
+ createGain(): GainNode;
16
+ createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer;
17
+ createBufferSource(): AudioBufferSourceNode;
18
+ decodeAudioData(buffer: ArrayBuffer): Promise<AudioBuffer>;
19
+ }
20
+ export interface NodeAudioOptions {
21
+ engine?: NodeAudioEngine | 'auto';
22
+ sink?: 'none' | 'default';
23
+ sampleRate?: number;
24
+ load?: (engine: NodeAudioEngine) => Promise<NodeAudioModule>;
25
+ }
26
+ export interface NodeAudioSession {
27
+ readonly engine: NodeAudioEngine;
28
+ readonly sink: 'none' | 'default';
29
+ readonly context: BaseAudioContext;
30
+ readonly out: Mixer;
31
+ close(): Promise<void>;
32
+ }
33
+ export declare function loadNodeAudioEngine(engine: NodeAudioEngine): Promise<NodeAudioModule>;
34
+ export declare function openNodeAudio(options?: NodeAudioOptions): Promise<NodeAudioSession>;
@@ -6,22 +6,25 @@ export interface AudioConvertProcessorOptions {
6
6
  sampleRate?: number;
7
7
  };
8
8
  }
9
+ interface WorkletPort {
10
+ postMessage(message: unknown): void;
11
+ onmessage: ((event: {
12
+ data?: unknown;
13
+ }) => void) | null;
14
+ }
9
15
  interface WorkletScope {
10
16
  AudioWorkletProcessor?: new (options?: AudioConvertProcessorOptions) => {
11
- port?: {
12
- postMessage(message: unknown): void;
13
- };
17
+ port?: WorkletPort;
14
18
  };
15
19
  registerProcessor?: (name: string, processor: new (options?: AudioConvertProcessorOptions) => object) => void;
16
20
  }
17
21
  declare const WorkletBase: new (options?: AudioConvertProcessorOptions) => {
18
- port?: {
19
- postMessage(message: unknown): void;
20
- };
22
+ port?: WorkletPort;
21
23
  };
22
24
  export declare class AudioConvertProcessor extends WorkletBase {
23
25
  #private;
24
26
  constructor(options?: AudioConvertProcessorOptions);
27
+ release(): void;
25
28
  process(inputs: Array<ArrayLike<Float32Array | undefined> | undefined>): boolean;
26
29
  take(): Uint8Array[];
27
30
  }
@@ -0,0 +1,13 @@
1
+ export declare const WORKLET_RELEASE = "pi-audio-release";
2
+ export interface WorkletPort {
3
+ postMessage(message: unknown): void;
4
+ onmessage: ((event: {
5
+ data?: unknown;
6
+ }) => void) | null;
7
+ }
8
+ export interface WorkletNodeLike {
9
+ disconnect(): void;
10
+ port: WorkletPort;
11
+ }
12
+ export declare function isWorkletRelease(data: unknown): boolean;
13
+ export declare function bindWorkletNode(node: WorkletNodeLike): () => void;
@@ -0,0 +1,25 @@
1
+ export declare const AUDIO_TAP_PROCESSOR = "pi-audio-tap";
2
+ interface TapPort {
3
+ postMessage(message: unknown): void;
4
+ onmessage: ((event: {
5
+ data?: unknown;
6
+ }) => void) | null;
7
+ }
8
+ interface WorkletScope {
9
+ AudioWorkletProcessor?: new () => {
10
+ port?: TapPort;
11
+ };
12
+ registerProcessor?: (name: string, processor: new () => object) => void;
13
+ }
14
+ interface TapInputs {
15
+ inputs: Array<ArrayLike<Float32Array | undefined> | undefined>;
16
+ outputs: Array<ArrayLike<Float32Array | undefined> | undefined>;
17
+ }
18
+ export declare function copyTap(inputs: TapInputs['inputs'], outputs: TapInputs['outputs']): void;
19
+ export declare function tapSamples(inputs: TapInputs['inputs']): Float32Array | undefined;
20
+ export declare function registerAudioTapWorklet(target?: WorkletScope): void;
21
+ export declare function audioTapWorkletUrl(): string;
22
+ export declare function loadAudioTap(worklet: {
23
+ addModule(module: unknown): Promise<void>;
24
+ }, url?: string): Promise<void>;
25
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { convertAudio } from "../convert.js";
2
+ import { isWorkletRelease, WORKLET_RELEASE } from "./release.js";
2
3
  const AUDIO_CONVERT_PROCESSOR = "pi-audio-convert";
3
4
  const scope = globalThis;
4
5
  const WorkletBase = scope.AudioWorkletProcessor ?? class {
@@ -8,6 +9,8 @@ class AudioConvertProcessor extends WorkletBase {
8
9
  #format;
9
10
  #sampleRate;
10
11
  #frames = 0;
12
+ #released = false;
13
+ #posted = false;
11
14
  #pending = [];
12
15
  constructor(options) {
13
16
  super(options);
@@ -18,8 +21,21 @@ class AudioConvertProcessor extends WorkletBase {
18
21
  };
19
22
  const sampleRate = options?.processorOptions?.sampleRate;
20
23
  this.#sampleRate = sampleRate !== void 0 && Number.isFinite(sampleRate) && sampleRate > 0 ? sampleRate : 48e3;
24
+ const port = this.port;
25
+ if (port) {
26
+ port.onmessage = (event) => {
27
+ if (isWorkletRelease(event.data)) this.release();
28
+ };
29
+ }
30
+ }
31
+ release() {
32
+ this.#released = true;
21
33
  }
22
34
  process(inputs) {
35
+ if (this.#released) {
36
+ this.#finish();
37
+ return true;
38
+ }
23
39
  const input = inputs[0];
24
40
  const channels = [];
25
41
  if (input) {
@@ -59,6 +75,13 @@ class AudioConvertProcessor extends WorkletBase {
59
75
  });
60
76
  return true;
61
77
  }
78
+ #finish() {
79
+ if (this.#posted) return;
80
+ this.#posted = true;
81
+ const port = this.port;
82
+ if (!port) return;
83
+ port.postMessage({ type: WORKLET_RELEASE });
84
+ }
62
85
  take() {
63
86
  return this.#pending.splice(0);
64
87
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/worklet/convert.ts"],
4
- "sourcesContent": ["import type { AudioFormat } from '../audio.ts';\nimport { convertAudio } from '../convert.ts';\n\nexport const AUDIO_CONVERT_PROCESSOR = 'pi-audio-convert';\n\nexport interface AudioConvertProcessorOptions {\n processorOptions?: {\n format?: AudioFormat;\n sampleRate?: number;\n };\n}\n\ninterface WorkletScope {\n AudioWorkletProcessor?: new (\n options?: AudioConvertProcessorOptions\n ) => {\n port?: { postMessage(message: unknown): void };\n };\n registerProcessor?: (\n name: string,\n processor: new (options?: AudioConvertProcessorOptions) => object\n ) => void;\n}\n\nconst scope = globalThis as WorkletScope;\n\nconst WorkletBase =\n scope.AudioWorkletProcessor ??\n class {\n port?: { postMessage(message: unknown): void };\n };\n\nexport class AudioConvertProcessor extends WorkletBase {\n readonly #format: AudioFormat;\n readonly #sampleRate: number;\n #frames = 0;\n readonly #pending: Uint8Array[] = [];\n\n constructor(options?: AudioConvertProcessorOptions) {\n super(options);\n this.#format = options?.processorOptions?.format ?? {\n encoding: 'pcm16',\n sampleRate: 24000,\n channels: 1,\n };\n const sampleRate = options?.processorOptions?.sampleRate;\n this.#sampleRate =\n sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate > 0\n ? sampleRate\n : 48000;\n }\n\n process(inputs: Array<ArrayLike<Float32Array | undefined> | undefined>): boolean {\n const input = inputs[0];\n const channels: Float32Array[] = [];\n if (input) {\n for (let index = 0; index < input.length; index++) {\n const channel = input[index];\n if (channel) channels.push(channel);\n }\n }\n const frames = channels[0]?.length ?? 0;\n if (channels.length === 0 || frames === 0) return true;\n const count = Math.min(channels.length, 2);\n const data = new Uint8Array(frames * count * 4);\n const view = new DataView(data.buffer);\n for (let frame = 0; frame < frames; frame++) {\n for (let channel = 0; channel < count; channel++) {\n view.setFloat32((frame * count + channel) * 4, channels[channel]?.[frame] ?? 0, true);\n }\n }\n const converted = convertAudio(\n {\n data,\n format: {\n encoding: 'pcmf32',\n sampleRate: this.#sampleRate,\n channels: count === 1 ? 1 : 2,\n },\n timestamp: (this.#frames / this.#sampleRate) * 1000,\n },\n this.#format\n );\n this.#frames += frames;\n this.#pending.push(converted.data);\n this.port?.postMessage({\n data: converted.data,\n format: converted.format,\n timestamp: converted.timestamp,\n });\n return true;\n }\n\n take(): Uint8Array[] {\n return this.#pending.splice(0);\n }\n}\n\nexport function registerAudioConvertWorklet(target: WorkletScope = scope): void {\n if (typeof target.registerProcessor !== 'function') return;\n target.registerProcessor(AUDIO_CONVERT_PROCESSOR, AudioConvertProcessor);\n}\n\nexport function audioConvertWorkletUrl(): string {\n return new URL(import.meta.url).href;\n}\n\nregisterAudioConvertWorklet();\n"],
5
- "mappings": "AACA,SAAS,oBAAoB;AAEtB,MAAM,0BAA0B;AAqBvC,MAAM,QAAQ;AAEd,MAAM,cACJ,MAAM,yBACN,MAAM;AAAA,EACJ;AACF;AAEK,MAAM,8BAA8B,YAAY;AAAA,EAC5C;AAAA,EACA;AAAA,EACT,UAAU;AAAA,EACD,WAAyB,CAAC;AAAA,EAEnC,YAAY,SAAwC;AAClD,UAAM,OAAO;AACb,SAAK,UAAU,SAAS,kBAAkB,UAAU;AAAA,MAClD,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AACA,UAAM,aAAa,SAAS,kBAAkB;AAC9C,SAAK,cACH,eAAe,UAAa,OAAO,SAAS,UAAU,KAAK,aAAa,IACpE,aACA;AAAA,EACR;AAAA,EAEA,QAAQ,QAAyE;AAC/E,UAAM,QAAQ,OAAO,CAAC;AACtB,UAAM,WAA2B,CAAC;AAClC,QAAI,OAAO;AACT,eAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS;AACjD,cAAM,UAAU,MAAM,KAAK;AAC3B,YAAI,QAAS,UAAS,KAAK,OAAO;AAAA,MACpC;AAAA,IACF;AACA,UAAM,SAAS,SAAS,CAAC,GAAG,UAAU;AACtC,QAAI,SAAS,WAAW,KAAK,WAAW,EAAG,QAAO;AAClD,UAAM,QAAQ,KAAK,IAAI,SAAS,QAAQ,CAAC;AACzC,UAAM,OAAO,IAAI,WAAW,SAAS,QAAQ,CAAC;AAC9C,UAAM,OAAO,IAAI,SAAS,KAAK,MAAM;AACrC,aAAS,QAAQ,GAAG,QAAQ,QAAQ,SAAS;AAC3C,eAAS,UAAU,GAAG,UAAU,OAAO,WAAW;AAChD,aAAK,YAAY,QAAQ,QAAQ,WAAW,GAAG,SAAS,OAAO,IAAI,KAAK,KAAK,GAAG,IAAI;AAAA,MACtF;AAAA,IACF;AACA,UAAM,YAAY;AAAA,MAChB;AAAA,QACE;AAAA,QACA,QAAQ;AAAA,UACN,UAAU;AAAA,UACV,YAAY,KAAK;AAAA,UACjB,UAAU,UAAU,IAAI,IAAI;AAAA,QAC9B;AAAA,QACA,WAAY,KAAK,UAAU,KAAK,cAAe;AAAA,MACjD;AAAA,MACA,KAAK;AAAA,IACP;AACA,SAAK,WAAW;AAChB,SAAK,SAAS,KAAK,UAAU,IAAI;AACjC,SAAK,MAAM,YAAY;AAAA,MACrB,MAAM,UAAU;AAAA,MAChB,QAAQ,UAAU;AAAA,MAClB,WAAW,UAAU;AAAA,IACvB,CAAC;AACD,WAAO;AAAA,EACT;AAAA,EAEA,OAAqB;AACnB,WAAO,KAAK,SAAS,OAAO,CAAC;AAAA,EAC/B;AACF;AAEO,SAAS,4BAA4B,SAAuB,OAAa;AAC9E,MAAI,OAAO,OAAO,sBAAsB,WAAY;AACpD,SAAO,kBAAkB,yBAAyB,qBAAqB;AACzE;AAEO,SAAS,yBAAiC;AAC/C,SAAO,IAAI,IAAI,YAAY,GAAG,EAAE;AAClC;AAEA,4BAA4B;",
4
+ "sourcesContent": ["import type { AudioFormat } from '../audio.ts';\nimport { convertAudio } from '../convert.ts';\nimport { isWorkletRelease, WORKLET_RELEASE } from './release.ts';\n\nexport const AUDIO_CONVERT_PROCESSOR = 'pi-audio-convert';\n\nexport interface AudioConvertProcessorOptions {\n processorOptions?: {\n format?: AudioFormat;\n sampleRate?: number;\n };\n}\n\ninterface WorkletPort {\n postMessage(message: unknown): void;\n onmessage: ((event: { data?: unknown }) => void) | null;\n}\n\ninterface WorkletScope {\n AudioWorkletProcessor?: new (\n options?: AudioConvertProcessorOptions\n ) => {\n port?: WorkletPort;\n };\n registerProcessor?: (\n name: string,\n processor: new (options?: AudioConvertProcessorOptions) => object\n ) => void;\n}\n\nconst scope = globalThis as WorkletScope;\n\nconst WorkletBase =\n scope.AudioWorkletProcessor ??\n class {\n port?: WorkletPort;\n };\n\nexport class AudioConvertProcessor extends WorkletBase {\n readonly #format: AudioFormat;\n readonly #sampleRate: number;\n #frames = 0;\n #released = false;\n #posted = false;\n readonly #pending: Uint8Array[] = [];\n\n constructor(options?: AudioConvertProcessorOptions) {\n super(options);\n this.#format = options?.processorOptions?.format ?? {\n encoding: 'pcm16',\n sampleRate: 24000,\n channels: 1,\n };\n const sampleRate = options?.processorOptions?.sampleRate;\n this.#sampleRate =\n sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate > 0\n ? sampleRate\n : 48000;\n const port = this.port;\n if (port) {\n port.onmessage = (event) => {\n if (isWorkletRelease(event.data)) this.release();\n };\n }\n }\n\n release(): void {\n this.#released = true;\n }\n\n process(inputs: Array<ArrayLike<Float32Array | undefined> | undefined>): boolean {\n if (this.#released) {\n this.#finish();\n return true;\n }\n const input = inputs[0];\n const channels: Float32Array[] = [];\n if (input) {\n for (let index = 0; index < input.length; index++) {\n const channel = input[index];\n if (channel) channels.push(channel);\n }\n }\n const frames = channels[0]?.length ?? 0;\n if (channels.length === 0 || frames === 0) return true;\n const count = Math.min(channels.length, 2);\n const data = new Uint8Array(frames * count * 4);\n const view = new DataView(data.buffer);\n for (let frame = 0; frame < frames; frame++) {\n for (let channel = 0; channel < count; channel++) {\n view.setFloat32((frame * count + channel) * 4, channels[channel]?.[frame] ?? 0, true);\n }\n }\n const converted = convertAudio(\n {\n data,\n format: {\n encoding: 'pcmf32',\n sampleRate: this.#sampleRate,\n channels: count === 1 ? 1 : 2,\n },\n timestamp: (this.#frames / this.#sampleRate) * 1000,\n },\n this.#format\n );\n this.#frames += frames;\n this.#pending.push(converted.data);\n this.port?.postMessage({\n data: converted.data,\n format: converted.format,\n timestamp: converted.timestamp,\n });\n return true;\n }\n\n #finish(): void {\n if (this.#posted) return;\n this.#posted = true;\n const port = this.port;\n if (!port) return;\n port.postMessage({ type: WORKLET_RELEASE });\n }\n\n take(): Uint8Array[] {\n return this.#pending.splice(0);\n }\n}\n\nexport function registerAudioConvertWorklet(target: WorkletScope = scope): void {\n if (typeof target.registerProcessor !== 'function') return;\n target.registerProcessor(AUDIO_CONVERT_PROCESSOR, AudioConvertProcessor);\n}\n\nexport function audioConvertWorkletUrl(): string {\n return new URL(import.meta.url).href;\n}\n\nregisterAudioConvertWorklet();\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB,uBAAuB;AAE3C,MAAM,0BAA0B;AA0BvC,MAAM,QAAQ;AAEd,MAAM,cACJ,MAAM,yBACN,MAAM;AAAA,EACJ;AACF;AAEK,MAAM,8BAA8B,YAAY;AAAA,EAC5C;AAAA,EACA;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,UAAU;AAAA,EACD,WAAyB,CAAC;AAAA,EAEnC,YAAY,SAAwC;AAClD,UAAM,OAAO;AACb,SAAK,UAAU,SAAS,kBAAkB,UAAU;AAAA,MAClD,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AACA,UAAM,aAAa,SAAS,kBAAkB;AAC9C,SAAK,cACH,eAAe,UAAa,OAAO,SAAS,UAAU,KAAK,aAAa,IACpE,aACA;AACN,UAAM,OAAO,KAAK;AAClB,QAAI,MAAM;AACR,WAAK,YAAY,CAAC,UAAU;AAC1B,YAAI,iBAAiB,MAAM,IAAI,EAAG,MAAK,QAAQ;AAAA,MACjD;AAAA,IACF;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,YAAY;AAAA,EACnB;AAAA,EAEA,QAAQ,QAAyE;AAC/E,QAAI,KAAK,WAAW;AAClB,WAAK,QAAQ;AACb,aAAO;AAAA,IACT;AACA,UAAM,QAAQ,OAAO,CAAC;AACtB,UAAM,WAA2B,CAAC;AAClC,QAAI,OAAO;AACT,eAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS;AACjD,cAAM,UAAU,MAAM,KAAK;AAC3B,YAAI,QAAS,UAAS,KAAK,OAAO;AAAA,MACpC;AAAA,IACF;AACA,UAAM,SAAS,SAAS,CAAC,GAAG,UAAU;AACtC,QAAI,SAAS,WAAW,KAAK,WAAW,EAAG,QAAO;AAClD,UAAM,QAAQ,KAAK,IAAI,SAAS,QAAQ,CAAC;AACzC,UAAM,OAAO,IAAI,WAAW,SAAS,QAAQ,CAAC;AAC9C,UAAM,OAAO,IAAI,SAAS,KAAK,MAAM;AACrC,aAAS,QAAQ,GAAG,QAAQ,QAAQ,SAAS;AAC3C,eAAS,UAAU,GAAG,UAAU,OAAO,WAAW;AAChD,aAAK,YAAY,QAAQ,QAAQ,WAAW,GAAG,SAAS,OAAO,IAAI,KAAK,KAAK,GAAG,IAAI;AAAA,MACtF;AAAA,IACF;AACA,UAAM,YAAY;AAAA,MAChB;AAAA,QACE;AAAA,QACA,QAAQ;AAAA,UACN,UAAU;AAAA,UACV,YAAY,KAAK;AAAA,UACjB,UAAU,UAAU,IAAI,IAAI;AAAA,QAC9B;AAAA,QACA,WAAY,KAAK,UAAU,KAAK,cAAe;AAAA,MACjD;AAAA,MACA,KAAK;AAAA,IACP;AACA,SAAK,WAAW;AAChB,SAAK,SAAS,KAAK,UAAU,IAAI;AACjC,SAAK,MAAM,YAAY;AAAA,MACrB,MAAM,UAAU;AAAA,MAChB,QAAQ,UAAU;AAAA,MAClB,WAAW,UAAU;AAAA,IACvB,CAAC;AACD,WAAO;AAAA,EACT;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,QAAS;AAClB,SAAK,UAAU;AACf,UAAM,OAAO,KAAK;AAClB,QAAI,CAAC,KAAM;AACX,SAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAAA,EAC5C;AAAA,EAEA,OAAqB;AACnB,WAAO,KAAK,SAAS,OAAO,CAAC;AAAA,EAC/B;AACF;AAEO,SAAS,4BAA4B,SAAuB,OAAa;AAC9E,MAAI,OAAO,OAAO,sBAAsB,WAAY;AACpD,SAAO,kBAAkB,yBAAyB,qBAAqB;AACzE;AAEO,SAAS,yBAAiC;AAC/C,SAAO,IAAI,IAAI,YAAY,GAAG,EAAE;AAClC;AAEA,4BAA4B;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,42 @@
1
+ const WORKLET_RELEASE = "pi-audio-release";
2
+ function isWorkletRelease(data) {
3
+ if (typeof data !== "object" || data === null) return false;
4
+ if (!("type" in data)) return false;
5
+ return data.type === WORKLET_RELEASE;
6
+ }
7
+ function bindWorkletNode(node) {
8
+ let disconnected = false;
9
+ const disconnect = () => {
10
+ if (disconnected) return;
11
+ disconnected = true;
12
+ node.disconnect();
13
+ closePort(node.port);
14
+ };
15
+ const previous = node.port.onmessage;
16
+ node.port.onmessage = (event) => {
17
+ if (previous) previous(event);
18
+ if (isWorkletRelease(event.data)) disconnect();
19
+ };
20
+ let stopped = false;
21
+ return () => {
22
+ if (stopped) return;
23
+ stopped = true;
24
+ node.port.postMessage({ type: WORKLET_RELEASE });
25
+ disconnect();
26
+ };
27
+ }
28
+ function closePort(port) {
29
+ const closer = port.close;
30
+ if (typeof closer !== "function") return;
31
+ try {
32
+ closer.call(port);
33
+ } catch {
34
+ return;
35
+ }
36
+ }
37
+ export {
38
+ WORKLET_RELEASE,
39
+ bindWorkletNode,
40
+ isWorkletRelease
41
+ };
42
+ //# sourceMappingURL=release.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/worklet/release.ts"],
4
+ "sourcesContent": ["export const WORKLET_RELEASE = 'pi-audio-release';\n\nexport interface WorkletPort {\n postMessage(message: unknown): void;\n onmessage: ((event: { data?: unknown }) => void) | null;\n}\n\nexport interface WorkletNodeLike {\n disconnect(): void;\n port: WorkletPort;\n}\n\nexport function isWorkletRelease(data: unknown): boolean {\n if (typeof data !== 'object' || data === null) return false;\n if (!('type' in data)) return false;\n return data.type === WORKLET_RELEASE;\n}\n\nexport function bindWorkletNode(node: WorkletNodeLike): () => void {\n let disconnected = false;\n const disconnect = () => {\n if (disconnected) return;\n disconnected = true;\n node.disconnect();\n closePort(node.port);\n };\n const previous = node.port.onmessage;\n node.port.onmessage = (event) => {\n if (previous) previous(event);\n if (isWorkletRelease(event.data)) disconnect();\n };\n let stopped = false;\n return () => {\n if (stopped) return;\n stopped = true;\n node.port.postMessage({ type: WORKLET_RELEASE });\n disconnect();\n };\n}\n\nfunction closePort(port: WorkletPort): void {\n const closer = (port as WorkletPort & { close?: () => void }).close;\n if (typeof closer !== 'function') return;\n try {\n closer.call(port);\n } catch {\n return;\n }\n}\n"],
5
+ "mappings": "AAAO,MAAM,kBAAkB;AAYxB,SAAS,iBAAiB,MAAwB;AACvD,MAAI,OAAO,SAAS,YAAY,SAAS,KAAM,QAAO;AACtD,MAAI,EAAE,UAAU,MAAO,QAAO;AAC9B,SAAO,KAAK,SAAS;AACvB;AAEO,SAAS,gBAAgB,MAAmC;AACjE,MAAI,eAAe;AACnB,QAAM,aAAa,MAAM;AACvB,QAAI,aAAc;AAClB,mBAAe;AACf,SAAK,WAAW;AAChB,cAAU,KAAK,IAAI;AAAA,EACrB;AACA,QAAM,WAAW,KAAK,KAAK;AAC3B,OAAK,KAAK,YAAY,CAAC,UAAU;AAC/B,QAAI,SAAU,UAAS,KAAK;AAC5B,QAAI,iBAAiB,MAAM,IAAI,EAAG,YAAW;AAAA,EAC/C;AACA,MAAI,UAAU;AACd,SAAO,MAAM;AACX,QAAI,QAAS;AACb,cAAU;AACV,SAAK,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/C,eAAW;AAAA,EACb;AACF;AAEA,SAAS,UAAU,MAAyB;AAC1C,QAAM,SAAU,KAA8C;AAC9D,MAAI,OAAO,WAAW,WAAY;AAClC,MAAI;AACF,WAAO,KAAK,IAAI;AAAA,EAClB,QAAQ;AACN;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,97 @@
1
+ import { isWorkletRelease, WORKLET_RELEASE } from "./release.js";
2
+ const AUDIO_TAP_PROCESSOR = "pi-audio-tap";
3
+ const scope = globalThis;
4
+ const registered = /* @__PURE__ */ new WeakSet();
5
+ function copyTap(inputs, outputs) {
6
+ const input = inputs[0] ?? [];
7
+ const output = outputs[0] ?? [];
8
+ for (let channel = 0; channel < output.length; channel += 1) {
9
+ const dst = output[channel];
10
+ if (dst === void 0) continue;
11
+ writeChannel(dst, input[channel] ?? input[0]);
12
+ }
13
+ }
14
+ function tapSamples(inputs) {
15
+ const heard = inputs[0]?.[0];
16
+ if (heard === void 0 || heard.length === 0) return void 0;
17
+ const copy = new Float32Array(heard.length);
18
+ copy.set(heard);
19
+ return copy;
20
+ }
21
+ function registerAudioTapWorklet(target = scope) {
22
+ if (typeof target.registerProcessor !== "function") return;
23
+ if (registered.has(target)) return;
24
+ registered.add(target);
25
+ const Base = target.AudioWorkletProcessor ?? class {
26
+ port;
27
+ };
28
+ const processor = class AudioTapProcessor extends Base {
29
+ released = false;
30
+ #posted = false;
31
+ constructor() {
32
+ super();
33
+ const port = this.port;
34
+ if (port) {
35
+ port.onmessage = (event) => {
36
+ if (isWorkletRelease(event.data)) this.released = true;
37
+ };
38
+ }
39
+ }
40
+ process(inputs, outputs) {
41
+ if (this.released) {
42
+ this.#finish();
43
+ return true;
44
+ }
45
+ copyTap(inputs, outputs);
46
+ const samples = tapSamples(inputs);
47
+ if (samples) this.port?.postMessage({ samples });
48
+ return true;
49
+ }
50
+ #finish() {
51
+ if (this.#posted) return;
52
+ this.#posted = true;
53
+ const port = this.port;
54
+ if (!port) return;
55
+ port.postMessage({ type: WORKLET_RELEASE });
56
+ }
57
+ };
58
+ target.registerProcessor(AUDIO_TAP_PROCESSOR, processor);
59
+ }
60
+ function audioTapWorkletUrl() {
61
+ return new URL(import.meta.url).href;
62
+ }
63
+ async function loadAudioTap(worklet, url = audioTapWorkletUrl()) {
64
+ try {
65
+ await worklet.addModule(url);
66
+ } catch (error) {
67
+ try {
68
+ await worklet.addModule((next) => {
69
+ registerAudioTapWorklet(next);
70
+ });
71
+ } catch {
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+ function writeChannel(dst, src) {
77
+ if (src === void 0) {
78
+ dst.fill(0);
79
+ return;
80
+ }
81
+ if (src.length === dst.length) {
82
+ dst.set(src);
83
+ return;
84
+ }
85
+ const count = Math.min(src.length, dst.length);
86
+ for (let frame = 0; frame < count; frame += 1) dst[frame] = src[frame];
87
+ }
88
+ registerAudioTapWorklet();
89
+ export {
90
+ AUDIO_TAP_PROCESSOR,
91
+ audioTapWorkletUrl,
92
+ copyTap,
93
+ loadAudioTap,
94
+ registerAudioTapWorklet,
95
+ tapSamples
96
+ };
97
+ //# sourceMappingURL=tap.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/worklet/tap.ts"],
4
+ "sourcesContent": ["import { isWorkletRelease, WORKLET_RELEASE } from './release.ts';\n\nexport const AUDIO_TAP_PROCESSOR = 'pi-audio-tap';\n\ninterface TapPort {\n postMessage(message: unknown): void;\n onmessage: ((event: { data?: unknown }) => void) | null;\n}\n\ninterface WorkletScope {\n AudioWorkletProcessor?: new () => { port?: TapPort };\n registerProcessor?: (name: string, processor: new () => object) => void;\n}\n\ninterface TapInputs {\n inputs: Array<ArrayLike<Float32Array | undefined> | undefined>;\n outputs: Array<ArrayLike<Float32Array | undefined> | undefined>;\n}\n\nconst scope = globalThis as WorkletScope;\nconst registered = new WeakSet<object>();\n\nexport function copyTap(inputs: TapInputs['inputs'], outputs: TapInputs['outputs']): void {\n const input = inputs[0] ?? [];\n const output = outputs[0] ?? [];\n for (let channel = 0; channel < output.length; channel += 1) {\n const dst = output[channel];\n if (dst === undefined) continue;\n writeChannel(dst, input[channel] ?? input[0]);\n }\n}\n\nexport function tapSamples(inputs: TapInputs['inputs']): Float32Array | undefined {\n const heard = inputs[0]?.[0];\n if (heard === undefined || heard.length === 0) return undefined;\n const copy = new Float32Array(heard.length);\n copy.set(heard);\n return copy;\n}\n\nexport function registerAudioTapWorklet(target: WorkletScope = scope): void {\n if (typeof target.registerProcessor !== 'function') return;\n if (registered.has(target)) return;\n registered.add(target);\n const Base =\n target.AudioWorkletProcessor ??\n class {\n port?: TapPort;\n };\n const processor = class AudioTapProcessor extends Base {\n released = false;\n #posted = false;\n\n constructor() {\n super();\n const port = this.port;\n if (port) {\n port.onmessage = (event) => {\n if (isWorkletRelease(event.data)) this.released = true;\n };\n }\n }\n\n process(inputs: TapInputs['inputs'], outputs: TapInputs['outputs']): boolean {\n if (this.released) {\n this.#finish();\n return true;\n }\n copyTap(inputs, outputs);\n const samples = tapSamples(inputs);\n if (samples) this.port?.postMessage({ samples });\n return true;\n }\n\n #finish(): void {\n if (this.#posted) return;\n this.#posted = true;\n const port = this.port;\n if (!port) return;\n port.postMessage({ type: WORKLET_RELEASE });\n }\n };\n target.registerProcessor(AUDIO_TAP_PROCESSOR, processor);\n}\n\nexport function audioTapWorkletUrl(): string {\n return new URL(import.meta.url).href;\n}\n\nexport async function loadAudioTap(\n worklet: { addModule(module: unknown): Promise<void> },\n url = audioTapWorkletUrl()\n): Promise<void> {\n try {\n await worklet.addModule(url);\n } catch (error) {\n try {\n await worklet.addModule((next: WorkletScope) => {\n registerAudioTapWorklet(next);\n });\n } catch {\n throw error;\n }\n }\n}\n\nfunction writeChannel(dst: Float32Array, src: Float32Array | undefined): void {\n if (src === undefined) {\n dst.fill(0);\n return;\n }\n if (src.length === dst.length) {\n dst.set(src);\n return;\n }\n const count = Math.min(src.length, dst.length);\n for (let frame = 0; frame < count; frame += 1) dst[frame] = src[frame] as number;\n}\n\nregisterAudioTapWorklet();\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB,uBAAuB;AAE3C,MAAM,sBAAsB;AAiBnC,MAAM,QAAQ;AACd,MAAM,aAAa,oBAAI,QAAgB;AAEhC,SAAS,QAAQ,QAA6B,SAAqC;AACxF,QAAM,QAAQ,OAAO,CAAC,KAAK,CAAC;AAC5B,QAAM,SAAS,QAAQ,CAAC,KAAK,CAAC;AAC9B,WAAS,UAAU,GAAG,UAAU,OAAO,QAAQ,WAAW,GAAG;AAC3D,UAAM,MAAM,OAAO,OAAO;AAC1B,QAAI,QAAQ,OAAW;AACvB,iBAAa,KAAK,MAAM,OAAO,KAAK,MAAM,CAAC,CAAC;AAAA,EAC9C;AACF;AAEO,SAAS,WAAW,QAAuD;AAChF,QAAM,QAAQ,OAAO,CAAC,IAAI,CAAC;AAC3B,MAAI,UAAU,UAAa,MAAM,WAAW,EAAG,QAAO;AACtD,QAAM,OAAO,IAAI,aAAa,MAAM,MAAM;AAC1C,OAAK,IAAI,KAAK;AACd,SAAO;AACT;AAEO,SAAS,wBAAwB,SAAuB,OAAa;AAC1E,MAAI,OAAO,OAAO,sBAAsB,WAAY;AACpD,MAAI,WAAW,IAAI,MAAM,EAAG;AAC5B,aAAW,IAAI,MAAM;AACrB,QAAM,OACJ,OAAO,yBACP,MAAM;AAAA,IACJ;AAAA,EACF;AACF,QAAM,YAAY,MAAM,0BAA0B,KAAK;AAAA,IACrD,WAAW;AAAA,IACX,UAAU;AAAA,IAEV,cAAc;AACZ,YAAM;AACN,YAAM,OAAO,KAAK;AAClB,UAAI,MAAM;AACR,aAAK,YAAY,CAAC,UAAU;AAC1B,cAAI,iBAAiB,MAAM,IAAI,EAAG,MAAK,WAAW;AAAA,QACpD;AAAA,MACF;AAAA,IACF;AAAA,IAEA,QAAQ,QAA6B,SAAwC;AAC3E,UAAI,KAAK,UAAU;AACjB,aAAK,QAAQ;AACb,eAAO;AAAA,MACT;AACA,cAAQ,QAAQ,OAAO;AACvB,YAAM,UAAU,WAAW,MAAM;AACjC,UAAI,QAAS,MAAK,MAAM,YAAY,EAAE,QAAQ,CAAC;AAC/C,aAAO;AAAA,IACT;AAAA,IAEA,UAAgB;AACd,UAAI,KAAK,QAAS;AAClB,WAAK,UAAU;AACf,YAAM,OAAO,KAAK;AAClB,UAAI,CAAC,KAAM;AACX,WAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAAA,IAC5C;AAAA,EACF;AACA,SAAO,kBAAkB,qBAAqB,SAAS;AACzD;AAEO,SAAS,qBAA6B;AAC3C,SAAO,IAAI,IAAI,YAAY,GAAG,EAAE;AAClC;AAEA,eAAsB,aACpB,SACA,MAAM,mBAAmB,GACV;AACf,MAAI;AACF,UAAM,QAAQ,UAAU,GAAG;AAAA,EAC7B,SAAS,OAAO;AACd,QAAI;AACF,YAAM,QAAQ,UAAU,CAAC,SAAuB;AAC9C,gCAAwB,IAAI;AAAA,MAC9B,CAAC;AAAA,IACH,QAAQ;AACN,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,SAAS,aAAa,KAAmB,KAAqC;AAC5E,MAAI,QAAQ,QAAW;AACrB,QAAI,KAAK,CAAC;AACV;AAAA,EACF;AACA,MAAI,IAAI,WAAW,IAAI,QAAQ;AAC7B,QAAI,IAAI,GAAG;AACX;AAAA,EACF;AACA,QAAM,QAAQ,KAAK,IAAI,IAAI,QAAQ,IAAI,MAAM;AAC7C,WAAS,QAAQ,GAAG,QAAQ,OAAO,SAAS,EAAG,KAAI,KAAK,IAAI,IAAI,KAAK;AACvE;AAEA,wBAAwB;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/pi-audio",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Voice bindings for pi: speech-to-text, text-to-speech and realtime speech for pi-coding-agent plugins and slicc-agent",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -105,6 +105,8 @@
105
105
  "@biomejs/biome": "2.5.15",
106
106
  "@earendil-works/pi-ai": "1.1.0",
107
107
  "@earendil-works/pi-durable": "1.1.0",
108
+ "node-web-audio-api": "2.2.0",
109
+ "web-audio-api": "1.6.0",
108
110
  "@types/node": "24.19.1",
109
111
  "esbuild": "0.28.2",
110
112
  "lefthook": "2.2.1",
@@ -114,11 +116,19 @@
114
116
  },
115
117
  "peerDependencies": {
116
118
  "@earendil-works/pi-ai": "1.1.0",
117
- "@earendil-works/pi-durable": "^1.1.0"
119
+ "@earendil-works/pi-durable": "^1.1.0",
120
+ "node-web-audio-api": "^2.2.0",
121
+ "web-audio-api": "^1.6.0"
118
122
  },
119
123
  "peerDependenciesMeta": {
120
124
  "@earendil-works/pi-durable": {
121
125
  "optional": true
126
+ },
127
+ "node-web-audio-api": {
128
+ "optional": true
129
+ },
130
+ "web-audio-api": {
131
+ "optional": true
122
132
  }
123
133
  }
124
134
  }