@ai-ecoverse/pi-audio 1.4.0 → 1.5.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
@@ -21,6 +21,8 @@ addAudioCounters(total, usage);
21
21
 
22
22
  pi-audio does not apply that patch. Hosts such as slicc-agent record monkeypatches in `patches/patches.json`.
23
23
 
24
+ `@ai-ecoverse/pi-audio/openai` registers OpenAI and OpenRouter speech adapters when `registerOpenAI()` is called. Batch transcription posts multipart audio to `/audio/transcriptions`. OpenRouter (`provider` `openrouter`, or a base URL on `openrouter.ai`) posts JSON `input_audio` instead. Speech posts JSON to `/audio/speech` and reads the raw audio response. OpenAI can return pcm, mp3, wav, opus, or flac. OpenRouter returns pcm or mp3. Realtime transcription uses a WebSocket at `/realtime?intent=transcription` for `gpt-realtime-whisper` and `gpt-live-transcribe`. The catalog key is type, provider, and id, so the batch model keeps the id `gpt-4o-transcribe`.
25
+
24
26
  `convertAudio` and `convertAudioSource` convert between `AudioFormat`s: PCM16, float32, G.711 μ-law and A-law, mono and stereo, sample rate, and PCM16 WAV. Opus, MP3 and FLAC pass through only when the format does not change. `encodeAudioBase64` and `decodeAudioBase64` are the wire helpers for JSON protocols. `AudioChunk.data` stays binary. The same converter runs in an AudioWorklet from `@ai-ecoverse/pi-audio/worklet` (`AUDIO_CONVERT_PROCESSOR`, `registerAudioConvertWorklet`). Adapters declare `inputFormats` and `outputFormats` and do not resample on their own.
25
27
 
26
28
  ## Development
@@ -0,0 +1,126 @@
1
+ import {
2
+ registerVoiceModel
3
+ } from "../models.js";
4
+ import { registerSpeechApiProvider, registerTranscriptionApiProvider } from "../registry.js";
5
+ import { listenOpenAI } from "./listen.js";
6
+ import { speakOpenAI } from "./speak.js";
7
+ import { transcribeOpenAI } from "./transcribe.js";
8
+ const tokenCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
9
+ const openai = "https://api.openai.com/v1";
10
+ const openrouter = "https://openrouter.ai/api/v1";
11
+ const pcm24 = { encoding: "pcm16", sampleRate: 24e3, channels: 1 };
12
+ const fileFormats = [
13
+ { encoding: "wav", sampleRate: 16e3, channels: 1 },
14
+ { encoding: "mp3", sampleRate: 16e3, channels: 1 },
15
+ { encoding: "flac", sampleRate: 16e3, channels: 1 }
16
+ ];
17
+ const openAISpeechFormats = [
18
+ pcm24,
19
+ { encoding: "mp3", sampleRate: 24e3, channels: 1 },
20
+ { encoding: "wav", sampleRate: 24e3, channels: 1 },
21
+ { encoding: "opus", sampleRate: 24e3, channels: 1 },
22
+ { encoding: "flac", sampleRate: 24e3, channels: 1 }
23
+ ];
24
+ const openRouterSpeechFormats = [
25
+ pcm24,
26
+ { encoding: "mp3", sampleRate: 24e3, channels: 1 }
27
+ ];
28
+ const classicVoices = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer"];
29
+ const miniVoices = [...classicVoices, "ballad", "verse", "marin", "cedar"];
30
+ function voices(ids) {
31
+ return ids.map((id) => ({ id, name: id }));
32
+ }
33
+ function transcription(id, provider, baseUrl, perInputMinute, streaming, turnDetection, inputFormats) {
34
+ return {
35
+ id,
36
+ name: id,
37
+ api: streaming ? "openai-realtime-transcription" : "openai-transcriptions",
38
+ provider,
39
+ baseUrl,
40
+ type: "transcription",
41
+ streaming,
42
+ turnDetection,
43
+ inputFormats,
44
+ cost: tokenCost,
45
+ audioCost: { perInputMinute }
46
+ };
47
+ }
48
+ function speech(id, provider, baseUrl, outputFormats, voiceIds, audioCost) {
49
+ return {
50
+ id,
51
+ name: id,
52
+ api: "openai-speech",
53
+ provider,
54
+ baseUrl,
55
+ type: "speech",
56
+ textStreaming: false,
57
+ outputFormats,
58
+ voices: voices(voiceIds),
59
+ cloning: false,
60
+ cost: tokenCost,
61
+ audioCost
62
+ };
63
+ }
64
+ function openAICatalog() {
65
+ return [
66
+ transcription("whisper-1", "openai", openai, 6e-3, false, "none", fileFormats),
67
+ transcription("gpt-4o-transcribe", "openai", openai, 6e-3, false, "none", fileFormats),
68
+ transcription("gpt-4o-mini-transcribe", "openai", openai, 3e-3, false, "none", fileFormats),
69
+ transcription("gpt-realtime-whisper", "openai", openai, 0.017, true, "vad", [pcm24]),
70
+ transcription("gpt-live-transcribe", "openai", openai, 0.017, true, "none", [pcm24]),
71
+ transcription("openai/whisper-1", "openrouter", openrouter, 6e-3, false, "none", fileFormats),
72
+ transcription(
73
+ "openai/whisper-large-v3",
74
+ "openrouter",
75
+ openrouter,
76
+ 6e-3,
77
+ false,
78
+ "none",
79
+ fileFormats
80
+ ),
81
+ speech("tts-1", "openai", openai, openAISpeechFormats, classicVoices, {
82
+ perThousandChars: 0.015
83
+ }),
84
+ speech("tts-1-hd", "openai", openai, openAISpeechFormats, classicVoices, {
85
+ perThousandChars: 0.03
86
+ }),
87
+ speech("gpt-4o-mini-tts", "openai", openai, openAISpeechFormats, miniVoices, {
88
+ perThousandChars: 15e-5,
89
+ perOutputMinute: 0.015
90
+ }),
91
+ speech(
92
+ "openai/gpt-4o-mini-tts-2025-12-15",
93
+ "openrouter",
94
+ openrouter,
95
+ openRouterSpeechFormats,
96
+ miniVoices,
97
+ { perThousandChars: 15e-5, perOutputMinute: 0.015 }
98
+ )
99
+ ];
100
+ }
101
+ function registerOpenAI() {
102
+ const models = openAICatalog();
103
+ const unregisterModels = models.map((model) => registerVoiceModel(model));
104
+ const unregisterBatch = registerTranscriptionApiProvider({
105
+ api: "openai-transcriptions",
106
+ transcribe: transcribeOpenAI
107
+ });
108
+ const unregisterRealtime = registerTranscriptionApiProvider({
109
+ api: "openai-realtime-transcription",
110
+ listen: listenOpenAI
111
+ });
112
+ const unregisterSpeech = registerSpeechApiProvider({ api: "openai-speech", speak: speakOpenAI });
113
+ let released = false;
114
+ return () => {
115
+ if (released) return;
116
+ released = true;
117
+ for (const unregister of unregisterModels) unregister();
118
+ unregisterBatch();
119
+ unregisterRealtime();
120
+ unregisterSpeech();
121
+ };
122
+ }
123
+ export {
124
+ registerOpenAI
125
+ };
126
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/catalog.ts"],
4
+ "sourcesContent": ["import type { AudioFormat } from '../audio.ts';\nimport {\n registerVoiceModel,\n type SpeechModel,\n type TranscriptionModel,\n type VoiceInfo,\n} from '../models.ts';\nimport { registerSpeechApiProvider, registerTranscriptionApiProvider } from '../registry.ts';\nimport { listenOpenAI } from './listen.ts';\nimport { speakOpenAI } from './speak.ts';\nimport { transcribeOpenAI } from './transcribe.ts';\n\nconst tokenCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };\nconst openai = 'https://api.openai.com/v1';\nconst openrouter = 'https://openrouter.ai/api/v1';\n\nconst pcm24: AudioFormat = { encoding: 'pcm16', sampleRate: 24000, channels: 1 };\nconst fileFormats: AudioFormat[] = [\n { encoding: 'wav', sampleRate: 16000, channels: 1 },\n { encoding: 'mp3', sampleRate: 16000, channels: 1 },\n { encoding: 'flac', sampleRate: 16000, channels: 1 },\n];\nconst openAISpeechFormats: AudioFormat[] = [\n pcm24,\n { encoding: 'mp3', sampleRate: 24000, channels: 1 },\n { encoding: 'wav', sampleRate: 24000, channels: 1 },\n { encoding: 'opus', sampleRate: 24000, channels: 1 },\n { encoding: 'flac', sampleRate: 24000, channels: 1 },\n];\nconst openRouterSpeechFormats: AudioFormat[] = [\n pcm24,\n { encoding: 'mp3', sampleRate: 24000, channels: 1 },\n];\n\nconst classicVoices = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];\nconst miniVoices = [...classicVoices, 'ballad', 'verse', 'marin', 'cedar'];\n\nfunction voices(ids: string[]): VoiceInfo[] {\n return ids.map((id) => ({ id, name: id }));\n}\n\nfunction transcription(\n id: string,\n provider: string,\n baseUrl: string,\n perInputMinute: number,\n streaming: boolean,\n turnDetection: TranscriptionModel<string>['turnDetection'],\n inputFormats: AudioFormat[]\n): TranscriptionModel<string> {\n return {\n id,\n name: id,\n api: streaming ? 'openai-realtime-transcription' : 'openai-transcriptions',\n provider,\n baseUrl,\n type: 'transcription',\n streaming,\n turnDetection,\n inputFormats,\n cost: tokenCost,\n audioCost: { perInputMinute },\n };\n}\n\nfunction speech(\n id: string,\n provider: string,\n baseUrl: string,\n outputFormats: AudioFormat[],\n voiceIds: string[],\n audioCost: SpeechModel<string>['audioCost']\n): SpeechModel<string> {\n return {\n id,\n name: id,\n api: 'openai-speech',\n provider,\n baseUrl,\n type: 'speech',\n textStreaming: false,\n outputFormats,\n voices: voices(voiceIds),\n cloning: false,\n cost: tokenCost,\n audioCost,\n };\n}\n\nfunction openAICatalog(): Array<TranscriptionModel<string> | SpeechModel<string>> {\n return [\n transcription('whisper-1', 'openai', openai, 0.006, false, 'none', fileFormats),\n transcription('gpt-4o-transcribe', 'openai', openai, 0.006, false, 'none', fileFormats),\n transcription('gpt-4o-mini-transcribe', 'openai', openai, 0.003, false, 'none', fileFormats),\n transcription('gpt-realtime-whisper', 'openai', openai, 0.017, true, 'vad', [pcm24]),\n transcription('gpt-live-transcribe', 'openai', openai, 0.017, true, 'none', [pcm24]),\n transcription('openai/whisper-1', 'openrouter', openrouter, 0.006, false, 'none', fileFormats),\n transcription(\n 'openai/whisper-large-v3',\n 'openrouter',\n openrouter,\n 0.006,\n false,\n 'none',\n fileFormats\n ),\n speech('tts-1', 'openai', openai, openAISpeechFormats, classicVoices, {\n perThousandChars: 0.015,\n }),\n speech('tts-1-hd', 'openai', openai, openAISpeechFormats, classicVoices, {\n perThousandChars: 0.03,\n }),\n speech('gpt-4o-mini-tts', 'openai', openai, openAISpeechFormats, miniVoices, {\n perThousandChars: 0.00015,\n perOutputMinute: 0.015,\n }),\n speech(\n 'openai/gpt-4o-mini-tts-2025-12-15',\n 'openrouter',\n openrouter,\n openRouterSpeechFormats,\n miniVoices,\n { perThousandChars: 0.00015, perOutputMinute: 0.015 }\n ),\n ];\n}\n\nexport function registerOpenAI(): () => void {\n const models = openAICatalog();\n const unregisterModels = models.map((model) => registerVoiceModel(model));\n const unregisterBatch = registerTranscriptionApiProvider({\n api: 'openai-transcriptions',\n transcribe: transcribeOpenAI,\n });\n const unregisterRealtime = registerTranscriptionApiProvider({\n api: 'openai-realtime-transcription',\n listen: listenOpenAI,\n });\n const unregisterSpeech = registerSpeechApiProvider({ api: 'openai-speech', speak: speakOpenAI });\n let released = false;\n return () => {\n if (released) return;\n released = true;\n for (const unregister of unregisterModels) unregister();\n unregisterBatch();\n unregisterRealtime();\n unregisterSpeech();\n };\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,5 @@
1
+ import { registerOpenAI } from "./catalog.js";
2
+ export {
3
+ registerOpenAI
4
+ };
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/index.ts"],
4
+ "sourcesContent": ["export { registerOpenAI } from './catalog.ts';\n"],
5
+ "mappings": "AAAA,SAAS,sBAAsB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,200 @@
1
+ import { encodeAudioBase64 } from "../convert.js";
2
+ import {
3
+ TranscriptionEventStream
4
+ } from "../transcription.js";
5
+ import { priceAudio } from "../usage.js";
6
+ import {
7
+ asVoiceError,
8
+ isRecord,
9
+ numberField,
10
+ realtimeUrl,
11
+ requestHeaders,
12
+ stringField
13
+ } from "./wire.js";
14
+ function turnDetection(model) {
15
+ if (model.turnDetection === "vad") return { type: "server_vad" };
16
+ if (model.turnDetection === "semantic") return { type: "semantic_vad" };
17
+ return null;
18
+ }
19
+ function sessionBody(model, options, format) {
20
+ const transcription = { model: model.id };
21
+ if (options?.language) {
22
+ transcription.language = options.language;
23
+ transcription.languages = [options.language];
24
+ }
25
+ if (options?.vocabulary && options.vocabulary.length > 0)
26
+ transcription.keywords = options.vocabulary;
27
+ return {
28
+ type: "session.update",
29
+ session: {
30
+ type: "transcription",
31
+ audio: {
32
+ input: {
33
+ format: { type: "audio/pcm", rate: format.sampleRate },
34
+ transcription,
35
+ turn_detection: turnDetection(model)
36
+ }
37
+ }
38
+ }
39
+ };
40
+ }
41
+ function matches(chunk, format) {
42
+ return chunk.format.encoding === format.encoding && chunk.format.sampleRate === format.sampleRate && chunk.format.channels === format.channels;
43
+ }
44
+ async function messageText(data) {
45
+ if (typeof data === "string") return data;
46
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
47
+ if (data instanceof Uint8Array) return new TextDecoder().decode(data);
48
+ if (typeof Blob !== "undefined" && data instanceof Blob) return data.text();
49
+ throw new Error("Unsupported realtime message");
50
+ }
51
+ function applyEvent(event, state, push) {
52
+ const type = stringField(event, "type");
53
+ if (type === "input_audio_buffer.speech_started") {
54
+ const at = numberField(event, "audio_start_ms");
55
+ push({ type: "speech_start", at: at === void 0 ? 0 : at });
56
+ return;
57
+ }
58
+ if (type === "conversation.item.input_audio_transcription.delta") {
59
+ const item = stringField(event, "item_id");
60
+ state.segmentId = item === void 0 ? "0" : item;
61
+ const delta = stringField(event, "delta");
62
+ state.partial += delta === void 0 ? "" : delta;
63
+ push({ type: "partial", segmentId: state.segmentId, text: state.partial });
64
+ return;
65
+ }
66
+ if (type === "conversation.item.input_audio_transcription.completed") {
67
+ const transcript = stringField(event, "transcript");
68
+ const text = transcript === void 0 ? state.partial : transcript;
69
+ state.turns.push(text);
70
+ state.partial = "";
71
+ push({ type: "final", segmentId: state.segmentId, text });
72
+ push({ type: "turn_end", text, at: 0 });
73
+ return;
74
+ }
75
+ if (type === "error") {
76
+ const detail = event.error;
77
+ const message = isRecord(detail) ? stringField(detail, "message") : void 0;
78
+ state.failed = true;
79
+ push({
80
+ type: "error",
81
+ reason: "error",
82
+ error: message === void 0 ? "Realtime transcription failed" : message
83
+ });
84
+ }
85
+ }
86
+ function listenOpenAI(model, audio, options) {
87
+ const stream = new TranscriptionEventStream();
88
+ void pumpListen(model, audio, options, stream);
89
+ return stream;
90
+ }
91
+ function openSocket(socket, fail) {
92
+ return new Promise((resolve) => {
93
+ let opened = false;
94
+ socket.addEventListener("open", () => {
95
+ opened = true;
96
+ resolve();
97
+ });
98
+ socket.addEventListener("error", () => {
99
+ fail("OpenAI realtime transcription socket failed");
100
+ if (!opened) resolve();
101
+ });
102
+ socket.addEventListener("close", () => {
103
+ if (!opened) {
104
+ fail("OpenAI realtime transcription socket closed");
105
+ resolve();
106
+ }
107
+ });
108
+ });
109
+ }
110
+ async function pumpListen(model, audio, options, stream) {
111
+ const state = {
112
+ turns: [],
113
+ partial: "",
114
+ segmentId: "0",
115
+ bytes: 0,
116
+ audioDone: false,
117
+ failed: false
118
+ };
119
+ let finished = false;
120
+ const push = (event) => {
121
+ if (finished) return;
122
+ if (event.type === "done" || event.type === "error") finished = true;
123
+ stream.push(event);
124
+ };
125
+ let socket;
126
+ let wake;
127
+ const wait = new Promise((resolve) => {
128
+ wake = resolve;
129
+ });
130
+ try {
131
+ if (options?.signal?.aborted) throw new DOMException("aborted", "AbortError");
132
+ const format = model.inputFormats[0];
133
+ if (!format) throw new Error("No input format");
134
+ requestHeaders(model, options);
135
+ const apiKey = options?.apiKey;
136
+ if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
137
+ socket = new WebSocket(realtimeUrl(model.baseUrl), [
138
+ "realtime",
139
+ `openai-insecure-api-key.${apiKey}`
140
+ ]);
141
+ let incoming = Promise.resolve();
142
+ socket.addEventListener("message", (event) => {
143
+ incoming = incoming.then(() => onMessage(event.data, state, push, wake));
144
+ });
145
+ socket.addEventListener("close", () => wake?.());
146
+ await openSocket(socket, (message) => {
147
+ push({ type: "error", reason: "error", error: message });
148
+ wake?.();
149
+ });
150
+ if (finished) return;
151
+ socket.send(JSON.stringify(sessionBody(model, options, format)));
152
+ for await (const chunk of audio) {
153
+ if (finished) return;
154
+ if (options?.signal?.aborted) throw new DOMException("aborted", "AbortError");
155
+ if (!matches(chunk, format))
156
+ throw new Error(`Unsupported audio format ${chunk.format.encoding}`);
157
+ state.bytes += chunk.data.length;
158
+ socket.send(
159
+ JSON.stringify({ type: "input_audio_buffer.append", audio: encodeAudioBase64(chunk.data) })
160
+ );
161
+ }
162
+ state.audioDone = true;
163
+ if (model.turnDetection === "none") {
164
+ socket.send(JSON.stringify({ type: "input_audio_buffer.commit" }));
165
+ }
166
+ if (!finished && (model.turnDetection === "none" || state.turns.length === 0)) await wait;
167
+ if (finished) return;
168
+ const width = format.sampleRate * format.channels * 2;
169
+ const inputSeconds = width > 0 ? state.bytes / width : 0;
170
+ push({
171
+ type: "done",
172
+ transcript: {
173
+ text: state.turns.join(" "),
174
+ usage: priceAudio(model.audioCost, { inputSeconds })
175
+ }
176
+ });
177
+ } catch (error) {
178
+ const voice = asVoiceError(error);
179
+ push({ type: "error", reason: voice.reason, error: voice.error });
180
+ } finally {
181
+ socket?.close();
182
+ }
183
+ }
184
+ async function onMessage(data, state, push, wake) {
185
+ try {
186
+ const parsed = JSON.parse(await messageText(data));
187
+ if (!isRecord(parsed)) throw new Error("Realtime transcription event was not JSON");
188
+ applyEvent(parsed, state, push);
189
+ if (state.failed || state.audioDone && state.turns.length > 0) wake?.();
190
+ } catch (error) {
191
+ const voice = asVoiceError(error);
192
+ state.failed = true;
193
+ push({ type: "error", reason: voice.reason, error: voice.error });
194
+ wake?.();
195
+ }
196
+ }
197
+ export {
198
+ listenOpenAI
199
+ };
200
+ //# sourceMappingURL=listen.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/listen.ts"],
4
+ "sourcesContent": ["import type { AudioChunk, AudioFormat } from '../audio.ts';\nimport { encodeAudioBase64 } from '../convert.ts';\nimport type { TranscriptionModel } from '../models.ts';\nimport {\n type ListenOptions,\n type TranscriptionEvent,\n TranscriptionEventStream,\n} from '../transcription.ts';\nimport { priceAudio } from '../usage.ts';\nimport {\n asVoiceError,\n isRecord,\n numberField,\n realtimeUrl,\n requestHeaders,\n stringField,\n} from './wire.ts';\n\ninterface TurnState {\n turns: string[];\n partial: string;\n segmentId: string;\n bytes: number;\n audioDone: boolean;\n failed: boolean;\n}\n\nfunction turnDetection(model: TranscriptionModel<string>): Record<string, unknown> | null {\n if (model.turnDetection === 'vad') return { type: 'server_vad' };\n if (model.turnDetection === 'semantic') return { type: 'semantic_vad' };\n return null;\n}\n\nfunction sessionBody(\n model: TranscriptionModel<string>,\n options: ListenOptions | undefined,\n format: AudioFormat\n) {\n const transcription: Record<string, unknown> = { model: model.id };\n if (options?.language) {\n transcription.language = options.language;\n transcription.languages = [options.language];\n }\n if (options?.vocabulary && options.vocabulary.length > 0)\n transcription.keywords = options.vocabulary;\n return {\n type: 'session.update',\n session: {\n type: 'transcription',\n audio: {\n input: {\n format: { type: 'audio/pcm', rate: format.sampleRate },\n transcription,\n turn_detection: turnDetection(model),\n },\n },\n },\n };\n}\n\nfunction matches(chunk: AudioChunk, format: AudioFormat): boolean {\n return (\n chunk.format.encoding === format.encoding &&\n chunk.format.sampleRate === format.sampleRate &&\n chunk.format.channels === format.channels\n );\n}\n\nasync function messageText(data: unknown): Promise<string> {\n if (typeof data === 'string') return data;\n if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);\n if (data instanceof Uint8Array) return new TextDecoder().decode(data);\n if (typeof Blob !== 'undefined' && data instanceof Blob) return data.text();\n throw new Error('Unsupported realtime message');\n}\n\nfunction applyEvent(\n event: Record<string, unknown>,\n state: TurnState,\n push: (event: TranscriptionEvent) => void\n) {\n const type = stringField(event, 'type');\n if (type === 'input_audio_buffer.speech_started') {\n const at = numberField(event, 'audio_start_ms');\n push({ type: 'speech_start', at: at === undefined ? 0 : at });\n return;\n }\n if (type === 'conversation.item.input_audio_transcription.delta') {\n const item = stringField(event, 'item_id');\n state.segmentId = item === undefined ? '0' : item;\n const delta = stringField(event, 'delta');\n state.partial += delta === undefined ? '' : delta;\n push({ type: 'partial', segmentId: state.segmentId, text: state.partial });\n return;\n }\n if (type === 'conversation.item.input_audio_transcription.completed') {\n const transcript = stringField(event, 'transcript');\n const text = transcript === undefined ? state.partial : transcript;\n state.turns.push(text);\n state.partial = '';\n push({ type: 'final', segmentId: state.segmentId, text });\n push({ type: 'turn_end', text, at: 0 });\n return;\n }\n if (type === 'error') {\n const detail = event.error;\n const message = isRecord(detail) ? stringField(detail, 'message') : undefined;\n state.failed = true;\n push({\n type: 'error',\n reason: 'error',\n error: message === undefined ? 'Realtime transcription failed' : message,\n });\n }\n}\n\nexport function listenOpenAI(\n model: TranscriptionModel<string>,\n audio: AsyncIterable<AudioChunk>,\n options?: ListenOptions\n): TranscriptionEventStream {\n const stream = new TranscriptionEventStream();\n void pumpListen(model, audio, options, stream);\n return stream;\n}\n\nfunction openSocket(socket: WebSocket, fail: (message: string) => void): Promise<void> {\n return new Promise((resolve) => {\n let opened = false;\n socket.addEventListener('open', () => {\n opened = true;\n resolve();\n });\n socket.addEventListener('error', () => {\n fail('OpenAI realtime transcription socket failed');\n if (!opened) resolve();\n });\n socket.addEventListener('close', () => {\n if (!opened) {\n fail('OpenAI realtime transcription socket closed');\n resolve();\n }\n });\n });\n}\n\nasync function pumpListen(\n model: TranscriptionModel<string>,\n audio: AsyncIterable<AudioChunk>,\n options: ListenOptions | undefined,\n stream: TranscriptionEventStream\n) {\n const state: TurnState = {\n turns: [],\n partial: '',\n segmentId: '0',\n bytes: 0,\n audioDone: false,\n failed: false,\n };\n let finished = false;\n const push = (event: TranscriptionEvent) => {\n if (finished) return;\n if (event.type === 'done' || event.type === 'error') finished = true;\n stream.push(event);\n };\n let socket: WebSocket | undefined;\n let wake: (() => void) | undefined;\n const wait = new Promise<void>((resolve) => {\n wake = resolve;\n });\n try {\n if (options?.signal?.aborted) throw new DOMException('aborted', 'AbortError');\n const format = model.inputFormats[0];\n if (!format) throw new Error('No input format');\n requestHeaders(model, options);\n const apiKey = options?.apiKey;\n if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);\n socket = new WebSocket(realtimeUrl(model.baseUrl), [\n 'realtime',\n `openai-insecure-api-key.${apiKey}`,\n ]);\n let incoming = Promise.resolve();\n socket.addEventListener('message', (event) => {\n incoming = incoming.then(() => onMessage(event.data, state, push, wake));\n });\n socket.addEventListener('close', () => wake?.());\n await openSocket(socket, (message) => {\n push({ type: 'error', reason: 'error', error: message });\n wake?.();\n });\n if (finished) return;\n socket.send(JSON.stringify(sessionBody(model, options, format)));\n for await (const chunk of audio) {\n if (finished) return;\n if (options?.signal?.aborted) throw new DOMException('aborted', 'AbortError');\n if (!matches(chunk, format))\n throw new Error(`Unsupported audio format ${chunk.format.encoding}`);\n state.bytes += chunk.data.length;\n socket.send(\n JSON.stringify({ type: 'input_audio_buffer.append', audio: encodeAudioBase64(chunk.data) })\n );\n }\n state.audioDone = true;\n if (model.turnDetection === 'none') {\n socket.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));\n }\n if (!finished && (model.turnDetection === 'none' || state.turns.length === 0)) await wait;\n if (finished) return;\n const width = format.sampleRate * format.channels * 2;\n const inputSeconds = width > 0 ? state.bytes / width : 0;\n push({\n type: 'done',\n transcript: {\n text: state.turns.join(' '),\n usage: priceAudio(model.audioCost, { inputSeconds }),\n },\n });\n } catch (error) {\n const voice = asVoiceError(error);\n push({ type: 'error', reason: voice.reason, error: voice.error });\n } finally {\n socket?.close();\n }\n}\n\nasync function onMessage(\n data: unknown,\n state: TurnState,\n push: (event: TranscriptionEvent) => void,\n wake: (() => void) | undefined\n) {\n try {\n const parsed = JSON.parse(await messageText(data)) as unknown;\n if (!isRecord(parsed)) throw new Error('Realtime transcription event was not JSON');\n applyEvent(parsed, state, push);\n if (state.failed || (state.audioDone && state.turns.length > 0)) wake?.();\n } catch (error) {\n const voice = asVoiceError(error);\n state.failed = true;\n push({ type: 'error', reason: voice.reason, error: voice.error });\n wake?.();\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,91 @@
1
+ import { VoiceRequestError } from "../errors.js";
2
+ import { SpeechEventStream } from "../speech.js";
3
+ import { priceAudio } from "../usage.js";
4
+ import {
5
+ applyPayload,
6
+ asVoiceError,
7
+ endpoint,
8
+ isOpenRouter,
9
+ isRecord,
10
+ voiceFetch
11
+ } from "./wire.js";
12
+ function sameFormat(left, right) {
13
+ return left.encoding === right.encoding && left.sampleRate === right.sampleRate && left.channels === right.channels;
14
+ }
15
+ function resolveSpeechFormat(model, requested) {
16
+ const fallback = model.outputFormats[0];
17
+ if (!fallback) throw new Error("No output format");
18
+ if (!requested) return fallback;
19
+ const match = model.outputFormats.find((format) => sameFormat(format, requested));
20
+ if (!match) throw new Error(`Unsupported speech format ${requested.encoding}`);
21
+ return match;
22
+ }
23
+ function speechResponseFormat(encoding, openRouter) {
24
+ if (encoding === "pcm16") return "pcm";
25
+ if (encoding === "mp3") return "mp3";
26
+ if (openRouter) throw new Error(`OpenRouter speech cannot return ${encoding}`);
27
+ if (encoding === "wav" || encoding === "opus" || encoding === "flac") return encoding;
28
+ throw new Error(`OpenAI speech cannot return ${encoding}`);
29
+ }
30
+ function outputSeconds(format, byteLength) {
31
+ if (format.encoding !== "pcm16") return void 0;
32
+ const width = format.sampleRate * format.channels * 2;
33
+ return width > 0 ? byteLength / width : 0;
34
+ }
35
+ async function readText(text, signal) {
36
+ if (typeof text === "string") return text;
37
+ let combined = "";
38
+ for await (const part of text) {
39
+ if (signal?.aborted) throw new VoiceRequestError("aborted", "aborted");
40
+ combined += part;
41
+ }
42
+ return combined;
43
+ }
44
+ function speakOpenAI(model, input, options) {
45
+ const stream = new SpeechEventStream();
46
+ void pumpSpeech(model, input, options, stream);
47
+ return stream;
48
+ }
49
+ async function pumpSpeech(model, input, options, stream) {
50
+ try {
51
+ if (options?.signal?.aborted) throw new DOMException("aborted", "AbortError");
52
+ const format = resolveSpeechFormat(model, input.format);
53
+ const openRouter = isOpenRouter(model);
54
+ const responseFormat = speechResponseFormat(format.encoding, openRouter);
55
+ const text = await readText(input.text, options?.signal);
56
+ const payload = {
57
+ model: model.id,
58
+ input: text,
59
+ voice: input.voice,
60
+ response_format: responseFormat
61
+ };
62
+ if (input.instructions !== void 0) payload.instructions = input.instructions;
63
+ const body = await applyPayload(payload, model, options);
64
+ if (!isRecord(body)) throw new Error("Speech payload was not JSON");
65
+ const response = await voiceFetch(
66
+ model,
67
+ options,
68
+ endpoint(model.baseUrl, "audio/speech"),
69
+ JSON.stringify(body),
70
+ "application/json"
71
+ );
72
+ const data = new Uint8Array(await response.arrayBuffer());
73
+ const seconds = outputSeconds(format, data.length);
74
+ const meters = { characters: text.length };
75
+ if (seconds !== void 0) meters.outputSeconds = seconds;
76
+ stream.push({ type: "start", format });
77
+ stream.push({
78
+ type: "audio",
79
+ chunk: { data, format, timestamp: 0 },
80
+ span: { itemId: model.id, start: 0, end: text.length }
81
+ });
82
+ stream.push({ type: "done", usage: priceAudio(model.audioCost, meters) });
83
+ } catch (error) {
84
+ const voice = asVoiceError(error);
85
+ stream.push({ type: "error", reason: voice.reason, error: voice.error });
86
+ }
87
+ }
88
+ export {
89
+ speakOpenAI
90
+ };
91
+ //# sourceMappingURL=speak.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/speak.ts"],
4
+ "sourcesContent": ["import type { ProviderRequestOptions } from '@earendil-works/pi-ai';\nimport type { AudioFormat } from '../audio.ts';\nimport { VoiceRequestError } from '../errors.ts';\nimport type { SpeechModel } from '../models.ts';\nimport { SpeechEventStream, type SpeechInput } from '../speech.ts';\nimport { type AudioCounters, priceAudio } from '../usage.ts';\nimport {\n applyPayload,\n asVoiceError,\n endpoint,\n isOpenRouter,\n isRecord,\n voiceFetch,\n} from './wire.ts';\n\nfunction sameFormat(left: AudioFormat, right: AudioFormat): boolean {\n return (\n left.encoding === right.encoding &&\n left.sampleRate === right.sampleRate &&\n left.channels === right.channels\n );\n}\n\nfunction resolveSpeechFormat(\n model: SpeechModel<string>,\n requested: AudioFormat | undefined\n): AudioFormat {\n const fallback = model.outputFormats[0];\n if (!fallback) throw new Error('No output format');\n if (!requested) return fallback;\n const match = model.outputFormats.find((format) => sameFormat(format, requested));\n if (!match) throw new Error(`Unsupported speech format ${requested.encoding}`);\n return match;\n}\n\nfunction speechResponseFormat(encoding: AudioFormat['encoding'], openRouter: boolean): string {\n if (encoding === 'pcm16') return 'pcm';\n if (encoding === 'mp3') return 'mp3';\n if (openRouter) throw new Error(`OpenRouter speech cannot return ${encoding}`);\n if (encoding === 'wav' || encoding === 'opus' || encoding === 'flac') return encoding;\n throw new Error(`OpenAI speech cannot return ${encoding}`);\n}\n\nfunction outputSeconds(format: AudioFormat, byteLength: number): number | undefined {\n if (format.encoding !== 'pcm16') return undefined;\n const width = format.sampleRate * format.channels * 2;\n return width > 0 ? byteLength / width : 0;\n}\n\nasync function readText(\n text: SpeechInput['text'],\n signal: AbortSignal | undefined\n): Promise<string> {\n if (typeof text === 'string') return text;\n let combined = '';\n for await (const part of text) {\n if (signal?.aborted) throw new VoiceRequestError('aborted', 'aborted');\n combined += part;\n }\n return combined;\n}\n\nexport function speakOpenAI(\n model: SpeechModel<string>,\n input: SpeechInput,\n options?: ProviderRequestOptions\n): SpeechEventStream {\n const stream = new SpeechEventStream();\n void pumpSpeech(model, input, options, stream);\n return stream;\n}\n\nasync function pumpSpeech(\n model: SpeechModel<string>,\n input: SpeechInput,\n options: ProviderRequestOptions | undefined,\n stream: SpeechEventStream\n) {\n try {\n if (options?.signal?.aborted) throw new DOMException('aborted', 'AbortError');\n const format = resolveSpeechFormat(model, input.format);\n const openRouter = isOpenRouter(model);\n const responseFormat = speechResponseFormat(format.encoding, openRouter);\n const text = await readText(input.text, options?.signal);\n const payload: Record<string, unknown> = {\n model: model.id,\n input: text,\n voice: input.voice,\n response_format: responseFormat,\n };\n if (input.instructions !== undefined) payload.instructions = input.instructions;\n const body = await applyPayload(payload, model, options);\n if (!isRecord(body)) throw new Error('Speech payload was not JSON');\n const response = await voiceFetch(\n model,\n options,\n endpoint(model.baseUrl, 'audio/speech'),\n JSON.stringify(body),\n 'application/json'\n );\n const data = new Uint8Array(await response.arrayBuffer());\n const seconds = outputSeconds(format, data.length);\n const meters: AudioCounters = { characters: text.length };\n if (seconds !== undefined) meters.outputSeconds = seconds;\n stream.push({ type: 'start', format });\n stream.push({\n type: 'audio',\n chunk: { data, format, timestamp: 0 },\n span: { itemId: model.id, start: 0, end: text.length },\n });\n stream.push({ type: 'done', usage: priceAudio(model.audioCost, meters) });\n } catch (error) {\n const voice = asVoiceError(error);\n stream.push({ type: 'error', reason: voice.reason, error: voice.error });\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,112 @@
1
+ import { VoiceRequestError } from "../errors.js";
2
+ import { priceAudio } from "../usage.js";
3
+ import {
4
+ applyPayload,
5
+ asVoiceError,
6
+ audioFile,
7
+ audioPayload,
8
+ encodeMultipart,
9
+ endpoint,
10
+ isOpenRouter,
11
+ isRecord,
12
+ multipartType,
13
+ numberField,
14
+ readAudio,
15
+ readJson,
16
+ requestSignal,
17
+ stringField,
18
+ voiceFetch
19
+ } from "./wire.js";
20
+ function durationSeconds(record) {
21
+ const usage = record.usage;
22
+ if (isRecord(usage)) {
23
+ const seconds = numberField(usage, "seconds");
24
+ if (seconds !== void 0) return seconds;
25
+ }
26
+ const duration = numberField(record, "duration");
27
+ return duration === void 0 ? 0 : duration;
28
+ }
29
+ function fieldText(segment) {
30
+ const text = stringField(segment, "text");
31
+ return text === void 0 ? "" : text;
32
+ }
33
+ function fieldNumber(segment, key) {
34
+ const value = numberField(segment, key);
35
+ return value === void 0 ? 0 : value;
36
+ }
37
+ function segmentFrom(segment) {
38
+ const speaker = stringField(segment, "speaker");
39
+ const mapped = {
40
+ text: fieldText(segment),
41
+ start: fieldNumber(segment, "start"),
42
+ end: fieldNumber(segment, "end")
43
+ };
44
+ if (speaker !== void 0) mapped.speaker = speaker;
45
+ return mapped;
46
+ }
47
+ function transcriptFrom(body, model, seconds) {
48
+ if (!isRecord(body)) throw new Error("Transcription response had no text");
49
+ const text = stringField(body, "text");
50
+ if (text === void 0) throw new Error("Transcription response had no text");
51
+ const language = stringField(body, "language");
52
+ const segments = Array.isArray(body.segments) ? body.segments.filter(isRecord).map(segmentFrom) : void 0;
53
+ return {
54
+ text,
55
+ ...language !== void 0 ? { language } : {},
56
+ ...segments !== void 0 ? { segments } : {},
57
+ usage: priceAudio(model.audioCost, { inputSeconds: seconds })
58
+ };
59
+ }
60
+ function fileFrom(loaded) {
61
+ const named = audioFile(loaded.mimeType);
62
+ return { data: loaded.data, mimeType: loaded.mimeType, ...named };
63
+ }
64
+ async function transcribeOpenAI(model, input, options) {
65
+ try {
66
+ if (options?.signal?.aborted) throw new DOMException("aborted", "AbortError");
67
+ const fetchImpl = options?.fetch ?? globalThis.fetch;
68
+ const loaded = await readAudio(input, fetchImpl, requestSignal(options));
69
+ const file = fileFrom(loaded);
70
+ const payload = audioPayload(file, isOpenRouter(model), model.id);
71
+ const body = await applyPayload(payload, model, options);
72
+ if (!isRecord(body)) throw new Error("Transcription payload was not JSON");
73
+ const url = endpoint(model.baseUrl, "audio/transcriptions");
74
+ const response = await postTranscription(model, options, url, body);
75
+ const json = await readJson(response);
76
+ const seconds = isRecord(json) ? durationSeconds(json) : 0;
77
+ return transcriptFrom(json, model, seconds);
78
+ } catch (error) {
79
+ const voice = asVoiceError(error);
80
+ throw new VoiceRequestError(voice.reason, voice.error);
81
+ }
82
+ }
83
+ async function postTranscription(model, options, url, body) {
84
+ const inline = body.input_audio;
85
+ if (isRecord(inline)) {
86
+ return voiceFetch(model, options, url, JSON.stringify(body), "application/json");
87
+ }
88
+ const file = body.file;
89
+ if (!isRecord(file) || !(file.data instanceof Uint8Array) || typeof file.mimeType !== "string") {
90
+ throw new Error("Transcription payload was not JSON");
91
+ }
92
+ const named = audioFile(file.mimeType);
93
+ const fields = {};
94
+ const modelId = stringField(body, "model");
95
+ fields.model = modelId === void 0 ? model.id : modelId;
96
+ const encoded = encodeMultipart(
97
+ {
98
+ data: file.data,
99
+ mimeType: file.mimeType,
100
+ filename: typeof file.filename === "string" ? file.filename : named.filename,
101
+ format: named.format
102
+ },
103
+ fields
104
+ );
105
+ const copy = new ArrayBuffer(encoded.byteLength);
106
+ new Uint8Array(copy).set(encoded);
107
+ return voiceFetch(model, options, url, copy, multipartType());
108
+ }
109
+ export {
110
+ transcribeOpenAI
111
+ };
112
+ //# sourceMappingURL=transcribe.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/transcribe.ts"],
4
+ "sourcesContent": ["import type { ProviderRequestOptions } from '@earendil-works/pi-ai';\nimport { VoiceRequestError } from '../errors.ts';\nimport type { TranscriptionModel } from '../models.ts';\nimport type { AudioInput, Transcript, TranscriptSegment } from '../transcription.ts';\nimport { priceAudio } from '../usage.ts';\nimport {\n type AudioFile,\n applyPayload,\n asVoiceError,\n audioFile,\n audioPayload,\n encodeMultipart,\n endpoint,\n isOpenRouter,\n isRecord,\n multipartType,\n numberField,\n readAudio,\n readJson,\n requestSignal,\n stringField,\n voiceFetch,\n} from './wire.ts';\n\nfunction durationSeconds(record: Record<string, unknown>): number {\n const usage = record.usage;\n if (isRecord(usage)) {\n const seconds = numberField(usage, 'seconds');\n if (seconds !== undefined) return seconds;\n }\n const duration = numberField(record, 'duration');\n return duration === undefined ? 0 : duration;\n}\n\nfunction fieldText(segment: Record<string, unknown>): string {\n const text = stringField(segment, 'text');\n return text === undefined ? '' : text;\n}\n\nfunction fieldNumber(segment: Record<string, unknown>, key: string): number {\n const value = numberField(segment, key);\n return value === undefined ? 0 : value;\n}\n\nfunction segmentFrom(segment: Record<string, unknown>): TranscriptSegment {\n const speaker = stringField(segment, 'speaker');\n const mapped: TranscriptSegment = {\n text: fieldText(segment),\n start: fieldNumber(segment, 'start'),\n end: fieldNumber(segment, 'end'),\n };\n if (speaker !== undefined) mapped.speaker = speaker;\n return mapped;\n}\n\nfunction transcriptFrom(\n body: unknown,\n model: TranscriptionModel<string>,\n seconds: number\n): Transcript {\n if (!isRecord(body)) throw new Error('Transcription response had no text');\n const text = stringField(body, 'text');\n if (text === undefined) throw new Error('Transcription response had no text');\n const language = stringField(body, 'language');\n const segments = Array.isArray(body.segments)\n ? body.segments.filter(isRecord).map(segmentFrom)\n : undefined;\n return {\n text,\n ...(language !== undefined ? { language } : {}),\n ...(segments !== undefined ? { segments } : {}),\n usage: priceAudio(model.audioCost, { inputSeconds: seconds }),\n };\n}\n\nfunction fileFrom(loaded: { data: Uint8Array; mimeType: string }): AudioFile {\n const named = audioFile(loaded.mimeType);\n return { data: loaded.data, mimeType: loaded.mimeType, ...named };\n}\n\nexport async function transcribeOpenAI(\n model: TranscriptionModel<string>,\n input: AudioInput,\n options?: ProviderRequestOptions\n): Promise<Transcript> {\n try {\n if (options?.signal?.aborted) throw new DOMException('aborted', 'AbortError');\n const fetchImpl = options?.fetch ?? globalThis.fetch;\n const loaded = await readAudio(input, fetchImpl, requestSignal(options));\n const file = fileFrom(loaded);\n const payload = audioPayload(file, isOpenRouter(model), model.id);\n const body = await applyPayload(payload, model, options);\n if (!isRecord(body)) throw new Error('Transcription payload was not JSON');\n const url = endpoint(model.baseUrl, 'audio/transcriptions');\n const response = await postTranscription(model, options, url, body);\n const json = await readJson(response);\n const seconds = isRecord(json) ? durationSeconds(json) : 0;\n return transcriptFrom(json, model, seconds);\n } catch (error) {\n const voice = asVoiceError(error);\n throw new VoiceRequestError(voice.reason, voice.error);\n }\n}\n\nasync function postTranscription(\n model: TranscriptionModel<string>,\n options: ProviderRequestOptions | undefined,\n url: string,\n body: Record<string, unknown>\n): Promise<Response> {\n const inline = body.input_audio;\n if (isRecord(inline)) {\n return voiceFetch(model, options, url, JSON.stringify(body), 'application/json');\n }\n const file = body.file;\n if (!isRecord(file) || !(file.data instanceof Uint8Array) || typeof file.mimeType !== 'string') {\n throw new Error('Transcription payload was not JSON');\n }\n const named = audioFile(file.mimeType);\n const fields: Record<string, string> = {};\n const modelId = stringField(body, 'model');\n fields.model = modelId === undefined ? model.id : modelId;\n const encoded = encodeMultipart(\n {\n data: file.data,\n mimeType: file.mimeType,\n filename: typeof file.filename === 'string' ? file.filename : named.filename,\n format: named.format,\n },\n fields\n );\n const copy = new ArrayBuffer(encoded.byteLength);\n new Uint8Array(copy).set(encoded);\n return voiceFetch(model, options, url, copy, multipartType());\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,201 @@
1
+ import { encodeAudioBase64 } from "../convert.js";
2
+ import { VoiceRequestError } from "../errors.js";
3
+ const boundary = "pi-audio-boundary";
4
+ const mimeFiles = {
5
+ "audio/wav": { format: "wav", filename: "audio.wav" },
6
+ "audio/wave": { format: "wav", filename: "audio.wav" },
7
+ "audio/x-wav": { format: "wav", filename: "audio.wav" },
8
+ "audio/mpeg": { format: "mp3", filename: "audio.mp3" },
9
+ "audio/mp3": { format: "mp3", filename: "audio.mp3" },
10
+ "audio/flac": { format: "flac", filename: "audio.flac" },
11
+ "audio/ogg": { format: "ogg", filename: "audio.ogg" },
12
+ "audio/opus": { format: "ogg", filename: "audio.ogg" },
13
+ "audio/webm": { format: "webm", filename: "audio.webm" },
14
+ "audio/mp4": { format: "m4a", filename: "audio.m4a" },
15
+ "audio/m4a": { format: "m4a", filename: "audio.m4a" },
16
+ "audio/aac": { format: "aac", filename: "audio.aac" },
17
+ "audio/pcm": { format: "pcm", filename: "audio.pcm" }
18
+ };
19
+ function isOpenRouter(model) {
20
+ return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai");
21
+ }
22
+ function endpoint(baseUrl, path) {
23
+ return `${baseUrl.replace(/\/$/, "")}/${path}`;
24
+ }
25
+ function realtimeUrl(baseUrl) {
26
+ const url = new URL(baseUrl);
27
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
28
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/realtime`;
29
+ url.search = "intent=transcription";
30
+ return url.toString();
31
+ }
32
+ function audioFile(mimeType) {
33
+ const found = mimeFiles[mimeType.toLowerCase()];
34
+ if (!found) throw new Error(`Unsupported audio type ${mimeType}`);
35
+ return found;
36
+ }
37
+ function isRecord(value) {
38
+ return typeof value === "object" && value !== null;
39
+ }
40
+ function stringField(record, key) {
41
+ const value = record[key];
42
+ return typeof value === "string" ? value : void 0;
43
+ }
44
+ function numberField(record, key) {
45
+ const value = record[key];
46
+ return typeof value === "number" ? value : void 0;
47
+ }
48
+ function asVoiceError(error) {
49
+ if (error instanceof VoiceRequestError) return { reason: error.reason, error: error.message };
50
+ if (error instanceof Error && error.name === "AbortError")
51
+ return { reason: "aborted", error: "aborted" };
52
+ if (error instanceof Error) return { reason: "error", error: error.message };
53
+ return { reason: "error", error: String(error) };
54
+ }
55
+ function headerValue(headers, name) {
56
+ if (!headers) return void 0;
57
+ const expected = name.toLowerCase();
58
+ for (const [key, value] of Object.entries(headers)) {
59
+ if (key.toLowerCase() === expected && value !== null && value.trim() !== "") return value;
60
+ }
61
+ return void 0;
62
+ }
63
+ function hasAuth(headers) {
64
+ return headerValue(headers, "authorization") !== void 0 || headerValue(headers, "cf-aig-authorization") !== void 0;
65
+ }
66
+ function requestHeaders(model, options) {
67
+ const headers = { ...model.headers };
68
+ const extra = options?.headers;
69
+ if (extra) {
70
+ for (const [key, value] of Object.entries(extra)) {
71
+ if (value === null) delete headers[key];
72
+ else headers[key] = value;
73
+ }
74
+ }
75
+ if (!hasAuth(headers)) {
76
+ const apiKey = options?.apiKey;
77
+ if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
78
+ headers.Authorization = `Bearer ${apiKey}`;
79
+ }
80
+ return headers;
81
+ }
82
+ function requestSignal(options) {
83
+ const signals = [];
84
+ if (options?.signal) signals.push(options.signal);
85
+ const timeout = options?.timeoutMs;
86
+ if (timeout !== void 0 && timeout > 0) signals.push(AbortSignal.timeout(timeout));
87
+ if (signals.length === 0) return void 0;
88
+ if (signals.length === 1) return signals[0];
89
+ return AbortSignal.any(signals);
90
+ }
91
+ function encodeMultipart(file, fields) {
92
+ const chunks = [];
93
+ const encoder = new TextEncoder();
94
+ const write = (value) => chunks.push(encoder.encode(value));
95
+ write(
96
+ `--${boundary}\r
97
+ Content-Disposition: form-data; name="file"; filename="${file.filename}"\r
98
+ Content-Type: ${file.mimeType}\r
99
+ \r
100
+ `
101
+ );
102
+ chunks.push(file.data);
103
+ write("\r\n");
104
+ for (const [name, value] of Object.entries(fields)) {
105
+ write(`--${boundary}\r
106
+ Content-Disposition: form-data; name="${name}"\r
107
+ \r
108
+ ${value}\r
109
+ `);
110
+ }
111
+ write(`--${boundary}--\r
112
+ `);
113
+ const body = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
114
+ let offset = 0;
115
+ for (const chunk of chunks) {
116
+ body.set(chunk, offset);
117
+ offset += chunk.length;
118
+ }
119
+ return body;
120
+ }
121
+ function multipartType() {
122
+ return `multipart/form-data; boundary=${boundary}`;
123
+ }
124
+ async function readAudio(input, fetchImpl, signal) {
125
+ if (!("url" in input)) return { data: input.data, mimeType: input.mimeType };
126
+ const response = await fetchImpl(input.url, { signal });
127
+ if (!response.ok) throw new Error(`Audio URL failed: HTTP ${response.status}`);
128
+ const mime = response.headers.get("content-type");
129
+ const mimeType = mime === null || mime.trim() === "" ? "audio/wav" : mime.split(";")[0].trim();
130
+ return { data: new Uint8Array(await response.arrayBuffer()), mimeType };
131
+ }
132
+ function audioPayload(file, openRouter, modelId) {
133
+ if (openRouter) {
134
+ return {
135
+ model: modelId,
136
+ input_audio: { data: encodeAudioBase64(file.data), format: file.format }
137
+ };
138
+ }
139
+ return { model: modelId, file };
140
+ }
141
+ async function applyPayload(payload, model, options) {
142
+ const hook = options?.onPayload;
143
+ const replaced = await hook?.(payload, model);
144
+ return replaced === void 0 ? payload : replaced;
145
+ }
146
+ function headerRecord(headers) {
147
+ const record = {};
148
+ headers.forEach((value, key) => {
149
+ record[key] = value;
150
+ });
151
+ return record;
152
+ }
153
+ async function voiceFetch(model, options, url, body, contentType) {
154
+ const headers = requestHeaders(model, options);
155
+ headers["Content-Type"] = contentType;
156
+ const fetchImpl = options?.fetch ?? globalThis.fetch;
157
+ const response = await fetchImpl(url, {
158
+ method: "POST",
159
+ headers,
160
+ body,
161
+ signal: requestSignal(options)
162
+ });
163
+ await options?.onResponse?.(
164
+ { status: response.status, headers: headerRecord(response.headers) },
165
+ model
166
+ );
167
+ if (!response.ok) {
168
+ const text = await response.text();
169
+ throw new Error(text === "" ? `HTTP ${response.status}` : text);
170
+ }
171
+ return response;
172
+ }
173
+ async function readJson(response) {
174
+ const text = await response.text();
175
+ try {
176
+ return JSON.parse(text);
177
+ } catch {
178
+ throw new Error("Transcription response was not JSON");
179
+ }
180
+ }
181
+ export {
182
+ applyPayload,
183
+ asVoiceError,
184
+ audioFile,
185
+ audioPayload,
186
+ encodeMultipart,
187
+ endpoint,
188
+ hasAuth,
189
+ isOpenRouter,
190
+ isRecord,
191
+ multipartType,
192
+ numberField,
193
+ readAudio,
194
+ readJson,
195
+ realtimeUrl,
196
+ requestHeaders,
197
+ requestSignal,
198
+ stringField,
199
+ voiceFetch
200
+ };
201
+ //# sourceMappingURL=wire.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/openai/wire.ts"],
4
+ "sourcesContent": ["import type { ProviderRequestOptions } from '@earendil-works/pi-ai';\nimport { encodeAudioBase64 } from '../convert.ts';\nimport { VoiceRequestError } from '../errors.ts';\n\nconst boundary = 'pi-audio-boundary';\n\nconst mimeFiles: Record<string, { format: string; filename: string }> = {\n 'audio/wav': { format: 'wav', filename: 'audio.wav' },\n 'audio/wave': { format: 'wav', filename: 'audio.wav' },\n 'audio/x-wav': { format: 'wav', filename: 'audio.wav' },\n 'audio/mpeg': { format: 'mp3', filename: 'audio.mp3' },\n 'audio/mp3': { format: 'mp3', filename: 'audio.mp3' },\n 'audio/flac': { format: 'flac', filename: 'audio.flac' },\n 'audio/ogg': { format: 'ogg', filename: 'audio.ogg' },\n 'audio/opus': { format: 'ogg', filename: 'audio.ogg' },\n 'audio/webm': { format: 'webm', filename: 'audio.webm' },\n 'audio/mp4': { format: 'm4a', filename: 'audio.m4a' },\n 'audio/m4a': { format: 'm4a', filename: 'audio.m4a' },\n 'audio/aac': { format: 'aac', filename: 'audio.aac' },\n 'audio/pcm': { format: 'pcm', filename: 'audio.pcm' },\n};\n\nexport interface AudioFile {\n data: Uint8Array;\n mimeType: string;\n filename: string;\n format: string;\n}\n\nexport interface JsonBody {\n [key: string]: unknown;\n}\n\ninterface HeaderModel {\n provider: string;\n headers?: Record<string, string>;\n}\n\nexport function isOpenRouter(model: { provider: string; baseUrl: string }): boolean {\n return model.provider === 'openrouter' || model.baseUrl.includes('openrouter.ai');\n}\n\nexport function endpoint(baseUrl: string, path: string): string {\n return `${baseUrl.replace(/\\/$/, '')}/${path}`;\n}\n\nexport function realtimeUrl(baseUrl: string): string {\n const url = new URL(baseUrl);\n url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';\n url.pathname = `${url.pathname.replace(/\\/$/, '')}/realtime`;\n url.search = 'intent=transcription';\n return url.toString();\n}\n\nexport function audioFile(mimeType: string): { format: string; filename: string } {\n const found = mimeFiles[mimeType.toLowerCase()];\n if (!found) throw new Error(`Unsupported audio type ${mimeType}`);\n return found;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nexport function stringField(record: Record<string, unknown>, key: string): string | undefined {\n const value = record[key];\n return typeof value === 'string' ? value : undefined;\n}\n\nexport function numberField(record: Record<string, unknown>, key: string): number | undefined {\n const value = record[key];\n return typeof value === 'number' ? value : undefined;\n}\n\nexport function asVoiceError(error: unknown): { reason: 'aborted' | 'error'; error: string } {\n if (error instanceof VoiceRequestError) return { reason: error.reason, error: error.message };\n if (error instanceof Error && error.name === 'AbortError')\n return { reason: 'aborted', error: 'aborted' };\n if (error instanceof Error) return { reason: 'error', error: error.message };\n return { reason: 'error', error: String(error) };\n}\n\nfunction headerValue(\n headers: Record<string, string | null> | undefined,\n name: string\n): string | undefined {\n if (!headers) return undefined;\n const expected = name.toLowerCase();\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === expected && value !== null && value.trim() !== '') return value;\n }\n return undefined;\n}\n\nexport function hasAuth(headers: Record<string, string | null> | undefined): boolean {\n return (\n headerValue(headers, 'authorization') !== undefined ||\n headerValue(headers, 'cf-aig-authorization') !== undefined\n );\n}\n\nexport function requestHeaders(\n model: HeaderModel,\n options: ProviderRequestOptions | undefined\n): Record<string, string> {\n const headers: Record<string, string> = { ...model.headers };\n const extra = options?.headers;\n if (extra) {\n for (const [key, value] of Object.entries(extra)) {\n if (value === null) delete headers[key];\n else headers[key] = value;\n }\n }\n if (!hasAuth(headers)) {\n const apiKey = options?.apiKey;\n if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);\n headers.Authorization = `Bearer ${apiKey}`;\n }\n return headers;\n}\n\nexport function requestSignal(\n options: ProviderRequestOptions | undefined\n): AbortSignal | undefined {\n const signals: AbortSignal[] = [];\n if (options?.signal) signals.push(options.signal);\n const timeout = options?.timeoutMs;\n if (timeout !== undefined && timeout > 0) signals.push(AbortSignal.timeout(timeout));\n if (signals.length === 0) return undefined;\n if (signals.length === 1) return signals[0];\n return AbortSignal.any(signals);\n}\n\nexport function encodeMultipart(file: AudioFile, fields: Record<string, string>): Uint8Array {\n const chunks: Uint8Array[] = [];\n const encoder = new TextEncoder();\n const write = (value: string) => chunks.push(encoder.encode(value));\n write(\n `--${boundary}\\r\\nContent-Disposition: form-data; name=\"file\"; filename=\"${file.filename}\"\\r\\nContent-Type: ${file.mimeType}\\r\\n\\r\\n`\n );\n chunks.push(file.data);\n write('\\r\\n');\n for (const [name, value] of Object.entries(fields)) {\n write(`--${boundary}\\r\\nContent-Disposition: form-data; name=\"${name}\"\\r\\n\\r\\n${value}\\r\\n`);\n }\n write(`--${boundary}--\\r\\n`);\n const body = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));\n let offset = 0;\n for (const chunk of chunks) {\n body.set(chunk, offset);\n offset += chunk.length;\n }\n return body;\n}\n\nexport function multipartType(): string {\n return `multipart/form-data; boundary=${boundary}`;\n}\n\nexport async function readAudio(\n input: { data: Uint8Array; mimeType: string } | { url: string },\n fetchImpl: typeof fetch,\n signal: AbortSignal | undefined\n): Promise<{ data: Uint8Array; mimeType: string }> {\n if (!('url' in input)) return { data: input.data, mimeType: input.mimeType };\n const response = await fetchImpl(input.url, { signal });\n if (!response.ok) throw new Error(`Audio URL failed: HTTP ${response.status}`);\n const mime = response.headers.get('content-type');\n const mimeType = mime === null || mime.trim() === '' ? 'audio/wav' : mime.split(';')[0].trim();\n return { data: new Uint8Array(await response.arrayBuffer()), mimeType };\n}\n\nexport function audioPayload(file: AudioFile, openRouter: boolean, modelId: string): JsonBody {\n if (openRouter) {\n return {\n model: modelId,\n input_audio: { data: encodeAudioBase64(file.data), format: file.format },\n };\n }\n return { model: modelId, file };\n}\n\nexport async function applyPayload(\n payload: unknown,\n model: unknown,\n options: ProviderRequestOptions | undefined\n): Promise<unknown> {\n const hook = options?.onPayload as\n | ((body: unknown, voiceModel: unknown) => unknown | undefined | Promise<unknown | undefined>)\n | undefined;\n const replaced = await hook?.(payload, model);\n return replaced === undefined ? payload : replaced;\n}\n\nfunction headerRecord(headers: Headers): Record<string, string> {\n const record: Record<string, string> = {};\n headers.forEach((value, key) => {\n record[key] = value;\n });\n return record;\n}\n\nexport async function voiceFetch(\n model: HeaderModel & { id: string },\n options: ProviderRequestOptions | undefined,\n url: string,\n body: BodyInit,\n contentType: string\n): Promise<Response> {\n const headers = requestHeaders(model, options);\n headers['Content-Type'] = contentType;\n const fetchImpl = options?.fetch ?? globalThis.fetch;\n const response = await fetchImpl(url, {\n method: 'POST',\n headers,\n body,\n signal: requestSignal(options),\n });\n await options?.onResponse?.(\n { status: response.status, headers: headerRecord(response.headers) },\n model as never\n );\n if (!response.ok) {\n const text = await response.text();\n throw new Error(text === '' ? `HTTP ${response.status}` : text);\n }\n return response;\n}\n\nexport async function readJson(response: Response): Promise<unknown> {\n const text = await response.text();\n try {\n return JSON.parse(text);\n } catch {\n throw new Error('Transcription response was not JSON');\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ export declare function registerOpenAI(): () => void;
@@ -0,0 +1 @@
1
+ export { registerOpenAI } from './catalog.ts';
@@ -0,0 +1,4 @@
1
+ import type { AudioChunk } from '../audio.ts';
2
+ import type { TranscriptionModel } from '../models.ts';
3
+ import { type ListenOptions, TranscriptionEventStream } from '../transcription.ts';
4
+ export declare function listenOpenAI(model: TranscriptionModel<string>, audio: AsyncIterable<AudioChunk>, options?: ListenOptions): TranscriptionEventStream;
@@ -0,0 +1,4 @@
1
+ import type { ProviderRequestOptions } from '@earendil-works/pi-ai';
2
+ import type { SpeechModel } from '../models.ts';
3
+ import { SpeechEventStream, type SpeechInput } from '../speech.ts';
4
+ export declare function speakOpenAI(model: SpeechModel<string>, input: SpeechInput, options?: ProviderRequestOptions): SpeechEventStream;
@@ -0,0 +1,4 @@
1
+ import type { ProviderRequestOptions } from '@earendil-works/pi-ai';
2
+ import type { TranscriptionModel } from '../models.ts';
3
+ import type { AudioInput, Transcript } from '../transcription.ts';
4
+ export declare function transcribeOpenAI(model: TranscriptionModel<string>, input: AudioInput, options?: ProviderRequestOptions): Promise<Transcript>;
@@ -0,0 +1,52 @@
1
+ import type { ProviderRequestOptions } from '@earendil-works/pi-ai';
2
+ export interface AudioFile {
3
+ data: Uint8Array;
4
+ mimeType: string;
5
+ filename: string;
6
+ format: string;
7
+ }
8
+ export interface JsonBody {
9
+ [key: string]: unknown;
10
+ }
11
+ interface HeaderModel {
12
+ provider: string;
13
+ headers?: Record<string, string>;
14
+ }
15
+ export declare function isOpenRouter(model: {
16
+ provider: string;
17
+ baseUrl: string;
18
+ }): boolean;
19
+ export declare function endpoint(baseUrl: string, path: string): string;
20
+ export declare function realtimeUrl(baseUrl: string): string;
21
+ export declare function audioFile(mimeType: string): {
22
+ format: string;
23
+ filename: string;
24
+ };
25
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
26
+ export declare function stringField(record: Record<string, unknown>, key: string): string | undefined;
27
+ export declare function numberField(record: Record<string, unknown>, key: string): number | undefined;
28
+ export declare function asVoiceError(error: unknown): {
29
+ reason: 'aborted' | 'error';
30
+ error: string;
31
+ };
32
+ export declare function hasAuth(headers: Record<string, string | null> | undefined): boolean;
33
+ export declare function requestHeaders(model: HeaderModel, options: ProviderRequestOptions | undefined): Record<string, string>;
34
+ export declare function requestSignal(options: ProviderRequestOptions | undefined): AbortSignal | undefined;
35
+ export declare function encodeMultipart(file: AudioFile, fields: Record<string, string>): Uint8Array;
36
+ export declare function multipartType(): string;
37
+ export declare function readAudio(input: {
38
+ data: Uint8Array;
39
+ mimeType: string;
40
+ } | {
41
+ url: string;
42
+ }, fetchImpl: typeof fetch, signal: AbortSignal | undefined): Promise<{
43
+ data: Uint8Array;
44
+ mimeType: string;
45
+ }>;
46
+ export declare function audioPayload(file: AudioFile, openRouter: boolean, modelId: string): JsonBody;
47
+ export declare function applyPayload(payload: unknown, model: unknown, options: ProviderRequestOptions | undefined): Promise<unknown>;
48
+ export declare function voiceFetch(model: HeaderModel & {
49
+ id: string;
50
+ }, options: ProviderRequestOptions | undefined, url: string, body: BodyInit, contentType: string): Promise<Response>;
51
+ export declare function readJson(response: Response): Promise<unknown>;
52
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/pi-audio",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -33,6 +33,10 @@
33
33
  "./worklet": {
34
34
  "types": "./dist/types/worklet/convert.d.ts",
35
35
  "default": "./dist/worklet/convert.js"
36
+ },
37
+ "./openai": {
38
+ "types": "./dist/types/openai/index.d.ts",
39
+ "default": "./dist/openai/index.js"
36
40
  }
37
41
  },
38
42
  "files": [