@effect-uai/inworld 0.17.0 → 0.19.0-dev-20261109183959
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/dist/InworldRealtimeSynthesizer.d.mts +1 -1
- package/dist/InworldRealtimeSynthesizer.mjs +2 -2
- package/dist/InworldRealtimeSynthesizer.mjs.map +1 -1
- package/dist/InworldRealtimeTranscriber.d.mts +1 -1
- package/dist/InworldRealtimeTranscriber.mjs +2 -2
- package/dist/InworldRealtimeTranscriber.mjs.map +1 -1
- package/dist/InworldSynthesizer.d.mts +1 -1
- package/dist/InworldSynthesizer.mjs +2 -2
- package/dist/InworldSynthesizer.mjs.map +1 -1
- package/dist/InworldTranscriber.d.mts +1 -1
- package/dist/InworldTranscriber.d.mts.map +1 -1
- package/dist/InworldTranscriber.mjs +5 -4
- package/dist/InworldTranscriber.mjs.map +1 -1
- package/dist/codec-BU4azW_k.d.mts.map +1 -1
- package/dist/codec.mjs +4 -3
- package/dist/codec.mjs.map +1 -1
- package/dist/dist-DHer1cql.mjs +1533 -0
- package/dist/dist-DHer1cql.mjs.map +1 -0
- package/dist/dist-DvYz71Uo.mjs +17472 -0
- package/dist/dist-DvYz71Uo.mjs.map +1 -0
- package/dist/realtimeStt.d.mts +6 -0
- package/dist/realtimeStt.d.mts.map +1 -1
- package/dist/realtimeStt.mjs +18 -19
- package/dist/realtimeStt.mjs.map +1 -1
- package/dist/realtimeStt.test.d.mts +1 -0
- package/dist/realtimeStt.test.mjs +83 -0
- package/dist/realtimeStt.test.mjs.map +1 -0
- package/dist/realtimeTts.d.mts +6 -0
- package/dist/realtimeTts.d.mts.map +1 -1
- package/dist/realtimeTts.mjs +18 -17
- package/dist/realtimeTts.mjs.map +1 -1
- package/dist/realtimeTts.test.d.mts +1 -0
- package/dist/realtimeTts.test.mjs +70 -0
- package/dist/realtimeTts.test.mjs.map +1 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
- package/dist/wsAuth.d.mts +1 -1
- package/dist/wsAuth.mjs.map +1 -1
- package/package.json +5 -5
- package/src/InworldRealtimeSynthesizer.ts +1 -1
- package/src/InworldRealtimeTranscriber.ts +1 -1
- package/src/InworldSynthesizer.ts +1 -1
- package/src/InworldTranscriber.ts +4 -3
- package/src/codec.ts +3 -2
- package/src/realtimeStt.test.ts +82 -0
- package/src/realtimeStt.ts +46 -36
- package/src/realtimeTts.test.ts +74 -0
- package/src/realtimeTts.ts +52 -36
- package/src/wsAuth.ts +1 -1
package/src/codec.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Effect,
|
|
1
|
+
import { Effect, Match, Redacted, Result } from "effect"
|
|
2
|
+
import { Base64 } from "effect/encoding"
|
|
2
3
|
import * as AiError from "@effect-uai/core/AiError"
|
|
3
4
|
import type { AudioFormat } from "@effect-uai/core/Audio"
|
|
4
5
|
import type { InworldAudioEncoding } from "./models.js"
|
|
@@ -82,7 +83,7 @@ export const defaultFormat: AudioFormat = {
|
|
|
82
83
|
// ---------------------------------------------------------------------------
|
|
83
84
|
|
|
84
85
|
export const decodeAudioContent = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
85
|
-
Result.match(
|
|
86
|
+
Result.match(Base64.decode(b64), {
|
|
86
87
|
onSuccess: Effect.succeed,
|
|
87
88
|
onFailure: (cause) =>
|
|
88
89
|
Effect.fail(
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Exit, Fiber, Redacted, Stream } from "effect"
|
|
3
|
+
import { expect } from "vitest"
|
|
4
|
+
import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
|
|
5
|
+
import { streamTranscription } from "./realtimeStt.js"
|
|
6
|
+
|
|
7
|
+
const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 } as const
|
|
8
|
+
|
|
9
|
+
const request = { model: "inworld/inworld-stt-1", inputFormat: pcm }
|
|
10
|
+
|
|
11
|
+
const transcribing = FakeWebSocket.make({
|
|
12
|
+
greeting: [
|
|
13
|
+
{ result: { speechStarted: { startTimeMs: 1500 } } },
|
|
14
|
+
{ result: { transcription: { transcript: "hel", isFinal: false } } },
|
|
15
|
+
{ result: { transcription: { transcript: "hello", isFinal: true } } },
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** Run a transcription against `server`, closing it, if asked, once the frames are in. */
|
|
20
|
+
const transcribe = <E>(
|
|
21
|
+
server: FakeWebSocket.FakeWebSocketServer,
|
|
22
|
+
audioIn: Stream.Stream<Uint8Array, E>,
|
|
23
|
+
close?: { readonly code: number; readonly afterSent?: number },
|
|
24
|
+
) =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const run = streamTranscription({
|
|
27
|
+
apiKey: Redacted.make("test-key"),
|
|
28
|
+
webSocket: server.connect,
|
|
29
|
+
})
|
|
30
|
+
const collector = yield* Effect.forkChild(Effect.exit(Stream.runCollect(run(audioIn, request))))
|
|
31
|
+
if (close !== undefined) {
|
|
32
|
+
yield* server.greeted
|
|
33
|
+
yield* server.awaitSent(close.afterSent ?? 0)
|
|
34
|
+
yield* server.close(close.code)
|
|
35
|
+
}
|
|
36
|
+
return yield* Fiber.join(collector)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("Inworld Realtime STT socket", () => {
|
|
40
|
+
it.live("sends the config before any audio, and ends on a clean close", () =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const server = yield* transcribing
|
|
43
|
+
const exit = yield* transcribe(server, Stream.make(new Uint8Array([1, 2])), {
|
|
44
|
+
code: 1000,
|
|
45
|
+
afterSent: 4,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(exit).toEqual(
|
|
49
|
+
Exit.succeed([
|
|
50
|
+
{ _tag: "speech-started", atSeconds: 1.5 },
|
|
51
|
+
{ _tag: "partial", text: "hel" },
|
|
52
|
+
{ _tag: "final", text: "hello" },
|
|
53
|
+
]),
|
|
54
|
+
)
|
|
55
|
+
const sent = (yield* server.sent) as ReadonlyArray<Record<string, unknown>>
|
|
56
|
+
expect(sent.map((f) => Object.keys(f)[0])).toEqual([
|
|
57
|
+
"transcribeConfig",
|
|
58
|
+
"audioChunk",
|
|
59
|
+
"endTurn",
|
|
60
|
+
"closeStream",
|
|
61
|
+
])
|
|
62
|
+
}).pipe(Effect.scoped),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it.live("fails rather than ending when the connection drops", () =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const server = yield* transcribing
|
|
68
|
+
const exit = yield* transcribe(server, Stream.never, { code: 1011 })
|
|
69
|
+
|
|
70
|
+
expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("Unavailable")
|
|
71
|
+
}).pipe(Effect.scoped),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
it.live("fails with the audio input's own error", () =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const server = yield* transcribing
|
|
77
|
+
const exit = yield* transcribe(server, Stream.fail("microphone unplugged"))
|
|
78
|
+
|
|
79
|
+
expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("microphone unplugged")
|
|
80
|
+
}).pipe(Effect.scoped),
|
|
81
|
+
)
|
|
82
|
+
})
|
package/src/realtimeStt.ts
CHANGED
|
@@ -18,17 +18,27 @@
|
|
|
18
18
|
* Inworld's STT WS sends audio as base64 inside JSON (NOT binary frames),
|
|
19
19
|
* matching the rest of the Inworld API style.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
21
|
+
import { Effect, Match, Option, Predicate, Redacted, Schema, Stream } from "effect"
|
|
22
|
+
import { Base64 } from "effect/encoding"
|
|
23
|
+
import * as Socket from "effect/socket/Socket"
|
|
23
24
|
import * as AiError from "@effect-uai/core/AiError"
|
|
24
25
|
import type { AudioFormat } from "@effect-uai/core/Audio"
|
|
25
26
|
import * as Capabilities from "@effect-uai/core/Capabilities"
|
|
26
27
|
import * as JSONL from "@effect-uai/core/JSONL"
|
|
27
28
|
import type { TranscriptEvent, WordTimestamp } from "@effect-uai/core/Transcript"
|
|
28
29
|
import type { CommonStreamTranscribeRequest } from "@effect-uai/core/Transcriber"
|
|
30
|
+
import * as WebSocketSession from "@effect-uai/core/WebSocketSession"
|
|
29
31
|
import { authedWsConstructor } from "./wsAuth.js"
|
|
30
32
|
|
|
31
|
-
export type Config = {
|
|
33
|
+
export type Config = {
|
|
34
|
+
readonly apiKey: Redacted.Redacted
|
|
35
|
+
readonly baseUrl?: string
|
|
36
|
+
/**
|
|
37
|
+
* Build the socket yourself, for a proxy or an in-memory transport. The
|
|
38
|
+
* default attaches the Basic auth header through the `ws` package.
|
|
39
|
+
*/
|
|
40
|
+
readonly webSocket?: Socket.WebSocketConstructor["Service"]
|
|
41
|
+
}
|
|
32
42
|
|
|
33
43
|
// ---------------------------------------------------------------------------
|
|
34
44
|
// AudioFormat → `audioEncoding` slug for Inworld STT
|
|
@@ -85,7 +95,7 @@ const configFrame = (encoding: WireEncoding, request: CommonStreamTranscribeRequ
|
|
|
85
95
|
})
|
|
86
96
|
|
|
87
97
|
const audioChunkFrame = (bytes: Uint8Array) =>
|
|
88
|
-
JSON.stringify({ audioChunk: { content:
|
|
98
|
+
JSON.stringify({ audioChunk: { content: Base64.encode(bytes) } })
|
|
89
99
|
|
|
90
100
|
const endTurnFrame = JSON.stringify({ endTurn: {} })
|
|
91
101
|
const closeStreamFrame = JSON.stringify({ closeStream: {} })
|
|
@@ -177,14 +187,13 @@ export const wireToEvent = (frame: typeof ServerFrame.Type): TranscriptEvent | u
|
|
|
177
187
|
return undefined
|
|
178
188
|
}
|
|
179
189
|
|
|
180
|
-
|
|
190
|
+
/** One raw text frame to at most one event; unknown or malformed frames yield `undefined`. */
|
|
191
|
+
const frameToEvent = (raw: string): Effect.Effect<TranscriptEvent | undefined> =>
|
|
181
192
|
Effect.gen(function* () {
|
|
182
193
|
const json = yield* JSONL.parseSafe(raw)
|
|
183
|
-
if (json === undefined) return
|
|
194
|
+
if (json === undefined) return undefined
|
|
184
195
|
const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
|
|
185
|
-
|
|
186
|
-
const event = wireToEvent(decoded.value)
|
|
187
|
-
if (event !== undefined) yield* Queue.offer(queue, event)
|
|
196
|
+
return Option.match(decoded, { onNone: () => undefined, onSome: wireToEvent })
|
|
188
197
|
})
|
|
189
198
|
|
|
190
199
|
// ---------------------------------------------------------------------------
|
|
@@ -206,32 +215,33 @@ export const streamTranscription =
|
|
|
206
215
|
reason: "Inworld STT has no free-form prompt field; bias via `biasingTerms`.",
|
|
207
216
|
})
|
|
208
217
|
const encoding = yield* inputFormatToWire(request.inputFormat)
|
|
209
|
-
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const write = yield* socket.writer
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
218
|
+
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)).pipe(
|
|
219
|
+
Effect.provideService(
|
|
220
|
+
Socket.WebSocketConstructor,
|
|
221
|
+
cfg.webSocket ?? authedWsConstructor(cfg.apiKey),
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
const { write } = yield* socket.writer
|
|
225
|
+
const asAiError = WebSocketSession.toAiError("inworld")
|
|
226
|
+
|
|
227
|
+
const events = Stream.fromPull(Socket.readerString(socket)).pipe(
|
|
228
|
+
Stream.scoped,
|
|
229
|
+
Stream.catchIf(WebSocketSession.isCleanClose, () => Stream.empty),
|
|
230
|
+
Stream.mapError(asAiError),
|
|
231
|
+
Stream.mapEffect(frameToEvent),
|
|
232
|
+
Stream.filter(Predicate.isNotUndefined),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// `endTurn` flushes tail audio into a final transcript before `closeStream`.
|
|
236
|
+
const outgoing = Stream.concat(
|
|
237
|
+
Stream.make(configFrame(encoding, request)),
|
|
238
|
+
Stream.concat(
|
|
239
|
+
Stream.map(audioIn, audioChunkFrame),
|
|
240
|
+
Stream.make(endTurnFrame, closeStreamFrame),
|
|
241
|
+
),
|
|
242
|
+
).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
|
|
243
|
+
|
|
244
|
+
// The server's close ends the transcript; the audio running out does not.
|
|
245
|
+
return Stream.merge(events, Stream.drain(outgoing), { haltStrategy: "left" })
|
|
236
246
|
}),
|
|
237
247
|
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Exit, Fiber, Redacted, Stream } from "effect"
|
|
3
|
+
import { Base64 } from "effect/encoding"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
|
|
6
|
+
import { streamSynthesis } from "./realtimeTts.js"
|
|
7
|
+
|
|
8
|
+
const request = { model: "inworld-tts-2", voiceId: "Ashley" } as const
|
|
9
|
+
|
|
10
|
+
const synthesizing = FakeWebSocket.make({
|
|
11
|
+
greeting: [
|
|
12
|
+
{ result: { contextCreated: {} } },
|
|
13
|
+
{ result: { audioChunk: { audioContent: Base64.encode(new Uint8Array([1, 2, 3])) } } },
|
|
14
|
+
{ error: { message: "transient" } },
|
|
15
|
+
{ result: { audioChunk: { audioContent: "" } } },
|
|
16
|
+
{ result: { contextClosed: {} } },
|
|
17
|
+
],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** Run a synthesis against `server`, closing it, if asked, once the frames are in. */
|
|
21
|
+
const synthesize = <E>(
|
|
22
|
+
server: FakeWebSocket.FakeWebSocketServer,
|
|
23
|
+
textIn: Stream.Stream<string, E>,
|
|
24
|
+
close?: { readonly code: number; readonly afterSent?: number },
|
|
25
|
+
) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const run = streamSynthesis({ apiKey: Redacted.make("test-key"), webSocket: server.connect })
|
|
28
|
+
const collector = yield* Effect.forkChild(Effect.exit(Stream.runCollect(run(textIn, request))))
|
|
29
|
+
if (close !== undefined) {
|
|
30
|
+
yield* server.greeted
|
|
31
|
+
yield* server.awaitSent(close.afterSent ?? 0)
|
|
32
|
+
yield* server.close(close.code)
|
|
33
|
+
}
|
|
34
|
+
return yield* Fiber.join(collector)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("Inworld Realtime TTS socket", () => {
|
|
38
|
+
it.live("creates the context before any text, and ends on a clean close", () =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const server = yield* synthesizing
|
|
41
|
+
const exit = yield* synthesize(server, Stream.make("Hello", "", " world"), {
|
|
42
|
+
code: 1000,
|
|
43
|
+
afterSent: 4,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(exit).toEqual(Exit.succeed([{ bytes: new Uint8Array([1, 2, 3]) }]))
|
|
47
|
+
const sent = (yield* server.sent) as ReadonlyArray<Record<string, unknown>>
|
|
48
|
+
expect(sent.map((f) => Object.keys(f)[0])).toEqual([
|
|
49
|
+
"create",
|
|
50
|
+
"send_text",
|
|
51
|
+
"send_text",
|
|
52
|
+
"close_context",
|
|
53
|
+
])
|
|
54
|
+
}).pipe(Effect.scoped),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
it.live("fails rather than ending when the connection drops", () =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const server = yield* synthesizing
|
|
60
|
+
const exit = yield* synthesize(server, Stream.never, { code: 1011 })
|
|
61
|
+
|
|
62
|
+
expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("Unavailable")
|
|
63
|
+
}).pipe(Effect.scoped),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
it.live("fails with the text input's own error", () =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const server = yield* synthesizing
|
|
69
|
+
const exit = yield* synthesize(server, Stream.fail("upstream llm failed"))
|
|
70
|
+
|
|
71
|
+
expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("upstream llm failed")
|
|
72
|
+
}).pipe(Effect.scoped),
|
|
73
|
+
)
|
|
74
|
+
})
|
package/src/realtimeTts.ts
CHANGED
|
@@ -19,17 +19,26 @@
|
|
|
19
19
|
* Multi-context (`contextId`) is not surfaced here — one logical
|
|
20
20
|
* utterance per call.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
23
|
-
import * as Socket from "effect/
|
|
22
|
+
import { Effect, Option, Predicate, Redacted, Schema, Stream } from "effect"
|
|
23
|
+
import * as Socket from "effect/socket/Socket"
|
|
24
24
|
import * as AiError from "@effect-uai/core/AiError"
|
|
25
25
|
import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
|
|
26
26
|
import * as JSONL from "@effect-uai/core/JSONL"
|
|
27
27
|
import type { CustomPronunciation } from "@effect-uai/core/SpeechSynthesizer"
|
|
28
|
+
import * as WebSocketSession from "@effect-uai/core/WebSocketSession"
|
|
28
29
|
import { audioConfigFor, decodeAudioContent, defaultFormat } from "./codec.js"
|
|
29
30
|
import type { InworldDeliveryMode, InworldTtsModel, InworldVoiceId } from "./models.js"
|
|
30
31
|
import { authedWsConstructor } from "./wsAuth.js"
|
|
31
32
|
|
|
32
|
-
export type Config = {
|
|
33
|
+
export type Config = {
|
|
34
|
+
readonly apiKey: Redacted.Redacted
|
|
35
|
+
readonly baseUrl?: string
|
|
36
|
+
/**
|
|
37
|
+
* Build the socket yourself, for a proxy or an in-memory transport. The
|
|
38
|
+
* default attaches the Basic auth header through the `ws` package.
|
|
39
|
+
*/
|
|
40
|
+
readonly webSocket?: Socket.WebSocketConstructor["Service"]
|
|
41
|
+
}
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Incremental-text-in request. Mirrors the sync request minus `text` (which
|
|
@@ -110,21 +119,25 @@ const ServerFrame = Schema.Struct({
|
|
|
110
119
|
})
|
|
111
120
|
const decodeServerFrame = Schema.decodeUnknownEffect(ServerFrame)
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
/** One raw text frame to at most one audio chunk; error frames are logged, not raised. */
|
|
123
|
+
const frameToChunk = (raw: string): Effect.Effect<AudioChunk | undefined> =>
|
|
114
124
|
Effect.gen(function* () {
|
|
115
125
|
const json = yield* JSONL.parseSafe(raw)
|
|
116
|
-
if (json === undefined) return
|
|
126
|
+
if (json === undefined) return undefined
|
|
117
127
|
const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
|
|
118
|
-
if (decoded
|
|
128
|
+
if (Option.isNone(decoded)) return undefined
|
|
119
129
|
const frame = decoded.value
|
|
120
130
|
if (frame.error !== undefined) {
|
|
121
131
|
yield* Effect.logWarning("[inworld-tts] server error frame", { error: frame.error })
|
|
122
|
-
return
|
|
132
|
+
return undefined
|
|
123
133
|
}
|
|
124
134
|
const audio = frame.result?.audioChunk?.audioContent
|
|
125
|
-
if (audio === undefined || audio === "") return
|
|
135
|
+
if (audio === undefined || audio === "") return undefined
|
|
126
136
|
const bytes = yield* decodeAudioContent(audio).pipe(Effect.option)
|
|
127
|
-
|
|
137
|
+
return Option.match(bytes, {
|
|
138
|
+
onNone: () => undefined,
|
|
139
|
+
onSome: (b): AudioChunk => ({ bytes: b }),
|
|
140
|
+
})
|
|
128
141
|
})
|
|
129
142
|
|
|
130
143
|
// ---------------------------------------------------------------------------
|
|
@@ -153,32 +166,35 @@ export const streamSynthesis =
|
|
|
153
166
|
)
|
|
154
167
|
}
|
|
155
168
|
const create = yield* createFrame(request)
|
|
156
|
-
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const write = yield* socket.writer
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
169
|
+
const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)).pipe(
|
|
170
|
+
Effect.provideService(
|
|
171
|
+
Socket.WebSocketConstructor,
|
|
172
|
+
cfg.webSocket ?? authedWsConstructor(cfg.apiKey),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
const { write } = yield* socket.writer
|
|
176
|
+
const asAiError = WebSocketSession.toAiError("inworld")
|
|
177
|
+
|
|
178
|
+
const chunks = Stream.fromPull(Socket.readerString(socket)).pipe(
|
|
179
|
+
Stream.scoped,
|
|
180
|
+
Stream.catchIf(WebSocketSession.isCleanClose, () => Stream.empty),
|
|
181
|
+
Stream.mapError(asAiError),
|
|
182
|
+
Stream.mapEffect(frameToChunk),
|
|
183
|
+
Stream.filter(Predicate.isNotUndefined),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const outgoing = Stream.concat(
|
|
187
|
+
Stream.make(create),
|
|
188
|
+
Stream.concat(
|
|
189
|
+
textIn.pipe(
|
|
190
|
+
Stream.filter((text) => text.length > 0),
|
|
191
|
+
Stream.map(sendTextFrame),
|
|
192
|
+
),
|
|
193
|
+
Stream.make(closeContextFrame),
|
|
194
|
+
),
|
|
195
|
+
).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
|
|
196
|
+
|
|
197
|
+
// The server drains remaining audio after `close_context`, then closes.
|
|
198
|
+
return Stream.merge(chunks, Stream.drain(outgoing), { haltStrategy: "left" })
|
|
183
199
|
}),
|
|
184
200
|
)
|
package/src/wsAuth.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* we use the `ws` peer dep (Node/Bun only).
|
|
10
10
|
*/
|
|
11
11
|
import { Redacted } from "effect"
|
|
12
|
-
import type * as Socket from "effect/
|
|
12
|
+
import type * as Socket from "effect/socket/Socket"
|
|
13
13
|
import { WebSocket as WSWebSocket } from "ws"
|
|
14
14
|
import { authHeader } from "./codec.js"
|
|
15
15
|
|