509 lines
20 KiB
Markdown
509 lines
20 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: voice
|
|
|
|
**Package**: `@suaveplan/voice`
|
|
**Status**: STABLE
|
|
**Tier**: universal
|
|
|
|
> Every requirement below carries a `**Verified by:**` line anchored to a real test in
|
|
> `packages/media/audio/voice/`. No exceptions.
|
|
|
|
`@suaveplan/voice` is the provider-agnostic voice core: abstract engine interfaces, shared
|
|
domain types, Zod schemas, and structured errors for text-to-speech, speech-to-text, voice
|
|
management, sound-effect generation, and voice isolation. The package owns **no** transport,
|
|
auth, retry, or vendor-specific DTOs — those live in provider adapter packages
|
|
(`@suaveplan/voice-elevenlabs`, future `@suaveplan/voice-azure`, etc.).
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Universal-tier purity and minimal dependency set
|
|
|
|
The package SHALL import zero browser-only APIs (`window`, `document`, `navigator`,
|
|
`AudioContext`, `MediaStream`) and zero Node-only APIs (`node:fs`, `node:path`, `process`,
|
|
`Buffer`). All audio bytes SHALL flow through `Uint8Array` or `ReadableStream<Uint8Array>`.
|
|
The runtime `dependencies` field SHALL be limited to `@suaveplan/error`, `@suaveplan/types`,
|
|
`tslib`, and `zod`. No other `@suaveplan/*` package SHALL appear in `dependencies`.
|
|
|
|
#### Scenario: Built dist references no platform globals
|
|
|
|
- **WHEN** the built `dist/` is grep'd for `window`, `document`, `node:fs`, `node:path`,
|
|
`process`, `AudioContext`, `Buffer`
|
|
- **THEN** the count is zero
|
|
|
|
#### Scenario: Dependency surface is exactly the four allowed packages
|
|
|
|
- **WHEN** `package.json` `dependencies` is inspected
|
|
- **THEN** keys are exactly `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`
|
|
|
|
**Verified by:** `tests/dep-graph.test.ts` AND `tests/tree-shake.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: Stable error code registry under VOICE.* namespace
|
|
|
|
The package SHALL declare exactly one error registry via `defineCodes("VOICE", [...])`
|
|
exported as `VOICE_CODES`. The registry SHALL contain exactly these twelve codes in this order:
|
|
`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`.
|
|
No hand-typed code-string literal SHALL appear anywhere in `src/`.
|
|
|
|
#### Scenario: Code values use VOICE.* namespace
|
|
|
|
- **WHEN** a consumer reads `VOICE_CODES["TTS.SYNTHESIS_FAILED"]`
|
|
- **THEN** the value is exactly `"VOICE.TTS.SYNTHESIS_FAILED"`
|
|
|
|
#### Scenario: Registry is frozen
|
|
|
|
- **WHEN** caller attempts to mutate `VOICE_CODES` at runtime
|
|
- **THEN** the assignment fails (object is `Object.freeze`-d) and the values stay stable
|
|
|
|
**Verified by:** `src/errors/errors.test.ts::"VOICE_CODES freezes a stable map of namespaced codes"`
|
|
|
|
---
|
|
|
|
### Requirement: Typed error subclasses with parameterised context
|
|
|
|
The package SHALL export twelve `VoiceError` subclasses, one per registry code:
|
|
`TtsSynthesisFailedError`, `TtsStreamInterruptedError`, `SttTranscriptionFailedError`,
|
|
`SttUnsupportedFormatError`, `VoiceNotFoundError`, `VoiceCloneRejectedError`,
|
|
`SoundEffectGenerationFailedError`, `VoiceIsolationFailedError`,
|
|
`ProviderNotConfiguredError`, `ProviderRateLimitedError`, `VoiceValidationError`,
|
|
`AudioFormatUnsupportedError`. Each subclass SHALL accept a `VoiceErrorOptions` argument
|
|
extended with subclass-specific structured fields where the proposal mandates
|
|
(`format` on STT/audio errors, `voiceId` on `VoiceNotFoundError`, `retryAfterMs` on
|
|
`ProviderRateLimitedError`).
|
|
|
|
#### Scenario: VoiceNotFoundError populates voiceId in message and context
|
|
|
|
- **WHEN** caller constructs `new VoiceNotFoundError({ voiceId: "abc" })`
|
|
- **THEN** `error.message === 'Voice not found: "abc"'`
|
|
- **AND** `error.context.voiceId === "abc"`
|
|
- **AND** `error.code === "VOICE.VOICE.NOT_FOUND"`
|
|
|
|
#### Scenario: ProviderRateLimitedError preserves retryAfterMs
|
|
|
|
- **WHEN** caller constructs `new ProviderRateLimitedError({ retryAfterMs: 60_000 })`
|
|
- **THEN** `error.context.retryAfterMs === 60_000`
|
|
|
|
#### Scenario: SttUnsupportedFormatError omits context when no format
|
|
|
|
- **WHEN** caller constructs `new SttUnsupportedFormatError({})`
|
|
- **THEN** `error.context` is undefined (no empty object)
|
|
|
|
#### Scenario: traceId propagates on every subclass
|
|
|
|
- **WHEN** caller passes `traceId` on any subclass
|
|
- **THEN** the resulting error exposes the same `traceId`
|
|
|
|
**Verified by:** `src/errors/errors.test.ts` (every subclass test)
|
|
|
|
---
|
|
|
|
### Requirement: Twelve documented audio formats and helpers
|
|
|
|
The package SHALL declare `AudioFormat` as a Zod enum and TypeScript union containing
|
|
exactly these twelve format strings:
|
|
`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`.
|
|
Format strings SHALL follow the convention `<codec>_<sampleRate>[_<bitrate>]`. Adapters
|
|
that cannot serve a requested format SHALL throw `AudioFormatUnsupportedError`.
|
|
|
|
#### Scenario: every documented format value is accepted
|
|
|
|
- **WHEN** each of the twelve format strings is parsed by `AudioFormatSchema`
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: unknown format rejected
|
|
|
|
- **WHEN** `AudioFormatSchema` parses `"mp3_unknown"`
|
|
- **THEN** Zod throws
|
|
|
|
**Verified by:** `src/types/types.test.ts::"AudioFormatSchema accepts every documented
|
|
format value"` AND `src/types/types.test.ts::"AudioFormatSchema rejects unknown formats"`
|
|
|
|
---
|
|
|
|
### Requirement: Normalized scales for all numeric parameters
|
|
|
|
All numeric parameters on requests SHALL be normalized to documented bounded ranges:
|
|
voice-setting fields `stability`, `similarityBoost`, `style` to `[0, 1]`;
|
|
confidence in `WordTimestamp` to `[0, 1]`;
|
|
temperature in `TranscriptionRequest` to `[0, 2]`;
|
|
diarization threshold to `[0, 1]`;
|
|
prompt influence in `SoundEffectRequest` to `[0, 1]`;
|
|
sound-effect duration to `[0.5, 30]` seconds;
|
|
`numSpeakers` to `[1, 32]`;
|
|
page size to `[1, 100]`;
|
|
seeds to non-negative integers.
|
|
Adapters MUST remap provider-native scales onto these ranges at the boundary.
|
|
|
|
#### Scenario: page size outside 1..100 is rejected
|
|
|
|
- **WHEN** `ListVoicesOptionsSchema` parses `{ pageSize: 0 }` or `{ pageSize: 101 }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: temperature outside 0..2 is rejected
|
|
|
|
- **WHEN** `TranscriptionRequestSchema` parses `{ temperature: 2.5 }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: numSpeakers outside 1..32 is rejected
|
|
|
|
- **WHEN** `TranscriptionRequestSchema` parses `{ numSpeakers: 33 }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: durationSeconds outside 0.5..30 is rejected
|
|
|
|
- **WHEN** `SoundEffectRequestSchema` parses `{ text: "x", durationSeconds: 0.1 }`
|
|
- **THEN** Zod throws
|
|
|
|
**Verified by:** `src/types/types.test.ts::"ListVoicesOptionsSchema rejects pageSize outside 1..100"`,
|
|
`src/stt/stt.test.ts::"TranscriptionRequestSchema rejects temperature outside 0..2"`,
|
|
`src/stt/stt.test.ts::"TranscriptionRequestSchema rejects numSpeakers outside 1..32"`,
|
|
`src/effects/effects.test.ts::"SoundEffectRequestSchema rejects durationSeconds outside 0.5..30"`
|
|
|
|
---
|
|
|
|
### Requirement: VoiceSynthesizer engine contract
|
|
|
|
The package SHALL export an interface `VoiceSynthesizer` with shape:
|
|
|
|
```ts
|
|
interface VoiceSynthesizer {
|
|
readonly providerName: string;
|
|
synthesize(request: SynthesisRequest): Promise<SynthesisResult>;
|
|
synthesizeStream(request: SynthesisRequest): ReadableStream<Uint8Array>;
|
|
}
|
|
```
|
|
|
|
`SynthesisRequest` SHALL carry `text`, `voiceId`, plus optional `modelId`, `languageCode`,
|
|
`voiceSettings`, `outputFormat`, `latencyMode`, `previousText`, `nextText`, `seed`,
|
|
`pronunciationDictionaryIds`, and `signal: AbortSignal`. `SynthesisResult` SHALL carry
|
|
`audio: Uint8Array`, `format: AudioFormat`, plus optional `durationMs`, `charactersUsed`,
|
|
`requestId`. `synthesizeStream` SHALL return a `ReadableStream<Uint8Array>` synchronously —
|
|
adapters perform any HTTP handshake inside the stream's `start()` callback, not before
|
|
returning. The package SHALL also export `isVoiceSynthesizer(value): value is VoiceSynthesizer`
|
|
duck-type guard.
|
|
|
|
#### Scenario: SynthesisRequestSchema accepts a minimal request
|
|
|
|
- **WHEN** `SynthesisRequestSchema` parses `{ text: "Hi", voiceId: "v1" }`
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: SynthesisRequestSchema rejects empty text or voiceId
|
|
|
|
- **WHEN** parsing `{ text: "", voiceId: "v1" }` or `{ text: "Hi", voiceId: "" }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: SynthesisResultSchema rejects non-Uint8Array audio
|
|
|
|
- **WHEN** parsing `{ audio: "string", format: "mp3_44100_128" }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: duck-type guard rejects engines missing stream method
|
|
|
|
- **WHEN** `isVoiceSynthesizer({ providerName: "x", synthesize: () => {} })` is called
|
|
- **THEN** the guard returns `false`
|
|
|
|
**Verified by:** `src/tts/tts.test.ts::"SynthesisRequestSchema accepts a minimal request"`,
|
|
`src/tts/tts.test.ts::"isVoiceSynthesizer rejects objects missing synthesizeStream function"`
|
|
|
|
---
|
|
|
|
### Requirement: TranscriptionEngine engine contract
|
|
|
|
The package SHALL export an interface `TranscriptionEngine` with shape:
|
|
|
|
```ts
|
|
interface TranscriptionEngine {
|
|
readonly providerName: string;
|
|
transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
|
|
}
|
|
```
|
|
|
|
`TranscriptionRequest` SHALL carry `audio: AudioSource`, plus optional `modelId`,
|
|
`languageCode`, `tagAudioEvents`, `numSpeakers`, `timestampsGranularity` (`"word" | "character"`),
|
|
`diarize`, `diarizationThreshold`, `useMultiChannel`, `entityDetection` (`string[] | "all"`),
|
|
`entityRedaction` (same shape), `keyterms`, `temperature`, `seed`, `signal`. `AudioSource`
|
|
SHALL be the union `Uint8Array | ReadableStream<Uint8Array> | string` (the string variant is
|
|
a URL). `TranscriptionResult` SHALL carry `text` plus optional `words`, `speakers`,
|
|
`entities`, `language`, `durationMs`, `requestId`. The package SHALL also export
|
|
`isTranscriptionEngine` guard.
|
|
|
|
#### Scenario: TranscriptionRequestSchema accepts an empty request
|
|
|
|
- **WHEN** `TranscriptionRequestSchema` parses `{}`
|
|
- **THEN** parsing succeeds (every field optional after `audio`, which is excluded from schema)
|
|
|
|
#### Scenario: entityDetection accepts literal "all"
|
|
|
|
- **WHEN** parsing `{ entityDetection: "all" }`
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: duck-type guard rejects engines missing transcribe
|
|
|
|
- **WHEN** `isTranscriptionEngine({ providerName: "x" })` is called
|
|
- **THEN** the guard returns `false`
|
|
|
|
**Verified by:** `src/stt/stt.test.ts::"TranscriptionRequestSchema accepts an empty request"`,
|
|
`src/stt/stt.test.ts::"TranscriptionRequestSchema accepts entityDetection as 'all'"`,
|
|
`src/stt/stt.test.ts::"isTranscriptionEngine rejects objects with non-function transcribe"`
|
|
|
|
---
|
|
|
|
### Requirement: VoiceRepository engine contract with null-on-not-found
|
|
|
|
The package SHALL export an interface `VoiceRepository` with shape:
|
|
|
|
```ts
|
|
interface VoiceRepository {
|
|
readonly providerName: string;
|
|
list(options?: ListVoicesOptions): Promise<VoiceList>;
|
|
get(voiceId: string): Promise<Voice | null>;
|
|
}
|
|
```
|
|
|
|
`get(voiceId)` SHALL return `null` when the upstream provider reports the voice as not
|
|
found. Adapters MUST map provider-specific 404s to `null` and SHALL NOT throw
|
|
`VoiceNotFoundError` from `get`. Other failures (auth, 5xx, network) SHALL propagate as
|
|
typed `VoiceError` subclasses. `list` SHALL be paginated via opaque `nextPageToken` and
|
|
boolean `hasMore`.
|
|
|
|
#### Scenario: get-result schema accepts null
|
|
|
|
- **WHEN** `VoiceRepositoryGetResultSchema` parses `null`
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: get-result schema rejects undefined
|
|
|
|
- **WHEN** the schema parses `undefined`
|
|
- **THEN** Zod throws (only `Voice | null` accepted)
|
|
|
|
#### Scenario: list-result envelope requires hasMore
|
|
|
|
- **WHEN** `VoiceRepositoryListResultSchema` parses `{ voices: [], nextPageToken: undefined }`
|
|
- **THEN** Zod throws (hasMore required)
|
|
|
|
**Verified by:** `src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema accepts null"`,
|
|
`src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema rejects undefined (only Voice or null)"`
|
|
|
|
---
|
|
|
|
### Requirement: SoundEffectEngine engine contract
|
|
|
|
The package SHALL export an interface `SoundEffectEngine` with shape:
|
|
|
|
```ts
|
|
interface SoundEffectEngine {
|
|
readonly providerName: string;
|
|
generate(request: SoundEffectRequest): Promise<SoundEffectResult>;
|
|
}
|
|
```
|
|
|
|
`SoundEffectRequest` SHALL carry `text` plus optional `durationSeconds: [0.5, 30]`,
|
|
`loop`, `promptInfluence: [0, 1]`, `modelId`, `outputFormat`, `signal`. The interface
|
|
SHALL NOT include a streaming variant (the dominant provider pattern returns full audio
|
|
in a single response). A future streaming extension would arrive in its own subpath.
|
|
|
|
#### Scenario: empty text is rejected
|
|
|
|
- **WHEN** `SoundEffectRequestSchema` parses `{ text: "" }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: promptInfluence outside 0..1 is rejected
|
|
|
|
- **WHEN** the schema parses `{ text: "x", promptInfluence: 1.5 }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: duck-type guard rejects engines missing generate
|
|
|
|
- **WHEN** `isSoundEffectEngine({ providerName: "x" })` is called
|
|
- **THEN** the guard returns `false`
|
|
|
|
**Verified by:** `src/effects/effects.test.ts::"SoundEffectRequestSchema rejects an empty text"`,
|
|
`src/effects/effects.test.ts::"SoundEffectRequestSchema rejects promptInfluence outside 0..1"`,
|
|
`src/effects/effects.test.ts::"isSoundEffectEngine rejects objects missing generate"`
|
|
|
|
---
|
|
|
|
### Requirement: VoiceIsolationEngine engine contract
|
|
|
|
The package SHALL export an interface `VoiceIsolationEngine` with shape:
|
|
|
|
```ts
|
|
interface VoiceIsolationEngine {
|
|
readonly providerName: string;
|
|
isolate(request: IsolationRequest): Promise<IsolationResult>;
|
|
}
|
|
```
|
|
|
|
`IsolationRequest` SHALL carry `audio: AudioSource` plus optional `outputFormat`,
|
|
`signal`. The interface SHALL NOT include a streaming variant — most isolation services
|
|
need full audio context for voice-activity detection. Adapters that accept `ReadableStream`
|
|
audio SHALL buffer internally and hide the buffering cost from callers.
|
|
|
|
#### Scenario: empty IsolationRequest is accepted
|
|
|
|
- **WHEN** `IsolationRequestSchema` parses `{}` (audio + signal excluded from schema)
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: unknown outputFormat is rejected
|
|
|
|
- **WHEN** parsing `{ outputFormat: "mp3_unknown" }`
|
|
- **THEN** Zod throws
|
|
|
|
#### Scenario: duck-type guard rejects engines missing isolate
|
|
|
|
- **WHEN** `isVoiceIsolationEngine({ providerName: "x" })` is called
|
|
- **THEN** the guard returns `false`
|
|
|
|
**Verified by:** `src/isolation/isolation.test.ts::"IsolationRequestSchema accepts an empty request
|
|
(every field optional after audio)"`,
|
|
`src/isolation/isolation.test.ts::"IsolationRequestSchema rejects unknown outputFormat"`,
|
|
`src/isolation/isolation.test.ts::"isVoiceIsolationEngine rejects objects missing isolate"`
|
|
|
|
---
|
|
|
|
### Requirement: Binary and stream fields excluded from Zod schemas
|
|
|
|
Binary payloads (`Uint8Array`/`ReadableStream`) and unserialisable runtime values
|
|
(`AbortSignal`) SHALL NOT appear in any Zod request schema. Adapters validate these
|
|
structurally at runtime via `instanceof` / `typeof`. Result schemas MAY include
|
|
`audio: Uint8Array` because the data is materialised by then. The `audio` field of
|
|
`TranscriptionRequest` and `IsolationRequest` SHALL be omitted from their respective
|
|
request schemas; the `signal` field SHALL be omitted from every request schema.
|
|
|
|
#### Scenario: TranscriptionRequestSchema accepts no audio field
|
|
|
|
- **WHEN** the schema parses `{ modelId: "m" }`
|
|
- **THEN** parsing succeeds even though `audio` is part of the TypeScript interface
|
|
|
|
#### Scenario: IsolationRequestSchema accepts an empty object
|
|
|
|
- **WHEN** the schema parses `{}`
|
|
- **THEN** parsing succeeds (audio + signal both excluded)
|
|
|
|
**Verified by:** `src/stt/stt.test.ts::"TranscriptionRequestSchema accepts an empty request (every field optional)"`,
|
|
`src/isolation/isolation.test.ts::"IsolationRequestSchema accepts an empty request (every field optional after audio)"`
|
|
|
|
---
|
|
|
|
### Requirement: Voice cloning intentionally absent
|
|
|
|
The package SHALL NOT declare a generic `cloneVoice` method on `VoiceRepository` or
|
|
any other engine. Voice cloning workflows differ too widely across vendors
|
|
(ElevenLabs instant/professional, Azure multi-week, OpenAI none) to share a stable
|
|
interface today. A future `VoiceCloner` interface SHALL live on its own subpath once
|
|
two or more providers converge. The `VoiceCloneRejectedError` code reserves the
|
|
namespace for future use; adapters that surface clone failures from provider-specific
|
|
flows MUST throw it.
|
|
|
|
#### Scenario: VoiceRepository surface contains only list and get
|
|
|
|
- **WHEN** the `VoiceRepository` interface is enumerated
|
|
- **THEN** members are exactly `providerName`, `list`, `get`
|
|
|
|
#### Scenario: VoiceCloneRejectedError code is registered
|
|
|
|
- **WHEN** `VOICE_CODES["VOICE.CLONE_REJECTED"]` is read
|
|
- **THEN** the value is `"VOICE.VOICE.CLONE_REJECTED"`
|
|
|
|
**Verified by:** `src/voices/voices.test.ts::"VoiceRepositoryGetResultSchema accepts a valid Voice"`
|
|
(via existence and shape of the interface) AND
|
|
`src/errors/errors.test.ts::"VoiceCloneRejectedError carries its code"`
|
|
|
|
---
|
|
|
|
### Requirement: Streaming responses only on TTS
|
|
|
|
The package SHALL declare exactly one streaming method across all five engines:
|
|
`VoiceSynthesizer.synthesizeStream`. STT, sound-effects, and voice-isolation engines
|
|
SHALL return complete results via `Promise`. `synthesizeStream` SHALL return
|
|
`ReadableStream<Uint8Array>` synchronously so callers can immediately wire it into
|
|
`pipeTo` / `pipeThrough`.
|
|
|
|
#### Scenario: TTS guard verifies both blocking and streaming methods
|
|
|
|
- **WHEN** `isVoiceSynthesizer` runs on an object missing `synthesizeStream`
|
|
- **THEN** the guard returns `false`
|
|
|
|
#### Scenario: No other engine guard checks for streaming method
|
|
|
|
- **WHEN** `isTranscriptionEngine` / `isSoundEffectEngine` / `isVoiceIsolationEngine` run on
|
|
an object lacking any stream method
|
|
- **THEN** the guards return `true` (streaming is not part of those contracts)
|
|
|
|
**Verified by:** `src/tts/tts.test.ts::"isVoiceSynthesizer rejects objects missing synthesizeStream function"`,
|
|
`src/stt/stt.test.ts::"isTranscriptionEngine accepts a valid engine shape"`,
|
|
`src/effects/effects.test.ts::"isSoundEffectEngine accepts a valid engine shape"`,
|
|
`src/isolation/isolation.test.ts::"isVoiceIsolationEngine accepts a valid engine shape"`
|
|
|
|
---
|
|
|
|
### Requirement: Voice domain type with provider-neutral fields
|
|
|
|
The package SHALL export a `Voice` interface with `id: string` (min length 1),
|
|
`name: string` (min length 1), `category: VoiceCategory`, plus optional
|
|
`gender: VoiceGender`, `accent: string`, `age: VoiceAge`,
|
|
`languageCodes: readonly string[]` (each 2..35 chars BCP-47),
|
|
`description: string`, `previewUrl: string` (URL-validated),
|
|
`settings: VoiceSettings`, `metadata: Record<string, unknown>`.
|
|
`VoiceCategory` SHALL be the enum `"premade" | "cloned" | "generated" | "professional"`.
|
|
`VoiceGender` SHALL be `"male" | "female" | "neutral"`.
|
|
`VoiceAge` SHALL be `"young" | "middle" | "old"`.
|
|
`metadata` exists for provider-specific labels that don't fit the canonical surface.
|
|
|
|
#### Scenario: minimal Voice round-trips
|
|
|
|
- **WHEN** `VoiceSchema` parses `{ id: "v1", name: "V", category: "premade" }`
|
|
- **THEN** parsing succeeds
|
|
|
|
#### Scenario: malformed previewUrl is rejected
|
|
|
|
- **WHEN** the schema parses `{ id: "v1", name: "V", category: "premade", previewUrl: "not a url" }`
|
|
- **THEN** Zod throws
|
|
|
|
**Verified by:** `src/types/types.test.ts::"VoiceSchema round-trips a minimal voice"`,
|
|
`src/types/types.test.ts::"VoiceSchema rejects a malformed previewUrl"`
|
|
|
|
---
|
|
|
|
### Requirement: Subpath exports for tree-shaking
|
|
|
|
The package's `exports` field SHALL expose, at minimum, these subpaths in addition to the
|
|
root barrel: `./errors/errors`, `./types/types`, `./tts/tts`, `./stt/stt`,
|
|
`./voices/voices`, `./effects/effects`, `./isolation/isolation`. Each subpath SHALL
|
|
re-export only the symbols of its module (not other modules), enabling per-engine
|
|
tree-shaking for adapters that only need one contract.
|
|
|
|
#### Scenario: importing `@suaveplan/voice/tts/tts` resolves without pulling other engines
|
|
|
|
- **WHEN** an adapter imports only from `@suaveplan/voice/tts/tts`
|
|
- **THEN** `dist/` byte-size impact contains only TTS module bytes (no STT/voices/effects/isolation)
|
|
|
|
#### Scenario: every subpath listed in `exports` resolves
|
|
|
|
- **WHEN** a consumer imports each declared subpath
|
|
- **THEN** all resolutions succeed
|
|
|
|
**Verified by:** `tests/tree-shake.test.ts`
|
|
|
|
---
|
|
|
|
### Requirement: 100% statement, branch, function, and line coverage
|
|
|
|
The package SHALL achieve 100% Istanbul coverage on every `.ts` under `src/` excluding
|
|
`.test.ts`, `.spec.ts`, `.types.ts`, `.stories.tsx`, and barrels. Coverage drops below
|
|
100% SHALL block publish.
|
|
|
|
#### Scenario: coverage gate
|
|
|
|
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/voice` runs
|
|
- **THEN** statements / branches / functions / lines are each `100`
|
|
|
|
**Verified by:** `bunx turbo test:coverage --filter=@suaveplan/voice` (CI gate)
|