@effect-uai/core 0.3.0 → 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/dist/{AiError-CBuPHVKA.d.mts → AiError-csR8Bhxx.d.mts} +26 -4
- package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-csR8Bhxx.d.mts.map} +1 -1
- package/dist/Audio-BfCTGnH3.d.mts +61 -0
- package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
- package/dist/{Image-BZmKfIdq.d.mts → Image-DxyXqzAM.d.mts} +4 -4
- package/dist/{Image-BZmKfIdq.d.mts.map → Image-DxyXqzAM.d.mts.map} +1 -1
- package/dist/{Items-CB8Bo3FI.d.mts → Items-Hg5AsYxl.d.mts} +5 -5
- package/dist/{Items-CB8Bo3FI.d.mts.map → Items-Hg5AsYxl.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-Cl41C56K.d.mts} +1 -1
- package/dist/{StructuredFormat-BWq5Hd1O.d.mts.map → StructuredFormat-Cl41C56K.d.mts.map} +1 -1
- package/dist/{Tool-DjVufH7i.d.mts → Tool-B8B5qVEy.d.mts} +2 -2
- package/dist/{Tool-DjVufH7i.d.mts.map → Tool-B8B5qVEy.d.mts.map} +1 -1
- package/dist/{Turn-OPaILVIB.d.mts → Turn-7geUcKsf.d.mts} +4 -4
- package/dist/{Turn-OPaILVIB.d.mts.map → Turn-7geUcKsf.d.mts.map} +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +18 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +2 -0
- package/dist/domain/Audio.mjs +14 -0
- package/dist/domain/Audio.mjs.map +1 -0
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Music.d.mts +116 -0
- package/dist/domain/Music.d.mts.map +1 -0
- package/dist/domain/Music.mjs +29 -0
- package/dist/domain/Music.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +95 -0
- package/dist/domain/Transcript.d.mts.map +1 -0
- package/dist/domain/Transcript.mjs +22 -0
- package/dist/domain/Transcript.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/index.d.mts +13 -7
- package/dist/index.mjs +7 -1
- package/dist/language-model/LanguageModel.d.mts +5 -5
- package/dist/loop/Loop.d.mts +2 -2
- package/dist/music-generator/MusicGenerator.d.mts +77 -0
- package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
- package/dist/music-generator/MusicGenerator.mjs +51 -0
- package/dist/music-generator/MusicGenerator.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
- package/dist/music-generator/MusicGenerator.test.mjs +154 -0
- package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +96 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
- package/dist/streaming/JSONL.d.mts +10 -3
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +12 -1
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/testing/MockMusicGenerator.d.mts +39 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
- package/dist/testing/MockMusicGenerator.mjs +96 -0
- package/dist/testing/MockMusicGenerator.mjs.map +1 -0
- package/dist/testing/MockProvider.d.mts +2 -2
- package/dist/testing/MockSpeechSynthesizer.d.mts +37 -0
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
- package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
- package/dist/testing/MockTranscriber.d.mts +37 -0
- package/dist/testing/MockTranscriber.d.mts.map +1 -0
- package/dist/testing/MockTranscriber.mjs +77 -0
- package/dist/testing/MockTranscriber.mjs.map +1 -0
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +1 -1
- package/dist/tool/Tool.d.mts +1 -1
- package/dist/tool/Toolkit.d.mts +2 -2
- package/dist/transcriber/Transcriber.d.mts +101 -0
- package/dist/transcriber/Transcriber.d.mts.map +1 -0
- package/dist/transcriber/Transcriber.mjs +49 -0
- package/dist/transcriber/Transcriber.mjs.map +1 -0
- package/dist/transcriber/Transcriber.test.d.mts +1 -0
- package/dist/transcriber/Transcriber.test.mjs +130 -0
- package/dist/transcriber/Transcriber.test.mjs.map +1 -0
- package/package.json +37 -1
- package/src/domain/AiError.ts +21 -0
- package/src/domain/Audio.ts +88 -0
- package/src/domain/Music.ts +121 -0
- package/src/domain/Transcript.ts +83 -0
- package/src/index.ts +6 -0
- package/src/music-generator/MusicGenerator.test.ts +170 -0
- package/src/music-generator/MusicGenerator.ts +123 -0
- package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
- package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
- package/src/streaming/JSONL.ts +12 -0
- package/src/testing/MockMusicGenerator.ts +170 -0
- package/src/testing/MockSpeechSynthesizer.ts +165 -0
- package/src/testing/MockTranscriber.ts +139 -0
- package/src/transcriber/Transcriber.test.ts +125 -0
- package/src/transcriber/Transcriber.ts +127 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Stream } from "effect"
|
|
2
|
+
import type { AudioBlob, AudioChunk } from "../domain/Audio.js"
|
|
3
|
+
import * as AiError from "../domain/AiError.js"
|
|
4
|
+
import {
|
|
5
|
+
SpeechSynthesizer,
|
|
6
|
+
TtsIncrementalText,
|
|
7
|
+
type CommonStreamSynthesizeRequest,
|
|
8
|
+
type CommonSynthesizeRequest,
|
|
9
|
+
type SpeechSynthesizerService,
|
|
10
|
+
} from "../speech-synthesizer/SpeechSynthesizer.js"
|
|
11
|
+
|
|
12
|
+
export type MockSynthesizerRecorder = {
|
|
13
|
+
readonly synthesizeCalls: ReadonlyArray<CommonSynthesizeRequest>
|
|
14
|
+
readonly streamSynthesisCalls: ReadonlyArray<CommonSynthesizeRequest>
|
|
15
|
+
readonly streamSynthesisFromCalls: ReadonlyArray<CommonStreamSynthesizeRequest>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MockSynthesizerScript = {
|
|
19
|
+
/** One blob per `synthesize` call, consumed in order. */
|
|
20
|
+
readonly blobs?: ReadonlyArray<AudioBlob>
|
|
21
|
+
/** One chunk-list per `streamSynthesis` call, consumed in order. */
|
|
22
|
+
readonly streamSynthesisChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
23
|
+
/** One chunk-list per `streamSynthesisFrom` call, consumed in order. */
|
|
24
|
+
readonly streamSynthesisFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const makeService = (
|
|
28
|
+
script: MockSynthesizerScript,
|
|
29
|
+
record: {
|
|
30
|
+
readonly synthesize: (req: CommonSynthesizeRequest) => Effect.Effect<void>
|
|
31
|
+
readonly streamSynthesis: (req: CommonSynthesizeRequest) => Effect.Effect<void>
|
|
32
|
+
readonly streamSynthesisFrom: (req: CommonStreamSynthesizeRequest) => Effect.Effect<void>
|
|
33
|
+
},
|
|
34
|
+
) =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const bCursor = yield* Ref.make(0)
|
|
37
|
+
const ssCursor = yield* Ref.make(0)
|
|
38
|
+
const ssfCursor = yield* Ref.make(0)
|
|
39
|
+
const service: SpeechSynthesizerService = {
|
|
40
|
+
synthesize: (request) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
yield* record.synthesize(request)
|
|
43
|
+
const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
|
|
44
|
+
const scripted = script.blobs ?? []
|
|
45
|
+
if (i >= scripted.length) {
|
|
46
|
+
return yield* Effect.fail(
|
|
47
|
+
new AiError.InvalidRequest({
|
|
48
|
+
provider: "mock",
|
|
49
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return scripted[i]!
|
|
54
|
+
}),
|
|
55
|
+
streamSynthesis: (request) =>
|
|
56
|
+
Stream.unwrap(
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
yield* record.streamSynthesis(request)
|
|
59
|
+
const i = yield* Ref.getAndUpdate(ssCursor, (n) => n + 1)
|
|
60
|
+
const scripted = script.streamSynthesisChunks ?? []
|
|
61
|
+
if (i >= scripted.length) {
|
|
62
|
+
return Stream.fail(
|
|
63
|
+
new AiError.InvalidRequest({
|
|
64
|
+
provider: "mock",
|
|
65
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesis lists scripted, but call ${i + 1} was made`,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return Stream.fromIterable(scripted[i]!)
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
streamSynthesisFrom: <E, R>(
|
|
73
|
+
textIn: Stream.Stream<string, E, R>,
|
|
74
|
+
request: CommonStreamSynthesizeRequest,
|
|
75
|
+
): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
|
|
76
|
+
Stream.unwrap(
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
yield* record.streamSynthesisFrom(request)
|
|
79
|
+
const i = yield* Ref.getAndUpdate(ssfCursor, (n) => n + 1)
|
|
80
|
+
const scripted = script.streamSynthesisFromChunks ?? []
|
|
81
|
+
if (i >= scripted.length) {
|
|
82
|
+
const exhausted: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
|
|
83
|
+
new AiError.InvalidRequest({
|
|
84
|
+
provider: "mock",
|
|
85
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesisFrom lists scripted, but call ${i + 1} was made`,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
return exhausted
|
|
89
|
+
}
|
|
90
|
+
// Drain the input text fully before emitting scripted audio chunks,
|
|
91
|
+
// so consumers can assert on what text was pushed.
|
|
92
|
+
return Stream.drain(textIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
return service
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Layer providing the `SpeechSynthesizer` service AND the
|
|
101
|
+
* `TtsIncrementalText` capability marker. Use for the common case
|
|
102
|
+
* where code under test exercises `streamSynthesisFrom`.
|
|
103
|
+
*/
|
|
104
|
+
export const layer = (
|
|
105
|
+
script: MockSynthesizerScript,
|
|
106
|
+
): {
|
|
107
|
+
readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText>
|
|
108
|
+
readonly recorder: Effect.Effect<MockSynthesizerRecorder>
|
|
109
|
+
} => {
|
|
110
|
+
const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
111
|
+
const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
112
|
+
const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
|
|
113
|
+
const synthesizerLayer = Layer.effect(
|
|
114
|
+
SpeechSynthesizer,
|
|
115
|
+
makeService(script, {
|
|
116
|
+
synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
|
|
117
|
+
streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
|
|
118
|
+
streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
const live = Layer.merge(synthesizerLayer, Layer.succeed(TtsIncrementalText, undefined))
|
|
122
|
+
return {
|
|
123
|
+
layer: live,
|
|
124
|
+
recorder: Effect.gen(function* () {
|
|
125
|
+
const synthesizeCalls = yield* Ref.get(bCalls)
|
|
126
|
+
const streamSynthesisCalls = yield* Ref.get(ssCalls)
|
|
127
|
+
const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
|
|
128
|
+
return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
|
|
129
|
+
}),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Variant that omits the `TtsIncrementalText` marker — simulates a
|
|
135
|
+
* provider without incremental-text-in support (e.g. OpenAI, AWS
|
|
136
|
+
* Polly non-Generative). Calls to `streamSynthesisFrom` in code under
|
|
137
|
+
* test should be a compile-time error.
|
|
138
|
+
*/
|
|
139
|
+
export const layerWithoutIncremental = (
|
|
140
|
+
script: MockSynthesizerScript,
|
|
141
|
+
): {
|
|
142
|
+
readonly layer: Layer.Layer<SpeechSynthesizer>
|
|
143
|
+
readonly recorder: Effect.Effect<MockSynthesizerRecorder>
|
|
144
|
+
} => {
|
|
145
|
+
const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
146
|
+
const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
|
|
147
|
+
const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
|
|
148
|
+
const live = Layer.effect(
|
|
149
|
+
SpeechSynthesizer,
|
|
150
|
+
makeService(script, {
|
|
151
|
+
synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
|
|
152
|
+
streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
|
|
153
|
+
streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
return {
|
|
157
|
+
layer: live,
|
|
158
|
+
recorder: Effect.gen(function* () {
|
|
159
|
+
const synthesizeCalls = yield* Ref.get(bCalls)
|
|
160
|
+
const streamSynthesisCalls = yield* Ref.get(ssCalls)
|
|
161
|
+
const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
|
|
162
|
+
return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
|
|
163
|
+
}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Stream } from "effect"
|
|
2
|
+
import * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type { TranscriptEvent, TranscriptResult } from "../domain/Transcript.js"
|
|
4
|
+
import {
|
|
5
|
+
SttStreaming,
|
|
6
|
+
Transcriber,
|
|
7
|
+
type CommonStreamTranscribeRequest,
|
|
8
|
+
type CommonTranscribeRequest,
|
|
9
|
+
type TranscriberService,
|
|
10
|
+
} from "../transcriber/Transcriber.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Recorder of every call made to the mock.
|
|
14
|
+
*/
|
|
15
|
+
export type MockTranscriberRecorder = {
|
|
16
|
+
readonly transcribeCalls: ReadonlyArray<CommonTranscribeRequest>
|
|
17
|
+
readonly streamCalls: ReadonlyArray<CommonStreamTranscribeRequest>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type MockTranscriberScript = {
|
|
21
|
+
/** One result per `transcribe` call, consumed in order. */
|
|
22
|
+
readonly transcripts?: ReadonlyArray<TranscriptResult>
|
|
23
|
+
/** One event-list per `streamTranscriptionFrom` call, consumed in order. */
|
|
24
|
+
readonly streams?: ReadonlyArray<ReadonlyArray<TranscriptEvent>>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const makeService = (
|
|
28
|
+
script: MockTranscriberScript,
|
|
29
|
+
record: {
|
|
30
|
+
readonly transcribe: (req: CommonTranscribeRequest) => Effect.Effect<void>
|
|
31
|
+
readonly stream: (req: CommonStreamTranscribeRequest) => Effect.Effect<void>
|
|
32
|
+
},
|
|
33
|
+
) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const tCursor = yield* Ref.make(0)
|
|
36
|
+
const sCursor = yield* Ref.make(0)
|
|
37
|
+
const service: TranscriberService = {
|
|
38
|
+
transcribe: (request) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
yield* record.transcribe(request)
|
|
41
|
+
const i = yield* Ref.getAndUpdate(tCursor, (n) => n + 1)
|
|
42
|
+
const scripted = script.transcripts ?? []
|
|
43
|
+
if (i >= scripted.length) {
|
|
44
|
+
return yield* Effect.fail(
|
|
45
|
+
new AiError.InvalidRequest({
|
|
46
|
+
provider: "mock",
|
|
47
|
+
raw: `MockTranscriber exhausted: ${scripted.length} transcripts scripted, but call ${i + 1} was made`,
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return scripted[i]!
|
|
52
|
+
}),
|
|
53
|
+
streamTranscriptionFrom: <E, R>(
|
|
54
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
55
|
+
request: CommonStreamTranscribeRequest,
|
|
56
|
+
): Stream.Stream<TranscriptEvent, AiError.AiError | E, R> =>
|
|
57
|
+
Stream.unwrap(
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
yield* record.stream(request)
|
|
60
|
+
const i = yield* Ref.getAndUpdate(sCursor, (n) => n + 1)
|
|
61
|
+
const scripted = script.streams ?? []
|
|
62
|
+
if (i >= scripted.length) {
|
|
63
|
+
const exhausted: Stream.Stream<TranscriptEvent, AiError.AiError | E, R> = Stream.fail(
|
|
64
|
+
new AiError.InvalidRequest({
|
|
65
|
+
provider: "mock",
|
|
66
|
+
raw: `MockTranscriber exhausted: ${scripted.length} streams scripted, but call ${i + 1} was made`,
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
return exhausted
|
|
70
|
+
}
|
|
71
|
+
// Drain the input audio fully before emitting the scripted events,
|
|
72
|
+
// so consumers can assert on what bytes were pushed.
|
|
73
|
+
return Stream.drain(audioIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
return service
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns a Layer that provides both the `Transcriber` service and the
|
|
82
|
+
* `SttStreaming` capability marker. Use when the code under test calls
|
|
83
|
+
* `streamTranscriptionFrom`.
|
|
84
|
+
*/
|
|
85
|
+
export const layer = (
|
|
86
|
+
script: MockTranscriberScript,
|
|
87
|
+
): {
|
|
88
|
+
readonly layer: Layer.Layer<Transcriber | SttStreaming>
|
|
89
|
+
readonly recorder: Effect.Effect<MockTranscriberRecorder>
|
|
90
|
+
} => {
|
|
91
|
+
const tCalls = Ref.makeUnsafe<ReadonlyArray<CommonTranscribeRequest>>([])
|
|
92
|
+
const sCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamTranscribeRequest>>([])
|
|
93
|
+
const transcriberLayer = Layer.effect(
|
|
94
|
+
Transcriber,
|
|
95
|
+
makeService(script, {
|
|
96
|
+
transcribe: (req) => Ref.update(tCalls, (xs) => [...xs, req]),
|
|
97
|
+
stream: (req) => Ref.update(sCalls, (xs) => [...xs, req]),
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
const live = Layer.merge(transcriberLayer, Layer.succeed(SttStreaming, undefined))
|
|
101
|
+
return {
|
|
102
|
+
layer: live,
|
|
103
|
+
recorder: Effect.gen(function* () {
|
|
104
|
+
const transcribeCalls = yield* Ref.get(tCalls)
|
|
105
|
+
const streamCalls = yield* Ref.get(sCalls)
|
|
106
|
+
return { transcribeCalls, streamCalls }
|
|
107
|
+
}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Variant that omits the `SttStreaming` marker — use to test that
|
|
113
|
+
* consumers calling `streamTranscriptionFrom` fail to compile against
|
|
114
|
+
* a non-streaming provider.
|
|
115
|
+
*/
|
|
116
|
+
export const layerSyncOnly = (
|
|
117
|
+
script: MockTranscriberScript,
|
|
118
|
+
): {
|
|
119
|
+
readonly layer: Layer.Layer<Transcriber>
|
|
120
|
+
readonly recorder: Effect.Effect<MockTranscriberRecorder>
|
|
121
|
+
} => {
|
|
122
|
+
const tCalls = Ref.makeUnsafe<ReadonlyArray<CommonTranscribeRequest>>([])
|
|
123
|
+
const sCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamTranscribeRequest>>([])
|
|
124
|
+
const live = Layer.effect(
|
|
125
|
+
Transcriber,
|
|
126
|
+
makeService(script, {
|
|
127
|
+
transcribe: (req) => Ref.update(tCalls, (xs) => [...xs, req]),
|
|
128
|
+
stream: (req) => Ref.update(sCalls, (xs) => [...xs, req]),
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
layer: live,
|
|
133
|
+
recorder: Effect.gen(function* () {
|
|
134
|
+
const transcribeCalls = yield* Ref.get(tCalls)
|
|
135
|
+
const streamCalls = yield* Ref.get(sCalls)
|
|
136
|
+
return { transcribeCalls, streamCalls }
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect"
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
3
|
+
import type * as AiError from "../domain/AiError.js"
|
|
4
|
+
import type { TranscriptEvent, TranscriptResult } from "../domain/Transcript.js"
|
|
5
|
+
import * as MockTranscriber from "../testing/MockTranscriber.js"
|
|
6
|
+
import * as Transcriber from "./Transcriber.js"
|
|
7
|
+
|
|
8
|
+
describe("Transcriber.transcribe", () => {
|
|
9
|
+
it("returns the scripted TranscriptResult", async () => {
|
|
10
|
+
const mock = MockTranscriber.layer({
|
|
11
|
+
transcripts: [{ text: "hello world", durationSeconds: 1.23 }],
|
|
12
|
+
})
|
|
13
|
+
const program = Transcriber.transcribe({
|
|
14
|
+
audio: { _tag: "bytes", bytes: new Uint8Array([0]), mimeType: "audio/wav" },
|
|
15
|
+
model: "mock-stt",
|
|
16
|
+
})
|
|
17
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
|
|
18
|
+
expect(result.text).toBe("hello world")
|
|
19
|
+
expect(result.durationSeconds).toBe(1.23)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("records each transcribe call", async () => {
|
|
23
|
+
const mock = MockTranscriber.layer({
|
|
24
|
+
transcripts: [{ text: "a" }, { text: "b" }],
|
|
25
|
+
})
|
|
26
|
+
const program = Effect.gen(function* () {
|
|
27
|
+
yield* Transcriber.transcribe({
|
|
28
|
+
audio: { _tag: "bytes", bytes: new Uint8Array([1]), mimeType: "audio/wav" },
|
|
29
|
+
model: "m1",
|
|
30
|
+
})
|
|
31
|
+
yield* Transcriber.transcribe({
|
|
32
|
+
audio: { _tag: "bytes", bytes: new Uint8Array([2]), mimeType: "audio/wav" },
|
|
33
|
+
model: "m2",
|
|
34
|
+
})
|
|
35
|
+
return yield* mock.recorder
|
|
36
|
+
})
|
|
37
|
+
const rec = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
|
|
38
|
+
expect(rec.transcribeCalls.map((c) => c.model)).toEqual(["m1", "m2"])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("Transcriber capability marker (compile-time)", () => {
|
|
43
|
+
const sttReq: Transcriber.CommonStreamTranscribeRequest = {
|
|
44
|
+
model: "mock-stt",
|
|
45
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it("requires `SttStreaming` on the R channel of streamTranscriptionFrom", () => {
|
|
49
|
+
const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
|
|
50
|
+
const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
|
|
51
|
+
expectTypeOf(events).toEqualTypeOf<
|
|
52
|
+
Stream.Stream<
|
|
53
|
+
TranscriptEvent,
|
|
54
|
+
AiError.AiError,
|
|
55
|
+
Transcriber.Transcriber | Transcriber.SttStreaming
|
|
56
|
+
>
|
|
57
|
+
>()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("does NOT require `SttStreaming` for sync `transcribe`", () => {
|
|
61
|
+
const eff = Transcriber.transcribe({
|
|
62
|
+
audio: { _tag: "bytes", bytes: new Uint8Array([0]), mimeType: "audio/wav" },
|
|
63
|
+
model: "m",
|
|
64
|
+
})
|
|
65
|
+
expectTypeOf(eff).toEqualTypeOf<
|
|
66
|
+
Effect.Effect<TranscriptResult, AiError.AiError, Transcriber.Transcriber>
|
|
67
|
+
>()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("a sync-only layer leaves `SttStreaming` unsatisfied in R", () => {
|
|
71
|
+
const syncOnly = MockTranscriber.layerSyncOnly({})
|
|
72
|
+
const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
|
|
73
|
+
const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
|
|
74
|
+
const program = Stream.runDrain(events).pipe(Effect.provide(syncOnly.layer))
|
|
75
|
+
// `Transcriber` is provided by syncOnly.layer; `SttStreaming` is not.
|
|
76
|
+
expectTypeOf(program).toEqualTypeOf<
|
|
77
|
+
Effect.Effect<void, AiError.AiError, Transcriber.SttStreaming>
|
|
78
|
+
>()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("a full layer (with marker) clears R to never", () => {
|
|
82
|
+
const fullMock = MockTranscriber.layer({ streams: [[]] })
|
|
83
|
+
const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
|
|
84
|
+
const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
|
|
85
|
+
const program = Stream.runDrain(events).pipe(Effect.provide(fullMock.layer))
|
|
86
|
+
expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("Transcriber.streamTranscriptionFrom", () => {
|
|
91
|
+
const sttReq: Transcriber.CommonStreamTranscribeRequest = {
|
|
92
|
+
model: "mock-stt",
|
|
93
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("emits scripted events after draining the input audio stream", async () => {
|
|
97
|
+
const mock = MockTranscriber.layer({
|
|
98
|
+
streams: [
|
|
99
|
+
[
|
|
100
|
+
{ _tag: "partial", text: "hello" },
|
|
101
|
+
{ _tag: "final", text: "hello world" },
|
|
102
|
+
],
|
|
103
|
+
],
|
|
104
|
+
})
|
|
105
|
+
const audio = Stream.fromIterable([new Uint8Array([0, 1, 2]), new Uint8Array([3, 4, 5])])
|
|
106
|
+
const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
|
|
107
|
+
const collected = await Effect.runPromise(
|
|
108
|
+
Stream.runCollect(events).pipe(Effect.provide(mock.layer)),
|
|
109
|
+
)
|
|
110
|
+
expect(collected).toEqual([
|
|
111
|
+
{ _tag: "partial", text: "hello" },
|
|
112
|
+
{ _tag: "final", text: "hello world" },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("works data-first (direct call) as well as pipeable (data-last)", async () => {
|
|
117
|
+
const mock = MockTranscriber.layer({
|
|
118
|
+
streams: [[{ _tag: "final", text: "x" }]],
|
|
119
|
+
})
|
|
120
|
+
const audio = Stream.fromIterable([new Uint8Array([0])])
|
|
121
|
+
const events = Transcriber.streamTranscriptionFrom(audio, sttReq)
|
|
122
|
+
const out = await Effect.runPromise(Stream.runCollect(events).pipe(Effect.provide(mock.layer)))
|
|
123
|
+
expect(out).toEqual([{ _tag: "final", text: "x" }])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Context, Effect, Function, Stream } from "effect"
|
|
2
|
+
import * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type { AudioFormat, AudioSource } from "../domain/Audio.js"
|
|
4
|
+
import type { TranscriptEvent, TranscriptResult } from "../domain/Transcript.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cross-provider sync transcription request. Provider-specific
|
|
8
|
+
* extensions (Deepgram `keyterm[]`, ElevenLabs `diarize`, Google
|
|
9
|
+
* `adaptation`, …) live on each provider's typed request which extends
|
|
10
|
+
* this and narrows `model`.
|
|
11
|
+
*/
|
|
12
|
+
export type CommonTranscribeRequest = {
|
|
13
|
+
readonly audio: AudioSource
|
|
14
|
+
/** Model identifier. Each provider narrows to its typed literal union. */
|
|
15
|
+
readonly model: string
|
|
16
|
+
/** ISO-639-1 / BCP-47. Omit for autodetection (where supported). */
|
|
17
|
+
readonly language?: string
|
|
18
|
+
/**
|
|
19
|
+
* Vocab biasing. Single-string covers OpenAI/Whisper-style prompts;
|
|
20
|
+
* `terms[]` covers Deepgram `keyterm`, Google adaptation phrases, AWS
|
|
21
|
+
* `vocabularyName`. Providers ignore what they don't support.
|
|
22
|
+
*/
|
|
23
|
+
readonly prompt?: string | { readonly terms: ReadonlyArray<string> }
|
|
24
|
+
readonly diarization?: boolean
|
|
25
|
+
readonly wordTimestamps?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Streaming-transcription request. `inputFormat` declares what the
|
|
30
|
+
* bytes in the input stream will look like — providers reject
|
|
31
|
+
* mismatches at stream startup with `AiError.InvalidRequest`.
|
|
32
|
+
*/
|
|
33
|
+
export type CommonStreamTranscribeRequest = Omit<CommonTranscribeRequest, "audio"> & {
|
|
34
|
+
readonly inputFormat: AudioFormat
|
|
35
|
+
readonly interimResults?: boolean
|
|
36
|
+
readonly vadEvents?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TranscriberService = {
|
|
40
|
+
/**
|
|
41
|
+
* One-shot transcription. Universal — AWS Transcribe (which has no
|
|
42
|
+
* native sync endpoint) emulates this by draining a streaming session
|
|
43
|
+
* internally.
|
|
44
|
+
*/
|
|
45
|
+
readonly transcribe: (
|
|
46
|
+
request: CommonTranscribeRequest,
|
|
47
|
+
) => Effect.Effect<TranscriptResult, AiError.AiError>
|
|
48
|
+
/**
|
|
49
|
+
* Live transcription as a Stream transformer. Consumes audio bytes
|
|
50
|
+
* from `audioIn`; emits `TranscriptEvent`s as they arrive. The
|
|
51
|
+
* underlying WS / gRPC connection is acquired on first pull and
|
|
52
|
+
* released when the output stream is finalized (success, failure, or
|
|
53
|
+
* interruption) via `Stream.scoped` — no explicit Scope handling at
|
|
54
|
+
* the call site.
|
|
55
|
+
*
|
|
56
|
+
* Gated by the `SttStreaming` capability marker on the top-level
|
|
57
|
+
* helper — providers without streaming-STT support don't ship the
|
|
58
|
+
* marker, so calls fail at `Effect.provide` with a type error.
|
|
59
|
+
*/
|
|
60
|
+
readonly streamTranscriptionFrom: <E, R>(
|
|
61
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
62
|
+
request: CommonStreamTranscribeRequest,
|
|
63
|
+
) => Stream.Stream<TranscriptEvent, AiError.AiError | E, R>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Transcriber extends Context.Service<Transcriber, TranscriberService>()(
|
|
67
|
+
"@betalyra/effect-uai/Transcriber",
|
|
68
|
+
) {}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Capability marker — provided by provider layers whose
|
|
72
|
+
* `streamTranscriptionFrom` is wired up at the wire level. Azure does
|
|
73
|
+
* not ship it (streaming-STT is SDK-internal). Calling
|
|
74
|
+
* `streamTranscriptionFrom` while only Azure's Layer is in scope fails
|
|
75
|
+
* at `Effect.provide` with a type error, not at runtime.
|
|
76
|
+
*
|
|
77
|
+
* Phantom — the value is `void`; providers register with
|
|
78
|
+
* `Layer.succeed(SttStreaming, undefined)`.
|
|
79
|
+
*/
|
|
80
|
+
export class SttStreaming extends Context.Service<SttStreaming, void>()(
|
|
81
|
+
"@betalyra/effect-uai/capability/SttStreaming",
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
/** One-shot transcription. */
|
|
85
|
+
export const transcribe = (
|
|
86
|
+
request: CommonTranscribeRequest,
|
|
87
|
+
): Effect.Effect<TranscriptResult, AiError.AiError, Transcriber> =>
|
|
88
|
+
Effect.flatMap(Transcriber.asEffect(), (t) => t.transcribe(request))
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Live transcription. Dual-arity: pipeable (data-last) and direct
|
|
92
|
+
* (data-first). Requires `SttStreaming` in R — providers without
|
|
93
|
+
* streaming support are a type error at provide time.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // Pipeable — composes with other Stream operators
|
|
98
|
+
* mic.frames.pipe(
|
|
99
|
+
* Transcriber.streamTranscriptionFrom(req),
|
|
100
|
+
* Stream.filter((e) => e._tag === "final"),
|
|
101
|
+
* )
|
|
102
|
+
*
|
|
103
|
+
* // Direct
|
|
104
|
+
* Transcriber.streamTranscriptionFrom(mic.frames, req)
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export const streamTranscriptionFrom: {
|
|
108
|
+
(
|
|
109
|
+
request: CommonStreamTranscribeRequest,
|
|
110
|
+
): <E, R>(
|
|
111
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
112
|
+
) => Stream.Stream<TranscriptEvent, AiError.AiError | E, R | Transcriber | SttStreaming>
|
|
113
|
+
<E, R>(
|
|
114
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
115
|
+
request: CommonStreamTranscribeRequest,
|
|
116
|
+
): Stream.Stream<TranscriptEvent, AiError.AiError | E, R | Transcriber | SttStreaming>
|
|
117
|
+
} = Function.dual(
|
|
118
|
+
2,
|
|
119
|
+
<E, R>(audioIn: Stream.Stream<Uint8Array, E, R>, request: CommonStreamTranscribeRequest) =>
|
|
120
|
+
Stream.unwrap(
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const t = yield* Transcriber.asEffect()
|
|
123
|
+
yield* SttStreaming.asEffect()
|
|
124
|
+
return t.streamTranscriptionFrom(audioIn, request)
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
)
|