All files / web/src/lib/audio voiceSource.ts

53.03% Statements 70/132
100% Branches 0/0
0% Functions 0/16
53.03% Lines 70/132

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 1331x 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)
}