@effect-uai/mistral 0.17.0 → 0.18.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 (53) hide show
  1. package/README.md +3 -3
  2. package/dist/Mistral.d.mts +1 -1
  3. package/dist/Mistral.mjs +2 -2
  4. package/dist/Mistral.mjs.map +1 -1
  5. package/dist/MistralRealtimeTranscriber.d.mts +2 -2
  6. package/dist/MistralRealtimeTranscriber.mjs +2 -2
  7. package/dist/MistralRealtimeTranscriber.mjs.map +1 -1
  8. package/dist/MistralSynthesizer.d.mts +1 -1
  9. package/dist/MistralSynthesizer.d.mts.map +1 -1
  10. package/dist/MistralSynthesizer.mjs +5 -4
  11. package/dist/MistralSynthesizer.mjs.map +1 -1
  12. package/dist/MistralTranscriber.d.mts +1 -1
  13. package/dist/MistralTranscriber.mjs +2 -2
  14. package/dist/MistralTranscriber.mjs.map +1 -1
  15. package/dist/audioCodec.d.mts.map +1 -1
  16. package/dist/audioCodec.mjs +3 -2
  17. package/dist/audioCodec.mjs.map +1 -1
  18. package/dist/codec.d.mts.map +1 -1
  19. package/dist/codec.mjs +4 -3
  20. package/dist/codec.mjs.map +1 -1
  21. package/dist/codec.test.mjs +1 -1
  22. package/dist/dist-DHer1cql.mjs +1533 -0
  23. package/dist/dist-DHer1cql.mjs.map +1 -0
  24. package/dist/http.d.mts +1 -1
  25. package/dist/http.mjs.map +1 -1
  26. package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → index.C4tKZ0yW-CUBm3Bmz.mjs} +9446 -5818
  27. package/dist/index.C4tKZ0yW-CUBm3Bmz.mjs.map +1 -0
  28. package/dist/onHalt.test.mjs +2 -2
  29. package/dist/onHalt.test.mjs.map +1 -1
  30. package/dist/{realtimeStt-BcthMc57.d.mts → realtimeStt-CcuQ6EZS.d.mts} +7 -1
  31. package/dist/realtimeStt-CcuQ6EZS.d.mts.map +1 -0
  32. package/dist/realtimeStt.d.mts +1 -1
  33. package/dist/realtimeStt.mjs +27 -30
  34. package/dist/realtimeStt.mjs.map +1 -1
  35. package/dist/realtimeStt.test.d.mts +1 -0
  36. package/dist/realtimeStt.test.mjs +298 -0
  37. package/dist/realtimeStt.test.mjs.map +1 -0
  38. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
  39. package/package.json +5 -5
  40. package/src/Mistral.ts +1 -1
  41. package/src/MistralRealtimeTranscriber.ts +1 -1
  42. package/src/MistralSynthesizer.ts +4 -13
  43. package/src/MistralTranscriber.ts +1 -1
  44. package/src/audioCodec.ts +3 -2
  45. package/src/codec.ts +3 -2
  46. package/src/http.ts +1 -1
  47. package/src/onHalt.test.ts +1 -1
  48. package/src/realtimeStt.test.ts +116 -0
  49. package/src/realtimeStt.ts +68 -64
  50. package/dist/magic-string.es-CUAnjKYC.mjs +0 -1014
  51. package/dist/magic-string.es-CUAnjKYC.mjs.map +0 -1
  52. package/dist/realtimeStt-BcthMc57.d.mts.map +0 -1
  53. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/mistral",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Mistral provider for @effect-uai/core: chat-completions plus Voxtral speech-to-text and text-to-speech.",
5
5
  "keywords": [
6
6
  "ai",
@@ -59,18 +59,18 @@
59
59
  "access": "public"
60
60
  },
61
61
  "dependencies": {
62
- "@effect-uai/chat-completions": "0.17.0"
62
+ "@effect-uai/chat-completions": "0.18.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/ws": "^8.5.13",
66
- "effect": "4.0.0-rc.111",
66
+ "effect": "4.0.1",
67
67
  "typescript": "^7.0.2",
68
68
  "ws": "^8.18.0",
69
- "@effect-uai/core": "0.17.0"
69
+ "@effect-uai/core": "0.18.0"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "@effect-uai/core": ">=0.2.0 <1",
73
- "effect": ">=4.0.0-rc.111 <5.0.0",
73
+ "effect": ">=4.0.1 <5.0.0",
74
74
  "ws": "^8.0.0"
75
75
  },
76
76
  "peerDependenciesMeta": {
package/src/Mistral.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect, Layer, Option, Redacted, Result, Schema, Stream } from "effect"
2
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
2
+ import { HttpClient, HttpClientRequest } from "effect/http"
3
3
  import * as AiError from "@effect-uai/core/AiError"
4
4
  import * as Capabilities from "@effect-uai/core/Capabilities"
5
5
  import {
@@ -9,7 +9,7 @@
9
9
  * header the WS upgrade requires.
10
10
  */
11
11
  import { Effect, Layer } from "effect"
12
- import { HttpClient } from "effect/unstable/http"
12
+ import { HttpClient } from "effect/http"
13
13
  import {
14
14
  type CommonTranscribeRequest,
15
15
  SttStreaming,
@@ -1,15 +1,6 @@
1
- import {
2
- Array as Arr,
3
- Context,
4
- Effect,
5
- Encoding,
6
- Layer,
7
- Redacted,
8
- Result,
9
- Schema,
10
- Stream,
11
- } from "effect"
12
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
1
+ import { Array as Arr, Context, Effect, Layer, Redacted, Result, Schema, Stream } from "effect"
2
+ import { Base64 } from "effect/encoding"
3
+ import { HttpClient, HttpClientRequest } from "effect/http"
13
4
  import * as AiError from "@effect-uai/core/AiError"
14
5
  import type { AudioBlob, AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
15
6
  import * as Capabilities from "@effect-uai/core/Capabilities"
@@ -140,7 +131,7 @@ const WireResponse = Schema.Struct({ audio_data: Schema.String })
140
131
  const decodeResponse = Schema.decodeUnknownEffect(WireResponse)
141
132
 
142
133
  const decodeAudioData = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
143
- Result.match(Encoding.decodeBase64(b64), {
134
+ Result.match(Base64.decode(b64), {
144
135
  onSuccess: Effect.succeed,
145
136
  onFailure: (cause) =>
146
137
  Effect.fail(
@@ -1,5 +1,5 @@
1
1
  import { Array as Arr, Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
2
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
2
+ import { HttpClient, HttpClientRequest } from "effect/http"
3
3
  import * as AiError from "@effect-uai/core/AiError"
4
4
  import * as Capabilities from "@effect-uai/core/Capabilities"
5
5
  import { isAudioUrl } from "@effect-uai/core/Audio"
package/src/audioCodec.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Array as Arr, Effect, Encoding, Match, Option, Result } from "effect"
1
+ import { Array as Arr, Effect, Match, Option, Result } from "effect"
2
+ import { Base64 } from "effect/encoding"
2
3
  import * as AiError from "@effect-uai/core/AiError"
3
4
  import type { AudioFormat, AudioSource } from "@effect-uai/core/Audio"
4
5
 
@@ -7,7 +8,7 @@ import type { AudioFormat, AudioSource } from "@effect-uai/core/Audio"
7
8
  // ---------------------------------------------------------------------------
8
9
 
9
10
  const decodeBase64ToBytes = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
10
- Result.match(Encoding.decodeBase64(b64), {
11
+ Result.match(Base64.decode(b64), {
11
12
  onSuccess: Effect.succeed,
12
13
  onFailure: (cause) =>
13
14
  Effect.fail(new AiError.InvalidRequest({ provider: "mistral", param: "audio", raw: cause })),
package/src/codec.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Array as Arr, Effect, Encoding, Match, Option, Result } from "effect"
1
+ import { Array as Arr, Effect, Match, Option, Result } from "effect"
2
+ import { Base64 } from "effect/encoding"
2
3
  import type { ContentBlock, HistoryItem, InputImage } from "@effect-uai/core/Items"
3
4
 
4
5
  // The streaming decoder, tool encoding, and terminal-turn assembly are the
@@ -37,7 +38,7 @@ type WireMessage = {
37
38
  const imageSourceToUrl = Match.type<InputImage["source"]>().pipe(
38
39
  Match.tag("url", (s) => s.url),
39
40
  Match.tag("base64", (s) => `data:${s.mimeType};base64,${s.base64}`),
40
- Match.tag("bytes", (s) => `data:${s.mimeType};base64,${Encoding.encodeBase64(s.bytes)}`),
41
+ Match.tag("bytes", (s) => `data:${s.mimeType};base64,${Base64.encode(s.bytes)}`),
41
42
  Match.exhaustive,
42
43
  )
43
44
 
package/src/http.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Effect, Match } from "effect"
2
- import { HttpClientRequest } from "effect/unstable/http"
2
+ import { HttpClientRequest } from "effect/http"
3
3
  import * as AiError from "@effect-uai/core/AiError"
4
4
  import * as Multipart from "@effect-uai/core/Multipart"
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { Effect, Exit, Redacted, Stream } from "effect"
2
- import { HttpClient, HttpClientResponse } from "effect/unstable/http"
2
+ import { HttpClient, HttpClientResponse } from "effect/http"
3
3
  import { describe, expect, it } from "vitest"
4
4
  import { TurnEvent } from "@effect-uai/core/Turn"
5
5
  import { make as makeMistral } from "./Mistral.js"
@@ -0,0 +1,116 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Exit, Fiber, Redacted, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
6
+ import { streamTranscription } from "./realtimeStt.js"
7
+
8
+ const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 } as const
9
+
10
+ const request = { model: "voxtral-mini-transcribe-realtime-2602", inputFormat: pcm }
11
+
12
+ const sessionCreated = {
13
+ type: "session.created",
14
+ session: { request_id: "req_1", model: request.model },
15
+ }
16
+
17
+ const delta = (text: string) => ({ type: "transcription.text.delta", text })
18
+
19
+ const start = <E>(
20
+ server: FakeWebSocket.FakeWebSocketServer,
21
+ audioIn: Stream.Stream<Uint8Array, E>,
22
+ ) =>
23
+ streamTranscription({ apiKey: Redacted.make("test-key"), webSocket: server.connect })(
24
+ audioIn,
25
+ request,
26
+ ).pipe(Stream.runCollect, Effect.exit, Effect.forkChild)
27
+
28
+ /** Run a transcription against `server`, closing it, if asked, once the frames are in. */
29
+ const transcribe = <E>(
30
+ server: FakeWebSocket.FakeWebSocketServer,
31
+ audioIn: Stream.Stream<Uint8Array, E>,
32
+ close?: { readonly code: number; readonly afterSent?: number },
33
+ ) =>
34
+ Effect.gen(function* () {
35
+ const collector = yield* start(server, audioIn)
36
+ if (close !== undefined) {
37
+ yield* server.greeted
38
+ yield* server.awaitSent(close.afterSent ?? 0)
39
+ yield* server.close(close.code)
40
+ }
41
+ return yield* Fiber.join(collector)
42
+ })
43
+
44
+ describe("Mistral Realtime STT socket", () => {
45
+ it.live("configures the session before any audio, and ends on a clean close", () =>
46
+ Effect.gen(function* () {
47
+ const server = yield* FakeWebSocket.make({
48
+ greeting: [
49
+ sessionCreated,
50
+ delta("hel"),
51
+ delta("lo"),
52
+ { type: "transcription.done", text: "hello", model: request.model, segments: [] },
53
+ { type: "error", error: { message: "slow down", code: 3001 } },
54
+ ],
55
+ })
56
+ const exit = yield* transcribe(server, Stream.make(new Uint8Array([1, 2])), {
57
+ code: 1000,
58
+ afterSent: 3,
59
+ })
60
+
61
+ expect(exit).toEqual(
62
+ Exit.succeed([
63
+ { _tag: "partial", text: "hel" },
64
+ { _tag: "partial", text: "hello" },
65
+ { _tag: "final", text: "hello" },
66
+ { _tag: "error", code: "3001", message: "slow down" },
67
+ ]),
68
+ )
69
+ const sent = (yield* server.sent) as ReadonlyArray<{ type: string }>
70
+ expect(sent.map((f) => f.type)).toEqual([
71
+ "session.update",
72
+ "input_audio.append",
73
+ "input_audio.end",
74
+ ])
75
+ }).pipe(Effect.scoped),
76
+ )
77
+
78
+ it.live("fails rather than ending when the connection drops", () =>
79
+ Effect.gen(function* () {
80
+ const server = yield* FakeWebSocket.make({ greeting: [sessionCreated] })
81
+ const exit = yield* transcribe(server, Stream.never, { code: 1011 })
82
+
83
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("Unavailable")
84
+ }).pipe(Effect.scoped),
85
+ )
86
+
87
+ it.live("fails with the audio input's own error", () =>
88
+ Effect.gen(function* () {
89
+ const server = yield* FakeWebSocket.make({ greeting: [sessionCreated] })
90
+ const exit = yield* transcribe(server, Stream.fail("microphone unplugged"))
91
+
92
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("microphone unplugged")
93
+ }).pipe(Effect.scoped),
94
+ )
95
+
96
+ it.effect("commits a synthetic final once the deltas go quiet", () =>
97
+ Effect.gen(function* () {
98
+ const server = yield* FakeWebSocket.make({
99
+ greeting: [sessionCreated, delta("hel"), delta("lo ")],
100
+ })
101
+ const collector = yield* start(server, Stream.never)
102
+ yield* server.greeted
103
+ yield* TestClock.withLive(Effect.sleep("5 millis"))
104
+ yield* TestClock.adjust("1 second")
105
+ yield* server.close(1000)
106
+
107
+ expect(yield* Fiber.join(collector)).toEqual(
108
+ Exit.succeed([
109
+ { _tag: "partial", text: "hel" },
110
+ { _tag: "partial", text: "hello " },
111
+ { _tag: "final", text: "hello" },
112
+ ]),
113
+ )
114
+ }).pipe(Effect.scoped),
115
+ )
116
+ })
@@ -24,24 +24,25 @@
24
24
  * when the deltas go quiet for `utteranceSilence`.
25
25
  */
26
26
  import {
27
- Cause,
28
27
  Clock,
29
28
  Duration,
30
29
  Effect,
31
- Encoding,
32
30
  Match,
33
- Queue,
31
+ Option,
32
+ Predicate,
34
33
  Redacted,
35
34
  Ref,
36
35
  Schema,
37
36
  Stream,
38
37
  } from "effect"
39
- import * as Socket from "effect/unstable/socket/Socket"
38
+ import { Base64 } from "effect/encoding"
39
+ import * as Socket from "effect/socket/Socket"
40
40
  import * as AiError from "@effect-uai/core/AiError"
41
41
  import type { AudioFormat } from "@effect-uai/core/Audio"
42
42
  import * as JSONL from "@effect-uai/core/JSONL"
43
43
  import type { TranscriptEvent } from "@effect-uai/core/Transcript"
44
44
  import type { CommonStreamTranscribeRequest } from "@effect-uai/core/Transcriber"
45
+ import * as WebSocketSession from "@effect-uai/core/WebSocketSession"
45
46
  import { WebSocket as WSWebSocket } from "ws"
46
47
 
47
48
  export type Config = {
@@ -55,6 +56,11 @@ export type Config = {
55
56
  * adapter delimits turns. Default 700 ms.
56
57
  */
57
58
  readonly utteranceSilence?: Duration.Duration
59
+ /**
60
+ * Build the socket yourself, for a proxy or an in-memory transport. The
61
+ * default attaches the bearer token through the `ws` package.
62
+ */
63
+ readonly webSocket?: Socket.WebSocketConstructor["Service"]
58
64
  }
59
65
 
60
66
  const DEFAULT_SILENCE = Duration.millis(700)
@@ -95,7 +101,7 @@ const sessionUpdateFrame = (cfg: Config) =>
95
101
  })
96
102
 
97
103
  const audioAppendFrame = (bytes: Uint8Array) =>
98
- JSON.stringify({ type: "input_audio.append", audio: Encoding.encodeBase64(bytes) })
104
+ JSON.stringify({ type: "input_audio.append", audio: Base64.encode(bytes) })
99
105
 
100
106
  const audioEndFrame = JSON.stringify({ type: "input_audio.end" })
101
107
 
@@ -120,68 +126,73 @@ const ServerEvent = Schema.Union([
120
126
  ])
121
127
  const decodeServerEvent = Schema.decodeUnknownEffect(ServerEvent)
122
128
 
123
- /** Mutable turn state shared by the message handler and the silence finalizer. */
129
+ /** Mutable turn state shared by the frame handler and the silence finalizer. */
124
130
  type TurnState = {
125
131
  readonly text: Ref.Ref<string>
126
132
  readonly lastActivityMs: Ref.Ref<number>
127
133
  }
128
134
 
129
- const emitFinal = (
130
- queue: Queue.Queue<TranscriptEvent, Cause.Done>,
135
+ const takeFinal = (
131
136
  state: TurnState,
132
137
  override?: string,
133
- ): Effect.Effect<void> =>
138
+ ): Effect.Effect<TranscriptEvent | undefined> =>
134
139
  Effect.gen(function* () {
135
140
  const acc = yield* Ref.getAndSet(state.text, "")
136
141
  const text = (override !== undefined && override.length > 0 ? override : acc).trim()
137
- if (text.length > 0) yield* Queue.offer(queue, { _tag: "final", text })
142
+ return text.length > 0 ? { _tag: "final", text } : undefined
138
143
  })
139
144
 
140
- const handleServerMessage =
141
- (queue: Queue.Queue<TranscriptEvent, Cause.Done>, state: TurnState) => (raw: string) =>
145
+ const frameToEvent =
146
+ (state: TurnState) =>
147
+ (raw: string): Effect.Effect<TranscriptEvent | undefined> =>
142
148
  Effect.gen(function* () {
143
149
  const json = yield* JSONL.parseSafe(raw)
144
- if (json === undefined) return
150
+ if (json === undefined) return undefined
145
151
  const decoded = yield* decodeServerEvent(json).pipe(Effect.option)
146
- if (decoded._tag === "None") return
147
- yield* Match.value(decoded.value).pipe(
152
+ if (Option.isNone(decoded)) return undefined
153
+ return yield* Match.value(decoded.value).pipe(
148
154
  Match.when({ type: "transcription.text.delta" }, (m) =>
149
155
  Effect.gen(function* () {
150
156
  const now = yield* Clock.currentTimeMillis
151
157
  const next = yield* Ref.updateAndGet(state.text, (t) => t + m.text)
152
158
  yield* Ref.set(state.lastActivityMs, now)
153
159
  // Cumulative partial so the UI shows the growing sentence.
154
- yield* Queue.offer(queue, { _tag: "partial", text: next })
160
+ return { _tag: "partial", text: next } satisfies TranscriptEvent
155
161
  }),
156
162
  ),
157
163
  // End-of-audio: commit whatever's left as the final utterance.
158
- Match.when({ type: "transcription.done" }, (m) => emitFinal(queue, state, m.text)),
164
+ Match.when({ type: "transcription.done" }, (m) => takeFinal(state, m.text)),
159
165
  Match.when({ type: "error" }, (m) =>
160
- Queue.offer(queue, {
166
+ Effect.succeed<TranscriptEvent>({
161
167
  _tag: "error",
162
168
  ...(m.error.code != null && { code: String(m.error.code) }),
163
169
  message: m.error.message,
164
170
  }),
165
171
  ),
166
172
  // session.created / .updated / language / segment: no user-visible event.
167
- Match.orElse(() => Effect.void),
173
+ Match.orElse(() => Effect.succeed(undefined)),
168
174
  )
169
175
  })
170
176
 
171
- // Background loop: commit a synthetic final once the deltas go quiet.
172
- const silenceFinalizer = (
173
- queue: Queue.Queue<TranscriptEvent, Cause.Done>,
177
+ // Commits a synthetic final once the deltas go quiet; checks now, then every 150 ms.
178
+ const silenceFinals = (
174
179
  state: TurnState,
175
180
  silence: Duration.Duration,
176
- ): Effect.Effect<never> => {
181
+ ): Stream.Stream<TranscriptEvent> => {
177
182
  const silenceMs = Duration.toMillis(silence)
178
- return Effect.gen(function* () {
179
- const now = yield* Clock.currentTimeMillis
180
- const last = yield* Ref.get(state.lastActivityMs)
181
- const acc = yield* Ref.get(state.text)
182
- if (acc.trim().length > 0 && now - last >= silenceMs) yield* emitFinal(queue, state)
183
- yield* Effect.sleep("150 millis")
184
- }).pipe(Effect.forever)
183
+ return Stream.tick("150 millis").pipe(
184
+ Stream.mapEffect(() =>
185
+ Effect.gen(function* () {
186
+ const now = yield* Clock.currentTimeMillis
187
+ const last = yield* Ref.get(state.lastActivityMs)
188
+ const acc = yield* Ref.get(state.text)
189
+ return acc.trim().length > 0 && now - last >= silenceMs
190
+ ? yield* takeFinal(state)
191
+ : undefined
192
+ }),
193
+ ),
194
+ Stream.filter(Predicate.isNotUndefined),
195
+ )
185
196
  }
186
197
 
187
198
  // ---------------------------------------------------------------------------
@@ -208,49 +219,42 @@ export const streamTranscription =
208
219
  Stream.unwrap(
209
220
  Effect.gen(function* () {
210
221
  yield* ensureInputFormat(request.inputFormat)
211
- const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg, request.model), {
212
- // Effect's Socket treats all close codes as errors by default —
213
- // whitelist standard clean-close codes (1000 / 1001 / 1005).
214
- closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
215
- }).pipe(Effect.provideService(Socket.WebSocketConstructor, authedWsConstructor(cfg)))
216
- const queue = yield* Queue.bounded<TranscriptEvent, Cause.Done>(64)
222
+ const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg, request.model)).pipe(
223
+ Effect.provideService(
224
+ Socket.WebSocketConstructor,
225
+ cfg.webSocket ?? authedWsConstructor(cfg),
226
+ ),
227
+ )
217
228
  const state: TurnState = {
218
229
  text: yield* Ref.make(""),
219
230
  lastActivityMs: yield* Ref.make(0),
220
231
  }
221
- const write = yield* socket.writer
232
+ const { write } = yield* socket.writer
233
+ const asAiError = WebSocketSession.toAiError("mistral")
222
234
 
223
- // session.update first, then stream base64 PCM frames, then signal end.
224
- // Fork-scoped so the Stream's downstream scope tears them down on
225
- // disconnect / cancel.
226
- yield* Effect.gen(function* () {
227
- yield* write(sessionUpdateFrame(cfg))
228
- yield* Stream.runForEach(audioIn, (bytes) => write(audioAppendFrame(bytes)))
229
- yield* write(audioEndFrame)
230
- }).pipe(
231
- Effect.tapError((cause) =>
232
- Effect.logWarning("[voxtral-realtime] audio send failed", { cause }),
233
- ),
234
- Effect.ignore,
235
- Effect.forkScoped,
235
+ const frames = Stream.fromPull(Socket.readerString(socket)).pipe(
236
+ Stream.scoped,
237
+ Stream.catchIf(WebSocketSession.isCleanClose, () => Stream.empty),
238
+ Stream.mapError(asAiError),
239
+ Stream.mapEffect(frameToEvent(state)),
240
+ Stream.filter(Predicate.isNotUndefined),
236
241
  )
237
-
238
- yield* silenceFinalizer(queue, state, cfg.utteranceSilence ?? DEFAULT_SILENCE).pipe(
239
- Effect.forkScoped,
242
+ const events = Stream.merge(
243
+ frames,
244
+ silenceFinals(state, cfg.utteranceSilence ?? DEFAULT_SILENCE),
245
+ { haltStrategy: "left" },
240
246
  )
241
247
 
242
- // `Queue.end` flushes pending events then ends the stream cleanly;
243
- // `Queue.shutdown` would drop queued items and interrupt takes. A
244
- // connection/read failure is logged (otherwise the stream would end
245
- // silently with no transcripts).
246
- yield* socket.runString(handleServerMessage(queue, state)).pipe(
247
- Effect.tapError((cause) =>
248
- Effect.logWarning("[voxtral-realtime] socket closed", { cause }),
248
+ // session.update first, then base64 PCM, then signal end. Writes wait for the reader to connect.
249
+ const outgoing = Stream.concat(
250
+ Stream.concat(
251
+ Stream.make(sessionUpdateFrame(cfg)),
252
+ Stream.map(audioIn, audioAppendFrame),
249
253
  ),
250
- Effect.ensuring(Queue.end(queue)),
251
- Effect.forkScoped,
252
- )
254
+ Stream.make(audioEndFrame),
255
+ ).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
253
256
 
254
- return Stream.fromQueue(queue)
257
+ // The server's close ends the transcript; the audio running out does not.
258
+ return Stream.merge(events, Stream.drain(outgoing), { haltStrategy: "left" })
255
259
  }),
256
260
  )