Initial Commit
This commit is contained in:
88
packages/media/audio/voice/README.md
Normal file
88
packages/media/audio/voice/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/voice - Provider-agnostic voice core: abstract interfaces, shared types, Zod schemas, and structured errors for TTS, STT, voice management, sound effects, and voice isolation.
|
||||
|
||||
## Summary
|
||||
|
||||
Provider-agnostic voice core for Suaveplan. Defines the abstract
|
||||
interfaces, shared types, Zod schemas, and structured error hierarchy that
|
||||
every voice provider adapter (`@suaveplan/voice-elevenlabs`, future
|
||||
`-openai`, `-google`, `-azure`, `-polly`) implements. This package contains
|
||||
zero provider-specific code, no HTTP, no API keys — it is the contract.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [effects](./src/effects/effects.md) — The text-to-sound-effect engine interface for `@suaveplan/voice`. Defines the `SoundEffectEngine` contract every sound-…
|
||||
- [effects](./src/effects/effects.md) — The text-to-sound-effect engine interface for `@suaveplan/voice`. Defines the `SoundEffectEngine` contract every sound-…
|
||||
- [errors](./src/errors/errors.md) — Stable error class hierarchy and code registry for `@suaveplan/voice`. Every typed error in this module extends `VoiceE…
|
||||
- [errors](./src/errors/errors.md) — Stable error class hierarchy and code registry for `@suaveplan/voice`. Every typed error in this module extends `VoiceE…
|
||||
- [isolation](./src/isolation/isolation.md) — The voice-isolation engine interface for `@suaveplan/voice`. Defines the `VoiceIsolationEngine` contract every voice-is…
|
||||
- [isolation](./src/isolation/isolation.md) — The voice-isolation engine interface for `@suaveplan/voice`. Defines the `VoiceIsolationEngine` contract every voice-is…
|
||||
- [stt](./src/stt/stt.md) — The speech-to-text (STT) engine interface for `@suaveplan/voice`. Defines the `TranscriptionEngine` contract every STT…
|
||||
- [stt](./src/stt/stt.md) — The speech-to-text (STT) engine interface for `@suaveplan/voice`. Defines the `TranscriptionEngine` contract every STT…
|
||||
- [tts](./src/tts/tts.md) — The text-to-speech (TTS) engine interface for `@suaveplan/voice`. Defines the `VoiceSynthesizer` contract every TTS pro…
|
||||
- [tts](./src/tts/tts.md) — The text-to-speech (TTS) engine interface for `@suaveplan/voice`. Defines the `VoiceSynthesizer` contract every TTS pro…
|
||||
- [types](./src/types/types.md) — Shared types and Zod schemas used by every voice engine interface in `@suaveplan/voice`. The module is the single sourc…
|
||||
- [types](./src/types/types.md) — Shared types and Zod schemas used by every voice engine interface in `@suaveplan/voice`. The module is the single sourc…
|
||||
- [voices](./src/voices/voices.md) — The voice repository interface for `@suaveplan/voice` — read-only listing and lookup of voices available to the active…
|
||||
- [voices](./src/voices/voices.md) — The voice repository interface for `@suaveplan/voice` — read-only listing and lookup of voices available to the active…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/voice
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Consume the contract via a provider adapter (here, ElevenLabs). The adapter
|
||||
is responsible for transport; this package owns the shape:
|
||||
|
||||
```ts
|
||||
import type { VoiceSynthesizer } from "@suaveplan/voice";
|
||||
import { createElevenLabsVoice } from "@suaveplan/voice-elevenlabs";
|
||||
|
||||
const { tts } = createElevenLabsVoice({ apiKey: process.env.ELEVENLABS_API_KEY! });
|
||||
|
||||
const synth: VoiceSynthesizer = tts;
|
||||
const result = await synth.synthesize({
|
||||
text: "Hello, world.",
|
||||
voiceId: "21m00Tcm4TlvDq8ikWAM",
|
||||
});
|
||||
// result.audio is a Uint8Array, result.format is an AudioFormat
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`errors/errors`](src/errors/errors.md) — `VoiceError` base class and 12 typed subclasses with stable `VOICE.*` codes.
|
||||
- [`types/types`](src/types/types.md) — `AudioFormat`, `Voice`, `VoiceSettings`, `WordTimestamp`, `SpeakerSegment`, `DetectedEntity` and their Zod schemas.
|
||||
- [`tts/tts`](src/tts/tts.md) — `VoiceSynthesizer` interface, `SynthesisRequest`, `SynthesisResult`.
|
||||
- [`stt/stt`](src/stt/stt.md) — `TranscriptionEngine` interface, `TranscriptionRequest`, `TranscriptionResult`.
|
||||
- [`voices/voices`](src/voices/voices.md) — `VoiceRepository` interface for listing and looking up voices.
|
||||
- [`effects/effects`](src/effects/effects.md) — `SoundEffectEngine` interface for text-to-sound-effect generation.
|
||||
- [`isolation/isolation`](src/isolation/isolation.md) — `VoiceIsolationEngine` interface for extracting a voice from mixed audio.
|
||||
|
||||
## Design
|
||||
|
||||
`@suaveplan/voice` is a pure abstraction layer:
|
||||
|
||||
1. **No transport.** This package contains no `fetch`, no `WebSocket`, no
|
||||
retry logic, no rate-limit handling. Provider adapters own all of that.
|
||||
2. **No factories tied to providers.** The package never knows which adapter
|
||||
is plugged in. Composition happens at the adapter site
|
||||
(`createElevenLabsVoice`, `createOpenAIVoice`, etc.).
|
||||
3. **One canonical type per concept.** `Voice`, `AudioFormat`,
|
||||
`WordTimestamp`, `SynthesisRequest`, etc. — adapters map their native
|
||||
shapes onto these at the package boundary.
|
||||
4. **Stable error codes.** Every adapter remaps its transport errors onto
|
||||
the 12 codes in `VOICE_CODES`. Callers branch on `error.code` or
|
||||
`instanceof TtsSynthesisFailedError` and never touch upstream details.
|
||||
5. **Zod schemas mirror every public type.** Inputs validate at API
|
||||
boundaries; outputs validate when crossing process or service borders.
|
||||
`AbortSignal` and binary `audio` fields are excluded from schemas (they
|
||||
have no canonical encoding) and validate structurally at runtime.
|
||||
|
||||
Provider-specific extensions (voice cloning, conversational AI, music,
|
||||
dubbing, voice changing) ship in their own packages under separate
|
||||
abstractions when those domains stabilise across two or more vendors.
|
||||
|
||||
132
packages/media/audio/voice/package.json
Normal file
132
packages/media/audio/voice/package.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"name": "@suaveplan/voice",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Provider-agnostic voice core: abstract interfaces, shared types, Zod schemas, and structured errors for TTS, STT, voice management, sound effects, and voice isolation.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
},
|
||||
"./tts/tts": {
|
||||
"import": {
|
||||
"types": "./dist/tts/tts.d.ts",
|
||||
"default": "./dist/tts/tts.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/tts/tts.d.ts",
|
||||
"default": "./dist/tts/tts.js"
|
||||
}
|
||||
},
|
||||
"./stt/stt": {
|
||||
"import": {
|
||||
"types": "./dist/stt/stt.d.ts",
|
||||
"default": "./dist/stt/stt.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/stt/stt.d.ts",
|
||||
"default": "./dist/stt/stt.js"
|
||||
}
|
||||
},
|
||||
"./voices/voices": {
|
||||
"import": {
|
||||
"types": "./dist/voices/voices.d.ts",
|
||||
"default": "./dist/voices/voices.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/voices/voices.d.ts",
|
||||
"default": "./dist/voices/voices.js"
|
||||
}
|
||||
},
|
||||
"./effects/effects": {
|
||||
"import": {
|
||||
"types": "./dist/effects/effects.d.ts",
|
||||
"default": "./dist/effects/effects.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/effects/effects.d.ts",
|
||||
"default": "./dist/effects/effects.js"
|
||||
}
|
||||
},
|
||||
"./isolation/isolation": {
|
||||
"import": {
|
||||
"types": "./dist/isolation/isolation.d.ts",
|
||||
"default": "./dist/isolation/isolation.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/isolation/isolation.d.ts",
|
||||
"default": "./dist/isolation/isolation.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
98
packages/media/audio/voice/src/effects/effects.md
Normal file
98
packages/media/audio/voice/src/effects/effects.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# `effects/effects`
|
||||
|
||||
The text-to-sound-effect engine interface for `@suaveplan/voice`. Defines the
|
||||
`SoundEffectEngine` contract every sound-effect provider adapter must
|
||||
implement, plus `SoundEffectRequest` / `SoundEffectResult` shapes, Zod
|
||||
schemas for both, and a runtime type guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Sound-effect generation is a distinct capability from speech synthesis:
|
||||
prompts describe non-vocal audio (rain, footsteps, magical chimes, engine
|
||||
roars), durations are typically short and bounded, and looping semantics
|
||||
matter for game and ambient applications. This module models that surface
|
||||
explicitly rather than overloading the TTS interface.
|
||||
|
||||
## Features
|
||||
|
||||
- `SoundEffectEngine` interface with `providerName` and `generate`.
|
||||
- `SoundEffectRequest` covering text prompt, optional duration (`0.5..30`
|
||||
seconds), loop flag, prompt-influence weight, model id, and output format.
|
||||
- `SoundEffectResult` containing audio bytes, format, optional duration, and
|
||||
request id.
|
||||
- Zod schemas excluding `AbortSignal`.
|
||||
- Runtime type guard `isSoundEffectEngine`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SoundEffectEngine } from "@suaveplan/voice/effects/effects";
|
||||
|
||||
async function generateExplosion(
|
||||
effects: SoundEffectEngine,
|
||||
): Promise<Uint8Array> {
|
||||
const result = await effects.generate({ text: "explosion" });
|
||||
return result.audio;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Generate a 10-second looping ocean ambience with a strong prompt-influence
|
||||
bias and a specific output format:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SoundEffectEngine,
|
||||
SoundEffectRequest,
|
||||
} from "@suaveplan/voice/effects/effects";
|
||||
|
||||
async function ambientOcean(effects: SoundEffectEngine, signal: AbortSignal) {
|
||||
const req: SoundEffectRequest = {
|
||||
text: "calm ocean waves rolling onto a sandy beach at dawn",
|
||||
durationSeconds: 10,
|
||||
loop: true,
|
||||
promptInfluence: 0.85,
|
||||
outputFormat: "opus_48000",
|
||||
signal,
|
||||
};
|
||||
return effects.generate(req);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SoundEffectEngine` — interface with:
|
||||
- `providerName: string` — stable provider identifier.
|
||||
- `generate(request: SoundEffectRequest): Promise<SoundEffectResult>`.
|
||||
- `SoundEffectRequest` — TypeScript interface with:
|
||||
- `text` — required, non-empty prompt.
|
||||
- `durationSeconds?` — bounded to `[0.5, 30]`.
|
||||
- `loop?`, `promptInfluence?` (0..1), `modelId?`, `outputFormat?`,
|
||||
`signal?`.
|
||||
- `SoundEffectResult` — TypeScript interface with:
|
||||
- `audio` — `Uint8Array`.
|
||||
- `format` — `AudioFormat`.
|
||||
- `durationMs?`, `requestId?`.
|
||||
- `SoundEffectRequestSchema` / `SoundEffectResultSchema` — Zod parsers.
|
||||
- `isSoundEffectEngine(value): value is SoundEffectEngine` — duck-type
|
||||
guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Duration upper-bound of 30 seconds is the published ElevenLabs maximum and
|
||||
matches typical industry limits. Providers with shorter limits should
|
||||
reject at their boundary with `SoundEffectGenerationFailedError`.
|
||||
- `loop` is a hint that the produced audio should be loop-compatible
|
||||
(start/end alignment, no silent tails). Providers may ignore the flag if
|
||||
unsupported.
|
||||
- `promptInfluence` controls how strictly the output adheres to the prompt —
|
||||
higher values produce more literal interpretations at the cost of variety.
|
||||
- Streaming sound effects is not part of this base interface. Most
|
||||
providers return the full audio in a single response; a future extension
|
||||
may add a `generateStream` method when a use case justifies it.
|
||||
- The runtime guard checks `providerName: string` and `generate: function`.
|
||||
24
packages/media/audio/voice/src/effects/effects.schemas.ts
Normal file
24
packages/media/audio/voice/src/effects/effects.schemas.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import z from "zod";
|
||||
import { AudioFormatSchema } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for `SoundEffectRequest`. Excludes `signal` (`AbortSignal`).
|
||||
* Duration is bounded to `[0.5, 30]` seconds — providers may impose tighter
|
||||
* upstream limits and reject at their boundary.
|
||||
*/
|
||||
export const SoundEffectRequestSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
durationSeconds: z.number().min(0.5).max(30).optional(),
|
||||
loop: z.boolean().optional(),
|
||||
promptInfluence: z.number().min(0).max(1).optional(),
|
||||
modelId: z.string().min(1).optional(),
|
||||
outputFormat: AudioFormatSchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `SoundEffectResult`. */
|
||||
export const SoundEffectResultSchema = z.object({
|
||||
audio: z.instanceof(Uint8Array),
|
||||
format: AudioFormatSchema,
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
requestId: z.string().min(1).optional(),
|
||||
});
|
||||
90
packages/media/audio/voice/src/effects/effects.test.ts
Normal file
90
packages/media/audio/voice/src/effects/effects.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isSoundEffectEngine } from "./effects.js";
|
||||
import {
|
||||
SoundEffectRequestSchema,
|
||||
SoundEffectResultSchema,
|
||||
} from "./effects.schemas.js";
|
||||
|
||||
describe("SoundEffectRequestSchema", () => {
|
||||
it("accepts a minimal request", () => {
|
||||
expect(SoundEffectRequestSchema.parse({ text: "explosion" })).toEqual({
|
||||
text: "explosion",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a fully-specified request", () => {
|
||||
const r = {
|
||||
text: "ocean waves",
|
||||
durationSeconds: 10,
|
||||
loop: true,
|
||||
promptInfluence: 0.7,
|
||||
modelId: "sound_v2",
|
||||
outputFormat: "mp3_44100_128" as const,
|
||||
};
|
||||
expect(SoundEffectRequestSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("rejects an empty text", () => {
|
||||
expect(() => SoundEffectRequestSchema.parse({ text: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects durationSeconds outside 0.5..30", () => {
|
||||
expect(() =>
|
||||
SoundEffectRequestSchema.parse({ text: "x", durationSeconds: 0.4 })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
SoundEffectRequestSchema.parse({ text: "x", durationSeconds: 31 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects promptInfluence outside 0..1", () => {
|
||||
expect(() =>
|
||||
SoundEffectRequestSchema.parse({ text: "x", promptInfluence: 1.5 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SoundEffectResultSchema", () => {
|
||||
it("accepts a minimal result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1, 2]),
|
||||
format: "mp3_44100_128" as const,
|
||||
};
|
||||
expect(SoundEffectResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("accepts a fully-specified result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1]),
|
||||
format: "wav_44100" as const,
|
||||
durationMs: 5000,
|
||||
requestId: "req-1",
|
||||
};
|
||||
expect(SoundEffectResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSoundEffectEngine", () => {
|
||||
it("accepts a valid engine shape", () => {
|
||||
const stub = { providerName: "test", generate: async () => ({}) };
|
||||
expect(isSoundEffectEngine(stub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null", () => {
|
||||
expect(isSoundEffectEngine(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects primitives", () => {
|
||||
expect(isSoundEffectEngine("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing generate", () => {
|
||||
expect(isSoundEffectEngine({ providerName: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects with non-string providerName", () => {
|
||||
expect(
|
||||
isSoundEffectEngine({ providerName: 1, generate: () => {} })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
13
packages/media/audio/voice/src/effects/effects.ts
Normal file
13
packages/media/audio/voice/src/effects/effects.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SoundEffectEngine } from "./effects.types.js";
|
||||
|
||||
/** Runtime type guard for `SoundEffectEngine`. */
|
||||
export function isSoundEffectEngine(
|
||||
value: unknown
|
||||
): value is SoundEffectEngine {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["providerName"] === "string" &&
|
||||
typeof obj["generate"] === "function"
|
||||
);
|
||||
}
|
||||
28
packages/media/audio/voice/src/effects/effects.types.ts
Normal file
28
packages/media/audio/voice/src/effects/effects.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AudioFormat } from "../types/types.js";
|
||||
|
||||
/** A request to generate a sound effect from a textual prompt. */
|
||||
export interface SoundEffectRequest {
|
||||
readonly text: string;
|
||||
readonly durationSeconds?: number;
|
||||
readonly loop?: boolean;
|
||||
readonly promptInfluence?: number;
|
||||
readonly modelId?: string;
|
||||
readonly outputFormat?: AudioFormat;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A completed sound-effect generation result. */
|
||||
export interface SoundEffectResult {
|
||||
readonly audio: Uint8Array;
|
||||
readonly format: AudioFormat;
|
||||
readonly durationMs?: number;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
/** The sound-effect generator interface. */
|
||||
export interface SoundEffectEngine {
|
||||
/** Stable provider identifier. */
|
||||
readonly providerName: string;
|
||||
/** Generate a sound effect for the supplied prompt. */
|
||||
generate(request: SoundEffectRequest): Promise<SoundEffectResult>;
|
||||
}
|
||||
10
packages/media/audio/voice/src/effects/index.ts
Normal file
10
packages/media/audio/voice/src/effects/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { isSoundEffectEngine } from "./effects.js";
|
||||
export {
|
||||
SoundEffectRequestSchema,
|
||||
SoundEffectResultSchema,
|
||||
} from "./effects.schemas.js";
|
||||
export type {
|
||||
SoundEffectEngine,
|
||||
SoundEffectRequest,
|
||||
SoundEffectResult,
|
||||
} from "./effects.types.js";
|
||||
122
packages/media/audio/voice/src/errors/errors.md
Normal file
122
packages/media/audio/voice/src/errors/errors.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# `errors/errors`
|
||||
|
||||
Stable error class hierarchy and code registry for `@suaveplan/voice`. Every
|
||||
typed error in this module extends `VoiceError`, which in turn extends
|
||||
`SuaveplanError` from `@suaveplan/error`. All twelve voice-domain reasons are
|
||||
declared once in `VOICE_CODES` so consumers can compare codes across providers
|
||||
without coupling to a specific transport (ElevenLabs, OpenAI, Google Cloud,
|
||||
etc.).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Voice provider adapters call out to remote APIs whose failure modes vary —
|
||||
ElevenLabs returns 429 with `Retry-After`, Google Cloud returns specific gRPC
|
||||
status codes, Azure returns its own envelopes. Without a stable domain error
|
||||
hierarchy, every consumer of an `@suaveplan/voice` engine would have to learn
|
||||
the upstream's quirks. This module collapses those into twelve codes that every
|
||||
adapter must remap onto. Callers branch on `error.code` or
|
||||
`error instanceof TtsSynthesisFailedError` and never touch transport details
|
||||
directly.
|
||||
|
||||
## Features
|
||||
|
||||
- 12-code stable registry under namespace `VOICE.*` via
|
||||
`defineCodes("VOICE", [...])`.
|
||||
- `VoiceError` base class extending `SuaveplanError<VoiceErrorCode>` —
|
||||
preserves the cause-chain, structured context, traceId, and JSON /
|
||||
binary wire format.
|
||||
- 12 typed subclasses, one per code, with sensible default messages.
|
||||
- Convenience options: `VoiceNotFoundError({ voiceId })`,
|
||||
`ProviderRateLimitedError({ retryAfterMs })`,
|
||||
`AudioFormatUnsupportedError({ format })`,
|
||||
`SttUnsupportedFormatError({ format })` — these populate
|
||||
`context` automatically.
|
||||
- Caller-supplied `context` is merged on top of derived context so adapters can
|
||||
layer their own attribution (e.g. `tenantId`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VoiceNotFoundError } from "@suaveplan/voice/errors/errors";
|
||||
|
||||
throw new VoiceNotFoundError({ voiceId: "v1" });
|
||||
// → error.code === "VOICE.VOICE.NOT_FOUND"
|
||||
// → error.message === 'Voice not found: "v1"'
|
||||
// → error.context === { voiceId: "v1" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Adapters remap upstream errors onto `VoiceError` codes while preserving the
|
||||
original via `cause`, so a downstream observer can still inspect the transport
|
||||
failure when needed:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ProviderRateLimitedError,
|
||||
TtsSynthesisFailedError,
|
||||
} from "@suaveplan/voice/errors/errors";
|
||||
|
||||
try {
|
||||
await tts.synthesize(req);
|
||||
} catch (transportError) {
|
||||
if (isUpstreamRateLimit(transportError)) {
|
||||
throw new ProviderRateLimitedError({
|
||||
retryAfterMs: transportError.retryAfterMs,
|
||||
cause: transportError,
|
||||
context: { tenantId: ctx.tenantId },
|
||||
});
|
||||
}
|
||||
throw new TtsSynthesisFailedError({ cause: transportError });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VOICE_CODES` — frozen record `{ "<reason>": "VOICE.<reason>" }` for all 12
|
||||
reasons. Used as the single source of truth.
|
||||
- `VoiceErrorCode` — TypeScript union of every value in `VOICE_CODES`.
|
||||
- `VoiceError` — abstract base extending `SuaveplanError<VoiceErrorCode>`. Do
|
||||
not instantiate directly; use a typed subclass.
|
||||
- `TtsSynthesisFailedError(opts?)` — code `VOICE.TTS.SYNTHESIS_FAILED`.
|
||||
- `TtsStreamInterruptedError(opts?)` — code `VOICE.TTS.STREAM_INTERRUPTED`.
|
||||
- `SttTranscriptionFailedError(opts?)` — code `VOICE.STT.TRANSCRIPTION_FAILED`.
|
||||
- `SttUnsupportedFormatError(opts?)` — code `VOICE.STT.UNSUPPORTED_FORMAT`;
|
||||
`opts.format` is folded into `context.format` when present.
|
||||
- `VoiceNotFoundError(opts?)` — code `VOICE.VOICE.NOT_FOUND`; `opts.voiceId` is
|
||||
folded into both the default message and `context.voiceId`.
|
||||
- `VoiceCloneRejectedError(opts?)` — code `VOICE.VOICE.CLONE_REJECTED`.
|
||||
- `SoundEffectGenerationFailedError(opts?)` — code
|
||||
`VOICE.EFFECTS.GENERATION_FAILED`.
|
||||
- `VoiceIsolationFailedError(opts?)` — code `VOICE.ISOLATION.FAILED`.
|
||||
- `ProviderNotConfiguredError(opts?)` — code `VOICE.PROVIDER.NOT_CONFIGURED`.
|
||||
- `ProviderRateLimitedError(opts?)` — code `VOICE.PROVIDER.RATE_LIMITED`;
|
||||
`opts.retryAfterMs` is folded into `context.retryAfterMs`.
|
||||
- `VoiceValidationError(opts?)` — code `VOICE.VALIDATION.INVALID_REQUEST`.
|
||||
- `AudioFormatUnsupportedError(opts?)` — code `VOICE.AUDIO.FORMAT_UNSUPPORTED`;
|
||||
`opts.format` is folded into `context.format`.
|
||||
|
||||
Every `opts` object accepts `{ message?, cause?, context?, traceId? }`. The
|
||||
caller's `context` is shallow-merged on top of any derived context, so caller
|
||||
keys win on collision.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 12 codes are intentionally narrow. Provider adapters that need finer
|
||||
granularity (e.g. distinguishing voice-clone-quota from voice-clone-policy)
|
||||
should preserve the original error via `cause` and add provider-specific
|
||||
attributes to `context`.
|
||||
- `VoiceError` is abstract because the constructor on `SuaveplanError` is
|
||||
`protected`; instantiating it would lose the discriminant identity that lets
|
||||
consumers branch on `error.constructor` or `instanceof`.
|
||||
- All subclasses default to a stable English message. Real localisation
|
||||
flows through the `MessageResolver` hook on `@suaveplan/error` — no message
|
||||
text in this file is part of the wire format.
|
||||
- `context` is wire-serialised (subject to redaction) by `SuaveplanError.toJSON`
|
||||
/ `toBinary`. Adapters MUST NOT put high-volume payloads (full request
|
||||
bodies, full audio bytes) in `context` — keep it to identifiers and small
|
||||
scalars.
|
||||
151
packages/media/audio/voice/src/errors/errors.test.ts
Normal file
151
packages/media/audio/voice/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AudioFormatUnsupportedError,
|
||||
ProviderNotConfiguredError,
|
||||
ProviderRateLimitedError,
|
||||
SoundEffectGenerationFailedError,
|
||||
SttTranscriptionFailedError,
|
||||
SttUnsupportedFormatError,
|
||||
TtsStreamInterruptedError,
|
||||
TtsSynthesisFailedError,
|
||||
VOICE_CODES,
|
||||
VoiceCloneRejectedError,
|
||||
VoiceError,
|
||||
VoiceIsolationFailedError,
|
||||
VoiceNotFoundError,
|
||||
VoiceValidationError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("VOICE_CODES", () => {
|
||||
it("freezes a stable map of namespaced codes", () => {
|
||||
expect(VOICE_CODES["TTS.SYNTHESIS_FAILED"]).toBe(
|
||||
"VOICE.TTS.SYNTHESIS_FAILED"
|
||||
);
|
||||
expect(VOICE_CODES["VOICE.NOT_FOUND"]).toBe("VOICE.VOICE.NOT_FOUND");
|
||||
expect(Object.isFrozen(VOICE_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceError typed subclasses", () => {
|
||||
it("TtsSynthesisFailedError uses default message and code", () => {
|
||||
const err = new TtsSynthesisFailedError();
|
||||
expect(err).toBeInstanceOf(VoiceError);
|
||||
expect(err.code).toBe("VOICE.TTS.SYNTHESIS_FAILED");
|
||||
expect(err.message).toBe("Text-to-speech synthesis failed");
|
||||
});
|
||||
|
||||
it("TtsSynthesisFailedError preserves cause and context", () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new TtsSynthesisFailedError({
|
||||
message: "custom",
|
||||
cause,
|
||||
context: { voiceId: "v1" },
|
||||
traceId: "trace-1",
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ voiceId: "v1" });
|
||||
expect(err.traceId).toBe("trace-1");
|
||||
expect(err.message).toBe("custom");
|
||||
});
|
||||
|
||||
it("TtsStreamInterruptedError carries its code", () => {
|
||||
expect(new TtsStreamInterruptedError().code).toBe(
|
||||
"VOICE.TTS.STREAM_INTERRUPTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("SttTranscriptionFailedError carries its code", () => {
|
||||
expect(new SttTranscriptionFailedError().code).toBe(
|
||||
"VOICE.STT.TRANSCRIPTION_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("SttUnsupportedFormatError attaches format to context when present", () => {
|
||||
const err = new SttUnsupportedFormatError({ format: "mp3_99000" });
|
||||
expect(err.context).toEqual({ format: "mp3_99000" });
|
||||
expect(err.code).toBe("VOICE.STT.UNSUPPORTED_FORMAT");
|
||||
});
|
||||
|
||||
it("SttUnsupportedFormatError omits context when no format provided", () => {
|
||||
const err = new SttUnsupportedFormatError();
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("VoiceNotFoundError formats voiceId into message and context", () => {
|
||||
const err = new VoiceNotFoundError({ voiceId: "abc" });
|
||||
expect(err.message).toBe('Voice not found: "abc"');
|
||||
expect(err.context).toEqual({ voiceId: "abc" });
|
||||
});
|
||||
|
||||
it("VoiceNotFoundError works without voiceId", () => {
|
||||
const err = new VoiceNotFoundError();
|
||||
expect(err.message).toBe("Voice not found");
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("VoiceCloneRejectedError carries its code", () => {
|
||||
expect(new VoiceCloneRejectedError().code).toBe(
|
||||
"VOICE.VOICE.CLONE_REJECTED"
|
||||
);
|
||||
});
|
||||
|
||||
it("SoundEffectGenerationFailedError carries its code", () => {
|
||||
expect(new SoundEffectGenerationFailedError().code).toBe(
|
||||
"VOICE.EFFECTS.GENERATION_FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("VoiceIsolationFailedError carries its code", () => {
|
||||
expect(new VoiceIsolationFailedError().code).toBe(
|
||||
"VOICE.ISOLATION.FAILED"
|
||||
);
|
||||
});
|
||||
|
||||
it("ProviderNotConfiguredError carries its code", () => {
|
||||
expect(new ProviderNotConfiguredError().code).toBe(
|
||||
"VOICE.PROVIDER.NOT_CONFIGURED"
|
||||
);
|
||||
});
|
||||
|
||||
it("ProviderRateLimitedError attaches retryAfterMs to context when present", () => {
|
||||
const err = new ProviderRateLimitedError({ retryAfterMs: 5000 });
|
||||
expect(err.context).toEqual({ retryAfterMs: 5000 });
|
||||
expect(err.code).toBe("VOICE.PROVIDER.RATE_LIMITED");
|
||||
});
|
||||
|
||||
it("ProviderRateLimitedError omits context when no retryAfterMs provided", () => {
|
||||
const err = new ProviderRateLimitedError();
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("VoiceValidationError carries its code", () => {
|
||||
expect(new VoiceValidationError().code).toBe(
|
||||
"VOICE.VALIDATION.INVALID_REQUEST"
|
||||
);
|
||||
});
|
||||
|
||||
it("AudioFormatUnsupportedError attaches format to context", () => {
|
||||
const err = new AudioFormatUnsupportedError({ format: "exotic" });
|
||||
expect(err.message).toBe('Audio format not supported: "exotic"');
|
||||
expect(err.context).toEqual({ format: "exotic" });
|
||||
});
|
||||
|
||||
it("AudioFormatUnsupportedError works without format", () => {
|
||||
const err = new AudioFormatUnsupportedError();
|
||||
expect(err.message).toBe("Audio format not supported");
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges caller context on top of derived context", () => {
|
||||
const err = new VoiceNotFoundError({
|
||||
voiceId: "v1",
|
||||
context: { tenantId: "t1" },
|
||||
});
|
||||
expect(err.context).toEqual({ voiceId: "v1", tenantId: "t1" });
|
||||
});
|
||||
|
||||
it("propagates traceId for every subclass", () => {
|
||||
const err = new TtsStreamInterruptedError({ traceId: "trace-x" });
|
||||
expect(err.traceId).toBe("trace-x");
|
||||
});
|
||||
});
|
||||
217
packages/media/audio/voice/src/errors/errors.ts
Normal file
217
packages/media/audio/voice/src/errors/errors.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable code registry for the @suaveplan/voice package.
|
||||
*
|
||||
* Codes follow the genesis pattern of `<NAMESPACE>.<DOMAIN>.<REASON>`. Provider
|
||||
* adapters (e.g. `@suaveplan/voice-elevenlabs`) re-throw their transport errors
|
||||
* remapped onto these codes so callers can branch on stable identifiers across
|
||||
* any backend.
|
||||
*/
|
||||
export const VOICE_CODES = defineCodes("VOICE", [
|
||||
"TTS.SYNTHESIS_FAILED",
|
||||
"TTS.STREAM_INTERRUPTED",
|
||||
"STT.TRANSCRIPTION_FAILED",
|
||||
"STT.UNSUPPORTED_FORMAT",
|
||||
"VOICE.NOT_FOUND",
|
||||
"VOICE.CLONE_REJECTED",
|
||||
"EFFECTS.GENERATION_FAILED",
|
||||
"ISOLATION.FAILED",
|
||||
"PROVIDER.NOT_CONFIGURED",
|
||||
"PROVIDER.RATE_LIMITED",
|
||||
"VALIDATION.INVALID_REQUEST",
|
||||
"AUDIO.FORMAT_UNSUPPORTED",
|
||||
] as const);
|
||||
|
||||
/** Union of every code defined under the `VOICE.*` namespace. */
|
||||
export type VoiceErrorCode = (typeof VOICE_CODES)[keyof typeof VOICE_CODES];
|
||||
|
||||
interface VoiceErrorOptions {
|
||||
readonly cause?: unknown;
|
||||
readonly message?: string;
|
||||
readonly context?: Readonly<Record<string, unknown>>;
|
||||
readonly traceId?: string;
|
||||
}
|
||||
|
||||
/** Base class for every typed voice domain error. */
|
||||
export abstract class VoiceError extends SuaveplanError<VoiceErrorCode> {}
|
||||
|
||||
function buildOpts(
|
||||
opts: VoiceErrorOptions,
|
||||
extraContext?: Readonly<Record<string, unknown>>
|
||||
): {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
if (extraContext !== undefined && opts.context !== undefined) {
|
||||
out.context = { ...extraContext, ...opts.context };
|
||||
} else if (extraContext !== undefined) {
|
||||
out.context = extraContext;
|
||||
} else if (opts.context !== undefined) {
|
||||
out.context = opts.context;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Thrown when text-to-speech synthesis fails for any reason. */
|
||||
export class TtsSynthesisFailedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["TTS.SYNTHESIS_FAILED"],
|
||||
opts.message ?? "Text-to-speech synthesis failed",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an in-progress TTS stream is closed before completion. */
|
||||
export class TtsStreamInterruptedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["TTS.STREAM_INTERRUPTED"],
|
||||
opts.message ?? "TTS stream was interrupted",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when speech-to-text transcription fails. */
|
||||
export class SttTranscriptionFailedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["STT.TRANSCRIPTION_FAILED"],
|
||||
opts.message ?? "Speech-to-text transcription failed",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the supplied audio format cannot be transcribed. */
|
||||
export class SttUnsupportedFormatError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions & { readonly format?: string } = {}) {
|
||||
const ctx =
|
||||
opts.format !== undefined ? { format: opts.format } : undefined;
|
||||
super(
|
||||
VOICE_CODES["STT.UNSUPPORTED_FORMAT"],
|
||||
opts.message ?? "Unsupported audio format for transcription",
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a requested voice id does not exist. */
|
||||
export class VoiceNotFoundError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions & { readonly voiceId?: string } = {}) {
|
||||
const ctx =
|
||||
opts.voiceId !== undefined ? { voiceId: opts.voiceId } : undefined;
|
||||
super(
|
||||
VOICE_CODES["VOICE.NOT_FOUND"],
|
||||
opts.message ??
|
||||
`Voice not found${opts.voiceId !== undefined ? `: "${opts.voiceId}"` : ""}`,
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a provider rejects a voice clone request (quota / policy). */
|
||||
export class VoiceCloneRejectedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["VOICE.CLONE_REJECTED"],
|
||||
opts.message ?? "Voice clone request was rejected",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when sound-effect generation fails. */
|
||||
export class SoundEffectGenerationFailedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["EFFECTS.GENERATION_FAILED"],
|
||||
opts.message ?? "Sound effect generation failed",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when voice isolation fails. */
|
||||
export class VoiceIsolationFailedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["ISOLATION.FAILED"],
|
||||
opts.message ?? "Voice isolation failed",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a provider is unreachable or its credentials are invalid /
|
||||
* missing. Adapters use this code for both `401` (auth) and unrecoverable
|
||||
* configuration errors (malformed responses indicating an API contract drift).
|
||||
*/
|
||||
export class ProviderNotConfiguredError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["PROVIDER.NOT_CONFIGURED"],
|
||||
opts.message ??
|
||||
"Voice provider is not configured or authentication failed",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the upstream provider rate-limits the caller. The
|
||||
* `context.retryAfterMs` field, when present, is set from the `Retry-After`
|
||||
* response header.
|
||||
*/
|
||||
export class ProviderRateLimitedError extends VoiceError {
|
||||
constructor(
|
||||
opts: VoiceErrorOptions & { readonly retryAfterMs?: number } = {}
|
||||
) {
|
||||
const ctx =
|
||||
opts.retryAfterMs !== undefined
|
||||
? { retryAfterMs: opts.retryAfterMs }
|
||||
: undefined;
|
||||
super(
|
||||
VOICE_CODES["PROVIDER.RATE_LIMITED"],
|
||||
opts.message ?? "Voice provider rate limit exceeded",
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a request fails domain validation before it reaches the wire. */
|
||||
export class VoiceValidationError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions = {}) {
|
||||
super(
|
||||
VOICE_CODES["VALIDATION.INVALID_REQUEST"],
|
||||
opts.message ?? "Invalid voice request",
|
||||
buildOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when an audio format is not supported by the active engine. */
|
||||
export class AudioFormatUnsupportedError extends VoiceError {
|
||||
constructor(opts: VoiceErrorOptions & { readonly format?: string } = {}) {
|
||||
const ctx =
|
||||
opts.format !== undefined ? { format: opts.format } : undefined;
|
||||
super(
|
||||
VOICE_CODES["AUDIO.FORMAT_UNSUPPORTED"],
|
||||
opts.message ??
|
||||
`Audio format not supported${opts.format !== undefined ? `: "${opts.format}"` : ""}`,
|
||||
buildOpts(opts, ctx)
|
||||
);
|
||||
}
|
||||
}
|
||||
17
packages/media/audio/voice/src/errors/index.ts
Normal file
17
packages/media/audio/voice/src/errors/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type { VoiceErrorCode } from "./errors.js";
|
||||
export {
|
||||
AudioFormatUnsupportedError,
|
||||
ProviderNotConfiguredError,
|
||||
ProviderRateLimitedError,
|
||||
SoundEffectGenerationFailedError,
|
||||
SttTranscriptionFailedError,
|
||||
SttUnsupportedFormatError,
|
||||
TtsStreamInterruptedError,
|
||||
TtsSynthesisFailedError,
|
||||
VOICE_CODES,
|
||||
VoiceCloneRejectedError,
|
||||
VoiceError,
|
||||
VoiceIsolationFailedError,
|
||||
VoiceNotFoundError,
|
||||
VoiceValidationError,
|
||||
} from "./errors.js";
|
||||
93
packages/media/audio/voice/src/index.ts
Normal file
93
packages/media/audio/voice/src/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Errors
|
||||
|
||||
// Effects
|
||||
export {
|
||||
isSoundEffectEngine,
|
||||
type SoundEffectEngine,
|
||||
type SoundEffectRequest,
|
||||
SoundEffectRequestSchema,
|
||||
type SoundEffectResult,
|
||||
SoundEffectResultSchema,
|
||||
} from "./effects/index.js";
|
||||
export {
|
||||
AudioFormatUnsupportedError,
|
||||
ProviderNotConfiguredError,
|
||||
ProviderRateLimitedError,
|
||||
SoundEffectGenerationFailedError,
|
||||
SttTranscriptionFailedError,
|
||||
SttUnsupportedFormatError,
|
||||
TtsStreamInterruptedError,
|
||||
TtsSynthesisFailedError,
|
||||
VOICE_CODES,
|
||||
VoiceCloneRejectedError,
|
||||
VoiceError,
|
||||
type VoiceErrorCode,
|
||||
VoiceIsolationFailedError,
|
||||
VoiceNotFoundError,
|
||||
VoiceValidationError,
|
||||
} from "./errors/index.js";
|
||||
// Isolation
|
||||
export {
|
||||
type IsolationRequest,
|
||||
IsolationRequestSchema,
|
||||
type IsolationResult,
|
||||
IsolationResultSchema,
|
||||
isVoiceIsolationEngine,
|
||||
type VoiceIsolationEngine,
|
||||
} from "./isolation/index.js";
|
||||
|
||||
// STT
|
||||
export {
|
||||
isTranscriptionEngine,
|
||||
type TimestampGranularity,
|
||||
type TranscriptionEngine,
|
||||
type TranscriptionRequest,
|
||||
TranscriptionRequestSchema,
|
||||
type TranscriptionResult,
|
||||
TranscriptionResultSchema,
|
||||
} from "./stt/index.js";
|
||||
// TTS
|
||||
export {
|
||||
isVoiceSynthesizer,
|
||||
type SynthesisRequest,
|
||||
SynthesisRequestSchema,
|
||||
type SynthesisResult,
|
||||
SynthesisResultSchema,
|
||||
type VoiceSynthesizer,
|
||||
} from "./tts/index.js";
|
||||
// Types
|
||||
export {
|
||||
type AudioFormat,
|
||||
AudioFormatSchema,
|
||||
type AudioSource,
|
||||
type DetectedEntity,
|
||||
DetectedEntitySchema,
|
||||
type LatencyMode,
|
||||
LatencyModeSchema,
|
||||
type ListVoicesOptions,
|
||||
ListVoicesOptionsSchema,
|
||||
type SpeakerSegment,
|
||||
SpeakerSegmentSchema,
|
||||
type Voice,
|
||||
type VoiceAge,
|
||||
VoiceAgeSchema,
|
||||
type VoiceCategory,
|
||||
VoiceCategorySchema,
|
||||
type VoiceGender,
|
||||
VoiceGenderSchema,
|
||||
type VoiceList,
|
||||
VoiceListSchema,
|
||||
VoiceSchema,
|
||||
type VoiceSettings,
|
||||
VoiceSettingsSchema,
|
||||
type WordTimestamp,
|
||||
WordTimestampSchema,
|
||||
} from "./types/index.js";
|
||||
// Voices
|
||||
export {
|
||||
isVoiceRepository,
|
||||
type VoiceRepository,
|
||||
VoiceRepositoryGetResultSchema,
|
||||
VoiceRepositoryListOptionsSchema,
|
||||
VoiceRepositoryListResultSchema,
|
||||
} from "./voices/index.js";
|
||||
10
packages/media/audio/voice/src/isolation/index.ts
Normal file
10
packages/media/audio/voice/src/isolation/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { isVoiceIsolationEngine } from "./isolation.js";
|
||||
export {
|
||||
IsolationRequestSchema,
|
||||
IsolationResultSchema,
|
||||
} from "./isolation.schemas.js";
|
||||
export type {
|
||||
IsolationRequest,
|
||||
IsolationResult,
|
||||
VoiceIsolationEngine,
|
||||
} from "./isolation.types.js";
|
||||
96
packages/media/audio/voice/src/isolation/isolation.md
Normal file
96
packages/media/audio/voice/src/isolation/isolation.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `isolation/isolation`
|
||||
|
||||
The voice-isolation engine interface for `@suaveplan/voice`. Defines the
|
||||
`VoiceIsolationEngine` contract every voice-isolation provider adapter must
|
||||
implement, plus `IsolationRequest` / `IsolationResult` shapes, Zod schemas
|
||||
for both, and a runtime type guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Voice isolation removes background noise, music, and other audio from a
|
||||
mixed recording, leaving only the dominant voice. Use cases include
|
||||
podcast clean-up, meeting recording extraction, and pre-processing for
|
||||
downstream STT. This module locks the canonical request / result so the
|
||||
caller is independent of the active backend.
|
||||
|
||||
## Features
|
||||
|
||||
- `VoiceIsolationEngine` interface with `providerName` and `isolate`.
|
||||
- `IsolationRequest` accepting `audio` (`Uint8Array | ReadableStream | URL`),
|
||||
optional output format, and `AbortSignal`.
|
||||
- `IsolationResult` containing isolated audio bytes, format, and optional
|
||||
request id.
|
||||
- Zod schemas excluding `audio` and `signal`.
|
||||
- Runtime type guard `isVoiceIsolationEngine`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { VoiceIsolationEngine } from "@suaveplan/voice/isolation/isolation";
|
||||
|
||||
async function cleanup(isolation: VoiceIsolationEngine, audio: Uint8Array) {
|
||||
const result = await isolation.isolate({ audio });
|
||||
return result.audio;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Stream a podcast file through isolation directly into a downstream STT
|
||||
engine, without buffering the intermediate result:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TranscriptionEngine,
|
||||
} from "@suaveplan/voice/stt/stt";
|
||||
import type {
|
||||
VoiceIsolationEngine,
|
||||
} from "@suaveplan/voice/isolation/isolation";
|
||||
|
||||
async function transcribeWithIsolation(
|
||||
isolation: VoiceIsolationEngine,
|
||||
stt: TranscriptionEngine,
|
||||
audio: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const isolated = await isolation.isolate({
|
||||
audio,
|
||||
outputFormat: "wav_44100",
|
||||
signal,
|
||||
});
|
||||
return stt.transcribe({ audio: isolated.audio, signal });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VoiceIsolationEngine` — interface with:
|
||||
- `providerName: string`.
|
||||
- `isolate(request: IsolationRequest): Promise<IsolationResult>`.
|
||||
- `IsolationRequest` — TypeScript interface with:
|
||||
- `audio` — `Uint8Array | ReadableStream<Uint8Array> | string` (URL).
|
||||
- `outputFormat?`, `signal?`.
|
||||
- `IsolationResult` — TypeScript interface with:
|
||||
- `audio` — `Uint8Array`.
|
||||
- `format` — `AudioFormat`.
|
||||
- `requestId?`.
|
||||
- `IsolationRequestSchema` / `IsolationResultSchema` — Zod parsers.
|
||||
- `isVoiceIsolationEngine(value): value is VoiceIsolationEngine` — duck-type
|
||||
guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Streaming isolation is not part of this base interface. The dominant
|
||||
pattern in production is full-file processing because isolation models
|
||||
often need a complete audio context to detect voice activity. Adapters
|
||||
that support streaming may expose it via a provider-specific subpath.
|
||||
- `audio` accepts the same three shapes as STT: in-memory bytes, a stream
|
||||
(which adapters typically buffer), or a URL string.
|
||||
- `outputFormat` defaults are provider-specific. Most isolation services
|
||||
return PCM or WAV by default to avoid lossy compression artefacts on top
|
||||
of the model's reconstruction.
|
||||
- The runtime guard checks `providerName: string` and `isolate: function`.
|
||||
@@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
import { AudioFormatSchema } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for `IsolationRequest`. Excludes `audio` (binary / stream input)
|
||||
* and `signal` — adapters validate those structurally at runtime.
|
||||
*/
|
||||
export const IsolationRequestSchema = z.object({
|
||||
outputFormat: AudioFormatSchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `IsolationResult`. */
|
||||
export const IsolationResultSchema = z.object({
|
||||
audio: z.instanceof(Uint8Array),
|
||||
format: AudioFormatSchema,
|
||||
requestId: z.string().min(1).optional(),
|
||||
});
|
||||
68
packages/media/audio/voice/src/isolation/isolation.test.ts
Normal file
68
packages/media/audio/voice/src/isolation/isolation.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isVoiceIsolationEngine } from "./isolation.js";
|
||||
import {
|
||||
IsolationRequestSchema,
|
||||
IsolationResultSchema,
|
||||
} from "./isolation.schemas.js";
|
||||
|
||||
describe("IsolationRequestSchema", () => {
|
||||
it("accepts an empty request (every field optional after audio)", () => {
|
||||
expect(IsolationRequestSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts an outputFormat", () => {
|
||||
expect(
|
||||
IsolationRequestSchema.parse({ outputFormat: "wav_44100" })
|
||||
).toEqual({ outputFormat: "wav_44100" });
|
||||
});
|
||||
|
||||
it("rejects unknown outputFormat", () => {
|
||||
expect(() =>
|
||||
IsolationRequestSchema.parse({ outputFormat: "exotic" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IsolationResultSchema", () => {
|
||||
it("accepts a minimal result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1, 2, 3]),
|
||||
format: "wav_44100" as const,
|
||||
};
|
||||
expect(IsolationResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("accepts a fully-specified result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1]),
|
||||
format: "pcm_44100" as const,
|
||||
requestId: "req-1",
|
||||
};
|
||||
expect(IsolationResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVoiceIsolationEngine", () => {
|
||||
it("accepts a valid engine shape", () => {
|
||||
const stub = { providerName: "test", isolate: async () => ({}) };
|
||||
expect(isVoiceIsolationEngine(stub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null", () => {
|
||||
expect(isVoiceIsolationEngine(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects primitives", () => {
|
||||
expect(isVoiceIsolationEngine(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing isolate", () => {
|
||||
expect(isVoiceIsolationEngine({ providerName: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects with non-string providerName", () => {
|
||||
expect(
|
||||
isVoiceIsolationEngine({ providerName: 1, isolate: () => {} })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
13
packages/media/audio/voice/src/isolation/isolation.ts
Normal file
13
packages/media/audio/voice/src/isolation/isolation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { VoiceIsolationEngine } from "./isolation.types.js";
|
||||
|
||||
/** Runtime type guard for `VoiceIsolationEngine`. */
|
||||
export function isVoiceIsolationEngine(
|
||||
value: unknown
|
||||
): value is VoiceIsolationEngine {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["providerName"] === "string" &&
|
||||
typeof obj["isolate"] === "function"
|
||||
);
|
||||
}
|
||||
23
packages/media/audio/voice/src/isolation/isolation.types.ts
Normal file
23
packages/media/audio/voice/src/isolation/isolation.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { AudioFormat, AudioSource } from "../types/types.js";
|
||||
|
||||
/** A request to isolate a single voice from a mixed audio source. */
|
||||
export interface IsolationRequest {
|
||||
readonly audio: AudioSource;
|
||||
readonly outputFormat?: AudioFormat;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A completed voice-isolation result. */
|
||||
export interface IsolationResult {
|
||||
readonly audio: Uint8Array;
|
||||
readonly format: AudioFormat;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
/** The voice-isolation engine interface. */
|
||||
export interface VoiceIsolationEngine {
|
||||
/** Stable provider identifier. */
|
||||
readonly providerName: string;
|
||||
/** Isolate the dominant voice in the supplied audio. */
|
||||
isolate(request: IsolationRequest): Promise<IsolationResult>;
|
||||
}
|
||||
11
packages/media/audio/voice/src/stt/index.ts
Normal file
11
packages/media/audio/voice/src/stt/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { isTranscriptionEngine } from "./stt.js";
|
||||
export {
|
||||
TranscriptionRequestSchema,
|
||||
TranscriptionResultSchema,
|
||||
} from "./stt.schemas.js";
|
||||
export type {
|
||||
TimestampGranularity,
|
||||
TranscriptionEngine,
|
||||
TranscriptionRequest,
|
||||
TranscriptionResult,
|
||||
} from "./stt.types.js";
|
||||
125
packages/media/audio/voice/src/stt/stt.md
Normal file
125
packages/media/audio/voice/src/stt/stt.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# `stt/stt`
|
||||
|
||||
The speech-to-text (STT) engine interface for `@suaveplan/voice`. Defines the
|
||||
`TranscriptionEngine` contract every STT provider adapter must implement,
|
||||
along with `TranscriptionRequest` / `TranscriptionResult` shapes, Zod
|
||||
schemas for both, and a runtime type guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
STT providers (ElevenLabs Scribe, OpenAI Whisper, Google Cloud Speech-to-Text,
|
||||
Azure Speech) accept different request shapes for diarization, entity
|
||||
detection, key-term boosting, and timestamp granularity. This module locks
|
||||
the canonical request and result so consumers never depend on a specific
|
||||
provider's wire format. Adapters map their native bodies onto these shapes
|
||||
at the package boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- `TranscriptionEngine` interface with `providerName` and `transcribe`.
|
||||
- `TranscriptionRequest` covering audio source, optional model, language,
|
||||
audio-event tagging, speaker count, timestamp granularity (word /
|
||||
character), diarization on/off + threshold, multi-channel support,
|
||||
entity-detection / entity-redaction lists or `'all'`, key-term hints,
|
||||
temperature, deterministic seed, and an `AbortSignal`.
|
||||
- `TranscriptionResult` containing transcript text plus optional
|
||||
`WordTimestamp[]`, `SpeakerSegment[]`, `DetectedEntity[]`, language code,
|
||||
duration, and request id.
|
||||
- Zod schemas excluding `audio` (binary / streaming) and `signal` — those
|
||||
validate structurally at runtime.
|
||||
- Runtime type guard `isTranscriptionEngine` for plugin / config-driven
|
||||
scenarios.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TranscriptionEngine } from "@suaveplan/voice/stt/stt";
|
||||
|
||||
async function transcribeBytes(
|
||||
stt: TranscriptionEngine,
|
||||
audio: Uint8Array,
|
||||
): Promise<string> {
|
||||
const result = await stt.transcribe({ audio });
|
||||
return result.text;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Diarized transcription with entity detection and a key-term hint:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TranscriptionEngine,
|
||||
TranscriptionRequest,
|
||||
} from "@suaveplan/voice/stt/stt";
|
||||
|
||||
async function transcribeMeeting(
|
||||
stt: TranscriptionEngine,
|
||||
audio: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const req: TranscriptionRequest = {
|
||||
audio,
|
||||
modelId: "scribe_v2",
|
||||
diarize: true,
|
||||
diarizationThreshold: 0.6,
|
||||
timestampsGranularity: "word",
|
||||
entityDetection: ["person", "organization"],
|
||||
keyterms: ["suaveplan", "OpenSpec"],
|
||||
signal,
|
||||
};
|
||||
const result = await stt.transcribe(req);
|
||||
return {
|
||||
transcript: result.text,
|
||||
speakers: result.speakers ?? [],
|
||||
entities: result.entities ?? [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TranscriptionEngine` — interface with:
|
||||
- `providerName: string`.
|
||||
- `transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>`.
|
||||
- `TimestampGranularity` — `'word' | 'character'`.
|
||||
- `TranscriptionRequest` — TypeScript interface with:
|
||||
- `audio` — `Uint8Array | ReadableStream<Uint8Array> | string` (URL).
|
||||
- `modelId?`, `languageCode?`, `tagAudioEvents?`, `numSpeakers?` (1..32),
|
||||
`timestampsGranularity?`, `diarize?`, `diarizationThreshold?` (0..1),
|
||||
`useMultiChannel?`, `entityDetection?`, `entityRedaction?`, `keyterms?`,
|
||||
`temperature?` (0..2), `seed?`, `signal?`.
|
||||
- `TranscriptionResult` — TypeScript interface with:
|
||||
- `text` — full transcript.
|
||||
- `words?`, `speakers?`, `entities?`, `language?`, `durationMs?`,
|
||||
`requestId?`.
|
||||
- `TranscriptionRequestSchema` / `TranscriptionResultSchema` — Zod parsers
|
||||
excluding `audio` and `signal`.
|
||||
- `isTranscriptionEngine(value): value is TranscriptionEngine` — duck-type
|
||||
guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `audio` is excluded from the Zod request schema because `Uint8Array`,
|
||||
`ReadableStream`, and URL string have no useful canonical encoding for
|
||||
comparison. Adapters validate the runtime shape via `instanceof` or
|
||||
`typeof` checks.
|
||||
- `numSpeakers` is bounded to `[1, 32]`. Providers without diarization should
|
||||
reject `diarize: true` with `SttUnsupportedFormatError` or similar at the
|
||||
adapter boundary, not silently ignore it.
|
||||
- `entityDetection` / `entityRedaction` accept either an explicit array of
|
||||
entity types or the literal `'all'`. The provider's set of recognised
|
||||
entity types is provider-specific.
|
||||
- `keyterms` is for biasing recognition toward domain-specific phrases — not
|
||||
every provider supports it; those that do not should ignore the field
|
||||
silently.
|
||||
- `temperature` is bounded to `[0, 2]` matching the OpenAI Whisper convention.
|
||||
Providers with different scales remap at the boundary.
|
||||
- The runtime guard checks that `providerName` is a string and `transcribe`
|
||||
is a function. Stronger validation requires invoking the engine against a
|
||||
known fixture.
|
||||
43
packages/media/audio/voice/src/stt/stt.schemas.ts
Normal file
43
packages/media/audio/voice/src/stt/stt.schemas.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
DetectedEntitySchema,
|
||||
SpeakerSegmentSchema,
|
||||
WordTimestampSchema,
|
||||
} from "../types/types.js";
|
||||
|
||||
const TimestampGranularitySchema = z.enum(["word", "character"]);
|
||||
|
||||
/**
|
||||
* Zod schema for `TranscriptionRequest`. Excludes `audio` (binary / stream
|
||||
* inputs) and `signal` — adapters validate those structurally at runtime.
|
||||
*/
|
||||
export const TranscriptionRequestSchema = z.object({
|
||||
modelId: z.string().min(1).optional(),
|
||||
languageCode: z.string().min(2).max(35).optional(),
|
||||
tagAudioEvents: z.boolean().optional(),
|
||||
numSpeakers: z.number().int().min(1).max(32).optional(),
|
||||
timestampsGranularity: TimestampGranularitySchema.optional(),
|
||||
diarize: z.boolean().optional(),
|
||||
diarizationThreshold: z.number().min(0).max(1).optional(),
|
||||
useMultiChannel: z.boolean().optional(),
|
||||
entityDetection: z
|
||||
.union([z.array(z.string().min(1)), z.literal("all")])
|
||||
.optional(),
|
||||
entityRedaction: z
|
||||
.union([z.array(z.string().min(1)), z.literal("all")])
|
||||
.optional(),
|
||||
keyterms: z.array(z.string().min(1)).optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
seed: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `TranscriptionResult`. */
|
||||
export const TranscriptionResultSchema = z.object({
|
||||
text: z.string(),
|
||||
words: z.array(WordTimestampSchema).optional(),
|
||||
speakers: z.array(SpeakerSegmentSchema).optional(),
|
||||
entities: z.array(DetectedEntitySchema).optional(),
|
||||
language: z.string().min(2).max(35).optional(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
requestId: z.string().min(1).optional(),
|
||||
});
|
||||
115
packages/media/audio/voice/src/stt/stt.test.ts
Normal file
115
packages/media/audio/voice/src/stt/stt.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isTranscriptionEngine } from "./stt.js";
|
||||
import {
|
||||
TranscriptionRequestSchema,
|
||||
TranscriptionResultSchema,
|
||||
} from "./stt.schemas.js";
|
||||
|
||||
describe("TranscriptionRequestSchema", () => {
|
||||
it("accepts an empty request (every field optional)", () => {
|
||||
expect(TranscriptionRequestSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a fully-specified request", () => {
|
||||
const r = {
|
||||
modelId: "scribe_v2",
|
||||
languageCode: "en",
|
||||
tagAudioEvents: true,
|
||||
numSpeakers: 4,
|
||||
timestampsGranularity: "word" as const,
|
||||
diarize: true,
|
||||
diarizationThreshold: 0.5,
|
||||
useMultiChannel: false,
|
||||
entityDetection: ["person", "location"],
|
||||
entityRedaction: "all" as const,
|
||||
keyterms: ["genesis"],
|
||||
temperature: 0.3,
|
||||
seed: 42,
|
||||
};
|
||||
expect(TranscriptionRequestSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("accepts entityDetection as 'all'", () => {
|
||||
expect(
|
||||
TranscriptionRequestSchema.parse({ entityDetection: "all" })
|
||||
).toEqual({ entityDetection: "all" });
|
||||
});
|
||||
|
||||
it("rejects numSpeakers outside 1..32", () => {
|
||||
expect(() =>
|
||||
TranscriptionRequestSchema.parse({ numSpeakers: 0 })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
TranscriptionRequestSchema.parse({ numSpeakers: 33 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects temperature outside 0..2", () => {
|
||||
expect(() =>
|
||||
TranscriptionRequestSchema.parse({ temperature: 2.5 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects diarizationThreshold outside 0..1", () => {
|
||||
expect(() =>
|
||||
TranscriptionRequestSchema.parse({ diarizationThreshold: 1.1 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TranscriptionResultSchema", () => {
|
||||
it("accepts a minimal result", () => {
|
||||
expect(
|
||||
TranscriptionResultSchema.parse({ text: "hello world" })
|
||||
).toEqual({
|
||||
text: "hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a fully-specified result", () => {
|
||||
const r = {
|
||||
text: "hello world",
|
||||
words: [{ word: "hello", startMs: 0, endMs: 250 }],
|
||||
speakers: [{ speakerId: "spk-1", startMs: 0, endMs: 250 }],
|
||||
entities: [
|
||||
{ text: "Alice", type: "person", startChar: 0, endChar: 5 },
|
||||
],
|
||||
language: "en",
|
||||
durationMs: 1500,
|
||||
requestId: "req-1",
|
||||
};
|
||||
expect(TranscriptionResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTranscriptionEngine", () => {
|
||||
it("accepts a valid engine shape", () => {
|
||||
const stub = {
|
||||
providerName: "test",
|
||||
transcribe: async () => ({ text: "" }),
|
||||
};
|
||||
expect(isTranscriptionEngine(stub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null and primitives", () => {
|
||||
expect(isTranscriptionEngine(null)).toBe(false);
|
||||
expect(isTranscriptionEngine(undefined)).toBe(false);
|
||||
expect(isTranscriptionEngine("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing providerName", () => {
|
||||
expect(isTranscriptionEngine({ transcribe: () => {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects with non-function transcribe", () => {
|
||||
expect(
|
||||
isTranscriptionEngine({ providerName: "x", transcribe: "nope" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects with non-string providerName", () => {
|
||||
expect(
|
||||
isTranscriptionEngine({ providerName: 42, transcribe: () => {} })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
16
packages/media/audio/voice/src/stt/stt.ts
Normal file
16
packages/media/audio/voice/src/stt/stt.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { TranscriptionEngine } from "./stt.types.js";
|
||||
|
||||
/**
|
||||
* Runtime type guard for `TranscriptionEngine`. Use at boundaries where the
|
||||
* source is untyped (plugin loading, config-driven engine construction).
|
||||
*/
|
||||
export function isTranscriptionEngine(
|
||||
value: unknown
|
||||
): value is TranscriptionEngine {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["providerName"] === "string" &&
|
||||
typeof obj["transcribe"] === "function"
|
||||
);
|
||||
}
|
||||
47
packages/media/audio/voice/src/stt/stt.types.ts
Normal file
47
packages/media/audio/voice/src/stt/stt.types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AudioSource,
|
||||
DetectedEntity,
|
||||
SpeakerSegment,
|
||||
WordTimestamp,
|
||||
} from "../types/types.js";
|
||||
|
||||
/** Granularity at which timing metadata is reported. */
|
||||
export type TimestampGranularity = "word" | "character";
|
||||
|
||||
/** A request to transcribe audio into text. */
|
||||
export interface TranscriptionRequest {
|
||||
readonly audio: AudioSource;
|
||||
readonly modelId?: string;
|
||||
readonly languageCode?: string;
|
||||
readonly tagAudioEvents?: boolean;
|
||||
readonly numSpeakers?: number;
|
||||
readonly timestampsGranularity?: TimestampGranularity;
|
||||
readonly diarize?: boolean;
|
||||
readonly diarizationThreshold?: number;
|
||||
readonly useMultiChannel?: boolean;
|
||||
readonly entityDetection?: readonly string[] | "all";
|
||||
readonly entityRedaction?: readonly string[] | "all";
|
||||
readonly keyterms?: readonly string[];
|
||||
readonly temperature?: number;
|
||||
readonly seed?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A completed transcription result. */
|
||||
export interface TranscriptionResult {
|
||||
readonly text: string;
|
||||
readonly words?: readonly WordTimestamp[];
|
||||
readonly speakers?: readonly SpeakerSegment[];
|
||||
readonly entities?: readonly DetectedEntity[];
|
||||
readonly language?: string;
|
||||
readonly durationMs?: number;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
/** The speech-to-text engine interface. */
|
||||
export interface TranscriptionEngine {
|
||||
/** Stable provider identifier, e.g. `'elevenlabs'`, `'openai'`. */
|
||||
readonly providerName: string;
|
||||
/** Transcribe the supplied audio source and resolve with the result. */
|
||||
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
|
||||
}
|
||||
10
packages/media/audio/voice/src/tts/index.ts
Normal file
10
packages/media/audio/voice/src/tts/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { isVoiceSynthesizer } from "./tts.js";
|
||||
export {
|
||||
SynthesisRequestSchema,
|
||||
SynthesisResultSchema,
|
||||
} from "./tts.schemas.js";
|
||||
export type {
|
||||
SynthesisRequest,
|
||||
SynthesisResult,
|
||||
VoiceSynthesizer,
|
||||
} from "./tts.types.js";
|
||||
109
packages/media/audio/voice/src/tts/tts.md
Normal file
109
packages/media/audio/voice/src/tts/tts.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# `tts/tts`
|
||||
|
||||
The text-to-speech (TTS) engine interface for `@suaveplan/voice`. Defines the
|
||||
`VoiceSynthesizer` contract every TTS provider adapter must implement, plus
|
||||
the `SynthesisRequest` / `SynthesisResult` shapes, Zod schemas for both, and a
|
||||
runtime type guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Voice synthesis providers — ElevenLabs, OpenAI, Google Cloud, Azure, Amazon
|
||||
Polly — surface different request shapes, parameter scales, and streaming
|
||||
semantics. This module locks the canonical request, the canonical result,
|
||||
and the canonical streaming surface so consumers depend on one stable type
|
||||
regardless of which adapter is plugged in. Adapters are responsible for
|
||||
mapping their native request bodies onto `SynthesisRequest` and their native
|
||||
responses onto `SynthesisResult`.
|
||||
|
||||
## Features
|
||||
|
||||
- `VoiceSynthesizer` interface with `providerName`, `synthesize`, and
|
||||
`synthesizeStream`.
|
||||
- `SynthesisRequest` covering text, voice id, optional model, language,
|
||||
voice settings, output format, latency mode, contextual `previousText` /
|
||||
`nextText`, deterministic `seed`, pronunciation dictionary ids, and an
|
||||
`AbortSignal`.
|
||||
- `SynthesisResult` containing audio bytes, format, optional duration,
|
||||
characters used, and request id.
|
||||
- Zod schemas `SynthesisRequestSchema` / `SynthesisResultSchema` that exclude
|
||||
`AbortSignal` (it cannot be serialised).
|
||||
- Runtime type guard `isVoiceSynthesizer` for plugin / config-driven
|
||||
scenarios where the engine is loaded dynamically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { VoiceSynthesizer } from "@suaveplan/voice/tts/tts";
|
||||
|
||||
async function speak(tts: VoiceSynthesizer, text: string, voiceId: string) {
|
||||
const result = await tts.synthesize({ text, voiceId });
|
||||
return result.audio;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Stream audio into a Web Audio `MediaSource` while honouring an `AbortSignal`,
|
||||
and capture per-character usage from the result envelope returned by a
|
||||
non-streaming follow-up call:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SynthesisRequest,
|
||||
VoiceSynthesizer,
|
||||
} from "@suaveplan/voice/tts/tts";
|
||||
|
||||
async function pipeIntoMediaSource(
|
||||
tts: VoiceSynthesizer,
|
||||
req: SynthesisRequest,
|
||||
source: MediaSourceHandle,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
const stream = tts.synthesizeStream({ ...req, signal });
|
||||
await stream.pipeTo(source.writable, { signal });
|
||||
// Optionally fetch a non-streaming response to capture charactersUsed:
|
||||
const final = await tts.synthesize({ ...req, signal });
|
||||
source.attribution = { charactersUsed: final.charactersUsed };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VoiceSynthesizer` — interface with:
|
||||
- `providerName: string` — stable identifier (e.g. `'elevenlabs'`).
|
||||
- `synthesize(request: SynthesisRequest): Promise<SynthesisResult>`.
|
||||
- `synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>`.
|
||||
- `SynthesisRequest` — TypeScript interface with:
|
||||
- `text` — required, non-empty.
|
||||
- `voiceId` — required, non-empty.
|
||||
- `modelId?`, `languageCode?`, `voiceSettings?`, `outputFormat?`,
|
||||
`latencyMode?`, `previousText?`, `nextText?`, `seed?`,
|
||||
`pronunciationDictionaryIds?`, `signal?`.
|
||||
- `SynthesisResult` — TypeScript interface with:
|
||||
- `audio` — `Uint8Array`.
|
||||
- `format` — `AudioFormat`.
|
||||
- `durationMs?`, `charactersUsed?`, `requestId?`.
|
||||
- `SynthesisRequestSchema` / `SynthesisResultSchema` — Zod parsers.
|
||||
- `isVoiceSynthesizer(value): value is VoiceSynthesizer` — duck-type guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `synthesizeStream` returns a `ReadableStream` synchronously rather than a
|
||||
`Promise<ReadableStream>` so callers can immediately wire the output into a
|
||||
`pipeTo`/`pipeThrough` chain. Adapters that need to await an HTTP handshake
|
||||
do that work inside the stream's `start()` callback.
|
||||
- `seed` is constrained to non-negative integers in the Zod schema. Providers
|
||||
that take signed seeds map at the boundary.
|
||||
- `AbortSignal` is excluded from the Zod schema — there is no canonical wire
|
||||
representation. Adapters thread the signal end-to-end into their HTTP
|
||||
client.
|
||||
- The runtime guard checks the three required members but does not enforce
|
||||
arity or argument types on the methods. Callers that need stronger checks
|
||||
should wrap the result of `isVoiceSynthesizer` with an additional sanity
|
||||
call (`await tts.synthesize(...)` against a known-good fixture).
|
||||
- `pronunciationDictionaryIds` is an array of opaque ids; the lookup
|
||||
resolution is provider-specific.
|
||||
33
packages/media/audio/voice/src/tts/tts.schemas.ts
Normal file
33
packages/media/audio/voice/src/tts/tts.schemas.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
AudioFormatSchema,
|
||||
LatencyModeSchema,
|
||||
VoiceSettingsSchema,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for `SynthesisRequest`. Excludes `signal` (an `AbortSignal`
|
||||
* cannot be serialised) — adapters validate it structurally at runtime.
|
||||
*/
|
||||
export const SynthesisRequestSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
voiceId: z.string().min(1),
|
||||
modelId: z.string().min(1).optional(),
|
||||
languageCode: z.string().min(2).max(35).optional(),
|
||||
voiceSettings: VoiceSettingsSchema.optional(),
|
||||
outputFormat: AudioFormatSchema.optional(),
|
||||
latencyMode: LatencyModeSchema.optional(),
|
||||
previousText: z.string().optional(),
|
||||
nextText: z.string().optional(),
|
||||
seed: z.number().int().nonnegative().optional(),
|
||||
pronunciationDictionaryIds: z.array(z.string().min(1)).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `SynthesisResult`. */
|
||||
export const SynthesisResultSchema = z.object({
|
||||
audio: z.instanceof(Uint8Array),
|
||||
format: AudioFormatSchema,
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
charactersUsed: z.number().int().nonnegative().optional(),
|
||||
requestId: z.string().min(1).optional(),
|
||||
});
|
||||
140
packages/media/audio/voice/src/tts/tts.test.ts
Normal file
140
packages/media/audio/voice/src/tts/tts.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isVoiceSynthesizer } from "./tts.js";
|
||||
import {
|
||||
SynthesisRequestSchema,
|
||||
SynthesisResultSchema,
|
||||
} from "./tts.schemas.js";
|
||||
|
||||
describe("SynthesisRequestSchema", () => {
|
||||
it("accepts a minimal request", () => {
|
||||
const r = { text: "hello", voiceId: "v1" };
|
||||
expect(SynthesisRequestSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("accepts a fully-specified request", () => {
|
||||
const r = {
|
||||
text: "hello",
|
||||
voiceId: "v1",
|
||||
modelId: "eleven_multilingual_v2",
|
||||
languageCode: "en",
|
||||
voiceSettings: { stability: 0.5 },
|
||||
outputFormat: "mp3_44100_128" as const,
|
||||
latencyMode: 2 as const,
|
||||
previousText: "before",
|
||||
nextText: "after",
|
||||
seed: 42,
|
||||
pronunciationDictionaryIds: ["dict-1"],
|
||||
};
|
||||
expect(SynthesisRequestSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("rejects an empty text", () => {
|
||||
expect(() =>
|
||||
SynthesisRequestSchema.parse({ text: "", voiceId: "v1" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty voiceId", () => {
|
||||
expect(() =>
|
||||
SynthesisRequestSchema.parse({ text: "hi", voiceId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a negative seed", () => {
|
||||
expect(() =>
|
||||
SynthesisRequestSchema.parse({
|
||||
text: "hi",
|
||||
voiceId: "v1",
|
||||
seed: -1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SynthesisResultSchema", () => {
|
||||
it("accepts a minimal result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1, 2, 3]),
|
||||
format: "mp3_44100_128" as const,
|
||||
};
|
||||
expect(SynthesisResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("accepts a fully-specified result", () => {
|
||||
const r = {
|
||||
audio: new Uint8Array([1, 2, 3]),
|
||||
format: "pcm_16000" as const,
|
||||
durationMs: 500,
|
||||
charactersUsed: 5,
|
||||
requestId: "req-1",
|
||||
};
|
||||
expect(SynthesisResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
|
||||
it("rejects a non-Uint8Array audio field", () => {
|
||||
expect(() =>
|
||||
SynthesisResultSchema.parse({
|
||||
audio: [1, 2, 3],
|
||||
format: "mp3_44100_128",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVoiceSynthesizer", () => {
|
||||
it("accepts a valid tts shape", () => {
|
||||
const stub = {
|
||||
providerName: "test",
|
||||
synthesize: async () => ({}),
|
||||
synthesizeStream: () => new ReadableStream(),
|
||||
};
|
||||
expect(isVoiceSynthesizer(stub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null and undefined", () => {
|
||||
expect(isVoiceSynthesizer(null)).toBe(false);
|
||||
expect(isVoiceSynthesizer(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
expect(isVoiceSynthesizer("nope")).toBe(false);
|
||||
expect(isVoiceSynthesizer(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing providerName", () => {
|
||||
expect(
|
||||
isVoiceSynthesizer({
|
||||
synthesize: () => {},
|
||||
synthesizeStream: () => {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects with non-string providerName", () => {
|
||||
expect(
|
||||
isVoiceSynthesizer({
|
||||
providerName: 42,
|
||||
synthesize: () => {},
|
||||
synthesizeStream: () => {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing synthesize function", () => {
|
||||
expect(
|
||||
isVoiceSynthesizer({
|
||||
providerName: "test",
|
||||
synthesizeStream: () => {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing synthesizeStream function", () => {
|
||||
expect(
|
||||
isVoiceSynthesizer({
|
||||
providerName: "test",
|
||||
synthesize: () => {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
15
packages/media/audio/voice/src/tts/tts.ts
Normal file
15
packages/media/audio/voice/src/tts/tts.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { VoiceSynthesizer } from "./tts.types.js";
|
||||
|
||||
/**
|
||||
* Runtime type guard for `VoiceSynthesizer`. Use at boundaries where the
|
||||
* source is untyped (plugin loading, config-driven engine construction).
|
||||
*/
|
||||
export function isVoiceSynthesizer(value: unknown): value is VoiceSynthesizer {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["providerName"] === "string" &&
|
||||
typeof obj["synthesize"] === "function" &&
|
||||
typeof obj["synthesizeStream"] === "function"
|
||||
);
|
||||
}
|
||||
47
packages/media/audio/voice/src/tts/tts.types.ts
Normal file
47
packages/media/audio/voice/src/tts/tts.types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AudioFormat,
|
||||
LatencyMode,
|
||||
VoiceSettings,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* A request to synthesize text into spoken audio. Optional fields default to
|
||||
* provider defaults when omitted; provider adapters MUST document their
|
||||
* defaults in their own README.
|
||||
*/
|
||||
export interface SynthesisRequest {
|
||||
readonly text: string;
|
||||
readonly voiceId: string;
|
||||
readonly modelId?: string;
|
||||
readonly languageCode?: string;
|
||||
readonly voiceSettings?: VoiceSettings;
|
||||
readonly outputFormat?: AudioFormat;
|
||||
readonly latencyMode?: LatencyMode;
|
||||
readonly previousText?: string;
|
||||
readonly nextText?: string;
|
||||
readonly seed?: number;
|
||||
readonly pronunciationDictionaryIds?: readonly string[];
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** A completed synthesis result. */
|
||||
export interface SynthesisResult {
|
||||
readonly audio: Uint8Array;
|
||||
readonly format: AudioFormat;
|
||||
readonly durationMs?: number;
|
||||
readonly charactersUsed?: number;
|
||||
readonly requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text-to-speech engine interface. Provider adapters implement this and
|
||||
* are constructed with their own provider-specific options.
|
||||
*/
|
||||
export interface VoiceSynthesizer {
|
||||
/** Stable provider identifier, e.g. `'elevenlabs'`, `'openai'`, `'google'`. */
|
||||
readonly providerName: string;
|
||||
/** Synthesize the request fully and resolve with audio bytes. */
|
||||
synthesize(request: SynthesisRequest): Promise<SynthesisResult>;
|
||||
/** Synthesize and stream audio bytes incrementally. */
|
||||
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>;
|
||||
}
|
||||
29
packages/media/audio/voice/src/types/index.ts
Normal file
29
packages/media/audio/voice/src/types/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type {
|
||||
AudioFormat,
|
||||
AudioSource,
|
||||
DetectedEntity,
|
||||
LatencyMode,
|
||||
ListVoicesOptions,
|
||||
SpeakerSegment,
|
||||
Voice,
|
||||
VoiceAge,
|
||||
VoiceCategory,
|
||||
VoiceGender,
|
||||
VoiceList,
|
||||
VoiceSettings,
|
||||
WordTimestamp,
|
||||
} from "./types.js";
|
||||
export {
|
||||
AudioFormatSchema,
|
||||
DetectedEntitySchema,
|
||||
LatencyModeSchema,
|
||||
ListVoicesOptionsSchema,
|
||||
SpeakerSegmentSchema,
|
||||
VoiceAgeSchema,
|
||||
VoiceCategorySchema,
|
||||
VoiceGenderSchema,
|
||||
VoiceListSchema,
|
||||
VoiceSchema,
|
||||
VoiceSettingsSchema,
|
||||
WordTimestampSchema,
|
||||
} from "./types.js";
|
||||
121
packages/media/audio/voice/src/types/types.md
Normal file
121
packages/media/audio/voice/src/types/types.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `types/types`
|
||||
|
||||
Shared types and Zod schemas used by every voice engine interface in
|
||||
`@suaveplan/voice`. The module is the single source of truth for
|
||||
audio-format strings, voice metadata, request/response timing primitives, and
|
||||
listing pagination shapes — provider adapters import from here so consumers
|
||||
get the same `Voice`, `WordTimestamp`, and `AudioFormat` regardless of which
|
||||
backend is plugged in.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Voice engines speak audio in many encodings, surface speaker information in
|
||||
many shapes, and page voice catalogues differently. Without a normalised set
|
||||
of types, callers would learn one shape per provider. This module locks the
|
||||
canonical shape: every adapter MUST accept the documented `AudioFormat`
|
||||
strings, return `Voice` objects matching `VoiceSchema`, and emit transcription
|
||||
results made of `WordTimestamp[]` and derived `SpeakerSegment[]`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `AudioFormatSchema` enum covering 12 documented codec/sample-rate/
|
||||
bitrate combinations spanning MP3, PCM, WAV, μ-law, Opus, AAC, FLAC.
|
||||
- `LatencyMode` literal union `0..4` for streaming-latency optimisation.
|
||||
- Voice taxonomy: `VoiceCategorySchema`, `VoiceGenderSchema`, `VoiceAgeSchema`.
|
||||
- `VoiceSettingsSchema` normalising stability, similarity-boost, style to
|
||||
`[0, 1]` plus a boolean speaker-boost flag.
|
||||
- `VoiceSchema` covering id, name, category, optional gender / accent / age,
|
||||
language codes, description, preview URL, settings, free-form metadata.
|
||||
- Pagination types `ListVoicesOptionsSchema` and `VoiceListSchema`.
|
||||
- `AudioSource = Uint8Array | ReadableStream<Uint8Array> | string` — the
|
||||
three input shapes every audio-consuming engine accepts.
|
||||
- Transcription primitives: `WordTimestampSchema`, `SpeakerSegmentSchema`,
|
||||
`DetectedEntitySchema`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AudioFormatSchema,
|
||||
type Voice,
|
||||
VoiceSchema,
|
||||
} from "@suaveplan/voice/types/types";
|
||||
|
||||
const v: Voice = VoiceSchema.parse({
|
||||
id: "v1",
|
||||
name: "Demo",
|
||||
category: "premade",
|
||||
});
|
||||
|
||||
// Validate a format string at the boundary:
|
||||
const fmt = AudioFormatSchema.parse("mp3_44100_128");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Build a `VoiceList` cursor pager around any provider's list response:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type ListVoicesOptions,
|
||||
type VoiceList,
|
||||
VoiceListSchema,
|
||||
} from "@suaveplan/voice/types/types";
|
||||
|
||||
async function* paginate(
|
||||
list: (opts: ListVoicesOptions) => Promise<VoiceList>,
|
||||
): AsyncIterable<Voice> {
|
||||
let token: string | undefined;
|
||||
while (true) {
|
||||
const opts: ListVoicesOptions = { pageSize: 100 };
|
||||
if (token !== undefined) opts.pageToken = token;
|
||||
const page = VoiceListSchema.parse(await list(opts));
|
||||
for (const v of page.voices) yield v;
|
||||
if (!page.hasMore || page.nextPageToken === undefined) break;
|
||||
token = page.nextPageToken;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AudioFormatSchema` / `AudioFormat` — Zod enum + inferred type covering all
|
||||
12 documented format strings.
|
||||
- `LatencyModeSchema` / `LatencyMode` — `0 | 1 | 2 | 3 | 4`.
|
||||
- `VoiceCategorySchema` / `VoiceCategory` —
|
||||
`'premade' | 'cloned' | 'generated' | 'professional'`.
|
||||
- `VoiceGenderSchema` / `VoiceGender` — `'male' | 'female' | 'neutral'`.
|
||||
- `VoiceAgeSchema` / `VoiceAge` — `'young' | 'middle' | 'old'`.
|
||||
- `VoiceSettingsSchema` / `VoiceSettings` — `{ stability?, similarityBoost?,
|
||||
style?, speakerBoost? }`; floats clamped to `[0, 1]`.
|
||||
- `VoiceSchema` / `Voice` — full voice record with required `id`/`name`/
|
||||
`category` and optional taxonomy + metadata.
|
||||
- `ListVoicesOptionsSchema` / `ListVoicesOptions` — paging and filter
|
||||
parameters; `pageSize` clamped to `[1, 100]`.
|
||||
- `VoiceListSchema` / `VoiceList` — `{ voices, nextPageToken?, hasMore }`.
|
||||
- `AudioSource` — input-shape union for audio-consuming engines.
|
||||
- `WordTimestampSchema` / `WordTimestamp` — single-word timing, optional
|
||||
`speakerId`, `channelIndex`, `confidence`.
|
||||
- `SpeakerSegmentSchema` / `SpeakerSegment` — collapsed contiguous runs of
|
||||
same-speaker words.
|
||||
- `DetectedEntitySchema` / `DetectedEntity` — entity-detection annotation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `AudioFormat` strings deliberately follow the `<codec>_<sampleRate>[_<bitrate>]`
|
||||
convention so adapters can parse them without a per-format lookup table.
|
||||
- `previewUrl` is validated by Zod's `.url()` check — provider adapters must
|
||||
ensure the URL is reachable; this package does not fetch it.
|
||||
- `pageSize` upper bound is `100`; providers may request smaller pages but
|
||||
must never exceed this without an explicit OpenSpec change.
|
||||
- `AudioSource` is a TypeScript type, not a Zod schema — `ReadableStream`
|
||||
cannot be deeply parsed by Zod without consuming the stream. Adapters
|
||||
validate the shape at runtime via `instanceof` checks.
|
||||
- `confidence` is optional: not every provider surfaces it. When present it
|
||||
is normalised to `[0, 1]`.
|
||||
- `metadata` is a free-form `Record<string, unknown>` so adapters can attach
|
||||
provider-specific labels without polluting the canonical surface.
|
||||
212
packages/media/audio/voice/src/types/types.test.ts
Normal file
212
packages/media/audio/voice/src/types/types.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AudioFormatSchema,
|
||||
DetectedEntitySchema,
|
||||
LatencyModeSchema,
|
||||
ListVoicesOptionsSchema,
|
||||
SpeakerSegmentSchema,
|
||||
VoiceAgeSchema,
|
||||
VoiceCategorySchema,
|
||||
VoiceGenderSchema,
|
||||
VoiceListSchema,
|
||||
VoiceSchema,
|
||||
VoiceSettingsSchema,
|
||||
WordTimestampSchema,
|
||||
} from "./types.js";
|
||||
|
||||
describe("AudioFormatSchema", () => {
|
||||
it("accepts every documented format value", () => {
|
||||
const formats = [
|
||||
"mp3_22050_32",
|
||||
"mp3_22050_64",
|
||||
"mp3_44100_128",
|
||||
"mp3_44100_192",
|
||||
"pcm_16000",
|
||||
"pcm_22050",
|
||||
"pcm_44100",
|
||||
"ulaw_8000",
|
||||
"wav_44100",
|
||||
"opus_48000",
|
||||
"aac_44100",
|
||||
"flac_44100",
|
||||
];
|
||||
for (const f of formats) {
|
||||
expect(AudioFormatSchema.parse(f)).toBe(f);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown formats", () => {
|
||||
expect(() => AudioFormatSchema.parse("mp3_99000")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LatencyModeSchema", () => {
|
||||
it("accepts integers 0 through 4", () => {
|
||||
for (const v of [0, 1, 2, 3, 4]) {
|
||||
expect(LatencyModeSchema.parse(v)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects out-of-range and non-integer values", () => {
|
||||
expect(() => LatencyModeSchema.parse(5)).toThrow();
|
||||
expect(() => LatencyModeSchema.parse(-1)).toThrow();
|
||||
expect(() => LatencyModeSchema.parse(1.5)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceCategorySchema", () => {
|
||||
it("accepts the four documented categories", () => {
|
||||
for (const c of ["premade", "cloned", "generated", "professional"]) {
|
||||
expect(VoiceCategorySchema.parse(c)).toBe(c);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceGenderSchema and VoiceAgeSchema", () => {
|
||||
it("accept documented values only", () => {
|
||||
expect(VoiceGenderSchema.parse("male")).toBe("male");
|
||||
expect(VoiceAgeSchema.parse("young")).toBe("young");
|
||||
expect(() => VoiceGenderSchema.parse("nonbinary")).toThrow();
|
||||
expect(() => VoiceAgeSchema.parse("teen")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceSettingsSchema", () => {
|
||||
it("accepts in-bounds values and rejects out-of-bounds", () => {
|
||||
expect(VoiceSettingsSchema.parse({ stability: 0.5 })).toEqual({
|
||||
stability: 0.5,
|
||||
});
|
||||
expect(() => VoiceSettingsSchema.parse({ stability: 1.1 })).toThrow();
|
||||
expect(() =>
|
||||
VoiceSettingsSchema.parse({ similarityBoost: -0.01 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("treats every field as optional", () => {
|
||||
expect(VoiceSettingsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts speakerBoost as a boolean", () => {
|
||||
expect(VoiceSettingsSchema.parse({ speakerBoost: true })).toEqual({
|
||||
speakerBoost: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceSchema", () => {
|
||||
it("round-trips a minimal voice", () => {
|
||||
const v = { id: "v1", name: "Demo", category: "premade" as const };
|
||||
expect(VoiceSchema.parse(v)).toEqual(v);
|
||||
});
|
||||
|
||||
it("round-trips a fully-specified voice", () => {
|
||||
const v = {
|
||||
id: "v1",
|
||||
name: "Demo",
|
||||
category: "professional" as const,
|
||||
gender: "female" as const,
|
||||
accent: "british",
|
||||
age: "young" as const,
|
||||
languageCodes: ["en", "en-GB"],
|
||||
description: "Friendly and warm",
|
||||
previewUrl: "https://example.com/preview.mp3",
|
||||
settings: { stability: 0.5, similarityBoost: 0.7 },
|
||||
metadata: { tag: "preview" },
|
||||
};
|
||||
expect(VoiceSchema.parse(v)).toEqual(v);
|
||||
});
|
||||
|
||||
it("rejects an empty id", () => {
|
||||
expect(() =>
|
||||
VoiceSchema.parse({ id: "", name: "x", category: "premade" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a malformed previewUrl", () => {
|
||||
expect(() =>
|
||||
VoiceSchema.parse({
|
||||
id: "v1",
|
||||
name: "x",
|
||||
category: "premade",
|
||||
previewUrl: "not-a-url",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListVoicesOptionsSchema", () => {
|
||||
it("treats every field as optional", () => {
|
||||
expect(ListVoicesOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects pageSize outside 1..100", () => {
|
||||
expect(() => ListVoicesOptionsSchema.parse({ pageSize: 0 })).toThrow();
|
||||
expect(() =>
|
||||
ListVoicesOptionsSchema.parse({ pageSize: 101 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts in-range pageSize", () => {
|
||||
expect(ListVoicesOptionsSchema.parse({ pageSize: 50 })).toEqual({
|
||||
pageSize: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceListSchema", () => {
|
||||
it("requires hasMore and accepts an optional nextPageToken", () => {
|
||||
expect(
|
||||
VoiceListSchema.parse({
|
||||
voices: [{ id: "v1", name: "x", category: "premade" }],
|
||||
hasMore: false,
|
||||
})
|
||||
).toEqual({
|
||||
voices: [{ id: "v1", name: "x", category: "premade" }],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WordTimestampSchema", () => {
|
||||
it("accepts a minimal word entry", () => {
|
||||
const w = { word: "hello", startMs: 0, endMs: 250 };
|
||||
expect(WordTimestampSchema.parse(w)).toEqual(w);
|
||||
});
|
||||
|
||||
it("rejects negative timestamps", () => {
|
||||
expect(() =>
|
||||
WordTimestampSchema.parse({ word: "x", startMs: -1, endMs: 0 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects confidence outside 0..1", () => {
|
||||
expect(() =>
|
||||
WordTimestampSchema.parse({
|
||||
word: "x",
|
||||
startMs: 0,
|
||||
endMs: 1,
|
||||
confidence: 1.5,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpeakerSegmentSchema", () => {
|
||||
it("requires a non-empty speakerId", () => {
|
||||
expect(() =>
|
||||
SpeakerSegmentSchema.parse({ speakerId: "", startMs: 0, endMs: 1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a valid segment", () => {
|
||||
const s = { speakerId: "spk-1", startMs: 0, endMs: 1500 };
|
||||
expect(SpeakerSegmentSchema.parse(s)).toEqual(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetectedEntitySchema", () => {
|
||||
it("accepts a valid entity", () => {
|
||||
const e = { text: "Alice", type: "person", startChar: 0, endChar: 5 };
|
||||
expect(DetectedEntitySchema.parse(e)).toEqual(e);
|
||||
});
|
||||
});
|
||||
152
packages/media/audio/voice/src/types/types.ts
Normal file
152
packages/media/audio/voice/src/types/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Audio formats every voice engine speaks. Provider adapters MUST accept any
|
||||
* value in this set as an output format request and either return audio in
|
||||
* that format or throw `AudioFormatUnsupportedError`.
|
||||
*
|
||||
* Format encoding follows the convention `<codec>_<sampleRate>[_<bitrate>]`:
|
||||
*
|
||||
* - `mp3_22050_32` — MP3 22.05 kHz @ 32 kbps
|
||||
* - `mp3_22050_64` — MP3 22.05 kHz @ 64 kbps
|
||||
* - `mp3_44100_128` — MP3 44.1 kHz @ 128 kbps
|
||||
* - `mp3_44100_192` — MP3 44.1 kHz @ 192 kbps
|
||||
* - `pcm_16000` — PCM 16 kHz (linear, 16-bit, little-endian)
|
||||
* - `pcm_22050` — PCM 22.05 kHz
|
||||
* - `pcm_44100` — PCM 44.1 kHz
|
||||
* - `ulaw_8000` — μ-law 8 kHz (telephony)
|
||||
* - `wav_44100` — WAV-wrapped PCM 44.1 kHz
|
||||
* - `opus_48000` — Opus 48 kHz
|
||||
* - `aac_44100` — AAC 44.1 kHz
|
||||
* - `flac_44100` — FLAC 44.1 kHz
|
||||
*/
|
||||
export const AudioFormatSchema = z.enum([
|
||||
"mp3_22050_32",
|
||||
"mp3_22050_64",
|
||||
"mp3_44100_128",
|
||||
"mp3_44100_192",
|
||||
"pcm_16000",
|
||||
"pcm_22050",
|
||||
"pcm_44100",
|
||||
"ulaw_8000",
|
||||
"wav_44100",
|
||||
"opus_48000",
|
||||
"aac_44100",
|
||||
"flac_44100",
|
||||
]);
|
||||
export type AudioFormat = z.infer<typeof AudioFormatSchema>;
|
||||
|
||||
/**
|
||||
* Streaming-latency optimisation level. `0` favours quality (lowest latency
|
||||
* optimisation), `4` favours speed (most aggressive optimisation, audible
|
||||
* quality drop in some providers).
|
||||
*/
|
||||
export const LatencyModeSchema = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(3),
|
||||
z.literal(4),
|
||||
]);
|
||||
export type LatencyMode = z.infer<typeof LatencyModeSchema>;
|
||||
|
||||
/** Voice category classification. */
|
||||
export const VoiceCategorySchema = z.enum([
|
||||
"premade",
|
||||
"cloned",
|
||||
"generated",
|
||||
"professional",
|
||||
]);
|
||||
export type VoiceCategory = z.infer<typeof VoiceCategorySchema>;
|
||||
|
||||
/** Voice gender classification. Free-form strings allowed via `metadata`. */
|
||||
export const VoiceGenderSchema = z.enum(["male", "female", "neutral"]);
|
||||
export type VoiceGender = z.infer<typeof VoiceGenderSchema>;
|
||||
|
||||
/** Voice age classification. */
|
||||
export const VoiceAgeSchema = z.enum(["young", "middle", "old"]);
|
||||
export type VoiceAge = z.infer<typeof VoiceAgeSchema>;
|
||||
|
||||
/**
|
||||
* Per-request voice tuning parameters. All values are normalised to `[0, 1]`
|
||||
* floats; providers map them onto their native scales.
|
||||
*/
|
||||
export const VoiceSettingsSchema = z.object({
|
||||
stability: z.number().min(0).max(1).optional(),
|
||||
similarityBoost: z.number().min(0).max(1).optional(),
|
||||
style: z.number().min(0).max(1).optional(),
|
||||
speakerBoost: z.boolean().optional(),
|
||||
});
|
||||
export type VoiceSettings = z.infer<typeof VoiceSettingsSchema>;
|
||||
|
||||
/** A voice available for synthesis. */
|
||||
export const VoiceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: VoiceCategorySchema,
|
||||
gender: VoiceGenderSchema.optional(),
|
||||
accent: z.string().optional(),
|
||||
age: VoiceAgeSchema.optional(),
|
||||
languageCodes: z.array(z.string().min(2).max(35)).optional(),
|
||||
description: z.string().optional(),
|
||||
previewUrl: z.string().url().optional(),
|
||||
settings: VoiceSettingsSchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type Voice = z.infer<typeof VoiceSchema>;
|
||||
|
||||
/** Filter / paging options for listing voices. */
|
||||
export const ListVoicesOptionsSchema = z.object({
|
||||
category: VoiceCategorySchema.optional(),
|
||||
languageCode: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
pageToken: z.string().optional(),
|
||||
pageSize: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
export type ListVoicesOptions = z.infer<typeof ListVoicesOptionsSchema>;
|
||||
|
||||
/** Paged voice list result. */
|
||||
export const VoiceListSchema = z.object({
|
||||
voices: z.array(VoiceSchema),
|
||||
nextPageToken: z.string().optional(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
export type VoiceList = z.infer<typeof VoiceListSchema>;
|
||||
|
||||
/**
|
||||
* Audio source accepted by engines.
|
||||
*
|
||||
* - `Uint8Array` — raw bytes already in memory.
|
||||
* - `ReadableStream<Uint8Array>` — streamed bytes; engines that require a
|
||||
* complete file (e.g. multipart STT upload) buffer the stream first.
|
||||
* - `string` — interpreted as a URL the engine should fetch.
|
||||
*/
|
||||
export type AudioSource = Uint8Array | ReadableStream<Uint8Array> | string;
|
||||
|
||||
/** Per-word transcription timing. */
|
||||
export const WordTimestampSchema = z.object({
|
||||
word: z.string().min(1),
|
||||
startMs: z.number().nonnegative(),
|
||||
endMs: z.number().nonnegative(),
|
||||
speakerId: z.string().optional(),
|
||||
channelIndex: z.number().int().nonnegative().optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
export type WordTimestamp = z.infer<typeof WordTimestampSchema>;
|
||||
|
||||
/** A continuous segment attributed to a single speaker. */
|
||||
export const SpeakerSegmentSchema = z.object({
|
||||
speakerId: z.string().min(1),
|
||||
startMs: z.number().nonnegative(),
|
||||
endMs: z.number().nonnegative(),
|
||||
});
|
||||
export type SpeakerSegment = z.infer<typeof SpeakerSegmentSchema>;
|
||||
|
||||
/** A detected entity in a transcript. */
|
||||
export const DetectedEntitySchema = z.object({
|
||||
text: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
startChar: z.number().int().nonnegative(),
|
||||
endChar: z.number().int().nonnegative(),
|
||||
});
|
||||
export type DetectedEntity = z.infer<typeof DetectedEntitySchema>;
|
||||
7
packages/media/audio/voice/src/voices/index.ts
Normal file
7
packages/media/audio/voice/src/voices/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { isVoiceRepository } from "./voices.js";
|
||||
export {
|
||||
VoiceRepositoryGetResultSchema,
|
||||
VoiceRepositoryListOptionsSchema,
|
||||
VoiceRepositoryListResultSchema,
|
||||
} from "./voices.schemas.js";
|
||||
export type { VoiceRepository } from "./voices.types.js";
|
||||
111
packages/media/audio/voice/src/voices/voices.md
Normal file
111
packages/media/audio/voice/src/voices/voices.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# `voices/voices`
|
||||
|
||||
The voice repository interface for `@suaveplan/voice` — read-only listing and
|
||||
lookup of voices available to the active provider. Defines the
|
||||
`VoiceRepository` contract every voice provider adapter must implement, plus
|
||||
the supporting Zod schemas and a runtime type guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provider voice catalogues differ in pagination scheme (cursor / page-token /
|
||||
offset), filter taxonomy, and "shared" vs "owned" voice splits. This module
|
||||
locks the canonical surface — `list(options?)` returning a `VoiceList`
|
||||
envelope and `get(voiceId)` returning `Voice | null`. Provider adapters
|
||||
collapse their native responses onto these shapes, so consumers can browse
|
||||
voices without learning each backend's quirks.
|
||||
|
||||
## Features
|
||||
|
||||
- `VoiceRepository` interface with `providerName`, `list`, and `get`.
|
||||
- `list(options?)` returns a `VoiceList` envelope (`voices`, `nextPageToken`,
|
||||
`hasMore`).
|
||||
- `get(voiceId)` resolves to `Voice | null` — adapters MUST map upstream
|
||||
not-found codes to `null` rather than throwing.
|
||||
- Zod schemas re-exporting `ListVoicesOptionsSchema` (as
|
||||
`VoiceRepositoryListOptionsSchema`), `VoiceListSchema` (as
|
||||
`VoiceRepositoryListResultSchema`), and a `Voice | null` union for the
|
||||
`get` result.
|
||||
- Runtime type guard `isVoiceRepository`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { VoiceRepository } from "@suaveplan/voice/voices/voices";
|
||||
|
||||
async function findVoiceByName(
|
||||
voices: VoiceRepository,
|
||||
name: string,
|
||||
) {
|
||||
const page = await voices.list({ search: name, pageSize: 25 });
|
||||
return page.voices[0] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Async-iterate every voice across all pages, with a guarded language filter:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ListVoicesOptions,
|
||||
Voice,
|
||||
VoiceRepository,
|
||||
} from "@suaveplan/voice";
|
||||
|
||||
async function* allEnglishVoices(
|
||||
voices: VoiceRepository,
|
||||
): AsyncIterable<Voice> {
|
||||
let token: string | undefined;
|
||||
while (true) {
|
||||
const opts: ListVoicesOptions = { languageCode: "en", pageSize: 100 };
|
||||
if (token !== undefined) opts.pageToken = token;
|
||||
const page = await voices.list(opts);
|
||||
for (const v of page.voices) yield v;
|
||||
if (!page.hasMore || page.nextPageToken === undefined) break;
|
||||
token = page.nextPageToken;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupOrFail(voices: VoiceRepository, id: string): Promise<Voice> {
|
||||
const v = await voices.get(id);
|
||||
if (v === null) throw new Error(`voice ${id} not found`);
|
||||
return v;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VoiceRepository` — interface with:
|
||||
- `providerName: string` — stable identifier (e.g. `'elevenlabs'`).
|
||||
- `list(options?: ListVoicesOptions): Promise<VoiceList>`.
|
||||
- `get(voiceId: string): Promise<Voice | null>`.
|
||||
- `VoiceRepositoryListOptionsSchema` — re-export of `ListVoicesOptionsSchema`
|
||||
for ergonomic discovery.
|
||||
- `VoiceRepositoryListResultSchema` — alias of `VoiceListSchema`.
|
||||
- `VoiceRepositoryGetResultSchema` — Zod union of `VoiceSchema` and
|
||||
`z.null()`.
|
||||
- `isVoiceRepository(value): value is VoiceRepository` — duck-type guard.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `null`-on-not-found contract for `get` is deliberate: callers commonly
|
||||
check existence before falling back to a default voice. Throwing would
|
||||
force a try/catch around every probe. Adapters that receive a 404 from
|
||||
upstream MUST map it to `null`; any other error is propagated as a
|
||||
`VoiceError` subclass.
|
||||
- Voice cloning is intentionally absent. ElevenLabs supports instant /
|
||||
professional cloning; OpenAI does not at all; Azure has its own
|
||||
"Custom Neural Voice" workflow with a multi-week verification process.
|
||||
A future `VoiceCloner` interface will land in a dedicated subpath when the
|
||||
design is settled and ≥ 2 providers are mapped onto it.
|
||||
- The schemas in `voices.schemas.ts` are re-exports / aliases of the
|
||||
authoritative schemas in `types/types.ts`. They exist to give callers
|
||||
ergonomic discovery without forcing them to know that `ListVoicesOptions`
|
||||
and `VoiceList` are shared with non-repository consumers.
|
||||
- The runtime guard checks the three required members but does not enforce
|
||||
argument shapes on the methods. Callers needing stronger validation should
|
||||
call `await voices.list({})` against a fixture and assert the response.
|
||||
11
packages/media/audio/voice/src/voices/voices.schemas.ts
Normal file
11
packages/media/audio/voice/src/voices/voices.schemas.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
import { VoiceListSchema, VoiceSchema } from "../types/types.js";
|
||||
|
||||
/** Schema for `VoiceRepository.list` arguments — re-exports `ListVoicesOptionsSchema`. */
|
||||
export { ListVoicesOptionsSchema as VoiceRepositoryListOptionsSchema } from "../types/types.js";
|
||||
|
||||
/** Schema for the result of `VoiceRepository.list`. */
|
||||
export const VoiceRepositoryListResultSchema = VoiceListSchema;
|
||||
|
||||
/** Schema for the result of `VoiceRepository.get` — `Voice` or `null`. */
|
||||
export const VoiceRepositoryGetResultSchema = z.union([VoiceSchema, z.null()]);
|
||||
81
packages/media/audio/voice/src/voices/voices.test.ts
Normal file
81
packages/media/audio/voice/src/voices/voices.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { isVoiceRepository } from "./voices.js";
|
||||
import {
|
||||
VoiceRepositoryGetResultSchema,
|
||||
VoiceRepositoryListOptionsSchema,
|
||||
VoiceRepositoryListResultSchema,
|
||||
} from "./voices.schemas.js";
|
||||
|
||||
describe("VoiceRepositoryListOptionsSchema", () => {
|
||||
it("accepts an empty options object", () => {
|
||||
expect(VoiceRepositoryListOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts pageSize within bounds", () => {
|
||||
expect(
|
||||
VoiceRepositoryListOptionsSchema.parse({ pageSize: 25 })
|
||||
).toEqual({
|
||||
pageSize: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceRepositoryListResultSchema", () => {
|
||||
it("accepts a valid result envelope", () => {
|
||||
const r = {
|
||||
voices: [{ id: "v1", name: "x", category: "premade" as const }],
|
||||
hasMore: false,
|
||||
};
|
||||
expect(VoiceRepositoryListResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoiceRepositoryGetResultSchema", () => {
|
||||
it("accepts a valid Voice", () => {
|
||||
const v = { id: "v1", name: "x", category: "premade" as const };
|
||||
expect(VoiceRepositoryGetResultSchema.parse(v)).toEqual(v);
|
||||
});
|
||||
|
||||
it("accepts null", () => {
|
||||
expect(VoiceRepositoryGetResultSchema.parse(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects undefined (only Voice or null)", () => {
|
||||
expect(() => VoiceRepositoryGetResultSchema.parse(undefined)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVoiceRepository", () => {
|
||||
it("accepts a valid voices shape", () => {
|
||||
const stub = {
|
||||
providerName: "test",
|
||||
list: async () => ({}),
|
||||
get: async () => null,
|
||||
};
|
||||
expect(isVoiceRepository(stub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null and primitives", () => {
|
||||
expect(isVoiceRepository(null)).toBe(false);
|
||||
expect(isVoiceRepository("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects objects missing one of the required functions", () => {
|
||||
expect(isVoiceRepository({ providerName: "x", list: () => {} })).toBe(
|
||||
false
|
||||
);
|
||||
expect(isVoiceRepository({ providerName: "x", get: () => {} })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects objects with non-string providerName", () => {
|
||||
expect(
|
||||
isVoiceRepository({
|
||||
providerName: 1,
|
||||
list: () => {},
|
||||
get: () => {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/voice/src/voices/voices.ts
Normal file
12
packages/media/audio/voice/src/voices/voices.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { VoiceRepository } from "./voices.types.js";
|
||||
|
||||
/** Runtime type guard for `VoiceRepository`. */
|
||||
export function isVoiceRepository(value: unknown): value is VoiceRepository {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj["providerName"] === "string" &&
|
||||
typeof obj["list"] === "function" &&
|
||||
typeof obj["get"] === "function"
|
||||
);
|
||||
}
|
||||
21
packages/media/audio/voice/src/voices/voices.types.ts
Normal file
21
packages/media/audio/voice/src/voices/voices.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ListVoicesOptions, Voice, VoiceList } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* The voice repository interface — read-only listing and lookup. Cloning
|
||||
* APIs are intentionally NOT in this base interface because clone semantics
|
||||
* (sample upload, verification, fine-tuning lifecycle) vary too widely
|
||||
* across providers; a future `VoiceCloner` interface will live in a
|
||||
* dedicated subpath when the design is settled.
|
||||
*/
|
||||
export interface VoiceRepository {
|
||||
/** Stable provider identifier. */
|
||||
readonly providerName: string;
|
||||
/** List voices, optionally filtered and paged. */
|
||||
list(options?: ListVoicesOptions): Promise<VoiceList>;
|
||||
/**
|
||||
* Look up a voice by id. Resolves to `null` when the id is not found —
|
||||
* this is the canonical contract that adapters MUST honour, mapping any
|
||||
* upstream "not found" code to `null` rather than throwing.
|
||||
*/
|
||||
get(voiceId: string): Promise<Voice | null>;
|
||||
}
|
||||
7
packages/media/audio/voice/tests/setup/polyfills.ts
Normal file
7
packages/media/audio/voice/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/media/audio/voice/tsconfig.lib.json
Normal file
12
packages/media/audio/voice/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/media/audio/voice/vite.config.ts
Normal file
36
packages/media/audio/voice/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/voice",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/media/audio/voice/vitest.config.ts
Normal file
27
packages/media/audio/voice/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/__benchmarks__/**",
|
||||
"src/**/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user