@flow-state-dev/voice-openai 0.1.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 +156 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/openai-voice-provider.d.ts +55 -0
- package/dist/openai-voice-provider.js +182 -0
- package/dist/output-format.d.ts +21 -0
- package/dist/output-format.js +42 -0
- package/dist/translate-error.d.ts +18 -0
- package/dist/translate-error.js +130 -0
- package/dist/voice-catalog.d.ts +15 -0
- package/dist/voice-catalog.js +28 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fixpoint Labs
|
|
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,156 @@
|
|
|
1
|
+
# @flow-state-dev/voice-openai
|
|
2
|
+
|
|
3
|
+
OpenAI implementation of the `VoiceProvider` contract: batch text-to-speech, batch transcription, and the OpenAI voice catalog. Use it directly, or compose it with another provider via `createCompositeVoiceProvider`.
|
|
4
|
+
|
|
5
|
+
`VoiceProvider` is the framework's single-object voice contract: one provider exposes some combination of `speak`, `speakStream`, `transcribe`, and `listVoices`, and declares which ones it supports via an `abilities` flags object. Callers branch on the flags (or the matching `canSpeak` / `canTranscribe` runtime type guards) instead of holding separate resolver objects per surface.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @flow-state-dev/voice-openai openai
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`openai` is a direct dependency of this package, not a peer — it ships with the package so you don't have to pin a compatible version yourself. You only need to install it explicitly if your app calls the SDK directly elsewhere.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { OpenAIVoiceProvider } from "@flow-state-dev/voice-openai";
|
|
19
|
+
|
|
20
|
+
const provider = new OpenAIVoiceProvider({ apiKey: process.env.OPENAI_API_KEY });
|
|
21
|
+
|
|
22
|
+
const { audio, mediaType } = await provider.speak({ text: "Hello, world." });
|
|
23
|
+
// audio: Uint8Array (MP3 by default), mediaType: "audio/mpeg"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pass it to your flow router via the M1 voice config; until the router migration lands you can still consume the provider directly in custom blocks.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
new OpenAIVoiceProvider({
|
|
32
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
33
|
+
baseURL: "https://my-proxy.example.com/v1", // optional
|
|
34
|
+
organization: "org-...", // optional
|
|
35
|
+
project: "proj_...", // optional
|
|
36
|
+
ttsModel: "gpt-4o-mini-tts", // default
|
|
37
|
+
sttModel: "gpt-4o-mini-transcribe", // default
|
|
38
|
+
voice: "alloy", // default
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Option | Purpose |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `apiKey` | OpenAI API key. Omit to let the SDK read `OPENAI_API_KEY` from the environment. |
|
|
45
|
+
| `baseURL` | Override the API endpoint. The SDK also reads `OPENAI_BASE_URL` from the environment when this is omitted. |
|
|
46
|
+
| `organization` | OpenAI organization id, forwarded to the SDK. |
|
|
47
|
+
| `project` | OpenAI project id, forwarded to the SDK. |
|
|
48
|
+
| `ttsModel` | Default model for `speak()`. Caller-supplied `SpeakOptions.model` overrides per call. |
|
|
49
|
+
| `sttModel` | Default model for `transcribe()`. Caller-supplied `TranscribeOptions.model` overrides per call. |
|
|
50
|
+
| `voice` | Default voice id for `speak()`. Caller-supplied `SpeakOptions.voice` overrides per call. |
|
|
51
|
+
| `client` | Escape hatch: supply a pre-configured `OpenAI` client. When set, the other client-config options (`apiKey`, `baseURL`, `organization`, `project`) are ignored. |
|
|
52
|
+
|
|
53
|
+
## Abilities
|
|
54
|
+
|
|
55
|
+
| Ability | Value | Notes |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `speak` | `true` | Batch TTS via `audio.speech.create`. |
|
|
58
|
+
| `speakStream` | `false` | Not in scope for M1. OpenAI's SDK does support streaming via `stream_format`; expect a follow-up. For now, compose with another provider for streaming. |
|
|
59
|
+
| `transcribe` | `true` | Batch STT via `audio.transcriptions.create`. |
|
|
60
|
+
| `listVoices` | `true` | Static catalog; OpenAI does not expose a discovery endpoint. |
|
|
61
|
+
|
|
62
|
+
## Voice catalog
|
|
63
|
+
|
|
64
|
+
`listVoices()` returns a 13-entry static list:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
alloy, ash, ballad, coral, echo, fable, nova, onyx,
|
|
68
|
+
sage, shimmer, verse, marin, cedar
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`marin` and `cedar` are only available on `gpt-4o-mini-tts`. They declare this via `supportedModels: ["gpt-4o-mini-tts"]`. Every other entry omits `supportedModels`, which by convention means "supported by every OpenAI TTS model" — there's no implicit allowlist to forget about.
|
|
72
|
+
|
|
73
|
+
The catalog is hand-maintained. When OpenAI ships a new voice, update `src/voice-catalog.ts`.
|
|
74
|
+
|
|
75
|
+
## Output formats
|
|
76
|
+
|
|
77
|
+
`SpeakOptions.outputFormat` accepts six values; the returned `mediaType` follows OpenAI's encoding directly:
|
|
78
|
+
|
|
79
|
+
| `outputFormat` | `mediaType` |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `mp3` (default) | `audio/mpeg` |
|
|
82
|
+
| `opus` | `audio/ogg; codecs=opus` |
|
|
83
|
+
| `aac` | `audio/aac` |
|
|
84
|
+
| `flac` | `audio/flac` |
|
|
85
|
+
| `wav` | `audio/wav` |
|
|
86
|
+
| `pcm` | `audio/pcm;rate=24000` |
|
|
87
|
+
|
|
88
|
+
PCM output is raw 24 kHz 16-bit little-endian mono. Unsupported values throw `VoiceError({ kind: "format_unsupported" })` synchronously, before any network call.
|
|
89
|
+
|
|
90
|
+
## Provider-specific options
|
|
91
|
+
|
|
92
|
+
`SpeakOptions.providerOptions.openai.instructions` is forwarded as OpenAI's `instructions` parameter (style guidance like "speak slowly and warmly"). This field is only valid on `gpt-4o-mini-tts*` models. Passing it with `tts-1` / `tts-1-hd` throws `VoiceError({ kind: "invalid_input" })` at the provider boundary rather than wasting a 400 round-trip.
|
|
93
|
+
|
|
94
|
+
## Error handling
|
|
95
|
+
|
|
96
|
+
Every method translates SDK errors into `VoiceError`, a discriminated class with a `kind` field. Branch on `kind` and inspect `retryable` instead of parsing message strings:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { VoiceError } from "@flow-state-dev/core";
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await provider.speak({ text: "hello" });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof VoiceError) {
|
|
105
|
+
if (err.kind === "rate_limit" && err.retryable) {
|
|
106
|
+
// back off and retry
|
|
107
|
+
} else if (err.kind === "auth") {
|
|
108
|
+
// surface to the operator
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Full mapping:
|
|
115
|
+
|
|
116
|
+
| OpenAI SDK error | `VoiceError.kind` | `retryable` |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `APIUserAbortError` | `aborted` | `false` |
|
|
119
|
+
| `AuthenticationError` (401) | `auth` | `false` |
|
|
120
|
+
| `PermissionDeniedError` (403) | `auth` | `false` |
|
|
121
|
+
| `RateLimitError` (429) | `rate_limit` | `true` |
|
|
122
|
+
| `NotFoundError` (404) | `not_found` | `false` |
|
|
123
|
+
| `BadRequestError` (400) — format signal | `format_unsupported` | `false` |
|
|
124
|
+
| `BadRequestError` (400) — otherwise | `invalid_input` | `false` |
|
|
125
|
+
| `UnprocessableEntityError` (422) | `invalid_input` (or `format_unsupported`) | `false` |
|
|
126
|
+
| `InternalServerError` (≥500) | `provider_unavailable` | `true` |
|
|
127
|
+
| `APIConnectionError` / `APIConnectionTimeoutError` | `network` | `true` |
|
|
128
|
+
| `APIError` (other) | `unknown` | `false` |
|
|
129
|
+
| Anything else | `unknown` | `false` |
|
|
130
|
+
|
|
131
|
+
The "format signal" heuristic looks at `err.code`, `err.param`, and the message; bad-format 400s become `format_unsupported`, generic bad inputs stay `invalid_input`.
|
|
132
|
+
|
|
133
|
+
## Composing with another provider
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { createCompositeVoiceProvider } from "@flow-state-dev/core";
|
|
137
|
+
import { OpenAIVoiceProvider } from "@flow-state-dev/voice-openai";
|
|
138
|
+
// import { ElevenLabsVoiceProvider } from "@flow-state-dev/voice-elevenlabs";
|
|
139
|
+
|
|
140
|
+
const openai = new OpenAIVoiceProvider({ apiKey: process.env.OPENAI_API_KEY });
|
|
141
|
+
|
|
142
|
+
const composite = createCompositeVoiceProvider({
|
|
143
|
+
// speak: new ElevenLabsVoiceProvider({ ... }), // streaming-capable provider
|
|
144
|
+
transcribe: openai,
|
|
145
|
+
listVoices: openai,
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Each ability slot can come from a different provider. Slots whose underlying provider doesn't advertise the matching ability show up as `false` in the composite's `abilities` rather than as a runtime failure.
|
|
150
|
+
|
|
151
|
+
## What's not in M1
|
|
152
|
+
|
|
153
|
+
- **Streaming TTS.** OpenAI's SDK supports it via `stream_format`, but the framework's pipeline doesn't need it from OpenAI yet, so the surface is left off the provider. ElevenLabs is the canonical streaming provider in M2.
|
|
154
|
+
- **Custom-voice helpers.** The SDK accepts custom voice ids; the catalog lists OpenAI's prebuilt voices only. Pass a custom voice via `SpeakOptions.voice` if you need one.
|
|
155
|
+
- **Realtime API.** Out of M1 scope.
|
|
156
|
+
- **Live-API integration tests.** The unit tests mock the SDK at its boundary. Live-credentials tests live under `packages/integration-tests/` and arrive in a follow-up.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Public surface of `@flow-state-dev/voice-openai`. */
|
|
2
|
+
export { OpenAIVoiceProvider } from "./openai-voice-provider";
|
|
3
|
+
export type { OpenAIVoiceProviderOptions } from "./openai-voice-provider";
|
|
4
|
+
export { OPENAI_VOICE_CATALOG } from "./voice-catalog";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OpenAIVoiceProvider`: implements the `VoiceProvider` contract against the
|
|
3
|
+
* official `openai` SDK. Exposes batch `speak`, batch `transcribe`, and a
|
|
4
|
+
* static `listVoices` catalog. `speakStream` is intentionally absent —
|
|
5
|
+
* `abilities.speakStream === false`.
|
|
6
|
+
*/
|
|
7
|
+
import OpenAI from "openai";
|
|
8
|
+
import type { SpeakOptions, SpeakResult, TranscribeOptions, TranscribeResult, VoiceAbilities, VoiceInfo, VoiceProvider } from "@flow-state-dev/core";
|
|
9
|
+
/** Constructor options for {@link OpenAIVoiceProvider}. */
|
|
10
|
+
export interface OpenAIVoiceProviderOptions {
|
|
11
|
+
/**
|
|
12
|
+
* OpenAI API key. Omit to fall back to the SDK's default behaviour, which
|
|
13
|
+
* reads `OPENAI_API_KEY` from the environment.
|
|
14
|
+
*/
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
/** Override the API base URL (Azure-OpenAI compatible endpoints, proxies). */
|
|
17
|
+
baseURL?: string;
|
|
18
|
+
/** OpenAI organization id, forwarded to the SDK. */
|
|
19
|
+
organization?: string;
|
|
20
|
+
/** OpenAI project id, forwarded to the SDK. */
|
|
21
|
+
project?: string;
|
|
22
|
+
/** Default TTS model for `speak()`. Defaults to `"gpt-4o-mini-tts"`. */
|
|
23
|
+
ttsModel?: string;
|
|
24
|
+
/** Default STT model for `transcribe()`. Defaults to `"gpt-4o-mini-transcribe"`. */
|
|
25
|
+
sttModel?: string;
|
|
26
|
+
/** Default voice for `speak()`. Defaults to `"alloy"`. */
|
|
27
|
+
voice?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Escape hatch: supply a pre-configured `OpenAI` client. When set, all
|
|
30
|
+
* other client-configuration options (`apiKey`, `baseURL`, `organization`,
|
|
31
|
+
* `project`) are ignored — the caller owns the client.
|
|
32
|
+
*/
|
|
33
|
+
client?: OpenAI;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* OpenAI-backed `VoiceProvider`. Single-instance, reusable across requests;
|
|
37
|
+
* the underlying SDK client is shared, so HTTP-keep-alive and per-instance
|
|
38
|
+
* options (org, project) are honoured for the instance's lifetime.
|
|
39
|
+
*/
|
|
40
|
+
export declare class OpenAIVoiceProvider implements VoiceProvider {
|
|
41
|
+
readonly providerName = "openai";
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly abilities: VoiceAbilities;
|
|
44
|
+
readonly defaultModels: {
|
|
45
|
+
speak: string;
|
|
46
|
+
transcribe: string;
|
|
47
|
+
};
|
|
48
|
+
private readonly client;
|
|
49
|
+
private readonly defaultVoice;
|
|
50
|
+
constructor(opts?: OpenAIVoiceProviderOptions);
|
|
51
|
+
speak(o: SpeakOptions): Promise<SpeakResult>;
|
|
52
|
+
transcribe(o: TranscribeOptions): Promise<TranscribeResult>;
|
|
53
|
+
listVoices(): Promise<VoiceInfo[]>;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=openai-voice-provider.d.ts.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OpenAIVoiceProvider`: implements the `VoiceProvider` contract against the
|
|
3
|
+
* official `openai` SDK. Exposes batch `speak`, batch `transcribe`, and a
|
|
4
|
+
* static `listVoices` catalog. `speakStream` is intentionally absent —
|
|
5
|
+
* `abilities.speakStream === false`.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import OpenAI, { toFile } from "openai";
|
|
9
|
+
import { VoiceError } from "@flow-state-dev/core";
|
|
10
|
+
import { mapOutputFormat, mediaTypeForFormat } from "./output-format";
|
|
11
|
+
import { translateError } from "./translate-error";
|
|
12
|
+
import { OPENAI_VOICE_CATALOG } from "./voice-catalog";
|
|
13
|
+
/**
|
|
14
|
+
* OpenAI-backed `VoiceProvider`. Single-instance, reusable across requests;
|
|
15
|
+
* the underlying SDK client is shared, so HTTP-keep-alive and per-instance
|
|
16
|
+
* options (org, project) are honoured for the instance's lifetime.
|
|
17
|
+
*/
|
|
18
|
+
export class OpenAIVoiceProvider {
|
|
19
|
+
providerName = "openai";
|
|
20
|
+
id;
|
|
21
|
+
abilities = {
|
|
22
|
+
speak: true,
|
|
23
|
+
speakStream: false,
|
|
24
|
+
transcribe: true,
|
|
25
|
+
listVoices: true,
|
|
26
|
+
};
|
|
27
|
+
defaultModels;
|
|
28
|
+
client;
|
|
29
|
+
defaultVoice;
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
const ttsModel = opts.ttsModel ?? "gpt-4o-mini-tts";
|
|
32
|
+
const sttModel = opts.sttModel ?? "gpt-4o-mini-transcribe";
|
|
33
|
+
this.defaultModels = { speak: ttsModel, transcribe: sttModel };
|
|
34
|
+
this.defaultVoice = opts.voice ?? "alloy";
|
|
35
|
+
this.client =
|
|
36
|
+
opts.client ??
|
|
37
|
+
new OpenAI({
|
|
38
|
+
apiKey: opts.apiKey,
|
|
39
|
+
baseURL: opts.baseURL,
|
|
40
|
+
organization: opts.organization,
|
|
41
|
+
project: opts.project,
|
|
42
|
+
});
|
|
43
|
+
// Read identity fields from the resolved client so a caller-supplied
|
|
44
|
+
// `opts.client` contributes its own baseURL/org/project to the id
|
|
45
|
+
// instead of degenerating to all-`undefined`. Never include `apiKey`.
|
|
46
|
+
this.id = deriveId({
|
|
47
|
+
ttsModel,
|
|
48
|
+
sttModel,
|
|
49
|
+
voice: this.defaultVoice,
|
|
50
|
+
baseURL: this.client.baseURL,
|
|
51
|
+
organization: this.client.organization ?? undefined,
|
|
52
|
+
project: this.client.project ?? undefined,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async speak(o) {
|
|
56
|
+
const model = o.model ?? this.defaultModels.speak;
|
|
57
|
+
const voice = o.voice ?? this.defaultVoice;
|
|
58
|
+
const responseFormat = mapOutputFormat(o.outputFormat);
|
|
59
|
+
// `instructions` is rejected by OpenAI on the legacy `tts-1` / `tts-1-hd`
|
|
60
|
+
// models. Pre-validate at the boundary so callers get a typed error
|
|
61
|
+
// rather than a 400 round-trip.
|
|
62
|
+
const providerOpts = o.providerOptions?.openai;
|
|
63
|
+
const instructions = providerOpts?.instructions;
|
|
64
|
+
if (instructions !== undefined && (model === "tts-1" || model === "tts-1-hd")) {
|
|
65
|
+
throw new VoiceError({
|
|
66
|
+
kind: "invalid_input",
|
|
67
|
+
provider: "openai",
|
|
68
|
+
message: `instructions is not supported on legacy TTS models (tts-1, tts-1-hd); got model="${model}"`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const response = await this.client.audio.speech.create({
|
|
73
|
+
input: o.text,
|
|
74
|
+
model,
|
|
75
|
+
// The SDK's `voice` union is narrower than OpenAI's actual catalog
|
|
76
|
+
// (it lags new voices and never accepts custom voice ids). The
|
|
77
|
+
// catalog truth lives in `voice-catalog.ts`.
|
|
78
|
+
voice: voice,
|
|
79
|
+
response_format: responseFormat,
|
|
80
|
+
speed: o.speed,
|
|
81
|
+
instructions,
|
|
82
|
+
}, { signal: o.signal });
|
|
83
|
+
const audio = new Uint8Array(await response.arrayBuffer());
|
|
84
|
+
return { audio, mediaType: mediaTypeForFormat(responseFormat) };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw translateError(err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async transcribe(o) {
|
|
91
|
+
const model = o.model ?? this.defaultModels.transcribe;
|
|
92
|
+
const file = await coerceUploadable(o.audio, o.mediaType);
|
|
93
|
+
try {
|
|
94
|
+
const result = await this.client.audio.transcriptions.create({
|
|
95
|
+
file,
|
|
96
|
+
model,
|
|
97
|
+
language: o.language,
|
|
98
|
+
response_format: "json",
|
|
99
|
+
}, { signal: o.signal });
|
|
100
|
+
// `json` always returns an object; the string overload only fires for
|
|
101
|
+
// `text` / `srt` / `vtt`. Handle both so the type assertion stays honest.
|
|
102
|
+
const text = typeof result === "string" ? result : result.text;
|
|
103
|
+
// OpenAI's `json` response doesn't echo a language field. Preserve the
|
|
104
|
+
// caller's hint so the result shape stays useful for downstream code.
|
|
105
|
+
return { text, language: o.language };
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
throw translateError(err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async listVoices() {
|
|
112
|
+
// Deep-copy entries: `supportedModels` is a mutable array on each entry,
|
|
113
|
+
// so a shallow spread would let callers mutate the module-global catalog.
|
|
114
|
+
return OPENAI_VOICE_CATALOG.map((v) => ({
|
|
115
|
+
...v,
|
|
116
|
+
...(v.supportedModels !== undefined
|
|
117
|
+
? { supportedModels: [...v.supportedModels] }
|
|
118
|
+
: {}),
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Builds a stable, opaque `id` from the instance's defining configuration.
|
|
124
|
+
* Identical options produce identical ids across processes (useful for
|
|
125
|
+
* registry deduplication and structured logging). The API key is NEVER
|
|
126
|
+
* included so the id is safe to log.
|
|
127
|
+
*/
|
|
128
|
+
function deriveId(fields) {
|
|
129
|
+
const hash = createHash("sha256")
|
|
130
|
+
.update(JSON.stringify(fields))
|
|
131
|
+
.digest("hex")
|
|
132
|
+
.slice(0, 12);
|
|
133
|
+
return `openai:${hash}`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Normalizes `TranscribeOptions.audio` into something the SDK accepts as
|
|
137
|
+
* its `file` parameter. `Blob` passes through; `Uint8Array` is wrapped via
|
|
138
|
+
* `toFile()` with a filename whose extension matches `mediaType` so OpenAI
|
|
139
|
+
* can infer the audio format. `Uint8Array` without `mediaType` throws.
|
|
140
|
+
*/
|
|
141
|
+
async function coerceUploadable(audio, mediaType) {
|
|
142
|
+
if (audio instanceof Blob)
|
|
143
|
+
return audio;
|
|
144
|
+
if (mediaType === undefined) {
|
|
145
|
+
throw new VoiceError({
|
|
146
|
+
kind: "invalid_input",
|
|
147
|
+
provider: "openai",
|
|
148
|
+
message: "transcribe(audio: Uint8Array) requires mediaType — pass mediaType: 'audio/mpeg' (etc.) so OpenAI can infer the format.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return toFile(audio, filenameForMediaType(mediaType), { type: mediaType });
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Maps common audio MIME types to a filename with the matching extension.
|
|
155
|
+
* Unrecognized subtypes fall back to using the subtype itself as the
|
|
156
|
+
* extension (e.g. `audio/mp4` → `audio.mp4`), which is OpenAI's preferred
|
|
157
|
+
* inference signal.
|
|
158
|
+
*/
|
|
159
|
+
function filenameForMediaType(mediaType) {
|
|
160
|
+
const known = {
|
|
161
|
+
"audio/mpeg": "audio.mp3",
|
|
162
|
+
"audio/mp3": "audio.mp3",
|
|
163
|
+
"audio/wav": "audio.wav",
|
|
164
|
+
"audio/x-wav": "audio.wav",
|
|
165
|
+
"audio/webm": "audio.webm",
|
|
166
|
+
"audio/ogg": "audio.ogg",
|
|
167
|
+
"audio/flac": "audio.flac",
|
|
168
|
+
"audio/m4a": "audio.m4a",
|
|
169
|
+
"audio/mp4": "audio.mp4",
|
|
170
|
+
};
|
|
171
|
+
const normalized = mediaType.split(";")[0].trim().toLowerCase();
|
|
172
|
+
if (normalized in known)
|
|
173
|
+
return known[normalized];
|
|
174
|
+
const slash = normalized.indexOf("/");
|
|
175
|
+
if (slash !== -1) {
|
|
176
|
+
const subtype = normalized.slice(slash + 1).replace(/^x-/, "");
|
|
177
|
+
if (subtype.length > 0)
|
|
178
|
+
return `audio.${subtype}`;
|
|
179
|
+
}
|
|
180
|
+
return "audio.bin";
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=openai-voice-provider.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps caller-supplied `SpeakOptions.outputFormat` strings onto OpenAI's
|
|
3
|
+
* `response_format` values, and onto MIME media types the returned
|
|
4
|
+
* `SpeakResult.mediaType` field carries.
|
|
5
|
+
*
|
|
6
|
+
* Unknown formats throw `VoiceError({ kind: "format_unsupported" })`
|
|
7
|
+
* synchronously so callers fail fast at the provider boundary rather than
|
|
8
|
+
* after the network round-trip.
|
|
9
|
+
*/
|
|
10
|
+
declare const ALLOWED: readonly ["mp3", "opus", "aac", "flac", "wav", "pcm"];
|
|
11
|
+
type OpenAIResponseFormat = (typeof ALLOWED)[number];
|
|
12
|
+
/**
|
|
13
|
+
* Validates and normalizes a caller-supplied output format. Returns `"mp3"`
|
|
14
|
+
* when the caller doesn't specify one (matches OpenAI's own default and the
|
|
15
|
+
* pre-`VoiceProvider` pipeline behaviour).
|
|
16
|
+
*/
|
|
17
|
+
export declare function mapOutputFormat(format: string | undefined): OpenAIResponseFormat;
|
|
18
|
+
/** MIME media type for an OpenAI response format. */
|
|
19
|
+
export declare function mediaTypeForFormat(format: OpenAIResponseFormat): string;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=output-format.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps caller-supplied `SpeakOptions.outputFormat` strings onto OpenAI's
|
|
3
|
+
* `response_format` values, and onto MIME media types the returned
|
|
4
|
+
* `SpeakResult.mediaType` field carries.
|
|
5
|
+
*
|
|
6
|
+
* Unknown formats throw `VoiceError({ kind: "format_unsupported" })`
|
|
7
|
+
* synchronously so callers fail fast at the provider boundary rather than
|
|
8
|
+
* after the network round-trip.
|
|
9
|
+
*/
|
|
10
|
+
import { VoiceError } from "@flow-state-dev/core";
|
|
11
|
+
const ALLOWED = ["mp3", "opus", "aac", "flac", "wav", "pcm"];
|
|
12
|
+
const MIME = {
|
|
13
|
+
mp3: "audio/mpeg",
|
|
14
|
+
opus: "audio/ogg; codecs=opus",
|
|
15
|
+
aac: "audio/aac",
|
|
16
|
+
flac: "audio/flac",
|
|
17
|
+
wav: "audio/wav",
|
|
18
|
+
// OpenAI's PCM output is 24 kHz 16-bit little-endian mono.
|
|
19
|
+
pcm: "audio/pcm;rate=24000",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Validates and normalizes a caller-supplied output format. Returns `"mp3"`
|
|
23
|
+
* when the caller doesn't specify one (matches OpenAI's own default and the
|
|
24
|
+
* pre-`VoiceProvider` pipeline behaviour).
|
|
25
|
+
*/
|
|
26
|
+
export function mapOutputFormat(format) {
|
|
27
|
+
if (format === undefined)
|
|
28
|
+
return "mp3";
|
|
29
|
+
if (ALLOWED.includes(format)) {
|
|
30
|
+
return format;
|
|
31
|
+
}
|
|
32
|
+
throw new VoiceError({
|
|
33
|
+
kind: "format_unsupported",
|
|
34
|
+
provider: "openai",
|
|
35
|
+
message: `outputFormat "${format}" is not supported by OpenAI TTS. Allowed: ${ALLOWED.join(", ")}.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** MIME media type for an OpenAI response format. */
|
|
39
|
+
export function mediaTypeForFormat(format) {
|
|
40
|
+
return MIME[format];
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=output-format.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates errors thrown by the `openai` SDK into the framework's typed
|
|
3
|
+
* `VoiceError` discriminated by `kind`. Callers branch on `kind` and inspect
|
|
4
|
+
* `retryable` instead of parsing message strings or sniffing SDK class names.
|
|
5
|
+
*
|
|
6
|
+
* `VoiceError` instances pass through unchanged so the translator is
|
|
7
|
+
* idempotent: helpers that throw `VoiceError` directly (`mapOutputFormat`,
|
|
8
|
+
* `coerceUploadable`, the instructions guard) don't get re-wrapped when
|
|
9
|
+
* they bubble through a translation site.
|
|
10
|
+
*/
|
|
11
|
+
import { VoiceError } from "@flow-state-dev/core";
|
|
12
|
+
/**
|
|
13
|
+
* Maps any thrown value from an OpenAI SDK call to a `VoiceError`. Already
|
|
14
|
+
* a `VoiceError` → returned as-is. Recognized `OpenAI.*Error` subclasses →
|
|
15
|
+
* mapped to their canonical `kind`. Anything else → `kind: "unknown"`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function translateError(err: unknown): VoiceError;
|
|
18
|
+
//# sourceMappingURL=translate-error.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates errors thrown by the `openai` SDK into the framework's typed
|
|
3
|
+
* `VoiceError` discriminated by `kind`. Callers branch on `kind` and inspect
|
|
4
|
+
* `retryable` instead of parsing message strings or sniffing SDK class names.
|
|
5
|
+
*
|
|
6
|
+
* `VoiceError` instances pass through unchanged so the translator is
|
|
7
|
+
* idempotent: helpers that throw `VoiceError` directly (`mapOutputFormat`,
|
|
8
|
+
* `coerceUploadable`, the instructions guard) don't get re-wrapped when
|
|
9
|
+
* they bubble through a translation site.
|
|
10
|
+
*/
|
|
11
|
+
import OpenAI from "openai";
|
|
12
|
+
import { VoiceError } from "@flow-state-dev/core";
|
|
13
|
+
/**
|
|
14
|
+
* Maps any thrown value from an OpenAI SDK call to a `VoiceError`. Already
|
|
15
|
+
* a `VoiceError` → returned as-is. Recognized `OpenAI.*Error` subclasses →
|
|
16
|
+
* mapped to their canonical `kind`. Anything else → `kind: "unknown"`.
|
|
17
|
+
*/
|
|
18
|
+
export function translateError(err) {
|
|
19
|
+
if (err instanceof VoiceError)
|
|
20
|
+
return err;
|
|
21
|
+
if (err instanceof OpenAI.APIUserAbortError) {
|
|
22
|
+
return new VoiceError({
|
|
23
|
+
kind: "aborted",
|
|
24
|
+
provider: "openai",
|
|
25
|
+
message: err.message,
|
|
26
|
+
cause: err,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (err instanceof OpenAI.AuthenticationError ||
|
|
30
|
+
err instanceof OpenAI.PermissionDeniedError) {
|
|
31
|
+
return new VoiceError({
|
|
32
|
+
kind: "auth",
|
|
33
|
+
provider: "openai",
|
|
34
|
+
message: err.message,
|
|
35
|
+
status: err.status,
|
|
36
|
+
cause: err,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof OpenAI.RateLimitError) {
|
|
40
|
+
return new VoiceError({
|
|
41
|
+
kind: "rate_limit",
|
|
42
|
+
provider: "openai",
|
|
43
|
+
message: err.message,
|
|
44
|
+
status: err.status,
|
|
45
|
+
cause: err,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (err instanceof OpenAI.NotFoundError) {
|
|
49
|
+
return new VoiceError({
|
|
50
|
+
kind: "not_found",
|
|
51
|
+
provider: "openai",
|
|
52
|
+
message: err.message,
|
|
53
|
+
status: err.status,
|
|
54
|
+
cause: err,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (err instanceof OpenAI.BadRequestError ||
|
|
58
|
+
err instanceof OpenAI.UnprocessableEntityError) {
|
|
59
|
+
const kind = looksLikeFormatError(err) ? "format_unsupported" : "invalid_input";
|
|
60
|
+
return new VoiceError({
|
|
61
|
+
kind,
|
|
62
|
+
provider: "openai",
|
|
63
|
+
message: err.message,
|
|
64
|
+
status: err.status,
|
|
65
|
+
cause: err,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (err instanceof OpenAI.InternalServerError) {
|
|
69
|
+
return new VoiceError({
|
|
70
|
+
kind: "provider_unavailable",
|
|
71
|
+
provider: "openai",
|
|
72
|
+
message: err.message,
|
|
73
|
+
status: err.status,
|
|
74
|
+
cause: err,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (err instanceof OpenAI.APIConnectionError) {
|
|
78
|
+
// Also catches APIConnectionTimeoutError, which extends APIConnectionError.
|
|
79
|
+
return new VoiceError({
|
|
80
|
+
kind: "network",
|
|
81
|
+
provider: "openai",
|
|
82
|
+
message: err.message,
|
|
83
|
+
cause: err,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (err instanceof OpenAI.APIError) {
|
|
87
|
+
return new VoiceError({
|
|
88
|
+
kind: "unknown",
|
|
89
|
+
provider: "openai",
|
|
90
|
+
message: err.message,
|
|
91
|
+
status: err.status,
|
|
92
|
+
cause: err,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return new VoiceError({
|
|
96
|
+
kind: "unknown",
|
|
97
|
+
provider: "openai",
|
|
98
|
+
message: err instanceof Error ? err.message : String(err),
|
|
99
|
+
cause: err,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Heuristic for "is this 400/422 about an unsupported audio format?"
|
|
104
|
+
* OpenAI doesn't have a dedicated error class for format failures, so we
|
|
105
|
+
* sniff the structured fields the SDK exposes (`code`, `param`, `error`)
|
|
106
|
+
* before falling back to `invalid_input`.
|
|
107
|
+
*/
|
|
108
|
+
function looksLikeFormatError(err) {
|
|
109
|
+
const anyErr = err;
|
|
110
|
+
if (anyErr.code === "unsupported_format")
|
|
111
|
+
return true;
|
|
112
|
+
if (anyErr.code === "invalid_value" && anyErr.param === "response_format")
|
|
113
|
+
return true;
|
|
114
|
+
if (anyErr.param === "response_format")
|
|
115
|
+
return true;
|
|
116
|
+
if (anyErr.param === "file" &&
|
|
117
|
+
typeof anyErr.message === "string" &&
|
|
118
|
+
anyErr.message.toLowerCase().includes("format")) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const inner = anyErr.error;
|
|
122
|
+
if (inner !== undefined &&
|
|
123
|
+
inner.type === "invalid_request_error" &&
|
|
124
|
+
inner.code === "invalid_value" &&
|
|
125
|
+
(inner.param === "response_format" || inner.param === "file")) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=translate-error.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static catalog of OpenAI's prebuilt TTS voices. Returned by
|
|
3
|
+
* `OpenAIVoiceProvider.listVoices()`.
|
|
4
|
+
*
|
|
5
|
+
* Convention: `supportedModels === undefined` means "supported by every
|
|
6
|
+
* OpenAI TTS model". Voices that are only available on a subset (currently
|
|
7
|
+
* `marin` and `cedar`, which require `gpt-4o-mini-tts`) declare the
|
|
8
|
+
* intersection explicitly via `supportedModels`.
|
|
9
|
+
*
|
|
10
|
+
* OpenAI does not expose a discovery endpoint for TTS voices; the catalog
|
|
11
|
+
* is updated manually when OpenAI publishes new entries.
|
|
12
|
+
*/
|
|
13
|
+
import type { VoiceInfo } from "@flow-state-dev/core";
|
|
14
|
+
export declare const OPENAI_VOICE_CATALOG: readonly VoiceInfo[];
|
|
15
|
+
//# sourceMappingURL=voice-catalog.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static catalog of OpenAI's prebuilt TTS voices. Returned by
|
|
3
|
+
* `OpenAIVoiceProvider.listVoices()`.
|
|
4
|
+
*
|
|
5
|
+
* Convention: `supportedModels === undefined` means "supported by every
|
|
6
|
+
* OpenAI TTS model". Voices that are only available on a subset (currently
|
|
7
|
+
* `marin` and `cedar`, which require `gpt-4o-mini-tts`) declare the
|
|
8
|
+
* intersection explicitly via `supportedModels`.
|
|
9
|
+
*
|
|
10
|
+
* OpenAI does not expose a discovery endpoint for TTS voices; the catalog
|
|
11
|
+
* is updated manually when OpenAI publishes new entries.
|
|
12
|
+
*/
|
|
13
|
+
export const OPENAI_VOICE_CATALOG = [
|
|
14
|
+
{ id: "alloy", name: "Alloy", provider: "openai" },
|
|
15
|
+
{ id: "ash", name: "Ash", provider: "openai" },
|
|
16
|
+
{ id: "ballad", name: "Ballad", provider: "openai" },
|
|
17
|
+
{ id: "coral", name: "Coral", provider: "openai" },
|
|
18
|
+
{ id: "echo", name: "Echo", provider: "openai" },
|
|
19
|
+
{ id: "fable", name: "Fable", provider: "openai" },
|
|
20
|
+
{ id: "nova", name: "Nova", provider: "openai" },
|
|
21
|
+
{ id: "onyx", name: "Onyx", provider: "openai" },
|
|
22
|
+
{ id: "sage", name: "Sage", provider: "openai" },
|
|
23
|
+
{ id: "shimmer", name: "Shimmer", provider: "openai" },
|
|
24
|
+
{ id: "verse", name: "Verse", provider: "openai" },
|
|
25
|
+
{ id: "marin", name: "Marin", provider: "openai", supportedModels: ["gpt-4o-mini-tts"] },
|
|
26
|
+
{ id: "cedar", name: "Cedar", provider: "openai", supportedModels: ["gpt-4o-mini-tts"] },
|
|
27
|
+
];
|
|
28
|
+
//# sourceMappingURL=voice-catalog.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flow-state-dev/voice-openai",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenAI voice transport for flow-state-dev.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"agents",
|
|
8
|
+
"llm",
|
|
9
|
+
"workflow",
|
|
10
|
+
"typescript",
|
|
11
|
+
"flow-state-dev",
|
|
12
|
+
"voice",
|
|
13
|
+
"openai",
|
|
14
|
+
"realtime",
|
|
15
|
+
"transport"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://flow-state.dev",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/fixpoint-labs/flow-state-dev.git",
|
|
21
|
+
"directory": "packages/voice-openai"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/fixpoint-labs/flow-state-dev/issues"
|
|
25
|
+
},
|
|
26
|
+
"author": "Fixpoint Labs",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"!dist/**/*.map"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"openai": "^6.38.0",
|
|
50
|
+
"@flow-state-dev/core": "0.1.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"vitest": "^3.2.4"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc -p tsconfig.json",
|
|
59
|
+
"typecheck": "node ../../scripts/typecheck.mjs",
|
|
60
|
+
"lint": "echo 'lint not configured yet'",
|
|
61
|
+
"test": "vitest run --root .",
|
|
62
|
+
"test:watch": "vitest --root ."
|
|
63
|
+
}
|
|
64
|
+
}
|