Files
2026-05-27 23:29:53 +01:00

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 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:

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:

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:

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:

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:

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)