7.6 KiB
OpenSpec Change: voice
- Change ID: voice
- Package:
@suaveplan/voice - Category: universal
- Target path:
universal/media/voice - Status: PROPOSED
- Date: 2026-04-24
- Reference spec: none — new capability
Summary
Deliver @suaveplan/voice on the genesis branch from scratch at enterprise quality. The package is the provider-agnostic voice core: abstract interfaces, shared types, Zod schemas, and a structured error hierarchy for speech synthesis (TTS), speech recognition (STT), voice management, sound-effect generation, and voice isolation. No provider-specific code, no HTTP client, no API keys. Concrete provider adapters (ElevenLabs, OpenAI, Google Cloud, Azure, Amazon Polly) live in sibling voice-* packages.
This package enables any downstream package — API plugins, React components, browser audio engines, or game narrative systems — to depend on voice contracts without pulling in provider SDKs or networking libraries.
Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/error (workspace:^) |
VoiceError; VOICE.* error codes |
@suaveplan/types (workspace:^) |
Clock, LoggerLike, CancellableOptions |
@suaveplan/streams (workspace:^) |
ReadableStream / WritableStream helpers for audio streaming |
zod (catalog:forms) |
Schema validation |
tslib (catalog:build) |
Runtime helpers |
Dev deps: vitest, @vitest/coverage-istanbul, fast-check (all catalog:testing).
Public API surface
Error hierarchy
VoiceError extends SuaveplanError with codes:
VOICE.TTS.SYNTHESIS_FAILEDVOICE.TTS.STREAM_INTERRUPTEDVOICE.STT.TRANSCRIPTION_FAILEDVOICE.STT.UNSUPPORTED_FORMATVOICE.VOICE.NOT_FOUNDVOICE.VOICE.CLONE_REJECTEDVOICE.EFFECTS.GENERATION_FAILEDVOICE.ISOLATION.FAILEDVOICE.PROVIDER.NOT_CONFIGUREDVOICE.PROVIDER.RATE_LIMITEDVOICE.VALIDATION.INVALID_REQUESTVOICE.AUDIO.FORMAT_UNSUPPORTED
Shared types (schemas + TypeScript)
// types/common
AudioFormat: 'mp3_22050_32' | 'mp3_44100_128' | 'mp3_44100_192' | 'pcm_16000' | 'pcm_22050' | 'pcm_44100' | 'ulaw_8000' | 'wav_44100' | 'opus_48000' | 'aac_44100' | 'flac_44100'
LatencyMode: 0 | 1 | 2 | 3 | 4
VoiceCategory: 'premade' | 'cloned' | 'generated' | 'professional'
VoiceGender: 'male' | 'female' | 'neutral'
VoiceAge: 'young' | 'middle' | 'old'
VoiceSettings {
stability?: number // 0–1
similarityBoost?: number // 0–1
style?: number // 0–1
speakerBoost?: boolean
}
Voice {
id: string
name: string
category: VoiceCategory
gender?: VoiceGender
accent?: string
age?: VoiceAge
languageCodes?: string[] // ISO 639-1
description?: string
previewUrl?: string
settings?: VoiceSettings
metadata?: Record<string, unknown>
}
ListVoicesOptions {
category?: VoiceCategory
languageCode?: string
search?: string
pageToken?: string
pageSize?: number // 1–100
}
VoiceList {
voices: Voice[]
nextPageToken?: string
hasMore: boolean
}
AudioSource = Uint8Array | ReadableStream<Uint8Array> | string // URL or file path (provider decides)
TTS engine interface
interface VoiceSynthesizer {
readonly providerName: string
synthesize(options: SynthesisRequest): Promise<SynthesisResult>
synthesizeStream(options: SynthesisRequest): ReadableStream<Uint8Array>
}
SynthesisRequest {
text: string
voiceId: string
modelId?: string
languageCode?: string
voiceSettings?: VoiceSettings
outputFormat?: AudioFormat
latencyMode?: LatencyMode
previousText?: string
nextText?: string
seed?: number
pronunciationDictionaryIds?: string[]
signal?: AbortSignal
}
SynthesisResult {
audio: Uint8Array
format: AudioFormat
durationMs?: number
charactersUsed?: number
requestId?: string
}
STT engine interface
interface TranscriptionEngine {
readonly providerName: string
transcribe(options: TranscriptionRequest): Promise<TranscriptionResult>
}
TranscriptionRequest {
audio: AudioSource
modelId?: string
languageCode?: string
tagAudioEvents?: boolean
numSpeakers?: number // 1–32
timestampsGranularity?: 'word' | 'character'
diarize?: boolean
diarizationThreshold?: number
useMultiChannel?: boolean
entityDetection?: string[] | 'all'
entityRedaction?: string[] | 'all'
keyterms?: string[]
temperature?: number // 0–2
seed?: number
signal?: AbortSignal
}
WordTimestamp {
word: string
startMs: number
endMs: number
speakerId?: string
channelIndex?: number
confidence?: number
}
SpeakerSegment {
speakerId: string
startMs: number
endMs: number
}
DetectedEntity {
text: string
type: string
startChar: number
endChar: number
}
TranscriptionResult {
text: string
words?: WordTimestamp[]
speakers?: SpeakerSegment[]
entities?: DetectedEntity[]
language?: string
durationMs?: number
requestId?: string
}
Voice repository interface
interface VoiceRepository {
readonly providerName: string
list(options?: ListVoicesOptions): Promise<VoiceList>
get(voiceId: string): Promise<Voice | null>
// Cloning is intentionally NOT in the base interface;
// provider-specific clone APIs vary too widely.
}
Sound-effect engine interface
interface SoundEffectEngine {
readonly providerName: string
generate(options: SoundEffectRequest): Promise<SoundEffectResult>
}
SoundEffectRequest {
text: string
durationSeconds?: number // 0.5–30
loop?: boolean
promptInfluence?: number // 0–1
modelId?: string
outputFormat?: AudioFormat
signal?: AbortSignal
}
SoundEffectResult {
audio: Uint8Array
format: AudioFormat
durationMs?: number
requestId?: string
}
Voice-isolation engine interface
interface VoiceIsolationEngine {
readonly providerName: string
isolate(options: IsolationRequest): Promise<IsolationResult>
}
IsolationRequest {
audio: AudioSource
outputFormat?: AudioFormat
signal?: AbortSignal
}
IsolationResult {
audio: Uint8Array
format: AudioFormat
requestId?: string
}
Design decisions
- No provider-specific types leak into the core.
AudioSourceis intentionally loose (Uint8Array | ReadableStream | string) so providers can accept URLs, file paths, or raw bytes. The provider validates and narrows at runtime. - Streaming is TTS-only in the base interface. STT streaming, real-time conversational AI, and duplex WebSocket audio are provider-specific extensions that will ship in per-provider
/streamingsubpaths, not in the core contract. - Voice cloning is excluded from
VoiceRepository. Clone APIs (sample upload, verification, fine-tuning status) vary radically across providers. AVoiceClonerinterface may be added later in a provider-specific subpath or a future core revision. - Voice changing is excluded from the core. It is an ElevenLabs-specific feature with no clear generic equivalent in other providers.
- Conversational AI agents are excluded from the core. They are a product-level abstraction above raw TTS/STT and will ship as a separate package if needed.
@suaveplan/typesstays pure. Voice types live in@suaveplan/voice/types/commonso API plugins can depend on the voice contract without circular issues, while@suaveplan/typesremains a zero-runtime foundation.- All async entrypoints accept
AbortSignal. Every engine method propagates cancellation to provider adapters. - All engines expose
readonly providerName. This enables observability tags and debugging withoutinstanceofchecks.