@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.
Files changed (97) hide show
  1. package/dist/{AiError-CBuPHVKA.d.mts → AiError-csR8Bhxx.d.mts} +26 -4
  2. package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-csR8Bhxx.d.mts.map} +1 -1
  3. package/dist/Audio-BfCTGnH3.d.mts +61 -0
  4. package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
  5. package/dist/{Image-BZmKfIdq.d.mts → Image-DxyXqzAM.d.mts} +4 -4
  6. package/dist/{Image-BZmKfIdq.d.mts.map → Image-DxyXqzAM.d.mts.map} +1 -1
  7. package/dist/{Items-CB8Bo3FI.d.mts → Items-Hg5AsYxl.d.mts} +5 -5
  8. package/dist/{Items-CB8Bo3FI.d.mts.map → Items-Hg5AsYxl.d.mts.map} +1 -1
  9. package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-Cl41C56K.d.mts} +1 -1
  10. package/dist/{StructuredFormat-BWq5Hd1O.d.mts.map → StructuredFormat-Cl41C56K.d.mts.map} +1 -1
  11. package/dist/{Tool-DjVufH7i.d.mts → Tool-B8B5qVEy.d.mts} +2 -2
  12. package/dist/{Tool-DjVufH7i.d.mts.map → Tool-B8B5qVEy.d.mts.map} +1 -1
  13. package/dist/{Turn-OPaILVIB.d.mts → Turn-7geUcKsf.d.mts} +4 -4
  14. package/dist/{Turn-OPaILVIB.d.mts.map → Turn-7geUcKsf.d.mts.map} +1 -1
  15. package/dist/domain/AiError.d.mts +2 -2
  16. package/dist/domain/AiError.mjs +18 -2
  17. package/dist/domain/AiError.mjs.map +1 -1
  18. package/dist/domain/Audio.d.mts +2 -0
  19. package/dist/domain/Audio.mjs +14 -0
  20. package/dist/domain/Audio.mjs.map +1 -0
  21. package/dist/domain/Image.d.mts +1 -1
  22. package/dist/domain/Items.d.mts +1 -1
  23. package/dist/domain/Music.d.mts +116 -0
  24. package/dist/domain/Music.d.mts.map +1 -0
  25. package/dist/domain/Music.mjs +29 -0
  26. package/dist/domain/Music.mjs.map +1 -0
  27. package/dist/domain/Transcript.d.mts +95 -0
  28. package/dist/domain/Transcript.d.mts.map +1 -0
  29. package/dist/domain/Transcript.mjs +22 -0
  30. package/dist/domain/Transcript.mjs.map +1 -0
  31. package/dist/domain/Turn.d.mts +1 -1
  32. package/dist/embedding-model/Embedding.d.mts +1 -1
  33. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  34. package/dist/index.d.mts +13 -7
  35. package/dist/index.mjs +7 -1
  36. package/dist/language-model/LanguageModel.d.mts +5 -5
  37. package/dist/loop/Loop.d.mts +2 -2
  38. package/dist/music-generator/MusicGenerator.d.mts +77 -0
  39. package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
  40. package/dist/music-generator/MusicGenerator.mjs +51 -0
  41. package/dist/music-generator/MusicGenerator.mjs.map +1 -0
  42. package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
  43. package/dist/music-generator/MusicGenerator.test.mjs +154 -0
  44. package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
  45. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +96 -0
  46. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
  47. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
  48. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
  49. package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
  50. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
  51. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
  52. package/dist/streaming/JSONL.d.mts +10 -3
  53. package/dist/streaming/JSONL.d.mts.map +1 -1
  54. package/dist/streaming/JSONL.mjs +12 -1
  55. package/dist/streaming/JSONL.mjs.map +1 -1
  56. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  57. package/dist/testing/MockMusicGenerator.d.mts +39 -0
  58. package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
  59. package/dist/testing/MockMusicGenerator.mjs +96 -0
  60. package/dist/testing/MockMusicGenerator.mjs.map +1 -0
  61. package/dist/testing/MockProvider.d.mts +2 -2
  62. package/dist/testing/MockSpeechSynthesizer.d.mts +37 -0
  63. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
  64. package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
  65. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
  66. package/dist/testing/MockTranscriber.d.mts +37 -0
  67. package/dist/testing/MockTranscriber.d.mts.map +1 -0
  68. package/dist/testing/MockTranscriber.mjs +77 -0
  69. package/dist/testing/MockTranscriber.mjs.map +1 -0
  70. package/dist/tool/HistoryCheck.d.mts +1 -1
  71. package/dist/tool/Outcome.d.mts +1 -1
  72. package/dist/tool/Resolvers.d.mts +1 -1
  73. package/dist/tool/Tool.d.mts +1 -1
  74. package/dist/tool/Toolkit.d.mts +2 -2
  75. package/dist/transcriber/Transcriber.d.mts +101 -0
  76. package/dist/transcriber/Transcriber.d.mts.map +1 -0
  77. package/dist/transcriber/Transcriber.mjs +49 -0
  78. package/dist/transcriber/Transcriber.mjs.map +1 -0
  79. package/dist/transcriber/Transcriber.test.d.mts +1 -0
  80. package/dist/transcriber/Transcriber.test.mjs +130 -0
  81. package/dist/transcriber/Transcriber.test.mjs.map +1 -0
  82. package/package.json +37 -1
  83. package/src/domain/AiError.ts +21 -0
  84. package/src/domain/Audio.ts +88 -0
  85. package/src/domain/Music.ts +121 -0
  86. package/src/domain/Transcript.ts +83 -0
  87. package/src/index.ts +6 -0
  88. package/src/music-generator/MusicGenerator.test.ts +170 -0
  89. package/src/music-generator/MusicGenerator.ts +123 -0
  90. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
  91. package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
  92. package/src/streaming/JSONL.ts +12 -0
  93. package/src/testing/MockMusicGenerator.ts +170 -0
  94. package/src/testing/MockSpeechSynthesizer.ts +165 -0
  95. package/src/testing/MockTranscriber.ts +139 -0
  96. package/src/transcriber/Transcriber.test.ts +125 -0
  97. 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
+ )