Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View 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"
]
}

View 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`.

View 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(),
});

View 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);
});
});

View 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"
);
}

View 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>;
}

View 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";

View 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.

View 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");
});
});

View 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)
);
}
}

View 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";

View 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";

View 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";

View 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`.

View File

@@ -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(),
});

View 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);
});
});

View 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"
);
}

View 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>;
}

View 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";

View 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.

View 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(),
});

View 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);
});
});

View 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"
);
}

View 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>;
}

View 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";

View 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.

View 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(),
});

View 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);
});
});

View 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"
);
}

View 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>;
}

View 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";

View 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.

View 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);
});
});

View 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>;

View 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";

View 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.

View 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()]);

View 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);
});
});

View 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"
);
}

View 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>;
}

View 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;
}

View 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"]
}

View 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:/,
],
},
},
});

View 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,
},
});