@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/InworldRealtimeSynthesizer.d.mts +30 -0
  3. package/dist/InworldRealtimeSynthesizer.d.mts.map +1 -0
  4. package/dist/InworldRealtimeSynthesizer.mjs +43 -0
  5. package/dist/InworldRealtimeSynthesizer.mjs.map +1 -0
  6. package/dist/InworldRealtimeTranscriber.d.mts +30 -0
  7. package/dist/InworldRealtimeTranscriber.d.mts.map +1 -0
  8. package/dist/InworldRealtimeTranscriber.mjs +37 -0
  9. package/dist/InworldRealtimeTranscriber.mjs.map +1 -0
  10. package/dist/InworldSynthesizer.d.mts +71 -0
  11. package/dist/InworldSynthesizer.d.mts.map +1 -0
  12. package/dist/InworldSynthesizer.mjs +131 -0
  13. package/dist/InworldSynthesizer.mjs.map +1 -0
  14. package/dist/InworldTranscriber.d.mts +48 -0
  15. package/dist/InworldTranscriber.d.mts.map +1 -0
  16. package/dist/InworldTranscriber.mjs +116 -0
  17. package/dist/InworldTranscriber.mjs.map +1 -0
  18. package/dist/chunk-CfYAbeIz.mjs +13 -0
  19. package/dist/codec-C-2ssVAz.d.mts +35 -0
  20. package/dist/codec-C-2ssVAz.d.mts.map +1 -0
  21. package/dist/codec.d.mts +2 -0
  22. package/dist/codec.mjs +100 -0
  23. package/dist/codec.mjs.map +1 -0
  24. package/dist/index.d.mts +7 -0
  25. package/dist/index.mjs +7 -0
  26. package/dist/models-CPLZDcgt.d.mts +47 -0
  27. package/dist/models-CPLZDcgt.d.mts.map +1 -0
  28. package/dist/models.d.mts +2 -0
  29. package/dist/models.mjs +1 -0
  30. package/dist/realtimeStt.d.mts +38 -0
  31. package/dist/realtimeStt.d.mts.map +1 -0
  32. package/dist/realtimeStt.mjs +145 -0
  33. package/dist/realtimeStt.mjs.map +1 -0
  34. package/dist/realtimeTts.d.mts +29 -0
  35. package/dist/realtimeTts.d.mts.map +1 -0
  36. package/dist/realtimeTts.mjs +94 -0
  37. package/dist/realtimeTts.mjs.map +1 -0
  38. package/dist/wsAuth.d.mts +8 -0
  39. package/dist/wsAuth.d.mts.map +1 -0
  40. package/dist/wsAuth.mjs +8 -0
  41. package/dist/wsAuth.mjs.map +1 -0
  42. package/package.json +80 -0
  43. package/src/InworldRealtimeSynthesizer.ts +76 -0
  44. package/src/InworldRealtimeTranscriber.ts +64 -0
  45. package/src/InworldSynthesizer.ts +235 -0
  46. package/src/InworldTranscriber.ts +225 -0
  47. package/src/codec.ts +141 -0
  48. package/src/index.ts +6 -0
  49. package/src/models.ts +69 -0
  50. package/src/realtimeStt.ts +236 -0
  51. package/src/realtimeTts.ts +167 -0
  52. 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