20 KiB
Capability Spec: voice
Package: @suaveplan/voice
Status: STABLE
Tier: universal
Every requirement below carries a
**Verified by:**line anchored to a real test inpackages/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 forwindow,document,node:fs,node:path,process,AudioContext,Buffer - THEN the count is zero
Scenario: Dependency surface is exactly the four allowed packages
- WHEN
package.jsondependenciesis 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_CODESat 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.contextis undefined (no empty object)
Scenario: traceId propagates on every subclass
- WHEN caller passes
traceIdon 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
AudioFormatSchemaparses"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
ListVoicesOptionsSchemaparses{ pageSize: 0 }or{ pageSize: 101 } - THEN Zod throws
Scenario: temperature outside 0..2 is rejected
- WHEN
TranscriptionRequestSchemaparses{ temperature: 2.5 } - THEN Zod throws
Scenario: numSpeakers outside 1..32 is rejected
- WHEN
TranscriptionRequestSchemaparses{ numSpeakers: 33 } - THEN Zod throws
Scenario: durationSeconds outside 0.5..30 is rejected
- WHEN
SoundEffectRequestSchemaparses{ 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:
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
SynthesisRequestSchemaparses{ 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:
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
TranscriptionRequestSchemaparses{} - 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:
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
VoiceRepositoryGetResultSchemaparsesnull - THEN parsing succeeds
Scenario: get-result schema rejects undefined
- WHEN the schema parses
undefined - THEN Zod throws (only
Voice | nullaccepted)
Scenario: list-result envelope requires hasMore
- WHEN
VoiceRepositoryListResultSchemaparses{ 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:
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
SoundEffectRequestSchemaparses{ 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:
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
IsolationRequestSchemaparses{}(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
audiois 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
VoiceRepositoryinterface 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
isVoiceSynthesizerruns on an object missingsynthesizeStream - THEN the guard returns
false
Scenario: No other engine guard checks for streaming method
- WHEN
isTranscriptionEngine/isSoundEffectEngine/isVoiceIsolationEnginerun 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
VoiceSchemaparses{ 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/voiceruns - THEN statements / branches / functions / lines are each
100
Verified by: bunx turbo test:coverage --filter=@suaveplan/voice (CI gate)