Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | /** Serializable plain-object form, used for DB storage, API wire format, and UI state. */
export type VoiceSourceData =
| { type: 'pregenerated'; name: string }
| { type: 'custom'; name: string }
| { type: 'browser-tts' }
| { type: 'subtitle' }
| { type: 'generate' }
// ---------------------------------------------------------------------------
// Class hierarchy — hydrated instances used by TtsAudioManager for polymorphic
// behavior (e.g. on-demand clip generation).
// ---------------------------------------------------------------------------
export abstract class VoiceSource {
abstract readonly type: string
/** Serialize back to the plain-object form. */
abstract toJSON(): VoiceSourceData
/** Whether this voice can generate clips on-demand. */
canGenerate(): boolean {
return false
}
/**
* Generate a clip on-demand and return the audio blob.
* Returns null on failure. Only meaningful when `canGenerate()` is true.
*/
async generate(_clipId: string, _text: string, _tone: string): Promise<Blob | null> {
return null
}
}
export class PregeneratedVoice extends VoiceSource {
readonly type = 'pregenerated' as const
constructor(readonly name: string) {
super()
}
toJSON() {
return { type: this.type, name: this.name } as const
}
canGenerate() {
return true
}
async generate(clipId: string, text: string, tone: string): Promise<Blob | null> {
try {
const res = await fetch('/api/audio/generate-clip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: this.name, clipId, text, tone }),
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body.error) detail = body.error
} catch {
// response wasn't JSON
}
throw new Error(`generate-clip failed: ${detail}`)
}
return res.blob()
} catch (err) {
if (err instanceof Error && err.message.startsWith('generate-clip failed:')) throw err
throw new Error(
`generate-clip network error: ${err instanceof Error ? err.message : String(err)}`
)
}
}
}
export class CustomVoice extends VoiceSource {
readonly type = 'custom' as const
constructor(readonly name: string) {
super()
}
toJSON() {
return { type: this.type, name: this.name } as const
}
// Custom voices can't generate yet — but the hook is here for future
// integrations (e.g. Asterisk-based recording).
}
export class BrowserTtsVoice extends VoiceSource {
readonly type = 'browser-tts' as const
toJSON() {
return { type: this.type } as const
}
}
export class SubtitleVoice extends VoiceSource {
readonly type = 'subtitle' as const
toJSON() {
return { type: this.type } as const
}
}
export class GenerateVoice extends VoiceSource {
readonly type = 'generate' as const
toJSON() {
return { type: this.type } as const
}
}
/** Hydrate a single plain-object voice source into a class instance. */
export function hydrateVoiceSource(data: VoiceSourceData): VoiceSource {
switch (data.type) {
case 'pregenerated':
return new PregeneratedVoice(data.name)
case 'custom':
return new CustomVoice(data.name)
case 'browser-tts':
return new BrowserTtsVoice()
case 'subtitle':
return new SubtitleVoice()
case 'generate':
return new GenerateVoice()
}
}
/** Hydrate an entire voice chain from plain objects. */
export function hydrateVoiceChain(data: VoiceSourceData[]): VoiceSource[] {
return data.map(hydrateVoiceSource)
}
|