@voiceinput/openai 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/index.cjs +486 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +16 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +484 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +165 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +38 -0
- package/dist/server.d.cts.map +1 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +164 -0
- package/dist/server.js.map +1 -0
- package/dist/session-config-CmoDDiY6.cjs +114 -0
- package/dist/session-config-CmoDDiY6.cjs.map +1 -0
- package/dist/session-config-CuDb4m9Q.js +91 -0
- package/dist/session-config-CuDb4m9Q.js.map +1 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hirad Arshadiyarahmadi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# `@voiceinput/openai`
|
|
2
|
+
|
|
3
|
+
OpenAI Realtime transcription adapter for VoiceInput. The root export is
|
|
4
|
+
browser-safe; `@voiceinput/openai/server` mints ephemeral client credentials
|
|
5
|
+
with a long-lived API key.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @voiceinput/react@next @voiceinput/openai@next
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Browser adapter
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { openai } from "@voiceinput/openai";
|
|
17
|
+
|
|
18
|
+
const provider = openai({
|
|
19
|
+
tokenEndpoint: "/api/voice-token",
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pass `provider` to `VoiceInputProvider` or directly to `useVoiceInput`.
|
|
24
|
+
|
|
25
|
+
### Defaults and shared-option mapping
|
|
26
|
+
|
|
27
|
+
- Model: `gpt-transcribe` (`OPENAI_DEFAULT_MODEL`)
|
|
28
|
+
- Audio: mono PCM16 at 24 kHz
|
|
29
|
+
- Omitted language: provider automatic detection
|
|
30
|
+
- `language`: normalized to its ISO 639-1 primary language code
|
|
31
|
+
- `vocabulary`: `keywords` for `gpt-live-transcribe*` models; a transcription
|
|
32
|
+
prompt for committed-turn transcription models
|
|
33
|
+
- `endpointing`: server VAD with 500 ms silence when omitted, manual commit when
|
|
34
|
+
`false`, or server VAD with the requested `silence_duration_ms`
|
|
35
|
+
- `gpt-live-transcribe*`: manual commit only; omitted endpointing maps to
|
|
36
|
+
`null`. Explicit server endpointing fails with `unsupported-feature` before
|
|
37
|
+
permission.
|
|
38
|
+
|
|
39
|
+
Vocabulary accepts at most 100 trimmed terms, each at most 200 characters and
|
|
40
|
+
without angle brackets or line breaks. Invalid or unsupported settings fail
|
|
41
|
+
before microphone permission with distinct error codes.
|
|
42
|
+
|
|
43
|
+
### `OpenAIVoiceInputProviderOptions`
|
|
44
|
+
|
|
45
|
+
| Option | Purpose |
|
|
46
|
+
| --------------- | ------------------------------------------------------------------ |
|
|
47
|
+
| `tokenEndpoint` | Required same-origin endpoint that returns an ephemeral credential |
|
|
48
|
+
| `model` | Model ID; default `gpt-transcribe` |
|
|
49
|
+
| `fetch` | Test/runtime override for `globalThis.fetch` |
|
|
50
|
+
| `webSocket` | Test/runtime override for `globalThis.WebSocket` |
|
|
51
|
+
| `realtimeUrl` | Realtime WebSocket URL override |
|
|
52
|
+
|
|
53
|
+
The last three options are provider-factory escape hatches, primarily useful for
|
|
54
|
+
controlled infrastructure and deterministic tests.
|
|
55
|
+
|
|
56
|
+
## Server token handler
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createOpenAITokenHandler } from "@voiceinput/openai/server";
|
|
60
|
+
|
|
61
|
+
export const POST = createOpenAITokenHandler({
|
|
62
|
+
apiKey: process.env.OPENAI_API_KEY!,
|
|
63
|
+
authorize: async (request) => {
|
|
64
|
+
const user = await authenticate(request);
|
|
65
|
+
return user ? { subject: user.id } : null;
|
|
66
|
+
},
|
|
67
|
+
rateLimit: async ({ subject }) => {
|
|
68
|
+
return (await underQuota(subject))
|
|
69
|
+
? { allowed: true }
|
|
70
|
+
: { allowed: false, retryAfterSeconds: 60 };
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The handler accepts only `POST`, always requires `authorize`, sends
|
|
76
|
+
`Cache-Control: no-store`, and never returns or logs the long-lived API key.
|
|
77
|
+
Returning `null` from `authorize` produces `401`. A denied `rateLimit` produces
|
|
78
|
+
`429` before a provider credential is minted. Requests must be JSON and are
|
|
79
|
+
limited to 16 KiB. Authorization and rate-limit callbacks receive independent
|
|
80
|
+
request bodies. If `onTokenIssued` throws, credential delivery fails closed.
|
|
81
|
+
|
|
82
|
+
### `CreateOpenAITokenHandlerOptions`
|
|
83
|
+
|
|
84
|
+
| Option | Purpose |
|
|
85
|
+
| --------------------------- | ------------------------------------------------------------------- |
|
|
86
|
+
| `apiKey` | Required server-only OpenAI key |
|
|
87
|
+
| `authorize(request)` | Required application authorization; returns `{ subject }` or `null` |
|
|
88
|
+
| `model` | Default model; default `gpt-transcribe` |
|
|
89
|
+
| `allowedModels` | Models a browser request may select; defaults to only `model` |
|
|
90
|
+
| `organization`, `project` | Optional OpenAI request headers |
|
|
91
|
+
| `safetyIdentifier(context)` | Optional per-subject OpenAI safety identifier |
|
|
92
|
+
| `rateLimit(context)` | Optional application quota check |
|
|
93
|
+
| `onTokenIssued(metadata)` | Metadata-only audit callback with subject, model, and expiry |
|
|
94
|
+
| `fetch`, `clientSecretUrl` | Transport/endpoint overrides |
|
|
95
|
+
|
|
96
|
+
`OpenAITokenHandlerContext` contains `request`, `subject`, and `model`.
|
|
97
|
+
`OpenAITokenIssuedMetadata` contains `provider: "openai"`, `subject`, `model`,
|
|
98
|
+
and `expiresAt`.
|
|
99
|
+
|
|
100
|
+
The default commits separate phrases during a recording, allowing undo and
|
|
101
|
+
correction to work at phrase boundaries. Live checks on 2026-09-04 confirmed
|
|
102
|
+
that `gpt-live-transcribe` rejects server VAD and does not commit until Stop. It
|
|
103
|
+
remains available for earlier interim feedback: set its model in both the
|
|
104
|
+
adapter and token handler and use `endpointing: false`. In that mode a recording
|
|
105
|
+
is one segment; editing its interim suppresses insertion until the next
|
|
106
|
+
recording. See [provider certification](../../docs/provider-certification.md)
|
|
107
|
+
for evidence and the latency tradeoff.
|
|
108
|
+
|
|
109
|
+
## Public API
|
|
110
|
+
|
|
111
|
+
Browser root:
|
|
112
|
+
|
|
113
|
+
- `openai(options)`
|
|
114
|
+
- `OPENAI_DEFAULT_MODEL`
|
|
115
|
+
- `OpenAIVoiceInputProviderOptions`
|
|
116
|
+
|
|
117
|
+
Server-only entry point:
|
|
118
|
+
|
|
119
|
+
- `createOpenAITokenHandler(options)`
|
|
120
|
+
- `CreateOpenAITokenHandlerOptions`
|
|
121
|
+
- `OpenAIAuthorization`
|
|
122
|
+
- `OpenAIRateLimitResult`
|
|
123
|
+
- `OpenAITokenHandlerContext`
|
|
124
|
+
- `OpenAITokenIssuedMetadata`
|
|
125
|
+
|
|
126
|
+
## Security
|
|
127
|
+
|
|
128
|
+
Import `/server` only from server code. The package export is disabled under the
|
|
129
|
+
browser condition. Never place `OPENAI_API_KEY` in a public environment variable
|
|
130
|
+
or send it to `openai()`. The browser adapter obtains an ephemeral credential
|
|
131
|
+
from your authenticated endpoint and then streams audio directly to OpenAI.
|
|
132
|
+
|
|
133
|
+
See the
|
|
134
|
+
[Next.js](https://github.com/VoiceInput/voiceinput/blob/main/docs/nextjs.md),
|
|
135
|
+
[Vite/Hono](https://github.com/VoiceInput/voiceinput/blob/main/docs/vite-hono.md),
|
|
136
|
+
and
|
|
137
|
+
[Express](https://github.com/VoiceInput/voiceinput/blob/main/docs/express.md)
|
|
138
|
+
integration guides.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_session_config = require("./session-config-CmoDDiY6.cjs");
|
|
3
|
+
let _voiceinput_provider_transport = require("@voiceinput/provider/transport");
|
|
4
|
+
let _voiceinput_provider = require("@voiceinput/provider");
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
const DEFAULT_REALTIME_URL = "wss://api.openai.com/v1/realtime";
|
|
7
|
+
function openai(options) {
|
|
8
|
+
const model = validateFactoryString(options.model ?? "gpt-transcribe", "model");
|
|
9
|
+
const tokenEndpoint = validateFactoryString(String(options.tokenEndpoint), "tokenEndpoint");
|
|
10
|
+
const realtimeUrl = validateFactoryString(options.realtimeUrl ?? DEFAULT_REALTIME_URL, "realtimeUrl");
|
|
11
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
12
|
+
const WebSocketImplementation = options.webSocket ?? globalThis.WebSocket;
|
|
13
|
+
const validateProviderOptions = (transcriptionOptions) => validateOptions(transcriptionOptions, model);
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
specificationVersion: "v1",
|
|
16
|
+
provider: "openai",
|
|
17
|
+
modelId: model,
|
|
18
|
+
sampleRate: require_session_config.OPENAI_SAMPLE_RATE,
|
|
19
|
+
validateOptions: validateProviderOptions,
|
|
20
|
+
async doOpen(callOptions) {
|
|
21
|
+
validateProviderOptions(callOptions);
|
|
22
|
+
return await openSession({
|
|
23
|
+
callOptions,
|
|
24
|
+
fetchImplementation,
|
|
25
|
+
model,
|
|
26
|
+
realtimeUrl,
|
|
27
|
+
tokenEndpoint,
|
|
28
|
+
WebSocketImplementation
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function validateOptions(options, model) {
|
|
34
|
+
try {
|
|
35
|
+
require_session_config.validateOpenAITokenRequest({
|
|
36
|
+
model,
|
|
37
|
+
...options
|
|
38
|
+
});
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
if (_voiceinput_provider.VoiceInputError.isInstance(cause)) throw cause;
|
|
41
|
+
throw new _voiceinput_provider.VoiceInputError({
|
|
42
|
+
code: "invalid-configuration",
|
|
43
|
+
message: cause instanceof Error ? cause.message : "Invalid OpenAI transcription options.",
|
|
44
|
+
provider: "openai",
|
|
45
|
+
cause
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function openSession(options) {
|
|
50
|
+
const { abortSignal } = options.callOptions;
|
|
51
|
+
throwIfAborted(abortSignal);
|
|
52
|
+
if (typeof options.fetchImplementation !== "function") throw unsupportedBrowserFeature("fetch");
|
|
53
|
+
if (typeof options.WebSocketImplementation !== "function") throw unsupportedBrowserFeature("WebSocket");
|
|
54
|
+
const tokenRequest = require_session_config.validateOpenAITokenRequest({
|
|
55
|
+
model: options.model,
|
|
56
|
+
...options.callOptions
|
|
57
|
+
});
|
|
58
|
+
const credential = await requestCredential(options.fetchImplementation, options.tokenEndpoint, tokenRequest, abortSignal);
|
|
59
|
+
throwIfAborted(abortSignal);
|
|
60
|
+
return await createSession(new options.WebSocketImplementation(options.realtimeUrl, ["realtime", `openai-insecure-api-key.${credential.value}`]), tokenRequest, abortSignal);
|
|
61
|
+
}
|
|
62
|
+
async function requestCredential(fetchImplementation, tokenEndpoint, tokenRequest, abortSignal) {
|
|
63
|
+
let response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetchImplementation(tokenEndpoint, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify(tokenRequest),
|
|
69
|
+
credentials: "same-origin",
|
|
70
|
+
signal: abortSignal
|
|
71
|
+
});
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
throwIfAborted(abortSignal);
|
|
74
|
+
throw new _voiceinput_provider.VoiceInputError({
|
|
75
|
+
code: "network-error",
|
|
76
|
+
message: "Unable to reach the OpenAI token endpoint.",
|
|
77
|
+
provider: "openai",
|
|
78
|
+
retryable: true,
|
|
79
|
+
cause
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!response.ok) throw await tokenResponseError(response);
|
|
83
|
+
let value;
|
|
84
|
+
try {
|
|
85
|
+
value = await response.json();
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
throw new _voiceinput_provider.VoiceInputError({
|
|
88
|
+
code: "token-error",
|
|
89
|
+
message: "The OpenAI token endpoint returned invalid JSON.",
|
|
90
|
+
provider: "openai",
|
|
91
|
+
cause
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(value) || typeof value["value"] !== "string" || value["value"].length === 0 || typeof value["expires_at"] !== "number" || !Number.isFinite(value["expires_at"])) throw new _voiceinput_provider.VoiceInputError({
|
|
95
|
+
code: "token-error",
|
|
96
|
+
message: "The OpenAI token endpoint returned an invalid credential.",
|
|
97
|
+
provider: "openai"
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
value: value["value"],
|
|
101
|
+
expires_at: value["expires_at"]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async function tokenResponseError(response) {
|
|
105
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("Retry-After"));
|
|
106
|
+
if (response.status === 401 || response.status === 403) return new _voiceinput_provider.VoiceInputError({
|
|
107
|
+
code: "unauthorized",
|
|
108
|
+
message: "The OpenAI token endpoint rejected this request.",
|
|
109
|
+
provider: "openai"
|
|
110
|
+
});
|
|
111
|
+
if (response.status === 429) return new _voiceinput_provider.VoiceInputError({
|
|
112
|
+
code: "rate-limited",
|
|
113
|
+
message: "The OpenAI token endpoint rate limit was exceeded.",
|
|
114
|
+
provider: "openai",
|
|
115
|
+
retryable: true,
|
|
116
|
+
...retryAfterMs === void 0 ? {} : { retryAfterMs }
|
|
117
|
+
});
|
|
118
|
+
const safeError = await readSafeTokenError(response);
|
|
119
|
+
if (safeError !== void 0) return new _voiceinput_provider.VoiceInputError({
|
|
120
|
+
...safeError,
|
|
121
|
+
provider: "openai"
|
|
122
|
+
});
|
|
123
|
+
return new _voiceinput_provider.VoiceInputError({
|
|
124
|
+
code: "token-error",
|
|
125
|
+
message: "The OpenAI token endpoint did not issue a credential.",
|
|
126
|
+
provider: "openai",
|
|
127
|
+
retryable: response.status >= 500
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function readSafeTokenError(response) {
|
|
131
|
+
if (response.status !== 400 || response.headers.get("X-VoiceInput-Error") !== "1" || response.headers.get("Content-Type")?.split(";", 1)[0]?.trim() !== "application/json") return;
|
|
132
|
+
const text = await readBoundedErrorText(response);
|
|
133
|
+
if (text === void 0) return void 0;
|
|
134
|
+
try {
|
|
135
|
+
const value = JSON.parse(text);
|
|
136
|
+
const error = isRecord(value) ? value["error"] : void 0;
|
|
137
|
+
if (!isRecord(error) || error["code"] !== "invalid-configuration" && error["code"] !== "unsupported-feature" || typeof error["message"] !== "string" || error["message"].length === 0 || error["message"].length > 1e3) return;
|
|
138
|
+
return {
|
|
139
|
+
code: error["code"],
|
|
140
|
+
message: error["message"]
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function readBoundedErrorText(response) {
|
|
147
|
+
const reader = response.body?.getReader();
|
|
148
|
+
if (reader === void 0) return void 0;
|
|
149
|
+
const decoder = new TextDecoder();
|
|
150
|
+
let bytesRead = 0;
|
|
151
|
+
let text = "";
|
|
152
|
+
try {
|
|
153
|
+
while (true) {
|
|
154
|
+
const { done, value } = await reader.read();
|
|
155
|
+
if (done) return text + decoder.decode();
|
|
156
|
+
bytesRead += value.byteLength;
|
|
157
|
+
if (bytesRead > 4096) {
|
|
158
|
+
await reader.cancel().catch(() => {});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
text += decoder.decode(value, { stream: true });
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function createSession(socket, tokenRequest, abortSignal) {
|
|
168
|
+
let streamController;
|
|
169
|
+
const stream = new ReadableStream({ start(controller) {
|
|
170
|
+
streamController = controller;
|
|
171
|
+
} });
|
|
172
|
+
if (streamController === void 0) throw new _voiceinput_provider.VoiceInputError({
|
|
173
|
+
code: "provider-error",
|
|
174
|
+
message: "Unable to initialize the OpenAI transcript stream.",
|
|
175
|
+
provider: "openai"
|
|
176
|
+
});
|
|
177
|
+
const transcripts = /* @__PURE__ */ new Map();
|
|
178
|
+
const transcriptOrder = [];
|
|
179
|
+
const closedItems = /* @__PURE__ */ new Set();
|
|
180
|
+
let audioSent = false;
|
|
181
|
+
let closed = false;
|
|
182
|
+
let failed = false;
|
|
183
|
+
let finishing = false;
|
|
184
|
+
let manualCommitPending = false;
|
|
185
|
+
let vadCommitPending = false;
|
|
186
|
+
const manualCommitEventId = "voiceinput-finish";
|
|
187
|
+
const closeStream = () => {
|
|
188
|
+
if (closed) return;
|
|
189
|
+
closed = true;
|
|
190
|
+
abortSignal.removeEventListener("abort", abort);
|
|
191
|
+
streamController?.close();
|
|
192
|
+
};
|
|
193
|
+
const closeSocket = (reason) => {
|
|
194
|
+
if (socket.readyState === 1 || socket.readyState === 0) socket.close(1e3, reason);
|
|
195
|
+
};
|
|
196
|
+
const finishCleanly = () => {
|
|
197
|
+
closeStream();
|
|
198
|
+
closeSocket("finished");
|
|
199
|
+
};
|
|
200
|
+
const fail = (error) => {
|
|
201
|
+
if (closed || failed) return;
|
|
202
|
+
failed = true;
|
|
203
|
+
streamController?.enqueue({
|
|
204
|
+
type: "error",
|
|
205
|
+
error
|
|
206
|
+
});
|
|
207
|
+
closeStream();
|
|
208
|
+
closeSocket("aborted");
|
|
209
|
+
};
|
|
210
|
+
const maybeFinish = () => {
|
|
211
|
+
if (finishing && !manualCommitPending && !vadCommitPending && transcripts.size === 0) finishCleanly();
|
|
212
|
+
};
|
|
213
|
+
const emitInterim = (state) => {
|
|
214
|
+
if (state.delta && state.delta !== state.lastEmittedInterim) {
|
|
215
|
+
state.lastEmittedInterim = state.delta;
|
|
216
|
+
streamController?.enqueue({
|
|
217
|
+
type: "interim",
|
|
218
|
+
text: state.delta,
|
|
219
|
+
segmentId: transcriptOrder[0]
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const flushFinals = () => {
|
|
224
|
+
while (transcriptOrder.length > 0) {
|
|
225
|
+
const itemId = transcriptOrder[0];
|
|
226
|
+
const state = transcripts.get(itemId);
|
|
227
|
+
if (state?.final === void 0) {
|
|
228
|
+
if (state !== void 0) emitInterim(state);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
transcriptOrder.shift();
|
|
232
|
+
transcripts.delete(itemId);
|
|
233
|
+
closedItems.add(itemId);
|
|
234
|
+
streamController?.enqueue({
|
|
235
|
+
type: "final",
|
|
236
|
+
text: state.final,
|
|
237
|
+
segmentId: itemId
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
maybeFinish();
|
|
241
|
+
};
|
|
242
|
+
const ensureTranscript = (itemId) => {
|
|
243
|
+
let state = transcripts.get(itemId);
|
|
244
|
+
if (state === void 0) {
|
|
245
|
+
state = { delta: "" };
|
|
246
|
+
transcripts.set(itemId, state);
|
|
247
|
+
transcriptOrder.push(itemId);
|
|
248
|
+
}
|
|
249
|
+
return state;
|
|
250
|
+
};
|
|
251
|
+
const handleMessage = (event) => {
|
|
252
|
+
if (closed) return;
|
|
253
|
+
try {
|
|
254
|
+
const value = JSON.parse(String(event.data));
|
|
255
|
+
if (!isRecord(value) || typeof value["type"] !== "string") throw new TypeError("OpenAI sent an invalid Realtime event.");
|
|
256
|
+
const type = value["type"];
|
|
257
|
+
if (type === "input_audio_buffer.speech_started") {
|
|
258
|
+
streamController?.enqueue({ type: "speech-start" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (type === "input_audio_buffer.speech_stopped") {
|
|
262
|
+
vadCommitPending = true;
|
|
263
|
+
streamController?.enqueue({ type: "speech-end" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (type === "input_audio_buffer.committed") {
|
|
267
|
+
const itemId = readString(value, "item_id");
|
|
268
|
+
if (closedItems.has(itemId)) return;
|
|
269
|
+
manualCommitPending = false;
|
|
270
|
+
vadCommitPending = false;
|
|
271
|
+
ensureTranscript(itemId);
|
|
272
|
+
flushFinals();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (type === "conversation.item.input_audio_transcription.delta") {
|
|
276
|
+
const itemId = readString(value, "item_id");
|
|
277
|
+
if (closedItems.has(itemId)) return;
|
|
278
|
+
const state = ensureTranscript(itemId);
|
|
279
|
+
state.delta += readString(value, "delta");
|
|
280
|
+
if (transcriptOrder[0] === itemId) emitInterim(state);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (type === "conversation.item.input_audio_transcription.completed") {
|
|
284
|
+
const itemId = readString(value, "item_id");
|
|
285
|
+
if (closedItems.has(itemId)) return;
|
|
286
|
+
ensureTranscript(itemId).final = readString(value, "transcript");
|
|
287
|
+
flushFinals();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (type === "conversation.item.input_audio_transcription.failed") {
|
|
291
|
+
fail(normalizeTranscriptionFailure(value));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (type === "error") {
|
|
295
|
+
if (isExpectedEmptyCommitError(value, manualCommitEventId)) {
|
|
296
|
+
manualCommitPending = false;
|
|
297
|
+
maybeFinish();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
fail(normalizeRealtimeError(value));
|
|
301
|
+
}
|
|
302
|
+
} catch (cause) {
|
|
303
|
+
fail(new _voiceinput_provider.VoiceInputError({
|
|
304
|
+
code: "provider-error",
|
|
305
|
+
message: "OpenAI sent an invalid Realtime event.",
|
|
306
|
+
provider: "openai",
|
|
307
|
+
cause
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const handleClose = (event) => {
|
|
312
|
+
if (closed) return;
|
|
313
|
+
if (event.code === 1e3) closeStream();
|
|
314
|
+
else fail(new _voiceinput_provider.VoiceInputError({
|
|
315
|
+
code: "network-error",
|
|
316
|
+
message: "The OpenAI Realtime connection closed unexpectedly.",
|
|
317
|
+
provider: "openai",
|
|
318
|
+
retryable: true,
|
|
319
|
+
cause: event
|
|
320
|
+
}));
|
|
321
|
+
};
|
|
322
|
+
const abort = () => {
|
|
323
|
+
if (closed) return;
|
|
324
|
+
closeStream();
|
|
325
|
+
closeSocket("aborted");
|
|
326
|
+
};
|
|
327
|
+
socket.addEventListener("message", handleMessage);
|
|
328
|
+
socket.addEventListener("close", handleClose);
|
|
329
|
+
socket.addEventListener("error", () => {
|
|
330
|
+
fail(new _voiceinput_provider.VoiceInputError({
|
|
331
|
+
code: "network-error",
|
|
332
|
+
message: "The OpenAI Realtime connection failed.",
|
|
333
|
+
provider: "openai",
|
|
334
|
+
retryable: true
|
|
335
|
+
}));
|
|
336
|
+
});
|
|
337
|
+
abortSignal.addEventListener("abort", abort, { once: true });
|
|
338
|
+
await waitForOpen(socket, abortSignal);
|
|
339
|
+
sendEvent(socket, {
|
|
340
|
+
type: "session.update",
|
|
341
|
+
session: require_session_config.createOpenAITranscriptionSession(tokenRequest)
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
stream,
|
|
345
|
+
sendAudio(chunk) {
|
|
346
|
+
if (closed || finishing) return;
|
|
347
|
+
audioSent ||= chunk.length > 0;
|
|
348
|
+
if (chunk.length > 0) return (0, _voiceinput_provider_transport.sendWithBackpressure)(socket, Math.ceil(chunk.byteLength * 4 / 3) + 128, abortSignal, "openai", () => sendEvent(socket, {
|
|
349
|
+
type: "input_audio_buffer.append",
|
|
350
|
+
audio: encodePcm16(chunk)
|
|
351
|
+
}));
|
|
352
|
+
},
|
|
353
|
+
finish() {
|
|
354
|
+
if (closed || finishing) return;
|
|
355
|
+
finishing = true;
|
|
356
|
+
if (!audioSent) {
|
|
357
|
+
finishCleanly();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!vadCommitPending) {
|
|
361
|
+
manualCommitPending = true;
|
|
362
|
+
sendEvent(socket, {
|
|
363
|
+
type: "input_audio_buffer.commit",
|
|
364
|
+
event_id: manualCommitEventId
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
maybeFinish();
|
|
368
|
+
},
|
|
369
|
+
abort
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function waitForOpen(socket, abortSignal) {
|
|
373
|
+
if (socket.readyState === 1) return Promise.resolve();
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const cleanup = () => {
|
|
376
|
+
socket.removeEventListener("open", handleOpen);
|
|
377
|
+
socket.removeEventListener("error", handleError);
|
|
378
|
+
abortSignal.removeEventListener("abort", handleAbort);
|
|
379
|
+
};
|
|
380
|
+
const handleOpen = () => {
|
|
381
|
+
cleanup();
|
|
382
|
+
resolve();
|
|
383
|
+
};
|
|
384
|
+
const handleError = (event) => {
|
|
385
|
+
cleanup();
|
|
386
|
+
reject(new _voiceinput_provider.VoiceInputError({
|
|
387
|
+
code: "network-error",
|
|
388
|
+
message: "Unable to open the OpenAI Realtime connection.",
|
|
389
|
+
provider: "openai",
|
|
390
|
+
retryable: true,
|
|
391
|
+
cause: event
|
|
392
|
+
}));
|
|
393
|
+
};
|
|
394
|
+
const handleAbort = () => {
|
|
395
|
+
cleanup();
|
|
396
|
+
reject(abortSignal.reason);
|
|
397
|
+
};
|
|
398
|
+
socket.addEventListener("open", handleOpen, { once: true });
|
|
399
|
+
socket.addEventListener("error", handleError, { once: true });
|
|
400
|
+
abortSignal.addEventListener("abort", handleAbort, { once: true });
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function sendEvent(socket, event) {
|
|
404
|
+
try {
|
|
405
|
+
socket.send(JSON.stringify(event));
|
|
406
|
+
} catch (cause) {
|
|
407
|
+
throw new _voiceinput_provider.VoiceInputError({
|
|
408
|
+
code: "network-error",
|
|
409
|
+
message: "Unable to send data to OpenAI Realtime.",
|
|
410
|
+
provider: "openai",
|
|
411
|
+
retryable: true,
|
|
412
|
+
cause
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function encodePcm16(chunk) {
|
|
417
|
+
const bytes = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
418
|
+
let binary = "";
|
|
419
|
+
for (let offset = 0; offset < bytes.length; offset += 8192) binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
|
|
420
|
+
return btoa(binary);
|
|
421
|
+
}
|
|
422
|
+
function normalizeRealtimeError(value) {
|
|
423
|
+
const error = isRecord(value["error"]) ? value["error"] : value;
|
|
424
|
+
const code = typeof error["code"] === "string" ? error["code"] : "";
|
|
425
|
+
const message = typeof error["message"] === "string" ? error["message"] : "OpenAI Realtime reported an error.";
|
|
426
|
+
const rateLimited = code.includes("rate_limit");
|
|
427
|
+
return new _voiceinput_provider.VoiceInputError({
|
|
428
|
+
code: rateLimited ? "rate-limited" : "provider-error",
|
|
429
|
+
message,
|
|
430
|
+
provider: "openai",
|
|
431
|
+
retryable: rateLimited || code.includes("server_error"),
|
|
432
|
+
cause: value
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function normalizeTranscriptionFailure(value) {
|
|
436
|
+
const error = isRecord(value["error"]) ? value["error"] : {};
|
|
437
|
+
return new _voiceinput_provider.VoiceInputError({
|
|
438
|
+
code: "provider-error",
|
|
439
|
+
message: typeof error["message"] === "string" ? error["message"] : "OpenAI could not transcribe an audio turn.",
|
|
440
|
+
provider: "openai",
|
|
441
|
+
retryable: typeof error["code"] === "string" && error["code"].includes("server_error"),
|
|
442
|
+
cause: value
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function isExpectedEmptyCommitError(value, eventId) {
|
|
446
|
+
const error = isRecord(value["error"]) ? value["error"] : {};
|
|
447
|
+
return error["code"] === "input_audio_buffer_commit_empty" && error["event_id"] === eventId;
|
|
448
|
+
}
|
|
449
|
+
function readString(value, key) {
|
|
450
|
+
const result = value[key];
|
|
451
|
+
if (typeof result !== "string") throw new TypeError(`${key} must be a string.`);
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
function parseRetryAfter(value) {
|
|
455
|
+
if (value === null) return;
|
|
456
|
+
const seconds = Number(value);
|
|
457
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1e3);
|
|
458
|
+
const date = Date.parse(value);
|
|
459
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
460
|
+
}
|
|
461
|
+
function throwIfAborted(signal) {
|
|
462
|
+
if (signal.aborted) throw signal.reason;
|
|
463
|
+
}
|
|
464
|
+
function validateFactoryString(value, name) {
|
|
465
|
+
if (value.trim().length === 0) throw new _voiceinput_provider.VoiceInputError({
|
|
466
|
+
code: "invalid-configuration",
|
|
467
|
+
message: `${name} must be a non-empty string.`,
|
|
468
|
+
provider: "openai"
|
|
469
|
+
});
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
function unsupportedBrowserFeature(feature) {
|
|
473
|
+
return new _voiceinput_provider.VoiceInputError({
|
|
474
|
+
code: "unsupported-browser",
|
|
475
|
+
message: `OpenAI voice input requires browser ${feature} support.`,
|
|
476
|
+
provider: "openai"
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function isRecord(value) {
|
|
480
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
exports.OPENAI_DEFAULT_MODEL = require_session_config.OPENAI_DEFAULT_MODEL;
|
|
484
|
+
exports.openai = openai;
|
|
485
|
+
|
|
486
|
+
//# sourceMappingURL=index.cjs.map
|