@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.
- package/README.md +3 -3
- package/dist/Mistral.d.mts +1 -1
- package/dist/Mistral.mjs +2 -2
- package/dist/Mistral.mjs.map +1 -1
- package/dist/MistralRealtimeTranscriber.d.mts +2 -2
- package/dist/MistralRealtimeTranscriber.mjs +2 -2
- package/dist/MistralRealtimeTranscriber.mjs.map +1 -1
- package/dist/MistralSynthesizer.d.mts +1 -1
- package/dist/MistralSynthesizer.d.mts.map +1 -1
- package/dist/MistralSynthesizer.mjs +5 -4
- package/dist/MistralSynthesizer.mjs.map +1 -1
- package/dist/MistralTranscriber.d.mts +1 -1
- package/dist/MistralTranscriber.mjs +2 -2
- package/dist/MistralTranscriber.mjs.map +1 -1
- package/dist/audioCodec.d.mts.map +1 -1
- package/dist/audioCodec.mjs +3 -2
- package/dist/audioCodec.mjs.map +1 -1
- package/dist/codec.d.mts.map +1 -1
- package/dist/codec.mjs +4 -3
- package/dist/codec.mjs.map +1 -1
- package/dist/codec.test.mjs +1 -1
- package/dist/dist-DHer1cql.mjs +1533 -0
- package/dist/dist-DHer1cql.mjs.map +1 -0
- package/dist/http.d.mts +1 -1
- package/dist/http.mjs.map +1 -1
- package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → index.C4tKZ0yW-CUBm3Bmz.mjs} +9446 -5818
- package/dist/index.C4tKZ0yW-CUBm3Bmz.mjs.map +1 -0
- package/dist/onHalt.test.mjs +2 -2
- package/dist/onHalt.test.mjs.map +1 -1
- package/dist/{realtimeStt-BcthMc57.d.mts → realtimeStt-CcuQ6EZS.d.mts} +7 -1
- package/dist/realtimeStt-CcuQ6EZS.d.mts.map +1 -0
- package/dist/realtimeStt.d.mts +1 -1
- package/dist/realtimeStt.mjs +27 -30
- package/dist/realtimeStt.mjs.map +1 -1
- package/dist/realtimeStt.test.d.mts +1 -0
- package/dist/realtimeStt.test.mjs +298 -0
- package/dist/realtimeStt.test.mjs.map +1 -0
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
- package/package.json +5 -5
- package/src/Mistral.ts +1 -1
- package/src/MistralRealtimeTranscriber.ts +1 -1
- package/src/MistralSynthesizer.ts +4 -13
- package/src/MistralTranscriber.ts +1 -1
- package/src/audioCodec.ts +3 -2
- package/src/codec.ts +3 -2
- package/src/http.ts +1 -1
- package/src/onHalt.test.ts +1 -1
- package/src/realtimeStt.test.ts +116 -0
- package/src/realtimeStt.ts +68 -64
- package/dist/magic-string.es-CUAnjKYC.mjs +0 -1014
- package/dist/magic-string.es-CUAnjKYC.mjs.map +0 -1
- package/dist/realtimeStt-BcthMc57.d.mts.map +0 -1
- 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.
|
|
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.
|
|
62
|
+
"@effect-uai/chat-completions": "0.18.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@types/ws": "^8.5.13",
|
|
66
|
-
"effect": "4.0.
|
|
66
|
+
"effect": "4.0.1",
|
|
67
67
|
"typescript": "^7.0.2",
|
|
68
68
|
"ws": "^8.18.0",
|
|
69
|
-
"@effect-uai/core": "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.
|
|
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/
|
|
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 {
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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(
|
|
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/
|
|
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,
|
|
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(
|
|
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,
|
|
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,${
|
|
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
package/src/onHalt.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Exit, Redacted, Stream } from "effect"
|
|
2
|
-
import { HttpClient, HttpClientResponse } from "effect/
|
|
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
|
+
})
|
package/src/realtimeStt.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
+
Option,
|
|
32
|
+
Predicate,
|
|
34
33
|
Redacted,
|
|
35
34
|
Ref,
|
|
36
35
|
Schema,
|
|
37
36
|
Stream,
|
|
38
37
|
} from "effect"
|
|
39
|
-
import
|
|
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:
|
|
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
|
|
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
|
|
130
|
-
queue: Queue.Queue<TranscriptEvent, Cause.Done>,
|
|
135
|
+
const takeFinal = (
|
|
131
136
|
state: TurnState,
|
|
132
137
|
override?: string,
|
|
133
|
-
): Effect.Effect<
|
|
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
|
-
|
|
142
|
+
return text.length > 0 ? { _tag: "final", text } : undefined
|
|
138
143
|
})
|
|
139
144
|
|
|
140
|
-
const
|
|
141
|
-
(
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
164
|
+
Match.when({ type: "transcription.done" }, (m) => takeFinal(state, m.text)),
|
|
159
165
|
Match.when({ type: "error" }, (m) =>
|
|
160
|
-
|
|
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.
|
|
173
|
+
Match.orElse(() => Effect.succeed(undefined)),
|
|
168
174
|
)
|
|
169
175
|
})
|
|
170
176
|
|
|
171
|
-
//
|
|
172
|
-
const
|
|
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
|
-
):
|
|
181
|
+
): Stream.Stream<TranscriptEvent> => {
|
|
177
182
|
const silenceMs = Duration.toMillis(silence)
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
239
|
-
|
|
242
|
+
const events = Stream.merge(
|
|
243
|
+
frames,
|
|
244
|
+
silenceFinals(state, cfg.utteranceSilence ?? DEFAULT_SILENCE),
|
|
245
|
+
{ haltStrategy: "left" },
|
|
240
246
|
)
|
|
241
247
|
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
254
|
+
Stream.make(audioEndFrame),
|
|
255
|
+
).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
|
|
253
256
|
|
|
254
|
-
|
|
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
|
)
|