@effect-uai/elevenlabs 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/LICENSE +21 -0
- package/dist/ElevenLabsSynthesizer.d.mts +58 -0
- package/dist/ElevenLabsSynthesizer.d.mts.map +1 -0
- package/dist/ElevenLabsSynthesizer.mjs +82 -0
- package/dist/ElevenLabsSynthesizer.mjs.map +1 -0
- package/dist/ElevenLabsSynthesizer.test.d.mts +1 -0
- package/dist/ElevenLabsSynthesizer.test.mjs +32 -0
- package/dist/ElevenLabsSynthesizer.test.mjs.map +1 -0
- package/dist/ElevenLabsTranscriber.d.mts +57 -0
- package/dist/ElevenLabsTranscriber.d.mts.map +1 -0
- package/dist/ElevenLabsTranscriber.mjs +97 -0
- package/dist/ElevenLabsTranscriber.mjs.map +1 -0
- package/dist/ElevenLabsTranscriber.test.d.mts +1 -0
- package/dist/ElevenLabsTranscriber.test.mjs +58 -0
- package/dist/ElevenLabsTranscriber.test.mjs.map +1 -0
- package/dist/chunk-uyGKjUfl.mjs +14 -0
- package/dist/codec.d.mts +43 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +120 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/codec.test.d.mts +1 -0
- package/dist/codec.test.mjs +142 -0
- package/dist/codec.test.mjs.map +1 -0
- package/dist/dist-DMsg8YLb.mjs +13782 -0
- package/dist/dist-DMsg8YLb.mjs.map +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +5 -0
- package/dist/magic-string.es-CaWgF-3p.mjs +1013 -0
- package/dist/magic-string.es-CaWgF-3p.mjs.map +1 -0
- package/dist/models.d.mts +37 -0
- package/dist/models.d.mts.map +1 -0
- package/dist/models.mjs +1 -0
- package/dist/realtimeStt.d.mts +67 -0
- package/dist/realtimeStt.d.mts.map +1 -0
- package/dist/realtimeStt.mjs +163 -0
- package/dist/realtimeStt.mjs.map +1 -0
- package/dist/realtimeTts.d.mts +31 -0
- package/dist/realtimeTts.d.mts.map +1 -0
- package/dist/realtimeTts.mjs +77 -0
- package/dist/realtimeTts.mjs.map +1 -0
- package/package.json +64 -0
- package/src/ElevenLabsSynthesizer.test.ts +49 -0
- package/src/ElevenLabsSynthesizer.ts +165 -0
- package/src/ElevenLabsTranscriber.test.ts +63 -0
- package/src/ElevenLabsTranscriber.ts +179 -0
- package/src/codec.test.ts +128 -0
- package/src/codec.ts +185 -0
- package/src/index.ts +4 -0
- package/src/models.ts +46 -0
- package/src/realtimeStt.ts +273 -0
- package/src/realtimeTts.ts +147 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtimeTts.mjs","names":[],"sources":["../src/realtimeTts.ts"],"sourcesContent":["import { Cause, Effect, Encoding, Queue, Redacted, Result, Schema, Stream } from \"effect\"\nimport * as Socket from \"effect/unstable/socket/Socket\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport type { AudioChunk, AudioFormat } from \"@effect-uai/core/Audio\"\nimport * as JSONL from \"@effect-uai/core/JSONL\"\nimport {\n defaultFormat,\n formatToOutputSlug,\n type VoiceSettings,\n wireVoiceSettings,\n} from \"./codec.js\"\nimport type { ElevenLabsTtsModel, ElevenLabsVoiceId } from \"./models.js\"\n\nexport type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }\n\nexport type { VoiceSettings } from \"./codec.js\"\n\n/**\n * Incremental-text-in request for `/stream-input`. `voiceSettings`\n * applies only on the BOS frame — mid-stream voice changes are\n * rejected. `autoMode: true` (default) lets the model pick flush\n * boundaries, which is what you want for LLM-token streams.\n */\nexport type StreamSynthesizeRequest = {\n readonly model?: ElevenLabsTtsModel\n readonly voiceId: ElevenLabsVoiceId\n readonly outputFormat?: AudioFormat\n readonly languageCode?: string\n readonly voiceSettings?: VoiceSettings\n readonly autoMode?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// URL + frame builders\n// ---------------------------------------------------------------------------\n\nexport const buildWsUrl = (cfg: Config, request: StreamSynthesizeRequest, outputFormat: string) => {\n const wsBase = (cfg.baseUrl ?? \"https://api.elevenlabs.io/v1\").replace(/^http/, \"ws\")\n const params = new URLSearchParams({\n output_format: outputFormat,\n auto_mode: String(request.autoMode ?? true),\n ...(request.model !== undefined && { model_id: request.model }),\n ...(request.languageCode !== undefined && { language_code: request.languageCode }),\n })\n return `${wsBase}/text-to-speech/${request.voiceId}/stream-input?${params.toString()}`\n}\n\nconst bosFrame = (cfg: Config, request: StreamSynthesizeRequest) => {\n const vs = wireVoiceSettings(request.voiceSettings)\n return JSON.stringify({\n text: \" \",\n \"xi-api-key\": Redacted.value(cfg.apiKey),\n ...(vs !== undefined && { voice_settings: vs }),\n })\n}\n\nconst textFrame = (text: string) => JSON.stringify({ text: text.endsWith(\" \") ? text : `${text} ` })\nconst eosFrame = JSON.stringify({ text: \"\" })\n\n// ---------------------------------------------------------------------------\n// Wire schema (server → client) + helpers\n// ---------------------------------------------------------------------------\n\nconst ServerFrame = Schema.Struct({\n audio: Schema.optional(Schema.NullOr(Schema.String)),\n isFinal: Schema.optional(Schema.NullOr(Schema.Boolean)),\n error: Schema.optional(Schema.Unknown),\n message: Schema.optional(Schema.String),\n})\nconst decodeServerFrame = Schema.decodeUnknownEffect(ServerFrame)\n\nconst decodeAudio = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>\n Result.match(Encoding.decodeBase64(b64), {\n onSuccess: Effect.succeed,\n onFailure: (cause) =>\n Effect.fail(\n new AiError.GenerationFailed({\n provider: \"elevenlabs\",\n raw: { message: \"failed to decode audio frame\", cause },\n }),\n ),\n })\n\nconst handleServerFrame = (queue: Queue.Queue<AudioChunk, Cause.Done>) => (raw: string) =>\n Effect.gen(function* () {\n const json = yield* JSONL.parseSafe(raw)\n if (json === undefined) return\n const decoded = yield* decodeServerFrame(json).pipe(Effect.option)\n if (decoded._tag === \"None\") return\n const frame = decoded.value\n if (frame.error !== undefined) {\n yield* Effect.logWarning(\"[elevenlabs-tts] server error frame\", {\n error: frame.error,\n message: frame.message,\n })\n return\n }\n if (frame.audio == null || frame.audio === \"\") return\n const bytes = yield* decodeAudio(frame.audio).pipe(Effect.option)\n if (bytes._tag === \"Some\") yield* Queue.offer(queue, { bytes: bytes.value })\n })\n\n// ---------------------------------------------------------------------------\n// Stream<string> → Stream<AudioChunk>. Requires `Socket.WebSocketConstructor`.\n// ---------------------------------------------------------------------------\n\nexport const streamSynthesis =\n (cfg: Config) =>\n <E, R>(\n textIn: Stream.Stream<string, E, R>,\n request: StreamSynthesizeRequest,\n ): Stream.Stream<AudioChunk, AiError.AiError | E, R | Socket.WebSocketConstructor> =>\n Stream.unwrap(\n Effect.gen(function* () {\n const slug = yield* formatToOutputSlug(request.outputFormat ?? defaultFormat)\n // ElevenLabs closes `/stream-input` with code 1000 after delivering the\n // final audio chunk. Effect's default treats all close codes as errors,\n // which would surface as a stream failure right after the last audio\n // arrives. Whitelist standard clean-close codes.\n const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg, request, slug), {\n closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,\n })\n const queue = yield* Queue.bounded<AudioChunk, Cause.Done>(64)\n const write = yield* socket.writer\n\n // Writer fiber: BOS → drain text stream → EOS. Socket-side failures\n // end this fiber; the reader still surfaces a clean stream end via\n // `Queue.end` (in the ensuring below) when the upstream WS closes.\n yield* Effect.gen(function* () {\n yield* write(bosFrame(cfg, request))\n yield* Stream.runForEach(textIn, (text) =>\n text.length === 0 ? Effect.void : write(textFrame(text)),\n )\n yield* write(eosFrame)\n }).pipe(Effect.ignore, Effect.forkScoped)\n\n // Reader fiber. `ensuring(Queue.end)` flushes pending chunks then\n // fails the next take with `Done`, which `Stream.fromQueue` treats\n // as a clean end. (`Queue.shutdown` would CLEAR queued items and\n // interrupt pending takes — wrong for graceful teardown.)\n yield* socket\n .runString(handleServerFrame(queue))\n .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)\n\n return Stream.fromQueue(queue)\n }),\n )\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effect-uai/elevenlabs",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "ElevenLabs speech-to-text and text-to-speech provider for @effect-uai/core.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"effect",
|
|
8
|
+
"elevenlabs",
|
|
9
|
+
"speech",
|
|
10
|
+
"stt",
|
|
11
|
+
"transcription",
|
|
12
|
+
"tts"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/elevenlabs",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/betalyra/effect-uai/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Betalyra",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/betalyra/effect-uai",
|
|
23
|
+
"directory": "packages/providers/elevenlabs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./dist/index.mjs",
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.mts",
|
|
37
|
+
"import": "./dist/index.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./ElevenLabsSynthesizer": {
|
|
40
|
+
"types": "./dist/ElevenLabsSynthesizer.d.mts",
|
|
41
|
+
"import": "./dist/ElevenLabsSynthesizer.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./ElevenLabsTranscriber": {
|
|
44
|
+
"types": "./dist/ElevenLabsTranscriber.d.mts",
|
|
45
|
+
"import": "./dist/ElevenLabsTranscriber.mjs"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"effect": "4.0.0-beta.57",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"@effect-uai/core": "0.4.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@effect-uai/core": ">=0.2.0 <1",
|
|
58
|
+
"effect": "4.0.0-beta.57"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsdown",
|
|
62
|
+
"typecheck": "tsc --noEmit"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
5
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
6
|
+
import type { AudioBlob } from "@effect-uai/core/Audio"
|
|
7
|
+
import * as SpeechSynthesizer from "@effect-uai/core/SpeechSynthesizer"
|
|
8
|
+
import * as ElevenLabsSynthesizer from "./ElevenLabsSynthesizer.js"
|
|
9
|
+
|
|
10
|
+
const cfg: ElevenLabsSynthesizer.Config = { apiKey: Redacted.make("test-key") }
|
|
11
|
+
// FetchHttpClient + globalThis.WebSocket are required for `make`, but these
|
|
12
|
+
// tests only exercise the codec and compile-time gating — no real network
|
|
13
|
+
// call is made.
|
|
14
|
+
const live = ElevenLabsSynthesizer.layer(cfg).pipe(
|
|
15
|
+
Layer.provide(FetchHttpClient.layer),
|
|
16
|
+
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
describe("ElevenLabsSynthesizer Layer (compile-time)", () => {
|
|
20
|
+
it("registers `TtsIncrementalText` — `streamSynthesisFrom` clears R to never", () => {
|
|
21
|
+
const tokens: Stream.Stream<string> = Stream.fromIterable(["hello ", "world"])
|
|
22
|
+
const audio = tokens.pipe(
|
|
23
|
+
SpeechSynthesizer.streamSynthesisFrom({
|
|
24
|
+
model: "eleven_flash_v2_5",
|
|
25
|
+
voiceId: "JBFqnCBsd6RMkjVDRZzb",
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
const program = Stream.runDrain(audio).pipe(Effect.provide(live))
|
|
29
|
+
expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("sync `synthesize` and chunked `streamSynthesis` require no marker", () => {
|
|
33
|
+
const synth = SpeechSynthesizer.synthesize({
|
|
34
|
+
text: "hi",
|
|
35
|
+
model: "eleven_multilingual_v2",
|
|
36
|
+
voiceId: "JBFqnCBsd6RMkjVDRZzb",
|
|
37
|
+
}).pipe(Effect.provide(live))
|
|
38
|
+
expectTypeOf(synth).toEqualTypeOf<Effect.Effect<AudioBlob, AiError.AiError, never>>()
|
|
39
|
+
|
|
40
|
+
const _stream = Stream.runCollect(
|
|
41
|
+
SpeechSynthesizer.streamSynthesis({
|
|
42
|
+
text: "hi",
|
|
43
|
+
model: "eleven_multilingual_v2",
|
|
44
|
+
voiceId: "JBFqnCBsd6RMkjVDRZzb",
|
|
45
|
+
}),
|
|
46
|
+
).pipe(Effect.provide(live))
|
|
47
|
+
expect(_stream).toBeDefined()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import type { AudioBlob, AudioChunk } from "@effect-uai/core/Audio"
|
|
6
|
+
import {
|
|
7
|
+
type CommonStreamSynthesizeRequest,
|
|
8
|
+
type CommonSynthesizeRequest,
|
|
9
|
+
SpeechSynthesizer,
|
|
10
|
+
type SpeechSynthesizerService,
|
|
11
|
+
TtsIncrementalText,
|
|
12
|
+
} from "@effect-uai/core/SpeechSynthesizer"
|
|
13
|
+
import {
|
|
14
|
+
defaultFormat,
|
|
15
|
+
formatToOutputSlug,
|
|
16
|
+
httpStatusError,
|
|
17
|
+
transportFailure,
|
|
18
|
+
type VoiceSettings,
|
|
19
|
+
wireVoiceSettings,
|
|
20
|
+
} from "./codec.js"
|
|
21
|
+
import type { ElevenLabsTtsModel, ElevenLabsVoiceId } from "./models.js"
|
|
22
|
+
import { streamSynthesis as realtimeStream } from "./realtimeTts.js"
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Public types
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ElevenLabs-typed synthesize request. `voiceSettings` exposes the
|
|
30
|
+
* provider's prosody/timbre controls; `seed` makes generation
|
|
31
|
+
* deterministic; `previousText` / `nextText` thread context across
|
|
32
|
+
* sequential calls for natural prosody.
|
|
33
|
+
*/
|
|
34
|
+
export type ElevenLabsSynthesizeRequest = Omit<CommonSynthesizeRequest, "model" | "voiceId"> & {
|
|
35
|
+
readonly model: ElevenLabsTtsModel
|
|
36
|
+
readonly voiceId: ElevenLabsVoiceId
|
|
37
|
+
readonly voiceSettings?: VoiceSettings
|
|
38
|
+
readonly seed?: number
|
|
39
|
+
readonly previousText?: string
|
|
40
|
+
readonly nextText?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ElevenLabsSynthesizerService = {
|
|
44
|
+
readonly synthesize: (r: ElevenLabsSynthesizeRequest) => Effect.Effect<AudioBlob, AiError.AiError>
|
|
45
|
+
readonly streamSynthesis: (
|
|
46
|
+
r: ElevenLabsSynthesizeRequest,
|
|
47
|
+
) => Stream.Stream<AudioChunk, AiError.AiError>
|
|
48
|
+
readonly streamSynthesisFrom: SpeechSynthesizerService["streamSynthesisFrom"]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ElevenLabsSynthesizer extends Context.Service<
|
|
52
|
+
ElevenLabsSynthesizer,
|
|
53
|
+
ElevenLabsSynthesizerService
|
|
54
|
+
>()("@betalyra/effect-uai/providers/elevenlabs/ElevenLabsSynthesizer") {}
|
|
55
|
+
|
|
56
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Codec — request → JSON body
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const buildBody = (r: ElevenLabsSynthesizeRequest) => ({
|
|
63
|
+
text: r.text,
|
|
64
|
+
model_id: r.model,
|
|
65
|
+
...(r.languageCode !== undefined && { language_code: r.languageCode }),
|
|
66
|
+
...(r.seed !== undefined && { seed: r.seed }),
|
|
67
|
+
...(r.previousText !== undefined && { previous_text: r.previousText }),
|
|
68
|
+
...(r.nextText !== undefined && { next_text: r.nextText }),
|
|
69
|
+
...(r.voiceSettings !== undefined && { voice_settings: wireVoiceSettings(r.voiceSettings) }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// HTTP plumbing (sync + chunked-HTTP streaming)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://api.elevenlabs.io/v1"
|
|
77
|
+
|
|
78
|
+
const buildHttpRequest = (cfg: Config, r: ElevenLabsSynthesizeRequest, path: "" | "/stream") =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const format = r.outputFormat ?? defaultFormat
|
|
81
|
+
const slug = yield* formatToOutputSlug(format)
|
|
82
|
+
const url = `${baseUrl(cfg)}/text-to-speech/${r.voiceId}${path}?output_format=${slug}`
|
|
83
|
+
const httpRequest = HttpClientRequest.post(url).pipe(
|
|
84
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
85
|
+
HttpClientRequest.bodyJsonUnsafe(buildBody(r)),
|
|
86
|
+
)
|
|
87
|
+
return { httpRequest, format }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const synthesizeImpl = (cfg: Config) => (request: ElevenLabsSynthesizeRequest) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const client = yield* HttpClient.HttpClient
|
|
93
|
+
const { httpRequest, format } = yield* buildHttpRequest(cfg, request, "")
|
|
94
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
95
|
+
if (response.status >= 400) {
|
|
96
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
97
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
98
|
+
}
|
|
99
|
+
const bytes = yield* response.arrayBuffer.pipe(Effect.mapError(transportFailure))
|
|
100
|
+
return { format, bytes: new Uint8Array(bytes) } satisfies AudioBlob
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const streamSynthesisImpl = (cfg: Config) => (request: ElevenLabsSynthesizeRequest) =>
|
|
104
|
+
Stream.unwrap(
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const client = yield* HttpClient.HttpClient
|
|
107
|
+
const { httpRequest } = yield* buildHttpRequest(cfg, request, "/stream")
|
|
108
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
109
|
+
if (response.status >= 400) {
|
|
110
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
111
|
+
return Stream.fail(httpStatusError(response.status, text))
|
|
112
|
+
}
|
|
113
|
+
return response.stream.pipe(
|
|
114
|
+
Stream.mapError(transportFailure),
|
|
115
|
+
Stream.map((bytes): AudioChunk => ({ bytes })),
|
|
116
|
+
)
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Constructors
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export const make = (cfg: Config) =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const client = yield* HttpClient.HttpClient
|
|
127
|
+
const ctor = yield* Socket.WebSocketConstructor
|
|
128
|
+
return {
|
|
129
|
+
synthesize: (r) =>
|
|
130
|
+
synthesizeImpl(cfg)(r).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
131
|
+
streamSynthesis: (r) =>
|
|
132
|
+
streamSynthesisImpl(cfg)(r).pipe(Stream.provideService(HttpClient.HttpClient, client)),
|
|
133
|
+
streamSynthesisFrom: (textIn, request) =>
|
|
134
|
+
realtimeStream(cfg)(textIn, request as ElevenLabsSynthesizeRequest).pipe(
|
|
135
|
+
Stream.provideService(Socket.WebSocketConstructor, ctor),
|
|
136
|
+
),
|
|
137
|
+
} satisfies ElevenLabsSynthesizerService
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Layer registers `ElevenLabsSynthesizer`, the generic
|
|
142
|
+
* `SpeechSynthesizer`, **and `TtsIncrementalText`** — incremental
|
|
143
|
+
* text-in is wired to the realtime `/stream-input` WebSocket. Provide
|
|
144
|
+
* `Socket.layerWebSocketConstructorGlobal` and an `HttpClient` Layer at
|
|
145
|
+
* the call site.
|
|
146
|
+
*/
|
|
147
|
+
export const layer = (cfg: Config) =>
|
|
148
|
+
Layer.mergeAll(
|
|
149
|
+
Layer.effect(ElevenLabsSynthesizer, make(cfg)),
|
|
150
|
+
Layer.effect(
|
|
151
|
+
SpeechSynthesizer,
|
|
152
|
+
Effect.map(
|
|
153
|
+
make(cfg),
|
|
154
|
+
(s): SpeechSynthesizerService => ({
|
|
155
|
+
synthesize: (req: CommonSynthesizeRequest) =>
|
|
156
|
+
s.synthesize(req as ElevenLabsSynthesizeRequest),
|
|
157
|
+
streamSynthesis: (req: CommonSynthesizeRequest) =>
|
|
158
|
+
s.streamSynthesis(req as ElevenLabsSynthesizeRequest),
|
|
159
|
+
streamSynthesisFrom: (textIn, req: CommonStreamSynthesizeRequest) =>
|
|
160
|
+
s.streamSynthesisFrom(textIn, req),
|
|
161
|
+
}),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
Layer.succeed(TtsIncrementalText, undefined),
|
|
165
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Effect, Layer, Redacted, Stream } from "effect"
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
5
|
+
import type * as AiError from "@effect-uai/core/AiError"
|
|
6
|
+
import type { TranscriptResult } from "@effect-uai/core/Transcript"
|
|
7
|
+
import * as Transcriber from "@effect-uai/core/Transcriber"
|
|
8
|
+
import * as ElevenLabsTranscriber from "./ElevenLabsTranscriber.js"
|
|
9
|
+
|
|
10
|
+
const cfg: ElevenLabsTranscriber.Config = { apiKey: Redacted.make("test-key") }
|
|
11
|
+
// FetchHttpClient + globalThis.WebSocket are required for `make`, but these
|
|
12
|
+
// tests only exercise the codec + compile-time / runtime branches — no real
|
|
13
|
+
// HTTP or WS connection is opened.
|
|
14
|
+
const live = ElevenLabsTranscriber.layer(cfg).pipe(
|
|
15
|
+
Layer.provide(FetchHttpClient.layer),
|
|
16
|
+
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const dummyAudio = {
|
|
20
|
+
_tag: "bytes" as const,
|
|
21
|
+
bytes: new Uint8Array([0, 1, 2, 3]),
|
|
22
|
+
mimeType: "audio/wav" as const,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("ElevenLabsTranscriber input format guard (runtime)", () => {
|
|
26
|
+
it("fails Unsupported for unsupported raw encodings", async () => {
|
|
27
|
+
const program = ElevenLabsTranscriber.ElevenLabsTranscriber.use((s) =>
|
|
28
|
+
Stream.runDrain(
|
|
29
|
+
s.streamTranscriptionFrom(Stream.fromIterable([new Uint8Array([0])]), {
|
|
30
|
+
model: "scribe_v2_realtime",
|
|
31
|
+
inputFormat: { container: "mp3", encoding: "mp3", sampleRate: 44100 },
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(live)))
|
|
36
|
+
expect(exit._tag).toBe("Failure")
|
|
37
|
+
if (exit._tag === "Failure") {
|
|
38
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
39
|
+
expect(JSON.stringify(exit.cause)).toContain("inputFormat")
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("ElevenLabsTranscriber Layer (compile-time)", () => {
|
|
45
|
+
it("registers `SttStreaming` — `streamTranscriptionFrom` clears R to never", () => {
|
|
46
|
+
const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
|
|
47
|
+
const events = audio.pipe(
|
|
48
|
+
Transcriber.streamTranscriptionFrom({
|
|
49
|
+
model: "scribe_v2_realtime",
|
|
50
|
+
inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
const provided = Stream.runDrain(events).pipe(Effect.provide(live))
|
|
54
|
+
expectTypeOf(provided).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("sync `transcribe` requires no marker", () => {
|
|
58
|
+
const t = Transcriber.transcribe({ audio: dummyAudio, model: "scribe_v2" }).pipe(
|
|
59
|
+
Effect.provide(live),
|
|
60
|
+
)
|
|
61
|
+
expectTypeOf(t).toEqualTypeOf<Effect.Effect<TranscriptResult, AiError.AiError, never>>()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
4
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
5
|
+
import type { TranscriptResult, WordTimestamp } from "@effect-uai/core/Transcript"
|
|
6
|
+
import {
|
|
7
|
+
type CommonTranscribeRequest,
|
|
8
|
+
SttStreaming,
|
|
9
|
+
Transcriber,
|
|
10
|
+
type TranscriberService,
|
|
11
|
+
} from "@effect-uai/core/Transcriber"
|
|
12
|
+
import { audioToBlob, defaultFileName, httpStatusError, transportFailure } from "./codec.js"
|
|
13
|
+
import type { ElevenLabsSttModel } from "./models.js"
|
|
14
|
+
import { streamTranscription } from "./realtimeStt.js"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Public types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ElevenLabs-typed transcribe request. `model` narrows to
|
|
22
|
+
* `ElevenLabsSttModel`. `numSpeakers` hints diarization. `audioEvents`
|
|
23
|
+
* surfaces `(laughter)` / `(music)` tags inline in `text` (provider
|
|
24
|
+
* default: true). `fileName` overrides the multipart upload's
|
|
25
|
+
* `filename`; ElevenLabs uses the extension to detect format.
|
|
26
|
+
*/
|
|
27
|
+
export type ElevenLabsTranscribeRequest = Omit<CommonTranscribeRequest, "model"> & {
|
|
28
|
+
readonly model: ElevenLabsSttModel
|
|
29
|
+
readonly numSpeakers?: number
|
|
30
|
+
readonly audioEvents?: boolean
|
|
31
|
+
readonly fileName?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ElevenLabsTranscriberService = {
|
|
35
|
+
readonly transcribe: (
|
|
36
|
+
r: ElevenLabsTranscribeRequest,
|
|
37
|
+
) => Effect.Effect<TranscriptResult, AiError.AiError>
|
|
38
|
+
readonly streamTranscriptionFrom: TranscriberService["streamTranscriptionFrom"]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ElevenLabsTranscriber extends Context.Service<
|
|
42
|
+
ElevenLabsTranscriber,
|
|
43
|
+
ElevenLabsTranscriberService
|
|
44
|
+
>()("@betalyra/effect-uai/providers/elevenlabs/ElevenLabsTranscriber") {}
|
|
45
|
+
|
|
46
|
+
export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Response schema
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const Word = Schema.Struct({
|
|
53
|
+
text: Schema.String,
|
|
54
|
+
start: Schema.optional(Schema.Number),
|
|
55
|
+
end: Schema.optional(Schema.Number),
|
|
56
|
+
type: Schema.optional(Schema.String),
|
|
57
|
+
speaker_id: Schema.optional(Schema.String),
|
|
58
|
+
logprob: Schema.optional(Schema.Number),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const Wire = Schema.Struct({
|
|
62
|
+
text: Schema.String,
|
|
63
|
+
language_code: Schema.optional(Schema.String),
|
|
64
|
+
language_probability: Schema.optional(Schema.Number),
|
|
65
|
+
words: Schema.optional(Schema.Array(Word)),
|
|
66
|
+
transcription_id: Schema.optional(Schema.String),
|
|
67
|
+
})
|
|
68
|
+
const decodeWire = Schema.decodeUnknownEffect(Wire)
|
|
69
|
+
|
|
70
|
+
const toWordTimestamp = (w: typeof Word.Type): WordTimestamp | undefined =>
|
|
71
|
+
w.start === undefined || w.end === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: {
|
|
74
|
+
text: w.text,
|
|
75
|
+
startSeconds: w.start,
|
|
76
|
+
endSeconds: w.end,
|
|
77
|
+
...(w.logprob !== undefined && { confidence: Math.exp(w.logprob) }),
|
|
78
|
+
...(w.speaker_id !== undefined && { speakerId: w.speaker_id }),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Multipart body
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const buildForm = (request: ElevenLabsTranscribeRequest) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const blob = yield* audioToBlob(request.audio)
|
|
88
|
+
const fileName = request.fileName ?? defaultFileName(request.audio.mimeType ?? "")
|
|
89
|
+
const form = new FormData()
|
|
90
|
+
form.set("model_id", request.model)
|
|
91
|
+
form.set("file", blob, fileName)
|
|
92
|
+
if (request.language !== undefined) form.set("language_code", request.language)
|
|
93
|
+
if (request.diarization !== undefined)
|
|
94
|
+
form.set("diarize", request.diarization ? "true" : "false")
|
|
95
|
+
if (request.wordTimestamps === true) form.set("timestamps_granularity", "word")
|
|
96
|
+
if (request.numSpeakers !== undefined) form.set("num_speakers", String(request.numSpeakers))
|
|
97
|
+
if (request.audioEvents !== undefined)
|
|
98
|
+
form.set("tag_audio_events", request.audioEvents ? "true" : "false")
|
|
99
|
+
return form
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// HTTP plumbing
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const baseUrl = (cfg: Config) => cfg.baseUrl ?? "https://api.elevenlabs.io/v1"
|
|
107
|
+
|
|
108
|
+
const transcribeImpl = (cfg: Config) => (request: ElevenLabsTranscribeRequest) =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const client = yield* HttpClient.HttpClient
|
|
111
|
+
const form = yield* buildForm(request)
|
|
112
|
+
const httpRequest = HttpClientRequest.post(`${baseUrl(cfg)}/speech-to-text`).pipe(
|
|
113
|
+
HttpClientRequest.setHeader("xi-api-key", Redacted.value(cfg.apiKey)),
|
|
114
|
+
HttpClientRequest.bodyFormData(form),
|
|
115
|
+
)
|
|
116
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
117
|
+
if (response.status >= 400) {
|
|
118
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
119
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
120
|
+
}
|
|
121
|
+
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
122
|
+
const wire = yield* decodeWire(json).pipe(
|
|
123
|
+
Effect.mapError(
|
|
124
|
+
(cause) => new AiError.GenerationFailed({ provider: "elevenlabs", raw: cause }),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
const words = wire.words
|
|
128
|
+
?.map(toWordTimestamp)
|
|
129
|
+
.filter((w): w is WordTimestamp => w !== undefined)
|
|
130
|
+
return {
|
|
131
|
+
text: wire.text,
|
|
132
|
+
...(wire.language_code !== undefined && { languageCode: wire.language_code }),
|
|
133
|
+
...(words !== undefined && words.length > 0 && { words }),
|
|
134
|
+
raw: json,
|
|
135
|
+
} satisfies TranscriptResult
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Constructors
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export const make = (cfg: Config) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const client = yield* HttpClient.HttpClient
|
|
145
|
+
const ctor = yield* Socket.WebSocketConstructor
|
|
146
|
+
return {
|
|
147
|
+
transcribe: (r) =>
|
|
148
|
+
transcribeImpl(cfg)(r).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
149
|
+
streamTranscriptionFrom: (audioIn, request) =>
|
|
150
|
+
streamTranscription(cfg)(audioIn, request).pipe(
|
|
151
|
+
Stream.provideService(HttpClient.HttpClient, client),
|
|
152
|
+
Stream.provideService(Socket.WebSocketConstructor, ctor),
|
|
153
|
+
),
|
|
154
|
+
} satisfies ElevenLabsTranscriberService
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Layer registers `ElevenLabsTranscriber`, the generic `Transcriber`,
|
|
159
|
+
* **and the `SttStreaming` capability marker** — `streamTranscriptionFrom`
|
|
160
|
+
* is wired to the realtime WebSocket. Provide
|
|
161
|
+
* `Socket.layerWebSocketConstructorGlobal` (or a custom constructor) +
|
|
162
|
+
* an `HttpClient` Layer at the call site.
|
|
163
|
+
*/
|
|
164
|
+
export const layer = (cfg: Config) =>
|
|
165
|
+
Layer.mergeAll(
|
|
166
|
+
Layer.effect(ElevenLabsTranscriber, make(cfg)),
|
|
167
|
+
Layer.effect(
|
|
168
|
+
Transcriber,
|
|
169
|
+
Effect.map(
|
|
170
|
+
make(cfg),
|
|
171
|
+
(s): TranscriberService => ({
|
|
172
|
+
transcribe: (req: CommonTranscribeRequest) =>
|
|
173
|
+
s.transcribe(req as ElevenLabsTranscribeRequest),
|
|
174
|
+
streamTranscriptionFrom: s.streamTranscriptionFrom,
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
Layer.succeed(SttStreaming, undefined),
|
|
179
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Effect, Encoding } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import type { AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
import { audioToBlob, defaultFileName, formatToOutputSlug, httpStatusError } from "./codec.js"
|
|
5
|
+
|
|
6
|
+
describe("formatToOutputSlug", () => {
|
|
7
|
+
it("encodes mp3 with default bitrate when omitted", async () => {
|
|
8
|
+
const slug = await Effect.runPromise(
|
|
9
|
+
formatToOutputSlug({ container: "mp3", encoding: "mp3", sampleRate: 44100 }),
|
|
10
|
+
)
|
|
11
|
+
expect(slug).toBe("mp3_44100_128")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("honours an explicit mp3 bitrate", async () => {
|
|
15
|
+
const slug = await Effect.runPromise(
|
|
16
|
+
formatToOutputSlug({ container: "mp3", encoding: "mp3", sampleRate: 22050, bitRate: 32 }),
|
|
17
|
+
)
|
|
18
|
+
expect(slug).toBe("mp3_22050_32")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("encodes raw pcm_s16le as `pcm_<sampleRate>`", async () => {
|
|
22
|
+
const slug = await Effect.runPromise(
|
|
23
|
+
formatToOutputSlug({ container: "raw", encoding: "pcm_s16le", sampleRate: 16000 }),
|
|
24
|
+
)
|
|
25
|
+
expect(slug).toBe("pcm_16000")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("encodes mu-law as `ulaw_<sampleRate>`", async () => {
|
|
29
|
+
const slug = await Effect.runPromise(
|
|
30
|
+
formatToOutputSlug({ container: "raw", encoding: "pcm_mulaw", sampleRate: 8000 }),
|
|
31
|
+
)
|
|
32
|
+
expect(slug).toBe("ulaw_8000")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("encodes opus with default bitrate", async () => {
|
|
36
|
+
const slug = await Effect.runPromise(
|
|
37
|
+
formatToOutputSlug({ container: "opus", encoding: "opus", sampleRate: 48000 }),
|
|
38
|
+
)
|
|
39
|
+
expect(slug).toBe("opus_48000_128")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("encodes wav as `wav_<sampleRate>`", async () => {
|
|
43
|
+
const slug = await Effect.runPromise(
|
|
44
|
+
formatToOutputSlug({ container: "wav", encoding: "pcm_s16le", sampleRate: 48000 }),
|
|
45
|
+
)
|
|
46
|
+
expect(slug).toBe("wav_48000")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("fails Unsupported for flac / aac / webm / ogg", async () => {
|
|
50
|
+
const exit = await Effect.runPromiseExit(
|
|
51
|
+
formatToOutputSlug({ container: "flac", encoding: "flac", sampleRate: 44100 }),
|
|
52
|
+
)
|
|
53
|
+
expect(exit._tag).toBe("Failure")
|
|
54
|
+
if (exit._tag === "Failure") {
|
|
55
|
+
expect(JSON.stringify(exit.cause)).toContain("Unsupported")
|
|
56
|
+
expect(JSON.stringify(exit.cause)).toContain("outputFormat")
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("audioToBlob", () => {
|
|
62
|
+
it("wraps bytes into a Blob with the right MIME type", async () => {
|
|
63
|
+
const source: AudioSource = {
|
|
64
|
+
_tag: "bytes",
|
|
65
|
+
bytes: new Uint8Array([1, 2, 3, 4]),
|
|
66
|
+
mimeType: "audio/wav",
|
|
67
|
+
}
|
|
68
|
+
const blob = await Effect.runPromise(audioToBlob(source))
|
|
69
|
+
expect(blob.type).toBe("audio/wav")
|
|
70
|
+
expect(blob.size).toBe(4)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("decodes a base64 source into bytes", async () => {
|
|
74
|
+
const original = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
|
75
|
+
const source: AudioSource = {
|
|
76
|
+
_tag: "base64",
|
|
77
|
+
base64: Encoding.encodeBase64(original),
|
|
78
|
+
mimeType: "audio/mpeg",
|
|
79
|
+
}
|
|
80
|
+
const blob = await Effect.runPromise(audioToBlob(source))
|
|
81
|
+
expect(blob.type).toBe("audio/mpeg")
|
|
82
|
+
expect(blob.size).toBe(4)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("fails InvalidRequest for url sources", async () => {
|
|
86
|
+
const source: AudioSource = { _tag: "url", url: "https://x/y.mp3", mimeType: "audio/mpeg" }
|
|
87
|
+
const exit = await Effect.runPromiseExit(audioToBlob(source))
|
|
88
|
+
expect(exit._tag).toBe("Failure")
|
|
89
|
+
if (exit._tag === "Failure") {
|
|
90
|
+
expect(JSON.stringify(exit.cause)).toContain("InvalidRequest")
|
|
91
|
+
expect(JSON.stringify(exit.cause)).toContain("cloud_storage_url")
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe("defaultFileName", () => {
|
|
97
|
+
it("maps audio/mpeg → audio.mp3", () => {
|
|
98
|
+
expect(defaultFileName("audio/mpeg")).toBe("audio.mp3")
|
|
99
|
+
})
|
|
100
|
+
it("maps audio/wav → audio.wav", () => {
|
|
101
|
+
expect(defaultFileName("audio/wav")).toBe("audio.wav")
|
|
102
|
+
})
|
|
103
|
+
it("falls back to audio (no extension) for unknown MIME", () => {
|
|
104
|
+
expect(defaultFileName("application/octet-stream")).toBe("audio")
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe("httpStatusError", () => {
|
|
109
|
+
const tags = (status: number) => JSON.parse(JSON.stringify(httpStatusError(status, "body")))._tag
|
|
110
|
+
it("maps 429 to RateLimited", () => expect(tags(429)).toBe("RateLimited"))
|
|
111
|
+
it("maps 408/504 to Timeout", () => {
|
|
112
|
+
expect(tags(408)).toBe("Timeout")
|
|
113
|
+
expect(tags(504)).toBe("Timeout")
|
|
114
|
+
})
|
|
115
|
+
it("maps 401/402/403 to AuthFailed", () => {
|
|
116
|
+
expect(tags(401)).toBe("AuthFailed")
|
|
117
|
+
expect(tags(402)).toBe("AuthFailed")
|
|
118
|
+
expect(tags(403)).toBe("AuthFailed")
|
|
119
|
+
})
|
|
120
|
+
it("maps 5xx to Unavailable", () => {
|
|
121
|
+
expect(tags(500)).toBe("Unavailable")
|
|
122
|
+
expect(tags(503)).toBe("Unavailable")
|
|
123
|
+
})
|
|
124
|
+
it("maps other 4xx to InvalidRequest", () => {
|
|
125
|
+
expect(tags(400)).toBe("InvalidRequest")
|
|
126
|
+
expect(tags(404)).toBe("InvalidRequest")
|
|
127
|
+
})
|
|
128
|
+
})
|