@effect-uai/inworld 0.4.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/LICENSE +21 -0
- package/dist/InworldRealtimeSynthesizer.d.mts +30 -0
- package/dist/InworldRealtimeSynthesizer.d.mts.map +1 -0
- package/dist/InworldRealtimeSynthesizer.mjs +43 -0
- package/dist/InworldRealtimeSynthesizer.mjs.map +1 -0
- package/dist/InworldRealtimeTranscriber.d.mts +30 -0
- package/dist/InworldRealtimeTranscriber.d.mts.map +1 -0
- package/dist/InworldRealtimeTranscriber.mjs +37 -0
- package/dist/InworldRealtimeTranscriber.mjs.map +1 -0
- package/dist/InworldSynthesizer.d.mts +71 -0
- package/dist/InworldSynthesizer.d.mts.map +1 -0
- package/dist/InworldSynthesizer.mjs +131 -0
- package/dist/InworldSynthesizer.mjs.map +1 -0
- package/dist/InworldTranscriber.d.mts +48 -0
- package/dist/InworldTranscriber.d.mts.map +1 -0
- package/dist/InworldTranscriber.mjs +116 -0
- package/dist/InworldTranscriber.mjs.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/codec-C-2ssVAz.d.mts +35 -0
- package/dist/codec-C-2ssVAz.d.mts.map +1 -0
- package/dist/codec.d.mts +2 -0
- package/dist/codec.mjs +100 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/models-CPLZDcgt.d.mts +47 -0
- package/dist/models-CPLZDcgt.d.mts.map +1 -0
- package/dist/models.d.mts +2 -0
- package/dist/models.mjs +1 -0
- package/dist/realtimeStt.d.mts +38 -0
- package/dist/realtimeStt.d.mts.map +1 -0
- package/dist/realtimeStt.mjs +145 -0
- package/dist/realtimeStt.mjs.map +1 -0
- package/dist/realtimeTts.d.mts +29 -0
- package/dist/realtimeTts.d.mts.map +1 -0
- package/dist/realtimeTts.mjs +94 -0
- package/dist/realtimeTts.mjs.map +1 -0
- package/dist/wsAuth.d.mts +8 -0
- package/dist/wsAuth.d.mts.map +1 -0
- package/dist/wsAuth.mjs +8 -0
- package/dist/wsAuth.mjs.map +1 -0
- package/package.json +80 -0
- package/src/InworldRealtimeSynthesizer.ts +76 -0
- package/src/InworldRealtimeTranscriber.ts +64 -0
- package/src/InworldSynthesizer.ts +235 -0
- package/src/InworldTranscriber.ts +225 -0
- package/src/codec.ts +141 -0
- package/src/index.ts +6 -0
- package/src/models.ts +69 -0
- package/src/realtimeStt.ts +236 -0
- package/src/realtimeTts.ts +167 -0
- package/src/wsAuth.ts +21 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inworld Realtime TTS — `wss://api.inworld.ai/tts/v1/voice:streamBidirectional`.
|
|
3
|
+
*
|
|
4
|
+
* Auth: `Authorization: Basic <API_KEY>` on the WS upgrade header (matches
|
|
5
|
+
* Inworld's own JS sample). The docs mention a `?Authorization=…` query
|
|
6
|
+
* variant but the server rejects it in practice. Setting headers needs the
|
|
7
|
+
* `ws` peer dep (Node/Bun); see `./wsAuth.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Wire shape (single-context per call):
|
|
10
|
+
* client → server:
|
|
11
|
+
* `{ "create": { voiceId, modelId, audioConfig, ... } }`
|
|
12
|
+
* `{ "send_text": { "text": "..." } }` (repeated)
|
|
13
|
+
* `{ "close_context": {} }` (text stream end)
|
|
14
|
+
* server → client:
|
|
15
|
+
* `{ "result": { "contextCreated": {...} } }` (handshake ack)
|
|
16
|
+
* `{ "result": { "audioChunk": { "audioContent": "<b64>" } } }` (×N)
|
|
17
|
+
* `{ "result": { "contextClosed": {...} } }` (final)
|
|
18
|
+
*
|
|
19
|
+
* Multi-context (`contextId`) is not surfaced here — one logical
|
|
20
|
+
* utterance per call.
|
|
21
|
+
*/
|
|
22
|
+
import { Cause, Effect, Queue, Redacted, Schema, Stream } from "effect"
|
|
23
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
24
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
25
|
+
import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
|
|
26
|
+
import * as JSONL from "@effect-uai/core/JSONL"
|
|
27
|
+
import { audioConfigFor, decodeAudioContent, defaultFormat } from "./codec.js"
|
|
28
|
+
import type { InworldDeliveryMode, InworldTtsModel, InworldVoiceId } from "./models.js"
|
|
29
|
+
import { authedWsConstructor } from "./wsAuth.js"
|
|
30
|
+
|
|
31
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Incremental-text-in request. Mirrors the sync request minus `text` (which
|
|
35
|
+
* comes from the input stream).
|
|
36
|
+
*/
|
|
37
|
+
export type StreamSynthesizeRequest = {
|
|
38
|
+
readonly model: InworldTtsModel
|
|
39
|
+
readonly voiceId: InworldVoiceId
|
|
40
|
+
readonly outputFormat?: AudioFormat
|
|
41
|
+
readonly languageCode?: string
|
|
42
|
+
readonly temperature?: number
|
|
43
|
+
readonly deliveryMode?: InworldDeliveryMode
|
|
44
|
+
readonly applyTextNormalization?: "ON" | "OFF"
|
|
45
|
+
readonly speed?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// URL + frame builders
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export const buildWsUrl = (cfg: Config) => {
|
|
53
|
+
const wsBase = (cfg.baseUrl ?? "https://api.inworld.ai").replace(/^http/, "ws")
|
|
54
|
+
return `${wsBase}/tts/v1/voice:streamBidirectional`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// TTS WS uses snake_case outbound (per Inworld's own JS sample). The
|
|
58
|
+
// camelCase helper from `codec.ts` is built for the REST/JSON sync paths;
|
|
59
|
+
// rewire the field names here for the WS path. Server responses come back
|
|
60
|
+
// camelCase regardless — see `ResultBody` below.
|
|
61
|
+
const audioConfigSnake = (request: StreamSynthesizeRequest) =>
|
|
62
|
+
Effect.map(audioConfigFor(request.outputFormat ?? defaultFormat, request.speed), (cfg) => ({
|
|
63
|
+
audio_encoding: cfg.audioEncoding,
|
|
64
|
+
...(cfg.sampleRateHertz !== undefined && { sample_rate_hertz: cfg.sampleRateHertz }),
|
|
65
|
+
...(cfg.bitRate !== undefined && { bit_rate: cfg.bitRate }),
|
|
66
|
+
...(cfg.speakingRate !== undefined && { speaking_rate: cfg.speakingRate }),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const createFrame = (request: StreamSynthesizeRequest) =>
|
|
70
|
+
Effect.map(audioConfigSnake(request), (audio_config) =>
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
create: {
|
|
73
|
+
voice_id: request.voiceId,
|
|
74
|
+
model_id: request.model,
|
|
75
|
+
audio_config,
|
|
76
|
+
...(request.languageCode !== undefined && { language: request.languageCode }),
|
|
77
|
+
...(request.deliveryMode !== undefined && { delivery_mode: request.deliveryMode }),
|
|
78
|
+
...(request.temperature !== undefined && { temperature: request.temperature }),
|
|
79
|
+
...(request.applyTextNormalization !== undefined && {
|
|
80
|
+
apply_text_normalization: request.applyTextNormalization,
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// `flush_context: {}` inside `send_text` nudges the server to flush
|
|
87
|
+
// pending audio promptly — matches the low-latency sample.
|
|
88
|
+
const sendTextFrame = (text: string) => JSON.stringify({ send_text: { text, flush_context: {} } })
|
|
89
|
+
const closeContextFrame = JSON.stringify({ close_context: {} })
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Wire schema (server → client)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const ResultBody = Schema.Struct({
|
|
96
|
+
audioChunk: Schema.optional(Schema.Struct({ audioContent: Schema.String })),
|
|
97
|
+
contextCreated: Schema.optional(Schema.Unknown),
|
|
98
|
+
flushCompleted: Schema.optional(Schema.Unknown),
|
|
99
|
+
contextClosed: Schema.optional(Schema.Unknown),
|
|
100
|
+
status: Schema.optional(Schema.Unknown),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const ServerFrame = Schema.Struct({
|
|
104
|
+
result: Schema.optional(ResultBody),
|
|
105
|
+
error: Schema.optional(Schema.Unknown),
|
|
106
|
+
})
|
|
107
|
+
const decodeServerFrame = Schema.decodeUnknownEffect(ServerFrame)
|
|
108
|
+
|
|
109
|
+
const handleServerFrame = (queue: Queue.Queue<AudioChunk, Cause.Done>) => (raw: string) =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
112
|
+
if (json === undefined) return
|
|
113
|
+
const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
|
|
114
|
+
if (decoded._tag === "None") return
|
|
115
|
+
const frame = decoded.value
|
|
116
|
+
if (frame.error !== undefined) {
|
|
117
|
+
yield* Effect.logWarning("[inworld-tts] server error frame", { error: frame.error })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const audio = frame.result?.audioChunk?.audioContent
|
|
121
|
+
if (audio === undefined || audio === "") return
|
|
122
|
+
const bytes = yield* decodeAudioContent(audio).pipe(Effect.option)
|
|
123
|
+
if (bytes._tag === "Some") yield* Queue.offer(queue, { bytes: bytes.value })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Stream<string> → Stream<AudioChunk>
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export const streamSynthesis =
|
|
131
|
+
(cfg: Config) =>
|
|
132
|
+
<E, R>(
|
|
133
|
+
textIn: Stream.Stream<string, E, R>,
|
|
134
|
+
request: StreamSynthesizeRequest,
|
|
135
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
|
|
136
|
+
Stream.unwrap(
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const create = yield* createFrame(request)
|
|
139
|
+
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg), {
|
|
140
|
+
// Effect's Socket treats all close codes as errors by default —
|
|
141
|
+
// whitelist standard clean-close codes (1000 / 1001 / 1005).
|
|
142
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
143
|
+
}).pipe(Effect.provideService(Socket.WebSocketConstructor, authedWsConstructor(cfg.apiKey)))
|
|
144
|
+
const queue = yield* Queue.bounded<AudioChunk, Cause.Done>(64)
|
|
145
|
+
const write = yield* socket.writer
|
|
146
|
+
|
|
147
|
+
// Writer: BOS `create` → drain text as `send_text` → `close_context`.
|
|
148
|
+
// The reader fiber surfaces the remaining audio drained by the server
|
|
149
|
+
// post-close and shuts the queue when the WS upstream closes.
|
|
150
|
+
yield* Effect.gen(function* () {
|
|
151
|
+
yield* write(create)
|
|
152
|
+
yield* Stream.runForEach(textIn, (text) =>
|
|
153
|
+
text.length === 0 ? Effect.void : write(sendTextFrame(text)),
|
|
154
|
+
)
|
|
155
|
+
yield* write(closeContextFrame)
|
|
156
|
+
}).pipe(Effect.ignore, Effect.forkScoped)
|
|
157
|
+
|
|
158
|
+
// `Queue.end` flushes pending chunks then fails the next take with
|
|
159
|
+
// `Done` — clean stream end. `Queue.shutdown` would CLEAR queued items
|
|
160
|
+
// and interrupt pending takes (wrong for graceful teardown).
|
|
161
|
+
yield* socket
|
|
162
|
+
.runString(handleServerFrame(queue))
|
|
163
|
+
.pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
|
|
164
|
+
|
|
165
|
+
return Stream.fromQueue(queue)
|
|
166
|
+
}),
|
|
167
|
+
)
|
package/src/wsAuth.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inworld WS auth helper. The Inworld realtime endpoints (`/tts/...` and
|
|
3
|
+
* `/stt/...`) require `Authorization: Basic <API_KEY>` on the WS upgrade
|
|
4
|
+
* — the docs document a `?Authorization=…` query-param variant, but in
|
|
5
|
+
* practice the server returns `authentication is required` for that
|
|
6
|
+
* path. Header auth is what Inworld's own samples use.
|
|
7
|
+
*
|
|
8
|
+
* Headers on a WS upgrade aren't settable via `globalThis.WebSocket`, so
|
|
9
|
+
* we use the `ws` peer dep (Node/Bun only).
|
|
10
|
+
*/
|
|
11
|
+
import { Redacted } from "effect"
|
|
12
|
+
import type * as Socket from "effect/unstable/socket/Socket"
|
|
13
|
+
import { WebSocket as WSWebSocket } from "ws"
|
|
14
|
+
import { authHeader } from "./codec.js"
|
|
15
|
+
|
|
16
|
+
export const authedWsConstructor =
|
|
17
|
+
(apiKey: Redacted.Redacted): Socket.WebSocketConstructor["Service"] =>
|
|
18
|
+
(url) =>
|
|
19
|
+
new WSWebSocket(url, undefined, {
|
|
20
|
+
headers: { Authorization: authHeader(apiKey) },
|
|
21
|
+
}) as unknown as globalThis.WebSocket
|